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

WebGPU notes   /   25

WebGPU PBR Textures in Rust: Cerberus Materials and IBL

Load the ornate Cerberus mesh and five material maps, reconstruct its tangent-space surface, and combine four-light GGX shading with CPU-generated image-based lighting.

Material maps
5
Runtime assets
12
Bind entries
13

Move PBR properties into textures

The previous PBR Basic example varies constant metallic and roughness values across a generated sphere grid. PBR Texture applies those material properties per texel instead. One detailed glTF mesh receives separate albedo, tangent-space normal, ambient-occlusion, metallic, and roughness images, while a bridge cubemap supplies the background, diffuse environment light, and rough reflections.

The original PBR examples commit introduced the Cerberus model, material images, shader, screenshot, and web registration. The current Rust example and unchanged WGSL shader retain that rendering design. Later work removed an obsolete clip-space correction and replaced approximate CPU preprocessing with gamma-aware mip filtering, cosine-convolved irradiance, numerical BRDF integration, and surface-aware output encoding. The settings window became collapsible, and a shared asset update replaced the bridge JPEGs with KTX faces. The demo runs through sib::render.

An ornate black, brass, and wood Cerberus triple-barrel pistol rendered against a sunlit bridge cubemap beside PBR controls.
Five authored textures preserve the engraved metal, wooden grip, brass details, and small surface relief of the Cerberus pistol. The same bridge environment appears behind the model and contributes diffuse and specular lighting.

Load Cerberus and eleven images

The current demo makes twelve asset requests totaling 50,453,345 bytes, or about 48.12 MiB. The browser first awaits the model, then fetches the five PNG material maps concurrently in a Web Worker, followed by a second concurrent batch for six KTX cubemap faces. Native execution follows the same sequence with local filesystem reads and one thread per batched request.

Scroll sideways to see all table columns.

PBR Texture runtime assets
Asset groupFilesDimensions or contentsEncoded sizeRole
cerberus.gltf1One embedded buffer; one triangle primitive2,372,173 bytesPosition, normal, tangent, UV, and indices
Cerberus PNG maps5Each 2048×2048 RGBA822,914,940 bytesAlbedo, normal, AO, metallic, and roughness
Bridge KTX faces6Each 1024×1024 RGBA8; one mip25,166,232 bytesSkybox and source environment

The asset helper decodes each PNG to RGBA8. The shared Bridge2 loader requests cubemap faces in positive-X, negative-X, positive-Y, negative-Y, positive-Z, negative-Z order, and the KTX 1 decoder extracts each uncompressed base mip. The WGSL source and 122,752-byte Vazirmatn font are compiled into the program rather than fetched separately.

The glTF JSON embeds its only 1,776,320-byte binary buffer as a Base64 data URI. It has one default scene, three nodes, one mesh, one material record, and one indexed triangle primitive. The example reads the geometry but deliberately ignores the glTF material record and applies its five separately requested images to the whole merged mesh.

Normalize and upload Cerberus

Rust traverses the default scene, multiplies parent and child transforms, and bakes them into vertex positions, normals, and tangents. It flattens the primitive into one interleaved vertex vector, converts the source 16-bit indices to u32, validates every index, and measures the transformed bounds.

The model matrix then translates the bounds center to the origin, scales the largest extent to six units, and rotates the result 90° around Y. The final display bounds are approximately X = ±3, Y = ±1.268, and Z = ±0.625. The camera therefore sees the pistol side-on without embedding presentation scale or orientation into the source asset.

Scroll sideways to see all table columns.

PBR Texture geometry and explicit GPU buffers
BufferItemsStrideGPU payloadPurpose
Cerberus vertices32,81448 bytes1,575,072 bytesPosition, normal, UV, and tangent
Cerberus indices100,6234 bytes402,492 bytes33,541 mesh triangles
Skybox vertices812 bytes96 bytesCube positions
Skybox indices362 bytes72 bytes12 background triangles
Scene uniforms1288 bytes288 bytesMatrices, camera, lights, and tone-map parameters

These buffers reserve 1,978,020 bytes. The custom loader accepts triangle primitives and Base64 data-URI buffers, but rejects external .bin files and GLB binary chunks. It also merges primitive boundaries, supplies simple fallback normals, UVs, and tangents when attributes are absent, and does not retain separate glTF materials or draw ranges.

Upload five material maps

Each material image becomes an independent one-mip Rgba8Unorm texture. One repeat-addressed sampler with linear minification and magnification serves all five shader bindings. Because there is no mip chain, the configured nearest mip filtering has no lower-resolution level to select.

WGSL raises albedo RGB to 2.2 to approximate conversion from authored display values into linear space. It remaps normal RGB from 0–1 to −1–1 and transforms that vector through the per-vertex tangent, bitangent, and normal basis. AO, metallic, and roughness each come from the red channel of a separate RGBA image. Metallic is clamped to 0.005–1 and roughness to 0.045–1 to avoid singular endpoints.

let albedo = pow(
    textureSample(albedo_map, material_sampler, input.uv).rgb,
    vec3<f32>(2.2),
);
let ao = textureSample(ao_map, material_sampler, input.uv).r;
let metallic = clamp(
    textureSample(metallic_map, material_sampler, input.uv).r,
    0.005, 1.0,
);
let roughness = clamp(
    textureSample(roughness_map, material_sampler, input.uv).r,
    0.045, 1.0,
);

Five uncompressed 2048×2048 RGBA8 GPU images occupy exactly 83,886,080 bytes, or 80 MiB. Their decoded CPU vectors remain inside PbrTextureAssets after upload, retaining another 80 MiB of pixel data. Packing AO, roughness, and metallic into separate channels and releasing decoded images would substantially reduce this example's persistent memory.

Generate image-based lighting on the CPU

Initialization turns the same six Bridge2 faces into three cube textures and a two-dimensional BRDF lookup table. This preparation runs on the CPU and uploads bytes with queue.write_texture; it records no preprocessing render or compute pass.

Scroll sideways to see all table columns.

PBR Texture authored, generated, and depth images
TextureFormatExtentMipsLogical storagePurpose
Five material mapsRgba8Unorm5 × 2048×2048183,886,080 bytesSurface properties
Display skyboxRgba8Unorm1024×1024 × 6125,165,824 bytesVisible bridge background
Reflection environmentRgba8Unorm64×64 × 67131,064 bytesRoughness-selected reflections
Diffuse irradianceRgba8Unorm32×32 × 6124,576 bytesCosine-weighted ambient diffuse
BRDF LUTRgba8Unorm64×64116,384 bytesSplit-sum specular scale and bias
Scene depthDepth32FloatSurface size1width * height * 4Depth-tests skybox and model

The fixed color images total 109,223,928 logical bytes, about 104.16 MiB. The display cube preserves 1024×1024 faces. The smaller reflection cube starts at 64×64 and repeatedly averages 2×2 texel blocks in a gamma-decoded working space to produce 32, 16, 8, 4, 2, and 1 pixel levels.

Those seven levels are a box-filtered roughness approximation. They are not a GGX-importance-sampled specular prefilter. The shader still selects roughness * 6 as its fractional LOD, so rough surfaces receive smoother reflections, but the lobe does not match the GGX distribution used by direct lighting.

Diffuse preparation resamples every source face to 16×16, assigns cubemap solid-angle weights, and cosine-convolves 1,536 source directions into 6,144 output texels. That is 9,437,184 candidate direction tests. The BRDF generator runs 256 Hammersley GGX samples at each of 4,096 lookup texels, for 1,048,576 integration iterations before quantizing scale and bias into eight-bit red and green channels.

Color handling remains approximate. Cubemap pixels are decoded for filtering, re-encoded into bytes, uploaded as linear Rgba8Unorm, and sampled without a matching shader decode or sRGB texture view. The sampled environment therefore is not strictly linear, and its eight-bit LDR storage limits lighting range and BRDF precision.

Bind thirteen PBR resources

One bind group serves the skybox and mesh pipelines. The uniform is visible to both vertex and fragment stages; every image and sampler is fragment-only. There are no dynamic offsets, immediate values, push constants, storage resources, or optional WebGPU features.

Scroll sideways to see all table columns.

PBR Texture bind-group layout
BindingResourceView or typeShader use
0Scene uniforms288-byte uniform bufferTransforms, camera, lights, exposure, gamma, and max LOD
1IrradianceCube textureDiffuse IBL
2Environment samplerFiltering samplerShared by all three cube textures
3BRDF LUT2D textureSpecular scale and bias
4BRDF samplerFiltering samplerClamped LUT lookup
5Reflection environmentCube textureExplicit roughness LOD
6Albedo2D textureBase color
7Normal2D textureTangent-space normal
8AO2D textureAmbient visibility
9Metallic2D textureDielectric-to-metal blend
10Roughness2D textureMicrofacet spread and environment LOD
11Material samplerFiltering samplerShared by all five material maps
12Display skyboxCube textureVisible environment

The 288-byte uniform contains three matrices, the camera position, four light vectors, and one parameter vector. Its final vector stores exposure, an output gamma adjusted for the surface format, maximum reflection LOD 6, and one unused value. The whole block is rewritten before every frame, on resize, and again when an egui control changes.

Nine samplers are created with the nine color texture wrappers, but the bind group references only the environment, BRDF, and albedo samplers. The normal, AO, metallic, roughness, display-skybox, and irradiance sampler objects remain allocated without their own binding.

Shade direct light with GGX and Schlick terms

The fragment shader reconstructs the world normal, view vector, and dielectric-or-metal reflectance F0. Four fixed lights sit at every X/Z combination of ±15 with Y = −7.5. Each loop normalizes the vector from the fragment to one position and evaluates a Cook–Torrance-shaped direct term:

let f0 = mix(vec3<f32>(0.04), albedo, 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 * albedo / PI + specular) * dot_nl;

The distribution uses alpha = roughness², the visibility term uses Schlick-GGX with k = (roughness + 1)² / 8, and the diffuse lobe is Lambertian. Metallic suppresses diffuse energy while moving F0 from 0.04 toward albedo.

One implementation detail separates this shader from the canonical reference formula: Schlick Fresnel receives NdotV, whereas a conventional direct microfacet BRDF uses VdotH. The four position vectors also provide no light color, intensity, distance attenuation, range, or shadow. Each contribution has unit radiance after direction normalization.

Combine diffuse and split-sum image-based lighting

Environment lighting samples diffuse irradiance in the reconstructed normal direction and the reflection cube in the reflected view direction. Roughness chooses the reflection LOD. A two-channel lookup supplies the scale and bias used by the common split-sum specular approximation:

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 ambient = (kd * irradiance * albedo
    + prefiltered * (fresnel * brdf.x + brdf.y)) * ao;

AO multiplies both diffuse and specular environment lighting but not the four direct-light terms. There is no separate specular-occlusion approximation. The shader adds ambient and direct color, applies Uncharted 2 tone mapping with white point 11.2, multiplies by the selected exposure, and performs gamma output.

The default gamma control is 2.2. On an sRGB surface, Rust sends gamma divided by 2.2 so the shader and hardware encoding combine to approximate the chosen result; a linear surface receives the selected gamma directly.

Render the skybox, Cerberus, and live controls

The camera remains at (0.25, 0.08, 4.85), looks at the origin, and uses a 60° right-handed perspective with near and far planes of 0.1 and 256. The skybox view removes translation. Neither the camera, model, nor lights animate.

The main pass clears surface color to (0.02, 0.022, 0.028, 1) and depth to 1. When enabled, the skybox pipeline draws a cube first with depth writes disabled and LessEqual. Its vertex shader emits xyww, placing the background at depth 1. The mesh pipeline then writes depth for the Cerberus. Both pipelines use one sample, triangle lists, no face culling, and no blending.

Scroll sideways to see all table columns.

PBR Texture render passes and default submitted work
PassAttachmentsDrawsTrianglesResult
PBR sceneSurface color + surface-size depthSkybox, then Cerberus33,553Textured pistol and bridge environment
eguiLoad surface color; no depthDynamic UI drawsUI-managedSettings and live statistics

With the skybox enabled, the scene submits two indexed draws: 12 skybox triangles and 33,541 Cerberus triangles. Turning it off removes only the 12-triangle cube draw. egui renders in a second color-load pass with dynamic draw work.

The top-left “PBR texture” window is interactive in both native and browser builds. Exposure defaults to 4.5 and accepts 0.1–12; gamma defaults to 2.2 and accepts 0.8–4; the Skybox checkbox starts enabled. The collapsible panel also reports CPU frame cadence, GPU adapter information, and the model's 32,814 vertices. Window events are forwarded to egui, and a changed setting becomes visible on the following continuously requested frame.

Resizing recreates the one-sample Depth32Float image and rewrites camera matrices for the new aspect. It does not rebuild the fixed material or environment textures.

Run and extend the example

From a local checkout with Rust installed, run the native textured PBR example:

cargo run --example pbrtexture

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 pbrtexture
cargo run --bin serve

Open http://127.0.0.1:8080/pbrtexture/ in a browser with WebGPU support. The example requests no optional WebGPU feature and uses downlevel default limits. Native and WebAssembly use the same geometry, generated IBL data, pipelines, controls, and WGSL with no rendering fallback.

The example is intentionally readable rather than production-complete. It retains large decoded material images, creates unused duplicate samplers, supplies no material mipmaps, uses eight-bit IBL resources, box-filters rather than GGX-prefilters reflection levels, approximates cubemap color transfer, ignores glTF material boundaries, and has no camera control, light attenuation, shadows, emissive map, or environment rotation.

Useful changes to try:

  • Pack AO, roughness, and metallic into channels, generate a material mip chain, and release decoded CPU images after upload.
  • Replace the box-filtered reflection pyramid with HDR GGX importance sampling, store the BRDF LUT at higher precision, and make every environment sample linear.
  • Evaluate direct Fresnel with VdotH, add radiometric light colors and inverse-square attenuation, then compare direct and environment energy.
  • Add orbit-camera and environment-rotation controls before continuing to the dedicated image-based-lighting example.