WebGPU notes / 12
WebGPU Stencil Buffer Outlines with Rust and wgpu
Draw a toon-shaded Venus model, record its projected footprint in a stencil buffer, then render a normal-expanded copy only where that footprint is absent.
- Model triangles
- 31,398
- Model draws
- 2
- Stencil reference
- 1
A stencil-buffer outline around a 3D model
The gears example used ordinary depth testing to separate overlapping surfaces. This demo adds a stencil channel: an integer mask that a render pipeline can compare and update for every covered sample. That mask lets a second draw distinguish the model's original screen-space footprint from the larger silhouette around it.
The result is a red, toon-shaded Venus statue with a white border. The original stencil-buffer commit introduced the example; this article follows the current Rust source, WGSL shader, and shared loader. It runs through sib::render and has no camera, keyboard, mouse, or animation controls.
Load and frame the Venus mesh
The bundled venus.gltf asset contains one scene, node, mesh, triangle primitive, and material. Its 1.48 MB JSON file embeds a 1,110,756-byte binary buffer as a data URI, so there is no separate .bin download. The primitive supplies 28,824 positions, normals, and texture coordinates plus 94,194 source indices, which form 31,398 triangles.
load_colored_gltf_scene walks the scene graph and bakes the node transform into each position and normal. It converts the indices to u32 and uploads one vertex buffer and one index buffer. The colored vertex layout also reserves location 3 for RGBA, but this shader reads only position at location 0 and normal at location 1. It ignores the asset's texture coordinates and material color, uses no textures or samplers, and sets the visible surface to red in WGSL.
The transformed bounds determine the framing. Their radius is about 1.123 units, so the camera distance becomes about 2.864 units and the object-space outline width becomes about 0.028 units. The model matrix recenters the bounds, rotates the statue −45° around Y, and applies a fixed view whose yaw is −35°. A 60° perspective projection uses a 0.1 near plane and a far plane equal to 16 times the radius. Resizing rebuilds these uniforms and the depth-stencil target, but it does not reload the mesh.
First draw: shade the model and write stencil
The first pipeline uses vs_toon and fs_toon. The fragment shader takes the dot product of the normalized surface normal and light vector, then selects one of five brightness bands at 0.25, 0.5, 0.9, and 0.98. Mixing 10% luminance back into the hardcoded red gives the bands a slightly desaturated finish.
At the same time, depth writes are enabled with LessEqual comparison. Stencil comparison is Always, and the fail, depth-fail, and pass operations all use Replace. With the render-pass reference set to 1, every rasterized sample covered by the original mesh becomes 1:
let face = wgpu::StencilFaceState {
compare: wgpu::CompareFunction::Always,
fail_op: wgpu::StencilOperation::Replace,
depth_fail_op: wgpu::StencilOperation::Replace,
pass_op: wgpu::StencilOperation::Replace,
};
depth_fail_op matters here. Hidden fragments do not update color or depth, but they still replace the stencil value. The mask therefore represents the union of the model's projected coverage rather than only the nearest visible fragments. The usual depth test still chooses which red surface reaches the color attachment.
Second draw: expand the mesh and test stencil
The outline pipeline draws the same 94,194 indices again; it does not build a separate outline mesh. Its vertex shader moves each position along its object-space normal before applying the same model and projection matrices:
let position = vec4<f32>(
input.position + input.normal * uniforms.outline_width,
1.0,
);
return uniforms.projection * uniforms.model * position;
The expanded copy overlaps most of the mask stamped by the first draw. A NotEqual stencil comparison rejects those samples because their stored value is already 1. Only the extra ring outside the original footprint begins with 0, passes the comparison, and reaches the white outline fragment shader.
Depth comparison is Always and depth writes are disabled for this draw, so the original depth buffer cannot hide that exterior ring. Both pipelines disable face culling. This works well for the closed, smooth statue, but normal expansion is not a universal outline solution: hard edges, split normals, open geometry, or a width that is too large can produce gaps and uneven thickness.
Configure depth, stencil, and pass order
A single-sampled Depth24PlusStencil8 texture provides both depth and stencil aspects. At the start of the scene pass, depth clears to 1.0 and stencil clears to 0. The code sets reference 1 once, binds the uniform and geometry buffers once, then switches pipelines between the two indexed draws.
Scroll sideways to see all table columns.
| Draw | Depth write | Depth compare | Stencil compare | Stencil operations | Color result |
|---|---|---|---|---|---|
| Toon model | Enabled | LessEqual | Always | Replace on fail, depth-fail, and pass | Five-band red shading |
| Expanded outline | Disabled | Always | NotEqual | Keep on fail/depth-fail; Replace on pass | White outside the original mask |
Both states use front and back stencil faces with 0xff read and write masks. The outline pass replaces the surviving exterior samples with 1, although no later model draw consumes that updated value. After the scene pass ends, a separate color-load pass adds the bundled Vazirmatn diagnostic overlay. Its text reports the frame interval, estimated FPS, and GPU name; the milliseconds are calculated as 1000 / fps after the default sampling interval, not measured with GPU timestamps.
Run and modify the example
From a local checkout with Rust installed, run the native example:
cargo run --example stencilbuffer
For WebAssembly, install the wasm32-unknown-unknown target and the wasm-bindgen CLI version matching Cargo.lock, then build and serve:
scripts/build-wasm.sh --release stencilbuffer
cargo run --bin serve
Open http://127.0.0.1:8080/stencilbuffer/ in a browser with WebGPU support. Native execution reads assets/models/venus.gltf from the checkout; the WASM build fetches the copied asset from the same local site. The geometry buffer is embedded inside that file, and the shader and overlay font are embedded in the program, so the demo has no third-party runtime asset dependency. The article and screenshot remain readable if WebGPU is unavailable.
Useful changes to try:
- Adjust the
radius * 0.025outline width and observe how object-space expansion changes across the silhouette. - Replace the five fixed toon thresholds with a one-dimensional ramp texture or a configurable uniform.
- Change the stencil reference and masks, then inspect how comparison and write masks affect the two draws.
- Add a second occluding model and decide whether the outline should respect scene depth or remain visible through it.