ShaderChunk, ShaderLib y el punto de inserción
Cómo está organizado el GLSL de Three.js por dentro, cómo encontrar el chunk exacto donde tu código tiene que entrar, y qué variables hay en alcance en cada punto.
Inyectar código en un shader ajeno se parece más a operar que a programar: el resultado depende por completo de dónde cortes. Un mismo bloque de GLSL puede ser correcto, inútil o directamente un error de compilación según el chunk detrás del cual lo pongas, porque cada punto de la secuencia tiene un conjunto distinto de variables vivas. Aprender a leer esa secuencia es la mitad del trabajo.
- Inspeccionar
ShaderChunkyShaderLibdesde la consola para encontrar un punto de inserción. - Nombrar los cuatro puntos de inyección canónicos y qué variable se toca en cada uno.
- Registrar un chunk propio y usarlo desde una inyección.
- Predecir qué variables están en alcance en un punto dado de la cadena.
Las dos estructuras
ShaderChunk es un objeto plano exportado desde three. Sus claves son de dos familias. La primera son los fragmentos individuales, en snake_case: common, begin_vertex, project_vertex, map_fragment, lights_fragment_begin, worldpos_vertex y compañía. La segunda son los shaders completos, con sufijo _vert o _frag: meshphysical_vert, meshphysical_frag, sprite_vert, background_frag.
ShaderLib es el catálogo de materiales. Sus claves son basic, lambert, phong, standard, toon, matcap, points, dashed, depth, normal, sprite, background, backgroundCube, cube, equirect, distance, shadow y physical. Cada entrada tiene exactamente la forma { uniforms, vertexShader, fragmentShader }.
Un detalle que sorprende: ShaderLib.standard existe pero ningún material lo selecciona. La tabla shaderIDs de WebGLPrograms mapea tanto MeshStandardMaterial como MeshPhysicalMaterial a physical. Las dos entradas comparten el mismo GLSL —ShaderChunk.meshphysical_vert y meshphysical_frag— y solo difieren en el conjunto de uniforms declarados. Si vas a inspeccionar el shader de un MeshStandardMaterial, mira ShaderLib.physical.
La forma más rápida de trabajar es imprimirlos:
import { ShaderChunk, ShaderLib } from 'three';
// Todos los chunks disponibles, ordenados.
console.log( Object.keys( ShaderChunk ).sort() );
// El vertex shader completo de MeshStandardMaterial, sin expandir.
console.log( ShaderLib.physical.vertexShader );
// El contenido literal de un chunk concreto.
console.log( ShaderChunk.lights_fragment_begin );
Ese último console.log es la herramienta que de verdad importa. Antes de inyectar nada, lee el chunk que vas a usar como ancla y comprueba qué declara, qué modifica y con qué nombre exacto.
Los cuatro puntos canónicos
Casi toda inyección útil cae en uno de cuatro sitios. Vale la pena memorizarlos junto con la variable que se toca en cada uno.
Declaraciones. Uniforms, varyings y funciones auxiliares van antes del cuerpo de main(), ancladas al primer include de la sección de declaraciones. En ambos shaders ese include es common:
shader.fragmentShader = shader.fragmentShader.replace(
'#include <common>',
`#include <common>
uniform vec3 uTinte;
varying vec3 vPosLocal;`
);
El chunk common es también donde el motor define constantes que puedes reutilizar sin declararlas: PI, PI2, PI_HALF, RECIPROCAL_PI, EPSILON, la macro saturate( a ) y whiteComplement( a ). Redeclararlas es un error de compilación.
El vértice. Después de begin_vertex, que declara vec3 transformed = vec3( position ). Todo lo que hagas a transformed lo recogen morphtarget_vertex, skinning_vertex, displacementmap_vertex y finalmente project_vertex, cuyo cuerpo es:
vec4 mvPosition = vec4( transformed, 1.0 );
#ifdef USE_BATCHING
mvPosition = batchingMatrix * mvPosition;
#endif
#ifdef USE_INSTANCING
mvPosition = instanceMatrix * mvPosition;
#endif
mvPosition = modelViewMatrix * mvPosition;
gl_Position = projectionMatrix * mvPosition;
Si necesitas la posición en espacio de mundo dentro del fragment shader, el ancla es worldpos_vertex, que va después de project_vertex y deja disponible worldPosition.
El color difuso. Después de map_fragment, que es el chunk que multiplica la textura difusa sobre diffuseColor:
#ifdef USE_MAP
vec4 sampledDiffuseColor = texture2D( map, vMapUv );
#ifdef DECODE_VIDEO_TEXTURE
sampledDiffuseColor = sRGBTransferEOTF( sampledDiffuseColor );
#endif
diffuseColor *= sampledDiffuseColor;
#endif
Nota que el chunk multiplica en lugar de asignar, y que respeta #ifdef USE_MAP. Si inyectas detrás, diffuseColor ya tiene el color base del material combinado con la textura si la hay. Ese es el sitio para un patrón procedural, un tinte por posición o una máscara. El valor está en espacio lineal, no en sRGB, y es un vec4 cuyo canal alfa importa.
La iluminación. Alrededor de lights_fragment_begin. Ese chunk empieza así:
vec3 geometryPosition = - vViewPosition;
vec3 geometryNormal = normal;
vec3 geometryViewDir = ( isOrthographic ) ? vec3( 0, 0, 1 ) : normalize( vViewPosition );
vec3 geometryClearcoatNormal = vec3( 0.0 );
y después recorre las luces acumulando en reflectedLight. Si inyectas antes, puedes falsear la normal geométrica o la dirección de vista y todo el cálculo posterior te seguirá. Si inyectas después de lights_fragment_end, tienes reflectedLight poblado con sus cuatro componentes: directDiffuse, indirectDiffuse, directSpecular, indirectSpecular.
Para tocar el color final, el ancla es la línea que declara outgoingLight, que no es un include sino código literal del shader:
shader.fragmentShader = shader.fragmentShader.replace(
'vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;',
`vec3 outgoingLight = totalDiffuse + totalSpecular + totalEmissiveRadiance;
outgoingLight = mix( outgoingLight, uTinte, 0.25 );`
);
Fíjate en que esta inyección va antes de tonemapping_fragment y colorspace_fragment. Eso es lo correcto: estás modificando radiancia lineal, y la conversión a espacio de pantalla ocurre después, una sola vez, donde debe.
Registrar un chunk propio
ShaderChunk es un objeto normal, así que puedes añadirle claves. El expansor de includes las resolverá igual que las nativas:
import { ShaderChunk } from 'three';
ShaderChunk.ruido_valor = /* glsl */`
float hash21( vec2 p ) {
p = fract( p * vec2( 233.34, 851.73 ) );
p += dot( p, p + 23.45 );
return fract( p.x * p.y );
}
float ruidoValor( vec2 p ) {
vec2 i = floor( p );
vec2 f = fract( p );
vec2 u = f * f * ( 3.0 - 2.0 * f );
return mix(
mix( hash21( i + vec2( 0.0, 0.0 ) ), hash21( i + vec2( 1.0, 0.0 ) ), u.x ),
mix( hash21( i + vec2( 0.0, 1.0 ) ), hash21( i + vec2( 1.0, 1.0 ) ), u.x ),
u.y
);
}
`;
material.onBeforeCompile = ( shader ) => {
shader.fragmentShader = shader.fragmentShader.replace(
'#include <common>',
`#include <common>
#include <ruido_valor>`
);
shader.fragmentShader = shader.fragmentShader.replace(
'#include <map_fragment>',
`#include <map_fragment>
float n = ruidoValor( vMapUv * 12.0 );
diffuseColor.rgb *= mix( 0.7, 1.0, n );`
);
};
Esto tiene una ventaja real sobre pegar el GLSL en la cadena: la biblioteca de funciones vive en un solo sitio, se reutiliza entre materiales y no engorda el texto de onBeforeCompile, que es lo que por defecto forma la clave de caché del programa.
Tiene también un riesgo evidente. ShaderChunk es un objeto global compartido con la librería: si eliges un nombre que Three.js use en una versión futura, lo pisas silenciosamente y rompes todos los materiales. Prefija siempre.
Ninguna documentación de Three.js dice qué variables están vivas después de cada chunk. Ese contrato existe solo en el código, no tiene tests que lo protejan y no aparece en las notas de versión cuando cambia. Y cambia. geometryNormal y geometryViewDir se llamaban de otra forma hace unas cuantas versiones; vMapUv se llamaba vUv antes de que el sistema de transformación de UV por canal aterrizara; transformed sobrevive desde hace años pero vViewPosition ha cambiado de signo en el pasado. Lo que esto significa en la práctica es que una inyección no depende de la API pública de Three.js, sino de su implementación interna, y por tanto no está cubierta por ninguna promesa de compatibilidad, ni siquiera entre versiones menores. La estrategia defensiva que de verdad funciona no es memorizar nombres: es imprimir el shader expandido. Guarda en tu proyecto un pequeño ayudante que, tras compilar, vuelque shader.fragmentShader a la consola una sola vez; cuando actualices Three.js y algo se rompa, ese volcado te dice en segundos si el ancla ya no existe o si la variable ha cambiado de nombre, en lugar de dejarte adivinando ante una pantalla negra. Y guarda el volcado del shader que funcionaba junto al código de la inyección, aunque parezca ridículo comprometer un fichero de GLSL generado: es el único registro de en qué contrato te apoyaste, y es lo que convierte una actualización de versión de un misterio en un diff.
- Imprime
Object.keys(ShaderChunk).sort()y localiza los chunks que empiezan porlights_. - Vuelca
ShaderLib.physical.fragmentShadery numera las líneas de sumain(). - Inyecta un tinte después de
map_fragmenty comprueba que las sombras siguen funcionando. - Mueve la misma inyección a después de
outgoingLighty explica por qué el resultado cambia. - Registra un chunk propio con prefijo y úsalo desde dos materiales distintos.