WebGPU notes / 31
WebGPU Deferred MSAA in Rust: Manual 4x Resolve with wgpu
Rasterize animated Jax and a checkerboard floor into four-sample position, normal, albedo, and depth attachments, shade every stored G-buffer sample against six lights, then average the lit results into a single-sample WebGPU surface.
- G-buffer samples
- 4×
- Light evaluations per pixel
- 24
- G-buffer bytes per pixel
- 96
Resolve after per-sample lighting
The previous WebGPU deferred shading example stores one world position, normal, and albedo/specular record per output pixel, then applies six lights in a fullscreen pass. This version keeps the same scene and two-pass architecture but gives every G-buffer attachment four samples. At a triangle boundary, different samples can belong to the floor, Jax, or cleared background. The composition shader lights those records independently and averages the four lit colors.
This ordering matters. Averaging world positions and normals first would create a synthetic surface record between unrelated pieces of geometry. The example instead preserves multisample coverage through lighting, then resolves the final result in WGSL. That is different from the regular 4x MSAA example, where WebGPU's fixed-function path resolves one scene color attachment directly into the presentation surface. Here all three G-buffer colors have resolve_target: None; the shader reads their stored samples explicitly.
The introducing deferred multisampling commit added the Rust example, WGSL shader, both screenshots, and build registration. The gallery registration followed in the next example commit. Current Rust also includes the modernized WebAssembly entry and direct WebGPU perspective projection. The shader's later safe-normalize fix prevents cleared zero normals from producing NaNs. Rendering runs through sib::render.
Load three Jax assets
The shared asset helper and skinned glTF loader first read jax.gltf, then resolve its external jax.bin buffer and jax_base_color.png image. Native startup reads the buffer batch and then the image batch after parsing the glTF. WebAssembly fetches the glTF, then uses a temporary asset Worker for each of those two batches before it starts the renderer.
Scroll sideways to see all table columns.
| Order | Input | Stored bytes | Encoded structure | Decoded or uploaded representation | Purpose |
|---|---|---|---|---|---|
| 1 | jax.gltf | 68,324 | JSON with 146 accessor declarations | 59 nodes, one mesh, one skin, one animation | References scene, geometry, skin, animation, and material |
| 2 | jax.bin | 1,957,752 | Binary data backing 146 buffer views | Attributes, indices, inverse binds, and keyframes | Mesh and skeletal animation payload |
| 3 | jax_base_color.png | 13,008 | 1024×1024 RGB8 PNG | 4,194,304-byte RGBA8 image | One-mip sRGB character texture |
| Compiled | deferredmultisampling.wgsl | 8,995 | 260 lines of WGSL | Two 4x MRT pipelines + one composition pipeline | Geometry capture and manual lighting resolve |
| Compiled | Vazirmatn font | 122,752 | TrueType font | Framework-managed glyph resources | Title, GPU, FPS, sample, and G-buffer overlay |
The three runtime files total 2,039,084 stored bytes, about 1.9446 MiB. Jax has one scene, 59 nodes, one triangle primitive and material, 35,880 positions, 35,880 source u16 indices, one skin, and one animation. There is no COLOR_0, so the loader supplies white. Its base-color factor is also white, metallic factor is zero, and the default one-sided material enables back-face culling for the character.
The RGB PNG expands to RGBA8 and uploads as a 4,194,304-byte, single-sample Rgba8UnormSrgb texture. Its sampler repeats U and V, clamps W, and uses linear magnification and minification. It has only mip zero, so MSAA does not prevent distant texture detail from aliasing. The Jax files arrived in the repository's Jax asset update and remain unchanged; the repository does not include a separate provenance or license file for them.
Animate a 46-joint character and two floor triangles
The one-second Walking_1 clip has 138 channels: translation, rotation, and scale for each of 46 joints. Each update advances the active first animation, caps animation delta at 1/15 second, wraps at the clip boundary, rebuilds the joint palette, and rewrites all 128 reserved matrices. The vertex shader blends four joint matrices per Jax vertex before applying a model transform with 1.75 uniform scale, a 20° Y rotation, and translation to Z = −2.
The procedural floor contributes four 40-byte vertices and six u32 indices, with no file request. It spans X = −8.5 through 8.5, Z = −10 through 5, and Y = −1.12. Jax and the floor together submit 11,962 triangles to the G-buffer pass.
Scroll sideways to see all table columns.
| Resource | Elements | Stride or block | Logical bytes | Update cadence | Submitted work or use |
|---|---|---|---|---|---|
| Jax vertices | 35,880 | 76 bytes | 2,726,880 | Uploaded once | Position, normal, UV, color, four joints, four weights |
| Jax indices | 35,880 u32 | 4 bytes | 143,520 | Uploaded once | 11,960 triangles in one indexed draw |
| Floor geometry | 4 vertices + 6 indices | 40-byte vertex | 184 | Uploaded once | 2 triangles in one indexed draw |
| Joint palette | 128 matrices; 46 used | 64-byte matrix | 8,192 | Fully rewritten every frame | GPU skinning in Jax's vertex stage |
| Jax uniforms | View-projection, model, base color | 144-byte block | 144 | Rewritten every frame | Character transform and material |
| Floor uniforms | Transforms + three instance slots | 224-byte block | 224 | Rewritten every frame | Only instance slot zero is drawn |
| Composition uniforms | Six lights, view position, parameters | 224-byte block | 224 | Rewritten every frame | Fullscreen lighting and debug selector |
The explicit buffers total 2,879,368 bytes. Adding the one 4,194,304-byte character texture gives 7,073,672 fixed logical bytes before the surface-sized G-buffer and overlay resources. The joint palette plus three uniforms account for 8,784 bytes of queue writes per frame.
The loader has since gained a hierarchy-depth guard and stable first-material selection plus per-primitive material records. This example still consumes the merged mesh's first material and base texture, which is sufficient for Jax's one primitive.
Store a 96-byte four-sample G-buffer per output pixel
The floor and skinned-character pipelines use four rasterization samples, a full sample mask, and alpha-to-coverage disabled. Their fragment shaders write three multiple render targets: half-float world position, half-float world normal, and normalized albedo with specular strength in alpha. Jax writes a fixed 0.45 specular value; the floor writes 0.08. A four-sample depth texture selects the nearest geometry with LessEqual and depth writes.
Scroll sideways to see all table columns.
| Attachment | Format | Samples and mips | Bytes per output pixel | Logical bytes at 1280×720 | End-of-pass action | Later use |
|---|---|---|---|---|---|---|
| World position | Rgba16Float | 4 samples; 1 mip | 32 | 29,491,200 | Store; no resolve target | Four indexed loads for lighting |
| World normal | Rgba16Float | 4 samples; 1 mip | 32 | 29,491,200 | Store; no resolve target | Four indexed loads for lighting |
| Albedo/specular | Rgba8Unorm | 4 samples; 1 mip | 16 | 14,745,600 | Store; no resolve target | Per-sample lighting + ambient average |
| Depth | Depth32Float | 4 samples; 1 mip | 16 | 14,745,600 | Discard | Not bound, sampled, or resolved |
| Total | Three colors + depth | 4 samples each | 96 | 88,473,600 | Colors stored; depth discarded | 84.375 MiB before allocation overhead |
The G-buffer is exactly four times the single-sample Deferred example's 24 bytes per pixel. The three sampled colors account for 70.3125 MiB at the screenshot size and depth adds 14.0625 MiB. Combined with the fixed mesh, uniform, and material payloads, the explicit logical allocation is 95,547,272 bytes, about 91.121 MiB, before the presentation surface, text, joystick, row alignment, and implementation-specific texture overhead.
This is multisampling, not rendering at four times the width or height. All attachments retain the physical surface dimensions, one mip, and one array layer. MSAA supplies per-sample coverage, depth, and storage at geometry edges. The geometry shaders do not use sample_index, sample-qualified interpolation, or a sample mask output, so the code does not request four geometry fragment invocations per output pixel. A resize recreates all four attachments and the composition bind group.
Resolve four lit samples manually in WGSL
The single-sample composition pipeline binds the three color views as texture_multisampled_2d<f32>. One fullscreen triangle covers the surface. Its fragment shader converts UV to an integer texel coordinate, indexes samples zero through three with textureLoad, evaluates six lights for every sample, and divides the accumulated lighting by four. Ambient uses a separate average of the four albedo values.
let albedo = resolve_albedo(coord);
var lighting = vec3<f32>(0.0);
for (var sample_index = 0u;
sample_index < MSAA_SAMPLE_COUNT;
sample_index = sample_index + 1u) {
let sample = load_gbuffer_sample(coord, sample_index);
lighting = lighting + calculate_lighting(
sample.position.xyz,
safe_normalize(sample.normal.xyz),
sample.albedo,
);
}
let frag_color = albedo.rgb * 0.15
+ lighting / f32(MSAA_SAMPLE_COUNT);
Scroll sideways to see all table columns.
| Source stage | Iterations | Multisampled loads | Logical source bytes per output fragment | Lighting work | Result |
|---|---|---|---|---|---|
| First-sample debug prefetch | 1 | 3 | 20 | None in normal mode | Position, normal, and albedo for dormant debug branches |
| Ambient albedo resolve | 4 samples | 4 | 16 | One four-way average | 15% ambient color |
| Per-sample lighting | 4 samples | 12 | 80 | 4 samples × 6 lights = 24 evaluations | Four lit colors accumulated and averaged |
| Normal-path total | One output fragment | 19 | 116 | 24 light evaluations | One opaque surface color |
The 116-byte figure follows source-level texel values: 8-byte position, 8-byte normal, and 4-byte albedo records. It is not measured physical bandwidth; a compiler, cache, or texture implementation may remove or hide repeated reads. At 1280×720, the source expresses 106,905,600 logical loaded bytes and 22,118,400 light evaluations per frame. Sample zero and albedo are loaded redundantly because the debug prefetch executes before the normal branch and ambient has its own albedo loop.
Each light uses Lambert diffuse, reflect-vector specular, and attenuationNumerator / (distance² + 1) with no hard radius cutoff. Five lights move along phase-shifted paths over a five-second cycle; one yellow light remains fixed. Cleared samples have zero albedo and normal. safe_normalize replaces a zero normal with a fixed axis, while zero albedo keeps uncovered contributions black and lets the four-way average weight edge coverage.
This example is not a controlled image-quality comparison with Deferred. Its ambient term is 15%, versus 2.5% in the single-sample shader, and its specular exponent is 8 instead of 16. Those changes make the output brighter with broader highlights independently of MSAA. WGSL also contains position, normal, albedo, and specular diagnostic branches, but they display sample zero only; Rust fixes debug_target to zero and exposes no selector.
Record two passes and three explicit draws
Scroll sideways to see all table columns.
| Pass | Attachments | Pipeline sample count | Explicit draws | Submitted triangles | Store or resolve behavior | Visible result |
|---|---|---|---|---|---|---|
| G-buffer | 4x position, normal, albedo, depth | 4 | Floor, then skinned Jax | 2 + 11,960 = 11,962 | Store colors without resolve; discard depth | Four samples of visible surface data |
| Composition and overlay | Single-sample presentation surface; no depth | 1 | Fullscreen triangle, text, optional joystick geometry | 1 explicit composition triangle + framework overlay | Manual shader average; store surface | Six-light result, diagnostics text, and controls |
The floor pipeline has no culling. Jax's one-sided material makes its pipeline cull back faces. Neither G-buffer pipeline blends or enables alpha-to-coverage. The composition pipeline is single-sample, has no depth attachment, and returns alpha one. Excluding framework-managed overlay geometry, a frame records two passes, three explicit draws, and 11,963 submitted triangles.
The second pass clears dark blue, but the fullscreen triangle overwrites the surface and fully empty G-buffer pixels produce black. The 21 px text and optional joystick draw afterward in the same pass, so the overlay itself is not multisampled. The text reads “Multi sampled deferred shading,” GPU device information, FPS, “MSAA samples: 4x,” and “G-buffer: position, normal, albedo.”
Control the first-person camera
The live example uses the shared joystick and first-person camera helper. The camera starts at (0, 1.35, 5) with zero yaw and a −0.04-radian pitch. It uses a right-handed 60° perspective projection 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 clamps to ±1.45 radians, combined input clamps to unit length, and the movement delta caps at 1/15 second. Focus loss resets input. The example has no collision, camera reset, animation pause, light controls, sample-count selector, or diagnostic selector.
Run and extend the example
From a local checkout with Rust installed, run the native WebGPU deferred multisampling example:
cargo run --example deferredmultisampling
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 deferredmultisampling
cargo run --bin serve
Open http://127.0.0.1:8080/deferredmultisampling/ in a browser with WebGPU support. Both targets load the same three Jax files before calling sib::render, allocate the same four-sample attachments, and record the same G-buffer and composition passes.
The sample count is hard-coded independently as four in Rust and WGSL. Changing it requires rebuilding the attachments and geometry pipelines and keeping the shader loop synchronized; there is no runtime capability query, selector, 1x comparison, or fallback. The implementation stores 96 bytes per output pixel and executes all 24 light terms even for background pixels. It has no tiled or clustered light culling, hardware color resolve, depth resolve, or later use for the depth texture. Reconstructing world position from depth could remove the 32-byte-per-pixel position target.
The manual path performs repeated albedo and sample-zero reads, and its four-way lighting cost scales directly with sample count. MSAA improves polygon coverage boundaries; it does not generate texture mipmaps or solve shader and texture minification aliasing. The half-float position target loses absolute precision as world coordinates grow, while the half-float normal target quantizes normalized directions. Diagnostic targets show one sample rather than resolved data, and the single-sample overlay remains outside the antialiased scene.
The renderer has no transparency, HDR intermediate, exposure, tone mapping, shadows, ambient occlusion, emissive term, normal mapping, or physically based material model. Jax writes fixed specular strength 0.45 instead of material or texture alpha. The loader bakes base-color factor into vertex color and the shader multiplies that factor again; Jax's white factor hides the duplicate application. Normals are not transformed by an inverse-transpose matrix, and specular is not gated by a positive N dot L.
The animation helper linearly interpolates translations and scales and spherically interpolates rotations without preserving glTF sampler interpolation modes. Jax declares 91 STEP and 47 LINEAR channels, so current playback does not retain its STEP transitions. The retained animated scene also keeps CPU mesh data and cloned image buffers after GPU upload.
Useful changes to try:
- Expose 1x, 2x, and 4x modes, query supported counts, and synchronize the Rust allocation with the WGSL loop.
- Remove the debug prefetch and duplicate albedo reads, then profile composition loads and light work.
- Reconstruct position from multisampled depth and compare memory, precision, and edge behavior.
- Add tiled light lists, HDR lighting, tone mapping, and a forward path for transparent materials.