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

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
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.

Animated rabbit-like Jax character walking across a checkerboard floor under colored lights with 4x deferred multisampling.
The 1280×720 view shows Jax walking under six colored lights, with the overlay reporting “MSAA samples: 4x.” The JPEG and WebP files are unchanged from the introducing commit and predate the later safe-normalize shader fix.

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.

Deferred multisampling runtime assets and compiled inputs
OrderInputStored bytesEncoded structureDecoded or uploaded representationPurpose
1jax.gltf68,324JSON with 146 accessor declarations59 nodes, one mesh, one skin, one animationReferences scene, geometry, skin, animation, and material
2jax.bin1,957,752Binary data backing 146 buffer viewsAttributes, indices, inverse binds, and keyframesMesh and skeletal animation payload
3jax_base_color.png13,0081024×1024 RGB8 PNG4,194,304-byte RGBA8 imageOne-mip sRGB character texture
Compileddeferredmultisampling.wgsl8,995260 lines of WGSLTwo 4x MRT pipelines + one composition pipelineGeometry capture and manual lighting resolve
CompiledVazirmatn font122,752TrueType fontFramework-managed glyph resourcesTitle, 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.

Deferred multisampling scene geometry and explicit GPU buffers
ResourceElementsStride or blockLogical bytesUpdate cadenceSubmitted work or use
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 indices40-byte vertex184Uploaded once2 triangles in one indexed draw
Joint palette128 matrices; 46 used64-byte matrix8,192Fully rewritten every frameGPU skinning in 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 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.

Four-sample Deferred MSAA G-buffer attachments at 1280 by 720
AttachmentFormatSamples and mipsBytes per output pixelLogical bytes at 1280×720End-of-pass actionLater use
World positionRgba16Float4 samples; 1 mip3229,491,200Store; no resolve targetFour indexed loads for lighting
World normalRgba16Float4 samples; 1 mip3229,491,200Store; no resolve targetFour indexed loads for lighting
Albedo/specularRgba8Unorm4 samples; 1 mip1614,745,600Store; no resolve targetPer-sample lighting + ambient average
DepthDepth32Float4 samples; 1 mip1614,745,600DiscardNot bound, sampled, or resolved
TotalThree colors + depth4 samples each9688,473,600Colors stored; depth discarded84.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.

Manual Deferred MSAA resolve work in the normal composition path
Source stageIterationsMultisampled loadsLogical source bytes per output fragmentLighting workResult
First-sample debug prefetch1320None in normal modePosition, normal, and albedo for dormant debug branches
Ambient albedo resolve4 samples416One four-way average15% ambient color
Per-sample lighting4 samples12804 samples × 6 lights = 24 evaluationsFour lit colors accumulated and averaged
Normal-path totalOne output fragment1911624 light evaluationsOne 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.

Deferred multisampling render passes and submitted work
PassAttachmentsPipeline sample countExplicit drawsSubmitted trianglesStore or resolve behaviorVisible result
G-buffer4x position, normal, albedo, depth4Floor, then skinned Jax2 + 11,960 = 11,962Store colors without resolve; discard depthFour samples of visible surface data
Composition and overlaySingle-sample presentation surface; no depth1Fullscreen triangle, text, optional joystick geometry1 explicit composition triangle + framework overlayManual shader average; store surfaceSix-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.