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.
El tamaño del shadow map es el único parámetro de este sistema que se paga en memoria de vídeo, y se paga al cuadrado. Duplicar mapSize de 1024 a 2048 no duplica el coste: lo cuadruplica. En una escena con cuatro luces con sombra en un móvil con 512 MB de VRAM compartida, esa decisión puede ser la diferencia entre funcionar y que el navegador mate la pestaña. Y encima, la mayoría de esos mapas se están regenerando sesenta veces por segundo para producir exactamente la misma imagen.
- Calcular la memoria de vídeo que consume el shadow map de cada tipo de luz.
- Justificar por qué el mapa de una
PointLightocupa ocho veces lo que sumapSizesugiere. - Congelar sombras estáticas con
autoUpdatey forzar una regeneración puntual conneedsUpdate. - Reducir el coste del primer pase eligiendo qué objetos participan en él.
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:
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.
Un iPhone o un Android de gama media no tienen memoria de vídeo dedicada: la GPU usa la misma memoria que el resto del sistema, y el navegador impone límites bastante agresivos. Superarlos no da un error legible; da una pestaña que se recarga sola. Cuatro luces a 2048 son 64 MB solo en sombras, antes de contar una sola textura de material. Un presupuesto realista para móvil es una sombra a 1024 o dos a 512.
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:
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.
// 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.
// 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.
Entre «cada frame» y «nunca» hay un punto intermedio muy útil. Actualizar el shadow map cada dos o tres frames es imperceptible para una sombra suave y ahorra la mitad o dos tercios del coste del primer pase. Basta con un contador y needsUpdate = ( frame % 3 === 0 ).
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.
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.
El tipo por defecto de LightShadow.mapType es UnsignedByteType, y la profundidad se empaqueta con packDepthToRGBA en los cuatro canales para reconstruir 32 bits. Sobre el papel eso da precisión de sobra. En la práctica hay una pérdida que casi nadie relaciona con su causa: el empaquetado funciona perfectamente mientras la lectura sea exacta, texel a texel, pero en cuanto el sampler interpola —y con PCFShadowMap o filtrado bilineal interpola— está mezclando linealmente cuatro valores empaquetados, no cuatro profundidades. Interpolar los bytes de un número empaquetado no da el número intermedio: da basura, porque los canales bajos son los bits de menor peso y saltan de 255 a 0 continuamente. Three.js lo evita forzando NearestFilter en el shadow map y haciendo el filtrado a mano en el shader, con cuatro o dieciséis lecturas puntuales y una media de los resultados de la comparación, no de las profundidades. Esa es la diferencia entre PCF y un simple desenfoque, y explica dos cosas a la vez: por qué PCF cuesta tantas texturas por fragmento —hasta dieciséis lecturas por luz en PCFSoftShadowMap—, y por qué el gradiente de una sombra suave tiene como mucho diecisiete niveles distintos y a distancia se ve como bandas. Si el escalonado te molesta más que el coste, el camino no es subir la resolución: es VSMShadowMap, que guarda dos momentos estadísticos en un render target de coma flotante y sí puede filtrarse linealmente, incluidos mipmaps y desenfoque gaussiano de verdad, porque la media de dos momentos sí es un momento válido.