
Una escena 3D fluida no se consigue acumulando trucos, sino administrando un presupuesto. Cada cuadro debe completar trabajo de JavaScript, actualización de la escena y dibujo en la GPU antes de que llegue el siguiente refresco de pantalla.
En una pantalla de 60 Hz ese margen ronda los 16,7 ms; a 120 Hz se reduce a 8,3 ms. No son objetivos universales: el dispositivo, la complejidad visual y el tipo de interacción determinan el presupuesto real.
Optimizar React Three Fiber significa identificar qué parte del cuadro consume el presupuesto y reducir ese costo sin degradar la intención visual.
01. Empieza por el presupuesto del cuadro
Anatomía del presupuesto de un cuadro WebGL con trabajo de CPU, render y GPU
Un FPS bajo no explica la causa. El cuello de botella puede estar en lugares distintos:
| Señal | Causa probable | Primera comprobación |
|---|---|---|
| JavaScript tarda demasiado | cálculos, allocations o renders de React | Performance de DevTools |
| muchos draw calls | demasiados objetos o materiales separados | gl.info.render.calls |
| demasiados triángulos | geometría más densa de lo necesario | gl.info.render.triangles |
| la GPU se satura al subir DPR | fill rate, sombras o postprocesado | comparar DPR y resolución |
| la memoria crece al navegar | texturas, materiales o geometrías sin liberar | gl.info.memory |
React Three Fiber expone el renderer de Three.js mediante useThree. Durante desarrollo puedes muestrear sus contadores sin actualizar estado de React en cada cuadro:
function RendererProbe() {
const gl = useThree((state) => state.gl);
const lastReport = useRef(0);
useFrame(() => {
const now = performance.now();
if (now - lastReport.current < 1000) return;
lastReport.current = now;
console.table({
calls: gl.info.render.calls,
triangles: gl.info.render.triangles,
geometries: gl.info.memory.geometries,
textures: gl.info.memory.textures,
});
});
return null;
}Úsalo como sonda temporal, no como telemetría de producción. Combina esos números con el perfil del navegador y prueba en un dispositivo representativo; el portátil de desarrollo rara vez es el límite real.
02. Reduce draw calls antes de reducir detalle
Comparación visual entre cientos de meshes individuales y un solo InstancedMesh
La GPU puede procesar muchos vértices, pero cada draw call requiere coordinación entre CPU y GPU. Cientos de objetos con la misma geometría y material son buenos candidatos para InstancedMesh.
function Field({ count = 1000 }) {
const mesh = useRef<THREE.InstancedMesh>(null);
const transform = useMemo(() => new THREE.Object3D(), []);
useLayoutEffect(() => {
if (!mesh.current) return;
for (let index = 0; index < count; index += 1) {
transform.position.set(
(index % 40) - 20,
0,
Math.floor(index / 40) - 12,
);
transform.updateMatrix();
mesh.current.setMatrixAt(index, transform.matrix);
}
mesh.current.instanceMatrix.needsUpdate = true;
mesh.current.computeBoundingSphere();
}, [count, transform]);
return (
<instancedMesh ref={mesh} args={[undefined, undefined, count]}>
<boxGeometry args={[0.18, 0.18, 0.18]} />
<meshStandardMaterial color="#6d4aff" />
</instancedMesh>
);
}El instancing funciona cuando las instancias comparten geometría y material. Para objetos estáticos diferentes, considera unir geometrías compatibles. También comparte materiales y geometrías en lugar de recrearlos dentro de cada componente.
No optimices solo por el número de objetos: un único mesh con un shader costoso o millones de triángulos todavía puede saturar la GPU.
03. Mantén el trabajo por cuadro fuera de React
useFrame se ejecuta en el loop de render. Llamar setState allí puede provocar reconciliaciones al ritmo de la pantalla. Para animaciones de alta frecuencia, modifica referencias de Three.js y reserva el estado de React para cambios semánticos de la interfaz.
function Rotor({ speed = 0.8 }) {
const group = useRef<THREE.Group>(null);
useFrame((_, delta) => {
if (group.current) {
group.current.rotation.y += speed * delta;
}
});
return <group ref={group}>{/* scene content */}</group>;
}Usar delta mantiene la velocidad independiente de los FPS. Evita además crear vectores, colores o arrays dentro del loop; reutiliza objetos o calcula datos estables con useMemo. Una pequeña allocation repetida miles de veces termina convertida en pausas del recolector de basura.
04. No renderices cuadros que nadie puede ver
Una escena animada necesita frameloop="always", el valor habitual. Un configurador, un modelo de producto o una visualización que solo cambia con la interacción puede trabajar bajo demanda:
function ProductViewer() {
return (
<Canvas frameloop="demand" dpr={[1, 1.5]}>
<Scene />
</Canvas>
);
}
function MaterialSync({ color }: { color: string }) {
const material = useRef<THREE.MeshStandardMaterial>(null);
const invalidate = useThree((state) => state.invalidate);
useEffect(() => {
material.current?.color.set(color);
invalidate();
}, [color, invalidate]);
return <meshStandardMaterial ref={material} />;
}Los cambios declarativos gestionados por React Three Fiber solicitan cuadros cuando corresponde. Si mutas un objeto de forma imperativa, llama invalidate() para programar el siguiente. No mezcles render bajo demanda con una animación continua sin definir quién despierta el loop.
05. Controla el costo de cada píxel
Panel técnico de calidad WebGL con controles de DPR, sombras, texturas y postprocesado
Duplicar el DPR puede aproximarse a cuadruplicar la cantidad de píxeles. Por eso una escena fluida en una pantalla estándar puede caer en una pantalla de alta densidad aunque tenga los mismos draw calls.
Ordena las decisiones de calidad por impacto:
- limita el DPR a un rango razonable;
- reduce resolución y cantidad de shadow maps;
- limita luces que proyectan sombras y objetos que las reciben;
- comprime y dimensiona texturas según su uso visible;
- elimina pases de postprocesado cuyo aporte no justifica otro render completo;
- usa niveles de detalle para objetos lejanos.
Las texturas suelen dominar memoria y ancho de banda. Una imagen grande no se vuelve barata porque ocupe pocos kilobytes comprimida en la red: en la GPU se expande a una representación apta para muestreo. Ajusta dimensiones, formato y mipmaps al caso real.
06. Shaders: mover trabajo no elimina el costo
Un shader puede reemplazar miles de actualizaciones de JavaScript por cálculo paralelo en la GPU. Es ideal para ondas, partículas y deformaciones, pero no es una licencia para hacer trabajo ilimitado por vértice o por fragmento.
uniform float uTime;
attribute float phase;
void main() {
vec3 displaced = position;
displaced.y += sin(uTime + phase) * 0.08;
gl_Position = projectionMatrix * modelViewMatrix * vec4(displaced, 1.0);
}Actualiza uniforms existentes en lugar de reconstruir materiales. Evita multiplicar variantes de shader mediante defines cambiantes: cada combinación puede requerir otro programa y una nueva compilación. Mide por separado escenas limitadas por vértices y escenas limitadas por fill rate.
07. Libera recursos y optimiza en orden
Secuencia editorial para diagnosticar y optimizar una escena React Three Fiber
Three.js no puede liberar automáticamente todos los recursos de GPU cuando desaparece una referencia de JavaScript. Los objetos creados manualmente fuera del reconciliador —o conservados en caches propias— necesitan una estrategia de propiedad y llamadas a dispose() para geometrías, materiales, texturas y render targets.
Antes de considerar terminada una optimización, sigue este orden:
- fija un dispositivo, una escena y un objetivo medible;
- identifica si el límite está en CPU, draw calls, geometría, píxeles o memoria;
- reduce trabajo estructural: renders de React, objetos, materiales y draw calls;
- ajusta DPR, sombras, texturas y postprocesado;
- elimina allocations del loop y estabiliza recursos;
- optimiza shaders solo cuando el perfil señale a la GPU;
- repite la medición y compara la calidad visual.
El mejor resultado no es el FPS más alto en una escena vacía. Es una experiencia consistente que conserva su jerarquía visual dentro del presupuesto de los dispositivos que realmente la ejecutan.