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