Skip to main content

pebble/wgpu/
skinned_mesh.rs

1use std::{collections::HashMap, sync::Arc};
2
3use crate::{
4    assets::{handle::Handle, storage::Assets, upload::{Asset, AssetSource}},
5    wgpu::{
6        backend::WGPUBackend,
7        buffer::Buffer,
8        buffers::BufferBuilder,
9        flags::BufferUsages,
10        vertex_format::{VertexAttribute, VertexBufferLayout, VertexFormat, VertexStepMode},
11    },
12};
13
14/// Same 4 fields as [`Vertex`](super::mesh::Vertex), plus skinning data.
15///
16/// `_pad` exists purely so this type has no *implicit* padding: `glam::Vec4`
17/// is 16-byte aligned (SIMD-backed), which forces this whole struct's
18/// alignment to 16 — without an explicit trailing field to account for the
19/// last 8 bytes, the compiler would insert *invisible* padding there
20/// instead, and `#[derive(bytemuck::Pod)]` correctly refuses to compile a
21/// type with any padding it can't account for byte-for-byte.
22#[repr(C)]
23#[derive(Copy, Clone, Default, bytemuck::Pod, bytemuck::Zeroable)]
24pub struct SkinnedVertex {
25    pub position: glam::Vec3,
26    pub tex_coords: glam::Vec2,
27    pub normal: glam::Vec3,
28    pub tangent: glam::Vec4,
29    /// Up to 4 joint indices this vertex is bound to, paired positionally
30    /// with `joint_weights`. `u16`, not `u32` or `u8`: glTF's `JOINTS_n`
31    /// accessor is spec-limited to `UNSIGNED_BYTE`/`UNSIGNED_SHORT` (never
32    /// `UNSIGNED_INT`), so `u16` already covers every legal glTF skeleton
33    /// (up to 65535 joints) losslessly, at half the bytes of `u32`.
34    /// [`gltf_loader::load_gltf`](super::gltf_loader::load_gltf) upconverts
35    /// `u8`-sourced `JOINTS_0` data.
36    pub joint_indices: [u16; 4],
37    pub joint_weights: [f32; 4],
38    _pad: [u32; 2],
39}
40
41impl SkinnedVertex {
42    pub fn new(
43        position: glam::Vec3,
44        tex_coords: glam::Vec2,
45        normal: glam::Vec3,
46        tangent: glam::Vec4,
47        joint_indices: [u16; 4],
48        joint_weights: [f32; 4],
49    ) -> Self {
50        Self {
51            position,
52            tex_coords,
53            normal,
54            tangent,
55            joint_indices,
56            joint_weights,
57            _pad: [0; 2],
58        }
59    }
60
61    /// Occupies locations 0–3 (same meaning as
62    /// [`Vertex`](super::mesh::Vertex)'s own 0–3) and 8–9 — deliberately
63    /// skipping 4–7 (reserved for [`InstanceVertex`](super::mesh::InstanceVertex))
64    /// so a skinned mesh can still be instanced in one pipeline without
65    /// either layout changing. WGSL side: all integer vertex formats widen
66    /// to `vec4<u32>` regardless of source width, so declare
67    /// `@location(8) joint_indices: vec4<u32>` and
68    /// `@location(9) joint_weights: vec4<f32>`.
69    pub fn layout() -> VertexBufferLayout {
70        VertexBufferLayout {
71            array_stride: std::mem::size_of::<SkinnedVertex>() as u64,
72            step_mode: VertexStepMode::Vertex,
73            attributes: vec![
74                VertexAttribute { format: VertexFormat::Float32x3, offset: 0, shader_location: 0 }, // position
75                VertexAttribute { format: VertexFormat::Float32x2, offset: 12, shader_location: 1 }, // tex_coords
76                VertexAttribute { format: VertexFormat::Float32x3, offset: 20, shader_location: 2 }, // normal
77                VertexAttribute { format: VertexFormat::Float32x4, offset: 32, shader_location: 3 }, // tangent
78                VertexAttribute { format: VertexFormat::Uint16x4, offset: 48, shader_location: 8 }, // joint_indices
79                VertexAttribute { format: VertexFormat::Float32x4, offset: 56, shader_location: 9 }, // joint_weights
80            ],
81        }
82    }
83}
84
85/// Source data for [`GPUSkinnedMesh`]: a plain vertex/index list, uploaded
86/// as-is. Fields are private — the only way to construct one is
87/// [`SkinnedMeshBuilder`]: `SkinnedMeshBuilder::new(vertices, indices).build()`.
88/// Usually obtained via [`gltf_loader::load_gltf`](super::gltf_loader::load_gltf)
89/// rather than hand-authored.
90pub struct SkinnedMesh {
91    vertices: Vec<SkinnedVertex>,
92    indices: Vec<u32>,
93}
94
95/// Builds a [`SkinnedMesh`] from a vertex/index list. Start from
96/// [`new`](Self::new), then finish with
97/// [`build`](Self::build)/[`build_asset`](Self::build_asset).
98pub struct SkinnedMeshBuilder {
99    vertices: Vec<SkinnedVertex>,
100    indices: Vec<u32>,
101}
102
103impl SkinnedMeshBuilder {
104    pub fn new(vertices: Vec<SkinnedVertex>, indices: Vec<u32>) -> Self {
105        Self { vertices, indices }
106    }
107
108    /// Same empty-vertices/empty-indices checks as
109    /// [`MeshBuilder`](super::mesh::MeshBuilder), plus: warns if any
110    /// vertex's `joint_weights` don't sum to ~1.0 (±0.01) — the usual
111    /// symptom of un-normalized weights, most likely from a hand-authored
112    /// `SkinnedMesh` that skipped `load_gltf` (which always normalizes).
113    fn validate(&self) {
114        if self.vertices.is_empty() {
115            tracing::warn!("SkinnedMeshBuilder::new(): no vertices — did you forget to pass them?");
116        }
117        if self.indices.is_empty() {
118            tracing::warn!("SkinnedMeshBuilder::new(): no indices — did you forget to pass them?");
119        }
120        for (i, vertex) in self.vertices.iter().enumerate() {
121            let sum: f32 = vertex.joint_weights.iter().sum();
122            if (sum - 1.0).abs() > 0.01 {
123                tracing::warn!(
124                    "SkinnedMeshBuilder: vertex {i}'s joint_weights sum to {sum}, not ~1.0 — did \
125                     you forget to normalize them?"
126                );
127            }
128        }
129    }
130
131    /// Consume the builder and return the finished [`SkinnedMesh`] value.
132    pub fn build(self) -> SkinnedMesh {
133        self.validate();
134        SkinnedMesh { vertices: self.vertices, indices: self.indices }
135    }
136
137    /// Consume the builder, insert into `assets` under `name`, and return
138    /// the resulting [`Handle<SkinnedMesh>`].
139    pub fn build_asset(self, name: &str, assets: &mut Assets<SkinnedMesh>) -> Handle<SkinnedMesh> {
140        let mesh = self.build();
141        assets.insert(name, mesh)
142    }
143}
144
145/// A skinned mesh uploaded to the GPU. `index_buffer`/`index_count` must
146/// stay in sync if you ever mutate one after construction — same caveat as
147/// [`GPUMesh`](super::mesh::GPUMesh).
148pub struct GPUSkinnedMesh {
149    pub vertex_buffer: Buffer,
150    pub index_buffer: Buffer,
151    pub index_count: u32,
152}
153
154impl AssetSource for SkinnedMesh {
155    type Processed = GPUSkinnedMesh;
156}
157
158impl Asset<WGPUBackend> for SkinnedMesh {
159    type Deps<'a> = ();
160
161    fn upload<'a>(&self, backend: &WGPUBackend, _deps: &()) -> Option<GPUSkinnedMesh> {
162        let vertex_buffer = BufferBuilder::with_data(bytemuck::cast_slice(self.vertices.as_slice()))
163            .with_label("SkinnedMesh Vertex Buffer")
164            .with_usage(BufferUsages::VERTEX)
165            .build(backend);
166        let index_buffer = BufferBuilder::with_data(bytemuck::cast_slice(&self.indices))
167            .with_label("SkinnedMesh Index Buffer")
168            .with_usage(BufferUsages::INDEX)
169            .build(backend);
170        Some(GPUSkinnedMesh {
171            vertex_buffer,
172            index_buffer,
173            index_count: self.indices.len() as u32,
174        })
175    }
176}
177
178crate::wgpu::plugin_macros::asset_plugin! {
179    /// Registers the [`SkinnedMesh`] asset pipeline. Included by
180    /// [`WGPUPlugin`](super::backend::WGPUPlugin); add directly only if
181    /// you're assembling the `wgpu` module's plugins by hand.
182    SkinnedMeshPlugin, SkinnedMesh
183}
184
185/// Convenience builder that loads a glTF with optional extra animation clips
186/// and produces a ready-to-use [`LoadedSkinnedMesh`].
187///
188/// Start from [`SkinnedMeshBuilder::from_file`], chain [`with_animation`](Self::with_animation)
189/// for additional clips, then call [`build`](Self::build).
190pub struct SkinnedModelBuilder {
191    primary: String,
192    extra_clips: Vec<(String, String)>,
193}
194
195impl SkinnedModelBuilder {
196    /// Add an extra animation clip loaded from `path` and stored under `name`.
197    pub fn with_animation(mut self, name: impl Into<String>, path: impl Into<String>) -> Self {
198        self.extra_clips.push((name.into(), path.into()));
199        self
200    }
201
202    /// Load the glTF, insert all skinned meshes into `assets`, and return the
203    /// result. Returns an error if the file cannot be loaded or has no skeleton.
204    pub fn build(self, assets: &mut Assets<SkinnedMesh>) -> Result<LoadedSkinnedMesh, super::gltf_loader::ModelLoadError> {
205        let model = super::gltf_loader::load_gltf(&self.primary)?;
206
207        let skeleton = model.skeleton
208            .ok_or_else(|| super::gltf_loader::ModelLoadError::MissingData("no skeleton in model".to_string()))?;
209        let skeleton = Arc::new(skeleton);
210
211        let meshes: Vec<(String, Handle<SkinnedMesh>)> = model.skinned_meshes
212            .into_iter()
213            .map(|(name, mesh)| {
214                let handle = assets.insert(&name, mesh);
215                (name, handle)
216            })
217            .collect();
218
219        let mut clips: HashMap<String, super::animation::AnimationClip> = model.animations
220            .into_iter()
221            .map(|clip| (clip.name.clone(), clip))
222            .collect();
223
224        for (clip_name, path) in self.extra_clips {
225            let extra = super::gltf_loader::load_gltf(&path)?;
226            for clip in extra.animations {
227                clips.insert(clip_name.clone(), clip);
228            }
229        }
230
231        let player = super::player::AnimationPlayer::new(Arc::clone(&skeleton), Arc::new(clips));
232        Ok(LoadedSkinnedMesh { meshes, player })
233    }
234}
235
236/// The output of [`SkinnedModelBuilder::build`]: mesh handles plus a ready
237/// [`AnimationPlayer`](super::player::AnimationPlayer) backed by the loaded skeleton and clips.
238pub struct LoadedSkinnedMesh {
239    pub meshes: Vec<(String, Handle<SkinnedMesh>)>,
240    pub player: super::player::AnimationPlayer,
241}
242
243impl LoadedSkinnedMesh {
244    /// The first mesh handle, for models with a single mesh. Returns `None` if
245    /// no meshes were loaded.
246    pub fn mesh(&self) -> Option<Handle<SkinnedMesh>> {
247        self.meshes.first().map(|(_, h)| *h)
248    }
249}
250
251impl SkinnedMeshBuilder {
252    /// Start a [`SkinnedModelBuilder`] that loads the primary glTF from `path`.
253    pub fn from_file(path: &str) -> SkinnedModelBuilder {
254        SkinnedModelBuilder { primary: path.to_string(), extra_clips: Vec::new() }
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn vertex(joint_weights: [f32; 4]) -> SkinnedVertex {
263        SkinnedVertex::new(
264            glam::Vec3::ZERO,
265            glam::Vec2::ZERO,
266            glam::Vec3::Z,
267            glam::Vec4::new(1.0, 0.0, 0.0, 1.0),
268            [0, 0, 0, 0],
269            joint_weights,
270        )
271    }
272
273    #[test]
274    fn layout_matches_the_verified_byte_offsets() {
275        assert_eq!(std::mem::size_of::<SkinnedVertex>(), 80);
276        let layout = SkinnedVertex::layout();
277        assert_eq!(layout.array_stride, 80);
278        assert_eq!(layout.attributes.len(), 6);
279        assert_eq!(layout.attributes[4].offset, 48);
280        assert_eq!(layout.attributes[4].shader_location, 8);
281        assert_eq!(layout.attributes[5].offset, 56);
282        assert_eq!(layout.attributes[5].shader_location, 9);
283    }
284
285    #[test]
286    fn build_does_not_panic_regardless_of_weight_sum() {
287        // Mismatched weights are a tracing::warn!, not a hard failure —
288        // matching every other validate() in this codebase.
289        let mesh = SkinnedMeshBuilder::new(vec![vertex([0.5, 0.0, 0.0, 0.0])], vec![0]).build();
290        assert_eq!(mesh.vertices.len(), 1);
291    }
292
293    #[test]
294    fn build_with_normalized_weights_does_not_panic() {
295        let mesh = SkinnedMeshBuilder::new(vec![vertex([1.0, 0.0, 0.0, 0.0])], vec![0]).build();
296        assert_eq!(mesh.indices.len(), 1);
297    }
298}