Skip to main content

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}