sRGB frente a lineal: por qué existe la curva
Qué es una función de transferencia, por qué ocho bits obligan a torcer la escala, y qué operaciones dejan de funcionar si las haces en el espacio equivocado.
Un fichero PNG no guarda cantidades de luz. Guarda números pensados para que, al mostrarlos en una pantalla, el resultado se parezca a lo que vio la cámara, y esa correspondencia no es proporcional. Toda la gestión de color de un motor gráfico consiste en deshacer esa torsión antes de calcular, y volver a aplicarla al final. Hacerlo mal no produce ningún error; produce imágenes que están mal y nadie sabe por qué.
- Explicar por qué la codificación sRGB no es lineal y qué problema resuelve.
- Reproducir las dos funciones de conversión tal y como están en Three.js.
- Demostrar por qué sumar y multiplicar luz exige valores lineales.
- Reconocer el aspecto de una imagen calculada en el espacio equivocado.
Ocho bits no dan para tanto
La sensibilidad humana a la luz es aproximadamente logarítmica: distinguimos mucho mejor dos tonos oscuros cercanos que dos tonos claros con la misma diferencia de energía. Duplicar la cantidad de fotones no duplica la claridad percibida; la sube en un escalón parecido al que produciría duplicar otra vez.
Si guardaras una imagen con valores proporcionales a la energía repartidos en 256 escalones, gastarías la mitad de los códigos en el rango alto, donde el ojo apenas nota diferencias, y dejarías el rango bajo, donde el ojo es exigente, con muy pocos. El resultado sería banding visible en las sombras y precisión desperdiciada en las luces.
La solución, que viene de las televisiones de tubo y se formalizó en el estándar sRGB en 1996, es aplicar una curva antes de cuantizar: una potencia de aproximadamente uno partido por 2,2, que expande el rango oscuro y comprime el claro. Así los 256 escalones se reparten de forma aproximadamente uniforme en la percepción.
Las dos funciones, exactas
Three.js implementa la conversión con la definición completa del estándar, que no es una potencia pura sino una función a trozos con un segmento lineal cerca del cero:
export function SRGBToLinear( c ) {
return ( c < 0.04045 )
? c * 0.0773993808
: Math.pow( c * 0.9478672986 + 0.0521327014, 2.4 );
}
export function LinearToSRGB( c ) {
return ( c < 0.0031308 )
? c * 12.92
: 1.055 * ( Math.pow( c, 0.41666 ) ) - 0.055;
}
Las constantes raras son inversos: 0.0773993808 es uno partido por 12.92, 0.9478672986 es uno partido por 1.055, y 0.0521327014 es 0.055 dividido entre 1.055. El tramo lineal cerca del origen existe porque la potencia tiene derivada infinita en cero, lo que provocaría una amplificación descontrolada del ruido en los negros.
Los números que conviene tener memorizados:
| valor sRGB | valor lineal |
|---|---|
| 0.00 | 0.000 |
| 0.25 | 0.051 |
| 0.50 | 0.214 |
| 0.75 | 0.522 |
| 1.00 | 1.000 |
El de en medio es el que importa. Un gris medio en una imagen no es media luz, es el 21 por ciento de la luz. Y al revés: media luz, el 0.5 lineal, se codifica como 0.735 en sRGB, es decir, 188 sobre 255. Es la razón por la que el gris que un fotógrafo llama gris medio, con un 18 por ciento de reflectancia, se ve en la pantalla como un gris bastante claro alrededor del 46 por ciento del rango.
Por qué la aritmética exige lineal
La luz se suma. Dos bombillas idénticas iluminando una pared producen exactamente el doble de fotones que una. La reflexión multiplica: una superficie con albedo 0.5 devuelve la mitad de lo que recibe. Las dos operaciones son válidas sobre cantidades de energía, es decir, sobre valores lineales, y no sobre valores codificados.
El ejemplo canónico: dos luces que por separado dan un valor sRGB de 0.5 cada una. Sumadas en el espacio equivocado dan 1.0, blanco puro. Sumadas correctamente dan 0.214 + 0.214 = 0.428 lineal, que recodificado es 0.687, un gris claro. La diferencia entre “blanco quemado” y “gris claro” no es un matiz: es la diferencia entre una escena que se satura en cuanto pones dos luces y una que se comporta.
Lo mismo con la mezcla alfa, con los degradados y con los mipmaps. Un degradado de negro a blanco interpolado en sRGB tiene el punto medio en 0.5, que es un 21 por ciento de luz: el degradado se ve oscuro en su mitad, con un salto brusco hacia el blanco al final. Interpolado en lineal, el punto medio es media luz y la transición es uniforme.
Puedes comprobarlo sin ningún render:
import * as THREE from 'three';
// Un gris medio interpretado como sRGB, llevado al espacio de trabajo.
const c = new THREE.Color().setRGB(0.5, 0.5, 0.5, THREE.SRGBColorSpace);
console.log(c.r); // 0.2140: eso es media escala, no media luz
// Sumar dos luces asi: en lineal, y despues recodificar para mostrarlo.
const suma = new THREE.Color(c.r * 2, c.g * 2, c.b * 2);
THREE.ColorManagement.workingToColorSpace(suma, THREE.SRGBColorSpace);
console.log(suma.r); // 0.6867, un gris claro, no blanco quemado
// La suma ingenua sobre valores codificados habria dado 1.0.
La conclusión operativa es la que gobierna todo el resto del nivel: todos los cálculos ocurren en lineal. Three.js lo formaliza con un espacio de trabajo declarado, ColorManagement.workingColorSpace, cuyo valor es LinearSRGBColorSpace, y con la obligación de decir en qué espacio viene cada dato que entra.
SRGBColorSpace y LinearSRGBColorSpace tienen exactamente los mismos primarios y el mismo punto blanco, D65. Míralo en la definición de Three.js: las dos entradas de ColorManagement.spaces comparten REC709_PRIMARIES y la misma matriz hacia XYZ. Lo único que las diferencia es el campo transfer, que en una es SRGBTransfer y en la otra LinearTransfer.
Eso significa que convertir entre las dos es aplicar o quitar la curva, sin ningún cambio de gama. Y significa que el término “espacio de color” está haciendo dos trabajos distintos: describir qué colores son representables, que es cosa de los primarios, y describir cómo se codifican los números, que es cosa de la transferencia. Cuando alguien dice “trabaja en espacio lineal”, habla de la segunda; cuando dice “esta pantalla cubre P3”, habla de la primera.
La consecuencia práctica llega el día que aparezcan pantallas de gama amplia en tu flujo. Cambiar de sRGB a Display P3 sí cambia los primarios, y entonces la conversión deja de ser una curva y pasa a ser una multiplicación por matrices, que es justo lo que hace ColorManagement.convert cuando detecta que los primarios difieren. Three.js tiene la arquitectura preparada para eso desde hace revisiones, aunque por defecto solo estén definidos los dos espacios sRGB.
Con la teoría clara, lo que queda es la parte operativa: decidir textura por textura si lo que guarda es color o es un número. Es la decisión que más veces se toma mal en todo el track.