wandres.dev
TSL III · Node materials

Reemplazar sin perder el PBR

Los accesores material* que devuelven el valor por defecto de cada slot, cómo modificar en lugar de sustituir, y los slots que operan por encima del modelo de iluminación.

⏱ 19 min

Asignar un slot lo sustituye por completo, y muchas veces eso no es lo que quieres: quieres partir de lo que el material ya calculaba y modificarlo. Para eso existe una familia de accesores que devuelven exactamente el valor por defecto de cada slot, y componer con ellos es la diferencia entre un material que reemplaza y uno que extiende.

🎯 Al terminar esta lección sabrás
  • Usar los accesores material* para leer el valor por defecto de un slot.
  • Componer una modificación en lugar de una sustitución.
  • Leer propiedades arbitrarias del material con materialReference.
  • Situar outputNode y lightsNode respecto al modelo de iluminación.

Los accesores material

Recuerda el patrón que usa NodeMaterial internamente:

let colorNode = this.colorNode ? vec4( this.colorNode ) : materialColor;

Ese materialColor de la rama por defecto es un nodo exportado de three/tsl, y puedes usarlo tú. Es decir, si escribes:

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

material.colorNode = mix( materialColor, color( 0xff0000 ), 0.25 );

estás diciendo “toma lo que ibas a calcular de todas formas y tíñelo un cuarto de rojo”. La textura difusa, el color base y el color de vértice siguen ahí; solo has añadido una capa encima.

MaterialNode exporta treinta y ocho de estos accesores. Los que más se usan:

Accesor Devuelve
materialColor el color difuso resuelto, con map aplicado
materialOpacity la opacidad, con alphaMap aplicado
materialRoughness la rugosidad, con roughnessMap
materialMetalness la metalidad, con metalnessMap
materialEmissive el emisivo, con emissiveMap
materialNormal la normal, con normalMap y bumpMap
materialAO la oclusión, con aoMap
materialAlphaTest el umbral de alpha test
materialIOR el índice de refracción
materialSpecularColor, materialSpecularIntensity el especular
materialClearcoat, materialClearcoatRoughness, materialClearcoatNormal la capa de barniz
materialSheen, materialSheenRoughness el brillo textil
materialIridescence, materialIridescenceIOR, materialIridescenceThickness la iridiscencia
materialTransmission, materialThickness, materialAttenuationColor, materialAttenuationDistance la transmisión
materialAnisotropy, materialAnisotropyVector la anisotropía
materialDispersion la dispersión
materialRotation, materialPointSize sprites y puntos
materialLineScale, materialLineDashSize, materialLineGapSize, materialLineWidth, materialLineDashOffset líneas
materialLightMap el mapa de luz

Y tres más que viven en otro módulo pero se importan igual: materialRefractionRatio, materialEnvIntensity y materialEnvRotation.

Lo importante de estos accesores no es la lista sino lo que significan: incluyen el trabajo de los mapas. materialRoughness no es el número material.roughness, es ese número multiplicado por el canal verde de roughnessMap si lo hay. Componer sobre él conserva todo el flujo de trabajo de texturas.

Un ejemplo de composición

Un material que envejece: mantiene su textura y sus mapas, pero oscurece las cavidades y añade rugosidad donde la geometría es cóncava.

import {
  materialColor, materialRoughness, materialAO,
  color, mix, float, positionLocal, mx_fractal_noise_float, clamp
} from 'three/tsl';

const suciedad = mx_fractal_noise_float( positionLocal.mul( 6 ), 4 )
  .mul( 0.5 ).add( 0.5 )
  .smoothstep( 0.4, 0.75 )
  .toVar( 'suciedad' );

const material = new THREE.MeshStandardNodeMaterial( {
  map: mapaDifuso,
  roughnessMap: mapaRugosidad,
  aoMap: mapaOclusion,
  roughness: 0.6
} );

// Oscurece sobre lo que ya habia, sin perder la textura.
material.colorNode = mix( materialColor, color( 0x2b2118 ), suciedad.mul( 0.7 ) );

// Sube la rugosidad partiendo de la que dice el mapa.
material.roughnessNode = clamp( materialRoughness.add( suciedad.mul( 0.35 ) ), 0, 1 );

Sin los accesores, ese mismo efecto obligaría a muestrear las tres texturas a mano y a reimplementar cómo se combinan con los escalares del material, que es exactamente el trabajo que el material ya hace bien.

materialReference, para lo que no tiene accesor

Cuando necesitas leer una propiedad del material para la que no hay accesor —incluidas propiedades tuyas— existe un nodo genérico:

import { materialReference } from 'three/tsl';

// Una propiedad propia del material.
material.miIntensidad = 0.4;

const intensidad = materialReference( 'miIntensidad', 'float' );
material.emissiveNode = color( 0x33ffcc ).mul( intensidad );

La ventaja sobre un uniform es que el valor pertenece al material, así que si clonas el material o tienes varios que comparten el mismo grafo de nodos, cada uno lleva su valor. Un uniform es compartido por todos los materiales que referencien ese nodo; una referencia de material es por instancia.

Por encima del modelo: outputNode y lightsNode

Los slots de superficie describen entradas del modelo de iluminación. Hay dos que operan a otro nivel.

outputNode sustituye el color final, después de que la iluminación se haya resuelto entera. Es el sitio para efectos que no son propiedades de la superficie: un ajuste de color, un desvanecido, una mezcla con otra cosa.

import { output, saturation, screenUV, mix, vec4 } from 'three/tsl';

// Desatura hacia los bordes de la pantalla.
const radio = screenUV.sub( 0.5 ).length();
material.outputNode = vec4( saturation( output.rgb, radio.oneMinus() ), output.a );

output es el nodo que representa el color ya calculado, y es lo que hace posible modificar en lugar de sustituir, igual que los accesores material* en los slots de superficie.

lightsNode sustituye el conjunto de luces que el material considera. Su uso más habitual no es inventar un modelo de iluminación sino restringir qué luces afectan a qué objetos, que es algo que Three.js no permite de otra forma:

import { lights } from 'three/tsl';

// Este material solo se ilumina con estas dos luces.
material.lightsNode = lights( [ luzPrincipal, luzRelleno ] );

Y hay dos slots más que rara vez se necesitan pero conviene saber que existen: castShadowNode, que sustituye lo que el material escribe al generar su shadow map, y receivedShadowNode, que recibe el factor de sombra calculado y permite modificarlo antes de que se aplique.

// Sombras teñidas de azul en lugar de simplemente oscuras.
material.receivedShadowNode = Fn( ( [ sombra ] ) => {
  return sombra.mix( color( 0x1a2a55 ), 1 );
} );

Fíjate en el orden de .mix() una vez más: el receptor sombra es el factor, y cuando vale uno el resultado es el segundo argumento. Con sombra completa —valor cero— se devuelve el color azulado.

Los accesores por defecto son lo que convierte un sistema de slots en un sistema componible

Un sistema de puntos de extensión donde solo puedes sustituir tiene un techo bajísimo, y es un error de diseño que se repite constantemente en librerías de todo tipo. El síntoma es siempre el mismo: la extensión funciona bien para un usuario y se rompe en cuanto hay dos. Si colorNode solo permitiera reemplazar, entonces un plugin que añade suciedad y otro que añade un tinte serían incompatibles, porque el segundo pisaría al primero, y la única salida sería que sus autores se coordinaran. Lo que hace que aquí no ocurra es la existencia de materialColor: el valor por defecto es accesible como valor, no solo como comportamiento implícito cuando no haces nada. Esa propiedad convierte cada slot de un interruptor en un punto de una cadena de transformaciones, y de golpe la composición funciona sin coordinación: cada capa lee lo que hay, aplica lo suyo, y devuelve el resultado. material.colorNode = suciedad(tinte(materialColor)). Es el mismo principio que hace componibles los middlewares de un servidor, los decoradores de una función o los filtros de una imagen, y la condición es siempre la misma: que la extensión reciba el valor anterior en lugar de sustituirlo a ciegas. Cuando evalúes cualquier sistema de extensión, tuyo o ajeno, esa es la pregunta que separa los que escalan de los que no: ¿puedo leer lo que había antes? Si la respuesta es no, el sistema solo admite un usuario por punto, y tarde o temprano alguien va a acabar copiando y pegando el comportamiento por defecto para poder modificarlo, con todo lo que eso implica cuando el original cambie.