HDRI, rango dinámico y los cargadores que existen en r184
Qué guarda un fichero HDR que un PNG no puede guardar, la diferencia real entre RGBE y half-float, y por qué RGBELoader ya no es el nombre correcto.
La diferencia entre una imagen normal y un HDRI no está en la resolución ni en el formato de compresión: está en que un PNG guarda valores acotados entre 0 y 1 y un HDRI guarda valores sin techo. Esa distinción, que suena académica, es la que decide si el reflejo de una ventana en un metal se ve como una ventana o como una mancha blanca uniforme. Y como Three.js renombró el cargador en r180, es también uno de los pocos sitios de esta guía donde el nombre que sale en todos los tutoriales ya no es el correcto.
- Explicar qué información pierde una imagen de 8 bits por canal y por qué importa al iluminar.
- Distinguir la codificación RGBE de un
.hdrde los half-floats de un.exry elegir entre las dos. - Cargar un entorno con el cargador correcto en r184 y aplicarle el mapping adecuado.
- Estimar el peso de un HDRI y decidir la resolución que necesita tu escena.
Lo que no cabe en 8 bits
Una imagen de 8 bits por canal tiene 256 niveles y un techo. El valor 255 significa «lo más brillante representable», y todo lo que en la escena real era más brillante que eso se recorta al mismo número. En una foto para mirar eso está bien: el ojo tampoco distingue. Como fuente de iluminación es fatal, por dos razones distintas.
Se pierde la jerarquía de energía. El sol es unas 50 000 veces más brillante que el cielo azul de al lado. En una imagen recortada, los dos son 255. Cuando convolucionas ese entorno para obtener la iluminación difusa, el sol aporta lo mismo que un trozo de cielo del mismo tamaño angular, y la escena sale iluminada de forma plana y sin dirección dominante.
Se pierde el brillo especular. Un reflejo especular en una superficie ligeramente rugosa es la fuente difuminada. Si la fuente es un valor de 5000 sobre un fondo de 3, después del desenfoque sigue siendo un punto claramente más brillante que su entorno. Si era 255 sobre 250, después del desenfoque es indistinguible del fondo. El resultado es un metal sin highlights, que es lo que hace que un render parezca de plástico.
Un HDRI guarda valores en coma flotante sin techo. El sol vale 5000 y punto. Ese rango es exactamente la información que la convolución necesita.
RGBE frente a half-float
Los dos formatos que Three.js sabe leer resuelven el mismo problema de formas muy distintas.
Radiance .hdr, codificación RGBE. Guarda tres mantisas de 8 bits y un exponente compartido de 8 bits, cuatro bytes por píxel. El truco es elegante: como los tres canales de un píxel suelen tener magnitudes parecidas, compartir el exponente casi no cuesta precisión y da un rango dinámico enorme por muy poco espacio. Su limitación es precisamente el exponente compartido: si un píxel tiene un canal muy alto y otro muy bajo —un rojo saturadísimo sobre negro—, el canal bajo pierde bits. Para entornos naturales es irrelevante.
OpenEXR .exr, half-float. Guarda tres o cuatro canales de 16 bits en coma flotante, con exponente propio por canal. Más preciso, con soporte de canales arbitrarios y varios esquemas de compresión sin pérdida. Y bastante más pesado.
.hdr (RGBE) |
.exr (half-float) |
|
|---|---|---|
| Bytes por píxel sin comprimir | 4 | 6 u 8 |
| Rango dinámico | muy alto, exponente compartido | muy alto, por canal |
| Precisión en canales dispares | limitada | completa |
| Peso típico a 2K | 6 a 12 MB | 15 a 40 MB |
| Cargador en r184 | HDRLoader |
EXRLoader |
Para iluminación de entorno, .hdr es la elección correcta en el 95 % de los casos: pesa la mitad y la diferencia no se ve. .exr tiene sentido cuando el fichero viene de una tubería de VFX o cuando necesitas canales adicionales.
Los nombres correctos en r184
Aquí está el detalle que hay que corregir respecto a prácticamente toda la documentación de terceros que circula.
RGBELoader está deprecado desde r180. El fichero sigue existiendo en three/addons/loaders/RGBELoader.js, pero su implementación completa es esta: una clase que extiende HDRLoader, imprime un console.warn y llama a super(). El nombre vigente es HDRLoader.
import { HDRLoader } from 'three/addons/loaders/HDRLoader.js';
import { EXRLoader } from 'three/addons/loaders/EXRLoader.js';
const hdr = await new HDRLoader().loadAsync( '/env/estudio_2k.hdr' );
hdr.mapping = THREE.EquirectangularReflectionMapping;
Esa línea de mapping no es opcional. Un HDRI equirectangular es una imagen 2:1 que representa la esfera completa con proyección latitud-longitud, y sin declarar el mapping Three.js la trataría como una textura plana. Los dos valores relevantes son THREE.EquirectangularReflectionMapping para usarla como entorno y THREE.EquirectangularRefractionMapping para refracción.
Existe además un tercer cargador que merece la pena conocer y que casi nadie usa: UltraHDRLoader, en three/addons/loaders/UltraHDRLoader.js. Lee el formato UltraHDR de Google, que es un JPEG normal con un mapa de ganancia adjunto. La ventaja es enorme en producción web: pesa como un JPEG —del orden de cientos de kilobytes en vez de megabytes— y se decodifica con el decodificador de imágenes nativo del navegador en vez de con JavaScript. La contrapartida es un rango dinámico menor que un .hdr completo, suficiente para casi todo salvo soles directos.
Un .hdr o un .exr ya están en valores lineales de radiancia. HDRLoader y EXRLoader configuran la textura correctamente por su cuenta. No le pongas texture.colorSpace = THREE.SRGBColorSpace a un HDRI, que es un reflejo aprendido de las texturas de color: le aplicarías una transferencia de gamma a datos que ya son lineales y la escena saldría con el contraste destrozado.
Cuánta resolución hace falta de verdad
La respuesta depende de para qué se usa el entorno, y son dos usos con requisitos opuestos.
Como iluminación. Muy poca. La convolución del PMREM difumina el entorno hasta convertirlo en manchas; para el término difuso, una resolución de 256 por 128 ya es indistinguible de una de 4096. Todo lo que descargues por encima de 1K se está tirando al procesar.
Como fondo visible. Mucha. Si el usuario ve el HDRI de fondo a pantalla completa, cualquier cosa por debajo de 4K se ve borrosa en un monitor moderno.
La consecuencia es la estrategia que usa cualquier proyecto serio: dos ficheros distintos. Un HDRI de 1K para scene.environment, que es el que alimenta el PMREM, y un JPEG o WebP normal de 4K para scene.background, que solo se mira y no necesita rango alto. El fondo pesa menos que el HDRI que reemplaza y se ve mejor.
// Iluminacion: HDR pequeno.
const hdr = await new HDRLoader().loadAsync( '/env/estudio_1k.hdr' );
hdr.mapping = THREE.EquirectangularReflectionMapping;
const pmrem = new THREE.PMREMGenerator( renderer );
scene.environment = pmrem.fromEquirectangular( hdr ).texture;
hdr.dispose();
pmrem.dispose();
// Fondo: LDR grande, y en espacio sRGB porque esto SI es color de pantalla.
const fondo = await new THREE.TextureLoader().loadAsync( '/env/estudio_4k.webp' );
fondo.mapping = THREE.EquirectangularReflectionMapping;
fondo.colorSpace = THREE.SRGBColorSpace;
scene.background = fondo;
La documentación del propio PMREMGenerator respalda esta decisión: “The ideal input image size is 1k (1024 x 512), as this matches best with the 256 x 256 cubemap output”. Cargar un HDRI de 8K para generar un cubemap de 256 no mejora nada y bloquea el hilo principal durante segundos.
Hay un coste del HDRI que no aparece en el tamaño del fichero y que arruina más métricas de carga de las que la gente sospecha. Un .hdr usa codificación RLE por líneas, un esquema de los años ochenta que no tiene equivalente en el decodificador nativo del navegador. HDRLoader lo descomprime en JavaScript puro, píxel a píxel, en el hilo principal y sin worker. Para un fichero de 2K —2048 por 1024, dos millones de píxeles— eso son del orden de 200 a 400 milisegundos de bloqueo total en un portátil, y por encima de un segundo en un móvil. Y no termina ahí: justo después, PMREMGenerator ejecuta su cadena de filtrado, que en r184 hace muestreo de importancia GGX con 256 muestras por nivel, otro tirón de decenas de milisegundos. En total, cargar un entorno puede costar medio segundo de hilo principal completamente congelado, justo en el momento en que el usuario está mirando la pantalla de carga y la barra de progreso deja de moverse. Las tres mitigaciones que funcionan, por orden de rentabilidad: usar UltraHDRLoader, porque un JPEG lo decodifica el navegador en un hilo aparte y en una fracción del tiempo; bajar la resolución del HDRI a 512 por 256 si solo lo usas como iluminación, lo que reduce el coste de descompresión a la dieciseisava parte; y llamar a pmremGenerator.compileEquirectangularShader() mientras el fichero se descarga, que es exactamente para lo que existe ese método —la documentación dice “You can get faster start-up by invoking this method during your texture’s network fetch for increased concurrency”— y solapa la compilación del shader con la espera de red.