onBeforeCompile: la firma real
Qué objeto recibe de verdad el callback, en qué momento exacto se ejecuta, qué campos puedes mutar con efecto y cuáles no sirven de nada.
La documentación de onBeforeCompile describe un objeto con tres campos. El objeto que llega en tiempo de ejecución tiene más de cuarenta, algunos de ellos referencias vivas a estructuras internas del renderer. Esa distancia entre lo documentado y lo real es la primera fuente de confusión de la técnica, y también la primera oportunidad: hay cosas que solo se pueden hacer si sabes qué te están pasando de verdad.
- Escribir un
onBeforeCompileque modifique el vertex y el fragment shader de un material integrado. - Añadir uniforms propios y actualizarlos desde el bucle de render.
- Explicar en qué momento se ejecuta el callback y cuántas veces.
- Distinguir qué campos del objeto tienen efecto al mutarlos.
La firma declarada y la que llega
En Material.js de r184 el método está declarado con la lista de parámetros comentada:
onBeforeCompile( /* shaderobject, renderer */ ) {}
La documentación describe shaderobject como {vertexShader, fragmentShader, uniforms}. En la práctica, lo que WebGLRenderer pasa es el objeto de parámetros completo que produce WebGLPrograms.getParameters(), con uniforms añadido justo antes de la llamada. Los campos que te importan son estos:
| Campo | Qué es | Mutarlo tiene efecto |
|---|---|---|
vertexShader |
fuente GLSL sin expandir, con sus #include |
sí |
fragmentShader |
ídem | sí |
defines |
referencia viva a material.defines |
sí, pero con cuidado |
uniforms |
el mapa de uniforms que usará el programa | sí |
shaderName |
el valor de material.name |
no |
shaderType |
el valor de material.type |
no |
shaderID |
la clave de ShaderLib, por ejemplo physical |
no |
Dos avisos que ahorran tiempo. El primero: el campo se llama shaderName, no name. El segundo: defines no es una copia sino la misma referencia que material.defines, así que escribir ahí muta el material y entra por su cuenta en la clave de caché del programa.
La fuente que recibes está sin expandir. Los #include todavía son directivas literales; la expansión ocurre después, dentro de WebGLProgram, con esta expresión regular:
const includePattern = /^[ \t]*#include +<([\w\d./]+)>/gm;
De ahí salen dos consecuencias prácticas. Buscar la cadena #include <map_fragment> funciona y es estable, porque la directiva sigue ahí en el texto que te dan. Y el include tiene que empezar la línea, tolerando solo espacios y tabuladores delante, así que si lo reemplazas conservando la directiva original debes mantener ese formato.
Un ejemplo completo
Vamos a añadir una onda vertical a un MeshStandardMaterial conservando su iluminación entera. Hacen falta tres cosas: un uniform de tiempo, su declaración en el vertex shader y la modificación del vértice antes de que se proyecte.
import * as THREE from 'three';
const uTime = { value: 0 };
const material = new THREE.MeshStandardMaterial( {
color: 0x88aaff,
roughness: 0.35,
metalness: 0.1
} );
material.onBeforeCompile = ( shader ) => {
// 1. El uniform entra en el mapa que usara el programa.
shader.uniforms.uTime = uTime;
// 2. Declaracion: justo detras del primer include del vertex shader.
shader.vertexShader = shader.vertexShader.replace(
'#include <common>',
`#include <common>
uniform float uTime;`
);
// 3. Modificacion: despues de begin_vertex, que es quien declara "transformed".
shader.vertexShader = shader.vertexShader.replace(
'#include <begin_vertex>',
`#include <begin_vertex>
transformed.y += sin( transformed.x * 3.0 + uTime ) * 0.15;`
);
};
const malla = new THREE.Mesh(
new THREE.PlaneGeometry( 4, 4, 128, 128 ),
material
);
function animar( ms ) {
uTime.value = ms * 0.001;
renderer.render( escena, camara );
requestAnimationFrame( animar );
}
requestAnimationFrame( animar );
El punto de inserción no es arbitrario. El chunk begin_vertex es exactamente esto:
vec3 transformed = vec3( position );
#ifdef USE_ALPHAHASH
vPosition = vec3( position );
#endif
Es quien declara la variable transformed, la que todos los chunks posteriores usan y la que project_vertex acaba multiplicando por modelViewMatrix y projectionMatrix. Escribir justo detrás significa que tu deformación pasa por el resto del pipeline como si fuera parte de la geometría: el skinning, los morph targets, el displacement map y la proyección la respetan. Escribir antes no compilaría, porque la variable aún no existe.
El ejemplo de arriba deja las normales apuntando adonde apuntaban antes de la onda. La superficie ondula pero la iluminación no acompaña, y el resultado parece una textura en movimiento, no un relieve. Corregirlo requiere recalcular la normal en el vertex shader —desplazando dos vecinos con la misma función y tomando el producto vectorial de las tangentes resultantes— o reconstruirla en el fragment shader con derivadas de pantalla. Es un problema del vertex shader, no de onBeforeCompile, pero aparece siempre en cuanto usas esta técnica para deformar.
Cuándo se ejecuta, y cuántas veces
Esta es la parte que casi todo el mundo asume mal. onBeforeCompile no se llama una vez por frame ni una vez por objeto. Se llama una vez por combinación de material y clave de caché de programa. El código de WebGLRenderer que lo decide es este:
let program = programs.get( programCacheKey );
if ( program !== undefined ) {
// salida temprana si el programa y el estado de luces son identicos
if ( materialProperties.currentProgram === program && ... ) {
updateCommonMaterialProperties( material, parameters );
return program;
}
} else {
parameters.uniforms = programCache.getUniforms( material );
material.onBeforeCompile( parameters, _this );
program = programCache.acquireProgram( parameters, programCacheKey );
programs.set( programCacheKey, program );
materialProperties.uniforms = parameters.uniforms;
}
Solo la rama else llega al callback. Y la clave de caché cambia cuando cambia algo del entorno de compilación: el número de luces de cada tipo, si hay sombras y de qué clase, el tone mapping activo, el espacio de color de salida, el número de planos de recorte, los defines. Así que el mismo material puede recompilar varias veces a lo largo de la vida de la escena, y tu callback se ejecutará de nuevo cada una de ellas, siempre sobre la fuente original limpia.
De ahí salen dos reglas que no son negociables.
El callback tiene que ser puro respecto a la fuente. No acumules estado, no dependas de haber corrido antes, no asumas que el shader.vertexShader que recibes lleva tus parches de la vez anterior. No los lleva: es el texto original de ShaderLib.
Los uniforms hay que compartirlos por referencia. Fíjate en que el ejemplo declara const uTime = { value: 0 } fuera del callback y luego hace shader.uniforms.uTime = uTime. Si en su lugar hubieras escrito shader.uniforms.uTime = { value: 0 }, tendrías un objeto nuevo en cada recompilación y perderías el hilo desde el bucle de render: el material se congelaría en el instante de la última recompilación sin ningún error visible. El patrón de guardar el objeto fuera y asignar la referencia dentro es lo que hace que actualizar uTime.value siga funcionando después de que la escena añada una luz.
El callback recibe un segundo argumento, renderer, y prácticamente ningún ejemplo de internet lo toca. Es una lástima, porque es la única forma de escribir un onBeforeCompile que se adapte al contexto en lugar de asumirlo. Con el renderer en la mano puedes leer renderer.capabilities.maxTextures, renderer.outputColorSpace o renderer.toneMapping en el momento exacto en que se va a compilar el programa, y ramificar tu inyección en consecuencia: una constante distinta si el tone mapping es AgX, una precisión distinta según lo que reporte el dispositivo, una ruta más barata si las capacidades son pobres. Pero hay un detalle que convierte esto de idea bonita en trampa si no lo ves venir: todo lo que leas del renderer para ramificar tiene que entrar también en customProgramCacheKey, porque el motor no tiene forma de saber que tu decisión depende de ello. La clave por defecto es el texto fuente de tu función, y el texto no cambia cuando cambia renderer.toneMapping. Así que el programa cacheado que generaste bajo un tone mapping se reutilizará alegremente bajo otro, y pasarás una tarde buscando el bug en tu GLSL cuando el GLSL nunca llegó a recompilarse. La forma correcta es tratar las dos funciones como un par indivisible: el callback lee del renderer, y la clave de caché declara exactamente qué ha leído. Cuando ves esa simetría, customProgramCacheKey deja de parecer un detalle de rendimiento y se revela como lo que es, la declaración de dependencias de tu inyección.