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}