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

WebGPU notes   /   41

Ray-Traced glTF Animation in WebGPU with Rust and WGSL

Load an animated glTF 2.0 character, skin its vertices on the CPU, rebuild a median-split triangle BVH for every animated pose, and traverse that hierarchy from a WebGPU compute shader for shadows and three reflection segments.

Skin joints
46
BVH leaf limit
4 triangles
Reflection segments
3

Move from eleven analytic objects to thousands of glTF triangles

The previous reflections example linearly tested four spheres, three boxes, and four planes. Ray-traced glTF keeps seven procedural room objects but adds a skinned triangle mesh. Scanning every Jax triangle for every primary, shadow, and reflection ray would be impractical, so Rust builds a bounding-volume hierarchy and WGSL traverses it with a fixed stack.

The introducing ray tracing glTF commit added the Rust source, compute shader, screenshot, build registration, and gallery entry. It deliberately reuses the preceding full-screen present shader. The current code later received a camera-basis and full-stack-capacity fix, a collapsible settings window, a mobile joystick input fix, and the modern WebAssembly entry. Rendering uses sib::render.

Animated Jax rabbit character ray traced between reflective orange and blue spheres, cyan and yellow boxes, and a checkerboard floor.
Jax walks between two reflective spheres, two boxes, and a checkerboard floor. The introducing commit's unchanged 1280×720 capture is a 59,422-byte JPEG and 30,362-byte WebP. At that animated pose, the overlay reports 9,545 accepted triangles, 6,801 BVH nodes, a 900×506 trace target, and 66 fps. Those are capture values, not fixed cross-device performance or immutable mesh counts.

Load Jax, one skin, one walking clip, and one base-color texture

The shared gltf_skin loader reads the JSON document, resolves its external buffer and image relative to that URL, decodes the PNG to RGBA8, and constructs one merged skinned scene. Native uses filesystem reads. WebAssembly fetches the same three copied files. Data URIs are supported by the loader, but Jax uses external resources.

Scroll sideways to see all table columns.

Jax runtime assets and documented repository provenance
AssetStored bytesContentsRuntime resultProvenance and license
jax.gltf68,324glTF 2.0 JSON; 59 nodes, 1 mesh/material/skin/animationScene hierarchy and accessorsGenerator field: Khronos glTF Blender I/O v5.0.21
jax.bin1,957,752Geometry, joints, inverse binds, animation keys35,880 vertices and indices; 11,960 raw trianglesBundled by the Jax asset update; files and repository provide no asset-specific creator or license notice
jax_base_color.png13,0081024×1024 RGB PNG4,194,304-byte one-mip Rgba8UnormSrgb texture
Total runtime files2,039,084Three relative requestsCPU scene plus GPU textureResolve licensing before redistributing the model separately

The primitive carries positions, normals, UVs, four joint indices, and four weights. The skin has 46 joints. Walking_1 has 138 supported translation, rotation, and scale channels over one second. The shared sampler linearly interpolates vectors and slerps quaternions; it does not preserve each glTF channel's STEP mode. The one material has a white base-color factor and zero metallic factor, but this ray tracer evaluates only base color.

The PNG sampler repeats U and V and uses linear minification and magnification. The uploaded texture has one mip, and WGSL explicitly samples level zero. The glTF's generator string identifies the exporting software, not the character's author or license.

Skin vertices and repack ray triangles on the CPU every animated frame

At initialization, Rust measures the rest-pose bounds, uniformly scales Jax to 2.05 world units tall, centers X and Z, and places the lowest point on Y = 0. Each update advances animation by at most 1/15 second, rebuilds the 46-joint palette, and blends as many as four joint matrices into each vertex position and normal on the CPU.

for slot in 0..4 {
  let weight = vertex.weights[slot];
  if weight <= f32::EPSILON { continue; }
  let joint_matrix = joints.matrices
    .get(vertex.joints[slot] as usize)
    .map(glam::Mat4::from_cols_array_2d)
    .unwrap_or(glam::Mat4::IDENTITY);
  position += joint_matrix.transform_point3(source_position) * weight;
  normal += joint_matrix.transform_vector3(source_normal) * weight;
}

Indices are consumed three at a time. Rust transforms the three points, drops a face when its cross-product length squared is at most 1e-9, transforms or repairs vertex normals, and packs eight vec4 values into a 128-byte RayTriangle. Because the degeneracy test runs after skinning, the ray-tracing triangle count is pose-derived rather than the raw glTF count of 11,960. The screenshot's pose retained 9,545.

Build a median-split BVH with leaves of at most four triangles

For every accepted triangle, Rust records its axis-aligned bounds and centroid. Each node chooses its longest bounds axis, sorts the local slice by centroid, and splits at the midpoint. Recursion stops at four triangles. Leaves copy their triangles into BVH order and store the first index plus count; interior nodes store left and right child indices.

let axis = longest_axis(max - min);
build_triangles.sort_by(|a, b| {
  axis_value(a.centroid, axis).partial_cmp(&axis_value(b.centroid, axis))
    .unwrap_or(Ordering::Equal)
});
let midpoint = build_triangles.len() / 2;

A node is 48 bytes: two bound vectors and one u32 data vector. This is a deterministic, balanced median hierarchy, not a surface-area-heuristic builder. Re-sorting and allocating the full hierarchy every animated frame is straightforward but CPU intensive.

Traverse the hierarchy with a fixed 64-entry WGSL stack

The shader first tests all seven procedural objects. It then starts at BVH node zero, rejects nodes with a ray/AABB slab test, and examines at most four Möller–Trumbore triangles per leaf. abs(det) accepts both triangle orientations, so glTF back-face and doubleSided material state do not control ray visibility. Barycentric weights interpolate vertex normals and UVs at the nearest hit.

if (leaf_count > 0u) {
  for (var i = 0u; i < leaf_count; i = i + 1u) {
    let candidate = triangle_intersect(ray_o, ray_d,
                                         gltf_triangles[first + i]);
    if (candidate.hit == 1u && candidate.t < hit.t) {
      hit = triangle_hit_to_scene_hit(gltf_triangles[first + i], candidate);
    }
  }
}

The stack holds 64 node indices. Interior children are pushed only when two entries fit; the later fix changed the check to allow the final two valid slots. This balanced Jax tree is far shallower than 64, but a future builder or malformed node buffer that exhausts the stack would skip those children rather than report an error. Children are not ordered by near distance.

Texture Jax, cast hard shadows, and accumulate three reflection segments

The scene supplements Jax with two spheres, two boxes, a reflective checker floor, and two wall planes. A moving point light provides diffuse plus a specular highlight. Every visible hit casts another full scene query toward the light; any blocker reduces visibility to 0.42. Jax samples its sRGB base-color texture at interpolated UV and uses fixed reflectivity 0.08.

The reflection loop follows at most three segments. Local light contributes 1 - reflectivity, throughput receives an 18% material-color tint, and a miss adds the blue sky gradient. Procedural reflectivity ranges from 0 for the walls to 0.42 for the floor. This combines direct hard shadows with mirror-like secondary rays, but it remains a hand-tuned renderer rather than a glTF PBR implementation.

Rebuild dynamic geometry, dispatch compute, then draw three overlays

With Skinning enabled, every update advances the clip, skins all vertices, rebuilds accepted triangles, sorts a new BVH, and uploads both buffers. When lengths match, queue.write_buffer updates them in place. If the degeneracy test changes either length, Rust reallocates the buffers and rebuilds the compute bind group. Disabling Skinning rebuilds the rest pose once, then leaves those buffers static until the checkbox changes again.

Scroll sideways to see all table columns.

Listed logical GPU payload at the screenshot pose and 900×506 target
ResourceScreenshot layoutLogical bytesUpdateRole
Trace target900×506 RGBA81,821,600Compute writes each frameRay-traced image
Jax triangles9,545 × 128 bytes1,221,760CPU rebuild + upload while animatedPositions, normals, UVs
BVH nodes6,801 × 48 bytes326,448CPU rebuild + upload while animatedBounds, children, leaf ranges
Jax base color1024×1024 sRGB RGBA84,194,304Uploaded onceTextured triangle color
Procedural scene7 × 64 bytes448Uploaded onceSpheres, boxes, planes
Uniforms5 × vec480Written each frameLight, camera, counts, material factor
Total listed payload7,564,640About 7.21 MiB; excludes UI and driver resources

The target caps its largest dimension at 900, producing 57×32 workgroups for the screenshot. That is 466,944 launched invocations: 455,400 active pixels and 11,544 edge lanes that return after the bounds check. A frame records compute, full-screen presentation, a combined text/joystick color-load pass, and an egui pass. The present shader has no depth attachment, blending, or MSAA.

Use FPS controls and understand the CPU/GPU split

Use W/A/S/D to move on the ground plane and the arrow keys to look. With mouse or touch, drag the left half of the canvas to move and the right half to look; active virtual sticks are drawn over the scene. The initial camera derives from the posed bounds. Its move speed scales with scene radius and look speed is 1.6 radians per second at full input.

The collapsible “Ray tracing glTF” window shows frame time, device, current triangle and BVH counts, and storage dimensions. Its Skinning checkbox defaults on. egui consumes its own pointer events and resets active joystick pointers, preventing a settings click from leaving a movement stick engaged.

This example recomputes skinning and acceleration on the CPU every animated frame. It supports one selected skinned mesh/material path rather than arbitrary multi-scene glTF, ignores metallic/roughness and most material features, samples one texture level, treats triangles as double-sided, uses hard shadows and mirror-like reflections, and has no hardware ray-tracing API, motion blur, anti-aliasing, accumulation, denoising, HDR tone mapping, or GPU BVH refit. The model files also lack an asset-specific license notice in this repository.

Run and extend the example

Run the native example:

cargo run --example raytracinggltf

Build and serve the browser target:

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

Open http://127.0.0.1:8080/raytracinggltf/. Keep jax.gltf, jax.bin, and jax_base_color.png at their existing relative asset paths. Useful extensions are a BVH refit that preserves topology, GPU skinning into ray-ready vertices, near-first traversal, multiple glTF materials, or a profiler that separates CPU build, upload, compute, and presentation time.