viewport_lib/plugins/skinning/mod.rs
1//! GPU skinning as a deformer-registry plugin.
2//!
3//! `SkinningPlugin::install` registers a linear-blend-skinning deformer body
4//! against the mesh shader family and returns a handle. The handle holds the
5//! assigned `DeformerId` and exposes `attach_weights` and `attach_palette`
6//! for per-mesh and per-(mesh, instance) data uploads, plus
7//! `is_skinned_mesh` so hosts can answer the "does this mesh have skinning
8//! data attached" question without consulting the registry.
9//!
10//! Hosts that do not need GPU skinning never call `install` and pay nothing.
11//! Static meshes pay nothing either way: the per-object `deform_flags`
12//! branch in the composed shader gates the LBS body off.
13//!
14//! `SkinningPlugin` is not a [`RuntimePlugin`](crate::RuntimePlugin); it is a
15//! renderer-side upload handle. Pair it with
16//! [`SkeletonPlugin`](crate::plugins::skeleton::SkeletonPlugin) or
17//! [`SkinnedActorPlugin`](crate::plugins::skeleton::SkinnedActorPlugin) for the
18//! runtime half: those compute the per-frame joint matrices and emit
19//! [`SkinnedPoseUpdate`] events on `output.events`; the host drains the events
20//! and calls [`SkinningPlugin::attach_palette`] on each one.
21
22use std::collections::HashMap;
23use std::sync::{Arc, Mutex};
24
25use crate::resources::ViewportGpuResources;
26use crate::resources::mesh_sidecar::registry::{DeformStage, DeformerDesc, DeformerId};
27use crate::resources::mesh_store::MeshId;
28
29/// A per-mesh deformation update produced by a skinning plugin on the CPU
30/// path. Apply by calling `write_mesh_positions_normals`:
31///
32/// ```rust,ignore
33/// for u in output.events.drain::<SkinnedMeshUpdate>() {
34/// renderer.resources_mut()
35/// .write_mesh_positions_normals(queue, u.mesh_id, &u.positions, &u.normals)
36/// .ok();
37/// }
38/// ```
39pub struct SkinnedMeshUpdate {
40 /// The mesh to deform.
41 pub mesh_id: MeshId,
42 /// Skinned vertex positions in local space.
43 pub positions: Vec<[f32; 3]>,
44 /// Skinned vertex normals.
45 pub normals: Vec<[f32; 3]>,
46}
47
48/// A per-instance joint palette update produced by a skinning plugin on the
49/// GPU path. Apply by calling [`SkinningPlugin::attach_palette`]:
50///
51/// ```rust,ignore
52/// for u in output.events.drain::<SkinnedPoseUpdate>() {
53/// skinning.attach_palette(
54/// renderer.resources_mut(), &device, &queue,
55/// u.mesh_id, u.instance_id, &u.joint_matrices,
56/// );
57/// }
58/// ```
59pub struct SkinnedPoseUpdate {
60 /// The skinned mesh to drive.
61 pub mesh_id: MeshId,
62 /// Which instance of the mesh this palette is for. Use `0` for single-
63 /// instance meshes.
64 pub instance_id: u32,
65 /// Per-joint skinning matrices in topological order, ready for upload to
66 /// the GPU joint palette storage buffer.
67 pub joint_matrices: Vec<glam::Mat4>,
68}
69
70/// Per-vertex joint influence data for linear blend skinning.
71///
72/// # Invariants
73///
74/// - `joint_indices.len() == joint_weights.len() == positions.len()` on the
75/// accompanying `MeshData`.
76/// - Each vertex carries up to four influences. Unused slots must have weight
77/// `0.0` and a valid (any in-range) index; the CPU path skips entries below
78/// `1e-6`.
79/// - Weights per vertex should sum to `1.0`. The CPU path does not renormalise,
80/// so a vertex whose weights sum to less than 1 will deform with reduced
81/// magnitude. Importers should normalise before constructing this.
82/// - There is no required ordering between the four slots.
83///
84/// Consumed by both paths: `plugins::skeleton::apply_skin` reads it on the
85/// CPU path; [`SkinningPlugin::attach_weights`] packs it into the
86/// deformer's per-mesh slot on the GPU path.
87#[derive(Clone)]
88pub struct SkinWeights {
89 /// Joint indices for each vertex: 4 per vertex, parallel to positions.
90 pub joint_indices: Vec<[u8; 4]>,
91 /// Blend weights for each vertex: 4 per vertex, normalised to sum 1.0.
92 pub joint_weights: Vec<[f32; 4]>,
93}
94
95// ---------------------------------------------------------------------------
96// Packed vertex format and stride constants
97// ---------------------------------------------------------------------------
98
99/// Packed per-vertex skin data: four `f32` weights followed by two packed
100/// joint-index `u32`s. Total 24 bytes, matching the per-vertex stride
101/// expected by the deformer's skinning body.
102#[repr(C)]
103#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
104struct PackedSkinVertex {
105 weights: [f32; 4],
106 /// `joints[0]` in the low 16 bits, `joints[1]` in the high 16 bits.
107 joints_01: u32,
108 /// `joints[2]` in the low 16 bits, `joints[3]` in the high 16 bits.
109 joints_23: u32,
110}
111
112/// Per-vertex stride of the skin-weights slot.
113const SKIN_WEIGHT_STRIDE_BYTES: u32 = 24;
114
115/// Per-instance stride of the joint-palette slot: one `mat4x4<f32>` per joint.
116const SKIN_PALETTE_STRIDE_BYTES: u32 = 64;
117
118// ---------------------------------------------------------------------------
119// Deformer body
120// ---------------------------------------------------------------------------
121
122/// Priority assigned to the skinning deformer. Negative so morph-target and
123/// other object-space deformers, which register at the default priority of
124/// 0, run after it.
125pub const DEFORM_PRIORITY_SKINNING: i32 = -1000;
126
127const SKINNING_DEFORMER_NAME: &str = "viewport_skin";
128
129const SKINNING_DEFORMER_BODY: &str = r#"
130fn deform(v: DeformVertex, ctx: DeformContext) -> DeformVertex {
131 var out = v;
132 let slot = ctx.slot;
133 if deform_slot_stride(slot) == 0u {
134 return out;
135 }
136 let vi = v.vertex_index;
137 let w0 = deform_read_f32(slot, vi, 0u);
138 let w1 = deform_read_f32(slot, vi, 1u);
139 let w2 = deform_read_f32(slot, vi, 2u);
140 let w3 = deform_read_f32(slot, vi, 3u);
141 let j01 = deform_read_u32(slot, vi, 4u);
142 let j23 = deform_read_u32(slot, vi, 5u);
143 let j0 = j01 & 0xFFFFu;
144 let j1 = (j01 >> 16u) & 0xFFFFu;
145 let j2 = j23 & 0xFFFFu;
146 let j3 = (j23 >> 16u) & 0xFFFFu;
147 let m = deform_read_instance_mat4(slot, j0) * w0
148 + deform_read_instance_mat4(slot, j1) * w1
149 + deform_read_instance_mat4(slot, j2) * w2
150 + deform_read_instance_mat4(slot, j3) * w3;
151 out.position = (m * vec4<f32>(v.position, 1.0)).xyz;
152 let m3 = mat3x3<f32>(m[0].xyz, m[1].xyz, m[2].xyz);
153 out.normal = m3 * v.normal;
154 return out;
155}
156"#;
157
158// ---------------------------------------------------------------------------
159// Plugin handle
160// ---------------------------------------------------------------------------
161
162/// Handle to the GPU skinning deformer.
163///
164/// Returned by [`SkinningPlugin::install`]. Holds the assigned
165/// [`DeformerId`] plus a marker set tracking which meshes have had weights
166/// attached, so [`Self::is_skinned_mesh`] can answer without going through
167/// the registry. Clone-cheap: the marker set is shared.
168#[derive(Clone)]
169pub struct SkinningPlugin {
170 deformer_id: DeformerId,
171 skinned_meshes: Arc<Mutex<HashMap<MeshId, ()>>>,
172}
173
174impl SkinningPlugin {
175 /// Register the skinning deformer with the renderer.
176 ///
177 /// Call once at startup before uploading any skin data. Composes the LBS
178 /// body into the mesh shader family and validates the result through
179 /// wgpu's error scope.
180 ///
181 /// # Errors
182 ///
183 /// Propagates the registry error if shader composition or validation
184 /// fails (extremely unlikely for the shipped body, which is covered by
185 /// unit tests).
186 pub fn install(
187 resources: &mut ViewportGpuResources,
188 device: &wgpu::Device,
189 ) -> crate::error::ViewportResult<Self> {
190 let deformer_id = match resources.deformer_id_by_name(SKINNING_DEFORMER_NAME) {
191 Some(id) => id,
192 None => {
193 let desc = DeformerDesc {
194 name: SKINNING_DEFORMER_NAME,
195 stage: DeformStage::ObjectSpace,
196 priority: DEFORM_PRIORITY_SKINNING,
197 wgsl_body: SKINNING_DEFORMER_BODY.to_string(),
198 per_vertex_stride: SKIN_WEIGHT_STRIDE_BYTES,
199 };
200 resources.register_internal_deformer(device, desc)?
201 }
202 };
203 Ok(Self {
204 deformer_id,
205 skinned_meshes: Arc::new(Mutex::new(HashMap::new())),
206 })
207 }
208
209 /// The [`DeformerId`] assigned to skinning on install.
210 pub fn deformer_id(&self) -> DeformerId {
211 self.deformer_id
212 }
213
214 /// Attach per-vertex skin weights to an uploaded mesh.
215 ///
216 /// Marks the mesh as skinnable and packs the weights into the deformer's
217 /// per-mesh slot. Calling again on the same mesh replaces the prior
218 /// weights buffer.
219 pub fn attach_weights(
220 &self,
221 resources: &mut ViewportGpuResources,
222 device: &wgpu::Device,
223 mesh_id: MeshId,
224 weights: &SkinWeights,
225 ) {
226 let packed = pack(weights);
227 let tight = pack_skin_weights_tight(&packed);
228 resources.deform.attach_slot(
229 device,
230 mesh_id,
231 self.deformer_id.slot(),
232 SKIN_WEIGHT_STRIDE_BYTES / 4,
233 &tight,
234 );
235 self.skinned_meshes
236 .lock()
237 .expect("skinning marker poisoned")
238 .insert(mesh_id, ());
239 }
240
241 /// Start an asynchronous skin-weights upload.
242 ///
243 /// Returns a `JobId` immediately. The packed weight stream is computed on
244 /// a worker thread; buffer creation runs on the main thread during the
245 /// next `process_uploads` call after the worker finishes.
246 pub fn begin_upload_weights(
247 &self,
248 resources: &mut ViewportGpuResources,
249 device: &wgpu::Device,
250 mesh_id: MeshId,
251 weights: SkinWeights,
252 ) -> crate::resources::JobId {
253 let device_for_apply = device.clone();
254 let slot = self.deformer_id.slot();
255 let marker = self.skinned_meshes.clone();
256 let mut runner = resources.jobs.lock().expect("upload job runner poisoned");
257 runner.submit_cpu(move |progress| {
258 progress.set(0.2);
259 let packed = pack(&weights);
260 let tight = pack_skin_weights_tight(&packed);
261 progress.set(0.9);
262 Ok(crate::resources::upload_jobs::JobProduct::with_apply(
263 Box::new(move |resources: &mut ViewportGpuResources| {
264 resources.deform.attach_slot(
265 &device_for_apply,
266 mesh_id,
267 slot,
268 SKIN_WEIGHT_STRIDE_BYTES / 4,
269 &tight,
270 );
271 marker
272 .lock()
273 .expect("skinning marker poisoned")
274 .insert(mesh_id, ());
275 }),
276 ))
277 })
278 }
279
280 /// Upload the joint palette for one instance of a skinned mesh.
281 ///
282 /// `instance_id` lets multiple skinned instances of one bind-pose mesh
283 /// coexist. For single-instance meshes pass `0`. Returns `false` if
284 /// [`Self::attach_weights`] has not been called for `mesh_id`.
285 ///
286 /// `palette[i]` is the object-space skinning matrix for joint `i`
287 /// produced by [`crate::JointMatrices::compute`]: the joint's
288 /// skeleton-local transform multiplied by its inverse bind. The mesh's
289 /// `object.model` is applied separately at draw time, so the palette
290 /// composes with the scene node transform rather than replacing it.
291 pub fn attach_palette(
292 &self,
293 resources: &mut ViewportGpuResources,
294 device: &wgpu::Device,
295 queue: &wgpu::Queue,
296 mesh_id: MeshId,
297 instance_id: u32,
298 palette: &[glam::Mat4],
299 ) -> bool {
300 if !self
301 .skinned_meshes
302 .lock()
303 .expect("skinning marker poisoned")
304 .contains_key(&mesh_id)
305 {
306 return false;
307 }
308 let bytes: Vec<[[f32; 4]; 4]> = palette.iter().map(|m| m.to_cols_array_2d()).collect();
309 resources.deform.attach_slot_instance(
310 device,
311 queue,
312 mesh_id,
313 instance_id,
314 self.deformer_id.slot(),
315 SKIN_PALETTE_STRIDE_BYTES / 4,
316 bytemuck::cast_slice(&bytes),
317 );
318 true
319 }
320
321 /// Whether `mesh_id` has been marked as skinnable via
322 /// [`Self::attach_weights`].
323 pub fn is_skinned_mesh(&self, mesh_id: MeshId) -> bool {
324 self.skinned_meshes
325 .lock()
326 .expect("skinning marker poisoned")
327 .contains_key(&mesh_id)
328 }
329}
330
331// ---------------------------------------------------------------------------
332// Packing helpers
333// ---------------------------------------------------------------------------
334
335fn pack(weights: &SkinWeights) -> Vec<PackedSkinVertex> {
336 weights
337 .joint_indices
338 .iter()
339 .zip(weights.joint_weights.iter())
340 .map(|(j, w)| {
341 let j0 = j[0] as u32;
342 let j1 = j[1] as u32;
343 let j2 = j[2] as u32;
344 let j3 = j[3] as u32;
345 PackedSkinVertex {
346 weights: *w,
347 joints_01: j0 | (j1 << 16),
348 joints_23: j2 | (j3 << 16),
349 }
350 })
351 .collect()
352}
353
354fn pack_skin_weights_tight(packed: &[PackedSkinVertex]) -> Vec<u8> {
355 let mut out = Vec::with_capacity(packed.len() * SKIN_WEIGHT_STRIDE_BYTES as usize);
356 for v in packed {
357 out.extend_from_slice(bytemuck::bytes_of(&v.weights));
358 out.extend_from_slice(bytemuck::bytes_of(&v.joints_01));
359 out.extend_from_slice(bytemuck::bytes_of(&v.joints_23));
360 }
361 out
362}
363
364#[cfg(test)]
365mod async_skin_tests {
366 use super::SkinWeights;
367 use super::SkinningPlugin;
368 use crate::ViewportGpuResources;
369 use crate::geometry::primitives;
370 use crate::resources::UploadStatus;
371
372 fn try_make_device() -> Option<(wgpu::Device, wgpu::Queue)> {
373 let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
374 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
375 power_preference: wgpu::PowerPreference::LowPower,
376 compatible_surface: None,
377 force_fallback_adapter: false,
378 }))
379 .ok()?;
380 pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
381 }
382
383 fn unit_weights(vertex_count: usize) -> SkinWeights {
384 SkinWeights {
385 joint_indices: vec![[0u8; 4]; vertex_count],
386 joint_weights: vec![[1.0, 0.0, 0.0, 0.0]; vertex_count],
387 }
388 }
389
390 #[test]
391 fn attach_weights_marks_mesh_skinnable() {
392 let Some((device, _queue)) = try_make_device() else {
393 eprintln!("skipping: no wgpu adapter available");
394 return;
395 };
396 let mut resources =
397 ViewportGpuResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
398 let skinning = SkinningPlugin::install(&mut resources, &device).unwrap();
399 let plane = primitives::grid_plane(1.0, 1.0, 4, 4);
400 let mesh_id = resources.upload_mesh_data(&device, &plane).unwrap();
401 let weights = unit_weights(plane.positions.len());
402
403 skinning.attach_weights(&mut resources, &device, mesh_id, &weights);
404 assert!(skinning.is_skinned_mesh(mesh_id));
405 }
406
407 #[test]
408 fn begin_upload_weights_completes() {
409 let Some((device, queue)) = try_make_device() else {
410 eprintln!("skipping: no wgpu adapter available");
411 return;
412 };
413 let mut resources =
414 ViewportGpuResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
415 let skinning = SkinningPlugin::install(&mut resources, &device).unwrap();
416 let plane = primitives::grid_plane(1.0, 1.0, 4, 4);
417 let mesh_id = resources.upload_mesh_data(&device, &plane).unwrap();
418 let weights = unit_weights(plane.positions.len());
419
420 assert!(!skinning.is_skinned_mesh(mesh_id));
421 let id = skinning.begin_upload_weights(&mut resources, &device, mesh_id, weights);
422
423 for _ in 0..200 {
424 resources.process_uploads(&device, &queue);
425 match resources.upload_status(id) {
426 UploadStatus::Ready => break,
427 UploadStatus::Failed(e) => panic!("upload failed: {e:?}"),
428 UploadStatus::Pending { .. } => {
429 std::thread::sleep(std::time::Duration::from_millis(5));
430 }
431 UploadStatus::Unknown => panic!("job disappeared"),
432 }
433 }
434
435 assert!(
436 skinning.is_skinned_mesh(mesh_id),
437 "skin weights did not install"
438 );
439 }
440}