Saltar al contenido

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.

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.

JS
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.

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.

JS
// 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.