WebGPU notes / 20
WebGPU Bloom in Rust: Separable Gaussian Blur with wgpu
Isolate a glowing UFO into a fixed WebGPU target, spread its light with vertical and horizontal nine-tap filters, and add the softened result over a separately lit scene.
- Glow target
- 256×256
- Blur taps
- 9 + 9
- Render passes
- 4
Build bloom with four passes
The previous WebGPU radial blur example gathers samples along rays that point toward the screen center. Bloom spreads selected bright regions in every direction. This example approximates a two-dimensional Gaussian filter as two one-dimensional passes: one vertical and one horizontal. That separable design produces a conceptual 9×9 footprint without taking 81 samples in one full-resolution fragment shader.
The original bloom commit introduced the two UFO models, four pipelines, fixed offscreen targets, separable WGSL filter, animation, and screenshots. The current Rust example and WGSL shader retain that rendering design. Later changes removed an obsolete clip-space compatibility matrix and modernized the WebAssembly entry point; the shader, models, and screenshots remain unchanged. The demo runs through sib::render.
Load the visible and glowing UFOs
The example requests two top-level glTF files in sequence. Each file embeds its binary buffer as a Base64 data URI, so there are no secondary .bin, image, or material-texture requests. Native initialization loads both files synchronously. WebAssembly awaits the same two URLs one after the other.
Scroll sideways to see all table columns.
| Asset | File size | Vertices | Indices | Triangles | Runtime role |
|---|---|---|---|---|---|
retroufo.gltf | 1,095,375 bytes | 19,990 | 49,284 | 16,428 | Phong-lit visible UFO |
retroufo_glow.gltf | 1,102,950 bytes | 20,245 | 50,148 | 16,716 | Black mask with colored emissive geometry and beam |
The two requests total 2,198,325 bytes, about 2.10 MiB. The visible file contains 253 nodes and 77 mesh primitives. The glow file contains 242 nodes and 74 primitives. Its bounds extend farther down Y because it includes the luminous beam below the craft.
The shared colored glTF loader uses the repository's asset helper to fetch each file. It traverses the node hierarchy, bakes transforms, supplies white because neither model has vertex colors, multiplies the material base-color factors, converts indices to u32, and flattens every primitive into one GPU mesh per file. Texture coordinates are present but are not copied into this colored vertex format.
One GltfColoredVertex occupies 40 bytes: position at shader location 0, normal at location 1, and RGBA color at location 3. The visible mesh uses 799,600 bytes of vertex data and 197,136 bytes of indices. The glow mesh uses 809,800 vertex bytes and 200,592 index bytes. Across both flattened models, that is 40,235 vertices, 99,432 indices, and 33,144 authored triangles. Together the four static buffers occupy 2,007,128 bytes, about 1.91 MiB.
The glow model is not a small emissive subset. It contains slightly more triangles than the visible UFO. Most of its non-emissive structure is assigned black, while cyan, yellow, orange, red, and beam geometry keep bright material colors. The first pass still rasterizes all 16,716 triangles and lets black pixels disappear into a black target.
Build the bloom targets and bindings
Initialization creates two fixed OffscreenTarget objects. One receives the authored glow scene. The other receives the vertical filter result and becomes the horizontal filter's input. Their dimensions stay at 256×256 when the surface is resized.
Scroll sideways to see all table columns.
| Resource | Format or size | How it changes | Purpose |
|---|---|---|---|
| Scene uniforms | 192 bytes | Fully rewritten every update and resize | Projection, view, and animated model matrices |
| Vertical blur uniforms | 16 bytes | Initialized once | Scale 1, strength 1.5, direction (0, 1) |
| Horizontal blur uniforms | 16 bytes | Initialized once | Scale 1, strength 1.5, direction (1, 0) |
| Glow color | 256×256 Rgba8Unorm | Cleared and rendered every frame | Input to the vertical blur |
| Glow depth | 256×256 Depth32Float | Cleared by the glow pass | Depth-tests glow geometry |
| Blur color | 256×256 Rgba8Unorm | Cleared and filtered every frame | Input to the horizontal blur |
| Blur depth | 256×256 Depth32Float | Allocated but never used | No current runtime role |
| Scene depth | Surface-size Depth32Float | Recreated on resize | Depth-tests the visible UFO |
Every fixed four-byte-per-pixel image occupies 262,144 bytes, so the two color and two depth images reserve exactly 1 MiB before texture-object overhead. Color textures have one mip and one sample, RENDER_ATTACHMENT | TEXTURE_BINDING usage, clamp-to-edge addressing, linear minification and magnification, and nearest mip filtering. The depth textures also expose texture-binding usage and comparison samplers, although the shaders never sample either depth image.
The bind-group layout combines a scene uniform, filterable texture, sampler, and blur uniform. One group pairs the glow color with vertical parameters; the other pairs the intermediate color with horizontal parameters. Mesh shaders read only the scene binding, while blur shaders ignore it. Rust writes exactly 192 example-owned uniform bytes per normal update. Each 16-byte blur block stays unchanged after initialization; text overlay uploads are managed separately.
Render the authored glow mask
The first pass clears the glow color to black and glow depth to 1, binds the glow pipeline, and draws all 50,148 indices once. The vertex shader applies projection * view * model. The fragment shader simply returns the baked material color with alpha 1. There is no brightness threshold or lighting calculation in this pass.
The target is Rgba8Unorm with blending disabled. The pipeline uses triangle lists, one sample, no face culling, and Depth32Float writes with LessEqual. Color is stored for filtering; depth is discarded at the end of the pass. The separately authored black materials provide the mask, while the retained bright geometry supplies the glow source.
Blur nine taps vertically
The second pass clears the intermediate color target, binds the vertical blur group, and generates one oversized fullscreen triangle from vertex_index. No vertex buffer or depth attachment is needed. With blur_scale equal to 1 and an input size of 256×256, the shader samples the center plus four texels above and four below:
let weights = array<f32, 5>(
0.227027,
0.1945946,
0.1216216,
0.054054,
0.016216,
);
let texture_size = vec2<f32>(textureDimensions(blur_input));
let texel_offset = blur.direction * blur.blur_scale
/ max(texture_size, vec2<f32>(1.0));
var result = textureSample(blur_input, blur_sampler, input.uv).rgb
* weights[0];
for (var i = 1u; i < 5u; i = i + 1u) {
let offset = texel_offset * f32(i);
result += textureSample(blur_input, blur_sampler, input.uv + offset).rgb
* weights[i] * blur.blur_strength;
result += textureSample(blur_input, blur_sampler, input.uv - offset).rgb
* weights[i] * blur.blur_strength;
}
The center coefficient is 0.227027. The four base weights on either side are 0.1945946, 0.1216216, 0.054054, and 0.016216. Those canonical coefficients sum to approximately 1 when mirrored, but the shader multiplies only the eight neighboring samples by a strength of 1.5. Their effective per-side weights become 0.2918919, 0.1824324, 0.081081, and 0.024324.
The resulting one-dimensional kernel sums to 1.3864856, so it amplifies brightness instead of preserving energy. The 256×256 vertical pass performs nine texture samples for each of 65,536 pixels, or 589,824 samples. Its Rgba8Unorm output can clamp amplified values before the horizontal pass reads them.
Render the Phong-lit UFO
The third pass clears the surface to opaque black and its depth image to 1, then draws the visible model's 49,284 indices. It uses the surface's configured color format, no blending, one sample, no face culling, and writable Depth32Float with LessEqual.
WGSL transforms positions by the same projection, view, and model matrices. A fixed view-space light sits at (-5, -5, 0). Bright material colors whose red, green, or blue channel reaches 0.9 receive 25% ambient color. Every fragment also receives color-weighted Lambert diffuse light and a white specular term:
let diffuse = max(dot(normal, light_vector), 0.0) * input.color;
let specular = pow(
max(dot(reflected, view_vector), 0.0),
8.0,
) * vec3<f32>(0.75);
return vec4<f32>(ambient + diffuse + specular, 1.0);
The normal transform uses the upper-left 3×3 part of view * model rather than an inverse transpose. That is sufficient for the current translation and rigid rotations, but it would become incorrect after nonuniform model scaling.
Blur horizontally and composite
The fourth pass loads the Phong scene color, binds the intermediate texture and horizontal parameters, and draws another fullscreen triangle. It takes the same nine coefficients at X offsets from −4 to +4 source texels. The separable result has a conceptual 9×9 footprint, but the horizontal stage needs only nine texture samples per final-surface pixel.
RGB blending uses source One, destination One, and addition, so the horizontally filtered glow is added over the visible UFO. Alpha uses source One and destination Zero, replacing the surface alpha with the shader's value of 1. The text overlay renders after the fullscreen draw in this same pass.
Scroll sideways to see all table columns.
| Pass | Target | Draw | Triangles | Color behavior |
|---|---|---|---|---|
| Glow mask | 256×256 Rgba8Unorm | 1 indexed glow mesh | 16,716 | Clear black, store authored colors |
| Vertical blur | 256×256 Rgba8Unorm | 1 fullscreen triangle | 1 | Clear black, replace with filtered result |
| Lit scene | Surface format | 1 indexed visible mesh | 16,428 | Clear black, store Phong shading |
| Horizontal composite | Surface format | 1 fullscreen triangle | 1 | Load scene and add filtered RGB |
Excluding text-overlay geometry, a frame records four render passes, four draws, and 33,146 submitted triangles. The two mesh draws and two procedural triangles produce 99,438 index or vertex invocations. Glyphon adds one instanced text draw when the overlay contains visible glyphs, but it does not create a fifth pass.
Animate the UFO
The camera stays fixed. Its right-handed perspective uses a 45° vertical field of view, current surface aspect, and near and far planes of 0.1 and 256. The eye sits 10.25 units from the origin at 17° yaw and 7.5° pitch, approximately (-2.971, 1.338, -9.718), and looks toward (0, -1, 0).
Rust advances normalized animation time by half of the elapsed seconds, so one cycle lasts two seconds. The phase drives a 0.25-unit XZ orbit around Y = −1, a complete Y rotation, and an X tilt of -sin(phase) * 0.15 radians, about ±8.59°. Elapsed time is not clamped, so a long stall can jump the animation.
The same 192-byte uniform block moves both model meshes together and is rewritten once per update. Resizing recreates only the surface-size depth image, rewrites the matrices, and rebuilds overlay placement; all four fixed offscreen images remain 256×256.
There are no demo keyboard, mouse, pointer, or touch controls. The 22 px Vazirmatn overlay displays “Bloom,” the GPU device description, and FPS. That FPS is CPU frame cadence averaged over 500 ms, not a GPU timestamp. Its text updates on the statistics cadence while overlay preparation runs every frame.
Run and extend the example
From a local checkout with Rust installed, run the native four-pass example:
cargo run --example bloom
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 bloom
cargo run --bin serve
Open http://127.0.0.1:8080/bloom/ in a browser with WebGPU support. Native code loads both models synchronously. The WebAssembly entry point starts an asynchronous task, awaits the visible model and then the glow model, and logs loading or rendering errors. Both targets run the same four passes and WGSL; there is no browser fallback. The shader and Vazirmatn font are compiled into the binary.
This compact example favors a readable filter over a production HDR bloom stack. Its two 256×256 targets use eight-bit color, the blur radius and strength are fixed, and the horizontal filter still executes at full surface resolution. It has no threshold pass over the lit scene, exposure or tone mapping, downsample pyramid, multi-scale reconstruction, temporal stabilization, MSAA, face culling, shadows, or user controls.
The two mostly duplicated glTF files and meshes make emissive selection easy to see, but they cost about 2.10 MiB of requests, about 1.91 MiB of mesh buffers, and a second full geometry draw. The blur target also allocates an unused depth texture. The fullscreen shader maps texture Y directly to clip-space Y, vertically flipping each input; the second fullscreen pass flips it back, so final orientation is correct only because both stages run.
Useful changes to try:
- Render an HDR emissive attachment beside the main scene color, then remove the duplicated glow model and geometry pass.
- Downsample through several resolutions, normalize or expose the kernel gain, and composite the reconstructed levels after tone mapping.
- Move the horizontal filter to the 256×256 target and use a separate one-sample composite, then compare total texture work at high resolutions.
- Remove the unused blur depth allocation, specialize bind-group layouts for mesh and blur pipelines, and correct the fullscreen Y convention directly.