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.STORAGEandGPUBufferUsage.VERTEXflags, 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:
- Compute Pass: The
GPUCommandEncoderbegins a compute pass, sets the compute pipeline and bind groups, and dispatches thread groups based on the vertex count (ceil(vertexCount / 64)). - 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. - 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-f16WebGPU 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.