WebGPU notes / 11
Procedural 3D Gears with Rust, wgpu, and WebGPU
Generate three toothed meshes in Rust, place their vertices in shared GPU buffers, animate each gear with its own uniform data, and shade the moving surfaces with WGSL.
- Gears
- 3
- Submitted triangles
- 880
- Model draws
- 3
Procedural gears without model files
The glTF example parsed geometry from a remote asset. Gears takes the opposite route: the Rust example constructs every position, normal, color, and index at startup. No model or texture file is needed.
Three solid-color gears rotate together on a black background. A bundled Vazirmatn font supplies the diagnostic overlay, whose first line literally reads Vulkan Example - Gears
despite this being a wgpu/WebGPU demo. It also reports frame time, FPS, and the selected GPU. The application runs through sib::render and has no mouse, keyboard, camera, or physics controls.
Define the three gear specifications
Each GearSpec describes the radii, thickness, tooth count and depth, vertex color, world position, speed multiplier, and starting angle. All three use a tooth depth of 0.7 units. Their other values differ:
Scroll sideways to see all table columns.
| Gear | Inner radius | Outer radius | Width | Teeth | Position | Angular rate | Start angle |
|---|---|---|---|---|---|---|---|
| Red | 1.0 | 4.0 | 1.0 | 20 | (−3.0, 0.0, 0.0) | +90°/s | 0° |
| Green | 0.5 | 2.0 | 2.0 | 10 | (3.1, 0.0, 0.0) | −180°/s | −9° |
| Blue | 1.3 | 2.0 | 0.5 | 10 | (−3.1, −6.2, 0.0) | −180°/s | −30° |
The base angle advances at 90° per second. Multipliers of 1.0 and -2.0 make the two smaller gears turn twice as fast in the opposite direction, matching the 20-tooth to 10-tooth ratio. Their positions and starting offsets make the teeth appear to engage, but there is no collision detection, constraint solver, or torque simulation.
The nominal outer radius sits halfway between the tooth root and tip. For every spec, the generator computes the root as outer_radius - tooth_depth * 0.5 and the tip as outer_radius + tooth_depth * 0.5.
Generate each toothed mesh
generate_gear divides one revolution into equal tooth sectors. A second angle, da, is one quarter of a sector. Those five angles describe the rising edge, tip, falling edge, and gap of each tooth:
let r0 = spec.inner_radius;
let r1 = spec.outer_radius - spec.tooth_depth * 0.5;
let r2 = spec.outer_radius + spec.tooth_depth * 0.5;
let da = std::f32::consts::TAU / spec.teeth as f32 / 4.0;
For each tooth, the function emits triangles for the front annulus and tooth face, mirrors them across the gear width for the back, then closes the sloped tooth edges, tooth tip, outer gap, and inner bore with side quads. Front vertices use a +Z normal, back vertices use −Z, and side normals point outward or inward as appropriate.
One tooth contributes 40 vertices and 66 indices, or 22 submitted triangles. Two triangles per tooth have zero area because the annular front and back sections repeat one point. The other 20 are nondegenerate. Faces duplicate vertices so they can carry flat normals instead of averaging across hard edges.
The 20-tooth gear therefore uses 800 vertices and 1,320 indices; each 10-tooth gear uses 400 vertices and 660 indices. Together they produce 1,600 vertices, 2,640 u32 indices, 880 submitted triangles, and 800 nondegenerate triangles.
GearVertex has a 36-byte stride. Position is a Float32x3 at shader location 0, normal is another Float32x3 at location 1, and RGB color is a third Float32x3 at location 2. There are no texture coordinates, textures, or samplers.
Share buffers and keep per-gear uniforms
The three generators append into the same CPU vertex and index arrays. Before each append, the example records the current index length as index_start; afterward, it calculates the new range length as index_count. The combined arrays become one GPU vertex buffer and one GPU index buffer.
Geometry is shared at the buffer level, but transforms are not instanced. Each Gear owns a uniform buffer and bind group containing projection, view, model, inverse-transpose normal matrices, and the light position. The uniform is visible to the vertex stage at @group(0) @binding(0).
The render loop binds the shared geometry once, then changes the bind group and indexed range for each gear:
for gear in &self.gears {
pass.set_bind_group(0, &gear.bind_group, &[]);
pass.draw_indexed(
gear.index_start..gear.index_start + gear.index_count,
0,
0..1,
);
}
This produces three model draw calls. An instanced design would be useful when many objects share identical geometry, but these gears have different tooth counts and index ranges.
Animate and light the gears in WGSL
Every update adds delta_seconds * 90.0 to the base angle. For each gear, translation is multiplied by a Z rotation built from its speed multiplier and starting offset. The CPU writes all three uniform buffers each frame; the procedural vertex and index buffers remain unchanged.
The camera sits at (0, 7, -17), looks toward (0, 2.5, 0), and uses -Y as its up vector. The perspective projection has a 60° vertical field of view, a 0.001 near plane, and a 256-unit far plane. A positional light at (0, 0, 2.5) is transformed into view space, while each gear receives its own model and normal matrices.
In the vertex stage, the WGSL shader transforms the normal with the inverse-transpose matrix and calculates view-space eye and light vectors. The fragment stage combines ambient, Lambert-style diffuse, and reflected-vector specular terms:
let diffuse = vec4<f32>(0.5, 0.5, 0.5, 0.5)
* max(dot(normal, light_vector), 0.0);
let specular = vec4<f32>(0.5, 0.5, 0.5, 1.0)
* pow(max(dot(reflected, eye), 0.0), 0.8)
* 0.25;
The exponent of 0.8 creates a broad highlight rather than a physically based material response. The scene pipeline uses counterclockwise front faces, back-face culling, a single sample, and Depth32Float with depth writes and LessEqual comparison. Alpha blending is not enabled.
The first pass clears color and depth, then issues the three gear draws. A second color-load pass renders the diagnostic overlay. Its FPS estimate updates on the default half-second sampling interval; the displayed milliseconds come from 1000 / fps after the first complete sample, not a GPU timestamp query.
Resizing recreates the depth texture, recalculates each projection and normal matrix, and rebuilds the overlay placement. It does not regenerate or re-upload the gear meshes.
Run and modify the example
From a local checkout with Rust installed, run the native example:
cargo run --example gears
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 gears
cargo run --bin serve
Open http://127.0.0.1:8080/gears/. Live rendering requires WebGPU support. The gear geometry is generated at startup, while the shader and overlay font are embedded in the build, so this demo does not download a runtime model or texture. The article and screenshot remain readable without WebGPU.
Useful changes to try:
- Change a tooth count and update the matching speed ratio, position, and starting offset.
- Adjust
widthandtooth_depth, then compare the new mesh counts and silhouette. - Replace the three draw calls with instancing after making the gears share one geometry specification.
- Tune the light position and specular exponent, or replace the lighting with a material model suited to metal.