WebGPU notes / 40
Ray-Traced Reflections in WebGPU with Rust and WGSL
Accumulate as many as four specular ray segments in one compute invocation, tint throughput by each material, map an uncompressed KTX texture onto two spheres, and resolve the result into a filterable WebGPU storage texture.
- Maximum segments
- 4
- Scene objects
- 11
- KTX mip levels
- 11
Iterate reflection rays without recursive shader calls
The previous ray-traced shadows example stopped after direct lighting and one visibility query. Reflections instead keep a ray origin, direction, RGB throughput, and accumulated color inside one invocation. A fixed WGSL loop finds the nearest surface, adds its local contribution, updates throughput, and reflects the ray. This is iterative ray tracing; MAX_RECURSION names the four-segment limit even though WGSL never calls the function recursively.
The introducing reflections commit added the original Rust source, compute WGSL, present WGSL, and initial screenshots. The immediately following texture-support commit added KTX loading, two textured spheres, and the current capture. The camera-basis fallback and modern WebAssembly entry are later changes. The demo uses sib::render.
Decode and upload an uncompressed 11-level KTX 1 texture
Before entering the render loop, native code reads gratefloor_rgba.ktx from disk and WebAssembly fetches the copied relative URL. The local decoder validates the 12-byte KTX 1 identifier, little-endian marker, unsigned-byte type, one face, no array layers, and no volume depth. It accepts plain RGBA8 and this file's integer RGBA8UI header, then uploads the bytes as normalized Rgba8Unorm.
Scroll sideways to see all table columns.
| Input | Delivery | Stored bytes | Decoded layout | Provenance and license |
|---|---|---|---|---|
gratefloor_rgba.ktx | Runtime disk read or browser fetch | 5,592,540 | 1024×1024 through 1×1; 11 RGBA8 levels; 5,592,404 texel bytes | First recorded by the texture-support commit; the file and repository provide no asset-specific author or license metadata |
raytracingreflections_compute.wgsl | Embedded text | 8,613 | 281 lines | Repository source history |
| Vazirmatn font | Embedded bytes | 122,752 | TrueType overlay font | SIL Open Font License 1.1 |
The sampler uses linear minification, magnification, and mip filtering with clamp-to-edge addressing. However, the current compute shader calls textureSampleLevel(..., 0.0), so only the 1024×1024 base level contributes. The ten lower levels are uploaded but never selected. The texture is linear Rgba8Unorm, not Rgba8UnormSrgb; no automatic sRGB-to-linear conversion occurs.
Define eleven analytic objects and per-object reflectivity
Every 64-byte object stores geometry, box extents, RGB plus reflectivity, and type/feature IDs. The scene contains four spheres, three axis-aligned boxes, and four planes. The center sphere has reflectivity 0.88. The floor uses 0.35; colored boxes range from 0.08 to 0.18; two small spheres are marked for texturing; and the three room walls have zero reflectivity.
Scroll sideways to see all table columns.
| Group | Count | Color source | Reflectivity | Intersection |
|---|---|---|---|---|
| Spheres | 4 | Base RGB; 2 multiply the grate sample | 0.08–0.88 | Two-root quadratic |
| Boxes | 3 | Solid cyan, yellow, and orange | 0.08–0.18 | Axis-aligned slab test |
| Floor | 1 | Procedural two-tone checker | 0.35 | Infinite plane |
| Walls | 3 | Blue-gray, red, and green | 0 | Infinite planes |
All eleven objects are linearly scanned for every segment; there is no spatial hierarchy. The maximum per-pixel intersection workload is therefore 44 analytic tests before early termination. Unlike the preceding article, this shader does not cast a separate shadow ray.
Convert a sphere normal into animated texture coordinates
The sphere normal supplies latitude and longitude. U repeats twice around the sphere, V covers 1.6 repeats, both coordinates use fract, and animation time scrolls U slowly. Manual wrapping means the sampler's clamp mode never sees coordinates outside the unit square.
let u = atan2(normal.z, normal.x) * INV_TWO_PI + 0.5;
let v = asin(clamp(normal.y, -1.0, 1.0)) * INV_PI + 0.5;
return vec2<f32>(
fract(u * 2.0 + uniforms.params.w * 0.04),
fract((1.0 - v) * 1.6),
);
The sampled texel is scaled by 1.35 and mixed with a dark 0.18 fallback according to alpha, then multiplied by the sphere's base color. This is decorative modulation rather than a glTF material or physically based texture workflow.
Accumulate local lighting and color-tinted reflection throughput
For each hit, a moving point light supplies diffuse and Blinn-style specular lighting. Diffuse is clamped to at least 0.6, keeping every surface bright. The shader adds the non-reflected share locally, stops on materials at or below 0.01 reflectivity, or continues from an origin offset by 0.004:
for (var bounce = 0u; bounce < 4u; bounce = bounce + 1u) {
let hit = intersect(ray_o, ray_d, MAX_LEN);
if (hit.object_id == -1) {
color = color + throughput * background(ray_d);
break;
}
let reflectivity = clamp(hit.reflectivity, 0.0, 0.96);
color = color + throughput * shade_hit(ray_o, ray_d, hit) * (1.0 - reflectivity);
throughput = throughput * mix(vec3<f32>(reflectivity), hit.color, 0.18);
ray_d = reflect(ray_d, hit.normal);
}
Mixing 18% surface color into throughput creates colored reflections. It is an artistic rule rather than Fresnel reflectance or an energy-conserving BRDF. A ray that remains reflective through the fourth segment contributes no environment after the loop ends, so deeper paths are truncated rather than resolved to a background.
Dispatch, present, and recreate size-dependent bindings
At a 1280×720 surface, the 1,024-pixel cap produces a 1024×576 trace target and 64×36 workgroups: 589,824 active invocations. The result is linearly sampled by a procedural full-screen triangle, then a third pass loads the surface to draw diagnostics. Resizing rebuilds the storage texture and both bind groups only when its capped dimensions change.
Scroll sideways to see all table columns.
| Resource | Layout | Bytes | Update path | Role |
|---|---|---|---|---|
| Ray target | 1024×576 RGBA8 | 2,359,296 | Compute writes each frame | Trace output |
| Grate texture | 11-level RGBA8 mip chain | 5,592,404 texels | Uploaded once | Two sphere materials |
| Scene storage | 11 × 64-byte records | 704 | Uploaded once | Geometry and materials |
| Uniform buffer | 4 × vec4 | 64 | Written each frame | Light, camera, target, time |
| Total listed GPU payload | — | 7,952,468 | — | About 7.58 MiB |
Controls and limitations
The camera is fixed at (0, 1.35, 5.4), looking near (0, 0.8, -0.9) with a 43-degree field of view. There are no input controls. Light position and texture U offset animate automatically. The overlay reports timing, device, 11 objects, two textured spheres, four reflection segments, texture dimensions, and trace dimensions.
The renderer has no shadow rays, triangle meshes, acceleration structure, Fresnel term, rough reflections, refraction, anti-aliasing, temporal accumulation, denoiser, HDR target, tone mapper, or sRGB conversion. All materials are hand-authored constants. The KTX decoder is intentionally narrow and the texture's asset-specific license is undocumented in this repository, which should be resolved before redistributing it outside the project.
Run and extend the example
Run the native example from the repository root:
cargo run --example raytracingreflections
Build and serve WebAssembly:
scripts/build-wasm.sh --release raytracingreflections
cargo run --bin serve
Open http://127.0.0.1:8080/raytracingreflections/. Keep assets/textures/gratefloor_rgba.ktx at the copied relative path. Useful experiments include selecting mip levels from ray differentials, adding shadow visibility to shade_hit, introducing roughness, or replacing the analytic scan with triangle and BVH traversal.