wandres.dev
SHADERS VI · Extender los materiales de Three

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.

⏱ 18 min

Hay un bug que aparece siempre en el mismo momento: cuando duplicas un material inyectado para hacer una variante y las dos versiones salen idénticas en pantalla. El GLSL es distinto, lo has comprobado. El material es distinto, lo has comprobado. Y sin embargo las dos mallas se ven igual. La explicación está en una función de una línea que casi nadie sobrescribe y que decide, en la práctica, si tu shader llega a compilarse.

🎯 Al terminar esta lección sabrás
  • Explicar cómo se construye la clave de caché de un programa en WebGLRenderer.
  • Escribir un customProgramCacheKey correcto para una inyección parametrizada.
  • Reconocer el síntoma de una clave de caché insuficiente.
  • Traducir una inyección típica a su equivalente 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.

💡
Si puedes usar defines, úsalos

Una variante que se puede expresar con material.defines = { MODO_SEPIA: '' } y un #ifdef dentro del GLSL inyectado no necesita customProgramCacheKey, porque los defines ya forman parte de la clave con nombre y valor. Es menos código, menos que recordar y menos que romper. Reserva la clave personalizada para lo que de verdad no se puede expresar como un define, como un bucle desenrollado con un número de iteraciones variable.

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.

La caché de programas explica el arranque de tu escena mejor que cualquier profiler

Hay un síntoma que todo el mundo ha visto y casi nadie diagnostica: la escena carga, los modelos aparecen, y durante los dos primeros segundos el frame rate es horroroso, con tirones que van desapareciendo hasta estabilizarse. La intuición dice que es la GPU calentándose, o el garbage collector, o las texturas subiéndose. Casi siempre es compilación de programas, y la clave de caché es el mapa exacto de cuántos vas a pagar. Cada permutación distinta que aparece en escena por primera vez cuesta entre diez y varios cientos de milisegundos de compilación y enlazado sincrónicos, en el hilo principal, dentro del frame que la descubre. Piensa qué entra en la clave: el número de luces de cada tipo. Eso significa que añadir la sexta luz puntual a una escena recompila todos los materiales que la reciben, en caliente, a mitad de animación. Los defines entran también, así que dos instancias del mismo modelo con flatShading distinto son dos programas. Y tu customProgramCacheKey, por supuesto: si la escribes demasiado específica —incluyendo, digamos, el color del tinte convertido a cadena—, has convertido una escena de tres programas en una de trescientos, y el arranque pasa de un tirón a una eternidad. La conclusión práctica es contraintuitiva y vale para todo Three.js, con inyecciones o sin ellas: la clave de caché debe ser lo más gruesa que la corrección permita, y ni un carácter más. Todo lo que pueda ser un uniform debe ser un uniform, porque los uniforms no entran en la clave y se cambian gratis. Solo lo que de verdad genera texto GLSL distinto merece un programa distinto. Cuando entiendes eso, customProgramCacheKey deja de ser un trámite y se convierte en la palanca con la que decides el coste de arranque de tu escena.

⚔️ Rompe y arregla la caché
  1. Crea dos materiales con el mismo onBeforeCompile parametrizado por clausura y comprueba que se ven idénticos.
  2. Añade customProgramCacheKey y verifica que ahora difieren.
  3. Sustituye la parametrización por un define y elimina la clave personalizada.
  4. Cuenta cuántos programas compila tu escena instrumentando WebGLProgram con un contador.
  5. Añade una luz puntual en caliente y mide el tirón del frame en el que ocurre.