Skip to main content

proof_engine/anim/
particle_skin.rs

1//! Skinning a particle cloud to a skeleton.
2//!
3//! The engine can already build a skeleton, sample a clip into a [`Pose`], mask
4//! it, blend it and solve IK onto it. What it could not do was move anything
5//! made of particles with the result: [`SkinningMatrices`] is built for GPU
6//! vertex skinning, and a body made of a hundred thousand loose points with a
7//! spring each is not a vertex buffer.
8//!
9//! This is that bridge, and it is the piece that makes skeletal animation
10//! useful to a renderer whose primitive is the particle.
11//!
12//! # Why it binds itself
13//!
14//! A mesh arrives from a modelling tool with weights already painted on. A
15//! particle cloud does not: it is produced by code that stamps shapes into
16//! space and has no idea a skeleton exists. So the binding is *derived* — each
17//! particle is attached to the bone segments it is nearest to, with weights
18//! that fall off smoothly across a joint, so an elbow bends instead of
19//! shearing. Bind once, at build time; after that a pose costs two transforms
20//! and a lerp per particle.
21//!
22//! # Where the result goes
23//!
24//! Skinning produces the *rest target*, not the final position. Hand it to an
25//! [`crate::entity::AmorphousEntity`]'s formation and the cohesion springs
26//! chase it, which is what gives the motion its weight for free: heavy matter
27//! lags the bone it is bound to, light matter whips past and overshoots, and
28//! cloth trails a beat behind the body it hangs from. None of that has to be
29//! authored. It falls out of skinning to a spring system rather than to a
30//! vertex.
31
32use crate::anim::skeleton::{BoneId, Pose, Skeleton};
33use glam::{Mat4, Vec3};
34
35/// How many bones may influence one particle.
36///
37/// Two is the interesting number. One is rigid and shears visibly at every
38/// joint; two lets a particle sitting in an elbow belong half to the upper arm
39/// and half to the forearm, which is all it takes for the joint to bend as a
40/// joint. Beyond two the cost per particle rises and, at the scale a particle
41/// occupies on screen, nothing more is visible.
42pub const MAX_INFLUENCES: usize = 2;
43
44/// One particle's attachment to the skeleton.
45#[derive(Debug, Clone, Copy, PartialEq)]
46pub struct Bind {
47    /// Which bones move this particle, and how much each of them does.
48    ///
49    /// Weights are normalised to sum to 1.0. An unused slot has weight 0.
50    pub bones: [u32; MAX_INFLUENCES],
51    pub weights: [f32; MAX_INFLUENCES],
52}
53
54impl Bind {
55    /// A particle rigidly attached to one bone.
56    pub fn rigid(bone: BoneId) -> Bind {
57        Bind {
58            bones: [bone.index() as u32, 0],
59            weights: [1.0, 0.0],
60        }
61    }
62
63    /// The bone that moves this particle most.
64    pub fn dominant(&self) -> BoneId {
65        let i = if self.weights[1] > self.weights[0] { 1 } else { 0 };
66        BoneId(self.bones[i])
67    }
68}
69
70impl Default for Bind {
71    fn default() -> Self {
72        Bind {
73            bones: [0; MAX_INFLUENCES],
74            weights: [1.0, 0.0],
75        }
76    }
77}
78
79/// A particle cloud's attachment to a skeleton.
80///
81/// Built once from the cloud's rest positions and reused for every frame after
82/// that. Holds one [`Bind`] per particle and nothing else: the rest positions
83/// stay with the caller, because the caller already has them and copying a
84/// hundred thousand of them twice would be the most expensive thing here.
85#[derive(Debug, Clone, Default)]
86pub struct ParticleSkin {
87    binds: Vec<Bind>,
88}
89
90/// A bone as a line segment in bind-pose space, which is what binding needs.
91///
92/// A `Bone` knows where its own origin sits. What decides whether a particle
93/// belongs to it is the *segment* from that origin to its child's — an upper
94/// arm is the run from shoulder to elbow, not a point at the shoulder — so the
95/// segments are derived from the hierarchy before any distances are measured.
96#[derive(Debug, Clone, Copy)]
97struct Segment {
98    bone: u32,
99    a: Vec3,
100    b: Vec3,
101}
102
103impl Segment {
104    /// Distance from a point to this segment, and where along it that lands.
105    fn distance(&self, p: Vec3) -> f32 {
106        let ab = self.b - self.a;
107        let len2 = ab.length_squared();
108        if len2 < 1e-9 {
109            return (p - self.a).length();
110        }
111        let t = ((p - self.a).dot(ab) / len2).clamp(0.0, 1.0);
112        (p - (self.a + ab * t)).length()
113    }
114}
115
116impl ParticleSkin {
117    /// An empty skin.
118    pub fn new() -> ParticleSkin {
119        ParticleSkin { binds: Vec::new() }
120    }
121
122    /// How many particles this skin covers.
123    pub fn len(&self) -> usize {
124        self.binds.len()
125    }
126
127    pub fn is_empty(&self) -> bool {
128        self.binds.is_empty()
129    }
130
131    /// The binding for one particle.
132    pub fn bind_of(&self, i: usize) -> Option<Bind> {
133        self.binds.get(i).copied()
134    }
135
136    /// Build a skin from explicit bindings.
137    pub fn from_binds(binds: Vec<Bind>) -> ParticleSkin {
138        let mut skin = ParticleSkin { binds };
139        skin.normalise();
140        skin
141    }
142
143    /// Attach every point to the bones nearest it.
144    ///
145    /// `points` are in the skeleton's bind-pose space. `falloff` is how far
146    /// beyond the closest bone a second bone may be and still take a share of
147    /// the particle, in the same units as the points: it is the width of the
148    /// soft band around each joint, so a value near a limb's thickness gives a
149    /// joint that bends and a value of zero gives a rigid, shearing one.
150    ///
151    /// Bones with no children still bind — a hand, a head, the end of a tail —
152    /// using a short stub along the direction they came from, so the tip of a
153    /// limb is not left orphaned to whatever happens to be nearest.
154    pub fn bind_nearest(
155        points: &[Vec3],
156        skeleton: &Skeleton,
157        falloff: f32,
158    ) -> ParticleSkin {
159        let segments = Self::segments(skeleton);
160        if segments.is_empty() || points.is_empty() {
161            return ParticleSkin {
162                binds: vec![Bind::default(); points.len()],
163            };
164        }
165        let falloff = falloff.max(0.0);
166
167        // The second bone's share falls from a half at the joint to nothing
168        // once it is a falloff further away than the first. Squared, so the
169        // blend is soft in the middle and firm at the ends.
170        let binds = points
171            .iter()
172            .map(|p| Self::nearest_bind(*p, &segments, falloff))
173            .collect();
174
175        ParticleSkin { binds }
176    }
177
178    /// Attach every point to the nearest of the bones it is *allowed* to use.
179    ///
180    /// Proximity on its own is not enough for a cloud that was sculpted rather
181    /// than modelled. A figure holding a two-handed sword has the blade up
182    /// beside its head at rest, so the nearest bone to most of the weapon is
183    /// the skull: bind by distance alone and swinging the arm leaves the sword
184    /// behind while nodding drags it around. The caller knows which parts of
185    /// the sculpt are the weapon, the cloak, the legs — that knowledge is what
186    /// `groups` carries.
187    ///
188    /// `groups[i]` names which entry of `group_bones` point `i` may bind to. A
189    /// group that is empty, or an index past the end, falls back to the whole
190    /// skeleton, so a caller that forgets to classify something still gets a
191    /// particle that moves with the body rather than one left in mid-air.
192    pub fn bind_grouped(
193        points: &[Vec3],
194        skeleton: &Skeleton,
195        falloff: f32,
196        groups: &[u16],
197        group_bones: &[Vec<BoneId>],
198    ) -> ParticleSkin {
199        let all = Self::segments(skeleton);
200        if all.is_empty() || points.is_empty() {
201            return ParticleSkin {
202                binds: vec![Bind::default(); points.len()],
203            };
204        }
205        let falloff = falloff.max(0.0);
206        // The segments each group is allowed to use, worked out once rather
207        // than per particle.
208        let per_group: Vec<Vec<Segment>> = group_bones
209            .iter()
210            .map(|bones| {
211                if bones.is_empty() {
212                    all.clone()
213                } else {
214                    all.iter()
215                        .filter(|s| bones.iter().any(|b| b.index() as u32 == s.bone))
216                        .copied()
217                        .collect()
218                }
219            })
220            .collect();
221
222        let binds = points
223            .iter()
224            .enumerate()
225            .map(|(i, p)| {
226                let g = groups.get(i).copied().unwrap_or(u16::MAX) as usize;
227                let segs = per_group.get(g).filter(|s| !s.is_empty()).unwrap_or(&all);
228                Self::nearest_bind(*p, segs, falloff)
229            })
230            .collect();
231
232        ParticleSkin { binds }
233    }
234
235    /// The best two bones for one point, out of a set of segments.
236    fn nearest_bind(p: Vec3, segments: &[Segment], falloff: f32) -> Bind {
237        let (mut best, mut second) = ((u32::MAX, f32::MAX), (u32::MAX, f32::MAX));
238        for seg in segments {
239            let d = seg.distance(p);
240            if d < best.1 {
241                second = best;
242                best = (seg.bone, d);
243            } else if d < second.1 && seg.bone != best.0 {
244                second = (seg.bone, d);
245            }
246        }
247        if best.0 == u32::MAX {
248            return Bind::default();
249        }
250        let share = if second.0 == u32::MAX || falloff <= 0.0 {
251            0.0
252        } else {
253            let excess = (second.1 - best.1) / falloff;
254            let k = (1.0 - excess).clamp(0.0, 1.0);
255            0.5 * k * k
256        };
257        Bind {
258            bones: [best.0, if second.0 == u32::MAX { best.0 } else { second.0 }],
259            weights: [1.0 - share, share],
260        }
261    }
262
263    /// Move a cloud into a pose.
264    ///
265    /// `rest` are the same points the skin was bound from and `out` receives
266    /// the posed positions; `out` is resized to match. Both may be the same
267    /// length as the skin or shorter — a cloud that has grown since it was
268    /// bound leaves its extra particles where they are rather than panicking,
269    /// because a figure gaining matter mid-animation is a real thing that
270    /// happens here and is not worth crashing over.
271    ///
272    /// Linear blend skinning: the classic artefact is that a joint under heavy
273    /// twist loses volume. At the scale one particle occupies that is not
274    /// visible, and the springs downstream hide what is left of it.
275    pub fn apply(&self, skeleton: &Skeleton, pose: &Pose, rest: &[Vec3], out: &mut Vec<Vec3>) {
276        let matrices = Self::skinning_matrices(skeleton, pose);
277        self.apply_with(&matrices, rest, out);
278    }
279
280    /// The same, against matrices the caller already has.
281    ///
282    /// Building the matrices walks the whole hierarchy and inverts a matrix per
283    /// bone. That is nothing next to skinning a hundred thousand particles, but
284    /// it is pure waste when positions and normals are both being skinned
285    /// against the same pose — which is every frame, for every figure.
286    pub fn apply_with(&self, matrices: &[Mat4], rest: &[Vec3], out: &mut Vec<Vec3>) {
287        out.resize(rest.len(), Vec3::ZERO);
288        for (i, p) in rest.iter().enumerate() {
289            let Some(bind) = self.binds.get(i) else {
290                out[i] = *p;
291                continue;
292            };
293            out[i] = Self::skin_one(*p, bind, matrices);
294        }
295    }
296
297    /// Where one point goes, given the skinning matrices for a pose.
298    ///
299    /// Exposed because a caller often needs a single position — where a hand
300    /// is, so an effect can be thrown from it — without posing the whole cloud.
301    pub fn skin_one(p: Vec3, bind: &Bind, matrices: &[Mat4]) -> Vec3 {
302        let mut out = Vec3::ZERO;
303        let mut total = 0.0;
304        for k in 0..MAX_INFLUENCES {
305            let w = bind.weights[k];
306            if w <= 0.0 {
307                continue;
308            }
309            let Some(m) = matrices.get(bind.bones[k] as usize) else {
310                continue;
311            };
312            out += m.transform_point3(p) * w;
313            total += w;
314        }
315        if total > 1e-6 {
316            out / total
317        } else {
318            p
319        }
320    }
321
322    /// Turn a cloud's surface normals to match a pose.
323    ///
324    /// Lighting here is per particle against a real normal, so a normal left
325    /// pointing where the bone used to be is an arm that bends while its
326    /// shading stays behind — which reads as the limb going flat exactly when
327    /// it moves. Uses the dominant bone rather than blending: a normal is a
328    /// direction, blending two of them needs a renormalise, and across a joint
329    /// the difference is a fraction of a degree on a particle a few pixels
330    /// wide.
331    pub fn apply_normals(
332        &self,
333        skeleton: &Skeleton,
334        pose: &Pose,
335        rest: &[Vec3],
336        out: &mut Vec<Vec3>,
337    ) {
338        let matrices = Self::skinning_matrices(skeleton, pose);
339        self.apply_normals_with(&matrices, rest, out);
340    }
341
342    /// The same, against matrices the caller already has.
343    pub fn apply_normals_with(&self, matrices: &[Mat4], rest: &[Vec3], out: &mut Vec<Vec3>) {
344        out.resize(rest.len(), Vec3::Z);
345        for (i, n) in rest.iter().enumerate() {
346            let Some(bind) = self.binds.get(i) else {
347                out[i] = *n;
348                continue;
349            };
350            let Some(m) = matrices.get(bind.dominant().index()) else {
351                out[i] = *n;
352                continue;
353            };
354            // A direction, so translation is dropped and only the rotation and
355            // scale of the matrix apply.
356            let turned = m.transform_vector3(*n);
357            out[i] = if turned.length_squared() > 1e-12 {
358                turned.normalize()
359            } else {
360                *n
361            };
362        }
363    }
364
365    /// The matrices that take a point from bind space to posed space.
366    ///
367    /// One per bone, in bone-index order. Split out so a caller posing several
368    /// clouds against the same skeleton computes them once.
369    pub fn skinning_matrices(skeleton: &Skeleton, pose: &Pose) -> Vec<Mat4> {
370        let n = skeleton.len();
371        let mut world = vec![Mat4::IDENTITY; n];
372        for id in skeleton.topological_order() {
373            let i = id.index();
374            let Some(bone) = skeleton.bone(id) else { continue };
375            let local = pose
376                .get(id)
377                .unwrap_or(bone.local_bind_pose)
378                .to_mat4();
379            world[i] = match bone.parent {
380                Some(p) if p.index() < n => world[p.index()] * local,
381                _ => local,
382            };
383        }
384        // Bind space → bone space → posed space.
385        let bind = skeleton.compute_bind_world_matrices();
386        (0..n)
387            .map(|i| world[i] * bind.get(i).copied().unwrap_or(Mat4::IDENTITY).inverse())
388            .collect()
389    }
390
391    /// Every bone as a segment in bind-pose space.
392    fn segments(skeleton: &Skeleton) -> Vec<Segment> {
393        let world = skeleton.compute_bind_world_matrices();
394        let origin = |i: usize| -> Vec3 {
395            world
396                .get(i)
397                .map(|m| m.transform_point3(Vec3::ZERO))
398                .unwrap_or(Vec3::ZERO)
399        };
400        let mut out = Vec::with_capacity(skeleton.len());
401        for i in 0..skeleton.len() {
402            let id = BoneId(i as u32);
403            let a = origin(i);
404            let kids = skeleton.children_of(id);
405            if kids.is_empty() {
406                // A tip: run a stub on past it, in the direction the bone
407                // arrived from, so the end of a limb still has length to bind
408                // against rather than collapsing to a point.
409                let back = skeleton
410                    .bone(id)
411                    .and_then(|b| b.parent)
412                    .map(|p| a - origin(p.index()))
413                    .unwrap_or(Vec3::Y);
414                let dir = if back.length_squared() > 1e-9 {
415                    back.normalize()
416                } else {
417                    Vec3::Y
418                };
419                let stub = back.length().max(1e-3) * 0.45;
420                out.push(Segment {
421                    bone: i as u32,
422                    a,
423                    b: a + dir * stub,
424                });
425            } else {
426                for kid in kids {
427                    out.push(Segment {
428                        bone: i as u32,
429                        a,
430                        b: origin(kid.index()),
431                    });
432                }
433            }
434        }
435        out
436    }
437
438    /// Make every particle's weights sum to one.
439    fn normalise(&mut self) {
440        for b in &mut self.binds {
441            let total: f32 = b.weights.iter().sum();
442            if total > 1e-6 {
443                for w in &mut b.weights {
444                    *w /= total;
445                }
446            } else {
447                b.weights = [1.0, 0.0];
448            }
449        }
450    }
451}