WebGPU notes / 19
WebGPU Radial Blur in Rust: 32-Sample Glow with wgpu
Render an animated glow mask into a fixed offscreen texture, sample it 32 times toward the image center, and add the resulting WebGPU radial blur over a separately shaded scene.
- Glow target
- 512×512
- Samples per pixel
- 32
- Effect draws
- 3
Separate the glow from the shaded scene
The previous WebGPU occlusion query example renders geometry to answer a visibility question. Radial blur uses another multi-pass pattern: render only the pixels that should glow, filter that image in screen space, and composite it over the normal scene. Keeping the source mask separate prevents the dark frame and background from producing unwanted streaks.
The original radial blur commit introduced the sphere, color ramp, three pipelines, and 32-sample post-process. The current Rust example and WGSL shader preserve that design. Later changes aligned projection with current wgpu conventions, corrected the fullscreen triangle's Y orientation, and modernized the WebAssembly entry point. The demo runs through sib::render with a fixed animation and no keyboard, pointer, touch, or scene controls.
Load and flatten the glow-sphere assets
The example loads two files before renderer initialization. Native code reads the KTX file and then the glTF synchronously. WebAssembly awaits the same two requests in sequence. The model's binary payload is embedded inside the glTF as a Base64 data URI, so loading it does not create a separate .bin request.
Scroll sideways to see all table columns.
| Asset | File size | Decoded content | Runtime role |
|---|---|---|---|
glowsphere.gltf | 146,434 bytes | 3,104 vertices, 3,120 indices | Orange glow shell and black geodesic frame |
particle_gradient_rgba.ktx | 1,124 bytes | 256×1 RGBA color ramp | Animated glow color |
The two asset files total 147,558 bytes. The current colored glTF loader applies both node transforms and flattens two primitives into one vertex and index buffer. The orange primitive contributes 240 vertices and 80 triangles. The black primitive contributes 2,864 vertices and 960 triangles. Together they produce one 1,040-triangle mesh and one indexed draw.
No vertex-color attribute exists in this model, so the loader starts with white and multiplies it by each material's base-color factor. A 40-byte GPU vertex stores a position, normal, and RGBA color. The 124,160-byte vertex buffer and 12,480-byte u32 index buffer occupy 136,640 bytes.
The custom KTX1 decoder accepts little-endian, uncompressed RGBA8, single-face 1D or 2D images. This file reports zero height, which the decoder promotes to one texel, and contains one 1,024-byte image level. The GPU texture is Rgba8UnormSrgb; its U coordinate repeats so the animation can wrap around the ramp, while linear minification and magnification interpolate between colors.
Create fixed offscreen resources
Initialization creates one scene bind group used by the mesh pipelines and one blur bind group used by the fullscreen fragment shader. The first supplies matrices, lighting, animation state, the gradient texture, and its sampler. The second supplies static blur parameters, the offscreen color texture, and a clamp-to-edge linear sampler.
Scroll sideways to see all table columns.
| Resource | Format or size | How it changes | Purpose |
|---|---|---|---|
| Scene uniforms | 224 bytes | Rewritten every update and resize | Projection, view, normal matrix, light, and ramp position |
| Blur uniforms | 16 bytes | Initialized once | Scale 0.35, strength 0.75, origin (0.5, 0.5) |
| Gradient ramp | 256×1 Rgba8UnormSrgb | Static after one 1,024-byte upload | Repeating animated glow color |
| Offscreen color | 512×512 Rgba8Unorm | Cleared and rendered every frame | Stores the isolated glow mask |
| Offscreen depth | 512×512 Depth32Float | Cleared every glow pass | Depth-tests the mask geometry |
| Scene depth | Surface-size Depth32Float | Recreated on resize | Depth-tests the visible scene |
Each fixed 512×512 attachment occupies 1,048,576 bytes, so the offscreen color and depth images reserve 2 MiB before views and driver overhead. They remain 512×512 when the window changes size. Only the surface-sized depth image is recreated on resize.
The 224-byte SceneUniforms block contains three 4×4 matrices and two four-component vectors. Rust rewrites the complete block once per normal update as the camera and gradient advance. The 16-byte BlurUniforms block contains two scalars and a two-component origin; it never changes after initialization. Overlay glyph uploads are separate from these example-owned buffer writes.
Render an explicit glow mask
The first render pass clears the fixed color target to black and its depth target to 1. It then submits the complete 3,120-index mesh once. The color pipeline uses triangle lists, one sample, no face culling or blending, and Depth32Float writes with LessEqual. The pass stores the color result for sampling and discards the depth contents.
Every vertex receives the same gradient coordinate: the current ramp position on U and zero on V. The fragment shader tests the material-derived vertex color. If any RGB channel is at least 0.9, it writes the sampled gradient; otherwise it writes the original color:
let gradient = textureSample(gradient_ramp, gradient_sampler, input.uv).rgb;
if input.color.r >= 0.9 || input.color.g >= 0.9 || input.color.b >= 0.9 {
return vec4<f32>(gradient, 1.0);
}
return vec4<f32>(input.color, 1.0);
The orange primitive has a red channel of 1, so it becomes the animated glow source. The black frame stays black. This is an explicit mask based on baked vertex color, not a brightness threshold applied after scene lighting.
Shade the main scene separately
The second pass clears the surface to (0.018, 0.021, 0.03, 1), clears the surface-sized depth image, and draws the same 1,040 triangles again. Bright vertices still return the animated ramp color without lighting. The remaining geometry receives a compact Phong calculation with 0.2 ambient light, a 0.5 diffuse term, and a low-power specular highlight raised to the fourth power.
Because the frame's base color is black, its ambient and diffuse contributions remain black; the specular term provides most of its visible definition. The fixed light position is (0, 0, -5). The shader transforms normals and eye positions into view space but derives its light vector from the original input position, so its simple lighting mixes coordinate spaces. Keeping all quantities in one space is a useful correctness improvement to try.
Take 32 samples toward the center
A procedural fullscreen vertex shader generates one oversized triangle from vertex_index, so the composite needs no vertex buffer. For each covered surface pixel, the fragment shader derives a half-texel offset from the 512×512 source, subtracts the origin at (0.5, 0.5), and gathers exactly 32 samples:
let sample_count = 32u;
for (var i = 0u; i < sample_count; i = i + 1u) {
let scale = 1.0 - blur.radial_blur_scale
* (f32(i) / f32(sample_count - 1u));
color += textureSample(
offscreen_color,
offscreen_sampler,
uv * scale + blur.radial_origin,
);
}
let blurred = (color / f32(sample_count))
* blur.radial_blur_strength;
With a scale parameter of 0.35, the first sample uses scale 1 and the last uses 0.65. The positions therefore move along one line from the current UV toward the center, ending 35% closer to the origin. All samples have equal weight. Their average is multiplied by the fixed strength of 0.75.
The source mask stays at 512×512, but this shader runs at the final surface resolution and performs 32 linear-filtered texture samples for every covered output pixel. A larger display therefore increases composite cost even though the offscreen geometry pass keeps a fixed resolution.
Composite the additive glow
The third render pass loads the already shaded surface, binds the offscreen mask, and draws the fullscreen triangle. RGB blending uses source One, destination One, and addition, so the filtered glow is added directly over the main scene. Alpha uses source One and destination Zero, replacing the previous alpha with the shader's value of 1. No depth attachment is bound.
Scroll sideways to see all table columns.
| Pass | Target | Effect draw | Triangles | Color behavior |
|---|---|---|---|---|
| Glow mask | 512×512 Rgba8Unorm | 1 indexed mesh draw | 1,040 | Clear black, isolate bright-color geometry |
| Main scene | Surface format | 1 indexed mesh draw | 1,040 | Clear dark background, shade the mesh |
| Composite | Surface format | 1 fullscreen draw | 1 | Load scene and add filtered RGB |
Excluding the variable text-overlay geometry, each frame records three render passes, three draws, and 2,081 submitted triangles. The overlay is rendered after the fullscreen triangle inside the same composite pass, so it does not add a fourth pass.
Animate the camera and gradient
The right-handed perspective uses a 45° vertical field of view, the current surface aspect ratio, and near and far planes of 1 and 256. A camera 17.5 units from the origin starts at a yaw of −28.75° and pitch of −16.25°. Rust adds 10° of yaw per second, completing an orbit every 36 seconds.
The ramp coordinate advances by 0.1 per second and wraps with fract, so it cycles through the 256 colors every 10 seconds. Elapsed time is not clamped. Resizing rewrites the projection and view data, recreates scene depth, and rebuilds the overlay placement while leaving the offscreen resources and blur bind group intact.
The text overlay displays “Radial blur,” the GPU device description, and estimated FPS in 22 px Vazirmatn. FPS measures CPU frame cadence over a 500 ms window rather than GPU execution time. The text content updates when that statistics interval completes, while the overlay renderer prepares its glyph data every frame.
Run and extend the example
From a local checkout with Rust installed, run the same three-pass effect natively:
cargo run --example radialblur
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 radialblur
cargo run --bin serve
Open http://127.0.0.1:8080/radialblur/ in a browser with WebGPU support. Native initialization loads the two files synchronously. The WebAssembly entry point uses an asynchronous task, awaits the KTX and glTF in sequence, and logs loading or rendering failures. Both targets use the same render passes and blur math. WGSL and the Vazirmatn font are compiled into the binary, while this article and screenshot remain readable without WebGPU.
The current implementation favors a small, readable post-process over a production bloom stack. It uses a fixed center, scale, and strength; one 8-bit low-dynamic-range source; equal weights; and a one-sided inward sampling path. There is no downsample pyramid, HDR exposure, depth-aware rejection, temporal filtering, MSAA, or user control. It also renders the complete mesh twice.
Useful changes to try:
- Move the blur origin to a projected world-space light and expose scale and strength as live controls.
- Render an HDR emissive attachment with the main scene, then build a downsampled glow pyramid instead of drawing the mesh twice.
- Compare equal taps with distance-weighted, symmetric, or jittered sampling while measuring full-resolution fragment cost.
- Replace the vertex-color threshold with explicit emissive material data and keep the Phong light, normal, and eye vectors in one coordinate space.