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

Lo que comparten todos: side, transparent, alphaTest y depthWrite

Las propiedades de la clase Material que gobiernan la transparencia y el orden de dibujado, con las consecuencias concretas de cada combinación.

⏱ 19 min

Todos los materiales de Three.js heredan de la misma clase base, y esa clase define una treintena de propiedades que no tienen nada que ver con el modelo de iluminación y todo que ver con cómo, cuándo y en qué orden se dibuja el objeto. Media docena de ellas concentran la práctica totalidad de los problemas visuales que verás en tu vida, y las cuatro peores giran alrededor de la transparencia.

🎯 Al terminar esta lección sabrás
  • Predecir el efecto de cada valor de side sobre el culling y sobre las sombras.
  • Distinguir los tres mecanismos de transparencia y elegir el adecuado en cada caso.
  • Explicar qué hace depthWrite y por qué desactivarlo resuelve unos problemas y crea otros.
  • Detectar cuándo un cambio de propiedad fuerza la recompilación del shader.

side, y las dos caras de un triángulo

side acepta tres valores. FrontSide, el defecto, dibuja solo las caras cuyo winding es antihorario visto desde la cámara. BackSide dibuja solo las contrarias. DoubleSide dibuja las dos y, además, invierte la normal en el fragment shader cuando estás viendo la cara trasera, para que la iluminación no salga al revés.

El culling de caras traseras es una de las optimizaciones más rentables que existen: descarta aproximadamente la mitad de los triángulos de un objeto cerrado antes de rasterizar. Poner DoubleSide “por si acaso” en un modelo cerrado tira esa optimización a la basura sin ganar nada, porque las caras traseras de un objeto cerrado no se ven nunca.

DoubleSide es obligatorio en tres casos: planos, superficies abiertas, y geometría de una sola cara como una hoja o una tela. Y hay una propiedad hermana que rara vez se toca, shadowSide, cuyo valor por defecto es null y significa “decídelo tú”: con FrontSide proyectan sombra las caras traseras, con BackSide las delanteras, con DoubleSide las dos. Esa inversión no es caprichosa, evita el acné de sombra en objetos cerrados. Si tienes una tela con DoubleSide que produce sombras sucias, forzar shadowSide = THREE.BackSide suele arreglarlo.

Los tres mecanismos de transparencia

Three.js ofrece tres formas distintas de que un fragmento no sea opaco, y son mutuamente independientes.

transparent más opacity. Activa la mezcla alfa clásica. El objeto pasa a la lista de transparentes, que se dibuja después de la de opacos y ordenada de lejos a cerca. Es el mecanismo correcto para cristal, humo, fundidos y cualquier degradado real de opacidad. Es también el que trae el problema del orden, porque la ordenación es por objeto y no por triángulo.

alphaTest. Descarta el fragmento si el alfa queda por debajo del umbral. No hay mezcla, el objeto sigue siendo opaco a todos los efectos, se dibuja en la lista de opacos y escribe profundidad con normalidad. Es la elección correcta para hojas, vallas, texto recortado y cualquier máscara con borde duro. El borde es duro de verdad: sin antialiasing, escalonado. Combinándolo con alphaToCoverage y un contexto creado con antialias: true, ese borde se suaviza usando las muestras del MSAA, que es la mejor relación calidad-coste que hay para vegetación.

alphaHash. Descarta el fragmento si la opacidad queda por debajo de un umbral aleatorio distinto en cada píxel. El resultado aproxima la mezcla alfa sin ninguno de sus problemas de orden, a cambio de un ruido granulado. Con acumulación temporal, que es lo que hace un TAA o un render progresivo, ese ruido desaparece y obtienes transparencia correcta y ordenada gratis. Sin acumulación temporal, se ve el grano.

// Vegetacion: mascara dura, sin problemas de orden, con borde suavizado.
const hoja = new THREE.MeshStandardMaterial({
  map: texturaHoja,
  alphaTest: 0.5,
  alphaToCoverage: true,
  side: THREE.DoubleSide,
  forceSinglePass: true
});

// Cristal de una ventana: mezcla real.
const cristal = new THREE.MeshPhysicalMaterial({
  color: 0xffffff,
  transparent: true,
  opacity: 0.25,
  roughness: 0.05,
  metalness: 0
});

depthWrite y depthTest

depthTest decide si el fragmento se compara con el valor que ya hay en el buffer de profundidad. depthWrite decide si escribe el suyo. Los dos están a true por defecto y hay que entender los cuatro cuadrantes.

depthTest depthWrite resultado
true true comportamiento normal, geometría sólida
true false el objeto queda oculto por lo que hay delante, pero no se oculta a sí mismo
false true se pinta encima de todo y bloquea lo que venga después
false false se pinta encima de todo sin dejar rastro

La combinación útil de verdad es la segunda. Un objeto transparente que escribe profundidad se tapa a sí mismo: las caras traseras de un cristal desaparecen porque las delanteras ya escribieron su profundidad, y lo mismo pasa entre partículas de un mismo sistema. Poniendo depthWrite: false en un material transparente, los fragmentos del propio objeto se mezclan entre sí y el objeto sigue quedando correctamente oculto tras la geometría opaca, porque depthTest sigue activo.

El precio es que ahora el orden de mezcla dentro del objeto es el orden del buffer de índices, que no tiene nada que ver con la profundidad. Para humo y partículas da igual o incluso conviene. Para un cristal con estructura interna, no.

La cuarta combinación es la de los elementos de interfaz dentro de la escena: marcadores, gizmos, contornos de selección. Suele ir acompañada de renderOrder alto para asegurar que se dibujan al final.

Doble cara y transparencia son dos llamadas de dibujo, no una

Cuando un material tiene transparent: true y side: DoubleSide, Three.js dibuja el objeto dos veces: primero solo las caras traseras y después solo las delanteras. Lo hace para mitigar los artefactos de orden, y para un cristal es exactamente lo que quieres. Pero para diez mil sprites de hierba con doble cara es catastrófico: has doblado las llamadas de dibujo sin ganar nada, porque en una hoja plana no hay cara trasera que mezclar con la delantera. Para eso existe forceSinglePass: true, que desactiva la doble pasada. Es una de las optimizaciones menos conocidas y de mayor impacto en escenas con vegetación: en una escena con miles de quads con alfa, activar forceSinglePass puede reducir a la mitad el número de llamadas de dibujo sin ningún cambio visual perceptible.

Las que fuerzan una recompilación

Cambiar la mayoría de las propiedades de un material es gratis: se traducen en un uniform que se actualiza antes de dibujar. Pero unas cuantas se traducen en un #define dentro del shader, y cambiarlas obliga a recompilar el programa, lo que cuesta entre unos milisegundos y varias décimas de segundo y produce un tirón muy visible.

Entran en esa categoría: asignar o quitar cualquier textura (map, normalMap, aoMap, todas), cambiar vertexColors, flatShading, transparent, side, fog, alphaToCoverage, dithering, premultipliedAlpha, y cruzar el cero en alphaTest, clearcoat, transmission, sheen, iridescence, anisotropy o dispersion.

Dos reglas prácticas salen de ahí. La primera: si vas a mostrar y ocultar una textura, no la asignes y desasignes; ponla desde el principio y modula su efecto con un uniform, por ejemplo aoMapIntensity a cero. La segunda: si vas a animar una de las capas del material físico desde cero, arráncala en un valor mínimo distinto de cero.

Y si cambias una propiedad que Three.js no vigila, como el contenido de defines, tienes que avisar tú:

material.defines.MI_VARIANTE = '';
material.needsUpdate = true;   // incrementa version y fuerza la recompilacion

Un último apunte que ahorra fugas de memoria: material.dispose() no se llama solo al quitar el objeto de la escena, y libera menos de lo que su nombre promete. Su implementación entera es una línea que emite un evento dispose; el renderer lo escucha y suelta el programa compilado y el estado asociado a ese material. Las texturas no entran en el trato, y es deliberado: una textura se comparte entre materiales con frecuencia y liberarla desde uno rompería a los demás. Hay que recorrer las propiedades del material y llamar a dispose() sobre cada textura por separado, que es lo que verás hecho bien en dispose de verdad. Si generas materiales dinámicamente, cada uno que descartes sin dispose se queda en memoria de vídeo hasta que se cierre la pestaña, y cada textura suya que no liberes aparte, también.