Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Loading glTF Models

load_gltf parses geometry, a skeleton, and animation clips out of a .gltf/.glb file:

use pebble::wgpu::gltf_loader::load_gltf;

let model = load_gltf("assets/models/character.glb")?;

Why Result, not Option/silent retry

Everything else fallible in the asset pipeline — a missing backend, a texture whose file doesn’t exist, an unregistered GroupEntry::Global name — returns None from Asset::upload, which the sync system silently retries every tick forever. That convention is right for genuinely transient conditions (“the backend isn’t ready yet”), and wrong for a condition that can never resolve on its own: a .gltf path that’s simply wrong, or a file that uses a glTF feature this loader doesn’t support, will never start working no matter how many times it’s retried.

load_gltf isn’t part of that pipeline at all — it’s a plain, synchronous function you call directly (in main(), a .once() system, wherever), and it returns a real Result<LoadedModel, ModelLoadError> you handle like any other fallible I/O:

let model = match load_gltf(path) {
    Ok(model) => model,
    Err(e) => {
        eprintln!("failed to load model: {e}");
        std::process::exit(1);
    }
};

LoadedModel

pub struct LoadedModel {
    pub skinned_meshes: Vec<(String, SkinnedMesh)>,
    pub static_meshes: Vec<(String, Mesh)>,
    pub skeleton: Option<Skeleton>,
    pub animations: Vec<AnimationClip>,
}

skinned_meshes are primitives bound to the file’s skin, as SkinnedMesh; static_meshes are everything else in the same file (rigid props, environment pieces) as the ordinary Mesh — not padded with identity joint weights just to force them through the skinned path. Both are plain, already-built values — load_gltf has no &mut Assets<T> to insert into (it isn’t a system), so you insert them yourself:

for (name, mesh) in model.skinned_meshes {
    let handle = skinned_meshes.insert(&name, mesh); // ResMut<Assets<SkinnedMesh>>
}

skeleton/animations are the plain CPU data covered in Skeletons and Animation ClipsNone/empty if the file has no skin.

Why not Asset<B>?

Skeleton and AnimationClip are pure CPU computation right up until you write a matrix palette into a buffer of your own — see Skeletons and Animation Clips for the full rationale. Only the mesh geometry load_gltf extracts is a genuine GPU resource, going through the ordinary SkinnedMesh/Mesh asset pipeline like anything else.

Scope

load_gltf covers geometry, a skeleton, and animation — nothing else. It never reads document.materials() or any embedded image data, even when the file references them: load your own textures via TextureBuilder and write your own Material/shader entirely separately, the same as every other example in this book.

A few glTF features are explicitly unsupported, returning ModelLoadError::UnsupportedFeature rather than a wrong or silently-degraded result:

  • More than one skin per file. Exactly zero or one is handled.
  • CUBICSPLINE animation interpolation. Only LINEAR/STEP — cubic-spline accessors pack an in-tangent/value/out-tangent triple per keyframe, not a plain value, and misreading one as a plain value would silently produce garbage rather than fail loudly.
  • Sparse accessors.
  • Non-indexed primitives — both SkinnedMesh and Mesh are index-buffer-only.
  • Morph targets.

One more limitation, not an error: a joint whose real parent (in the glTF scene graph) isn’t itself one of the skin’s joints — an “Armature” root object that isn’t a joint, say — is treated as a Skeleton root, discarding that ancestor’s transform. Most standard exports (a plain identity-transform armature root) aren’t affected; a rig where that ancestor has a real, non-identity transform will render offset from where it should be.

ModelLoadError also covers ordinary I/O/parse failures (Io, Parse) and missing required data (MissingData — e.g. a skinned primitive with no JOINTS_0/WEIGHTS_0).

Putting it together

examples/skeletal_animation is the full loop, end to end. The recommended approach uses SkinnedMeshBuilder::from_file (which calls load_gltf internally) to get a LoadedSkinnedMesh — mesh handles plus a ready AnimationPlayer component — then adds SkinnedBatchingPlugin to handle the per-frame matrix upload automatically. The shader declares GroupEntry::Global("pebble_skinning") and uses instance_index * skin_info.joint_count as the matrix base offset. See Skeletons and Animation for the full walkthrough of AnimationPlayer, SkinnedBatchingPlugin, and the render loop.