Luz lineal frente a valor codificado
Qué operación pertenece a cada dominio, dónde hace cada cosa el navegador, y por qué un desenfoque de SVG y uno de CSS no dan el mismo resultado.
Todo color de tu página existe en dos versiones: el número que escribes, que es una medida perceptual comprimida, y la cantidad de luz que representa. Las dos son legítimas y cada operación pertenece a una de ellas. El problema no es que la web use la codificada, es que la usa para todo, incluidas las operaciones que están definidas físicamente sobre la luz.
- Clasificar una operación de color según el dominio al que pertenece.
- Escribir colores y mezclas en el espacio lineal con
color()ycolor-mix(). - Explicar por qué un desenfoque de SVG y uno de CSS difieren por defecto.
- Anticipar las consecuencias visibles de operar en el dominio equivocado.
Dos números para el mismo color
#808080 es un número codificado: expresa una posición en una escala perceptual. Su luz lineal es 0.216, según la función de transferencia. Los dos describen el mismo gris y responden a preguntas distintas: el primero a “cuán claro parece” y el segundo a “cuántos fotones salen”.
CSS te da acceso explícito al segundo:
.a { background: #808080; } /* codificado */
.b { background: color(srgb-linear 0.216 0.216 0.216); } /* la misma luz */
.c { background: color(srgb-linear 0.5 0.5 0.5); } /* la mitad de la luz */
.a y .b pintan exactamente el mismo píxel. .c es el gris #bcbcbc, que es el que emite la mitad de la luz del blanco.
La función color() con el espacio srgb-linear no es exótica: está en los tres motores desde hace años y es la forma estándar de decir “este número es luz, no percepción”.
Qué operación pertenece a qué dominio
La clasificación es sorprendentemente nítida y merece aprenderse de memoria.
Se define sobre luz lineal todo lo que simula un fenómeno físico: sumar dos fuentes, promediar píxeles al escalar o desenfocar una imagen, componer una superficie translúcida sobre otra, calcular la iluminación de una escena, aplicar una fusión aditiva o multiplicativa con sentido óptico. En todos estos casos, la operación correcta es convertir a lineal, operar y volver a codificar.
Se define sobre un espacio perceptual todo lo que un humano va a juzgar como una progresión: los pasos de una escala de tonos, la interpolación de un degradado que debe parecer uniforme, la distancia entre dos colores de una paleta, un cálculo de contraste. Aquí lo correcto no es el lineal sino un espacio diseñado para que las distancias iguales se perciban iguales.
El valor codificado de sRGB no es ninguno de los dos. Es un formato de almacenamiento que resulta ser una aproximación decente de un espacio perceptual —por eso los degradados en sRGB no son un desastre absoluto— y una aproximación pésima de la luz.
De ahí sale la regla práctica: elige el espacio de interpolación según lo que estés simulando. srgb-linear para luz, oklab u oklch para percepción, y srgb solo cuando necesites compatibilidad con un comportamiento antiguo.
.luz { background: linear-gradient(in srgb-linear, #0000ff, #ffff00); }
.percep { background: linear-gradient(in oklab, #0000ff, #ffff00); }
.legado { background: linear-gradient(#0000ff, #ffff00); }
Dónde hace cada cosa el navegador
Aquí está el mapa que evita la mitad de las sorpresas.
Los degradados y color-mix() sin espacio explícito interpolan en sRGB codificado cuando los colores están en notaciones antiguas. Es el comportamiento por defecto y es el que produce el gris sucio.
La composición con opacity y los modos de fusión operan sobre valores codificados y no se pueden cambiar. No hay ninguna propiedad que pida composición en luz lineal. Es una limitación real del modelo de la web y hay que diseñar contando con ella.
Los filtros de CSS —la función blur(), brightness() y compañía en la propiedad filter— operan en sRGB codificado, porque la especificación de efectos de filtro fija ese espacio para las funciones abreviadas.
Los filtros de SVG operan por defecto en RGB lineal, que es lo que dice la especificación de SVG. Es la discrepancia más útil de conocer de toda esta lección: un feGaussianBlur y un filter: blur() con el mismo radio no dan el mismo resultado, y la diferencia es exactamente la del dominio. El desenfoque de SVG conserva el brillo de las zonas claras porque promedia luz; el de CSS oscurece los bordes porque promedia códigos.
<svg width="0" height="0">
<filter id="desenfoque-srgb" color-interpolation-filters="sRGB">
<feGaussianBlur stdDeviation="8"/>
</filter>
</svg>
El atributo color-interpolation-filters es el interruptor, y ponerlo a sRGB es la forma de que un filtro de SVG coincida con el equivalente de CSS. En el otro sentido no hay interruptor: no se puede pedir que filter: blur() trabaje en lineal.
Consecuencias que se ven
El tablero de ajedrez. Una imagen de cuadros blancos y negros reducida a la mitad debería dar un gris del 50% de luz, es decir #bcbcbc. Promediando códigos da #808080. La imagen se oscurece al reducirla, y el efecto es más visible cuanto más contraste tenga el detalle fino. Es la demostración más limpia del problema y se puede reproducir en cinco minutos con cualquier herramienta.
Los velos. Un negro al 50% sobre blanco da 128, con su 21.6% de luz. Si esperabas la mitad de la luz, tienes que bajar el alfa bastante por debajo de 0.5. Como no se puede cambiar el dominio de la composición, la única vía es ajustar el alfa a ojo, y por eso los valores de los velos de un sistema de diseño nunca son números redondos.
Los bordes antialiaseados. El antialiasing promedia cobertura y color, así que un borde negro sobre blanco tiene píxeles intermedios más oscuros de lo que la geometría dice. Es la razón de que el texto negro sobre blanco parezca ligeramente más grueso de lo debido, y de que los motores de fuentes apliquen correcciones de gamma específicas al renderizar texto.
Las sombras y los resplandores. Un resplandor es óptimamente aditivo, y sumar en el dominio codificado produce halos que crecen demasiado rápido cerca del origen y se apagan demasiado pronto lejos.
Si necesitas la luminancia de un color en JavaScript, la conversión son cuatro líneas y no requiere ninguna librería.
const aLineal = v => v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
function luminancia([r, g, b]) {
const [R, G, B] = [r, g, b].map(c => aLineal(c / 255));
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
luminancia([128, 128, 128]); // 0.2159
luminancia([0, 0, 255]); // 0.0722La discrepancia entre filter: blur() y feGaussianBlur parece una anécdota de compatibilidad y es en realidad un fósil perfectamente conservado de dos comunidades de ingeniería que tomaron decisiones opuestas y correctas. SVG lo especificó gente que venía de los gráficos vectoriales y del procesado de imagen, donde trabajar en luz lineal es la práctica correcta y nadie discute lo contrario; por eso su valor por defecto es linearRGB. Los filtros de CSS los especificó gente que venía del render de páginas, donde todo lo demás —la composición, el alfa, los modos de fusión— ya operaba en el dominio codificado, y hacer que los filtros fueran los únicos en lineal habría producido resultados incoherentes con las capas de al lado; por eso su valor por defecto es sRGB. Ninguna de las dos decisiones es un error, y sin embargo conviven en el mismo documento y producen imágenes distintas para la misma operación matemática. La lección que conviene extraer no es cuál de las dos está bien, sino que en cualquier sistema con varias capas de composición hay que preguntar por el dominio de cada una, porque es la clase de suposición que nunca se documenta en el sitio donde la vas a necesitar y que explica de golpe una diferencia que llevabas media hora atribuyendo a un bug.