wandres.dev
TEXTURAS III · Espacios de color y compresión

El camino del color, del fichero al píxel

El espacio de trabajo de Three.js, outputColorSpace, y qué ocurre exactamente en cada etapa desde que se lee un téxel hasta que se escribe un píxel.

⏱ 17 min

Three.js gestiona el color con tres piezas: cada entrada declara de dónde viene, todo el cálculo ocurre en un espacio de trabajo declarado, y la salida se codifica al espacio del destino. Visto de golpe, el sistema es coherente y difícil de romper. Visto por trozos, que es como se aprende normalmente, parece un conjunto de propiedades sueltas que a veces hay que tocar y a veces no.

🎯 Al terminar esta lección sabrás
  • Recorrer el camino completo del color desde el fichero hasta el framebuffer.
  • Explicar qué es el espacio de trabajo y por qué es lineal.
  • Configurar outputColorSpace y saber cuándo no se aplica.
  • Anticipar el comportamiento del color al renderizar a un render target.

El camino completo

flowchart TB
A[Fichero PNG o JPEG] --> B{texture.colorSpace}
B -->|SRGBColorSpace| C[Decodificacion sRGB a lineal]
B -->|NoColorSpace| D[Se usa el valor tal cual]
E[Color en JavaScript] --> F[setHex decodifica sRGB]
C --> G[Espacio de trabajo lineal]
D --> G
F --> G
G --> H[BRDF luces y mezclas]
H --> I[Tone mapping]
I --> J[outputColorSpace codifica]
J --> K[Pixel del framebuffer]
style A fill:#94e2d5,color:#11111b
style E fill:#94e2d5,color:#11111b
style B fill:#cba6f7,color:#11111b
style G fill:#89b4fa,color:#11111b
style H fill:#fab387,color:#11111b
style I fill:#f9e2af,color:#11111b
style K fill:#a6e3a1,color:#11111b

Cinco etapas y solo dos decisiones tuyas: la de la izquierda, declarar de dónde viene cada dato, y la del final, declarar a dónde va la imagen. Todo lo de en medio lo hace Three.js sin que tengas que intervenir.

El espacio de trabajo

ColorManagement es un objeto singleton con dos propiedades que gobiernan el sistema entero:

const ColorManagement = {
  enabled: true,
  workingColorSpace: LinearSRGBColorSpace,
  // ...
};

workingColorSpace es el espacio en el que ocurre todo el cálculo: la BRDF, las sumas de luces, las mezclas alfa, la interpolación de vértices, la generación de mipmaps que hace la GPU cuando la textura declara su formato. Es lineal por la razón que ya conoces, porque la aritmética de la luz solo tiene sentido sobre cantidades proporcionales a la energía.

Los dos métodos que hacen el trabajo se llaman, desde la revisión 177, colorSpaceToWorking y workingToColorSpace. Los nombres antiguos, toWorkingColorSpace y fromWorkingColorSpace, siguen existiendo pero emiten un aviso de obsolescencia. Si mantienes código de hace un par de años, ese aviso es lo que verás.

enabled es el interruptor general. Ponerlo a false desactiva todas las conversiones a la vez, y solo existe por compatibilidad con proyectos anteriores a la revisión 152, cuando la gestión de color se volvió automática. Desactivarlo hoy es garantizar que ninguna textura ni ningún color se interpreten correctamente.

outputColorSpace

Al final del fragment shader, después del mapeo de tonos, el color pasa por una última conversión que lo lleva del espacio de trabajo al espacio del destino. Ese destino lo declara el renderer:

import * as THREE from 'three';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;   // ya es el valor por defecto

Es el valor correcto para una pantalla normal y no hay que tocarlo. El único otro valor con sentido es LinearSRGBColorSpace, y solo si la salida no va a una pantalla sino a otro proceso que espera valores lineales.

Merece la pena entender por qué esta conversión existe y por qué no es opcional. El framebuffer de la página tiene ocho bits por canal. Escribir en él valores lineales gastaría casi todos los códigos en el rango alto y produciría banding severo en las sombras, exactamente el problema que la codificación sRGB vino a resolver. La conversión de salida no es un ajuste estético: es lo que hace que ocho bits basten.

Al dibujar a un render target, outputColorSpace no interviene

Esta es la regla que rompe la mitad de las cadenas de post-proceso escritas a mano. Cuando el destino del render no es el lienzo sino un WebGLRenderTarget, la codificación de salida no se aplica: el espacio del destino lo declara la textura del render target, y su valor por defecto deja los valores en lineal.

Y es lo correcto. Una cadena de post-proceso encadena pasadas que hacen desenfoques, sumas y umbrales, todas ellas operaciones de luz que exigen valores lineales. Si cada pasada intermedia codificara a sRGB y la siguiente decodificara, además de perder precisión estarías haciendo trabajo inútil. Lo correcto es mantener toda la cadena lineal y convertir una sola vez, en la pasada final que escribe en el lienzo.

Hay un corolario que sorprende y que la documentación de Material recoge: material.toneMapped se ignora al dibujar a un render target o con post-proceso, y en ese caso el mapeo de tonos afecta a todos los materiales. Un material al que le habías puesto toneMapped: false para que no se apagara vuelve a pasar por la curva en cuanto añades un EffectComposer, y tu elemento de interfaz superpuesto cambia de aspecto sin que hayas tocado nada suyo.

El síntoma clásico de tener esto mal es una escena que se ve correcta sin post-proceso y demasiado clara o demasiado oscura en cuanto añades un solo pase. Si eso te pasa, la conversión se está aplicando dos veces o ninguna, y la culpa está en la pasada final, no en el efecto que acabas de añadir.

La lista mínima de configuración

Reduciendo todo el nivel a lo imprescindible, un proyecto con la gestión de color correcta tiene exactamente esto:

import * as THREE from 'three';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.outputColorSpace = THREE.SRGBColorSpace;      // defecto, explicito por claridad
renderer.toneMapping = THREE.ACESFilmicToneMapping;    // este NO es el defecto
renderer.toneMappingExposure = 1.0;

const loader = new THREE.TextureLoader();

const albedo = loader.load('/tex/color.jpg');
albedo.colorSpace = THREE.SRGBColorSpace;              // color

const normales = loader.load('/tex/normal.png');       // dato: nada
const rugosidad = loader.load('/tex/rough.png');       // dato: nada

Cuatro líneas de configuración y una decisión por textura. Lo único que no viene bien por defecto es el mapeo de tonos, que arranca en NoToneMapping, y ese es el tema de la lección siguiente.

Un último apunte de higiene: si cargas modelos glTF, no tienes que hacer nada de la parte de texturas, porque el formato lo declara y el cargador lo respeta. Toda esta lección aplica a las texturas que cargas tú y a los colores que escribes tú, que en una escena real suelen ser bastantes más de los que parece.