Custom Shader Hooks for glTF in Three.js and Babylon.js
Modifying the rendering pipeline of glTF meshes without losing standard Physically Based Rendering (PBR) features requires injecting custom shader code directly into existing materials. Both Three.js and Babylon.js load glTF models with pre-configured PBR materials that handle complex tasks like skinning, morph targets, environment mapping, and shadows. Rather than replacing these with raw shader materials from scratch, developers use shader hooks to alter vertex positions, inject custom procedural textures, or manipulate lighting equations while keeping the core rendering pipeline intact.
The Challenge with Standard glTF Pipelines
When a glTF file loads, the engine parses its primitives and binds
standard PBR materials—MeshStandardMaterial or
MeshPhysicalMaterial in Three.js, and
PBRMaterial in Babylon.js. Completely swapping these out
for a native ShaderMaterial removes the engine's built-in
handling of skeletal animations, directional shadows, and image-based
lighting (IBL). Shader hooks solve this by exposing the underlying
shader compilation process, allowing the injection of custom GLSL or
WGSL code at designated lifecycle stages.
Modifying glTF Shaders in Three.js
Three.js provides the Material.onBeforeCompile callback
to intercept and alter shader code before it compiles on the GPU.
1. Traverse and Intercept
After loading a glTF via GLTFLoader, traverse the scene
graph to find mesh nodes and target their materials:
gltfLoader.load('model.gltf', (gltf) => {
gltf.scene.traverse((child) => {
if (child.isMesh && child.material) {
modifyMaterial(child.material);
}
});
});2. Inject Code via String Manipulation
Within onBeforeCompile, Three.js exposes the shader
object containing vertexShader,
fragmentShader, and uniforms. Custom code is
added by replacing default Three.js shader chunks:
function modifyMaterial(material) {
material.userData.time = { value: 0 };
material.onBeforeCompile = (shader) => {
shader.uniforms.uTime = material.userData.time;
// Inject uniform declaration into vertex shader
shader.vertexShader = `
uniform float uTime;
${shader.vertexShader}
`;
// Inject custom deformation logic into the vertex pipeline
shader.vertexShader = shader.vertexShader.replace(
'#include <begin_vertex>',
`
#include <begin_vertex>
transformed.y += sin(transformed.x * 5.0 + uTime) * 0.1;
`
);
// Modify the final color output in the fragment shader
shader.fragmentShader = shader.fragmentShader.replace(
'#include <dithering_fragment>',
`
#include <dithering_fragment>
gl_FragColor.rgb = mix(gl_FragColor.rgb, vec3(1.0, 0.0, 0.0), 0.2);
`
);
};
}Updating material.userData.time.value in the animation
loop updates the uniform dynamically without triggering expensive
re-compilations.
Modifying glTF Shaders in Babylon.js
Babylon.js offers a modular, structured alternative to string
replacement through the MaterialPluginBase API, as well as
legacy injection via PBRCustomMaterial.
1. The Modern Approach: MaterialPluginBase
The Babylon.js MaterialPluginBase system allows
developers to create reusable shader modifications that seamlessly hook
into any PBRMaterial created by the
SceneLoader.
class WavePlugin extends BABYLON.MaterialPluginBase {
public time = 0;
constructor(material: BABYLON.Material) {
super(material, "WavePlugin", 200, { WavePlugin: false });
this.enable(true);
}
getUniforms() {
return {
ubo: [{ name: "uTime", size: 1, type: "float" }],
fragment: "",
vertex: "uniform float uTime;"
};
}
bindForSubMesh(uniformBuffer: BABYLON.UniformBuffer) {
if (this._isEnabled) {
uniformBuffer.updateFloat("uTime", this.time);
}
}
getCustomCode(shaderType: string) {
if (shaderType === "vertex") {
return {
// Injects right before position update in the vertex stage
CUSTOM_VERTEX_UPDATE_POSITION: `
positionUpdated.y += sin(positionUpdated.x * 5.0 + uTime) * 0.1;
`
};
}
return null;
}
}2. Applying the Plugin to glTF Meshes
Attach the plugin directly to the materials of the imported model during or after the loading sequence:
BABYLON.SceneLoader.ImportMeshAsync("", "", "model.gltf", scene).then((result) => {
result.meshes.forEach((mesh) => {
if (mesh.material instanceof BABYLON.PBRMaterial) {
new WavePlugin(mesh.material);
}
});
});Babylon.js executes defined hook points like
CUSTOM_VERTEX_UPDATE_POSITION or
CUSTOM_FRAGMENT_BEFORE_FRAGCOLOR, ensuring uniform buffers,
lighting calculations, and shadow passes remain fully synchronized
without manual string patching.
Key Architectural Differences
- Integration Safety: Three.js uses raw string
replacement on specific chunks
(
#include <begin_vertex>). If Three.js renames or deprecates an internal chunk in an update, shader injection can fail silently. Babylon.js defines formal hook keys viaMaterialPluginBase, abstracting internal chunk names away from the developer. - Uniform Management: Three.js binds custom uniforms
by appending to
shader.uniformsinsideonBeforeCompile, requiring persistent references on the host material. Babylon.js manages uniforms centrally through standard uniform buffers viabindForSubMesh. - State Preservation: Both approaches successfully retain glTF-specific properties—such as normal mapping, roughness/metallic workflows, skinning matrices, and environment lighting—by executing custom vertex offsets before world transformations and custom color modifications right before final composition.