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 Clips — None/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.
CUBICSPLINEanimation interpolation. OnlyLINEAR/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
SkinnedMeshandMeshare 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.