WebGPU Compute Shaders for glTF Morph Targets

This article explains how to leverage WebGPU compute shaders to process large-scale glTF morph target animations efficiently. By offloading morph target blending from traditional vertex shaders to compute passes, developers can bypass standard vertex attribute limitations, optimize memory bandwidth, and scale up to hundreds of active blend shapes across complex 3D meshes in real-time web applications.

The Limits of Traditional Morph Target Processing

Standard glTF morph targets (blend shapes) apply per-vertex displacement vectors—such as position, normal, and tangent deltas—to a base mesh based on dynamic weight values. In standard graphics pipelines using WebGL or basic vertex shaders, morphing is typically constrained by:

  • Vertex Attribute Limits: Hardware usually caps vertex attributes to 16 slots. Because each morph target requires displacement vectors for positions, normals, and tangents, a vertex shader can typically only evaluate 2 to 4 targets simultaneously in a single pass without resorting to multiple draw passes.
  • Texture Fetch Overhead: Storing deltas in 2D texture arrays overcomes attribute limits but introduces texture sampling latency inside the vertex stage, often degrading geometry processing performance on complex meshes.
  • Redundant Work: When morph targets are processed directly in the vertex shader during multi-pass rendering (such as depth pre-passes, shadow maps, and forward shading), the same linear blend calculations are repeated for each pass.

The WebGPU Compute Shader Architecture

WebGPU compute shaders solve these constraints by shifting morph target evaluation to an isolated compute stage that runs once per frame before any rendering occurs. The compute shader calculates the final blended positions, normals, and tangents, writing the output directly to a vertex buffer that the render pipeline consumes as standard static geometry.

1. Data Layout and Storage Buffers

In glTF, morph target deltas are stored as arrays of displacements relative to the base attributes. In WebGPU, this data is mapped to GPUBuffer instances with the GPUBufferUsage.STORAGE flag:

  • Base Geometry Buffer: Contains the original vertex positions, normals, and tangents.
  • Morph Delta Buffer: A structured storage buffer holding target offsets. To minimize bandwidth, deltas can be packed tightly into 16-bit floats (f16) or quantized integer formats.
  • Weight Buffer: A small uniform or storage buffer holding the scalar weights (one float per target) driven by the animation controller.
  • Output Vertex Buffer: A buffer created with both GPUBufferUsage.STORAGE and GPUBufferUsage.VERTEX flags, allowing the compute pass to write transformed data and the render pass to read it directly without CPU-GPU readbacks.

2. WGSL Compute Kernel Implementation

The compute shader operates on a 1D grid where each invocation maps to a single vertex index. The kernel loops through all active morph targets, scales the deltas by their respective weights, and accumulates the total offset:

struct Vertex {
    position: vec3<f32>,
    normal: vec3<f32>,
};

struct MorphTargetDelta {
    pos_delta: vec3<f32>,
    norm_delta: vec3<f32>,
};

@group(0) @binding(0) var<storage, read> baseVertices: array<Vertex>;
@group(0) @binding(1) var<storage, read> morphDeltas: array<MorphTargetDelta>; // Size: target_count * vertex_count
@group(0) @binding(2) var<storage, read> weights: array<f32>;
@group(0) @binding(3) var<storage, read_write> blendedVertices: array<Vertex>;

struct MorphUniforms {
    vertexCount: u32,
    activeTargetCount: u32,
};
@group(0) @binding(4) var<uniform> uniforms: MorphUniforms;

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {
    let index = global_id.x;
    if (index >= uniforms.vertexCount) {
        return;
    }

    var blendedPos = baseVertices[index].position;
    var blendedNorm = baseVertices[index].normal;

    for (var i = 0u; i < uniforms.activeTargetCount; i = i + 1u) {
        let weight = weights[i];
        if (abs(weight) > 0.0001) {
            let deltaIndex = (i * uniforms.vertexCount) + index;
            blendedPos += morphDeltas[deltaIndex].pos_delta * weight;
            blendedNorm += morphDeltas[deltaIndex].norm_delta * weight;
        }
    }

    blendedVertices[index].position = blendedPos;
    blendedVertices[index].normal = normalize(blendedNorm);
}

3. Command Encoding and Synchronization

Because WebGPU explicitly separates passes, synchronization between the compute pass and the render pass is lightweight and deterministic:

  1. Compute Pass: The GPUCommandEncoder begins a compute pass, sets the compute pipeline and bind groups, and dispatches thread groups based on the vertex count (ceil(vertexCount / 64)).
  2. Implicit Barrier: Ending the compute pass via pass.end() automatically establishes an execution barrier. WebGPU guarantees that compute shader writes to the storage buffer are finished before a subsequent render pass reads from that same buffer as a vertex buffer.
  3. Render Pass: The render pass binds the compute output buffer to a vertex slot using renderPass.setVertexBuffer(0, blendedVertexBuffer) and executes drawing calls normally.

Performance Optimization Strategies

To handle dense meshes with hundreds of targets (e.g., high-fidelity facial rigs), apply the following optimizations:

  • Dynamic Active Target Lists: Do not loop through every morph target present in the glTF file. Update an index indirection buffer on the CPU each frame containing only target indices with non-zero weights. The compute shader only iterates over these active targets.
  • Sparse Delta Packing: In glTF models, facial blend shapes typically modify only a localized subset of the mesh (e.g., eyebrows or mouth). Store deltas sparsely with vertex index mappings rather than storing zeros for unmodified vertices, reducing overall memory bandwidth consumption.
  • Half-Precision (f16): Enable the shader-f16 WebGPU feature extension. Morph target deltas rarely require full 32-bit floating-point precision; packing displacements into 16-bit floats halves the memory footprint and doubles the cache efficiency of the storage buffer fetches.