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

WebGPU notes   /   39

Ray-Traced Shadows in WebGPU with Rust and WGSL

Trace a primary ray against ten analytic objects, launch a bounded visibility ray toward an animated point light, and turn occlusion into crisp contact shadows on spheres, boxes, and a procedural checkerboard.

Scene objects
10
Shadow visibility
30%
Trace cap
1024 px

Add a visibility ray between each visible hit and the light

The previous compute ray tracing example mixed hard shadows with stylized reflection steps. This page isolates direct-light visibility in a cleaner scene. One primary ray finds the visible surface. A second ray starts just above that surface and asks whether any other object lies before the point light. The answer is binary, so the edge remains hard.

The introducing ray tracing shadows commit added the Rust source, compute shader, present shader, and both screenshots. A later camera-basis fix chooses Z-up when a view points almost vertically, and the current WebAssembly entry update modernized startup. Rendering uses sib::render.

Three spheres and three colored boxes casting hard ray-traced shadows across a checkerboard floor between red and green walls.
A blue central sphere, two smaller colored spheres, and three boxes cast crisp shadows on a checkerboard floor. The 1280×720 capture is unchanged since introduction: a 55,149-byte JPEG and 20,198-byte WebP. Its overlay shows a 1024×576 storage target and one 120 fps sample; neither value guarantees performance on another GPU or browser.

Build a ten-object room from typed storage records

Each 64-byte scene record holds four vec4 values: primary geometry, extra box extents, RGB plus specular exponent, and integer IDs. Rust writes three spheres, three axis-aligned boxes, a floor, a back plane, and two side planes into a single read-only storage buffer.

Scroll sideways to see all table columns.

Objects searched by every primary ray
TypeCountShape dataSurface treatmentRole
Sphere3Center + radiusBlue, red, and green; exponents 32–48Curved receivers and occluders
Box3Center + half extentsCyan, yellow, and orange; exponents 20–28Flat-faced receivers and occluders
Plane4Normal + signed distanceChecker floor, gray back, red and green sidesRoom enclosure

The fixed camera sits at (0, 1.55, 8.8), looks toward (0, 0.42, -1.15), and uses a 40-degree vertical field of view. The point light orbits mostly above the objects: X sweeps around −1.4 with radius 2.4, Y oscillates around 5.8, and Z moves around 3.2. Animation time advances at 0.25 times elapsed wall time and wraps every cycle.

Intersect spheres, slab-tested boxes, and infinite planes

Spheres use the two quadratic roots and accept the far root if the near root is behind the origin. Planes solve their signed equation. Boxes use a slab test: a guarded reciprocal prevents zero direction components from producing invalid bounds, then the shader compares the largest entry distance with the smallest exit distance. The winning object also supplies a normalized surface normal, procedural color, and specular exponent.

let t0 = (min_bounds - ray_o) * safe_inverse(ray_d);
let t1 = (max_bounds - ray_o) * safe_inverse(ray_d);
let t_min = max(max(min(t0, t1).x, min(t0, t1).y), min(t0, t1).z);
let t_max = min(min(max(t0, t1).x, max(t0, t1).y), max(t0, t1).z);
if (t_max < max(t_min, EPSILON)) { return -1.0; }

The checkerboard is not a texture. On the floor, the shader adds floor(x * 0.75) and floor(z * 0.75), then multiplies the base gray by either 0.52 or 0.82. That keeps this example free of runtime image assets.

Trace a bounded ray and dim blocked light to 30 percent

After the primary hit, the shader computes the exact point-to-light vector and distance. It offsets the origin by normal * 0.004, skips the surface's own ID, and stops at the first positive intersection nearer than the light:

for (var i = 0u; i < object_count(); i = i + 1u) {
  if (i32(scene_objects[i].ids.x) == object_id) { continue; }
  let t = object_intersect(ray_o, ray_d, scene_objects[i]);
  if ((t > EPSILON) && (t < max_t)) { return true; }
}

An unblocked hit receives base color times diffuse plus a Blinn-style highlight. Diffuse has a 0.2 floor. When blocked, the complete lit color is multiplied by 0.3, retaining visible ambient detail rather than becoming black. Distance fog blends toward a dark blue background over 24 world units.

Trace at most 19 analytic intersections per active pixel

The trace target caps its largest dimension at 1,024 while preserving aspect ratio. At the screenshot's 1024×576 resolution, one dispatch contains 64×36 workgroups and exactly 589,824 active 16×16 invocations. A primary ray always tests ten objects. A shadowed surface can test up to nine more after excluding itself, although early exit often stops sooner. Misses do no shadow work.

Scroll sideways to see all table columns.

Core ray-traced shadow resources at 1024×576
ResourceLogical layoutBytesLifetimeUse
Storage target1024×576 Rgba8Unorm2,359,296Recreated when capped size changesCompute output, then sampled input
Scene buffer10 × 64 bytes640Uploaded onceAll analytic geometry and materials
Uniform buffer4 × vec464Written each frameLight, camera, dimensions, time
Embedded fontVazirmatn TrueType; SIL OFL 1.1122,752 storedOverlay atlasRuntime diagnostics

The three passes are compute, a three-vertex full-screen presentation draw, and a color-load text overlay. The target has one mip and one sample. Linear filtering scales a capped ray image to the presentation surface; no depth attachment, blending, hardware ray-tracing API, or multisampling participates.

Controls and limitations

There are no interactive controls. The camera is fixed, the light animates automatically, and the overlay reports frame timing, device, ten objects, shadow-ray status, and trace dimensions. The camera-basis helper contains a Z-up fallback for vertical views even though the current fixed view does not approach that singularity.

Every shadow sample is binary and uses one point light, so edges cannot soften with blocker distance. Infinite planes and axis-aligned boxes have no object transforms. The linear object list scales poorly, specular highlights are not physically based, output is clamped directly into linear Rgba8Unorm, and there is no anti-aliasing, transparency, refraction, global illumination, temporal accumulation, or denoising.

Run and modify the example

Run the native target:

cargo run --example raytracingshadows

Build and serve the browser version:

scripts/build-wasm.sh --release raytracingshadows
cargo run --bin serve

Open http://127.0.0.1:8080/raytracingshadows/ with WebGPU enabled. All shader and font inputs are embedded. Try sampling several points across an area light for soft shadows, returning a continuous visibility estimate, or adding a BVH before increasing the object count.