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

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

Dense grove of alpha-textured oak trees rendered with 4x WebGPU MSAA and alpha-to-coverage against a dark blue background.
A dense oak grove exposes the problem alpha-to-coverage solves: every canopy contains many textured polygons whose transparent regions must not write color or depth. This 1280×720 capture arrived in a later screenshot update and predates two subsequent source fixes, so it documents the demo rather than proving the exact pixels produced by today's startup state.

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.

Alpha-to-coverage runtime asset and compiled inputs
InputDeliveryStored bytesDecoded shapePurpose
oaktree.gltfRuntime request 11,706,820JSON plus a 1,275,656-byte embedded bufferScene, geometry, materials, and two PNG images
Geometry viewsInside the glTF buffer207,7805,499 vertices and 15,906 source indicesTwo triangle primitives
oak_barkEmbedded PNG852,536512×512 RGBA8; 1,048,576 texel bytesFully opaque bark color
oak_leafsEmbedded PNG215,340512×512 RGBA8; 1,048,576 texel bytesLeaf color and coverage alpha
WGSLCompiled into the program1,78061 source linesInstanced transforms and diffuse texturing
Vazirmatn fontCompiled into the program122,752TrueType fontTitle, 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.

Oak geometry, materials, and instanced draw work
DrawMaterialVerticesIndicesTriangles per treeTriangles across 25 instancesAlpha behavior
1bark033,11912,8464,282107,050All 262,144 texels are opaque
2oak leaf2,3803,0601,02025,500Transparent, opaque, and fractional texels
Total2 active materials5,49915,9065,302132,550Alpha-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.

Alpha-to-coverage explicit GPU resources
ResourceExtent or elementsFormatLogical bytesLifetime or update
Tree vertices5,499 × 44-byte strideFour vertex attributes241,956Uploaded once
Tree indices15,906 u32 valuesUint3263,624Uploaded once
Instances25 × 12-byte translationInstance vertex attribute300Uploaded once
Scene uniformTwo matrices + light vectorVertex-visible uniform144Rewritten every update
Material texels2 × 512×512 + 1 × 1×1Rgba8UnormSrgb2,097,156Three initialization uploads
MSAA color at 1280×7204 samples per pixelFour-byte surface format assumed14,745,600Recreated on resize; resolved then discarded
MSAA depth at 1280×7204 samples per pixelDepth32Float14,745,600Recreated 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.

How sampled alpha affects the four-sample pipeline
Fragment alphaAlpha-derived coverageColor behaviorDepth behaviorTypical oak region
0No samples surviveExisting sample colors remainNo sample depth is writtenTransparent space around leaves
Between 0 and 1A subset of 4 samples survivesResolve mixes covered leaf samples with uncovered contentsOnly covered sample depths updateFractional leaf silhouette
1All geometrically covered samples surviveOpaque shaded colorCovered sample depths updateBark 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.

Alpha-to-coverage passes and submitted scene work
PassSamplesAttachmentsFixed scene workResult
Oak groveMSAA color + Depth32Float; resolve to surface2 indexed instanced draws; 397,650 index references; 132,550 trianglesResolved geometry and alpha coverage
Text and joystickLoad and store presentation surface; no depthFramework-managed overlay drawsTitle, 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.