Stack 3D con Three.js
Verificado 2026-10: three 0.186, @react-three/fiber 9.8, @react-three/drei 10.7, @react-three/rapier 2.2, @react-three/postprocessing 3.2 (+ postprocessing 6.39). Fiber 9 / Drei 10 / Rapier 2 / postprocessing 3 requieren React 19 (fiber 9.8 peer: react >=19 <19.4).
npm i three @react-three/fiber @react-three/drei
npm i @react-three/rapier # física
npm i @react-three/postprocessing postprocessing # efectos
npm i -D @types/three
Escena base (web)
import { useRef, Suspense } from 'react'
import * as THREE from 'three'
import { Canvas, useFrame } from '@react-three/fiber'
import { OrbitControls, Environment, ContactShadows } from '@react-three/drei'
function Caja() {
const ref = useRef<THREE.Mesh>(null!)
useFrame((_, delta) => { ref.current.rotation.y += delta }) // mutar refs, nunca llamar setState por fotograma
return (
<mesh ref={ref} castShadow>
<boxGeometry />
<meshStandardMaterial color="#4a9eff" />
</mesh>
)
}
<Canvas camera={{ position: [3, 2, 5], fov: 45 }} dpr={[1, 2]} shadows>
<ambientLight intensity={0.3} />
<directionalLight position={[5, 5, 5]} castShadow />
<Suspense fallback={null}>
<Caja />
<Environment preset="city" />
</Suspense>
<ContactShadows position={[0, -0.5, 0]} opacity={0.5} blur={2} />
<OrbitControls />
</Canvas>
Reglas:
- Cualquier componente asíncrono (useGLTF, useTexture, Environment, fuentes de Text, WASM de Physics) debe ir dentro de
<Suspense>. - Usar
useGLTF.preload(url)a nivel de módulo para modelos que aparecen en el primer render. - Mantener las escenas en un rango de 1 a 10 unidades; escalas muy grandes o muy pequeñas rompen sombras y controles.
MeshStandardMaterialnecesita luz o un<Environment>; sin ellos, se renderiza en negro.
Drei (elegir lo necesario)
- Cámara/controles:
OrbitControls,CameraControls,PerspectiveCamera makeDefault - Assets:
useGLTF,useTexture,Environment(presets: apartment, city, dawn, forest, lobby, night, park, studio, sunset, warehouse),Center,Bounds - Escenografía:
ContactShadows,AccumulativeShadows,Stage,Float,Sparkles,Stars,Cloud - Materiales:
MeshTransmissionMaterial(vidrio),MeshReflectorMaterial(suelo reflectante) - Texto:
Text(SDF, cualquier URL de fuente),Text3D(requiere JSON de typeface),Html(superposición DOM)
Física Rapier
import { Physics, RigidBody, CuboidCollider, type RapierRigidBody } from '@react-three/rapier'
<Suspense>
<Physics gravity={[0, -9.81, 0]} /* debug */>
<RigidBody colliders="hull" restitution={0.6} ref={refCuerpo}>
<mesh><torusGeometry /><meshStandardMaterial /></mesh>
</RigidBody>
<RigidBody type="fixed">
<CuboidCollider args={[10, 0.1, 10]} /> {/* semiejes */}
</RigidBody>
</Physics>
</Suspense>
// refCuerpo.current?.applyImpulse({ x: 0, y: 5, z: 0 }, true)
colliders:"cuboid"(por defecto),"ball","hull","trimesh",false(añadir componentes de colisión manualmente).type:"dynamic"(por defecto),"fixed","kinematicPosition","kinematicVelocity".timeSteppor defecto es fijo a 1/60.timeStep="vary"no es determinista. Paso manual:useRapier().step(1/60).debugen<Physics>dibuja los colisionadores.
Postprocesado
import { EffectComposer, Bloom, Vignette, DepthOfField, Noise } from '@react-three/postprocessing'
<EffectComposer>{/* último hijo de <Canvas> */}
<Bloom luminanceThreshold={0.9} intensity={1.2} mipmapBlur />
<Vignette offset={0.1} darkness={0.9} />
</EffectComposer>
- Bloom solo captura píxeles por encima de
luminanceThreshold. Para hacer que un objeto brille: coloremissive+emissiveIntensity> 1 +toneMapped={false}en su material. - Cada efecto cuesta un pase de pantalla completa; entre 2 y 4 efectos es suficiente.
Para video
Remotion (ver remotion-markup/3d.md): <ThreeCanvas width height> de @remotion/three; no usar useFrame; todo movimiento proviene de useCurrentFrame(). Además:
- No usar
OrbitControls— mover la cámara desdeframe(interpolate/spring). - Los helpers de Drei que animan con su propio reloj (
Float,Sparkles,Stars,Cloud,MeshWobbleMaterial) parpadean en los renders. Evitarlos o reproducir el movimiento desdeframe. - Física: Remotion renderiza fotogramas fuera de orden entre pestañas, por lo que una simulación en vivo no funciona. Horneado: inicializar
@dimforge/rapier3d-compat(el motor bajo @react-three/rapier) una vez, ejecutarworld.step()contimestep = 1 / fpspara cada fotograma, guardar posición + rotación de cada cuerpo en cada fotograma, y luego renderizar las transformaciones en el índiceframe.
Fuera de Remotion (grabación de pantalla, animación con GSAP): useFrame está bien; conducir el tiempo desde una línea de tiempo (tl.time()) en lugar de delta cuando la toma debe ser repetible.