wandres.dev
TSL III · Node materials

Los slots de superficie

colorNode, emissiveNode, roughnessNode, metalnessNode, opacityNode y normalNode: qué sustituye cada uno, con qué tipo y en qué espacio.

⏱ 18 min

Los seis slots que describen la superficie son los que vas a usar el noventa por ciento de las veces. Cada uno sustituye exactamente un parámetro de entrada del modelo de iluminación, y para usarlos bien hay que saber tres cosas de cada uno: qué tipo espera, en qué rango y en qué espacio. Equivocarse en cualquiera de las tres produce un resultado que compila y se ve mal.

🎯 Al terminar esta lección sabrás
  • Asignar los seis slots de superficie con el tipo y rango correctos.
  • Explicar en qué espacio de color trabaja colorNode.
  • Combinar varios slots derivados de una misma máscara.
  • Reconocer qué slot corresponde a cada síntoma visual.

Color y emisión

colorNode

Sustituye el color difuso base, el que en el material clásico controla la propiedad color multiplicada por map.

import { color, uv, mix, texture } from 'three/tsl';

material.colorNode = color( 0xff6622 );
material.colorNode = mix( color( 0x1f4fd8 ), color( 0xff7a3d ), uv().y );
material.colorNode = texture( miTextura ).mul( color( 0xffddaa ) );

Acepta vec3 o vec4; internamente se convierte a vec4. Si le das cuatro componentes, la cuarta se usa como opacidad y se combina con la del material, así que un vec4 con alfa distinto de uno afecta a la transparencia sin tocar opacityNode.

Y el detalle que más importa: el valor está en espacio lineal. color( 0xff6622 ) hace la conversión desde sRGB por ti, porque el constructor de Color de Three.js respeta el sistema de gestión de color. Pero un valor construido a mano con vec3( 1.0, 0.4, 0.13 ) es lineal directamente, y saldrá más claro de lo que esperas si lo elegiste mirando un selector de color.

emissiveNode

La luz que el material emite por sí mismo. No se ilumina ni recibe sombras: se suma al final.

import { color, oscSine, uv, smoothstep } from 'three/tsl';

// Una banda que late.
const banda = smoothstep( 0.48, 0.5, uv().y ).mul( smoothstep( 0.52, 0.5, uv().y ) );
material.emissiveNode = color( 0x33ffcc ).mul( banda ).mul( oscSine().mul( 0.5 ).add( 0.5 ) );

Espera un vec3 en espacio lineal, y puede superar el valor uno. Ese es precisamente su interés: un emisivo de valor cinco es lo que hace que un UnrealBloomPass con umbral alto lo detecte como fuente de luz y lo haga resplandecer, mientras que el resto de la escena permanece limpia. La combinación de emisivo por encima de uno con buffers de media precisión y tone mapping es la receta estándar de los efectos luminosos.

Recuerda que solo MeshStandardNodeMaterial y sus descendientes lo declaran, así que en un material básico hay que asignarlo como propiedad, no en el constructor.

Rugosidad y metalidad

Los dos parámetros del modelo PBR. Ambos esperan un float en el rango de cero a uno.

import { texture, uv, mx_fractal_noise_float, positionLocal, clamp } from 'three/tsl';

// Rugosidad desde un canal de textura.
material.roughnessNode = texture( mapaORM ).g;

// Metalidad procedural: metal en las zonas bajas, dielectrico arriba.
material.metalnessNode = clamp( positionLocal.y.negate().mul( 2 ), 0, 1 );

// Rugosidad ruidosa, para romper el aspecto de plastico perfecto.
material.roughnessNode = mx_fractal_noise_float( positionLocal.mul( 8 ), 3 )
  .mul( 0.5 ).add( 0.5 )
  .mul( 0.4 ).add( 0.15 );

Ese último ejemplo ilustra el patrón habitual: el ruido devuelve un rango centrado en cero, se lleva a cero-uno, y luego se comprime al rango de rugosidad que de verdad quieres, aquí entre 0.15 y 0.55. Dejar la rugosidad recorrer todo el rango casi nunca queda bien.

Sobre metalness conviene recordar la teoría: en el modelo PBR es un interruptor conceptual, no un mando. Los valores intermedios solo tienen sentido como transición espacial entre una zona metálica y otra que no lo es, por ejemplo pintura desconchada sobre acero. Una superficie con metalness a 0.5 uniforme no corresponde a ningún material real.

Opacidad y normal

opacityNode y alphaTestNode

import { uv, smoothstep, length } from 'three/tsl';

// Un disco que se desvanece en el borde.
material.transparent = true;
material.opacityNode = length( uv().sub( 0.5 ) ).smoothstep( 0.5, 0.3 );

opacityNode espera un float de cero a uno. Y hay un requisito que no está en el nodo sino en el material: material.transparent = true sigue siendo necesario, porque es lo que decide el orden de dibujado y el modo de mezcla. Un opacityNode sobre un material opaco no hace nada visible.

Para recortes duros, alphaTestNode es más barato que la transparencia porque no requiere ordenar:

material.alphaTestNode = float( 0.5 );
material.opacityNode = texture( mascara ).r;

normalNode

Sustituye la normal de la superficie. Es el más delicado de los seis, porque el espacio importa.

import { normalMap, texture, normalLocal, normalize } from 'three/tsl';

// Desde un mapa de normales en espacio tangente.
material.normalNode = normalMap( texture( mapaNormal ) );

// Aplanado deliberado: normal geometrica sin mapas.
material.normalNode = normalLocal;

El nodo normalMap() hace el trabajo de convertir del espacio tangente al espacio de vista, que es lo que el modelo de iluminación espera. Si le das directamente el color de la textura sin pasar por normalMap(), estarás alimentando el modelo con un vector en el rango cero-uno que además está en otro espacio, y el resultado es un sombreado sin sentido que sin embargo no da ningún error.

Hay también bumpMap() para mapas de altura, que deriva la normal de las pendientes de la textura, y normalFlat para obtener la normal geométrica reconstruida por derivadas de pantalla, útil para conseguir un facetado duro sin duplicar vértices.

Componer varios slots desde una máscara

El patrón que hace que todo esto valga la pena es derivar varias propiedades de un mismo cálculo. Un ejemplo de óxido que afecta a color, rugosidad y metalidad a la vez:

import {
  color, mix, float, positionLocal, mx_fractal_noise_float, smoothstep
} from 'three/tsl';

// Una unica mascara, calculada una vez y compartida.
const oxido = mx_fractal_noise_float( positionLocal.mul( 4 ), 4 )
  .mul( 0.5 ).add( 0.5 )
  .smoothstep( 0.45, 0.65 )
  .toVar( 'oxido' );

const material = new THREE.MeshStandardNodeMaterial();

material.colorNode     = mix( color( 0x8899aa ), color( 0x6b3a1f ), oxido );
material.roughnessNode = mix( float( 0.15 ), float( 0.9 ), oxido );
material.metalnessNode = mix( float( 1.0 ), float( 0.0 ), oxido );

El .toVar( 'oxido' ) no es opcional aquí. Sin él, el ruido fractal de cuatro octavas —que no es barato— podría acabar evaluado tres veces, una por cada slot que lo usa. Con él, se calcula una vez y las tres mezclas leen la misma variable.

💡
Cada síntoma apunta a un slot

Si el objeto se ve del color equivocado pero la luz responde bien, es colorNode. Si brilla donde no debe o no brilla donde debe, es roughnessNode. Si parece plástico cuando debería ser metal, o el reflejo del entorno tiene el color equivocado, es metalnessNode. Si el relieve se ve pero la luz no lo acompaña, es normalNode. Y si algo brilla sin que ninguna luz lo alcance, es emissiveNode. Esa tabla mental ahorra mucho tiempo de depuración.

Los slots son las entradas del modelo, y por eso el catálogo es corto

Hay una pregunta que aclara toda la lógica de este sistema: ¿por qué estos seis slots y no otros? La respuesta no está en el diseño de la API sino en la física. Un modelo PBR es, en el fondo, una función con una firma fija: dado un conjunto de parámetros que describen la superficie en un punto —su albedo, su rugosidad, si es conductor o dieléctrico, hacia dónde mira, cuánta luz emite por sí misma— y dado el conjunto de luces incidentes, devuelve la radiancia saliente hacia la cámara. Los slots son, uno a uno, los parámetros de esa función. No son puntos de extensión elegidos por comodidad; son las entradas del modelo, y por eso la lista es corta y por eso es estable: no cambia porque la ecuación de renderizado no cambia. Los diecisiete slots extra de MeshPhysicalNodeMaterial son exactamente los parámetros que añaden sus capas adicionales, ni uno más. Y esa observación tiene una consecuencia práctica muy concreta: si lo que quieres hacer se puede expresar como “esta superficie tiene esta propiedad aquí”, hay un slot para ello y todo va a funcionar sin sorpresas, incluidas las sombras, la iluminación de entorno y el tone mapping, porque solo estás rellenando un parámetro. Pero si lo que quieres hacer es cambiar cómo se combina la luz con la superficie —un sombreado de celdas, un modelo de dispersión propio, una ley de reflexión inventada— ningún slot de superficie te sirve, porque no estás describiendo la superficie sino sustituyendo la función. Para eso están lightsNode y outputNode, que operan a otro nivel y con otras garantías. Saber en cuál de los dos lados cae lo que quieres hacer es la decisión que evita horas de pelearse con el slot equivocado.