WebGPU notes / 24
WebGPU PBR in Rust: GGX Metallic-Roughness Grid
Render one procedural sphere mesh 49 times, vary gold metallic and roughness values per instance, and evaluate a compact GGX and Schlick specular model under four moving or fixed lights.
- Spheres
- 49
- Lights
- 4
- Scene draws
- 1
Compare metallic and roughness across one grid
The previous omnidirectional shadow mapping example projects visibility from a point light in every direction. PBR Basic removes the shadow map and focuses on how a microfacet specular response changes as metallic and roughness values move across 49 otherwise identical gold spheres. One indexed instanced draw keeps the geometry and material comparison compact.
The original PBR examples commit introduced the grid, procedural mesh, GGX and Schlick shader, four-light animation, screenshots, and web registration. The current Rust example and WGSL shader preserve that design. Later changes modernized the WebAssembly entry point, removed an obsolete clip-space correction, made output encoding follow the surface format, and bounded normalized animation time. The demo runs through sib::render.
Generate one sphere mesh in Rust
The renderer makes no runtime model, image, material-texture, or shader request. Rust generates a radius-one latitude-longitude sphere with 40 latitude segments and 56 longitude segments. The pbr.wgsl shader and 122,752-byte Vazirmatn font are compiled into the program.
Scroll sideways to see all table columns.
| Data | Items | Stride | GPU payload | Per-frame use | Purpose |
|---|---|---|---|---|---|
| Sphere vertices | 2,337 | 24 bytes | 56,088 bytes | Reused by 49 instances | Position and normal |
u32 indices | 13,440 | 4 bytes | 53,760 bytes | 4,480 submitted triangles per instance | Triangle-list topology |
| Material instances | 49 | 32 bytes | 1,568 bytes | One record per sphere | Position, roughness, color, and metallic |
The vertex loops include both ends of each angular range, producing 41 latitude rows and 57 vertices per row. Positions and normals each use three f32 values. The indexed loops add two triangles for every latitude-longitude cell, giving 13,440 indices and 4,480 submitted triangles.
This straightforward topology duplicates the longitudinal seam and every pole position. At the top and bottom, 112 submitted triangles per sphere collapse to zero area. The GPU still receives all 4,480 triangles; 4,368 are geometrically nondegenerate. Across 49 instances, the scene submits 219,520 triangles, including 5,488 pole degenerates.
Pack 49 material instances
The grid is seven spheres wide and seven deep with 2.5 units between centers. X changes metallic while Z changes roughness. Both axes span −8.75 through 6.25 and are centered on −1.25, which matches the camera target. Radius-one spheres therefore keep a half-unit gap.
let metallic = (x as f32 / 6.0).clamp(0.1, 1.0);
let roughness = (y as f32 / 6.0).clamp(0.05, 1.0);
InstanceData {
position: [(x as f32 - 3.5) * 2.5, 0.0,
(y as f32 - 3.5) * 2.5],
roughness,
color: [1.0, 0.765557, 0.336057],
metallic,
}
Metallic values are 0.1, one sixth, one third, 0.5, two thirds, five sixths, and 1. Roughness begins at 0.05 and then follows the same fractions. The grid deliberately has no fully dielectric metallic value of 0. Each instance record supplies shader locations 2 through 5 with no per-frame mutation.
A model matrix rotates the source mesh −90° around Y before adding the instance translation. Because positions and normals rotate together and the mesh is a perfect sphere, this transform has no visible effect.
Bind one uniform and one surface-size depth image
The example owns four fixed buffers. Its 208-byte uniform contains a view-projection matrix, model matrix, camera vector, and four light vectors. One bind group exposes only that buffer to the vertex and fragment stages. There are no material texture or sampler bindings, dynamic offsets, immediate data, or push constants.
Scroll sideways to see all table columns.
| Resource | Format or size | How it changes | Purpose |
|---|---|---|---|
| Sphere vertices | 56,088 bytes | Initialized once | Shared positions and normals |
| Sphere indices | 53,760 bytes | Initialized once | Shared triangle list |
| Material instances | 1,568 bytes | Initialized once | 49 transforms and material parameters |
| Scene uniforms | 208 bytes | Fully rewritten every update and resize | Camera, model, transfer exponent, and four lights |
| Scene depth | Surface-size Depth32Float | Recreated on resize | Depth-tests the sphere grid |
| Surface color | Adapter-selected surface format | Presented every frame | Receives PBR color directly |
The fixed example-owned buffer payload totals 111,624 bytes. The depth image has one mip, one layer, and one sample with RENDER_ATTACHMENT | TEXTURE_BINDING usage. At four bytes per texel it needs width * height * 4 logical bytes, or 3,686,400 bytes at the native default 1280×720 size. Its comparison sampler and texture-binding capability are not used here.
The scene pipeline uses triangle-list topology, no face culling, no blending, and no MSAA. Depth writes are enabled with LessEqual. The color target is the current surface format; there is no HDR intermediate or post-processing pass.
Evaluate a compact GGX specular BRDF
The fragment shader forms a normalized view vector and, for each of four lights, a normalized vector from the fragment to the light position. It evaluates a GGX normal-distribution term, Schlick-GGX visibility, and Schlick Fresnel:
let alpha = roughness * roughness;
let alpha2 = alpha * alpha;
let d = alpha2 /
(PI * denom * denom);
let k = ((roughness + 1.0) *
(roughness + 1.0)) / 8.0;
let g = geometry_nl * geometry_nv;
let f0 = mix(vec3<f32>(0.04),
material_color, vec3<f32>(metallic));
let f = f0 + (vec3<f32>(1.0) - f0) *
pow(1.0 - dot_nv, 5.0);
The shader divides D * F * G by max(4 * NdotL * NdotV, 0.001), multiplies the result by NdotL, and sums four contributions. It then adds material_color * 0.02 and multiplies the accumulated radiance by 4.
This is a focused specular demonstration rather than a complete energy-conserving metallic-roughness renderer. It has no diffuse term such as kD * albedo / PI. Schlick Fresnel uses NdotV where a conventional Cook-Torrance implementation evaluates HdotV. The four positional lights also have no color, intensity, range, or inverse-square attenuation.
Before writing the result, Rust stores an output exponent in cam_pos.w. It is 1 on an sRGB surface, allowing hardware encoding, and 1 / 2.2 on a linear surface for approximate manual encoding. This avoids the earlier version's double encoding on sRGB swapchains, but the direct surface path still has no exposure, tone mapping, or HDR headroom.
Render the instanced grid in one scene draw
The first pass clears surface color to near-black (0.02, 0.022, 0.028, 1) and depth to 1. It binds the scene pipeline, uniform group, shared sphere buffers, and instance buffer before issuing one draw_indexed call over all 49 instances.
Scroll sideways to see all table columns.
| Pass | Attachments | Draws | Submitted work | Result |
|---|---|---|---|---|
| PBR scene | Surface color + surface-size depth | 1 indexed instanced draw | 658,560 index invocations; 219,520 triangles | 49 shaded gold spheres |
| Text overlay | Load surface color; no depth | 1 framework glyph draw when text is visible | Glyph count varies with GPU and FPS text | Title, GPU information, FPS, and material name |
A frame therefore records two render passes. The example owns one scene pipeline and one bind group; Glyphon supplies its own text pipeline, atlas group, viewport group, and instanced glyph draw. Overlay preparation and atlas trimming run every frame, while the displayed FPS is CPU frame cadence sampled over approximately 500 ms rather than a GPU timestamp.
Animate four lights around a fixed camera
The camera stays at (12, 15.5, 5) and looks toward the grid center at (-1.25, 0, -1.25). Its right-handed perspective uses a 60° vertical field of view, current surface aspect, and near and far planes of 0.1 and 256. Resizing recreates depth, rebuilds overlay placement, and rewrites the uniform for the new aspect.
Normalized animation time advances by delta_seconds * 0.25 and keeps its fractional part, producing a four-second cycle. One light circles the grid with radius 20 at Y = 11.25. A second ranges from −20 to 20 on X and 7.25 to 15.25 on Y while Z stays 15. The other two remain at (15, 11.25, 15) and (15, 11.25, -15).
let lights = [
[phase.sin() * 20.0, 11.25,
phase.cos() * 20.0, 1.0],
[phase.cos() * 20.0,
11.25 + phase.sin() * 4.0,
15.0, 1.0],
// Two static lights follow.
];
The complete 208-byte uniform is rewritten every normal update. There are no keyboard, mouse, pointer, touch, camera, or material controls. The 22 px Vazirmatn overlay displays “PBR basic,” GPU device information, FPS, and “material: Gold.”
Run and extend the example
From a local checkout with Rust installed, run the native WebGPU PBR example:
cargo run --example pbr
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 pbr
cargo run --bin serve
Open http://127.0.0.1:8080/pbr/ in a browser with WebGPU support. The demo requires no optional WebGPU feature and uses downlevel default limits. Native and WebAssembly execute the same generated geometry, two passes, and WGSL with no rendering fallback. Browser startup downloads the JavaScript module and WebAssembly binary, but no separate scene data.
The example intentionally keeps its PBR ingredients visible, but it omits diffuse energy conservation, distance attenuation, light colors, shadows, environment lighting, texture maps, normal mapping, HDR rendering, tone mapping, MSAA, face culling, and controls. Directly adding four unattenuated specular responses and scaling them by 4 is useful for comparison, not a calibrated lighting model.
Useful changes to try:
- Add a Lambert or Burley diffuse lobe with metallic energy conservation, then include a true metallic-zero column.
- Evaluate Schlick Fresnel with
HdotV, add radiometric light intensity and inverse-square attenuation, and compare the highlights. - Remove degenerate pole triangles or generate an indexed icosphere, then compare vertex and rasterization work.
- Render to an HDR target, add exposure and tone mapping, and continue to the texture and image-based-lighting examples.