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

WebGPU notes   /   10

Load and Render glTF 2.0 with Rust and WebGPU

Load a glTF 2.0 scene at runtime, turn its nodes and mesh attributes into GPU buffers, upload its base-color texture, and render the result with Rust, wgpu, and WGSL.

Vertices
24
Indices
36
Model draws
1

From a glTF file to a WebGPU frame

The previous HTML mesh example assembles browser-like content from generated geometry. This example starts with an existing glTF 2.0 asset: the BoxTextured sample from Khronos. Its scene contains one textured cube, one triangle primitive, and one material.

The Rust example passes a raw GitHub URL to load_gltf_scene before initializing the renderer. Native builds load synchronously. The WebAssembly entry point starts the same work asynchronously and only calls sib::render after the scene is ready.

The live demo rotates the cube at 0.72 radians per second, with a fixed 12° tilt around X. A separate text overlay shows the selected GPU and a frame-rate sample. There are no camera, mouse, keyboard, or file-picker controls.

An angled gray cube textured with blue, green, and white Cesium logos on a dark WebGPU canvas.
The Khronos BoxTextured sample after its node transform, vertex data, material, sampler, and base-color image have been converted into wgpu resources.

Fetch the JSON, buffer, and image

The glTF file is a JSON document that points to two neighboring resources. load_gltf_scene first downloads and parses that JSON with the Rust gltf crate. It resolves each relative URI against the document URL, loads the buffer and image, and decodes the PNG into RGBA8 pixels.

Scroll sideways to see all table columns.

BoxTextured data used by the demo
Resource or valueSource dataLoader result
BoxTextured.gltfglTF 2.0 JSONScene, nodes, accessors, material, and URIs
BoxTextured0.bin840 bytes24 positions, normals, UVs, and 36 indices
CesiumLogoFlat.png256 × 256 pixelsRGBA8 sRGB base-color texture
Primitive modeTriangles12 indexed triangles
SamplerRepeat; linear magnification; nearest minification with linear mip filteringwgpu sampler options

The shared asset loader reads local paths or HTTP URLs on native targets. In a browser it uses Fetch, while URI resource batches run through a Web Worker. Base64-encoded data URIs are also accepted.

This URL loader deliberately covers a smaller format subset than glTF itself. It accepts URI-backed .gltf resources, but rejects binary buffer chunks from .glb files and images stored in buffer views. The demo also depends on the remote Khronos files and network access during startup. Its URL follows the sample repository’s main branch rather than a pinned revision.

Traverse the scene and merge its primitives

After loading resources, the code selects the default scene or falls back to the first scene. It visits every root node and recursively computes parent_transform * node_transform. A guard rejects recursion depth greater than 256.

let scene = gltf
    .default_scene()
    .or_else(|| gltf.scenes().next())
    .ok_or_else(|| RenderError::message("glTF file has no scene"))?;

for node in scene.nodes() {
    collect_node(node, glam::Mat4::IDENTITY, /* resources and output */)?;
}

Only triangle primitives are accepted. POSITION is required; missing normals become [0.0, 0.0, 1.0], and missing TEXCOORD_0 values become [0.0, 0.0]. If a primitive has no index accessor, the loader generates sequential indices.

Node transforms are applied to positions and normals on the CPU. Normals use transform_vector3 followed by normalization, not an inverse-transpose normal matrix, so nonuniform node scaling can produce incorrect lighting. Every primitive is appended to one MeshVertex array and one u32 index array, with incoming indices offset by the current vertex count. This makes the sample efficient to draw, but it also means the loader does not preserve separate primitives for separate material draws.

The merged positions produce an axis-aligned bounding box. Its center is subtracted in the model matrix, while its radius sets the camera height, camera distance, and far plane. That keeps this cube framed without hard-coding its original dimensions.

Map the material, texture, and sampler

The loader reads the first primitive material’s base-color factor, metallic factor, roughness factor, and doubleSided flag. It also keeps the first base-color image and its sampler for the entire merged mesh. If no base-color texture exists, it uploads a one-pixel white image so the same shader and bind-group layout still work.

let primitive_material = primitive.material();
let pbr = primitive_material.pbr_metallic_roughness();

GltfMaterial {
    base_color_factor: pbr.base_color_factor(),
    metallic_factor: pbr.metallic_factor(),
    roughness_factor: pbr.roughness_factor(),
    double_sided: primitive_material.double_sided(),
    ..Default::default()
}

The sampler conversion maps glTF wrap modes and minification, magnification, and mip filters to wgpu values. The texture helper uploads the image as Rgba8UnormSrgb with one mip level. The sample asks for nearest minification with linear interpolation between mip levels, but this upload does not generate a mip chain, so only level 0 is available.

This is a focused learning loader, not a general material system. Multiple materials are merged under the first material, and the example does not render normal, metallic-roughness, occlusion, or emissive textures. It also does not process alpha modes, animations, skins, morph targets, cameras, or lights. The following glTF vertex skinning demo handles animation and joints separately.

Upload and render the model with WGSL

Each MeshVertex occupies 32 bytes: a Float32x3 position at shader location 0, a Float32x2 UV at location 1, and a Float32x3 normal at location 2. The CPU mesh becomes one vertex buffer and one u32 index buffer during initialization.

Bind group 0 contains the uniform buffer at binding 0, the base-color texture at binding 1, and its sampler at binding 2. The uniforms provide view-projection and model matrices, the camera position, the base-color factor, and packed material values. The model pass clears color and depth, then issues one indexed draw:

render_pass.set_vertex_buffer(0, gpu_mesh.vertex_buffer.slice(..));
render_pass.set_index_buffer(gpu_mesh.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
render_pass.draw_indexed(0..gpu_mesh.index_count, 0, 0..1);

The pipeline writes a Depth32Float attachment and uses LessEqual depth testing. Face culling is disabled. A second render pass loads the existing color attachment and draws the GPU device and FPS overlay.

The WGSL shader samples the sRGB base-color texture, multiplies it by the material factor, and adds ambient, directional diffuse, and white specular terms. Roughness changes the specular exponent:

let roughness = clamp(uniforms.metallic_roughness.y, 0.04, 1.0);
let specular_power = mix(96.0, 16.0, roughness);
let color = base_color.rgb * (0.28 + diffuse * 0.82)
    + vec3<f32>(specular);

Despite the packed uniform name, the current shader does not use the metallic factor or doubleSided flag, and the pipeline disables culling for every model. The shader returns the sampled alpha, but the color target does not enable alpha blending. This is compact teaching lighting rather than a full glTF metallic-roughness implementation.

Rotation uses frame delta time, so its intended speed is independent of frame rate. Each update writes new uniforms without rebuilding the mesh. Resizing recreates the depth texture, reframes the projection for the new aspect ratio, and rebuilds the text placement.

Run and extend the example

From a local checkout with Rust installed and network access to the Khronos sample repository, run:

cargo run --example gltf

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 gltf
cargo run --bin serve

Open http://127.0.0.1:8080/gltf/. The browser needs WebGPU support and must be able to fetch the model, buffer, and image from raw.githubusercontent.com. The article and screenshot remain available if live rendering cannot start.

Useful next experiments include:

  • Point BOX_TEXTURED_GLTF_URL at another URI-backed .gltf file that fits the loader’s current subset.
  • Keep primitives and materials separate, create one bind group per material, and draw each primitive with its matching material.
  • Generate a mip chain for the base-color image and compare it with the current single-level upload.
  • Add the remaining metallic-roughness inputs before treating the shader as a glTF PBR renderer.