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//! 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::SkinningPlugin;
367    use crate::ViewportGpuResources;
368    use crate::geometry::primitives;
369    use super::SkinWeights;
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}