Skip to main content

pebble/wgpu/
skinned_mesh.rs

1use crate::{
2    assets::{handle::Handle, storage::Assets, upload::Asset},
3    wgpu::{
4        backend::WGPUBackend,
5        buffer::Buffer,
6        buffers::BufferBuilder,
7        flags::BufferUsages,
8        vertex_format::{VertexAttribute, VertexBufferLayout, VertexFormat, VertexStepMode},
9    },
10};
11
12/// Same 4 fields as [`Vertex`](super::mesh::Vertex), plus skinning data.
13///
14/// `_pad` exists purely so this type has no *implicit* padding: `glam::Vec4`
15/// is 16-byte aligned (SIMD-backed), which forces this whole struct's
16/// alignment to 16 — without an explicit trailing field to account for the
17/// last 8 bytes, the compiler would insert *invisible* padding there
18/// instead, and `#[derive(bytemuck::Pod)]` correctly refuses to compile a
19/// type with any padding it can't account for byte-for-byte.
20#[repr(C)]
21#[derive(Copy, Clone, Default, bytemuck::Pod, bytemuck::Zeroable)]
22pub struct SkinnedVertex {
23    pub position: glam::Vec3,
24    pub tex_coords: glam::Vec2,
25    pub normal: glam::Vec3,
26    pub tangent: glam::Vec4,
27    /// Up to 4 joint indices this vertex is bound to, paired positionally
28    /// with `joint_weights`. `u16`, not `u32` or `u8`: glTF's `JOINTS_n`
29    /// accessor is spec-limited to `UNSIGNED_BYTE`/`UNSIGNED_SHORT` (never
30    /// `UNSIGNED_INT`), so `u16` already covers every legal glTF skeleton
31    /// (up to 65535 joints) losslessly, at half the bytes of `u32`.
32    /// [`gltf_loader::load_gltf`](super::gltf_loader::load_gltf) upconverts
33    /// `u8`-sourced `JOINTS_0` data.
34    pub joint_indices: [u16; 4],
35    pub joint_weights: [f32; 4],
36    _pad: [u32; 2],
37}
38
39impl SkinnedVertex {
40    pub fn new(
41        position: glam::Vec3,
42        tex_coords: glam::Vec2,
43        normal: glam::Vec3,
44        tangent: glam::Vec4,
45        joint_indices: [u16; 4],
46        joint_weights: [f32; 4],
47    ) -> Self {
48        Self {
49            position,
50            tex_coords,
51            normal,
52            tangent,
53            joint_indices,
54            joint_weights,
55            _pad: [0; 2],
56        }
57    }
58
59    /// Occupies locations 0–3 (same meaning as
60    /// [`Vertex`](super::mesh::Vertex)'s own 0–3) and 8–9 — deliberately
61    /// skipping 4–7 (reserved for [`InstanceVertex`](super::mesh::InstanceVertex))
62    /// so a skinned mesh can still be instanced in one pipeline without
63    /// either layout changing. WGSL side: all integer vertex formats widen
64    /// to `vec4<u32>` regardless of source width, so declare
65    /// `@location(8) joint_indices: vec4<u32>` and
66    /// `@location(9) joint_weights: vec4<f32>`.
67    pub fn layout() -> VertexBufferLayout {
68        VertexBufferLayout {
69            array_stride: std::mem::size_of::<SkinnedVertex>() as u64,
70            step_mode: VertexStepMode::Vertex,
71            attributes: vec![
72                VertexAttribute { format: VertexFormat::Float32x3, offset: 0, shader_location: 0 }, // position
73                VertexAttribute { format: VertexFormat::Float32x2, offset: 12, shader_location: 1 }, // tex_coords
74                VertexAttribute { format: VertexFormat::Float32x3, offset: 20, shader_location: 2 }, // normal
75                VertexAttribute { format: VertexFormat::Float32x4, offset: 32, shader_location: 3 }, // tangent
76                VertexAttribute { format: VertexFormat::Uint16x4, offset: 48, shader_location: 8 }, // joint_indices
77                VertexAttribute { format: VertexFormat::Float32x4, offset: 56, shader_location: 9 }, // joint_weights
78            ],
79        }
80    }
81}
82
83/// Source data for [`GPUSkinnedMesh`]: a plain vertex/index list, uploaded
84/// as-is. Fields are private — the only way to construct one is
85/// [`SkinnedMeshBuilder`]: `SkinnedMeshBuilder::new(vertices, indices).build()`.
86/// Usually obtained via [`gltf_loader::load_gltf`](super::gltf_loader::load_gltf)
87/// rather than hand-authored.
88pub struct SkinnedMesh {
89    vertices: Vec<SkinnedVertex>,
90    indices: Vec<u32>,
91}
92
93/// Builds a [`SkinnedMesh`] from a vertex/index list. Start from
94/// [`new`](Self::new), then finish with
95/// [`build`](Self::build)/[`build_asset`](Self::build_asset).
96pub struct SkinnedMeshBuilder {
97    vertices: Vec<SkinnedVertex>,
98    indices: Vec<u32>,
99}
100
101impl SkinnedMeshBuilder {
102    pub fn new(vertices: Vec<SkinnedVertex>, indices: Vec<u32>) -> Self {
103        Self { vertices, indices }
104    }
105
106    /// Same empty-vertices/empty-indices checks as
107    /// [`MeshBuilder`](super::mesh::MeshBuilder), plus: warns if any
108    /// vertex's `joint_weights` don't sum to ~1.0 (±0.01) — the usual
109    /// symptom of un-normalized weights, most likely from a hand-authored
110    /// `SkinnedMesh` that skipped `load_gltf` (which always normalizes).
111    fn validate(&self) {
112        if self.vertices.is_empty() {
113            tracing::warn!("SkinnedMeshBuilder::new(): no vertices — did you forget to pass them?");
114        }
115        if self.indices.is_empty() {
116            tracing::warn!("SkinnedMeshBuilder::new(): no indices — did you forget to pass them?");
117        }
118        for (i, vertex) in self.vertices.iter().enumerate() {
119            let sum: f32 = vertex.joint_weights.iter().sum();
120            if (sum - 1.0).abs() > 0.01 {
121                tracing::warn!(
122                    "SkinnedMeshBuilder: vertex {i}'s joint_weights sum to {sum}, not ~1.0 — did \
123                     you forget to normalize them?"
124                );
125            }
126        }
127    }
128
129    /// Consume the builder and return the finished [`SkinnedMesh`] value.
130    pub fn build(self) -> SkinnedMesh {
131        self.validate();
132        SkinnedMesh { vertices: self.vertices, indices: self.indices }
133    }
134
135    /// Consume the builder, insert into `assets` under `name`, and return
136    /// the resulting [`Handle<SkinnedMesh>`].
137    pub fn build_asset(self, name: &str, assets: &mut Assets<SkinnedMesh>) -> Handle<SkinnedMesh> {
138        let mesh = self.build();
139        assets.insert(name, mesh)
140    }
141}
142
143/// A skinned mesh uploaded to the GPU. `index_buffer`/`index_count` must
144/// stay in sync if you ever mutate one after construction — same caveat as
145/// [`GPUMesh`](super::mesh::GPUMesh).
146pub struct GPUSkinnedMesh {
147    pub vertex_buffer: Buffer,
148    pub index_buffer: Buffer,
149    pub index_count: u32,
150}
151
152impl Asset<WGPUBackend> for GPUSkinnedMesh {
153    type Source = SkinnedMesh;
154    type Deps<'a> = ();
155
156    fn upload<'a>(source: &SkinnedMesh, backend: &WGPUBackend, _deps: &()) -> Option<Self> {
157        let vertex_buffer = BufferBuilder::with_data(bytemuck::cast_slice(source.vertices.as_slice()))
158            .label("SkinnedMesh Vertex Buffer")
159            .usage(BufferUsages::VERTEX)
160            .build(backend);
161        let index_buffer = BufferBuilder::with_data(bytemuck::cast_slice(&source.indices))
162            .label("SkinnedMesh Index Buffer")
163            .usage(BufferUsages::INDEX)
164            .build(backend);
165        Some(Self {
166            vertex_buffer,
167            index_buffer,
168            index_count: source.indices.len() as u32,
169        })
170    }
171}
172
173crate::wgpu::plugin_macros::asset_plugin! {
174    /// Registers the [`GPUSkinnedMesh`] asset pipeline (`Assets<SkinnedMesh>`
175    /// → `ProcessedAssets<GPUSkinnedMesh>`). Included by
176    /// [`WGPUPlugin`](super::backend::WGPUPlugin); add directly only if
177    /// you're assembling the `wgpu` module's plugins by hand.
178    SkinnedMeshPlugin, GPUSkinnedMesh
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    fn vertex(joint_weights: [f32; 4]) -> SkinnedVertex {
186        SkinnedVertex::new(
187            glam::Vec3::ZERO,
188            glam::Vec2::ZERO,
189            glam::Vec3::Z,
190            glam::Vec4::new(1.0, 0.0, 0.0, 1.0),
191            [0, 0, 0, 0],
192            joint_weights,
193        )
194    }
195
196    #[test]
197    fn layout_matches_the_verified_byte_offsets() {
198        assert_eq!(std::mem::size_of::<SkinnedVertex>(), 80);
199        let layout = SkinnedVertex::layout();
200        assert_eq!(layout.array_stride, 80);
201        assert_eq!(layout.attributes.len(), 6);
202        assert_eq!(layout.attributes[4].offset, 48);
203        assert_eq!(layout.attributes[4].shader_location, 8);
204        assert_eq!(layout.attributes[5].offset, 56);
205        assert_eq!(layout.attributes[5].shader_location, 9);
206    }
207
208    #[test]
209    fn build_does_not_panic_regardless_of_weight_sum() {
210        // Mismatched weights are a tracing::warn!, not a hard failure —
211        // matching every other validate() in this codebase.
212        let mesh = SkinnedMeshBuilder::new(vec![vertex([0.5, 0.0, 0.0, 0.0])], vec![0]).build();
213        assert_eq!(mesh.vertices.len(), 1);
214    }
215
216    #[test]
217    fn build_with_normalized_weights_does_not_panic() {
218        let mesh = SkinnedMeshBuilder::new(vec![vertex([1.0, 0.0, 0.0, 0.0])], vec![0]).build();
219        assert_eq!(mesh.indices.len(), 1);
220    }
221}