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 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 );#endifEs 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.
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.