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