wandres.dev
MATERIALES · El catálogo y qué modela cada uno

Toon, Matcap, Normal y Depth: estilo y depuración

Cuatro materiales que no persiguen el realismo: el sombreado por bandas, la esfera de referencia horneada, y los dos que existen para que veas lo que la GPU está calculando.

⏱ 17 min

El catálogo de Three.js no termina en la escala Basic-Lambert-Phong-Standard. Hay cuatro materiales más que no encajan en esa progresión porque no intentan aproximarse a la realidad: dos buscan un estilo concreto y dos existen para mostrarte, en color, magnitudes que normalmente no ves. Los dos últimos son la herramienta de diagnóstico más rápida que tienes cuando algo se ve mal y no sabes por qué.

🎯 Al terminar esta lección sabrás
  • Construir la textura de gradiente que MeshToonMaterial necesita, con el filtrado correcto.
  • Explicar qué información codifica un matcap y por qué no necesita luces.
  • Interpretar los colores de MeshNormalMaterial para diagnosticar normales rotas.
  • Ajustar near y far para que MeshDepthMaterial muestre algo útil.

Toon: cuantizar el difuso

MeshToonMaterial calcula la iluminación difusa igual que Lambert y después, en lugar de usar el valor continuo, lo usa como coordenada para buscar en una textura de una fila. Si esa textura tiene tres texeles, el resultado tiene tres tonos y las transiciones son bordes duros. Eso es todo el material: una tabla de consulta sobre el difuso.

La textura se pasa en gradientMap, y hay un requisito que la propia documentación subraya: hay que poner minFilter y magFilter en NearestFilter. Con el filtrado lineal por defecto, la GPU interpola entre texeles y recuperas un degradado suave, que es exactamente lo contrario de lo que buscabas. Es el fallo número uno con este material.

import * as THREE from 'three';

function gradienteDeTonos(tonos) {
  // Un texel por tono, del mas oscuro al mas claro.
  const datos = new Uint8Array(tonos);
  for (let i = 0; i < tonos; i++) {
    datos[i] = Math.round((i / (tonos - 1)) * 255);
  }

  const textura = new THREE.DataTexture(datos, tonos, 1, THREE.RedFormat);
  textura.minFilter = THREE.NearestFilter;
  textura.magFilter = THREE.NearestFilter;
  textura.generateMipmaps = false;
  textura.needsUpdate = true;
  return textura;
}

const material = new THREE.MeshToonMaterial({
  color: 0xcba6f7,
  gradientMap: gradienteDeTonos(3)
});

Tres consideraciones. RedFormat basta porque solo se lee un canal. generateMipmaps a false evita que la GPU cree niveles inferiores de una textura de un píxel de alto, que no tendrían sentido. Y needsUpdate es obligatorio en una DataTexture recién creada, porque no hay ninguna carga asíncrona que dispare la subida.

MeshToonMaterial acepta normalMap, bumpMap, aoMap, emissiveMap y displacementMap, así que puedes tener un personaje con detalle de superficie y sombreado por bandas a la vez. Lo que no tiene es envMap ni especular: un contorno brillante en estilo cel-shading hay que hacerlo con geometría invertida o con post-proceso, no con el material.

Matcap: la iluminación horneada en una esfera

Un matcap, abreviatura de material capture, es una fotografía o un render de una esfera perfecta iluminada de cierta manera. MeshMatcapMaterial toma la normal del fragmento en espacio de vista, la usa como coordenada dentro de esa imagen de esfera, y devuelve el color que encuentra. Fin.

Como toda la iluminación está dentro de la imagen, el material no consulta ninguna luz de la escena y su coste por fragmento es una sola lectura de textura. Es de los materiales más baratos que existen y a la vez de los que mejor se ven, lo que explica que casi todos los visores de modelos 3D lo usen por defecto.

La limitación es estructural y no tiene arreglo: la iluminación está fijada al espacio de la cámara. Si giras el objeto, el brillo se queda quieto respecto a la pantalla en lugar de quedarse quieto respecto al mundo. Para un visor donde la cámara orbita, es imperceptible. Para una escena con una fuente de luz identificable, se nota inmediatamente y queda mal.

Normal: la normal como color

MeshNormalMaterial mapea el vector normal a color: cada componente, que va de menos uno a uno, se remapea al rango de cero a uno y se escribe en un canal. La normal que usa está en espacio de vista, no en espacio de mundo ni de objeto. Por eso los colores cambian cuando mueves la cámara sin mover nada más, cosa que desconcierta la primera vez.

En espacio de vista, la cara que mira directamente a la cámara tiene normal (0, 0, 1), que se codifica como azul claro. Las caras que miran a la derecha tiran a rosa, a la izquierda a verde azulado, arriba a verde claro, abajo a violeta. Con esa clave puedes leer una escena de un vistazo.

Lo que hace útil el material es lo que revela cuando algo está mal:

  • Una superficie de color plano donde esperabas un degradado: las normales de esa zona son todas iguales. Suele significar que la geometría se exportó con normales de cara en lugar de suavizadas, o que faltó computeVertexNormals().
  • Bandas duras en una superficie que debería ser suave: vértices duplicados en la costura, cada copia con su propia normal.
  • Colores complementarios en caras adyacentes: normales invertidas, casi siempre por winding order al revés tras un espejado con escala negativa.
  • Ruido de colores: el atributo normal está desalineado o tiene el itemSize equivocado.
// Diagnostico rapido: sustituye todos los materiales de la escena sin tocar nada.
scene.overrideMaterial = new THREE.MeshNormalMaterial({ flatShading: false });
renderer.render(scene, camera);
scene.overrideMaterial = null;

scene.overrideMaterial es el atajo: aplica ese material a todo lo que se dibuje, y vuelves a la normalidad poniéndolo a null. Alternar entre flatShading: true y false en el mismo material te dice de inmediato si la geometría trae normales suavizadas o si las está fabricando la derivada del fragmento.

Depth: la profundidad, y por qué siempre sale blanca

MeshDepthMaterial pinta el valor de profundidad en escala de grises. Su propiedad distintiva es depthPacking, que decide cómo se codifica ese valor: BasicDepthPacking lo escribe invertido, 1 - z, pensado para que lo veas; RGBADepthPacking lo reparte entre los cuatro canales de ocho bits para conservar precisión en un render target normal, que es lo que necesita el sistema de sombras.

La primera vez que lo usas, el resultado es una silueta blanca sobre fondo blanco y parece que no funciona. Funciona: lo que pasa es que la profundidad en una proyección en perspectiva no es lineal. La precisión se concentra brutalmente cerca del plano near, y si tu cámara tiene near: 0.1 y far: 1000, prácticamente todo el rango del buffer se gasta en el primer metro. Baja el far a la extensión real de tu escena y sube el near todo lo que puedas, y la imagen aparece.

Ese blanco plano es la explicación del z-fighting

La no linealidad no es un detalle de visualización: es la causa directa del z-fighting. La proporción entre far y near determina cuánta precisión te queda a distancia. Con near: 0.01 y far: 10000, la relación es de un millón a uno y a media distancia dos superficies separadas varios centímetros pueden acabar en el mismo valor cuantizado del buffer de profundidad, así que parpadean según cuál gane el redondeo en cada píxel. La reacción instintiva, subir el far, empeora las cosas mucho menos que bajar el near, porque la precisión depende sobre todo del plano cercano. MeshDepthMaterial con el far ajustado a la escena es la forma más rápida de ver dónde estás desperdiciando el buffer: si toda tu escena aparece en el mismo tono de gris, tienes el rango mal repartido, y arreglarlo suele hacer desaparecer el z-fighting sin tocar nada más.

Un apunte final sobre el catálogo: hay dos materiales más que rara vez instanciarás tú. MeshDistanceMaterial lo usa internamente el sistema de sombras de las luces puntuales, que necesita distancia radial en lugar de profundidad. Y ShadowMaterial es un material que solo dibuja las sombras recibidas sobre un fondo transparente, la herramienta exacta para poner un objeto 3D sobre una página web y que proyecte sombra sin que se vea el suelo.