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

WebGPU notes   /   28

WebGPU Multisampling in Rust: 4x MSAA with wgpu

Load one self-contained Voyager glTF, flatten three material primitives into 20,378 triangles, and rasterize them into four-sample color and depth attachments before WebGPU performs a fixed-function color resolve into the presentation surface.

Rasterization samples
Voyager triangles
20,378
Runtime scene assets
1

Smooth Voyager's geometry edges with 4x MSAA

The previous WebGPU parallax occlusion mapping example adds apparent depth to a flat two-triangle plane inside its fragment shader. Multisampling addresses a different rasterization problem. The Voyager remains ordinary triangle geometry, but each output pixel receives four coverage locations and four color and depth storage samples. At the end of the scene pass, the GPU combines the multisampled color into the single-sample surface shown by the browser or native window.

The introducing multisampling commit added the example, WGSL shader, Voyager glTF asset, and gallery registration. The model and shader remain byte-identical to that commit. The current Rust source also includes a modernized WebAssembly entry point, removal of an obsolete clip-space adjustment, and a fallback-material update. The current 1280×720 screenshot arrived in a later screenshot commit, and the gallery's canonical host moved to pooya.ai in the domain update. Rendering runs through sib::render.

Textured Voyager spacecraft on a white background rendered with four-sample WebGPU multisampling.
The textured Voyager spacecraft is drawn against a white clear color. Four-sample coverage softens the high-contrast polygon silhouettes and thin structural edges before the color attachment resolves to the surface.

Load one self-contained Voyager asset

The demo issues one runtime request through the shared asset helper. Native startup reads assets/models/voyager.gltf from disk. WebAssembly awaits one browser fetch for ../assets/models/voyager.gltf, then starts the renderer. Geometry and PNG images live inside the same Base64 data-URI buffer, so they do not create separate network requests.

Scroll sideways to see all table columns.

Multisampling runtime asset and embedded data
DataDeliveryStored bytesDimensions or structureDecoded or uploaded representationRole
voyager.gltfRuntime request 13,203,450JSON with one Base64 buffer2,396,228-byte raw embedded bufferComplete scene, geometry, and source images
Geometry and accessorsInside the glTF buffer887,5163 nodes, 3 meshes, 3 primitives, 12 accessors23,914 vertices and 61,134 u32 indicesThree indexed material draws
tex_02_AO.pngbufferView 4392,0211024×1024 RGB8 PNG4,194,304-byte RGBA8 image; uploaded twiceBase color for materials 0 and 1
Voyager_tex_01bufferView 131,116,6851024×1024 RGB8 PNG4,194,304-byte RGBA8 image; uploaded onceBase color for material 2
Buffer alignmentInside the glTF buffer6Padding between buffer viewsNo GPU resourceMaintains byte-view alignment
WGSLCompiled into the program1,86362 source linesShader module at initializationVertex transform and textured lighting
Vazirmatn fontCompiled into the program122,752TrueType fontFramework-managed glyph resourcesTitle, GPU, FPS, and sample-count overlay

The embedded geometry, two PNG files, and six padding bytes sum to the 2,396,228-byte raw buffer. Base64 encoding and glTF JSON expand the requested file to 3,203,450 bytes, about 3.055 MiB. The parser accepts embedded Base64 buffers, embedded image views, triangle primitives, and the first texture-coordinate set; this path rejects external buffers and GLB binary chunks.

The file describes one default scene, three nodes, three meshes, three materials, three texture records, two distinct images, one buffer, 14 buffer views, and 12 accessors. It contains no animations, skins, cameras, or explicit sampler objects. Rust flips the V texture coordinate, supplies white for missing vertex colors, and bakes each node transform into positions and normals before upload.

Flatten three material primitives into one mesh

Each of the three glTF meshes contains one indexed triangle primitive. Rust appends their vertices and converted indices into a shared vertex buffer and a shared u32 index buffer, while retaining one draw range per material. Every primitive provides position, normal, and UV attributes but no COLOR_0, so the active per-vertex color is white.

Scroll sideways to see all table columns.

Voyager geometry and material draw ranges
DrawMaterialFirst indexVerticesIndicesTrianglesBase-color image
1tex_02_AO_dark04331,368456Embedded image 0
2tex_02_AO1,3681,5536,9602,320Embedded image 0
3tex_018,32821,92852,80617,602Embedded image 1
Total3 active materials23,91461,13420,3782 distinct images

A vertex stores a three-component position, three-component normal, two-component UV, and three-component color in a 44-byte stride. The resulting vertex buffer occupies 1,052,216 bytes. Converting all source indices to u32 produces a 244,536-byte index buffer. The three draws bind a different material group but reuse those two buffers and the same scene pipeline.

The object transform translates Voyager by (2.5, 0.35, -7.5) and rotates it −90° around Y. The 144-byte uniform contains a 60° perspective matrix, the current camera view multiplied by that object transform, and light vector (5, -5, 5, 1). It is rewritten every frame as the camera moves and also after a resize changes the aspect ratio.

Upload duplicated one-mip material textures

The image decoder expands the two embedded RGB PNGs into two 4 MiB RGBA vectors. The fallback-material update also appends one generated 1×1 white image and one fallback material. Those three decoded image vectors total 8,388,612 bytes before initialization consumes and drops them.

Texture creation loops over all four materials. Materials 0 and 1 each receive a separate GPU upload of embedded image 0, material 2 uploads image 1, and the unused fallback uploads four white bytes. That reserves 12,582,916 logical texel bytes rather than sharing the first image between its two users. The three current draw ranges never select the fallback bind group.

Every material image becomes a single-sample, one-mip Rgba8UnormSrgb texture with its own sampler and bind group. Because the glTF declares no samplers, the defaults repeat U and V, clamp W, linearly magnify and minify, and select the only mip. MSAA does not create texture mips or antialias texture detail; the missing mip chain can still shimmer under minification.

Allocate four-sample color and depth attachments

Initialization creates a color target in the current surface format and a Depth32Float target. Both match the surface width and height, contain one mip and one array layer, use four samples, and are restricted to render-attachment usage. A resize recreates both images.

Scroll sideways to see all table columns.

Multisampling GPU resources and render attachments
ResourceExtent or elementsFormatSamples and mipsLogical bytesUpdate or lifetime
Voyager vertices23,914 × 44-byte strideFour vertex attributesNot a texture1,052,216Uploaded once
Voyager indices61,134 u32 valuesUint32Not a texture244,536Uploaded once
Scene uniformOne blockTwo matrices + light vectorNot a texture144Rewritten every frame
Material textures3 × 1024×1024 + 1 × 1×1Rgba8UnormSrgb1 sample; 1 mip each12,582,916Four initialization uploads
MSAA colorSurface width × heightCurrent surface format4 samples; 1 mip16 × width × height for a 4-byte formatRecreated on resize; discarded after resolve
MSAA depthSurface width × heightDepth32Float4 samples; 1 mip16 × width × heightRecreated on resize; discarded after scene pass
Presentation surfaceSurface width × heightCurrent surface format1 sampleFramework-managedReceives color resolve, then overlays

The fixed mesh, uniform, and four material texture payloads total 13,879,812 bytes, excluding texture-object alignment and framework overlays. At the screenshot's 1280×720 size and a four-byte surface format, each four-sample attachment accounts for 14,745,600 logical bytes, or 14.0625 MiB. Color and depth together account for 29,491,200 bytes, or 28.125 MiB, before implementation-specific allocation overhead.

This is multisampling, not supersampling. The attachments stay at the surface's output dimensions; each pixel stores four raster samples instead of rendering a larger image and downscaling it. Coverage and depth are tracked at those sample locations. The shader has no sample_index, sample mask output, or sample-qualified interpolation, so the code does not request or guarantee four fragment-shader invocations per output pixel.

Shade, resolve, then draw single-sample overlays

The scene pipeline uses triangle-list topology, no face culling, no blending, a full sample mask, and alpha-to-coverage disabled. Depth32Float writes are enabled with LessEqual. The fragment shader samples an sRGB base-color texture, applies a diffuse floor of 0.15, and adds a reflected-vector specular highlight with exponent 16 and strength 0.75.

let base = textureSample(color_map, color_sampler, input.uv).rgb
    * input.color;
let diffuse = max(dot(normal, light_dir), 0.15)
    * input.color;
let specular = pow(max(dot(reflect_dir, view_dir), 0.0), 16.0)
    * vec3<f32>(0.75);

return vec4<f32>(diffuse * base + specular, 1.0);

The current Voyager supplies white vertex and base-color factors, so multiplying input.color into both base and diffuse is neutral. For a non-white factor, the implementation would apply that color twice. Texture sampling automatically decodes Rgba8UnormSrgb RGB into linear values; final surface encoding depends on the surface format chosen by the rendering framework.

Scroll sideways to see all table columns.

Multisampling render passes, attachments, and submitted work
PassColor attachmentDepth attachmentPipeline and draw workStore or resolveVisible result
Voyager sceneFour-sample color; clear whiteFour-sample Depth32Float; clear 1Three indexed material draws; 61,134 indices; 20,378 trianglesResolve color to surface; discard multisampled color and depthAntialiased geometry coverage
Text and joystick overlayLoad resolved single-sample surfaceNoneFramework glyph draw plus active joystick geometryStore surface; no resolveTitle, GPU, FPS, 4x count, and optional stick rings

The scene pass names the single-sample swapchain view as the color attachment's resolve target. When the pass ends, fixed-function resolve combines the four stored colors into one surface color. The multisampled color store can then be discarded. The depth samples are also discarded because this example has no depth resolve and no later pass reads them.

The second pass loads that resolved surface and draws the 22 px Vazirmatn text followed by the joystick overlay without a depth attachment. Those framework pipelines are single-sample and operate after the resolve, so the overlay itself is not part of the four-sample scene. The text reads “Multisampling,” the GPU device information, FPS, and “rasterization samples: 4x.”

Control the first-person camera

The example uses the shared joystick and first-person camera helper. The camera starts at the origin with yaw and pitch zero, looking along negative Z. Its horizontal movement speed is four world units per second, its look speed is 1.6 radians per second, and pitch is clamped between −1.45 and 1.45 radians.

  • Press W and S to move forward and backward, and A and D to move left and right.
  • Use the arrow keys to look up, down, left, and right.
  • Press and drag on the left half of the canvas for movement or the right half for looking. Mouse and touch use the same virtual-stick path with a 44 px maximum radius.

Keyboard and virtual-stick axes combine and clamp to unit length. Losing focus resets input, while releasing or cancelling a pointer clears its stick. Camera motion updates the view-model uniform each frame; the delta is capped at 1/15 second so a stalled frame cannot produce an unbounded movement step. Active stick rings appear in the post-resolve overlay pass.

Run and extend the example

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

cargo run --example multisampling

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

Open http://127.0.0.1:8080/multisampling/ in a browser with WebGPU support. Native startup reads and parses the glTF before calling sib::render. WebAssembly awaits its one fetch and then starts the same renderer asynchronously. Both targets flatten the same geometry, upload the same material data, allocate surface-sized four-sample attachments, and record the same two render passes.

The sample count is fixed at four with no runtime selector or capability-based fallback. There is no built-in lower-sample comparison, explicit per-sample shader evaluation, alpha-to-coverage, or depth resolve. MSAA smooths polygon coverage boundaries; it does not fix shader aliasing or the material textures' missing mipmaps. The two uses of the first embedded image also remain separate GPU allocations.

The lighting is intentionally compact rather than physically based. Its upper-left view-model matrix transforms the light as a direction and drops translation, the base-color factor would be applied twice when non-white, normal transformation assumes rigid or uniform scale, and back-face normals are not flipped even though culling is disabled. The shader ignores texture alpha and glTF metallic, roughness, emissive, occlusion, normal-map, and alpha-mode data. It has no HDR intermediate, tone mapping, shadows, or distance attenuation.

Useful changes to try:

  • Query supported sample counts and rebuild the pipeline and attachments when the selected count changes.
  • Deduplicate shared glTF images, generate a mip chain, and compare geometry-edge coverage with texture minification behavior.
  • Move lighting into consistent world or view space, apply material color once, and transform normals with an inverse-transpose matrix.
  • Add an edge magnifier or sample-coverage visualization while keeping the text and joystick after the scene resolve.