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

Color o dato: la regla que evita el error silencioso

Qué texturas llevan SRGBColorSpace y cuáles no llevan nada, qué se ve exactamente cuando te equivocas en cada dirección, y cómo auditarlo por código.

⏱ 17 min

De todos los errores que puedes cometer en Three.js, este es el único que nunca produce una excepción, ni un aviso por consola, ni un objeto que desaparece. Marcar mal el espacio de color de una textura no rompe nada: simplemente hace que la imagen esté mal, de una forma lo bastante sutil como para que dediques la tarde a ajustar luces cuando el problema era una línea que faltaba.

🎯 Al terminar esta lección sabrás
  • Aplicar la regla que decide si una textura es color o es dato.
  • Clasificar correctamente los quince mapas de un MeshPhysicalMaterial.
  • Reconocer el síntoma visual de cada uno de los dos errores posibles.
  • Escribir una auditoría que recorra la escena y avise de las incoherencias.

La regla, en una pregunta

¿Lo que guarda esa textura es algo que verías con los ojos?

Si la respuesta es sí, es color, y lleva texture.colorSpace = THREE.SRGBColorSpace. Si la respuesta es no, si lo que guarda es un número que alimenta un cálculo, es dato, y no se le pone nada: el valor por defecto de Texture.colorSpace es NoColorSpace, que es exactamente lo correcto.

Aplicada al catálogo completo:

textura tipo colorSpace
map color SRGBColorSpace
emissiveMap color SRGBColorSpace
sheenColorMap color SRGBColorSpace
specularColorMap color SRGBColorSpace
matcap color SRGBColorSpace
lightMap color SRGBColorSpace si es una imagen sRGB
normalMap dato ninguno
bumpMap dato ninguno
roughnessMap dato ninguno
metalnessMap dato ninguno
aoMap dato ninguno
alphaMap dato ninguno
displacementMap dato ninguno
clearcoatMap y su rugosidad dato ninguno
clearcoatNormalMap dato ninguno
transmissionMap y thicknessMap dato ninguno
iridescenceMap y su grosor dato ninguno
anisotropyMap dato ninguno
sheenRoughnessMap dato ninguno
specularIntensityMap dato ninguno

La proporción cuenta la historia: de veinte mapas, cinco son color. La regla por defecto es “no tocar”, y la excepción es marcar sRGB.

Hay dos casos que merecen matiz. Un lightMap guarda radiancia, así que conceptualmente es color; si viene de un PNG creado por una herramienta de horneado, lleva sRGB, y si viene de un fichero de rango alto tipo EXR o HDR, ya es lineal y no se marca. Y un gradientMap de material toon es una tabla de consulta sobre la que el argumento se puede sostener en las dos direcciones; lo pragmático es tratarlo como color si lo pintaste a ojo y como dato si lo generaste numéricamente, y en cualquier caso el efecto de equivocarse es solo que las bandas caen en sitios ligeramente distintos.

Qué se ve exactamente al equivocarse

Los dos errores tienen firmas visuales distintas y reconocibles. Conocerlas convierte media hora de depuración en diez segundos.

Textura de color sin marcar. Los valores codificados se usan como si ya fueran lineales, y como la codificación sRGB sube los valores respecto a su equivalente lineal, todo sale más claro de lo que debería, con poco contraste y aspecto lavado. Los tonos medios se van hacia arriba, los negros dejan de ser negros y la imagen parece velada. Es el aspecto característico del 3D web mal configurado, y la reacción instintiva, bajar la intensidad de las luces, lo empeora porque aplana todavía más el contraste.

Textura de datos marcada como color. Los valores se decodifican y bajan, mucho más en la zona media que en los extremos. El efecto depende del mapa:

  • Un normalMap marcado como sRGB es el caso grave. El 0.5 neutro de las componentes X e Y se convierte en 0.214, que remapeado al rango de menos uno a uno da -0.57. Es decir, una zona plana deja de ser plana y pasa a tener normales fuertemente inclinadas hacia la misma esquina en toda la superficie. El resultado no parece un error de textura: parece que la luz viene de otro sitio, o que el material tiene un relieve raro y sucio. No hay ningún aviso.
  • Un roughnessMap marcado como sRGB baja todos los valores, así que el material sale mucho más pulido y brillante de lo que pintaste.
  • Un aoMap marcado como sRGB oscurece de más: la oclusión se aplica con mucha más fuerza y el objeto parece manchado.
  • Un metalnessMap marcado como sRGB baja los grises intermedios, lo que en un mapa que debería ser binario apenas se nota, porque el cero y el uno se conservan. Es el único caso donde el error es casi inocuo.

La auditoría

Como el error es silencioso, la forma sensata de defenderse es comprobarlo por código. Esta función recorre la escena y avisa de las dos incoherencias:

import * as THREE from 'three';

const MAPAS_DE_COLOR = new Set([
  'map', 'emissiveMap', 'sheenColorMap', 'specularColorMap', 'matcap'
]);

const MAPAS_DE_DATOS = new Set([
  'normalMap', 'bumpMap', 'roughnessMap', 'metalnessMap', 'aoMap',
  'alphaMap', 'displacementMap', 'clearcoatMap', 'clearcoatRoughnessMap',
  'clearcoatNormalMap', 'transmissionMap', 'thicknessMap', 'iridescenceMap',
  'iridescenceThicknessMap', 'anisotropyMap', 'sheenRoughnessMap',
  'specularIntensityMap'
]);

export function auditarEspaciosDeColor(raiz) {
  const problemas = [];

  raiz.traverse((objeto) => {
    const materiales = Array.isArray(objeto.material)
      ? objeto.material
      : (objeto.material ? [objeto.material] : []);

    for (const material of materiales) {
      for (const clave of MAPAS_DE_COLOR) {
        const t = material[clave];
        if (t && t.isTexture && t.colorSpace !== THREE.SRGBColorSpace) {
          problemas.push(`${objeto.name || objeto.type}.${clave} deberia ser SRGBColorSpace`);
        }
      }
      for (const clave of MAPAS_DE_DATOS) {
        const t = material[clave];
        if (t && t.isTexture && t.colorSpace === THREE.SRGBColorSpace) {
          problemas.push(`${objeto.name || objeto.type}.${clave} NO deberia ser SRGBColorSpace`);
        }
      }
    }
  });

  return problemas;
}

// Uso tras cargar un modelo.
const fallos = auditarEspaciosDeColor(scene);
if (fallos.length) console.warn(fallos.join('\n'));

Con modelos glTF esta auditoría no debería sacar nada: el formato declara qué texturas son de color y GLTFLoader marca cada una correctamente. Los avisos aparecen cuando cargas texturas sueltas con TextureLoader, que es cuando la decisión es tuya, y cuando alguien sustituye una textura de un modelo cargado por otra propia.

La corrección hay que aplicarla también a lo que no es una textura

El espacio de color no es solo cosa de imágenes. Todo lo que aporte color a la escena entra por el mismo sitio y hay que declarar de dónde viene:

  • Los colores de JavaScript. material.color.setHex(0xff8844) interpreta sRGB por defecto, que es lo correcto para un valor sacado de un selector de color o de una guía de estilo. setRGB(r, g, b) sin cuarto argumento, en cambio, interpreta el espacio de trabajo, es decir, lineal. Los dos son correctos y significan cosas distintas, así que copiar valores de uno a otro sin pensar produce colores que no coinciden.
  • Los DataTexture que fabricas. Si generas una textura de color por código, con un degradado o un ruido, tienes que decidir en qué espacio están esos números. Si los calculaste como cantidades de luz, son lineales y no llevas nada; si los sacaste de una paleta de diseño, son sRGB y hay que marcarlo.
  • Los CanvasTexture. Un canvas 2D trabaja en sRGB, así que cualquier textura creada desde un canvas con contenido de color lleva SRGBColorSpace. Es fácil olvidarlo porque el canvas no vino de un fichero.
  • El fondo de la escena. scene.background con un Color sigue las mismas reglas que cualquier color; con una textura, las de cualquier textura.
  • El color de fondo del renderer. renderer.setClearColor() acepta los mismos formatos de Color.

Y hay una comprobación global que ahorra sorpresas: THREE.ColorManagement.enabled vale true por defecto y desactivarlo apaga todas las conversiones a la vez. Aparece en tutoriales antiguos como parche para código escrito antes de la revisión 152. Si heredas un proyecto donde los colores no cuadran de una forma difícil de explicar, comprobar esa bandera es lo primero.