La fragilidad de sustituir cadenas
Por qué esta técnica se rompe entre versiones, qué modos de fallo tiene, y cómo escribir inyecciones que al menos avisen cuando dejan de funcionar.
Toda la técnica descansa sobre String.prototype.replace. No hay validación, no hay tipos, no hay contrato: si la cadena que buscas no está, replace devuelve el texto intacto y no dice nada. El shader compila, la escena se dibuja, y tu efecto simplemente no existe. Es el peor modo de fallo posible —silencioso y tardío— y es el precio real de conseguir PBR completo con tres líneas de GLSL propio.
- Enumerar los cuatro modos de fallo de una inyección por sustitución.
- Escribir un ayudante que falle ruidosamente cuando el ancla desaparece.
- Fijar la versión de Three.js de forma coherente con inyecciones frágiles.
- Decidir cuándo la fragilidad es aceptable y cuándo no.
Los cuatro modos de fallo
El ancla desaparece. Es el caso más común y el más traicionero. Three.js renombra un chunk, fusiona dos, o cambia el orden de los includes. Tu replace no encuentra nada, devuelve la cadena original y todo sigue funcionando salvo tu efecto. Nadie ve un error. Nadie ve un aviso. El objeto se ve normal, solo que sin la onda, sin el tinte o sin la disolución. Si tu inyección era sutil, puede pasar meses en producción rota.
El ancla aparece más de una vez. replace con una cadena sustituye solo la primera ocurrencia. Si el ancla que elegiste aparece dos veces, tu código entra en la primera y la segunda queda sin tocar. Peor aún: si eliges una cadena corta como diffuseColor esperando la línea concreta, acabarás pegando el parche en el sitio equivocado.
El nombre de la variable cambia. El ancla sigue ahí, tu replace funciona, pero la variable que usas dentro se llama distinto. Aquí sí hay error de compilación, con un mensaje de GLSL sobre un identificador no declarado y un número de línea que no corresponde a ningún fichero tuyo, porque el shader es texto generado.
El significado cambia sin que cambie el nombre. El más raro y el más caro. El chunk sigue llamándose igual, la variable también, pero lo que contiene ahora es otra cosa: el valor pasa de estar en espacio lineal a estar codificado, la normal pasa de espacio de vista a espacio de mundo, el signo se invierte. No hay error, hay un resultado sutilmente mal.
Fallar ruidosamente
La defensa más rentable es un ayudante de veinte líneas que convierte los dos primeros modos de fallo en excepciones inmediatas:
/**
* Sustituye `ancla` por `reemplazo` exigiendo exactamente una ocurrencia.
* Lanza si no aparece o si aparece mas de una vez.
*/
function inyectar( fuente, ancla, reemplazo, etiqueta ) {
const partes = fuente.split( ancla );
if ( partes.length === 1 ) {
throw new Error(
`[shader:${etiqueta}] ancla no encontrada: ${JSON.stringify( ancla )}. ` +
`Three.js ha cambiado el shader; revisa el chunk.`
);
}
if ( partes.length > 2 ) {
throw new Error(
`[shader:${etiqueta}] ancla ambigua: ${partes.length - 1} ocurrencias de ` +
`${JSON.stringify( ancla )}. Usa un ancla mas larga.`
);
}
return partes[ 0 ] + reemplazo + partes[ 1 ];
}
Y se usa así:
material.onBeforeCompile = ( shader ) => {
shader.uniforms.uTime = uTime;
shader.vertexShader = inyectar(
shader.vertexShader,
'#include <common>',
`#include <common>\nuniform float uTime;`,
'onda/decl'
);
shader.vertexShader = inyectar(
shader.vertexShader,
'#include <begin_vertex>',
`#include <begin_vertex>\ntransformed.y += sin( transformed.x * 3.0 + uTime ) * 0.15;`,
'onda/vertice'
);
};
El coste es una función. El beneficio es que el día que actualices Three.js y el chunk ya no exista, lo sabrás en el primer frame con un mensaje que dice qué inyección y qué ancla, en lugar de descubrirlo cuando un cliente te pregunte por qué el agua no se mueve.
Merece la pena añadir un tercer control, esta vez sobre la variable:
function exigirIdentificador( fuente, ident, etiqueta ) {
if ( ! fuente.includes( ident ) ) {
throw new Error( `[shader:${etiqueta}] el identificador "${ident}" ya no existe en el shader.` );
}
}
// Antes de inyectar codigo que use vMapUv:
exigirIdentificador( shader.fragmentShader, 'vMapUv', 'tinte' );
Esto cubre el tercer modo de fallo. El cuarto no lo cubre nada automático: solo capturas de referencia y ojo.
Si tu package.json dice "three": "^0.184.0", estás autorizando a npm a instalar cualquier versión menor futura. Y las versiones menores de Three.js —que es como se publican todas las releases— cambian chunks con regularidad, porque los chunks son implementación interna y no forman parte de ninguna promesa de compatibilidad. Un proyecto con inyecciones debe fijar la versión exacta, sin acento circunflejo ni tilde, y actualizar de forma deliberada, con una pasada de revisión visual de cada material inyectado.
Cuándo el precio compensa
La fragilidad no es un argumento absoluto en contra. Es un coste que hay que comparar con la alternativa, y la comparación depende del proyecto.
Compensa cuando la inyección es pequeña, localizada y visible. Un desplazamiento de vértices en un material que se ve en pantalla todo el rato se rompe de forma evidente en la primera prueba manual tras actualizar. Compensa cuando el proyecto tiene una vida corta o una versión fija: una landing, una campaña, una instalación. Y compensa cuando la alternativa es reimplementar el PBR, que se rompe también, solo que de forma más lenta y más cara.
No compensa cuando tienes muchas inyecciones distintas repartidas por el código. Diez materiales inyectados son diez contratos independientes con la implementación interna de la librería, y actualizar deja de ser una decisión técnica para convertirse en un proyecto. Tampoco compensa cuando la inyección es sutil —un ajuste de sombreado que solo se nota comparando— porque entonces el fallo silencioso es realmente silencioso. Y no compensa en absoluto en una librería que publicas para otros: estarías exportando tu dependencia con la implementación interna de Three.js a todos tus usuarios, cada uno con su versión.
En cualquiera de esos tres casos, la respuesta correcta en r184 es mirar hacia los node materials, que expresan la misma intención —“quiero PBR pero con este color”— como una asignación a una propiedad en lugar de como una cirugía de texto.
La forma más útil de entender por qué esto es tan frágil es cambiar el modelo mental. Cuando llamas a mesh.position.set(...) estás usando una API: hay un contrato público, hay versionado semántico, hay una promesa de que el nombre y el comportamiento sobreviven a las versiones menores. Cuando escribes shader.fragmentShader.replace('#include <map_fragment>', ...) no estás usando una API en absoluto: estás parcheando la salida intermedia de un generador de código. Es la diferencia entre llamar a una función de una librería y editar el ensamblador que produjo su compilador. La cadena que buscas no es un símbolo exportado, es un detalle del texto que el generador emite hoy. Y hay una consecuencia de esta observación que casi nadie extrae, y que es la más importante de todas: si estás parcheando la salida de un generador, la solución de verdad no es parchear mejor, es programar el generador. Eso es exactamente lo que son los node materials. material.colorNode = algo no es una alternativa cosmética a replace(); es la operación equivalente expresada en el nivel correcto, como una entrada del grafo del que el generador parte, en lugar de como una edición del texto que el generador escupe. Por eso la técnica de este nivel no se puede “arreglar” con mejores ayudantes, mejores expresiones regulares o mejores tests: el problema no es la calidad de la sustitución, es la capa en la que ocurre. Los ayudantes de esta lección son un cinturón de seguridad, y merecen la pena mientras conduzcas este coche. Pero conviene saber que existe otro coche.