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.
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.
| Asset | Stored bytes | Contents | Runtime result | Provenance and license |
|---|---|---|---|---|
jax.gltf | 68,324 | glTF 2.0 JSON; 59 nodes, 1 mesh/material/skin/animation | Scene hierarchy and accessors | Generator field: Khronos glTF Blender I/O v5.0.21 |
jax.bin | 1,957,752 | Geometry, joints, inverse binds, animation keys | 35,880 vertices and indices; 11,960 raw triangles | Bundled by the Jax asset update; files and repository provide no asset-specific creator or license notice |
jax_base_color.png | 13,008 | 1024×1024 RGB PNG | 4,194,304-byte one-mip Rgba8UnormSrgb texture | |
| Total runtime files | 2,039,084 | Three relative requests | CPU scene plus GPU texture | Resolve 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.
| Resource | Screenshot layout | Logical bytes | Update | Role |
|---|---|---|---|---|
| Trace target | 900×506 RGBA8 | 1,821,600 | Compute writes each frame | Ray-traced image |
| Jax triangles | 9,545 × 128 bytes | 1,221,760 | CPU rebuild + upload while animated | Positions, normals, UVs |
| BVH nodes | 6,801 × 48 bytes | 326,448 | CPU rebuild + upload while animated | Bounds, children, leaf ranges |
| Jax base color | 1024×1024 sRGB RGBA8 | 4,194,304 | Uploaded once | Textured triangle color |
| Procedural scene | 7 × 64 bytes | 448 | Uploaded once | Spheres, boxes, planes |
| Uniforms | 5 × vec4 | 80 | Written each frame | Light, camera, counts, material factor |
| Total listed payload | — | 7,564,640 | — | About 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.