tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
1//! Jerarquía de huesos 2D + skinning ponderado de vértices (linear blend
2//! skinning, LBS) — el sustrato de la deformación esqueletal estilo Rive.
3//!
4//! **Puro y sin renderer.** Este módulo sólo calcula geometría: dada una
5//! jerarquía de huesos posada y una malla con pesos por vértice, produce las
6//! **posiciones deformadas** de los vértices (`kurbo::Point`). Quién las pinta
7//! (vello vía paths para malla vectorial, o clip+imagen para malla texturizada)
8//! es trabajo del consumidor; el spike confirmó que ambas rutas funcionan en
9//! vello 0.7.
10//!
11//! ## Bind pose vs pose actual
12//!
13//! El esqueleto se construye en su **bind pose** (la configuración en la que se
14//! ató la malla) y se llama [`Skeleton::bind`] para congelarla: por cada hueso
15//! se guarda su *inverse-bind* (el world transform inverso en el bind). Al
16//! animar, se cambian las poses locales ([`Skeleton::set_pose`]),
17//! [`Skeleton::update`] recompone los world transforms, y la matriz de skinning
18//! de un hueso es `world_actual · inverse_bind`. En el bind pose esa matriz es
19//! la identidad → los vértices quedan en reposo.
20//!
21//! ## LBS
22//!
23//! La posición deformada de un vértice es la media ponderada, sobre los huesos
24//! que lo influyen, de aplicarle a su posición de reposo la matriz de skinning
25//! de cada hueso: `v' = Σ_b w_b · (skin_b · v_rest)`.
26
27use std::collections::HashMap;
28
29use kurbo::{Affine, Point, Vec2};
30
31use crate::RenderFrame;
32
33/// Índice de un hueso dentro del esqueleto.
34pub type BoneId = usize;
35
36/// Pose local de un hueso (TRS) relativa a su padre. Se compone como
37/// `translate · rotate · scale` — el orden estándar.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct Pose {
40 pub translation: Vec2,
41 /// Rotación en radianes (convención de kurbo).
42 pub rotation: f64,
43 pub scale: Vec2,
44}
45
46impl Default for Pose {
47 fn default() -> Self {
48 Self::identity()
49 }
50}
51
52impl Pose {
53 pub fn identity() -> Self {
54 Self {
55 translation: Vec2::ZERO,
56 rotation: 0.0,
57 scale: Vec2::new(1.0, 1.0),
58 }
59 }
60 pub fn translate(t: Vec2) -> Self {
61 Self {
62 translation: t,
63 ..Self::identity()
64 }
65 }
66 pub fn rotate(r: f64) -> Self {
67 Self {
68 rotation: r,
69 ..Self::identity()
70 }
71 }
72 /// Translación + rotación (lo más común al animar un hueso).
73 pub fn new(translation: Vec2, rotation: f64, scale: Vec2) -> Self {
74 Self {
75 translation,
76 rotation,
77 scale,
78 }
79 }
80 pub fn to_affine(&self) -> Affine {
81 Affine::translate(self.translation)
82 * Affine::rotate(self.rotation)
83 * Affine::scale_non_uniform(self.scale.x, self.scale.y)
84 }
85}
86
87#[derive(Debug, Clone)]
88struct Bone {
89 parent: Option<BoneId>,
90 local: Pose,
91}
92
93/// Una jerarquía de huesos posable. Los huesos se agregan **padre antes que
94/// hijo** (el índice del padre debe ser menor), así el cómputo de world
95/// transforms es una sola pasada hacia adelante.
96#[derive(Debug, Clone, Default)]
97pub struct Skeleton {
98 bones: Vec<Bone>,
99 /// World transform inverso de cada hueso, capturado en `bind`.
100 inverse_bind: Vec<Affine>,
101 /// Scratch: world transforms actuales (recomputados por `update`).
102 world: Vec<Affine>,
103}
104
105impl Skeleton {
106 pub fn new() -> Self {
107 Self::default()
108 }
109
110 /// Agrega un hueso con `parent` (o `None` = raíz) y su pose local. Devuelve
111 /// su `BoneId`. **Pánico** si el padre no fue agregado antes (índice ≥ id).
112 pub fn add_bone(&mut self, parent: Option<BoneId>, local: Pose) -> BoneId {
113 if let Some(p) = parent {
114 assert!(
115 p < self.bones.len(),
116 "el padre {p} debe agregarse antes que el hijo"
117 );
118 }
119 let id = self.bones.len();
120 self.bones.push(Bone { parent, local });
121 self.inverse_bind.push(Affine::IDENTITY);
122 self.world.push(Affine::IDENTITY);
123 id
124 }
125
126 fn recompute_world(&mut self) {
127 for i in 0..self.bones.len() {
128 let local = self.bones[i].local.to_affine();
129 self.world[i] = match self.bones[i].parent {
130 Some(p) => self.world[p] * local,
131 None => local,
132 };
133 }
134 }
135
136 /// Congela la pose actual como **bind pose**: recompone los world transforms
137 /// y guarda el inverse-bind de cada hueso. Llamar una vez tras construir el
138 /// esqueleto en reposo (antes de animar).
139 pub fn bind(&mut self) {
140 self.recompute_world();
141 for i in 0..self.bones.len() {
142 self.inverse_bind[i] = self.world[i].inverse();
143 }
144 }
145
146 /// Cambia la pose local de un hueso (animar). Requiere [`update`] después
147 /// para que los world transforms reflejen el cambio.
148 ///
149 /// [`update`]: Skeleton::update
150 pub fn set_pose(&mut self, bone: BoneId, local: Pose) {
151 self.bones[bone].local = local;
152 }
153
154 /// Pose local actual de un hueso.
155 pub fn pose(&self, bone: BoneId) -> Pose {
156 self.bones[bone].local
157 }
158
159 /// Recalcula los world transforms desde las poses actuales. Llamar tras
160 /// posear y antes de deformar/leer `world`/`skin_matrix`.
161 pub fn update(&mut self) {
162 self.recompute_world();
163 }
164
165 /// Matriz de skinning del hueso: `world_actual · inverse_bind`. En el bind
166 /// pose es la identidad.
167 pub fn skin_matrix(&self, bone: BoneId) -> Affine {
168 self.world[bone] * self.inverse_bind[bone]
169 }
170
171 /// World transform actual del hueso (útil para dibujar el hueso mismo o
172 /// adjuntar algo a su punta).
173 pub fn world(&self, bone: BoneId) -> Affine {
174 self.world[bone]
175 }
176
177 /// Padre de un hueso (`None` si es raíz). Lo usan los constraints para
178 /// convertir una rotación deseada en mundo a la pose local.
179 pub fn parent(&self, bone: BoneId) -> Option<BoneId> {
180 self.bones[bone].parent
181 }
182
183 pub fn len(&self) -> usize {
184 self.bones.len()
185 }
186 pub fn is_empty(&self) -> bool {
187 self.bones.is_empty()
188 }
189}
190
191/// Influencia de un hueso sobre un vértice. Idealmente los pesos de un vértice
192/// suman 1; si no, `deform` normaliza.
193#[derive(Debug, Clone, Copy, PartialEq)]
194pub struct Weight {
195 pub bone: BoneId,
196 pub weight: f64,
197}
198
199/// Un vértice de la malla: su posición de reposo (en model/bind space), su UV
200/// `0..1` (para textura, lo usa el render de malla texturizada) y los huesos que
201/// lo influyen.
202#[derive(Debug, Clone)]
203pub struct Vertex {
204 pub rest: Point,
205 pub uv: (f64, f64),
206 pub weights: Vec<Weight>,
207}
208
209impl Vertex {
210 /// Vértice rígido a un solo hueso (peso 1).
211 pub fn rigid(rest: Point, uv: (f64, f64), bone: BoneId) -> Self {
212 Self {
213 rest,
214 uv,
215 weights: vec![Weight { bone, weight: 1.0 }],
216 }
217 }
218}
219
220/// Una malla deformable: vértices con pesos + topología de triángulos (índices
221/// a `vertices`). El render la consume tras `deform`.
222#[derive(Debug, Clone, Default)]
223pub struct Mesh {
224 pub vertices: Vec<Vertex>,
225 pub triangles: Vec<[u32; 3]>,
226}
227
228impl Mesh {
229 pub fn new() -> Self {
230 Self::default()
231 }
232
233 /// Calcula las posiciones deformadas (LBS) en `out`, una por vértice, en el
234 /// mismo orden que `self.vertices`. `skel` debe tener sus world transforms
235 /// actualizados ([`Skeleton::update`]). Reutiliza el buffer `out` (sin
236 /// asignar por frame).
237 pub fn deform_into(&self, skel: &Skeleton, out: &mut Vec<Point>) {
238 out.clear();
239 out.reserve(self.vertices.len());
240 for v in &self.vertices {
241 if v.weights.is_empty() {
242 out.push(v.rest);
243 continue;
244 }
245 let mut acc = Vec2::ZERO;
246 let mut wsum = 0.0;
247 for w in &v.weights {
248 let p = skel.skin_matrix(w.bone) * v.rest;
249 acc += p.to_vec2() * w.weight;
250 wsum += w.weight;
251 }
252 let p = if wsum.abs() > 1e-12 {
253 (acc * (1.0 / wsum)).to_point()
254 } else {
255 v.rest
256 };
257 out.push(p);
258 }
259 }
260
261 /// Variante que asigna y devuelve el `Vec` (conveniencia para tests/uso
262 /// ocasional; en el bucle de render preferí [`deform_into`]).
263 ///
264 /// [`deform_into`]: Mesh::deform_into
265 pub fn deform(&self, skel: &Skeleton) -> Vec<Point> {
266 let mut out = Vec::new();
267 self.deform_into(skel, &mut out);
268 out
269 }
270}
271
272/// Interpola linealmente dos poses (translación/escala lerp, rotación lerp del
273/// ángulo). Es el blend de poses que usa el crossfade esqueletal — mezclar
274/// *poses* da una deformación correcta, a diferencia del crossfade de píxeles.
275pub fn lerp_pose(a: Pose, b: Pose, f: f64) -> Pose {
276 let lerp = |x: f64, y: f64| x + (y - x) * f;
277 Pose {
278 translation: Vec2::new(
279 lerp(a.translation.x, b.translation.x),
280 lerp(a.translation.y, b.translation.y),
281 ),
282 rotation: lerp(a.rotation, b.rotation),
283 scale: Vec2::new(lerp(a.scale.x, b.scale.x), lerp(a.scale.y, b.scale.y)),
284 }
285}
286
287/// Keyframe de pose de un hueso en un instante.
288#[derive(Debug, Clone, Copy, PartialEq)]
289pub struct PoseKey {
290 pub t: f64,
291 pub pose: Pose,
292}
293
294/// Pista de animación de un hueso: keyframes de pose ordenados por tiempo.
295#[derive(Debug, Clone, Default)]
296pub struct BoneTrack {
297 pub bone: BoneId,
298 pub keys: Vec<PoseKey>,
299}
300
301impl BoneTrack {
302 /// Pose en el instante `t` (lineal entre keyframes, clamp en los extremos).
303 pub fn sample(&self, t: f64) -> Pose {
304 match self.keys.as_slice() {
305 [] => Pose::identity(),
306 [only] => only.pose,
307 keys => {
308 if t <= keys[0].t {
309 return keys[0].pose;
310 }
311 let last = keys[keys.len() - 1];
312 if t >= last.t {
313 return last.pose;
314 }
315 for w in keys.windows(2) {
316 let (a, b) = (w[0], w[1]);
317 if t >= a.t && t <= b.t {
318 let span = b.t - a.t;
319 let f = if span > 1e-12 { (t - a.t) / span } else { 0.0 };
320 return lerp_pose(a.pose, b.pose, f);
321 }
322 }
323 last.pose
324 }
325 }
326 }
327}
328
329/// Una animación esqueletal: pistas de pose por hueso, con duración y loop. Es
330/// lo que un *estado* de la máquina reproduce (un clip = una `BoneAnimation`).
331/// Los huesos no incluidos en ninguna pista conservan su pose actual.
332#[derive(Debug, Clone, Default)]
333pub struct BoneAnimation {
334 pub duration: f64,
335 pub looping: bool,
336 pub tracks: Vec<BoneTrack>,
337}
338
339impl BoneAnimation {
340 /// Samplea todas las pistas en `t` (envuelto por la duración si es loop) a
341 /// pares `(hueso, pose)`, reusando el buffer `out`.
342 pub fn sample_into(&self, t: f64, out: &mut Vec<(BoneId, Pose)>) {
343 let tt = if self.looping && self.duration > 0.0 {
344 t.rem_euclid(self.duration)
345 } else {
346 t
347 };
348 out.clear();
349 out.extend(self.tracks.iter().map(|tr| (tr.bone, tr.sample(tt))));
350 }
351
352 /// Posa el esqueleto con esta animación en el instante `t` y llama `update`.
353 pub fn apply(&self, skel: &mut Skeleton, t: f64) {
354 let mut buf = Vec::new();
355 self.sample_into(t, &mut buf);
356 for (b, p) in buf {
357 skel.set_pose(b, p);
358 }
359 skel.update();
360 }
361}
362
363/// Posa el esqueleto según el [`RenderFrame`] de una máquina de estados, usando
364/// las `clips` ([`BoneAnimation`]) indexadas por `ClipId`. Durante una
365/// transición blendea **poses** (no píxeles) por la mezcla del frame. Llama
366/// `skel.update()` al final. Es el puente que une máquina-de-estados →
367/// esqueleto: estado/transición deciden la pose, el skinning la convierte en
368/// deformación.
369///
370/// [`RenderFrame`]: crate::RenderFrame
371pub fn pose_from_render_frame(skel: &mut Skeleton, frame: &RenderFrame, clips: &[BoneAnimation]) {
372 let mut poses: HashMap<BoneId, Pose> = HashMap::new();
373 let mut buf = Vec::new();
374 if let Some(a) = clips.get(frame.primary.clip as usize) {
375 a.sample_into(frame.primary.time_secs, &mut buf);
376 for (b, p) in buf.drain(..) {
377 poses.insert(b, p);
378 }
379 }
380 if let Some((inc, mix)) = frame.blend {
381 if let Some(a) = clips.get(inc.clip as usize) {
382 a.sample_into(inc.time_secs, &mut buf);
383 let mix = mix as f64;
384 for (b, p) in buf.drain(..) {
385 poses
386 .entry(b)
387 .and_modify(|cur| *cur = lerp_pose(*cur, p, mix))
388 .or_insert(p);
389 }
390 }
391 }
392 for (b, p) in poses {
393 skel.set_pose(b, p);
394 }
395 skel.update();
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 fn approx(a: Point, x: f64, y: f64) -> bool {
403 (a.x - x).abs() < 1e-9 && (a.y - y).abs() < 1e-9
404 }
405
406 #[test]
407 fn bind_pose_deja_los_vertices_en_reposo() {
408 let mut s = Skeleton::new();
409 let b = s.add_bone(None, Pose::translate(Vec2::new(5.0, 7.0)));
410 s.bind();
411 s.update();
412 let mut m = Mesh::new();
413 m.vertices.push(Vertex::rigid(Point::new(3.0, 4.0), (0.0, 0.0), b));
414 let d = m.deform(&s);
415 // Sin re-posar: la matriz de skinning es identidad → reposo intacto.
416 assert!(approx(d[0], 3.0, 4.0), "fue {:?}", d[0]);
417 }
418
419 #[test]
420 fn rotar_un_hueso_rota_su_vertice() {
421 let mut s = Skeleton::new();
422 let b = s.add_bone(None, Pose::identity());
423 s.bind();
424 // Rotar 90°: (10,0) → (0,10) en convención de kurbo.
425 s.set_pose(b, Pose::rotate(std::f64::consts::FRAC_PI_2));
426 s.update();
427 let mut m = Mesh::new();
428 m.vertices.push(Vertex::rigid(Point::new(10.0, 0.0), (0.0, 0.0), b));
429 let d = m.deform(&s);
430 assert!(approx(d[0], 0.0, 10.0), "fue {:?}", d[0]);
431 }
432
433 #[test]
434 fn cadena_padre_hijo_compone_transforms() {
435 let mut s = Skeleton::new();
436 let root = s.add_bone(None, Pose::identity());
437 let child = s.add_bone(Some(root), Pose::translate(Vec2::new(10.0, 0.0)));
438 s.bind();
439 // Rotar la raíz 90° rota rígidamente al hijo y su vértice.
440 s.set_pose(root, Pose::rotate(std::f64::consts::FRAC_PI_2));
441 s.update();
442 let mut m = Mesh::new();
443 m.vertices
444 .push(Vertex::rigid(Point::new(20.0, 0.0), (0.0, 0.0), child));
445 let d = m.deform(&s);
446 // (20,0) rotado 90° sobre el origen → (0,20).
447 assert!(approx(d[0], 0.0, 20.0), "fue {:?}", d[0]);
448 }
449
450 #[test]
451 fn peso_repartido_mezcla_dos_huesos() {
452 let mut s = Skeleton::new();
453 let a = s.add_bone(None, Pose::identity());
454 let b = s.add_bone(None, Pose::identity());
455 s.bind();
456 // A queda quieto; B se traslada (0,20).
457 s.set_pose(b, Pose::translate(Vec2::new(0.0, 20.0)));
458 s.update();
459 let mut m = Mesh::new();
460 m.vertices.push(Vertex {
461 rest: Point::new(0.0, 0.0),
462 uv: (0.5, 0.5),
463 weights: vec![
464 Weight { bone: a, weight: 0.5 },
465 Weight { bone: b, weight: 0.5 },
466 ],
467 });
468 let d = m.deform(&s);
469 // 0.5·(0,0) + 0.5·(0,20) = (0,10).
470 assert!(approx(d[0], 0.0, 10.0), "fue {:?}", d[0]);
471 }
472
473 #[test]
474 fn pesos_sin_normalizar_se_normalizan() {
475 let mut s = Skeleton::new();
476 let a = s.add_bone(None, Pose::identity());
477 let b = s.add_bone(None, Pose::translate(Vec2::new(0.0, 10.0)));
478 s.bind();
479 s.update();
480 let mut m = Mesh::new();
481 // Pesos 2 y 2 (suman 4) → media ponderada, no suma cruda.
482 m.vertices.push(Vertex {
483 rest: Point::new(0.0, 0.0),
484 uv: (0.0, 0.0),
485 weights: vec![
486 Weight { bone: a, weight: 2.0 },
487 Weight { bone: b, weight: 2.0 },
488 ],
489 });
490 let d = m.deform(&s);
491 // skin_a = id → (0,0); skin_b = id (b en bind pose, no re-posado) → (0,0).
492 // Ambos en reposo: (0,0). (Verifica que no explota por wsum=4.)
493 assert!(approx(d[0], 0.0, 0.0), "fue {:?}", d[0]);
494 }
495
496 #[test]
497 fn vertice_sin_pesos_queda_en_reposo() {
498 let mut s = Skeleton::new();
499 let _b = s.add_bone(None, Pose::rotate(1.0));
500 s.bind();
501 s.update();
502 let mut m = Mesh::new();
503 m.vertices.push(Vertex {
504 rest: Point::new(7.0, 7.0),
505 uv: (0.0, 0.0),
506 weights: vec![],
507 });
508 let d = m.deform(&s);
509 assert!(approx(d[0], 7.0, 7.0), "fue {:?}", d[0]);
510 }
511
512 #[test]
513 fn track_samplea_lineal_entre_keyframes() {
514 let tr = BoneTrack {
515 bone: 0,
516 keys: vec![
517 PoseKey { t: 0.0, pose: Pose::rotate(0.0) },
518 PoseKey { t: 2.0, pose: Pose::rotate(2.0) },
519 ],
520 };
521 assert!((tr.sample(0.0).rotation - 0.0).abs() < 1e-9);
522 assert!((tr.sample(2.0).rotation - 2.0).abs() < 1e-9);
523 assert!((tr.sample(1.0).rotation - 1.0).abs() < 1e-9, "punto medio");
524 // Clamp fuera de rango.
525 assert!((tr.sample(-5.0).rotation - 0.0).abs() < 1e-9);
526 assert!((tr.sample(99.0).rotation - 2.0).abs() < 1e-9);
527 }
528
529 #[test]
530 fn animacion_en_loop_envuelve_el_tiempo() {
531 let anim = BoneAnimation {
532 duration: 2.0,
533 looping: true,
534 tracks: vec![BoneTrack {
535 bone: 0,
536 keys: vec![
537 PoseKey { t: 0.0, pose: Pose::rotate(0.0) },
538 PoseKey { t: 2.0, pose: Pose::rotate(2.0) },
539 ],
540 }],
541 };
542 let mut buf = Vec::new();
543 anim.sample_into(3.0, &mut buf); // 3 % 2 = 1 → rotación ~1.0
544 assert!((buf[0].1.rotation - 1.0).abs() < 1e-9, "fue {}", buf[0].1.rotation);
545 }
546
547 #[test]
548 fn render_frame_simple_posa_el_esqueleto() {
549 let mut s = Skeleton::new();
550 let b = s.add_bone(None, Pose::identity());
551 s.bind();
552 let anim = BoneAnimation {
553 duration: 2.0,
554 looping: true,
555 tracks: vec![BoneTrack {
556 bone: b,
557 keys: vec![
558 PoseKey { t: 0.0, pose: Pose::identity() },
559 PoseKey {
560 t: 1.0,
561 pose: Pose::rotate(std::f64::consts::FRAC_PI_2),
562 },
563 ],
564 }],
565 };
566 let clips = vec![anim];
567 let frame = crate::RenderFrame {
568 primary: crate::ClipSample { clip: 0, time_secs: 1.0 },
569 blend: None,
570 };
571 pose_from_render_frame(&mut s, &frame, &clips);
572 let mut m = Mesh::new();
573 m.vertices.push(Vertex::rigid(Point::new(10.0, 0.0), (0.0, 0.0), b));
574 let d = m.deform(&s);
575 assert!(approx(d[0], 0.0, 10.0), "fue {:?}", d[0]);
576 }
577
578 #[test]
579 fn render_frame_con_blend_mezcla_poses() {
580 let mut s = Skeleton::new();
581 let b = s.add_bone(None, Pose::identity());
582 s.bind();
583 // clip0: identidad; clip1: 90°. Blend 0.5 → 45°.
584 let rest = BoneAnimation {
585 duration: 1.0,
586 looping: true,
587 tracks: vec![BoneTrack {
588 bone: b,
589 keys: vec![PoseKey { t: 0.0, pose: Pose::identity() }],
590 }],
591 };
592 let turn = BoneAnimation {
593 duration: 1.0,
594 looping: true,
595 tracks: vec![BoneTrack {
596 bone: b,
597 keys: vec![PoseKey {
598 t: 0.0,
599 pose: Pose::rotate(std::f64::consts::FRAC_PI_2),
600 }],
601 }],
602 };
603 let clips = vec![rest, turn];
604 let frame = crate::RenderFrame {
605 primary: crate::ClipSample { clip: 0, time_secs: 0.0 },
606 blend: Some((crate::ClipSample { clip: 1, time_secs: 0.0 }, 0.5)),
607 };
608 pose_from_render_frame(&mut s, &frame, &clips);
609 let mut m = Mesh::new();
610 m.vertices.push(Vertex::rigid(Point::new(10.0, 0.0), (0.0, 0.0), b));
611 let d = m.deform(&s);
612 // 10·(cos45, sin45) ≈ (7.071, 7.071).
613 let c = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
614 assert!((d[0].x - c).abs() < 1e-9 && (d[0].y - c).abs() < 1e-9, "fue {:?}", d[0]);
615 }
616}