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
- 4×
- 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.
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.
| Data | Delivery | Stored bytes | Dimensions or structure | Decoded or uploaded representation | Role |
|---|---|---|---|---|---|
voyager.gltf | Runtime request 1 | 3,203,450 | JSON with one Base64 buffer | 2,396,228-byte raw embedded buffer | Complete scene, geometry, and source images |
| Geometry and accessors | Inside the glTF buffer | 887,516 | 3 nodes, 3 meshes, 3 primitives, 12 accessors | 23,914 vertices and 61,134 u32 indices | Three indexed material draws |
tex_02_AO.png | bufferView 4 | 392,021 | 1024×1024 RGB8 PNG | 4,194,304-byte RGBA8 image; uploaded twice | Base color for materials 0 and 1 |
Voyager_tex_01 | bufferView 13 | 1,116,685 | 1024×1024 RGB8 PNG | 4,194,304-byte RGBA8 image; uploaded once | Base color for material 2 |
| Buffer alignment | Inside the glTF buffer | 6 | Padding between buffer views | No GPU resource | Maintains byte-view alignment |
| WGSL | Compiled into the program | 1,863 | 62 source lines | Shader module at initialization | Vertex transform and textured lighting |
| Vazirmatn font | Compiled into the program | 122,752 | TrueType font | Framework-managed glyph resources | Title, 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.
| Draw | Material | First index | Vertices | Indices | Triangles | Base-color image |
|---|---|---|---|---|---|---|
| 1 | tex_02_AO_dark | 0 | 433 | 1,368 | 456 | Embedded image 0 |
| 2 | tex_02_AO | 1,368 | 1,553 | 6,960 | 2,320 | Embedded image 0 |
| 3 | tex_01 | 8,328 | 21,928 | 52,806 | 17,602 | Embedded image 1 |
| Total | 3 active materials | — | 23,914 | 61,134 | 20,378 | 2 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.
| Resource | Extent or elements | Format | Samples and mips | Logical bytes | Update or lifetime |
|---|---|---|---|---|---|
| Voyager vertices | 23,914 × 44-byte stride | Four vertex attributes | Not a texture | 1,052,216 | Uploaded once |
| Voyager indices | 61,134 u32 values | Uint32 | Not a texture | 244,536 | Uploaded once |
| Scene uniform | One block | Two matrices + light vector | Not a texture | 144 | Rewritten every frame |
| Material textures | 3 × 1024×1024 + 1 × 1×1 | Rgba8UnormSrgb | 1 sample; 1 mip each | 12,582,916 | Four initialization uploads |
| MSAA color | Surface width × height | Current surface format | 4 samples; 1 mip | 16 × width × height for a 4-byte format | Recreated on resize; discarded after resolve |
| MSAA depth | Surface width × height | Depth32Float | 4 samples; 1 mip | 16 × width × height | Recreated on resize; discarded after scene pass |
| Presentation surface | Surface width × height | Current surface format | 1 sample | Framework-managed | Receives 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.
| Pass | Color attachment | Depth attachment | Pipeline and draw work | Store or resolve | Visible result |
|---|---|---|---|---|---|
| Voyager scene | Four-sample color; clear white | Four-sample Depth32Float; clear 1 | Three indexed material draws; 61,134 indices; 20,378 triangles | Resolve color to surface; discard multisampled color and depth | Antialiased geometry coverage |
| Text and joystick overlay | Load resolved single-sample surface | None | Framework glyph draw plus active joystick geometry | Store surface; no resolve | Title, 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.