Tone mapping: las seis curvas y cuál elegir
Qué se hace con los valores que superan el uno, cómo se comporta cada operador de Three.js r184, y por qué el ACES de Three.js no es el ACES de referencia.
El cálculo de iluminación produce valores sin techo: una superficie blanca bajo un foco potente puede dar un cinco o un veinte. La pantalla acepta de cero a uno. El mapeo de tonos es la función que reduce ese rango, y la decisión más importante que puedes tomar sobre el aspecto de tu escena no es qué luces pones, es qué curva usas para comprimirlas.
- Explicar por qué recortar en uno produce blancos planos y desplazamientos de tono.
- Comparar el comportamiento de los seis operadores integrados.
- Configurar la exposición y saber en qué orden se aplica.
- Sustituir la curva por una propia con el punto de extensión que existe.
El problema del recorte
El valor por defecto de renderer.toneMapping es NoToneMapping, que no hace nada: los valores por encima de uno se recortan al escribir en el framebuffer.
El recorte tiene dos efectos, y el segundo es el feo. El primero es obvio: todo lo que supere el uno se convierte en el mismo blanco, así que un foco potente y otro diez veces más potente producen la misma mancha plana, sin ninguna información de forma dentro.
El segundo es el desplazamiento de tono. Los tres canales se recortan por separado, y una luz cálida no llega al uno en los tres a la vez: primero satura el rojo, después el verde, y al final el azul. Así que a medida que la intensidad sube, el color va del naranja al amarillo y de ahí al blanco, pasando por tonos que no estaban en la escena. Es el aspecto quemado y sucio que reconoces de cualquier render sin mapeo de tonos.
Una curva de mapeo de tonos resuelve las dos cosas: comprime de forma continua, así que sigue habiendo diferencia entre un cinco y un veinte, y lo hace teniendo en cuenta los tres canales juntos.
Los seis operadores
Three.js r184 trae seis curvas más el modo personalizado. Estos son sus comportamientos reales, leídos de sus implementaciones.
LinearToneMapping multiplica por la exposición y recorta. Es útil como control de exposición cuando quieres el aspecto de recorte pero con una intensidad ajustable. No resuelve ninguno de los dos problemas.
ReinhardToneMapping aplica la función clásica, el valor dividido entre uno más el valor. Comprime todo el rango infinito al intervalo de cero a uno de forma continua, así que nada llega nunca al blanco puro. El resultado conserva muchísimo detalle en las luces y a cambio desatura y aplana la imagen: las escenas Reinhard tienen un aspecto grisáceo y de bajo contraste característico.
CineonToneMapping implementa el operador filmico de Jim Hejl y Richard Burgess-Dawson, una aproximación de la respuesta de una película fotográfica. Da mucho contraste y hunde los negros, con un aspecto cinematográfico algo agresivo. Fíjate en que su implementación termina con una potencia de 2,2: es que el operador original devuelve valores ya codificados y hay que deshacerlo para que la codificación de salida los vuelva a aplicar.
ACESFilmicToneMapping es el más usado y el que la mayoría de escenas debería usar por defecto. Transforma al espacio de color AP1 de ACES, aplica un ajuste racional que aproxima la cadena de referencia de la Academia, y vuelve. Produce una curva en S con hombro suave en las luces y pie en las sombras, y desplaza los tonos altos hacia el ámbar, lo que resulta muy agradable y muy reconocible.
AgXToneMapping viene de Blender, a través de Filament. Trabaja en primarios Rec.2020, codifica en logaritmo entre unos menos 12,5 y unos más 4 EV respecto al gris medio, aplica un sigmoide y vuelve. Su característica distintiva es que los tonos muy brillantes tienden a blanco conservando el matiz durante mucho más rango que ACES, en lugar de virar. El resultado es menos contrastado, más neutro y más fotográfico. Es la mejor elección cuando la escena tiene fuentes muy intensas y no quieres el viraje cálido.
NeutralToneMapping implementa el mapeador neutro del grupo 3D Commerce de Khronos. Está diseñado con un objetivo muy concreto: que el color de un producto se parezca lo más posible al color real del producto. Deja los valores intactos por debajo de un umbral cercano a 0.76 y solo comprime a partir de ahí, con una desaturación leve. Si estás haciendo un configurador o una ficha de producto donde el cliente compara el color con una foto del catálogo, es el operador correcto y ACES no lo es.
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
toneMappingExposure multiplica el color antes de la curva, así que es un control de exposición fotográfica real, no un brillo aplicado al final. Duplicarlo equivale a abrir un paso de diafragma. Y el mapeo de tonos ocurre antes de la codificación al espacio de salida, en ese orden y nunca al revés.
Dentro de ACESFilmicToneMapping hay esta línea, con su comentario original:
// this implementation of ACES is modified to accommodate a brighter viewing environment.
// the scale factor of 1/0.6 is subjective. see discussion in #19621.
color *= toneMappingExposure / 0.6;La exposición se divide entre 0.6, es decir, se multiplica por 1,667. No es un error: es una decisión deliberada para que las escenas web, que se ven en pantallas con luz ambiente y no en una sala de proyección a oscuras, no salieran demasiado apagadas.
Las consecuencias son tres y todas muerden en algún momento. Primera: si comparas el mismo modelo renderizado en Three.js y en Blender con ACES, no coinciden, y no es culpa de tus luces. Segunda: cambiar de ACESFilmicToneMapping a AgXToneMapping o a NeutralToneMapping produce un salto de brillo notable, porque los otros dos no aplican ese factor; hay que reajustar la exposición al cambiar de curva y no basta con mirar la forma de la curva. Tercera: si necesitas colorimetría reproducible entre herramientas, ninguna curva integrada te la da tal cual y tienes que escribir la tuya.
Para eso está CustomToneMapping. El punto de extensión es una función vacía que puedes sustituir con una operación de texto sobre el chunk correspondiente, porque ShaderChunk está exportado:
THREE.ShaderChunk.tonemapping_pars_fragment =
THREE.ShaderChunk.tonemapping_pars_fragment.replace(
'vec3 CustomToneMapping( vec3 color ) { return color; }',
`vec3 CustomToneMapping( vec3 color ) {
color *= toneMappingExposure;
// Reinhard extendido con punto blanco explicito.
const float blanco = 4.0;
vec3 numerador = color * ( 1.0 + color / ( blanco * blanco ) );
return saturate( numerador / ( 1.0 + color ) );
}`
);
renderer.toneMapping = THREE.CustomToneMapping;La sustitución tiene que hacerse antes de que se compile ningún material, porque el chunk se lee al construir el programa. Y si hay materiales ya compilados, hay que marcarlos con needsUpdate.
Cómo elegir
Cuatro preguntas resuelven la decisión.
¿La fidelidad del color es un requisito? Comercio electrónico, configuradores, catálogos. Entonces NeutralToneMapping.
¿Hay fuentes muy intensas en cuadro? Neones, soles, emisivos fuertes. Entonces AgXToneMapping, que aguanta mucho más rango sin virar.
¿Buscas un aspecto cinematográfico y contrastado? Entonces ACESFilmicToneMapping, que es además lo que espera ver la mayoría de la gente porque es lo que usan los motores de videojuego.
¿Estás renderizando datos, un diagrama o interfaz? Entonces NoToneMapping es defendible, porque una curva altera los colores que has elegido a propósito. Y recuerda que material.toneMapped = false saca un material concreto de la curva sin afectar al resto, aunque esa bandera se ignora en cuanto hay post-proceso.
Lo que no es defensable es dejar NoToneMapping en una escena PBR con entorno. Toda la información de rango alto que aporta el HDRI se recorta en el uno, y has pagado la iluminación basada en imagen para tirar la mitad de su rango a la basura.