WebGPU notes / 29
WebGPU Alpha-to-Coverage in Rust: 4x MSAA Foliage
Convert sampled leaf alpha into a four-sample coverage mask, draw 25 oak-tree instances in two indexed calls, and resolve smoother cutout foliage into the presentation surface without fragment discard or color blending.
- Rasterization samples
- 4×
- Tree instances
- 25
- Submitted triangles
- 132,550
Turn leaf alpha into four-sample coverage
The previous WebGPU multisampling example uses 4x MSAA to soften the polygon edges of an opaque Voyager model. This example keeps the same four-sample color, depth, and resolve structure, then enables alpha-to-coverage so a texture can also control which samples survive inside each leaf triangle. Transparent areas of the rectangular leaf cards preserve the background and depth already stored in uncovered samples; fractional edge texels produce partial sample coverage.
The introducing multisampling commit added both MSAA examples, the oak model, their Rust and WGSL sources, and their original gallery registrations. The current Rust source later received a modern WebAssembly entry point, direct WebGPU projection, and a corrected row offset and stable fallback material. The WGSL shader and oak glTF remain byte-identical to their introducing versions. Rendering runs through sib::render.
Load one self-contained oak glTF asset
The demo makes one runtime request through the shared asset loader. Native startup reads assets/models/oaktree.gltf from disk. WebAssembly awaits one browser fetch for ../assets/models/oaktree.gltf before starting the renderer. The geometry and both PNG images occupy one Base64 data-URI buffer, so neither texture causes another request.
Scroll sideways to see all table columns.
| Input | Delivery | Stored bytes | Decoded shape | Purpose |
|---|---|---|---|---|
oaktree.gltf | Runtime request 1 | 1,706,820 | JSON plus a 1,275,656-byte embedded buffer | Scene, geometry, materials, and two PNG images |
| Geometry views | Inside the glTF buffer | 207,780 | 5,499 vertices and 15,906 source indices | Two triangle primitives |
oak_bark | Embedded PNG | 852,536 | 512×512 RGBA8; 1,048,576 texel bytes | Fully opaque bark color |
oak_leafs | Embedded PNG | 215,340 | 512×512 RGBA8; 1,048,576 texel bytes | Leaf color and coverage alpha |
| WGSL | Compiled into the program | 1,780 | 61 source lines | Instanced transforms and diffuse texturing |
| Vazirmatn font | Compiled into the program | 122,752 | TrueType font | Title, device, FPS, and sample-count overlay |
The glTF describes one default scene, two root nodes, two meshes, two triangle primitives, two materials, two images and textures, ten buffer views, and eight accessors. It declares no external buffer, image, or sampler. The loader accepts embedded Base64 buffers and images, rejects external resources and GLB binary chunks, flips texture V, and bakes node transforms into the uploaded positions and normals.
Flatten bark and leaf primitives into one mesh
Each root node contributes one indexed primitive. Rust appends both into a shared 44-byte vertex stream and converts their indices to u32, while retaining a draw range and material index for each primitive. Every vertex contains position, normal, UV, and RGB color. The current asset has no vertex colors, so the loader supplies white and multiplies it by each material's white base-color factor.
Scroll sideways to see all table columns.
| Draw | Material | Vertices | Indices | Triangles per tree | Triangles across 25 instances | Alpha behavior |
|---|---|---|---|---|---|---|
| 1 | bark03 | 3,119 | 12,846 | 4,282 | 107,050 | All 262,144 texels are opaque |
| 2 | oak leaf | 2,380 | 3,060 | 1,020 | 25,500 | Transparent, opaque, and fractional texels |
| Total | 2 active materials | 5,499 | 15,906 | 5,302 | 132,550 | Alpha-to-coverage runs for both draws |
The two indexed instanced draws submit 397,650 index references per frame: 321,150 for bark and 76,500 for leaves. That count describes submitted index work, not guaranteed vertex-shader invocations, because a GPU can reuse post-transform vertex results. The pipeline disables face culling, matching the two current double-sided materials without implementing material-specific cull state.
The leaf material declares glTF alphaMode: BLEND, but this focused loader does not implement glTF blending. Its only material inputs are RGB base-color factor, base-color texture, and sampler settings. The pipeline deliberately replaces conventional transparency with alpha-to-coverage, which is appropriate for cutout-like foliage but is not a general implementation of layered translucent blending.
Instance a 5 by 5 oak grove
A second vertex buffer contains 25 three-component translations. Nested loops place trees at integer grid coordinates from −2 through 2 in X and Z, multiply both axes by 1.25, and shift odd Z rows by +0.25 in X. The resulting grove spans X −2.5 through 2.75 and Z −2.5 through 2.5 before each tree's own geometry bounds are included.
for x in -2..3 {
for z in -2i32..3 {
instances.push(InstanceData {
position: [
x as f32 * 1.25 + z.rem_euclid(2) as f32 * 0.25,
0.0,
z as f32 * 1.25,
],
});
}
}
All instances share the same scale, orientation, geometry, and two material bind groups. There is no random placement, wind animation, level of detail, culling, or per-instance color. Instancing reduces CPU draw submission and duplicate geometry storage, but the GPU still rasterizes all 132,550 submitted triangles.
Upload bark, leaf, and fallback textures
The two embedded PNGs decode to 2,097,152 RGBA bytes. The loader also appends a generated 1×1 white image and fallback material, then creates a separate GPU texture, sampler, and bind group for every material. The resulting 2,097,156 logical texture bytes include an unused four-byte fallback: both current primitives already select their bark or leaf material.
All three images become single-sample, one-mip Rgba8UnormSrgb textures. Because the glTF has no sampler declarations, its defaults repeat U and V, clamp W, linearly magnify and minify, and select the only mip with nearest mip filtering. Automatic sRGB decoding applies to RGB; the stored alpha remains the value returned for coverage. The missing mip chain can still make distant leaf cards shimmer even when polygon and alpha edges use MSAA.
The leaf texture makes the coverage case measurable. Of its 262,144 texels, 210,634 are fully transparent, 48,915 are fully opaque, and 2,595 use one of 43 intermediate non-endpoint values. Alpha-to-coverage removes the large transparent card regions and quantizes that narrow fractional border across four raster samples.
Allocate four-sample color and depth targets
Initialization creates a surface-sized color attachment in the current presentation format and a surface-sized Depth32Float attachment. Both contain one mip, one array layer, and four samples, and both are used only as render attachments. A resize recreates the pair, rebuilds the text overlay, and uploads the current 144-byte camera uniform.
Scroll sideways to see all table columns.
| Resource | Extent or elements | Format | Logical bytes | Lifetime or update |
|---|---|---|---|---|
| Tree vertices | 5,499 × 44-byte stride | Four vertex attributes | 241,956 | Uploaded once |
| Tree indices | 15,906 u32 values | Uint32 | 63,624 | Uploaded once |
| Instances | 25 × 12-byte translation | Instance vertex attribute | 300 | Uploaded once |
| Scene uniform | Two matrices + light vector | Vertex-visible uniform | 144 | Rewritten every update |
| Material texels | 2 × 512×512 + 1 × 1×1 | Rgba8UnormSrgb | 2,097,156 | Three initialization uploads |
| MSAA color at 1280×720 | 4 samples per pixel | Four-byte surface format assumed | 14,745,600 | Recreated on resize; resolved then discarded |
| MSAA depth at 1280×720 | 4 samples per pixel | Depth32Float | 14,745,600 | Recreated on resize; discarded |
The fixed mesh, index, instance, uniform, and decoded texture payloads total 2,403,180 bytes, about 2.292 MiB. At the screenshot resolution, four-sample color and depth add 29,491,200 logical bytes, or 28.125 MiB, for about 30.417 MiB of explicit core resources. These calculations omit the presentation surface, overlay atlas and buffers, descriptor objects, allocator padding, and implementation-specific storage.
Convert fragment alpha to a four-sample mask
The scene pipeline uses a four-sample count, full sample mask, and alpha_to_coverage_enabled: true. Color blending is disabled. Depth writes use LessEqual. The fragment shader samples the material texture, multiplies its RGB by vertex and material color, applies diffuse lighting with a 0.5 floor, and returns the sampled alpha unchanged.
multisample: wgpu::MultisampleState {
count: 4,
mask: !0,
alpha_to_coverage_enabled: true,
},
let color = textureSample(color_map, color_sampler, input.uv)
* vec4<f32>(input.color, 1.0);
return vec4<f32>(diffuse * color.rgb, color.a);
Scroll sideways to see all table columns.
| Fragment alpha | Alpha-derived coverage | Color behavior | Depth behavior | Typical oak region |
|---|---|---|---|---|
| 0 | No samples survive | Existing sample colors remain | No sample depth is written | Transparent space around leaves |
| Between 0 and 1 | A subset of 4 samples survives | Resolve mixes covered leaf samples with uncovered contents | Only covered sample depths update | Fractional leaf silhouette |
| 1 | All geometrically covered samples survive | Opaque shaded color | Covered sample depths update | Bark and solid leaf interior |
At four samples, an alpha value can select only zero through four surviving samples before the mask intersects ordinary triangle coverage and the pipeline's full sample mask. The exact threshold and sample pattern are implementation dependent, so alpha-to-coverage approximates fractional coverage rather than storing continuous transparency. It works especially well for many small cutout edges, but it does not reproduce correct order-independent blending through several translucent layers.
There is no fragment discard, shader-written sample mask, sample_index, or sample-qualified interpolation. The shader does not request per-sample execution. Alpha-to-coverage is a fixed-function step after the fragment shader produces target-zero alpha. For this source, it is also what prevents transparent leaf-card fragments from writing the dark shaded RGB and near depth that they would write if the feature were simply disabled.
Render, resolve, then draw a single-sample overlay
The encoder records two render passes. The first clears four-sample color to (0, 0, 0.2, 1) and depth to 1, draws bark and leaves for all 25 instances, and resolves color into the single-sample surface. Both multisampled attachments use discard stores because no later pass reads them. The second pass loads the resolved surface and draws the text and any active joystick rings without depth or another resolve.
Scroll sideways to see all table columns.
| Pass | Samples | Attachments | Fixed scene work | Result |
|---|---|---|---|---|
| Oak grove | 4× | MSAA color + Depth32Float; resolve to surface | 2 indexed instanced draws; 397,650 index references; 132,550 triangles | Resolved geometry and alpha coverage |
| Text and joystick | 1× | Load and store presentation surface; no depth | Framework-managed overlay draws | Title, device, CPU-side FPS, 4x label, and active sticks |
The overlay reports “Multisampling alpha to coverage,” GPU device information, an averaged FPS value, and “rasterization samples: 4x.” Its 500 ms frame statistic comes from CPU event-loop timing rather than GPU timestamp queries. The screenshot's 120.2 FPS label is therefore capture text, not a portable benchmark.
Control the first-person camera
The shared first-person camera and virtual joystick starts at (0, 1, 4), looking along negative Z. A right-handed 60° perspective projection uses near and far planes of 0.1 and 256. The camera moves four world units per second, looks at 1.6 radians per second, clamps pitch to ±1.45 radians, and caps a movement update at 1/15 second.
- Press W and S to move forward and backward, and A and D to strafe.
- Use the arrow keys to look up, down, left, and right.
- Press and drag on the left half of the canvas to move or the right half to look. Touch can operate both virtual sticks; mouse controls one at a time.
The complete 144-byte projection, view, and light uniform is rewritten every update even while the camera is still. Losing focus clears active input. There is no vertical movement, collision, camera reset, tree animation, or sample-count control.
Run and extend the example
From a local checkout with Rust installed, run the native WebGPU alpha-to-coverage example:
cargo run --example multisamplingalphatocoverage
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 multisamplingalphatocoverage
cargo run --bin serve
Open http://127.0.0.1:8080/multisamplingalphatocoverage/ in a browser with WebGPU support. Native and WebAssembly builds load the same self-contained glTF before calling sib::render, upload the same three textures, allocate the same surface-sized four-sample targets, and record the same scene and overlay passes.
The sample count is fixed at four, with no runtime capability branch, selector, 1x comparison, or lower-sample fallback. The example requires four-sample support for both the chosen surface format and Depth32Float. Alpha-to-coverage is enabled for bark as well as leaves, although opaque bark alpha makes its mask full. There is no conventional alpha blend, sorting, stochastic mask, temporal accumulation, foliage-specific alpha cutoff, or depth resolve.
The compact shader has no attenuation, specular term, shadows, normal mapping, physically based material response, HDR intermediate, or tone mapping. Its 3×3 view rotation transforms the configured light vector but drops view translation, then subtracts a translated view-space position; the effective light therefore follows camera translation instead of behaving as the configured world-space point. The interpolated view vector is unused. General nonuniform model scaling would also need an inverse-transpose normal matrix, while the current baked node scales are uniform.
Useful changes to try:
- Add a split-screen or hotkey comparison among opaque rendering, alpha discard, conventional blending, and alpha-to-coverage.
- Query supported sample counts and rebuild the pipeline and attachments for 1x, 2x, 4x, or 8x where available.
- Generate texture mipmaps and apply alpha-coverage preservation so distant foliage retains density with less shimmer.
- Add wind animation, per-instance variation, frustum culling, and level of detail while retaining the two-material instanced structure.