Saltar al contenido

Tamaño del mapa, memoria y sombras que no se recalculan

Cuánta VRAM cuesta cada shadow map, por qué PointLight cuesta ocho veces lo que parece, y cómo congelar las sombras estáticas con autoUpdate y needsUpdate.

La cuenta de memoria

El shadow map de Three.js se guarda en un render target cuyo tipo por defecto es UnsignedByteType, con formato RGBA. Cuatro bytes por texel, porque la profundidad va empaquetada en los cuatro canales. La fórmula es directa:

Texto
memoria = ancho * alto * 4 bytes * numero de vistas
Luz mapSize Textura real Memoria
DirectionalLight 512 (defecto) 512 × 512 1 MB
DirectionalLight 2048 2048 × 2048 16 MB
DirectionalLight 4096 4096 × 4096 64 MB
SpotLight 1024 1024 × 1024 4 MB
PointLight 1024 4096 × 2048 32 MB

La fila que sorprende es la última. PointLightShadow declara _frameExtents = new Vector2( 4, 2 ) y _viewportCount = 6: las seis caras del cubo se renderizan en un atlas de cuatro celdas de ancho por dos de alto. Con mapSize de 1024, la textura física es de 4096 por 2048 y ocupa ocho veces lo que la gente calcula mentalmente. Con mapSize de 2048 son 128 MB para una sola bombilla.

Y si activas VSMShadowMap, hay un segundo render target: mapPass, usado para el desenfoque separable. La memoria se duplica.

Elegir la resolución con un criterio, no por costumbre

El valor por defecto es 512 y casi nadie lo deja. Pero subirlo a ciegas es exactamente el error que la lección anterior desmonta: si el frustum es enorme, 4096 sigue sin salvarte, y si el frustum está apretado, 1024 puede ser de sobra.

El razonamiento correcto empieza por el resultado: cuántas unidades de mundo quieres que mida un texel de sombra. Para que el borde de una sombra no se vea dentado en pantalla, un texel debería proyectarse en dos o tres píxeles como mucho a la distancia de trabajo típica. De ahí sale la resolución:

Texto
mapSize = ancho del frustum / tamano de texel deseado

Para una escena de escritorio con el frustum apretado a 20 unidades y un texel objetivo de 1 centímetro, salen 2000, es decir 2048. Para la misma escena vista siempre de lejos, un texel de 4 centímetros basta y 512 sobra.

Y las resoluciones tienen que ser potencias de dos. La documentación de LightShadow.mapSize lo dice explícitamente. Un valor de 1500 puede funcionar en algunos dispositivos y fallar en otros.

Sombras que no cambian no deberían recalcularse

Aquí está la optimización más grande de todo el nivel, y es de una línea. Si una luz no se mueve y los objetos que proyectan tampoco, el shadow map del frame 600 es idéntico al del frame 1. Regenerarlo es tirar un pase completo de la escena por frame.

JS
// Global: ninguna sombra se regenera automaticamente.renderer.shadowMap.autoUpdate = false;// Fuerza una unica regeneracion en el proximo render.renderer.shadowMap.needsUpdate = true;

Ambas propiedades existen también por luz, lo que permite la estrategia mixta que casi siempre es la correcta: el sol se congela, el foco que sigue al jugador no.

JS
// El sol ilumina arquitectura estatica: se calcula una vez.sol.shadow.autoUpdate = false;sol.shadow.needsUpdate = true;   // se consume en el proximo render// El foco de la linterna sigue al jugador: se actualiza siempre.linterna.shadow.autoUpdate = true;

needsUpdate se pone a false solo después de que el renderizador haya generado el mapa, así que el patrón es «ponlo a true cuando algo cambie y olvídate». Los momentos en los que hay que volver a ponerlo a true son concretos y se pueden enumerar: al mover la luz, al mover un objeto con castShadow, al añadir o quitar un objeto de la escena, y al redimensionar el frustum.

Reducir el primer pase eligiendo quién participa

Además de la frecuencia, se puede reducir el contenido del pase de sombra. Cada objeto con castShadow = true es un draw call extra por luz con sombra, y muchos objetos no aportan nada a la sombra.

Los candidatos a castShadow = false son fáciles de reconocer: la hierba y el follaje de detalle, los objetos pequeños que quedan bajo otros, las partículas, cualquier cosa cuya sombra sea de menos de dos texels. Y el suelo, siempre: un plano que recibe no tiene ninguna razón para proyectar.

JS
modelo.traverse( ( obj ) => {  if ( ! obj.isMesh ) return;  obj.receiveShadow = true;  // Solo proyectan sombra los objetos por encima de cierto tamano.  obj.geometry.computeBoundingSphere();  obj.castShadow = obj.geometry.boundingSphere.radius > 0.25;} );

Y hay una simetría útil: si un objeto no se ve nunca desde la cámara pero sí ocluye —una viga en el techo—, marcarlo castShadow = true y visible = false no funciona, porque visible = false lo saca de los dos pases. Lo correcto para ese caso es obj.layers, o simplemente aceptar que se renderice.