Loading WebGPU example...
More info
Back to the demo ↑

WebGPU notes   /   16

WebGPU Render Pipelines in Rust: Phong, Toon, and Wireframe

Flatten one 67-node glTF treasure scene, draw its 6,968 triangles through Phong and toon pipelines, convert every triangle edge into a line list, and compare all three render states in adjacent WebGPU viewports.

Render pipelines
3
Scene triangles
6,968
Scene draws
3

Compare render-pipeline state with one scene

The previous indirect-draw article changes where a draw call reads its arguments. This example keeps the geometry, camera, uniform bind group, color target, and depth attachment constant, then changes the graphics pipeline itself. Two pipelines interpret the index stream as triangles and enter different fragment functions; the third interprets a generated index stream as independent lines.

The introducing pipelines commit added the Rust example, WGSL module, treasure model, and original PNG. The image-loading update replaced that PNG with the current JPEG and WebP. Later Rust revisions replaced internal panics with reported errors, aligned the WebAssembly entry point, and removed an obsolete projection conversion; the WGSL and glTF files remain byte-identical to their introduction. The program runs through sib::render.

One WebGPU treasure scene shown side by side with glossy Phong shading, banded toon shading, and colored wireframe lines.
The same treasure clearing appears with glossy continuous lighting, discrete toon bands, and explicit triangle edges. At 1280×720, the panels are 426, 426, and 428 pixels wide.

Load and flatten the glTF asset

Before renderer initialization, load_colored_gltf_scene reads treasure_smooth.gltf. Native builds address assets/models/treasure_smooth.gltf; WebAssembly uses ../assets/models/treasure_smooth.gltf. The 285,755-byte JSON file contains its 107,040-byte binary buffer as a Base64 data URI, so startup makes one model request and no secondary buffer or texture request.

The default scene has 67 root nodes, 67 meshes, and 86 triangle primitives spread across 11 materials. The loader recursively applies each node transform, appends every primitive to one mesh, rebases its indices, and computes one axis-aligned bound. The result contains 4,050 vertices and 20,904 u32 indices, or 6,968 triangles.

Scroll sideways to see all table columns.

Treasure glTF data used by the render-pipelines example
Source dataCount or sizeCurrent loader behavior
JSON glTF285,755 bytesOne native read or browser fetch
Embedded buffer107,040 decoded bytesDecoded in memory; no extra request
Scene hierarchy67 roots, no childrenNode transforms are baked into vertices
Meshes and primitives67 meshes, 86 triangle primitivesMerged into one indexed mesh
Materials11Only base-color factors become vertex color
Flattened geometry4,050 vertices; 20,904 indicesPosition, normal, and RGBA color

The source primitives contain positions and normals but no COLOR_0. The loader supplies white, multiplies it by each primitive's material base-color factor, and stores that result in a 40-byte GltfColoredVertex. Position uses shader location 0, normal uses location 1, location 2 is unused, and RGBA color uses location 3. Metallic, roughness, textures, alpha modes, and the original material boundaries do not reach the pipelines.

The flattened bounds run from approximately (−7.565, −1.006, −7.507) to (7.588, 5.846, 7.501). Their center is (0.011, 2.420, −0.003), and the half-diagonal radius is about 11.201. Those values drive centering and camera distance rather than hard-coding the treasure asset's scale.

Create three compatible WebGPU pipelines

All three pipeline objects share one layout and one 144-byte uniform buffer. The buffer contains a projection matrix, a combined view/centering matrix named model, and a four-component light value. Its bind group is visible to both shader stages, although the fragment functions consume only interpolated values from the vertex stage.

let phong = create_pipeline(
    context, layout, shader, "vs_lit", "fs_phong",
    wgpu::PrimitiveTopology::TriangleList,
);
let toon = create_pipeline(
    context, layout, shader, "vs_lit", "fs_toon",
    wgpu::PrimitiveTopology::TriangleList,
);
let wireframe = create_pipeline(
    context, layout, shader, "vs_wireframe", "fs_wireframe",
    wgpu::PrimitiveTopology::LineList,
);

The helper changes only entry points and primitive topology. Every pipeline uses the surface format with color writes and no blending, counterclockwise front faces, no face culling, one sample, and a Depth32Float attachment with writes enabled and LessEqual comparison. Because the third pipeline uses LineList, it does not request the optional polygon-line feature used by a native fill-mode wireframe.

Scroll sideways to see all table columns.

The three scene pipelines and their submitted primitives
PipelineVertex entryFragment entryTopologyPer-frame work
Phongvs_litfs_phongTriangle list20,904 indices; 6,968 triangles
Toonvs_litfs_toonTriangle list20,904 indices; 6,968 triangles
Wireframevs_wireframefs_wireframeLine list41,808 indices; 20,904 segments

Contrast continuous Phong terms with discrete toon bands

vs_lit transforms each position into view space and passes a transformed normal, view vector, and light vector to both filled pipelines. The Phong fragment path desaturates the material color by 65%, then adds full-strength ambient color, a Lambert term scaled by 1.75, and a reflected-vector highlight with exponent 32 and strength 0.35:

let diffuse = max(dot(normal, light), 0.0) * color;
let specular = pow(
    max(dot(reflect(-light, normal), view), 0.0),
    32.0,
) * vec3<f32>(0.35);
return vec4<f32>(color + diffuse * 1.75 + specular, 1.0);

The toon function uses the same per-fragment normal and light vector but quantizes their dot product. Intensities at least 0.5 use a factor of 1.0; successive bands use 0.75, 0.6, 0.5, and 0.25 below thresholds 0.5, 0.35, 0.25, and 0.1. It multiplies the original material color by both that factor and 3.0, creating the bright flat bands visible in the middle panel.

These are compact teaching shaders, not energy-conserving material models. Neither path tone-maps its output, and both can produce components above 1.0 that the color target clamps. The value named light_position is transformed with homogeneous w = 0 and then treated as a point by subtracting the vertex position; view translation therefore does not affect it as a true positional light would.

Build a portable wireframe index buffer

The original 20,904-index triangle stream remains intact for the two filled draws. Initialization also walks every group of three indices and emits its three edges as six line-list indices:

for triangle in indices.chunks_exact(3) {
    lines.extend_from_slice(&[
        triangle[0], triangle[1],
        triangle[1], triangle[2],
        triangle[2], triangle[0],
    ]);
}

Each triangle contributes three segments, so 6,968 triangles produce 20,904 segments and 41,808 indices. This shows every triangulation edge, including diagonals inside surfaces. It does not deduplicate edges shared by adjacent triangles, calculate silhouette edges, hide back lines, or add perspective-correct line thickness. Shared edges can therefore be submitted twice, and line width remains the implementation's one-pixel rasterization behavior.

The wire vertex shader uses the same projection and combined transform but passes only RGB color. Its fragment stage multiplies that color by 1.5 and writes opaque output. Depth testing still hides lines behind nearer geometry within the wireframe panel.

Split one render pass into three viewports

Landscape surfaces are divided into three columns with integer widths. The first two receive floor(width / 3); the third receives the remainder. Portrait surfaces use the same rule for three rows. Before each indexed draw, draw_panel sets both the floating-point viewport and matching integer scissor rectangle, changes the pipeline, and submits one instance.

pass.set_viewport(
    panel.x as f32, panel.y as f32,
    panel.width as f32, panel.height as f32,
    0.0, 1.0,
);
pass.set_scissor_rect(panel.x, panel.y, panel.width, panel.height);
pass.set_pipeline(pipeline);
pass.draw_indexed(indices, 0, 0..1);

All panels occupy disjoint regions of one color target and one full-surface depth image, so a single clear at the start of the scene pass is sufficient. One projection is calculated from the first panel's aspect ratio and shared by all three. Only the last panel can differ by one or two pixels after integer division, making its aspect ratio slightly different without a separate projection.

Account for explicit buffers and two render passes

Scroll sideways to see all table columns.

Render-pipelines example fixed GPU buffers
ResourceLayoutLogical bytesUse
Colored vertices4,050 × 40162,000Shared by all three draws
Triangle indices20,904 u3283,616Phong and toon
Line indices41,808 u32167,232Wireframe
Uniform2 matrices + light vector144Shared bind group
Fixed buffer totalExcluding depth and overlay412,992Unchanged after initialization

The Depth32Float image adds 4 × width × height logical bytes. At the 1280×720 screenshot size, that is 3,686,400 bytes, bringing these explicit fixed buffers plus depth to 4,099,392 bytes (about 3.909 MiB) before alignment, the presentation surface, and text-overlay resources.

Each frame uses one color-and-depth scene pass for the three indexed draws, followed by one color-load pass for the text overlay. The three scene calls consume 83,616 index references in total: 20,904 for each filled panel and 41,808 for lines. The overlay reports the adapter string and CPU-sampled FPS; it does not issue GPU timestamps or compare the cost of the three pipelines.

Understand camera, resize, and input behavior

The model bound is translated to the origin before the view transform. With the current radius of about 11.201, the camera eye is approximately (0, 5.824, 12.097), the target is (0, −1.344, 0), and the far plane is about 89.605. The vertical field of view is 60° and the near plane is 0.1.

The scene is static. There are no keyboard, pointer, touch, orbit, zoom, pipeline, material, light, or animation controls. Resize is the only input-dependent scene change: it recreates the full-surface depth texture, recalculates the first-panel projection, rewrites 144 uniform bytes, and rebuilds the overlay placements. Wide windows show three columns; tall windows show three rows. During ordinary frames, only the FPS text may change.

The example's compact loader also defines its limits. It accepts triangle primitives only, substitutes +Z normals and white colors when attributes are missing, ignores material textures and PBR properties, and merges every primitive into one draw range. Node normals are transformed with the node's ordinary linear matrix rather than its inverse transpose, so non-uniform node scales in this asset can distort lighting normals. The demo is a pipeline comparison rather than a production glTF renderer.

Run and extend the example

Run the native example from the repository root:

cargo run --example pipelines

Build the WebAssembly target and serve the generated pages:

scripts/build-wasm.sh --release pipelines
cargo run --bin serve

Open http://127.0.0.1:8080/pipelines/ in a browser with WebGPU support. The treasure glTF is the only runtime scene asset. WGSL and the Vazirmatn overlay font are compiled into the native binary or WebAssembly module, while the article and screenshot remain readable if WebGPU initialization fails.

Useful extensions include adding a normal-debug or PBR pipeline, giving each panel its own projection and uniform, deduplicating wireframe edges, adding multisampling, and measuring each draw with timestamp queries on adapters that support them. A fuller glTF path could retain primitive/material boundaries, load textures, and use inverse-transpose normal transforms.