WebGPU notes / 38
Compute Shader Ray Tracing in WebGPU with Rust and WGSL
Launch one WGSL compute invocation per output texel, intersect nine analytic objects, cast hard-shadow rays, follow two additional reflection steps, and present the result through a procedural full-screen triangle.
- Scene objects
- 9
- Compute tile
- 16×16
- Reflection loop
- 2 steps
Move image generation from rasterization into a compute shader
The previous Compute N-body example used compute to update a particle buffer before a graphics draw. This example uses compute to produce the image itself. Each invocation constructs a camera ray, searches a compact analytic scene, shades the closest hit, and stores one rgba8unorm texel. A separate render pipeline only samples that result onto the surface.
The introducing ray tracing commit added the Rust source, compute WGSL, present WGSL, screenshots, build entry, README row, and gallery card. The current shader later changed fog to use camera-ray hit distance in the hit-distance fog fix; the modern WebAssembly entry update supplies the current start function. The application runs through sib::render.
Pack three spheres and six planes into one storage buffer
Rust creates three colored spheres and six infinite planes. Each 48-byte SceneObject contains geometry in one vec4, RGB plus a specular exponent in a second, and an ID plus type tag in a third. Sphere XYZ and radius occupy the geometry vector. A plane stores its normal and signed distance for the equation dot(normal, position) + distance = 0.
Scroll sideways to see all table columns.
| Group | Count | Geometry | Material | Intersection |
|---|---|---|---|---|
| Spheres | 3 | Centers near X = −1.75, 0, and 1.75; radii 1–1.25 | Green, pale blue, and gold; exponent 32 | Quadratic ray/sphere test |
| Room planes | 6 | Axis-aligned at signed distance 4 | White ceiling/floor/back, black front, red and green sides | Ray/plane equation |
The scene buffer is 432 bytes. A 64-byte uniform buffer carries the moving light, black fog color, camera position, object count, target dimensions, aspect ratio, and animation time. The camera remains at (0, 0, 4). The light follows a compact three-dimensional loop as normalized animation time advances at one quarter of wall-clock speed.
Find the nearest analytic hit with a linear search
The compute shader tests all nine records for every ray. Sphere intersection solves the quadratic and returns the nearer root. Plane intersection divides the signed distance by the ray/normal dot product. The closest positive value above 0.0001 wins.
for (var i = 0u; i < object_count(); i = i + 1u) {
let t = object_intersect(ray_o, ray_d, scene_objects[i]);
if ((t > EPSILON) && (t < hit.t)) {
hit.object_id = i32(scene_objects[i].ids.x);
hit.t = t;
}
}
There is no acceleration structure because nine records are cheap enough to scan. The tradeoff is direct: adding objects increases the intersection work for every primary, shadow, and reflection ray. This representation also supports only spheres and planes; it has no triangles, transforms, meshes, or material indirection.
Shade each hit, cast a hard shadow, apply fog, and reflect
Local shading combines diffuse color with a Blinn-style highlight. Diffuse never falls below 0.1. A secondary ray toward the moving light searches the other eight objects and multiplies the result by 0.5 when blocked. Fog then blends the hit toward black according to abs(hit.t) / 20. The current code correctly uses distance along the ray; the original version used distance to the moving light and made fog change as the light passed a surface.
After the first surface result, a fixed loop calls the same routine twice more. Reflection starts at 0.4 and halves after each step. The blend is a stylized recurrence rather than an energy-conserving path tracer:
var reflection_strength = 0.4;
for (var i = 0u; i < 2u; i = i + 1u) {
let reflected = render_scene(ray_o, ray_d, object_id);
final_color.color = (1.0 - reflection_strength) * final_color.color
+ reflection_strength * mix(reflected.color, final_color.color,
1.0 - reflection_strength);
reflection_strength = reflection_strength * 0.5;
}
A reflected origin moves only one EPSILON along the normal. The shader has no stochastic sampling, soft shadows, anti-aliasing, refraction, physically based BRDF, gamma conversion, or denoiser.
Dispatch at a capped resolution and present a full-screen triangle
The storage texture preserves surface aspect ratio but caps its largest dimension at 1,024. A 1280×720 surface therefore traces 1024×576 pixels: 589,824 active invocations in 64×36 workgroups. Because both dimensions divide by 16, that capture has no surplus lanes. At other sizes, out-of-range invocations return before writing.
Scroll sideways to see all table columns.
| Resource | Layout | Logical bytes | Update | Purpose |
|---|---|---|---|---|
| Ray target | 1024×576 Rgba8Unorm; 1 mip; 1 sample | 2,359,296 | Compute writes every frame | Storage output and sampled presentation input |
| Scene storage | 9 × 48-byte records | 432 | Uploaded once | Spheres and room planes |
| Uniform buffer | 4 × vec4 | 64 | One queue write per frame | Camera, light, dimensions, time |
| Vazirmatn font | Embedded TrueType; SIL OFL 1.1 | 122,752 stored | Atlas managed by overlay | FPS, device, object, and target text |
The frame records three passes: one compute pass, one graphics pass that draws three procedural vertices, and one color-load pass for diagnostics. The presentation sampler is linear and clamp-to-edge, so a capped ray image scales to the surface. There is no depth attachment, blending, or multisampling in the present pipeline.
Controls, diagnostics, and important simplifications
This demo has no keyboard, pointer, camera, or material controls. It animates the light automatically and reports sampled frame time, FPS, GPU description, nine objects, and storage-texture dimensions. Resizing recreates the target and both bind groups only when the capped dimensions change.
The camera_pos_fov.w field contains 60 degrees, but this first shader does not read it: normalize(vec3(screen, -1)) produces an effective 90-degree vertical field of view. Its specular view vector is also the normalized camera position rather than the direction from each hit to the camera. Sphere intersection accepts only the near quadratic root, so a ray starting inside a sphere can miss its exit. These choices are useful discussion points when extending the example, not features hidden by the article.
Run and extend the example
Run the native example from the repository root:
cargo run --example computeraytracing
Build and serve its WebAssembly page:
scripts/build-wasm.sh --release computeraytracing
cargo run --bin serve
Open http://127.0.0.1:8080/computeraytracing/ in a browser with WebGPU support. The shaders and font are embedded, so the example has no runtime image or model request. Good next experiments are to use the stored FOV, fix the view vector, return the far sphere root, or compare the linear object scan with a small bounding-volume hierarchy.