tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
llimphi-mesh/examples/clips_to_bones_demo.rs
Historia
Quién tocó qué
crudo
1//! Demo que **cierra el círculo** del motor: máquina de estados → animación de
2//! huesos → skinning → malla deformada.
3//!
4//! Dos clips son `BoneAnimation`s (animaciones esqueletales keyframeadas):
5//! `rest` (tira recta) y `wave` (onda viajera). Una máquina de estados
6//! (`llimphi-anim`) transiciona entre ellos por el input `active`, y
7//! `pose_from_render_frame` posa el esqueleto **blendeando poses** durante el
8//! crossfade — así la tira se *ease-in* a la onda en vez de saltar. El skinning
9//! deforma la malla texturizada (`llimphi-mesh`).
10//!
11//! **Espacio** alterna `active` (rest ⇄ wave). Todo Rust nativo sobre vello 0.7.
12//!
13//! Corre con:
14//! `cargo run -p llimphi-mesh --example clips_to_bones_demo --release`
15
16use std::f64::consts::PI;
17use std::sync::Arc;
18use std::time::Duration;
19
20use llimphi_anim::skel::{
21 pose_from_render_frame, BoneAnimation, BoneTrack, Mesh, Pose, PoseKey, Skeleton, Vertex,
22};
23use llimphi_anim::{Condition, Instance, StateMachine};
24use llimphi_mesh::{fit_transform, paint_textured, paint_wireframe, rest_bounds};
25use llimphi_ui::llimphi_layout::taffy::{
26 prelude::{length, percent, FlexDirection, Size, Style},
27 AlignItems, JustifyContent, Rect,
28};
29use llimphi_ui::llimphi_raster::kurbo::{Point, Vec2};
30use llimphi_ui::llimphi_raster::peniko::{
31 Blob, Color, ImageAlphaType, ImageBrush, ImageData, ImageFormat,
32};
33use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, PaintRect, View};
34
35const SEG: usize = 8;
36const STEP: f64 = 30.0;
37const STRIP_H: f64 = 72.0;
38
39/// Cadena de huesos (raíz anclada + SEG eslabones) + malla-tira atada rigid.
40fn build_rig() -> (Skeleton, Mesh) {
41 let mut s = Skeleton::new();
42 s.add_bone(None, Pose::translate(Vec2::new(0.0, STRIP_H / 2.0)));
43 for _ in 1..=SEG {
44 s.add_bone(Some(s.len() - 1), Pose::translate(Vec2::new(STEP, 0.0)));
45 }
46 s.bind();
47
48 let mut m = Mesh::new();
49 for i in 0..=SEG {
50 let x = i as f64 * STEP;
51 let u = i as f64 / SEG as f64;
52 m.vertices.push(Vertex::rigid(Point::new(x, 0.0), (u, 0.0), i));
53 m.vertices.push(Vertex::rigid(Point::new(x, STRIP_H), (u, 1.0), i));
54 }
55 for i in 0..SEG {
56 let (t0, t1) = ((2 * i) as u32, (2 * (i + 1)) as u32);
57 let (b0, b1) = ((2 * i + 1) as u32, (2 * (i + 1) + 1) as u32);
58 m.triangles.push([t0, t1, b1]);
59 m.triangles.push([t0, b1, b0]);
60 }
61 (s, m)
62}
63
64/// Pose de un eslabón: mantiene la translación STEP (estructura de la cadena) y
65/// anima sólo la rotación.
66fn link_pose(rot: f64) -> Pose {
67 Pose::new(Vec2::new(STEP, 0.0), rot, Vec2::new(1.0, 1.0))
68}
69
70/// Clip "recto": todos los eslabones a rotación 0 (un keyframe).
71fn straight_anim() -> BoneAnimation {
72 let tracks = (1..=SEG)
73 .map(|i| BoneTrack {
74 bone: i,
75 keys: vec![PoseKey { t: 0.0, pose: link_pose(0.0) }],
76 })
77 .collect();
78 BoneAnimation { duration: 1.0, looping: true, tracks }
79}
80
81/// Clip "onda": cada eslabón keyframeado con una senoidal de fase creciente.
82fn wave_anim(amp: f64, period: f64, nkeys: usize) -> BoneAnimation {
83 let tracks = (1..=SEG)
84 .map(|i| {
85 let keys = (0..=nkeys)
86 .map(|k| {
87 let t = period * k as f64 / nkeys as f64;
88 let r = amp * (2.0 * PI * t / period - i as f64 * 0.6).sin();
89 PoseKey { t, pose: link_pose(r) }
90 })
91 .collect();
92 BoneTrack { bone: i, keys }
93 })
94 .collect();
95 BoneAnimation { duration: period, looping: true, tracks }
96}
97
98fn checker(n: u32, sq: u32) -> ImageBrush {
99 let mut px = Vec::with_capacity((n * n * 4) as usize);
100 for y in 0..n {
101 for x in 0..n {
102 let on = ((x / sq + y / sq) % 2) == 0;
103 let (r, g, b) = if on { (70, 130, 205) } else { (240, 180, 70) };
104 px.extend_from_slice(&[r, g, b, 255]);
105 }
106 }
107 ImageBrush::new(ImageData {
108 data: Blob::new(Arc::new(px)),
109 format: ImageFormat::Rgba8,
110 alpha_type: ImageAlphaType::Alpha,
111 width: n,
112 height: n,
113 })
114}
115
116#[derive(Clone)]
117enum Msg {
118 Tick,
119 Toggle,
120}
121
122struct Model {
123 sm: Instance,
124 skel: Skeleton,
125 mesh: Mesh,
126 clips: Vec<BoneAnimation>,
127 image: ImageBrush,
128 active: bool,
129}
130
131struct Demo;
132
133const TICK: Duration = Duration::from_millis(16);
134
135impl App for Demo {
136 type Model = Model;
137 type Msg = Msg;
138
139 fn title() -> &'static str {
140 "llimphi · clips → huesos → malla"
141 }
142 fn initial_size() -> (u32, u32) {
143 (560, 420)
144 }
145
146 fn init(handle: &Handle<Self::Msg>) -> Self::Model {
147 let (skel, mesh) = build_rig();
148 let clips = vec![straight_anim(), wave_anim(0.42, 2.2, 16)];
149
150 let mut sm = StateMachine::new();
151 let rest = sm.add_state("rest", 0, 1.0, true);
152 let wave = sm.add_state("wave", 1, 1.0, true);
153 sm.set_entry(rest);
154 sm.transition(rest, wave, vec![Condition::bool("active", true)], 0.5);
155 sm.transition(wave, rest, vec![Condition::bool("active", false)], 0.5);
156
157 handle.spawn_periodic(TICK, || Msg::Tick);
158
159 Model {
160 sm: sm.instance(),
161 skel,
162 mesh,
163 clips,
164 image: checker(8, 1),
165 active: false,
166 }
167 }
168
169 fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
170 match msg {
171 Msg::Tick => {
172 model.sm.advance(TICK.as_secs_f64());
173 // El estado/transición de la máquina posa el esqueleto (blend de
174 // poses durante el crossfade). pose_from_render_frame llama
175 // skel.update() internamente.
176 let frame = model.sm.render_frame();
177 pose_from_render_frame(&mut model.skel, &frame, &model.clips);
178 }
179 Msg::Toggle => {
180 model.active = !model.active;
181 model.sm.set_bool("active", model.active);
182 }
183 }
184 model
185 }
186
187 fn on_key(_: &Self::Model, e: &KeyEvent) -> Option<Self::Msg> {
188 if e.state == KeyState::Pressed && e.key == Key::Named(NamedKey::Space) {
189 Some(Msg::Toggle)
190 } else {
191 None
192 }
193 }
194
195 fn view(model: &Self::Model) -> View<Self::Msg> {
196 let positions = model.mesh.deform(&model.skel);
197 let mesh = model.mesh.clone();
198 let image = model.image.clone();
199
200 let stage = View::new(Style {
201 size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
202 flex_grow: 1.0,
203 ..Default::default()
204 })
205 .paint_with(move |scene, _ts, rect| {
206 let inner = PaintRect {
207 x: rect.x + 50.0,
208 y: rect.y + 50.0,
209 w: (rect.w - 100.0).max(1.0),
210 h: (rect.h - 100.0).max(1.0),
211 };
212 let xf = fit_transform(rest_bounds(&mesh), inner);
213 paint_textured(scene, &mesh, &positions, xf, &image);
214 paint_wireframe(scene, &mesh, &positions, xf, Color::from_rgba8(255, 255, 255, 110), 1.2);
215 });
216
217 let label = if model.sm.is_transitioning() {
218 "· · · blend de poses · · ·".to_string()
219 } else {
220 format!("estado: {} (Espacio: rest ⇄ wave)", model.sm.current_state())
221 };
222 let status = View::new(Style {
223 size: Size { width: percent(1.0_f32), height: length(30.0_f32) },
224 align_items: Some(AlignItems::Center),
225 justify_content: Some(JustifyContent::Center),
226 ..Default::default()
227 })
228 .text(label, 14.0, Color::from_rgba8(150, 165, 190, 255));
229
230 View::new(Style {
231 flex_direction: FlexDirection::Column,
232 size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
233 padding: Rect {
234 left: length(8.0_f32),
235 right: length(8.0_f32),
236 top: length(8.0_f32),
237 bottom: length(8.0_f32),
238 },
239 ..Default::default()
240 })
241 .fill(Color::from_rgba8(18, 22, 30, 255))
242 .children(vec![stage, status])
243 }
244}
245
246fn main() {
247 llimphi_ui::run::<Demo>();
248}