WebGPU notes / 13
glTF Vertex Skinning in WebGPU with Rust and wgpu
Load a glTF skeleton and walking clip in Rust, rebuild its joint palette every frame, and blend four bone influences per vertex in WGSL.
- Skin joints
- 46
- Model triangles
- 11,960
- Animation clip
- 1.0 s
From a glTF skeleton to animated vertices
The earlier glTF loading example rendered one rigid textured cube. Vertex skinning adds a hierarchy of animated joints. Each mesh vertex stores up to four joint indices and four weights; every frame, the CPU evaluates the skeleton and the vertex shader blends the corresponding matrices before projection.
The original glTF skinning commit introduced the technique with the remotely hosted CesiumMan sample. The current Rust example instead loads the bundled Jax character, turns it 30° around Y, and uses the current direct perspective projection. Its WGSL shader is unchanged from that commit. The demo runs through sib::render, loops automatically, and has no mouse, keyboard, camera, or playback controls.
Load the Jax mesh, skin, and texture
The local asset is split across jax.gltf, a 68,324-byte JSON document; jax.bin, a 1,957,752-byte geometry and animation buffer; and a 1024×1024 base-color texture. The glTF describes one scene, 59 nodes, one mesh, one triangle primitive, one material, one skin, and one animation.
The primitive has 35,880 positions, normals, UV pairs, joint-index vectors, weight vectors, and sequential unsigned-short indices. That produces 11,960 nondegenerate triangles. The loader converts indices to u32, joint indices to four f32 values, and normalizes every four-component weight vector. Of the vertices, 9,287 have one nonzero influence, 9,030 have two, 15,184 have three, and 2,379 use all four. Together they reference every one of the 46 joints. Jax has no COLOR_0 attribute, so the loader supplies white vertex colors.
The resulting SkinnedVertex uses a 76-byte stride across shader locations 0 through 5: position, normal, UV, RGB color, four joint indices, and four weights. Missing normals, UVs, colors, joints, or weights receive safe defaults. Positions are required; if indices are absent, the loader generates a sequential range. The example accepts triangle primitives and merges the supported data into one CPU mesh.
AssetLoader reads the three files from disk on native builds. The WASM build fetches those same three resources from the copied assets directory on the local site. Relative buffer and image URLs are resolved against jax.gltf; base64 data URIs are also supported, but this asset uses external files. The RGB image is decoded and uploaded as a one-level sRGB texture. It has no third-party runtime request.
This standalone example keeps its loader inside gltfskinning.rs; it does not call the newer reusable gltf_skin module used by other demos. Its private loader records one mesh node, skin, material, and base-color texture, so it is suitable for Jax rather than a complete multi-mesh, multi-material scene renderer.
Sample the walking animation
The one-second Walking_1 clip contains 138 channels: translation, rotation, and scale for each of the 46 skin joints. Thirty-seven channels contain 25 keys at 1/24-second intervals, while the other 101 contain two. The loader uses the first animation in the file, records the earliest and latest input times, and skips morph-target channels.
On every update, animation time advances by the frame delta, capped at 1/15 second so a long stall does not create a large pose jump. Time wraps from the clip end back to its start. Translation and scale values use vector interpolation, while rotations use normalized quaternion spherical interpolation:
let factor = ((time - start) / (end - start)).clamp(0.0, 1.0);
let rotation = a.slerp(b, factor).normalize();
This sampler is intentionally compact rather than fully glTF-conformant. The Jax file marks 91 channels as STEP, but the example does not retain each sampler's interpolation mode and smooths every supported channel. Those STEP endpoints differ only by tiny floating-point noise in this asset, so the visible walk is effectively unaffected. The loader also does not implement CUBICSPLINE, morph animation, clip selection, pausing, blending, or animation events.
Build and upload the joint palette
Each node keeps its parent, children, translation, quaternion rotation, scale, and optional matrix. Its local transform is composed as translation × rotation × scale × matrix. To find a node's world transform, the code walks upward through the parent chain and prepends every ancestor transform.
For every joint, the example combines three matrices:
let joint = inverse_mesh
* node_world_matrix(&self.nodes, joint_node)
* inverse_bind;
The inverse bind matrix moves vertices from mesh space into the joint's bind space. The animated joint world matrix applies the current pose. Multiplying by the inverse mesh-node world matrix brings the result back into mesh-local coordinates, ready for the model transform used by the shader.
JointMatrices reserves 128 mat4x4<f32> values, an 8,192-byte read-only storage buffer. Jax fills the first 46; unused entries remain identity matrices. Rust rebuilds this palette and writes the full buffer each frame. The fixed 128-joint capacity is sufficient here, but a general loader should reject or split a skin whose vertices reference a larger palette.
Blend four joint influences in WGSL
The vertex shader casts the four uploaded joint indices to u32, fetches their matrices, and forms a weighted matrix sum:
let skin =
input.joint_weights.x * joints.matrices[u32(input.joint_indices.x)] +
input.joint_weights.y * joints.matrices[u32(input.joint_indices.y)] +
input.joint_weights.z * joints.matrices[u32(input.joint_indices.z)] +
input.joint_weights.w * joints.matrices[u32(input.joint_indices.w)];
That matrix deforms the bind-pose position before the fixed model, view, and projection transforms. The normal receives the same skin, model, and view matrices with w = 0. This is linear blend skinning: simple, fast, and widely used, but it can lose volume around strongly twisting joints. Applying the blended matrix directly to normals also assumes transforms that do not require a separate inverse-transpose normal matrix.
Skinning happens during the model's vertex stage, not in a compute pass and not by rewriting the vertex buffer on the CPU. The uploaded mesh stays unchanged while the 46 animated palette matrices deform all 35,880 input vertices.
Render, texture, and light the skinned mesh
One bind group supplies the complete model draw:
Scroll sideways to see all table columns.
| Binding | Resource | Shader visibility | Purpose |
|---|---|---|---|
| 0 | 224-byte uniform buffer | Vertex + fragment | Projection, view, model, light, and base-color factor |
| 1 | 8,192-byte read-only storage buffer | Vertex | Up to 128 joint matrices |
| 2 | 2D float texture | Fragment | Jax base color |
| 3 | Filtering sampler | Fragment | Linear filtering with repeating U and V coordinates |
The fragment shader multiplies the texture sample by the material's base-color factor and the vertex color. It adds a simple view-dependent highlight with exponent 16 and keeps diffuse light at a minimum of 0.5, so shadowed surfaces retain half their base color. Metallic and roughness material fields are not evaluated; this is a compact lit-texture shader rather than a PBR renderer.
The model uses one indexed draw and one instance. Because Jax is not marked double-sided, the pipeline culls back faces. A single-sampled Depth32Float attachment enables depth writes with LessEqual comparison; alpha blending is disabled. A second color-load pass adds the Vazirmatn overlay with the GPU device string and sampled FPS.
Bind-pose bounds have a radius of about 1.581 units. They place the fixed camera near (0, 0.822, 3.872), looking toward (0, 0.285, 0), with a 60° field of view, 0.1 near plane, and a far plane about 37.93 units away. Resizing recreates the depth texture, updates the projection, and rebuilds overlay placement without reloading the model.
Run and modify the example
From a local checkout with Rust installed, run the native example:
cargo run --example gltfskinning
For WebAssembly, install the wasm32-unknown-unknown target and the wasm-bindgen CLI version matching Cargo.lock, then build and serve:
scripts/build-wasm.sh --release gltfskinning
cargo run --bin serve
Open http://127.0.0.1:8080/gltfskinning/ in a browser with WebGPU support. The model, binary buffer, and texture must remain under the same relative assets paths. The shader and overlay font are embedded in the program. The article and screenshot remain available if WebGPU initialization or asset loading fails.
Useful changes to try:
- Preserve each glTF sampler's
LINEAR,STEP, orCUBICSPLINEmode, then test an asset with visibly changing step keys. - Add clip selection, pause, speed, and cross-fade controls while keeping the joint palette format unchanged.
- Upload only the used 46 matrices, or move animation and palette construction to GPU-friendly buffers for a crowd.
- Compare linear blend skinning with dual-quaternion skinning around twisting shoulders and limbs.