wandres.dev
TSL III · Node materials

La familia de node materials

Las diecisiete clases que trae r184, qué relación tienen con los materiales clásicos, y el mecanismo por el que un slot vacío no cuesta nada.

⏱ 16 min

Un node material no es un material distinto: es el mismo material de siempre con puntos de extensión. Acepta los mismos parámetros de constructor, produce la misma imagen si no tocas nada, y expone una colección de propiedades que, cuando les asignas un nodo, sustituyen exactamente una parte del cálculo dejando el resto intacto. Ese diseño es lo que resuelve, sin trucos, el problema del que arrancó el nivel treinta y cinco.

🎯 Al terminar esta lección sabrás
  • Enumerar las clases de node material que existen en r184 y su equivalencia clásica.
  • Construir un node material con los mismos parámetros que su versión clásica.
  • Explicar el mecanismo por el que un slot sin asignar no altera el shader.
  • Distinguir qué slots viven en la clase base y cuáles en cada subclase.

three/webgpu exporta diecisiete clases de material de nodos:

Node material Equivalente clásico
NodeMaterial la base, sin equivalente directo
MeshBasicNodeMaterial MeshBasicMaterial
MeshLambertNodeMaterial MeshLambertMaterial
MeshPhongNodeMaterial MeshPhongMaterial
MeshStandardNodeMaterial MeshStandardMaterial
MeshPhysicalNodeMaterial MeshPhysicalMaterial
MeshToonNodeMaterial MeshToonMaterial
MeshNormalNodeMaterial MeshNormalMaterial
MeshMatcapNodeMaterial MeshMatcapMaterial
MeshSSSNodeMaterial sin equivalente, dispersión subsuperficial
PointsNodeMaterial PointsMaterial
SpriteNodeMaterial SpriteMaterial
LineBasicNodeMaterial LineBasicMaterial
LineDashedNodeMaterial LineDashedMaterial
Line2NodeMaterial el material de líneas gruesas de los addons
ShadowNodeMaterial ShadowMaterial
VolumeNodeMaterial sin equivalente, renderizado volumétrico

La correspondencia con los nombres clásicos es literal: añade Node antes de Material. Y el constructor acepta el mismo objeto de parámetros:

import * as THREE from 'three/webgpu';

const material = new THREE.MeshPhysicalNodeMaterial( {
  color: 0x0000ff,
  metalness: 0.9,
  roughness: 0.5,
  clearcoat: 1.0,
  clearcoatRoughness: 0.1,
  normalMap: mapaNormal,
  normalScale: new THREE.Vector2( 0.15, 0.15 )
} );

Migrar un proyecto sin tocar shaders es, por tanto, una sustitución de nombres. Si el material clásico funcionaba, el de nodos funciona igual.

Dos ausencias que conviene notar. MeshDepthNodeMaterial y MeshDistanceNodeMaterial no existen: en el sistema de nodos, la generación de shadow maps y de profundidad se resuelve por otra vía, con los slots de sombra de la clase base. Y InstancedPointsNodeMaterial tampoco está en el catálogo de r184.

Los slots de la clase base

NodeMaterial declara veintitrés propiedades que terminan en Node, todas inicializadas a null. Las que se usan a diario son estas:

Slot Qué sustituye
colorNode el color difuso base
positionNode la posición del vértice
normalNode la normal de la superficie
opacityNode la opacidad
alphaTestNode el umbral del alpha test
envNode la contribución del entorno
aoNode la oclusión ambiental
depthNode la profundidad escrita
outputNode el color final, tras iluminación y salida
fragmentNode el fragment shader entero
vertexNode el vertex shader entero

Y las de menor uso: lightsNode, backdropNode, backdropAlphaNode, maskNode, maskShadowNode, geometryNode, mrtNode, contextNode, castShadowNode, receivedShadowNode, castShadowPositionNode y receivedShadowPositionNode.

Cuidado con los dos últimos, porque el nombre corto que uno espera —shadowPositionNodeno existe. Son dos slots distintos, uno para cuando el objeto proyecta sombra y otro para cuando la recibe.

Los slots de cada subclase

Cada material añade lo suyo. MeshStandardNodeMaterial declara tres:

emissiveNode, metalnessNode, roughnessNode

MeshPhysicalNodeMaterial hereda esos tres y añade diecisiete más, uno por cada capa del modelo físico:

clearcoatNode, clearcoatRoughnessNode, clearcoatNormalNode,
sheenNode, sheenRoughnessNode,
iridescenceNode, iridescenceIORNode, iridescenceThicknessNode,
specularIntensityNode, specularColorNode, iorNode,
transmissionNode, thicknessNode,
attenuationDistanceNode, attenuationColorNode,
dispersionNode, anisotropyNode

Y MeshBasicNodeMaterial no declara ninguno propio: se conforma con los de la base, que es coherente con que su modelo de iluminación sea el más simple.

ℹ️
emissiveNode se lee siempre, pero solo lo declara Standard

El método setupLighting() de la clase base lee emissiveNode sea cual sea el material, así que asignarlo funciona en cualquiera. Pero solo MeshStandardNodeMaterial y sus descendientes lo declaran en el constructor, y Material.setValues avisa por consola cuando le pasas una clave que la instancia no tiene definida. La consecuencia práctica: asígnalo como propiedad después de construir, no dentro del objeto del constructor, si el material no es Standard o Physical.

Por qué un slot vacío no cuesta nada

El mecanismo es una línea repetida por todo NodeMaterial.js, siempre con la misma forma:

let colorNode = this.colorNode ? vec4( this.colorNode ) : materialColor;
const opacityNode = this.opacityNode ? float( this.opacityNode ) : materialOpacity;
const metalnessNode = this.metalnessNode ? float( this.metalnessNode ) : materialMetalness;
let roughnessNode = this.roughnessNode ? float( this.roughnessNode ) : materialRoughness;

Cada slot es un ternario evaluado en tiempo de construcción del grafo, en JavaScript. Si el slot es null, se usa el accesor por defecto, que lee la propiedad correspondiente del material. Si tiene un nodo, se usa ese.

No hay ninguna rama en el shader generado. No hay coste en tiempo de ejecución. El material sin slots asignados produce exactamente el mismo grafo que produciría si el sistema de slots no existiera. Y un material con un slot asignado produce un grafo donde ese subárbol está sustituido, y nada más.

Compara esto con lo que hacía falta en el sistema clásico para conseguir lo mismo: encontrar el chunk correcto, sustituir una cadena, declarar la clave de caché, y esperar que la próxima versión no renombre nada.

Los slots son puntos de extensión declarados, y eso es la diferencia entre una API y una intrusión

Vale la pena poner nombre a lo que ha cambiado entre el nivel treinta y cinco y este, porque es un principio de diseño de librerías que trasciende los gráficos. onBeforeCompile es un mecanismo de extensión intrusiva: no hay contrato, hay acceso al interior. La librería te entrega su código fuente y tú lo modificas, lo cual significa que cualquier detalle interno —el nombre de un chunk, el nombre de una variable, el orden de dos líneas— se convierte de facto en API pública, sin que sus autores lo hayan decidido ni puedan saberlo. El resultado es que la librería no puede refactorizar nada sin romper a alguien, y que tú no puedes actualizar sin revisar. Los slots son extensión declarada: los autores han enumerado explícitamente los puntos donde admiten sustitución, les han dado nombre, tipo y semántica, y se han comprometido a mantenerlos. Todo lo que hay entre un slot y otro sigue siendo suyo y pueden reescribirlo entero mañana sin que tu código se entere. Fíjate en la asimetría de poder que eso corrige: con el modelo intrusivo, cada usuario que inyecta código congela un trozo de la implementación; con el declarado, la superficie congelada es exactamente la que los autores eligieron y ni un carácter más. Ahora bien, la extensión declarada tiene un coste, y es honesto reconocerlo: solo puedes hacer lo que alguien previó. Si necesitas sustituir algo para lo que no hay slot, el sistema de nodos no tiene un equivalente de replace(). Ese es el compromiso real de este diseño, y la razón de que la lista de slots de MeshPhysicalNodeMaterial sea tan larga: cada uno de esos diecisiete nombres es alguien que necesitó tocar una capa concreta y consiguió que le abrieran la puerta por la vía correcta, en lugar de forzarla.