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

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.

A perspective 7 by 7 grid of gold spheres showing metallic and roughness changes under bright point-light highlights.
The same gold sphere ranges from broad, dim highlights to tight reflections as roughness and metallic values change. Animated and fixed white lights make the response visible from several directions.

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.

PBR Basic procedural geometry and material-instance storage
DataItemsStrideGPU payloadPer-frame usePurpose
Sphere vertices2,33724 bytes56,088 bytesReused by 49 instancesPosition and normal
u32 indices13,4404 bytes53,760 bytes4,480 submitted triangles per instanceTriangle-list topology
Material instances4932 bytes1,568 bytesOne record per spherePosition, 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.

PBR Basic buffers, bindings, attachments, and update cadence
ResourceFormat or sizeHow it changesPurpose
Sphere vertices56,088 bytesInitialized onceShared positions and normals
Sphere indices53,760 bytesInitialized onceShared triangle list
Material instances1,568 bytesInitialized once49 transforms and material parameters
Scene uniforms208 bytesFully rewritten every update and resizeCamera, model, transfer exponent, and four lights
Scene depthSurface-size Depth32FloatRecreated on resizeDepth-tests the sphere grid
Surface colorAdapter-selected surface formatPresented every frameReceives 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.

PBR Basic render passes and submitted draw work
PassAttachmentsDrawsSubmitted workResult
PBR sceneSurface color + surface-size depth1 indexed instanced draw658,560 index invocations; 219,520 triangles49 shaded gold spheres
Text overlayLoad surface color; no depth1 framework glyph draw when text is visibleGlyph count varies with GPU and FPS textTitle, 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.