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.
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.
- 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
outputNodeylightsNoderespecto 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.
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.