customProgramCacheKey y el sucesor
Cómo funciona la caché de programas de WebGLRenderer, por qué tu inyección puede acabar usando el shader de otro material, y cómo se expresa lo mismo con node materials.
Cómo se construye la clave
WebGLRenderer no compila un programa por material: compila uno por combinación de parámetros de compilación, y los comparte entre todos los materiales que produzcan la misma clave. El array que forma esa clave lleva, por este orden, el shaderID, cada nombre y valor de defines, un puñado de parámetros y booleanos del entorno de render, el espacio de color de salida y, en último lugar:
array.push( parameters.customProgramCacheKey );return array.join();La cadena resultante es la clave de un Map global:
function acquireProgram( parameters, cacheKey ) { let program = programsMap.get( cacheKey ); if ( program !== undefined ) { ++ program.usedTimes; } else { program = new WebGLProgram( renderer, cacheKey, parameters, bindingStates );Fíjate en lo que implica la rama if: cuando la clave ya existe, WebGLProgram no se construye. El texto que tu onBeforeCompile acaba de modificar se descarta sin llegar a GLSL. Ese es el bug del párrafo inicial.
La implementación por defecto de la clave es esta:
customProgramCacheKey() { return this.onBeforeCompile.toString();}El texto fuente de la función. Es una heurística sorprendentemente buena: dos materiales con callbacks escritos por separado tienen textos distintos y por tanto claves distintas. Pero falla exactamente en el caso más frecuente, que es el que hace útil la técnica: cuando el mismo callback se comporta de forma distinta según algo que captura por clausura.
// MAL: los dos materiales comparten programa.function crearMaterial( modo ) { const material = new THREE.MeshStandardMaterial(); material.onBeforeCompile = ( shader ) => { shader.fragmentShader = shader.fragmentShader.replace( '#include <map_fragment>', `#include <map_fragment> ${ modo === 'sepia' ? 'diffuseColor.rgb = vec3( dot( diffuseColor.rgb, vec3( 0.393, 0.769, 0.189 ) ) );' : 'diffuseColor.rgb = 1.0 - diffuseColor.rgb;' }` ); }; return material;}const a = crearMaterial( 'sepia' );const b = crearMaterial( 'invertido' );Las dos funciones flecha son objetos distintos, pero toString() devuelve el mismo texto para ambas: la clausura no aparece en el fuente. Misma clave, mismo programa, y el segundo material se dibuja con el shader del primero. Cuál gane depende del orden de render, que puede cambiar entre frames, así que a veces el bug parece intermitente.
La corrección es declarar la dependencia:
material.customProgramCacheKey = () => `tinte-${ modo }`;La regla se enuncia en una frase: la clave debe incluir todo lo que hace que el GLSL generado sea distinto y no esté ya en defines. Los defines entran solos, con nombre y valor; todo lo demás —clausuras, propiedades del material que lees dentro del callback, cosas que consultas del renderer— es responsabilidad tuya.
Lo mismo, con node materials
Toda la técnica de este nivel existe para responder a una pregunta: cómo cambiar una parte del material sin escribir el resto. Los node materials responden a esa misma pregunta con una asignación.
Compara. La inyección que tiñe el color difuso, en su versión completa y defendida, ocupa unas veinticinco líneas entre el callback, el ayudante de sustitución, la clave de caché y el uniform compartido. La versión con nodos es esta:
import { MeshStandardNodeMaterial } from 'three/webgpu';import { color, uniform, mix, materialColor } from 'three/tsl';const tinte = uniform( new THREE.Color( 0xff8844 ) );const material = new MeshStandardNodeMaterial( { roughness: 0.35 } );material.colorNode = mix( materialColor, tinte, 0.25 );Y la onda de vértices:
import { positionLocal, time, sin } from 'three/tsl';material.positionNode = positionLocal.add( vec3( 0, sin( positionLocal.x.mul( 3 ).add( time ) ).mul( 0.15 ), 0 ));No hay ancla que pueda desaparecer, porque no se busca ninguna cadena. No hay clave de caché que declarar, porque el grafo de nodos es la clave: el motor conoce su estructura y sabe cuándo dos materiales generan el mismo programa. No hay riesgo de que cambie el nombre de una variable interna, porque positionLocal y materialColor son API pública con documentación y compatibilidad. Y el resultado sigue siendo un MeshStandardNodeMaterial completo, con su PBR, sus sombras y su iluminación de entorno intactos.
El precio es que los node materials viven en otro punto de entrada. Se importan de three/webgpu, no de three, y su renderer natural es WebGPURenderer. Si quieres usarlos con el WebGLRenderer clásico, r184 incluye un adaptador explícito que hay que instalar a mano:
import * as THREE from 'three/webgpu';import { WebGLRenderer } from 'three';import { WebGLNodesHandler } from 'three/addons/tsl/WebGLNodesHandler.js';const renderer = new WebGLRenderer( { antialias: true } );renderer.setNodesHandler( new WebGLNodesHandler() );Ese adaptador tiene limitaciones que su propio fichero documenta: no soporta sombras VSM, ni MRT, ni transmisión, ni la pila de post-procesado de WebGPU, ni texturas de almacenamiento. Está pensado como puente de migración, no como destino.