wandres.dev
SHADERS VI · Extender los materiales de Three

Un detalle propio sin renunciar al PBR

Por qué reescribir un ShaderMaterial para cambiar un detalle sale carísimo, qué hay realmente dentro de un MeshStandardMaterial, y las tres salidas posibles.

⏱ 16 min

El momento llega siempre igual. Tienes una escena con iluminación basada en imagen, sombras, tone mapping y color management funcionando, y necesitas una sola cosa que el material no ofrece: que los vértices ondulen, que el color salga de un patrón procedural, que las caras que miran a la cámara se desvanezcan. La tentación es escribir un ShaderMaterial y hacerlo a mano. Es casi siempre la decisión equivocada, y conviene entender exactamente por qué antes de tomarla.

🎯 Al terminar esta lección sabrás
  • Enumerar lo que un ShaderMaterial te obliga a reimplementar si quieres paridad con el material integrado.
  • Leer la estructura del shader de MeshStandardMaterial y localizar sus fases.
  • Elegir entre ShaderMaterial, onBeforeCompile y los node materials según el caso.
  • Justificar por qué la técnica de este nivel sigue existiendo pese a sus defectos.

Lo que hay dentro de un material integrado

MeshStandardMaterial no tiene un shader propio. Su shaderID es physical, igual que MeshPhysicalMaterial, y ambos resuelven al mismo par de fuentes GLSL en ShaderLib.physical. Esas fuentes no son un bloque de código: son una lista ordenada de directivas #include que el motor expande justo antes de compilar.

El main() del fragment shader de meshphysical es literalmente esto, en este orden:

void main() {
	vec4 diffuseColor = vec4( diffuse, opacity );
	#include <clipping_planes_fragment>

	ReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );
	vec3 totalEmissiveRadiance = emissive;

	#include <logdepthbuf_fragment>
	#include <map_fragment>
	#include <color_fragment>
	#include <alphamap_fragment>
	#include <alphatest_fragment>
	#include <alphahash_fragment>
	#include <roughnessmap_fragment>
	#include <metalnessmap_fragment>
	#include <normal_fragment_begin>
	#include <normal_fragment_maps>
	#include <clearcoat_normal_fragment_begin>
	#include <clearcoat_normal_fragment_maps>
	#include <emissivemap_fragment>

	// accumulation
	#include <lights_physical_fragment>
	#include <lights_fragment_begin>
	#include <lights_fragment_maps>
	#include <lights_fragment_end>

	// modulation
	#include <aomap_fragment>

	vec3 totalDiffuse = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse;
	vec3 totalSpecular = reflectedLight.directSpecular + reflectedLight.indirectSpecular;

	#include <transmission_fragment>

	vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;

	#include <opaque_fragment>
	#include <tonemapping_fragment>
	#include <colorspace_fragment>
	#include <fog_fragment>
	#include <premultiplied_alpha_fragment>
	#include <dithering_fragment>
}

Lee la lista despacio, porque es el mapa de todo lo que estarías tirando a la basura. Antes del bloque de acumulación hay quince chunks que solo se ocupan de construir los parámetros de la superficie: leer el mapa difuso, aplicar el color de vértice, resolver el alpha test, muestrear rugosidad y metalidad, construir la normal desde el normal map y desde el bump map, y hacer lo mismo otra vez para el clearcoat. El bloque de acumulación resuelve la ecuación de renderizado para cada luz de la escena, más la contribución indirecta del entorno. Y los cinco últimos chunks son la salida: tone mapping, conversión al espacio de color de salida, niebla, alpha premultiplicado y dithering.

Añade a eso el vertex shader, con su cadena de beginnormal_vertex, skinbase_vertex, skinnormal_vertex, defaultnormal_vertex, begin_vertex, morphtarget_vertex, skinning_vertex, displacementmap_vertex y project_vertex, y tienes la respuesta a por qué escribir un ShaderMaterial “equivalente” es una tarea de semanas que además hay que rehacer cada vez que la librería cambia.

Las tres salidas

Ante la necesidad de un detalle propio hay exactamente tres caminos en r184, y cada uno paga un precio distinto.

Camino Qué te da Qué te cuesta
ShaderMaterial control total, código legible, cero sorpresas reimplementar PBR, sombras, IBL, tone mapping, color management
onBeforeCompile PBR completo con una inyección quirúrgica sustitución de cadenas frágil, dependiente de versión, difícil de depurar
Node materials y TSL PBR completo, composición declarativa, dos backends API distinta, three/webgpu en lugar de three, ecosistema más joven

El primero es correcto cuando el material no quiere ser PBR. Un efecto de holograma, una malla de puntos, un shader de agua estilizado, un fondo procedural: si no vas a necesitar sombras recibidas ni reflejos del entorno, el ShaderMaterial es más limpio, más rápido de compilar y mucho más fácil de mantener. Elegirlo por defecto es un error; descartarlo por defecto también.

El segundo es de lo que trata este nivel. Es la técnica que ha sostenido la mitad de los efectos bonitos de la web durante una década, y sigue siendo la única disponible si tu proyecto usa WebGLRenderer clásico con materiales integrados.

El tercero es hacia donde va la librería, y no lo digo yo. El propio Material.js de r184 lo dice en la documentación de onBeforeCompile:

This method can only be used when rendering with WebGLRenderer. The
recommended approach when customizing materials is to use WebGPURenderer
with the new Node Material system and TSL.

Por qué esta técnica sigue existiendo

Si la recomendación oficial es otra, cabe preguntarse por qué dedicar cinco lecciones a onBeforeCompile. Tres razones.

La primera es que el código que existe ya está escrito así. Cualquier proyecto Three.js con más de dos años tiene inyecciones de este tipo, y entenderlas es requisito para tocarlas. La segunda es que el sistema de chunks no desaparece: es la estructura interna del renderer WebGL, y saber leerla te dice cosas sobre el coste real de cada opción del material que no están escritas en ninguna documentación. La tercera es que la migración a node materials no es un interruptor: implica cambiar el punto de entrada de three a three/webgpu y, si conservas el WebGLRenderer clásico, instalar un adaptador explícito. Mientras esa migración no ocurra, esta es la técnica que hay.

El sistema de chunks no es modularidad, es una tabla de decisiones

Es tentador leer la lista de #include como si fuera una arquitectura modular bien pensada: piezas reutilizables, responsabilidad única, todo eso. No lo es, y verlo como lo que realmente es cambia cómo lo usas. El sistema de chunks resuelve un problema muy concreto de GLSL: no hay ramificación gratis en la GPU y no hay compilación separada. Un shader que soportara todas las combinaciones de opciones de MeshPhysicalMaterial mediante if en tiempo de ejecución sería catastróficamente lento, porque el hardware ejecuta ambas ramas de un condicional divergente. Y no puedes compilar cada característica por separado y enlazarlas, porque WebGL no tiene enlazado de módulos. La única salida es generar el texto fuente exacto para cada combinación antes de compilar, y eso es un preprocesador. Los chunks son las celdas de esa tabla, y los #ifdef USE_MAP que hay dentro de cada uno son las condiciones. Cuando entiendes eso, dos cosas encajan de golpe. La primera: el motivo por el que cada permutación de opciones genera un programa nuevo, con su compilación de decenas de milisegundos, y por qué una escena con muchos materiales ligeramente distintos tarda tanto en arrancar aunque el frame luego vaya fino. La segunda, más importante: el punto de inserción que elijas no es una preferencia estética, es una posición en una secuencia de dependencias de datos. transformed no existe antes de begin_vertex; reflectedLight no está poblado antes de lights_fragment_end; outgoingLight no existe hasta después. Elegir mal el punto no produce un resultado feo, produce un error de compilación o, peor, un valor basura. La lista de includes de la sección anterior no es documentación de contexto: es el grafo de dependencias que vas a tener que respetar.