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

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.

Three glossy spheres reflected across red, green, white, and black walls in a WebGPU compute ray traced room.
Three spheres reflect a six-plane red, green, white, and black room while the animated point light creates hard shadows. The unchanged 1280×720 capture is stored as a 62,771-byte JPEG and a 23,938-byte WebP. It came with the introducing commit, predates the fog correction, and its displayed 6 fps is one browser capture rather than a benchmark.

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.

Analytic scene assembled by scene_objects()
GroupCountGeometryMaterialIntersection
Spheres3Centers near X = −1.75, 0, and 1.75; radii 1–1.25Green, pale blue, and gold; exponent 32Quadratic ray/sphere test
Room planes6Axis-aligned at signed distance 4White ceiling/floor/back, black front, red and green sidesRay/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.

Core resources at the screenshot's 1024×576 trace resolution
ResourceLayoutLogical bytesUpdatePurpose
Ray target1024×576 Rgba8Unorm; 1 mip; 1 sample2,359,296Compute writes every frameStorage output and sampled presentation input
Scene storage9 × 48-byte records432Uploaded onceSpheres and room planes
Uniform buffer4 × vec464One queue write per frameCamera, light, dimensions, time
Vazirmatn fontEmbedded TrueType; SIL OFL 1.1122,752 storedAtlas managed by overlayFPS, 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.