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

WebGPU notes   /   30

WebGPU Deferred Shading in Rust: G-Buffer with wgpu

Skin an animated glTF character and draw a checkerboard floor into world-position, world-normal, and albedo G-buffer targets, then evaluate six colored point lights over every output pixel with one fullscreen triangle.

G-buffer color targets
3
Deferred lights
6
Geometry-pass triangles
11,962

Defer lighting to a fullscreen pass

The previous WebGPU alpha-to-coverage example converts oak-leaf texture alpha into four-sample coverage across an instanced grove, then resolves those samples into the surface. Deferred shading changes the frame's organization by separating visibility and material capture from lighting. The first pass writes information about the nearest floor or character fragment into multiple render targets. The second pass reads those targets and calculates every light after geometry has been rasterized.

The introducing deferred shading commit added the Rust example, WGSL shader, screenshot, web registration, and reusable skinned glTF loader. The three Jax files had arrived earlier in the Jax asset update. The current source includes a modernized WebAssembly entry point, direct WebGPU perspective projection, and the safe-normalize fix for cleared background normals. Loader updates later added a hierarchy-depth guard and stable first material plus per-primitive material records. This demo still uses the merged mesh's first material and texture, which is correct for Jax's single primitive. Rendering runs through sib::render.

Animated rabbit-like Jax character walking across a checkerboard floor under colored deferred lights.
Jax walks across a procedural checkerboard while red, yellow, green, blue, warm, and white lights illuminate the character and floor. The current 1280×720 JPEG and WebP screenshots are unchanged from the introducing commit.

Load three Jax assets

The shared asset helper first reads jax.gltf. The loader resolves its external buffer and image paths, then loads jax.bin and jax_base_color.png in separate one-item resource batches. Native builds read local files; WebAssembly fetches the glTF and uses temporary asset Workers for the two dependent batches before starting the renderer.

Scroll sideways to see all table columns.

Deferred shading runtime assets and compiled inputs
OrderInputFile bytesEncoded contentsDecoded or uploaded representationPurpose
1jax.gltf68,324JSON with 146 accessor declarations59 nodes, one mesh, one skin, one animationReferences geometry, animation, material, and texture
2jax.bin1,957,752Binary data backing 146 buffer viewsMesh attributes, indices, inverse binds, and keyframesGeometry and skeletal animation data
3jax_base_color.png13,0081024×1024 RGB8 PNG4,194,304-byte RGBA8 imageOne-mip sRGB character texture
Compileddeferred.wgsl7,964233 lines of WGSLThree render pipelinesFloor MRT, skinned MRT, and composition stages
CompiledVazirmatn font122,752TrueType fontFramework-managed glyph resourcesTitle, GPU, FPS, and G-buffer overlay

The three runtime files total 2,039,084 stored bytes, about 1.9446 MiB. The glTF contains one scene, 59 nodes, one mesh and triangle primitive, one material, texture, image, sampler, skin, and animation. It has no vertex-color attribute, so the loader supplies white. The material's base-color factor is also white, metallic is zero, and the default one-sided material lets the character pipeline cull back faces.

The decoded image is uploaded as a single-sample, one-mip Rgba8UnormSrgb texture. Its sampler repeats U and V, clamps W, and uses linear magnification and minification. With no lower-resolution mips, distant texture detail can still alias. The generated floor has no file request or texture.

Animate a 46-joint skin on the GPU

The Walking_1 clip spans one second and contains 138 channels: translation, rotation, and scale for each of 46 joints. Rust advances the active first animation every update, caps its delta at 1/15 second, wraps at the clip boundary, rebuilds the joint palette, and writes the complete 8,192-byte storage buffer. Jax uses 46 of its 128 reserved matrices.

let skin =
    input.joint_weights.x * joints.matrices[u32(input.joint_indices.x)] +
    input.joint_weights.y * joints.matrices[u32(input.joint_indices.y)] +
    input.joint_weights.z * joints.matrices[u32(input.joint_indices.z)] +
    input.joint_weights.w * joints.matrices[u32(input.joint_indices.w)];

The skinned vertex shader applies that weighted matrix to positions and normals, then applies a model transform. A uniform scale of 1.75, 20° Y rotation, and translation to Z = −2 place the animated bind-pose minimum on the floor at Y = −1.12. The floor spans X = −8.5 through 8.5 and Z = −10 through 5.

Scroll sideways to see all table columns.

Deferred shading geometry and explicit GPU buffers
ResourceElements or sizeStrideGPU bytesUpdate cadenceSubmitted work
Jax vertices35,88076 bytes2,726,880Uploaded oncePosition, normal, UV, color, four joints, four weights
Jax indices35,880 u324 bytes143,520Uploaded once11,960 triangles in one indexed draw
Floor geometry4 vertices + 6 u32 indices40-byte vertex184Uploaded once2 triangles in one indexed draw
Joint palette128 matrices; 46 used64 bytes8,192Fully rewritten every frameRead by Jax's vertex stage
Jax uniformsView-projection, model, base color144-byte block144Rewritten every frameCharacter transform and material
Floor uniformsTransforms + three instance slots224-byte block224Rewritten every frameOnly instance slot zero is drawn
Composition uniformsSix lights, view position, parameters224-byte block224Rewritten every frameFullscreen lighting state

These explicit buffers total 2,879,368 bytes. Adding the 4,194,304-byte material texture produces 7,073,672 fixed logical bytes before G-buffer attachments and overlay resources. The character's source vertex and index buffers remain unchanged; animation moves vertices in WGSL through the updated joint palette.

Fill three G-buffer targets in one MRT pass

The geometry pass binds three color attachments at once. Both floor and character fragments write world position, normalized world normal, and material data. The floor shader builds a checker pattern from world-space X and Z. Its albedo alpha is 0.08. Jax samples the sRGB texture, multiplies RGB by its vertex and material colors, and writes a fixed alpha of 0.45. The composition shader later interprets that alpha channel as specular strength rather than transparency.

Scroll sideways to see all table columns.

Full-resolution Deferred G-buffer attachments
AttachmentFormatBytes per pixelStored valueEnd-of-pass actionComposition use
World positionRgba16Float8World XYZ + 1StoreLight distance and direction
World normalRgba16Float8Normalized world XYZ + 1StoreDiffuse and reflected-light directions
Albedo/specularRgba8Unorm4Linear RGB + specular strengthStoreAmbient, diffuse, and specular terms
DepthDepth32Float4Nearest geometry depthDiscardNot bound or sampled

Every attachment has the surface width and height, one layer, one mip, and one sample. The color textures support both RENDER_ATTACHMENT and TEXTURE_BINDING. Composition reads exact texels with textureLoad, so the nearest samplers created alongside those textures are not bound. Depth is also created with texture-binding usage and a comparison sampler, but this example only uses it for visibility in the geometry pass and discards it afterward.

The two floating-point targets store world values at half-float precision. That keeps this scene compact but loses precision as coordinates grow. A larger renderer can often reconstruct position from depth and camera matrices, avoiding the eight-byte position target at the cost of reconstruction math.

Budget 24 bytes per output pixel

Position, normal, albedo, and depth total 24 logical bytes per physical pixel. At the screenshot's 1280×720 resolution, the G-buffer occupies 22,118,400 bytes, or 21.09375 MiB, before row alignment and implementation-specific texture overhead. The three sampled color targets account for 18,432,000 bytes; depth adds 3,686,400 bytes.

Resizing recreates all four attachments at the new surface size and rebuilds the composition bind group so its views point at the replacements. It also updates camera matrices and text placement. Geometry, the material texture, joint storage, and pipelines remain allocated.

Compose six colored lights in one fullscreen draw

A buffer-free vertex shader generates a fullscreen triangle from vertex_index. The fragment shader loads the three G-buffer color texels, adds 2.5% ambient albedo, and loops over all six lights. Five move on phase-shifted paths driven by a five-second cycle; one yellow light stays fixed near the floor.

Scroll sideways to see all table columns.

Six lights evaluated by every deferred composition fragment
LightLinear RGBMotionYAttenuation numeratorVisible role
White(1.5, 1.5, 1.5)Radius-5 orbit03.75Neutral moving illumination
Red(1, 0, 0)Radius-2 orbit centered at X = −4015Strong red pool across the left floor
Blue(0, 0, 2.5)Radius-2 orbit centered at X = 4−15Blue highlight from the right
Yellow(1, 1, 0)Fixed at (0, -0.9, 0.5)−0.92Small center-floor pool
Green(0, 1, 0.2)Phase-shifted radius-5 path−0.55Green moving floor light
Warm(1, 0.7, 0.3)Counter-rotating radius-10 path−125Wide orange illumination
let attenuation = light_radius / (distance * distance + 1.0);
let n_dot_l = max(dot(normal, light_vector), 0.0);
let diffuse = light_color * albedo.rgb * n_dot_l * attenuation;

let reflected = reflect(-light_vector, normal);
let specular = light_color * albedo.a
    * pow(max(dot(reflected, view_vector), 0.0), 16.0)
    * attenuation;

The uniform calls the attenuation numerator a radius, but the shader does not apply a hard cutoff. Every light affects every surface pixel through radius / (distance² + 1). Cleared background pixels contain a zero normal and albedo; safe_normalize substitutes a fixed normal to avoid NaNs, while zero albedo keeps the final background black.

WGSL also contains position, normal, albedo, and specular diagnostic views selected by debug_target values one through four. Rust always uploads zero and exposes no UI for changing it, so the live demo only shows composed lighting.

Record two render passes and three scene draws

Scroll sideways to see all table columns.

Deferred shading render passes and explicit draw work
PassAttachmentsDrawsSubmitted trianglesDepth behaviorResult
G-bufferPosition + normal + albedo + depthFloor, then skinned Jax2 + 11,960 = 11,962LessEqual, writes enabled; discard after passStored surface attributes for visible geometry
Composition and overlaySingle-sample presentation surface; no depthFullscreen triangle, text, optional joystick geometry1 explicit composition triangle + framework-managed overlayNo depth attachmentSix-light shading plus title, GPU, FPS, and controls

The floor pipeline uses no culling. Jax is not marked double-sided, so its pipeline culls back faces. Both geometry pipelines are single-sample triangle lists with no blending. The composition pipeline is also single-sample and overwrites the surface with opaque output. Excluding overlay implementation details, a frame records two passes, three explicit draws, and 11,963 submitted triangles.

The second pass clears the surface dark blue, but the fullscreen triangle covers it and returns black for empty G-buffer pixels. The 21 px Vazirmatn text and joystick geometry render afterward in that same pass. The overlay shows “Deferred shading,” GPU information, FPS, and “G-buffer: position, normal, albedo.”

Control the first-person camera

The demo is interactive through the shared joystick and first-person camera helper. It starts at (0, 1.35, 5) with zero yaw and a slight −0.04-radian downward pitch. Projection uses a 60° right-handed field of view with near and far planes of 0.1 and 256.

  • Use W and S to move forward and backward, and A and D to move left and right at four world units per second.
  • Use the arrow keys to look at 1.6 radians per second.
  • Press and drag on the left half of the canvas to move or the right half to look. Mouse and touch share 44 px virtual sticks.

Pitch is clamped to ±1.45 radians, input axes clamp to unit length, and the update delta is capped at 1/15 second. Losing focus resets input. There is no collision, camera reset, animation pause, light control, or G-buffer diagnostic selector.

Run and extend the example

From a local checkout with Rust installed, run the native WebGPU deferred shading example:

cargo run --example deferred

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

Open http://127.0.0.1:8080/deferred/ in a browser with WebGPU support. Both targets load the same three Jax files before calling sib::render, build the same merged mesh and joint palette, and execute the same geometry and composition passes.

This compact renderer pays 24 bytes per output pixel and stores world position rather than reconstructing it from depth. It evaluates all six lights for every fullscreen fragment with no tiled, clustered, stencil-volume, or compute culling. There are no shadows, ambient occlusion, emissive contribution, normal mapping, physically based BRDF, HDR intermediate, exposure, or tone mapping. Bright sums clamp in the presentation target.

Transparent geometry is not supported: the G-buffer pipelines do not blend, Jax writes a fixed 0.45 specular value instead of material or texture alpha, and composition always outputs alpha one. The material base-color factor is baked into loader vertex color and multiplied again in the deferred character shader; Jax's white factor hides that double application. Normals use the blended skin and model matrices directly instead of inverse transpose, which is safe for the current uniform outer scale but not general nonuniform scaling. Specular is not gated by a positive N dot L.

The skinned pipeline layout also carries the floor uniform layout in group zero even though Jax's shader entry points use group one. Its draw sets only group one after the floor draw has left a compatible group zero bound. Reordering or isolating that draw would require binding group zero explicitly or removing the unused layout.

The animation helper interpolates translations and scales linearly and rotations spherically without retaining glTF sampler interpolation modes. Jax declares 91 STEP and 47 LINEAR channels, but all are smoothed, so playback does not preserve the file's declared STEP transitions. The demo also keeps the CPU scene and cloned image data after GPU upload because it retains the scene for animation.

Useful changes to try:

  • Reconstruct position from depth, compare full and compact G-buffer layouts, and measure bandwidth at several resolutions.
  • Add tiled or clustered light lists, then compare six lights with hundreds of culled lights.
  • Expose the existing diagnostic targets and controls for animation, light count, attenuation, and camera reset.
  • Add HDR lighting, tone mapping, shadows, material-aware PBR data, and a separate forward path for transparent objects.