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 via MaterialPluginBase, abstracting internal chunk names away from the developer.
  • Uniform Management: Three.js binds custom uniforms by appending to shader.uniforms inside onBeforeCompile, requiring persistent references on the host material. Babylon.js manages uniforms centrally through standard uniform buffers via bindForSubMesh.
  • 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.