3. Color, mapeo de tonos y entornos HDR
Secciones
Ajustes de lectura
Ninguna voz para este idioma en este navegador.
Más voces
Código y términos técnicos en inglés
Desactivado: la voz de la página lo lee todo, sin pausa entre dos voces.
Ninguna voz para este idioma en este navegador.
Más voces
Más voces: Microsoft Edge ofrece voces naturales en línea, y Windows (Configuración › Hora e idioma › Voz), macOS y Android instalan otras.
Código: los parches de color en src/03-color/main.ts, la página del entorno en src/03-environment/main.ts, y dos scripts de Node.js: scripts/l03-color.ts y scripts/make-studio-hdr.ts.
Dos tipos de números
Sección titulada «Dos tipos de números»En WPF, Color.FromRgb(128, 128, 128) es un gris, y el número llega a la pantalla tal cual. Eso funciona para una interfaz 2D. Falla para la iluminación: la luz se suma de forma lineal, dos lámparas dan el doble de luz, pero los bytes de una imagen no. El byte 128 de un monitor emite alrededor del 22 % de la luz del byte 255, no el 50 %. La codificación sRGB dedica más de sus 256 pasos a los tonos oscuros, donde el ojo percibe las diferencias, y menos a los claros.
Por eso three.js mantiene separados dos tipos de números:
- Los valores lineales, proporcionales a la luz. La iluminación, la mezcla y el mapeo de tonos calculan con ellos. Es el espacio de color de trabajo,
LinearSRGBColorSpace, escritosrgb-linear. - Los valores sRGB, los de los selectores de color, CSS, las imágenes PNG y JPEG, y el canvas. Se convierten a lineal a la entrada y de vuelta a sRGB a la salida.
scripts/l03-color.ts muestra las conversiones sin renderizador:
$ node scripts/l03-color.tsworking color space: srgb-linearColor(0x4a2c1d) stores [ 0.0685, 0.0252, 0.0123 ]getHexString() 4a2c1d | getHexString(LinearSRGBColorSpace) 110603setRGB(0.5, 0.5, 0.5).getHexString() bcbcbcsetRGB(0.5, 0.5, 0.5, SRGBColorSpace) stores [ 0.214, 0.214, 0.214 ]new Texture().colorSpace: ""...- Un color hexadecimal es sRGB. El palisandro de la lección 2,
0x4a2c1d, tiene un byte rojo de 74, que es 0.29 de 255;Colorguarda 0.0685, su valor lineal.getHexString()convierte de vuelta y da el hexadecimal que escribiste; si se le piden valores lineales, da110603. setRGBsin espacio de color recibe valores lineales.setRGB(0.5, 0.5, 0.5)es la mitad de la luz del blanco, que en sRGB esbcbcbc, no808080. ConSRGBColorSpacecomo cuarto argumento, los mismos números significan un gris sRGB y se guardan como 0.214.- Una textura nueva no tiene espacio de color, la cadena vacía
NoColorSpace: sus valores llegan al shader tal cual. Eso es correcto para datos, como los mapas de normales, de rugosidad o de metalicidad, e incorrecto para una imagen en color, que necesitatexture.colorSpace = THREE.SRGBColorSpace.GLTFLoaderlo asigna a las texturas de un material glTF (lección 4); para una textura que creas tú mismo, es tarea tuya.
Es la gestión del color del manual, activada por defecto desde r152 (guía de migración).
Medirlo
Sección titulada «Medirlo»Una página de parches planos hace visibles las reglas. MeshBasicMaterial ignora las luces, así que el píxel de un parche es su color después de la conversión de salida del renderizador. La fila de arriba contiene intensidades lineales de 0.05 a 8; la de abajo, el mismo gris dado de cuatro maneras (main.ts, líneas 40-56):
// Valores lineales, como los produciría una luz: 1 no es lo más brillante que puede ser una escena[0.05, 0.18, 0.5, 1, 2, 8].forEach((value, i) => { const color = new THREE.Color().setRGB(value, value, value, THREE.LinearSRGBColorSpace); patch(`linear ${value}`, new THREE.MeshBasicMaterial({ color }), -6.25 + i * 2.5, 2);});
// Una textura de un píxel que contiene el byte 128, leída como sRGB o como datos en brutofunction grayTexture(colorSpace: string) { const texture = new THREE.DataTexture(new Uint8Array([128, 128, 128, 255]), 1, 1); texture.colorSpace = colorSpace; texture.needsUpdate = true; return texture;}patch('texture 128, SRGBColorSpace', new THREE.MeshBasicMaterial({ map: grayTexture(THREE.SRGBColorSpace) }), -3.75, -2);patch('texture 128, NoColorSpace', new THREE.MeshBasicMaterial({ map: grayTexture(THREE.NoColorSpace) }), -1.25, -2);// Un color hexadecimal es sRGB; setRGB() sin espacio de color recibe valores del espacio de trabajo (lineales)patch('Color(0x808080)', new THREE.MeshBasicMaterial({ color: new THREE.Color(0x808080) }), 1.25, -2);patch('setRGB(0.5, 0.5, 0.5)', new THREE.MeshBasicMaterial({ color: new THREE.Color().setRGB(0.5, 0.5, 0.5) }), 3.75, -2);La página indica dónde está en pantalla el centro de cada parche, y scripts/probe.mjs lee esos píxeles de la captura de pantalla:
$ node scripts/probe.mjs 03-color --query tone=none{ ... "render": { "calls": 6, "drawCalls": 11, "triangles": 21 }, ... "tone": "none", "pixels": { "linear 0.05": "rgb(63, 63, 63)", "linear 0.18": "rgb(118, 118, 118)", "linear 0.5": "rgb(188, 188, 188)", "linear 1": "rgb(255, 255, 255)", "linear 2": "rgb(255, 255, 255)", "linear 8": "rgb(255, 255, 255)", "texture 128, SRGBColorSpace": "rgb(128, 128, 128)", "texture 128, NoColorSpace": "rgb(188, 188, 188)", "Color(0x808080)": "rgb(128, 128, 128)", "setRGB(0.5, 0.5, 0.5)": "rgb(188, 188, 188)" }}- La conversión de salida convierte el lineal 0.18, el gris de la tarjeta gris de un fotógrafo, en el byte 118, y 0.5 en 188: los bytes
bcbcbcdel script. - La textura que contiene 128 y está marcada como sRGB sale como 128: decodificada a la entrada, codificada a la salida. La misma textura sin espacio de color sale como 188: su 128 se tomó por un lineal 0.5 y se codificó. Una imagen con el espacio de color equivocado se ve desvaída.
Color(0x808080)da 128 ysetRGB(0.5, 0.5, 0.5)da 188, como predijo el script.- Sin mapeo de tonos, 1, 2 y 8 dan todos 255: la salida recorta todo lo que supera 1.
Mapeo de tonos
Sección titulada «Mapeo de tonos»Una escena real contiene mucho más que de 0 a 1. Una pared iluminada puede valer 0.5 y una lámpara en la misma vista 20: el entorno de esta lección tiene exactamente esos valores. Recortar en 1 convierte cada reflejo en un blanco plano. Una curva de mapeo de tonos (tone mapping) comprime los valores altos dentro del rango que se puede mostrar, como una cámara o el ojo, antes de la codificación sRGB. Se configura en el renderizador y se aplica a toda la imagen:
renderer.toneMapping = THREE.ACESFilmicToneMapping;renderer.toneMappingExposure = 1; // el valor por defecto: un multiplicador aplicado antes de la curvaLa misma página con las tres curvas más usadas hoy:

Página en vivo: ejecutarla aquí
Abrir a pantalla completa. Pruébalo: elige agx en tone mapping y pon exposure en 2 y en 4: las dos pruebas del ejercicio 3.
| Parche | ninguno | ACES Filmic | AgX | Neutral |
|---|---|---|---|---|
| lineal 0.05 | 63 | 50 | 70 | 33 |
| lineal 0.18 | 118 | 127 | 128 | 105 |
| lineal 0.5 | 188 | 197 | 174 | 181 |
| lineal 1 | 255 | 227 | 202 | 240 |
| lineal 2 | 255 | 242 | 224 | 250 |
| lineal 8 | 255 | 253 | 248 | 254 |
| textura 128, sRGB | 128 | 141 | 136 | 116 |
| textura 128, sin espacio de color | 188 | 197 | 175 | 181 |
Los valores son el canal rojo de cada píxel, obtenidos con node scripts/probe.mjs 03-color --query tone=aces, tone=agx y tone=neutral, con WebGPU en la máquina del autor.
- ACES Filmic viene del sistema de color de la industria del cine. Sube los tonos medios (0.18 da 127 en lugar de 118), oscurece las sombras, y sigue separando 1, 2 y 8. También desplaza los colores saturados, algo que los naranjas y azules intensos muestran más que los grises.
- AgX, de Blender desde la versión 4.0, es la más plana: tonos oscuros más claros, tonos claros más bajos, 8 en 248. Los reflejos de colores saturados se desvanecen hacia el blanco en lugar de cambiar de tono, como hace la película fotográfica.
- Neutral, la curva PBR Neutral de Khronos, deja casi sin cambios los colores por debajo de 0.8 aproximadamente, salvo una parte baja más oscura, y comprime solo la parte alta. Está pensada para vistas de producto, donde el color base de un material debe coincidir con su muestra.
El mapeo de tonos es una elección de estilo, no una corrección: el curso usa ACES donde una escena tiene reflejos intensos, como hace GA, y usaría Neutral para mostrar el acabado de una guitarra fiel a su color. WebGLRenderer dibuja los mismos píxeles: con ?webgl, ACES da los mismos valores que WebGPU en esta máquina. En los runners de CI, que dibujan con un rasterizador por software, algunos valores difieren en uno, y por eso check.sh acepta una diferencia de dos por canal.
WebGPURenderer aplica el mapeo de tonos y la codificación sRGB en la pasada de salida que contó la lección 1: una llamada de dibujo y un triángulo más, 11 llamadas de dibujo y 21 triángulos para 10 parches. WebGLRenderer, en cambio, los aplica en el shader de cada material.
Entornos HDR
Sección titulada «Entornos HDR»Una foto de estudio de una guitarra no está iluminada por dos lámparas: las paredes, el techo y las softboxes se reflejan en la laca. La iluminación basada en imágenes ilumina una escena a partir de una imagen de todo lo que la rodea, un mapa de entorno, en lugar de objetos de luz. La imagen tiene que contener niveles de luz reales, así que es un archivo HDR, no un PNG.
scripts/make-studio-hdr.ts escribe un archivo así en lugar de descargar una foto: una imagen equirrectangular, toda la esfera desplegada como un mapamundi, de 256 por 128 píxeles, con paredes tenues, un suelo cálido y dos softboxes 40 veces más brillantes que las paredes. El formato es Radiance RGBE: tres mantisas de 8 bits que comparten un exponente de 8 bits, así que un píxel puede contener 20 en 4 bytes (líneas 9-15):
function rgbe([r, g, b]: [number, number, number]): number[] { const max = Math.max(r, g, b); if (max < 1e-32) return [0, 0, 0, 0]; const exponent = Math.ceil(Math.log2(max)); const scale = 255.9999 / 2 ** exponent; return [Math.floor(r * scale), Math.floor(g * scale), Math.floor(b * scale), exponent + 128];}$ node scripts/make-studio-hdr.tspublic/generated/studio.hdr: 256×128, 131174 bytes$ node scripts/l03-color.ts...HDRLoader.parse: { width: 256, height: 128, type: 'HalfFloatType', values: 'Uint16Array'}a wall texel (0, 20): [ 0.4038, 0.4038, 0.4451 ] | a softbox texel (64, 32): [ 19.9375, 19.9375, 18.9375 ]HDRLoader decodifica el archivo en medios flotantes (half float), 16 bits por canal, que la GPU filtra en todas las plataformas, y marca la textura como srgb-linear: los valores HDR son luz lineal. La softbox vuelve como 19.94 en lugar de 20: RGBE conserva 8 bits de mantisa, y el medio flotante de 16 bits vuelve a redondear. HDRLoader es el nuevo nombre de RGBELoader, obsoleto desde r180: la clase antigua sigue cargando, y avisa RGBELoader has been deprecated. Please use HDRLoader instead. (RGBELoader.js).
La página usa el archivo a la vez como entorno y como fondo, sin ningún objeto de luz (main.ts, líneas 15-31):
const environment = await new HDRLoader().loadAsync('generated/studio.hdr');environment.mapping = THREE.EquirectangularReflectionMapping;scene.environment = environment;scene.background = environment;scene.backgroundBlurriness = 0.4;
// Filas: un dieléctrico (metalness 0) y un metal (metalness 1). Columnas: rugosidad de 0 a 1.const sphere = new THREE.SphereGeometry(0.45, 64, 32);for (const [row, metalness] of [0, 1].entries()) { for (let column = 0; column < 5; column++) { const material = new THREE.MeshStandardMaterial({ color: 0xd4a373, metalness, roughness: column / 4 }); const mesh = new THREE.Mesh(sphere, material); mesh.position.set((column - 2) * 1.2, 0.6 - row * 1.2, 0); scene.add(mesh); }}
Página en vivo: ejecutarla aquí
Abrir a pantalla completa. Pruébalo: la escena del estudio de la lección 7 ilumina una guitarra entera con el mismo studio.hdr, y su panel cambia environmentIntensity y la exposición.
Las reglas de la lección 2 se ven en cada esfera. Los dieléctricos de la fila de arriba conservan su color canela y reflejan la softbox en blanco; los metales de la fila de abajo no tienen color difuso, y tiñen el reflejo, que es nítido con rugosidad 0 y se extiende por toda la esfera con rugosidad 1.
PMREM: lo que hace el renderizador con el entorno
Sección titulada «PMREM: lo que hace el renderizador con el entorno»Una superficie rugosa refleja un entorno desenfocado, así que el renderizador necesita el entorno ya desenfocado a varios niveles de rugosidad. Construye un PMREM (prefiltered, mipmapped radiance environment map, un mapa de entorno de radiancia prefiltrado y con mipmaps) a partir de cualquier textura equirrectangular o cúbica asignada a scene.environment o al envMap de un material, sin que se lo pidan: WebGPURenderer envuelve la textura en un nodo PMREM (EnvironmentNode.js, línea 64), y WebGLRenderer hace lo mismo en WebGLEnvironments.js. Los contadores muestran el trabajo:
$ node scripts/probe.mjs 03-environment{ "backend": "WebGPU", "gpu": "nvidia, blackwell", "frame": 4, "render": { "calls": 23, "drawCalls": 12, "triangles": 41665 }, "memory": { "geometries": 12, "textures": 6, "programs": 10 }, "environment": { "width": 256, "height": 128, "halfFloat": true, "colorSpace": "srgb-linear" }}- 23 llamadas para 3 fotogramas, donde la página de parches tenía 6. Los fotogramas suman 6: la escena y la pasada de salida. Las otras 17 son el PMREM, construido una vez durante el primer fotograma.
PMREMGenerator.jsdimensiona las caras del cubo a un cuarto del ancho de la imagen, 64 píxeles, lo que da 9 niveles de rugosidad (_createPlanes, línea 747). Dibuja la imagen en el primer nivel (1 renderizado), y luego filtra cada uno de los otros 8 a partir del anterior, con un renderizado en un destino temporal y una copia de vuelta (líneas 590-667): 1 + 8 × 2 = 17. - 12 llamadas de dibujo: 10 esferas, el fondo y la pasada de salida. Las esferas comparten una geometría, de 64 por 32 segmentos, que da 2 × 64 × 32 − 2 × 64 = 3 968 triángulos cada una. El fondo es una esfera propia, de 32 por 32 segmentos (
Background.js, línea 131): 1 984 triángulos. 39 680 + 1 984 + 1 = 41 665. backgroundBlurrinesslee el fondo de un nivel más desenfocado del mismo PMREM, así que no cuesta ninguna textura adicional.
Un archivo HDR más grande cuesta más trabajo de PMREM en la carga, no en cada fotograma: un entorno de 2K da caras de 512 píxeles y más niveles. Una escena que cambia de entorno a menudo debería conservar sus texturas y reutilizarlas, ya que el PMREM se guarda en caché por textura.
En GuitarAlchemist/ga
Sección titulada «En GuitarAlchemist/ga»El diapasón de GA en 05c8eda ya usa ACES y un entorno. Dibuja el entorno en un canvas, un degradado gris vertical, en lugar de cargar un archivo HDR:
const envTexture = new THREE.CanvasTexture(envCanvas); envTexture.mapping = THREE.EquirectangularReflectionMapping; envTexture.colorSpace = THREE.SRGBColorSpace;
// Apply environment map directly (no PMREM for performance) scene.environment = envTexture;Lo que dicen de él las mediciones de esta lección:
- El PMREM ocurre de todos modos. Los comentarios encima de ese código dicen que el PMREM se omite con WebGPU, para evitar avisos y por rendimiento. Como el renderizador construye un PMREM para cualquier
scene.environmentequirrectangular, como muestran las 17 llamadas adicionales de arriba, asignar la textura directamente no lo evita; solo lo traslada al primer fotograma. Si r180, la versión de GA, imprime los avisos que menciona el comentario está por verificar. - Un degradado de 8 bits no tiene reflejos intensos. Un canvas contiene bytes: la parte más clara del degradado,
#e0e0e0, es un lineal 0.75, por debajo del rango de 0.5 a 20 de un estudio con softboxes. Las cuerdas y los trastes metálicos reflejan un techo gris, ytoneMappingExposure = 1.2aclara toda la imagen, no los reflejos. Un entorno HDR, incluso uno generado como el de esta lección, daría al metal reflejos reales. - La textura de madera no tiene espacio de color.
createWoodTexture(líneas 563-654) pinta la madera en#4a3525sobre un canvas y devuelve unaCanvasTexturesin asignarcolorSpace, y el material la usa comomap(lección 2). La medición de arriba predice el resultado: el byte rojo 74 se toma por un lineal 0.29, y se muestra como 147 antes de la iluminación, dondeSRGBColorSpacelo mantendría en 74. El diapasón es más claro y más gris que el color escrito en el código, algo que elcolordel material quizá se ajustó para disimular (por verificar en un navegador). Guitar3D.tsxconstruye su entorno conpmremGenerator.fromScenesobre una escena que solo contiene unaAmbientLight(líneas 171-187). Una luz sin nada que iluminar no renderiza nada: el entorno es negro, y los modelos glTF cargados en ese componente no obtienen de él ningún reflejo.
Experimento GA
Sección titulada «Experimento GA»El experimento 7 de la lección 14 ilumina una cuadrícula de esferas de metal y de plástico, como para cuerdas y trastes: valores distintos de metalness y roughness no añaden programas de shader, porque son uniforms, y los materiales físicos cuestan tiempo de sombreado, no de compilación.
Puntos clave
Sección titulada «Puntos clave»- El espacio de color de trabajo es lineal; los colores hexadecimales y las imágenes son sRGB, y three.js convierte a la entrada y a la salida.
setRGBrecibe valores lineales salvo que se le indique un espacio de color; una textura que creas necesitacolorSpace = SRGBColorSpacecuando contiene colores, y nada cuando contiene datos.- Sin mapeo de tonos, todo lo que supera 1 es blanco. ACES Filmic, AgX y Neutral comprimen los reflejos de forma distinta; la elección es de estilo.
WebGPURendereraplica el mapeo de tonos y la codificación sRGB en su pasada de salida.- Los entornos HDR iluminan una escena con niveles de luz reales; el renderizador los convierte en un PMREM una sola vez, con un costo que muestra
renderer.info.
Ejercicios
Sección titulada «Ejercicios»- Una textura contiene un píxel con los bytes (200, 100, 50) y tiene
NoColorSpace. Sin mapeo de tonos y con unMeshBasicMaterial, ¿el canal rojo en pantalla quedará por encima o por debajo de 200? ¿Y el verde? - ¿Qué devuelve
new THREE.Color('#808080').getHexString(THREE.LinearSRGBColorSpace)? Haz tu predicción y luego comprueba ambas respuestas conscripts/l03-exercises.ts. - Con AgX, el lineal 1 da 202. ¿Qué
toneMappingExposureprobarías para acercarlo a 240, el valor que da Neutral, y qué le pasa al lineal 0.05? La página acepta un parámetro?exposure=: compruébalo connode scripts/probe.mjs 03-color --query "tone=agx&exposure=2", y luego con 4.
Solución 1
$ node scripts/l03-exercises.tsbytes [ 200, 100, 50 ] with NoColorSpace are shown as [ 229, 168, 122 ]Color('#808080').getHexString(LinearSRGBColorSpace): 373737Los dos por encima. Sin espacio de color, los bytes se toman como valores lineales, 200/255 = 0.784 y 100/255 = 0.392, y la salida los codifica a sRGB. La codificación sube todos los valores entre 0 y 1, y más los oscuros: el rojo pasa a 229 y el verde a 168. La fila “textura 128, sin espacio de color” midió el mismo efecto: 128 se convirtió en 188. La imagen se ve más clara y menos saturada de lo que es.
Solución 2
373737, como muestra la salida de la solución 1. #808080 es sRGB 0.502, guardado como lineal 0.2158, y 0.2158 × 255 = 55, que es 37 en hexadecimal. Es la misma conversión que el 110603 del palisandro en la salida del script: pedir el hexadecimal lineal muestra lo que recibe el shader.
Solución 3
$ node scripts/probe.mjs 03-color --query "tone=agx&exposure=2"{ ... "tone": "agx", "exposure": 2, "pixels": { "linear 0.05": "rgb(100, 100, 100)", "linear 0.18": "rgb(160, 160, 160)", "linear 0.5": "rgb(202, 202, 202)", "linear 1": "rgb(224, 224, 224)", "linear 2": "rgb(239, 239, 239)", "linear 8": "rgb(254, 254, 254)", ... }}$ node scripts/probe.mjs 03-color --query "tone=agx&exposure=4"{ ... "pixels": { "linear 0.05": "rgb(133, 133, 133)", "linear 0.18": "rgb(189, 189, 189)", "linear 0.5": "rgb(224, 224, 224)", "linear 1": "rgb(239, 239, 239)", "linear 2": "rgb(248, 248, 248)", "linear 8": "rgb(255, 255, 255)", ... }}Una exposición de 4 lleva el lineal 1 a 239. La exposición multiplica los valores lineales antes de la curva, así que cada parche se desplaza a lo largo de la misma curva AgX: con exposición 2, el lineal 1 da 224, el valor que tenía el lineal 2 con exposición 1, y el lineal 0.5 da 202, el antiguo valor del lineal 1. Todo lo demás se mueve también: el lineal 0.05 pasa de 70 a 133, y toda la imagen es mucho más clara, mientras que Neutral mantenía 0.05 en 33. La exposición no puede convertir AgX en Neutral: la forma de la curva se mantiene.
Fuentes
Sección titulada «Fuentes»- Manual de three.js: Color management
- Documentación de three.js:
Color,Texture.colorSpace,Renderer.toneMapping,HDRLoader,PMREMGenerator - Código fuente de three.js en r186:
ToneMappingFunctions.js,PMREMGenerator.js,EnvironmentNode.js - W3C: sRGB; Khronos: PBR Neutral tone mapper
- Manual de Blender: AgX view transform
- Greg Ward: el formato RGBE