WebGPU notes / 26
WebGPU PBR Image-Based Lighting in Rust with wgpu
Load six Bridge2 cubemap faces, generate diffuse irradiance and a split-sum BRDF lookup on the CPU, then shade ten gold spheres with direct GGX light and roughness-selected environment reflections. The seven-level specular source is a box-filtered roughness mip approximation; it is not a GGX-prefiltered cubemap.
- Sphere instances
- 10
- Environment mips
- 7
- Render passes
- 2
Add environment lighting to PBR
The previous WebGPU PBR texture example reads material properties from a glTF mesh and five texture maps. This example returns to procedural spheres so the lighting data stays visible. Direct lights still evaluate a Cook–Torrance-style BRDF, while a cubemap supplies diffuse illumination and direction-dependent reflections from the scene around each sphere.
The original PBR examples commit introduced this example, its WGSL shader, the Bridge2 environment, screenshots, and web registration. The current Rust source keeps the original two-pipeline design. A later clip-space correction removed an obsolete extra matrix, and the PBR preprocessing update replaced nearest resizing and an approximate LUT with linear-space filtering, cosine-convolved irradiance, numerical GGX integration, and surface-aware output gamma. The shared KTX asset update later replaced the JPEG faces with the current uncompressed KTX1 files. Rendering runs through sib::render.
Load six Bridge2 cubemap faces
The demo makes six runtime asset requests through the shared skybox loader and asset helper. Each file in the Bridge2 directory is a 1024×1024, single-face, single-mip KTX1 image containing uncompressed RGBA8 pixels.
Scroll sideways to see all table columns.
| Array layer | Direction | Asset | Dimensions | File bytes | Decoded bytes |
|---|---|---|---|---|---|
| 0 | Positive X | posx.ktx | 1024×1024 | 4,194,372 | 4,194,304 |
| 1 | Negative X | negx.ktx | 1024×1024 | 4,194,372 | 4,194,304 |
| 2 | Positive Y | posy.ktx | 1024×1024 | 4,194,372 | 4,194,304 |
| 3 | Negative Y | negy.ktx | 1024×1024 | 4,194,372 | 4,194,304 |
| 4 | Positive Z | posz.ktx | 1024×1024 | 4,194,372 | 4,194,304 |
| 5 | Negative Z | negz.ktx | 1024×1024 | 4,194,372 | 4,194,304 |
Together, the six requests transfer 25,166,232 bytes and decode to 25,165,824 RGBA bytes. The minimal KTX1 decoder validates uncompressed RGBA8 or sRGBA8 input and extracts only the base image. It is not a general compressed KTX2 loader. The current assets declare GL_RGBA8, one face, and one mip apiece.
Native code reads the six local files concurrently on worker threads. WebAssembly creates a browser Worker, fetches all six URLs with Promise.all, and transfers their buffers back before starting the renderer. The 6,179-byte WGSL source and 122,752-byte Vazirmatn font are compiled into the program, so they are not additional runtime asset requests.
Build an instanced line of gold spheres
Rust procedurally generates one radius-one sphere with 40 latitude segments and 56 longitude segments. A second vertex buffer supplies one position, roughness, gold color, and metallic value for each of ten instances. One indexed call can therefore draw the complete material line.
Scroll sideways to see all table columns.
| Data | Stride | Elements | Submitted triangles | Buffer bytes | Purpose |
|---|---|---|---|---|---|
| Sphere template | 24-byte vertex | 2,337 vertices + 13,440 u32 indices | 4,480 × 10 = 44,800 | 109,848 | Position and normal reused by every material instance |
| Material instances | 32 bytes | 10 instances | Instanced with sphere | 320 | Position, roughness, color, and metallic |
| Skybox cube | 12-byte vertex | 8 vertices + 36 u16 indices | 12 | 168 | High-resolution environment background |
| Scene uniforms | One block | 288 bytes | Not geometry | 288 | Matrices, camera, lights, exposure, gamma, and maximum LOD |
The ten X positions run from −10.75 through 8.6 in steps of 2.15. The first sphere uses metallic 0.005 and roughness 0.995. Each later instance adds 0.1 metallic and subtracts 0.1 roughness, ending at metallic 0.9 and roughness 0.1. Because those properties change together, the row demonstrates a combined transition rather than independently isolating either parameter.
Every instance uses gold base color (1, 0.765557, 0.336057). The sphere model matrix applies a 90° Y rotation, which does not change its rotationally symmetric silhouette. Explicit geometry, instance, and uniform buffers occupy 110,624 bytes in total, excluding text-overlay resources.
Generate a box-filtered roughness mip chain
Initialization turns the six decoded faces into four sampled GPU textures. All are generated on the CPU and uploaded with queue.write_texture; the example records no preprocessing render or compute passes.
Scroll sideways to see all table columns.
| Resource | Extent and mips | Format | Logical bytes | Creation or update | Role |
|---|---|---|---|---|---|
| Display skybox | 1024×1024 × 6, one mip | Rgba8Unorm | 25,165,824 | 6 uploads at initialization | Sharp background sampled at LOD 0 |
| Roughness environment | 64×64 × 6, seven mips down to 1×1 | Rgba8Unorm | 131,064 | 42 uploads at initialization | Reflection sampled at roughness * 6 |
| Diffuse irradiance | 32×32 × 6, one mip | Rgba8Unorm | 24,576 | 6 uploads at initialization | Cosine-weighted environment around the normal |
| BRDF LUT | 64×64, one mip | Rgba8Unorm | 16,384 | 1 upload at initialization | Split-sum specular scale and bias in red and green |
| Scene depth | Surface width × height, one mip | Depth32Float | 4 bytes per surface pixel | Created at initialization; recreated on resize | Depth-tests skybox and spheres |
The four fixed sampled images reserve 25,337,848 logical bytes before texture-object overhead and receive 55 initialization writes. The high-resolution display cube accounts for 24 MiB by itself. All four color textures use TEXTURE_BINDING | COPY_DST; none is a render attachment.
The seven-level specular source is a box-filtered mip pyramid, not a GGX-prefiltered environment. Rust first area-averages each 1024×1024 face into a 64×64 base, then derives 32, 16, 8, 4, 2, and 1-pixel levels with chained 2×2 averages. The shader binding is named prefiltered_map, but its increasing blur comes from ordinary box downsampling. Sampling roughness * 6 is a compact approximation to rough specular reflection rather than the GGX convolution normally paired with a split-sum LUT.
The CPU interprets source RGB bytes with gamma 2.2, filters in linear space, and encodes each result back to eight-bit values. The environment sampler clamps on every axis and uses linear minification, magnification, and mip filtering, so a fractional explicit LOD blends neighboring texels and mip levels. Each cube face is downsampled independently; the code does not filter across face boundaries.
Convolve diffuse irradiance across the cube
Diffuse image-based lighting needs the incoming environment integrated over the hemisphere around a surface normal. Rust first reduces every source face to 16×16 linear-space RGB. That creates 1,536 directional samples across the six faces.
let weight = (1.0 + u * u + v * v).powf(-1.5);
let cosine = normal.dot(direction);
if cosine > 0.0 {
radiance += color * weight * cosine;
total_weight += weight * cosine;
}
The factor (1 + u² + v²)-1.5 approximates the relative solid angle of a cube-face texel. For each of the 6,144 output texels in the six 32×32 faces, the loop checks all 1,536 directions and keeps the positive cosine hemisphere. That is 9,437,184 candidate dot products during initialization.
Dividing by the accumulated solid-angle and cosine weight keeps a constant environment unchanged. Unlike the per-face box mips, this diffuse convolution reads directions from all six source faces, then uploads one result into every layer of the irradiance cube.
Integrate a 64×64 split-sum BRDF LUT
The second half of the specular approximation depends on view angle and roughness rather than the environment image. Rust generates a 64×64 lookup table whose X axis is N dot V and Y axis is roughness. Coordinates lie at texel centers, from 0.0078125 through 0.9921875.
Each texel evaluates 256 Hammersley points, importance-samples a GGX half vector, reflects the view vector, and accumulates Schlick Fresnel scale and bias. The complete table runs 1,048,576 sampling iterations. It uses alpha = max(roughness * roughness, 0.0001) and an image-lighting Smith term with k = alpha * 0.5.
The two integrated values are clamped, quantized to eight-bit red and green, and uploaded with blue 0 and alpha 255. A clamp-to-edge sampler linearly filters the LUT. This removes hundreds of integration samples from each rendered fragment, at the cost of 64×64 resolution and eight-bit precision.
Combine direct GGX and split-sum image lighting
The fragment shader clamps roughness and metallic to 0.005 through 1, then mixes dielectric reflectance 0.04 toward the gold base color according to metallic. Four fixed lights sit at X and Z combinations of ±15 and Y = −7.5. Each contributes a normalized direction with no separate color, intensity, or distance attenuation.
let f0 = mix(vec3<f32>(0.04), material_color, vec3<f32>(metallic));
let d = d_ggx(dot_nh, roughness);
let g = g_schlicksmith_ggx(dot_nl, dot_nv, roughness);
let f = f_schlick(dot_nv, f0);
let specular = d * f * g / max(4.0 * dot_nl * dot_nv, 0.001);
let kd = (vec3<f32>(1.0) - f) * (1.0 - metallic);
return (kd * material_color / PI + specular) * dot_nl;
The normal-distribution function uses GGX with alpha = roughness². Direct-light visibility uses Schlick-Smith with k = (roughness + 1)² / 8. One implementation detail matters when comparing this code with other Cook–Torrance shaders: its direct Fresnel call receives N dot V, not V dot H.
The image-lighting half samples three precomputed signals and recombines them:
let brdf = textureSample(
brdf_lut, brdf_sampler, vec2<f32>(dot_nv, roughness)
).rg;
let irradiance = textureSample(
irradiance_map, environment_sampler, normal
).rgb;
let prefiltered = textureSampleLevel(
prefiltered_map,
environment_sampler,
reflection,
roughness * uniforms.params.z,
).rgb;
let fresnel = f_schlick_roughness(dot_nv, f0, roughness);
let diffuse = irradiance * input.color;
let specular = prefiltered * (fresnel * brdf.x + brdf.y);
let kd = (vec3<f32>(1.0) - fresnel) * (1.0 - metallic);
let ambient = kd * diffuse + specular;
Roughness-aware Schlick Fresnel reduces the grazing response as roughness increases. Multiplying diffuse by 1 - metallic removes the diffuse lobe from a metal, while the split-sum scale and bias modulate the environment reflection. The shader adds this ambient result to the four direct contributions before tone mapping.
Render the skybox and spheres in one scene pass
Both pipelines share one 288-byte uniform and one seven-entry bind group. The group contains the uniform, irradiance cube, a shared environment sampler, BRDF LUT and sampler, box-filtered environment cube, and display skybox cube. The same environment sampler handles all three cube lookups; the separately created skybox and irradiance samplers remain unbound.
The uniform stores three 64-byte matrices, camera position, four light vectors, and four parameters: exposure 4.5, output gamma, maximum LOD 6, and an unused zero. It is initialized once and fully rewritten on resize, not every frame.
Scroll sideways to see all table columns.
| Pass | Attachments | Draw | Triangles | Depth behavior | Result |
|---|---|---|---|---|---|
| Scene | Surface color + surface-size depth | Skybox, then one 10-instance sphere draw | 12 + 44,800 = 44,812 | LessEqual; skybox does not write, spheres write | Environment background and material line |
| Text overlay | Load surface color; no depth | Framework glyph draw | Framework-managed | No depth attachment | Title, device, FPS, material, and LUT size |
The scene pass clears color to (0.02, 0.022, 0.028, 1) and depth to 1. The skybox vertex shader removes camera translation and writes clip.xyww, placing its cube at far depth. It draws 36 indices first with depth writes disabled. The PBR pipeline then submits 13,440 indices across ten instances with depth writes enabled.
Both triangle-list pipelines use one sample, no face culling, no blending, and Depth32Float with LessEqual. Excluding framework text, each frame records two render passes, two explicit scene draws, 44,812 submitted triangles, and 134,436 index invocations. The overlay adds its instanced glyph draw in the second pass.
Tone-map a fixed camera and environment
WGSL feeds ambient + direct through the Uncharted 2 curve with exposure 4.5 and normalizes the result against a white value of 11.2. When the surface format is sRGB, the shader uses gamma 1 because the target performs the final encoding. A non-sRGB surface uses gamma 2.2 in the shader.
The camera remains at (0.55, 0.85, 12), looks toward (-0.7, 0.15, 0), and uses a 60° right-handed perspective with near and far planes of 0.1 and 256. There are no keyboard, mouse, pointer, touch, camera, light, exposure, or material controls. The scene has no automatic animation.
Resizing recreates the surface-size depth texture, rewrites the 288-byte uniform for the new aspect ratio, and rebuilds overlay placement. The four fixed color textures and geometry buffers remain unchanged. The 22 px Vazirmatn overlay updates its CPU frame-rate estimate on a roughly 500 ms cadence; overlay preparation still runs every frame.
Run and extend the example
From a local checkout with Rust installed, run the native PBR image-based lighting example:
cargo run --example pbribl
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 pbribl
cargo run --bin serve
Open http://127.0.0.1:8080/pbribl/ in a browser with WebGPU support. Native loading is synchronous from the caller's perspective after its worker threads join. WebAssembly awaits the Worker-loaded faces and then starts sib::render. Both targets perform the same CPU preprocessing and run the same two render passes with no rendering fallback.
The example keeps every stage inspectable, but its environment data is LDR and quantized to Rgba8Unorm. CPU filtering decodes source bytes with gamma 2.2 and re-encodes the results; the sampled GPU textures are not sRGB formats, and WGSL applies no explicit source decode. The 64×64 BRDF LUT also has only eight bits per channel.
Startup transfers about 24 MiB of uncompressed cubemap files and rebuilds the display cube, box mip chain, irradiance, and LUT on every launch. Independent per-face mip filtering can leave seams. The scene has no HDR source, true GGX environment convolution, precomputed derived maps, light attenuation, shadows, ambient occlusion, normal mapping, material textures, or controls.
Useful changes to try:
- Precompute a floating-point HDR irradiance cube, GGX-filtered specular mip chain, and higher-precision BRDF LUT, then compare them with the current CPU-generated approximations.
- Build a two-dimensional roughness and metallic grid so each parameter can vary independently.
- Filter across cube-face boundaries and compare seam behavior at high roughness and grazing reflection angles.
- Add camera, exposure, and environment controls, then introduce physically scaled lights, distance attenuation, shadows, and ambient occlusion.