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

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.

A rotating black geodesic WebGPU sphere with vivid magenta triangular openings and radial glow streaks on a dark background.
The dark geodesic frame is shaded in the main pass. Its animated magenta interior is isolated offscreen, stretched toward the center, and added back as a soft radial glow.

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.

Radial blur runtime assets
AssetFile sizeDecoded contentRuntime role
glowsphere.gltf146,434 bytes3,104 vertices, 3,120 indicesOrange glow shell and black geodesic frame
particle_gradient_rgba.ktx1,124 bytes256×1 RGBA color rampAnimated 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.

Radial blur GPU resources
ResourceFormat or sizeHow it changesPurpose
Scene uniforms224 bytesRewritten every update and resizeProjection, view, normal matrix, light, and ramp position
Blur uniforms16 bytesInitialized onceScale 0.35, strength 0.75, origin (0.5, 0.5)
Gradient ramp256×1 Rgba8UnormSrgbStatic after one 1,024-byte uploadRepeating animated glow color
Offscreen color512×512 Rgba8UnormCleared and rendered every frameStores the isolated glow mask
Offscreen depth512×512 Depth32FloatCleared every glow passDepth-tests the mask geometry
Scene depthSurface-size Depth32FloatRecreated on resizeDepth-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.

Radial blur render passes
PassTargetEffect drawTrianglesColor behavior
Glow mask512×512 Rgba8Unorm1 indexed mesh draw1,040Clear black, isolate bright-color geometry
Main sceneSurface format1 indexed mesh draw1,040Clear dark background, shade the mesh
CompositeSurface format1 fullscreen draw1Load 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.