Skip to main content

pebble/wgpu/
gltf_loader.rs

1//! One-shot glTF 2.0 loading — [`load_gltf`] parses geometry, a skeleton,
2//! and animation clips out of a `.gltf`/`.glb` file. Deliberately **not**
3//! part of the [`Asset`](crate::assets::upload::Asset)/[`LazyResource`](crate::assets::singleton_asset::LazyResource)
4//! pipeline: those retry forever on `None`, which is right for "the backend
5//! isn't ready yet" but wrong for "this file doesn't exist" or "this glTF
6//! feature isn't supported" — conditions that can never resolve on their
7//! own. Call `load_gltf` directly (in `main()`, a `.once()` system, wherever
8//! you need it) and handle the `Result` like any other fallible I/O.
9//!
10//! Scoped to geometry + skeleton + animation only — materials and textures
11//! are never read, even though the file may reference them; load your own
12//! textures via [`TextureBuilder`](super::textures::TextureBuilder) and
13//! write your own [`Material`](super::material::Material)/shader separately.
14//! See [`ModelLoadError::UnsupportedFeature`] for the full list of glTF
15//! features this doesn't handle (multiple skins, `CUBICSPLINE`
16//! interpolation, sparse accessors, morph targets, non-indexed primitives).
17
18use std::collections::{HashMap, HashSet};
19
20use super::{
21    animation::{AnimationClip, Interpolation, JointTrack, Keyframe},
22    mesh::{Mesh, MeshBuilder, Vertex},
23    skeleton::{Joint, Skeleton, Transform},
24    skinned_mesh::{SkinnedMesh, SkinnedMeshBuilder, SkinnedVertex},
25};
26
27#[derive(Debug)]
28pub enum ModelLoadError {
29    Io(std::io::Error),
30    /// Wraps `gltf::Error`'s `Display` output — the `gltf` crate stays an
31    /// implementation detail, not exposed in this crate's own error type.
32    Parse(String),
33    /// A glTF feature this loader doesn't support, named specifically:
34    /// `"more than one skin"`, `"CUBICSPLINE interpolation"`,
35    /// `"sparse accessors"`, `"non-indexed primitives"`, `"morph targets"`.
36    UnsupportedFeature(&'static str),
37    /// A primitive or accessor is missing data this loader requires, e.g. a
38    /// skinned primitive with no `JOINTS_0`/`WEIGHTS_0`.
39    MissingData(String),
40}
41
42impl std::fmt::Display for ModelLoadError {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            Self::Io(e) => write!(f, "failed to read model file: {e}"),
46            Self::Parse(msg) => write!(f, "failed to parse glTF: {msg}"),
47            Self::UnsupportedFeature(feature) => write!(f, "unsupported glTF feature: {feature}"),
48            Self::MissingData(msg) => write!(f, "missing glTF data: {msg}"),
49        }
50    }
51}
52
53impl std::error::Error for ModelLoadError {
54    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
55        match self {
56            Self::Io(e) => Some(e),
57            _ => None,
58        }
59    }
60}
61
62impl From<std::io::Error> for ModelLoadError {
63    fn from(e: std::io::Error) -> Self {
64        Self::Io(e)
65    }
66}
67
68/// Everything [`load_gltf`] extracted from one file. `skinned_meshes` are
69/// primitives bound to the file's one skin; `static_meshes` are everything
70/// else (rigid props, environment pieces in the same file) as the ordinary
71/// [`Mesh`] — not padded with identity joint weights just to force them
72/// through the skinned path.
73pub struct LoadedModel {
74    pub skinned_meshes: Vec<(String, SkinnedMesh)>,
75    pub static_meshes: Vec<(String, Mesh)>,
76    pub skeleton: Option<Skeleton>,
77    pub animations: Vec<AnimationClip>,
78}
79
80/// Placeholder tangent handedness for primitives with no `TANGENT`
81/// attribute — same convention already used by hand-written vertex data
82/// elsewhere in this repo (see `examples/*/src/main.rs`).
83const DEFAULT_TANGENT: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
84
85/// Loads geometry, a skeleton, and animation clips from a glTF 2.0 file
86/// (`.gltf` or `.glb` — both handled transparently). One-shot and
87/// synchronous: call it directly, not through the asset pipeline (see the
88/// module docs above for why).
89///
90/// Supports exactly one skin per file; a file with more than one returns
91/// [`ModelLoadError::UnsupportedFeature`]. A joint whose real parent (in the
92/// glTF scene graph) isn't itself one of the skin's joints is treated as a
93/// [`Skeleton`] root — a documented v1 limitation, not an error: this
94/// discards that ancestor's transform, which is wrong for a rig where the
95/// ancestor has a non-identity transform (most standard exports, with a
96/// plain identity-transform armature root, aren't affected).
97///
98/// `LINEAR`/`STEP` animation interpolation only — `CUBICSPLINE` is a hard
99/// error rather than being silently misread (its accessors pack an
100/// in-tangent/value/out-tangent triple per keyframe, not a plain value).
101/// Sparse accessors and non-indexed primitives are also hard errors.
102///
103/// Everything below is one function rather than several: the `gltf` crate's
104/// `Reader` type is built from a `get_buffer_data` closure whose lifetime
105/// has to match the primitive/skin/channel it reads from exactly, which
106/// only infers cleanly when the reading code stays inline (a named,
107/// separately-compiled helper function would have to spell out that
108/// lifetime relationship explicitly, and the two ends of it — a loop-local
109/// glTF handle and a closure borrowing the outer `buffers` — can't actually
110/// be unified across a function boundary this way).
111pub fn load_gltf(path: &str) -> Result<LoadedModel, ModelLoadError> {
112    let (document, buffers, _images) = gltf::import(path).map_err(|e| match e {
113        gltf::Error::Io(io_err) => ModelLoadError::Io(io_err),
114        other => ModelLoadError::Parse(other.to_string()),
115    })?;
116    let get_buffer_data = |buffer: gltf::Buffer| buffers.get(buffer.index()).map(|b| b.0.as_slice());
117
118    let skin = match document.skins().len() {
119        0 => None,
120        1 => document.skins().next(),
121        _ => return Err(ModelLoadError::UnsupportedFeature("more than one skin")),
122    };
123
124    let skeleton = match &skin {
125        Some(skin) => {
126            let joint_nodes: Vec<gltf::Node> = skin.joints().collect();
127            let node_to_joint: HashMap<usize, usize> =
128                joint_nodes.iter().enumerate().map(|(i, node)| (node.index(), i)).collect();
129
130            let inverse_bind_matrices: Vec<glam::Mat4> =
131                match skin.reader(get_buffer_data).read_inverse_bind_matrices() {
132                    Some(matrices) => matrices.map(|m| glam::Mat4::from_cols_array_2d(&m)).collect(),
133                    // glTF allows omitting inverse bind matrices entirely (implies identity for every joint).
134                    None => vec![glam::Mat4::IDENTITY; joint_nodes.len()],
135                };
136            if inverse_bind_matrices.len() != joint_nodes.len() {
137                return Err(ModelLoadError::MissingData(format!(
138                    "skin has {} joints but {} inverse bind matrices",
139                    joint_nodes.len(),
140                    inverse_bind_matrices.len(),
141                )));
142            }
143
144            // Maps a child glTF node index to its parent's *joint-list*
145            // index — by scanning every joint's own `children()` (glTF
146            // nodes carry no back-pointer to their own parent, only forward
147            // `children()` links). A joint whose real glTF-graph parent
148            // isn't itself one of this skin's joints simply never appears
149            // as a key here, and so becomes a Skeleton root — a documented
150            // v1 limitation, see this function's doc comment.
151            let mut parent_of_node: HashMap<usize, usize> = HashMap::new();
152            for (parent_joint_index, joint_node) in joint_nodes.iter().enumerate() {
153                for child in joint_node.children() {
154                    if node_to_joint.contains_key(&child.index()) {
155                        parent_of_node.insert(child.index(), parent_joint_index);
156                    }
157                }
158            }
159
160            let joints = joint_nodes
161                .iter()
162                .zip(inverse_bind_matrices)
163                .map(|(node, inverse_bind_matrix)| {
164                    let (translation, rotation, scale) = node.transform().decomposed();
165                    Joint {
166                        name: node.name().unwrap_or("joint").to_string(),
167                        parent: parent_of_node.get(&node.index()).copied(),
168                        inverse_bind_matrix,
169                        local_bind_transform: Transform {
170                            translation: glam::Vec3::from(translation),
171                            rotation: glam::Quat::from_array(rotation),
172                            scale: glam::Vec3::from(scale),
173                        },
174                    }
175                })
176                .collect();
177
178            Some(Skeleton::new(joints))
179        }
180        None => None,
181    };
182
183    let skinned_node_indices: HashSet<usize> = match &skin {
184        Some(skin) => document
185            .nodes()
186            .filter(|n| matches!(n.skin(), Some(s) if s.index() == skin.index()))
187            .map(|n| n.index())
188            .collect(),
189        None => HashSet::new(),
190    };
191
192    let mut skinned_meshes = Vec::new();
193    let mut static_meshes = Vec::new();
194    for node in document.nodes() {
195        let Some(mesh) = node.mesh() else { continue };
196        let is_skinned = skinned_node_indices.contains(&node.index());
197
198        for (i, primitive) in mesh.primitives().enumerate() {
199            let name = format!("{}_{i}", mesh.name().unwrap_or("mesh"));
200            check_not_sparse(&primitive, gltf::Semantic::Positions)?;
201            let reader = primitive.reader(get_buffer_data);
202
203            let positions: Vec<[f32; 3]> = reader
204                .read_positions()
205                .ok_or_else(|| ModelLoadError::MissingData(format!("primitive '{name}' has no POSITION attribute")))?
206                .collect();
207            let normals: Vec<[f32; 3]> = reader
208                .read_normals()
209                .ok_or_else(|| ModelLoadError::MissingData(format!("primitive '{name}' has no NORMAL attribute")))?
210                .collect();
211            let tex_coords: Vec<[f32; 2]> = reader
212                .read_tex_coords(0)
213                .map(|t| t.into_f32().collect())
214                .ok_or_else(|| ModelLoadError::MissingData(format!("primitive '{name}' has no TEXCOORD_0 attribute")))?;
215            let tangents: Vec<[f32; 4]> = match reader.read_tangents() {
216                Some(t) => t.collect(),
217                None => vec![DEFAULT_TANGENT; positions.len()],
218            };
219            let indices: Vec<u32> = reader
220                .read_indices()
221                .map(|idx| idx.into_u32().collect())
222                .ok_or(ModelLoadError::UnsupportedFeature("non-indexed primitives"))?;
223
224            if is_skinned {
225                check_not_sparse(&primitive, gltf::Semantic::Joints(0))?;
226                check_not_sparse(&primitive, gltf::Semantic::Weights(0))?;
227                let joints: Vec<[u16; 4]> = reader
228                    .read_joints(0)
229                    .map(|j| j.into_u16().collect())
230                    .ok_or_else(|| ModelLoadError::MissingData(format!("skinned primitive '{name}' has no JOINTS_0 attribute")))?;
231                let weights: Vec<[f32; 4]> = reader
232                    .read_weights(0)
233                    .map(|w| w.into_f32().collect())
234                    .ok_or_else(|| ModelLoadError::MissingData(format!("skinned primitive '{name}' has no WEIGHTS_0 attribute")))?;
235
236                let vertices = positions
237                    .into_iter()
238                    .zip(normals)
239                    .zip(tex_coords)
240                    .zip(tangents)
241                    .zip(joints)
242                    .zip(weights)
243                    .map(|(((((p, n), uv), t), j), w)| {
244                        SkinnedVertex::new(
245                            glam::Vec3::from(p),
246                            glam::Vec2::from(uv),
247                            glam::Vec3::from(n),
248                            glam::Vec4::from(t),
249                            j,
250                            w,
251                        )
252                    })
253                    .collect();
254                skinned_meshes.push((name, SkinnedMeshBuilder::new(vertices, indices).build()));
255            } else {
256                let vertices = positions
257                    .into_iter()
258                    .zip(normals)
259                    .zip(tex_coords)
260                    .zip(tangents)
261                    .map(|(((p, n), uv), t)| {
262                        Vertex::new(glam::Vec3::from(p), glam::Vec2::from(uv), glam::Vec3::from(n), glam::Vec4::from(t))
263                    })
264                    .collect();
265                static_meshes.push((name, MeshBuilder::new(vertices, indices).build()));
266            }
267        }
268    }
269
270    let animations = match &skeleton {
271        Some(skeleton) => {
272            let mut clips = Vec::new();
273            for animation in document.animations() {
274                let mut tracks: HashMap<usize, JointTrack> = HashMap::new();
275
276                for channel in animation.channels() {
277                    let node = channel.target().node();
278                    let Some(joint_index) = node
279                        .name()
280                        .and_then(|name| (0..skeleton.joint_count()).find(|&i| skeleton.joint(i).name == name))
281                    else {
282                        // Channel targets a node that isn't one of this
283                        // skeleton's joints (a camera, a non-joint prop) —
284                        // not animatable via Skeleton, so it's skipped.
285                        continue;
286                    };
287
288                    let interpolation = match channel.sampler().interpolation() {
289                        gltf::animation::Interpolation::Linear => Interpolation::Linear,
290                        gltf::animation::Interpolation::Step => Interpolation::Step,
291                        gltf::animation::Interpolation::CubicSpline => {
292                            return Err(ModelLoadError::UnsupportedFeature("CUBICSPLINE interpolation"));
293                        }
294                    };
295
296                    let reader = channel.reader(get_buffer_data);
297                    let times: Vec<f32> = reader
298                        .read_inputs()
299                        .ok_or_else(|| ModelLoadError::MissingData("animation channel has no keyframe times".to_string()))?
300                        .collect();
301                    let outputs = reader.read_outputs().ok_or_else(|| {
302                        ModelLoadError::MissingData("animation channel has no keyframe values".to_string())
303                    })?;
304
305                    let track = tracks.entry(joint_index).or_insert_with(|| JointTrack {
306                        joint_index,
307                        translation: Vec::new(),
308                        translation_interpolation: Interpolation::Linear,
309                        rotation: Vec::new(),
310                        rotation_interpolation: Interpolation::Linear,
311                        scale: Vec::new(),
312                        scale_interpolation: Interpolation::Linear,
313                    });
314
315                    match outputs {
316                        gltf::animation::util::ReadOutputs::Translations(values) => {
317                            track.translation = times
318                                .into_iter()
319                                .zip(values)
320                                .map(|(time, v)| Keyframe { time, value: glam::Vec3::from(v) })
321                                .collect();
322                            track.translation_interpolation = interpolation;
323                        }
324                        gltf::animation::util::ReadOutputs::Rotations(values) => {
325                            track.rotation = times
326                                .into_iter()
327                                .zip(values.into_f32())
328                                .map(|(time, v)| Keyframe { time, value: glam::Quat::from_array(v) })
329                                .collect();
330                            track.rotation_interpolation = interpolation;
331                        }
332                        gltf::animation::util::ReadOutputs::Scales(values) => {
333                            track.scale = times
334                                .into_iter()
335                                .zip(values)
336                                .map(|(time, v)| Keyframe { time, value: glam::Vec3::from(v) })
337                                .collect();
338                            track.scale_interpolation = interpolation;
339                        }
340                        gltf::animation::util::ReadOutputs::MorphTargetWeights(_) => {
341                            return Err(ModelLoadError::UnsupportedFeature("morph targets"));
342                        }
343                    }
344                }
345
346                clips.push(AnimationClip::new(
347                    animation.name().unwrap_or("animation").to_string(),
348                    tracks.into_values().collect(),
349                ));
350            }
351            clips
352        }
353        None => {
354            let animation_count = document.animations().len();
355            if animation_count > 0 {
356                tracing::warn!(
357                    "load_gltf: file has {animation_count} animation(s) but no skin — skipping, \
358                     nothing to animate"
359                );
360            }
361            Vec::new()
362        }
363    };
364
365    Ok(LoadedModel { skinned_meshes, static_meshes, skeleton, animations })
366}
367
368fn check_not_sparse(primitive: &gltf::Primitive, semantic: gltf::Semantic) -> Result<(), ModelLoadError> {
369    if let Some(accessor) = primitive.get(&semantic)
370        && accessor.sparse().is_some()
371    {
372        return Err(ModelLoadError::UnsupportedFeature("sparse accessors"));
373    }
374    Ok(())
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    fn fixture_path() -> String {
382        concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/gltf/two_joint_skeleton.gltf").to_string()
383    }
384
385    #[test]
386    fn loads_geometry_skeleton_and_animation_from_a_hand_authored_fixture() {
387        let model = load_gltf(&fixture_path()).expect("fixture should load cleanly");
388
389        assert_eq!(model.static_meshes.len(), 0);
390        // One primitive on the one skinned mesh node — SkinnedMesh's fields
391        // are private (see skinned_mesh.rs), so this just confirms load_gltf
392        // produced exactly one, not its internal vertex/index counts.
393        assert_eq!(model.skinned_meshes.len(), 1);
394
395        let skeleton = model.skeleton.expect("fixture has one skin, expected a Skeleton");
396        assert_eq!(skeleton.joint_count(), 3);
397        // Scrambled input order (child=0, root=1, mid=2) must still resolve
398        // correctly: "root" has no parent, "mid"'s parent is "root", "child"'s
399        // parent is "mid".
400        let root = skeleton.joint_index_by_name("root").unwrap();
401        let mid = skeleton.joint_index_by_name("mid").unwrap();
402        let child = skeleton.joint_index_by_name("child").unwrap();
403        assert_eq!(skeleton.joint(root).parent, None);
404        assert_eq!(skeleton.joint(mid).parent, Some(root));
405        assert_eq!(skeleton.joint(child).parent, Some(mid));
406
407        assert_eq!(model.animations.len(), 1);
408        let clip = &model.animations[0];
409        assert_eq!(clip.name, "wave");
410        assert_eq!(clip.duration, 1.0);
411
412        // Sampling at the midpoint should linearly interpolate the animated
413        // root joint's translation from (0,0,0) to (0,0,5).
414        let poses = clip.sample(0.5, &skeleton);
415        assert!((poses[root].translation.z - 2.5).abs() < 1e-5);
416    }
417}