tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
feat(3d): cosechar efectos genéricos de supay + refrescar voxel/director
1//! `director` — una **timeline guionada** para filmar: describe qué hace cada
2//! actor y qué hace la cámara *en función del tiempo*, de forma **determinista**
3//! (mismo `t` → mismo estado → reproducible cuadro a cuadro). Es la capa de
4//! *dirección* que faltaba: en vez de hardcodear el bucle de la escena, se
5//! declara un [`Sequence`] (el "guion") y se reproduce.
6//!
7//! Modelo, deliberadamente chico:
8//! - [`ActorScript`] = keyframes `(t, posición de grilla, clip?, rumbo?)` de un
9//! actor; `sample(t)` interpola la posición y decide el [`Clip`] (auto: camina
10//! si se mueve, quieto si no) y el rumbo (auto: dirección de marcha).
11//! - [`Shot`] = un plano de cámara ([`CameraTrack`]) con su instante de inicio;
12//! varios planos dan **cortes duros** (cambia de plano sin interpolar).
13//! - [`Sequence`] = el reparto de scripts + la lista de planos + duración.
14//!
15//! Es **contenido puro** (coordenadas de grilla, sin terreno ni GPU): el que
16//! reproduce (la app) mapea la posición de grilla al relieve y posa cada
17//! [`Actor`](crate::Actor) (con su cross-fade de clips). Reusable por cualquier
18//! película/juego voxel.
19
20use llimphi_3d::glam::Vec3;
21use llimphi_3d::{Camera3d, CameraTrack};
22
23use crate::actor::Clip;
24
25/// Umbral de desplazamiento (unidades de grilla por segmento) para considerar
26/// que un actor "se mueve" (y por tanto camina y mira hacia donde va).
27const MOVING_EPS: f32 = 0.05;
28
29/// Envuelve un ángulo a `[-π, π]` (la diferencia más corta entre dos rumbos).
30fn wrap_pi(mut a: f32) -> f32 {
31 use std::f32::consts::PI;
32 while a > PI {
33 a -= 2.0 * PI;
34 }
35 while a < -PI {
36 a += 2.0 * PI;
37 }
38 a
39}
40
41/// Un keyframe de actor: dónde está (grilla), opcionalmente qué clip reproduce y
42/// hacia dónde mira. `clip`/`face` en `None` = automático.
43#[derive(Debug, Clone, Copy)]
44pub struct ActorKey {
45 /// Instante (segundos).
46 pub t: f32,
47 /// Posición de grilla en `t`.
48 pub gx: f32,
49 pub gz: f32,
50 /// Clip explícito mientras se sale de esta key (`None` = auto: caminar/quieto).
51 pub clip: Option<Clip>,
52 /// Rumbo explícito (yaw; `None` = auto: dirección de marcha).
53 pub face: Option<f32>,
54}
55
56impl ActorKey {
57 /// Key en `(gx, gz)` a tiempo `t`, clip/rumbo automáticos.
58 pub fn at(t: f32, gx: f32, gz: f32) -> Self {
59 Self { t, gx, gz, clip: None, face: None }
60 }
61
62 /// Fija el clip a reproducir desde esta key (p.ej. quedarse saludando).
63 pub fn play(mut self, clip: Clip) -> Self {
64 self.clip = Some(clip);
65 self
66 }
67
68 /// Fija el rumbo (yaw) desde esta key (p.ej. mirar a la cámara al detenerse).
69 pub fn facing(mut self, yaw: f32) -> Self {
70 self.face = Some(yaw);
71 self
72 }
73}
74
75/// Lo que un [`ActorScript`] dicta para un instante: dónde poner al actor
76/// (grilla), hacia dónde mirar y qué clip animar.
77#[derive(Debug, Clone, Copy)]
78pub struct ActorSample {
79 pub gx: f32,
80 pub gz: f32,
81 pub facing: f32,
82 pub clip: Clip,
83}
84
85/// El guion de **un** actor: una lista de [`ActorKey`] ordenada en el tiempo.
86#[derive(Debug, Clone, Default)]
87pub struct ActorScript {
88 keys: Vec<ActorKey>,
89 /// **Tasa de cuadros propia** de este actor (`None` = la nativa de la peli,
90 /// fluida). Si está, el tiempo se *cuantiza* a esa rejilla antes de muestrear
91 /// posición **y** pose: el actor se mueve "a tirones" (animación *en doses*,
92 /// estilo stop-motion/Spider-Verse) aunque el render salga a 30/60. Es el
93 /// **sello de animación**: el Héroe a 12 fps contra un Avatar fluido a 60 da
94 /// una extrañeza que nadie obtiene usando el motor por defecto.
95 frame_rate: Option<u32>,
96}
97
98impl ActorScript {
99 /// Crea el guion (ordena las keys por `t`).
100 pub fn new(mut keys: Vec<ActorKey>) -> Self {
101 keys.sort_by(|a, b| a.t.total_cmp(&b.t));
102 Self { keys, frame_rate: None }
103 }
104
105 /// Fija la **tasa de cuadros propia** del actor (animación en doses). `0` o
106 /// quitarla = fluido (nativo). Ver [`quantize`](Self::quantize).
107 pub fn with_frame_rate(mut self, fps: Option<u32>) -> Self {
108 self.frame_rate = fps.filter(|&f| f > 0);
109 self
110 }
111
112 /// La tasa de cuadros propia del actor (`None` = fluido).
113 pub fn frame_rate(&self) -> Option<u32> {
114 self.frame_rate
115 }
116
117 /// **Cuantiza** un instante a la rejilla de cuadros del actor: si tiene
118 /// `frame_rate = Some(fps)`, devuelve el inicio del cuadro que contiene a `t`
119 /// (`floor(t·fps)/fps`); si no, devuelve `t` tal cual. El reproductor debe
120 /// muestrear **posición y pose** con este tiempo cuantizado para que el cuerpo
121 /// avance en escalones (no sólo se teletransporte la posición).
122 pub fn quantize(&self, t: f32) -> f32 {
123 match self.frame_rate {
124 Some(fps) => (t * fps as f32).floor() / fps as f32,
125 None => t,
126 }
127 }
128
129 /// Tiempo de la última key (duración del guion del actor).
130 pub fn duration(&self) -> f32 {
131 self.keys.last().map(|k| k.t).unwrap_or(0.0)
132 }
133
134 /// Instantes (segundos) en que este actor **arranca un gesto** (clip emote
135 /// explícito en una key). Son momentos expresivos del guion — el director los usa
136 /// para acentuar la música.
137 pub fn emote_onsets(&self) -> Vec<f32> {
138 self.keys.iter().filter(|k| k.clip.is_some_and(|c| c.is_emote())).map(|k| k.t).collect()
139 }
140
141 /// El estado dictado en `t`: posición interpolada + clip + rumbo. Antes/
142 /// después del rango, clampa a la primera/última key (quieto).
143 pub fn sample(&self, t: f32) -> ActorSample {
144 let keys = &self.keys;
145 if keys.is_empty() {
146 return ActorSample { gx: 0.0, gz: 0.0, facing: 0.0, clip: Clip::Idle };
147 }
148 let last = keys.len() - 1;
149 // Extremos: quieto en la primera/última key.
150 if t <= keys[0].t || last == 0 {
151 let k = &keys[0];
152 return ActorSample {
153 gx: k.gx,
154 gz: k.gz,
155 facing: k.face.unwrap_or(0.0),
156 clip: k.clip.unwrap_or(Clip::Idle),
157 };
158 }
159 if t >= keys[last].t {
160 let k = &keys[last];
161 return ActorSample {
162 gx: k.gx,
163 gz: k.gz,
164 facing: k.face.unwrap_or_else(|| self.motion_facing(last.saturating_sub(1))),
165 clip: k.clip.unwrap_or(Clip::Idle),
166 };
167 }
168 // Segmento `[i, i+1]` que contiene a `t`.
169 let i = keys.iter().rposition(|k| k.t <= t).unwrap_or(0).min(last - 1);
170 let (a, b) = (&keys[i], &keys[i + 1]);
171 let f = ((t - a.t) / (b.t - a.t).max(1e-6)).clamp(0.0, 1.0);
172 let (dx, dz) = (b.gx - a.gx, b.gz - a.gz);
173 let moving = dx.hypot(dz) > MOVING_EPS;
174 // Rumbo: si el guion lo fija en ambas keys, **gira suave** entre ambas
175 // (por el camino más corto); si sólo en la de salida, lo mantiene; si en
176 // ninguna, automático (dirección de marcha, o la última al detenerse).
177 let facing = match (a.face, b.face) {
178 (Some(fa), Some(fb)) => fa + wrap_pi(fb - fa) * f,
179 (Some(fa), None) => fa,
180 (None, _) if moving => dx.atan2(dz), // yaw=0 → +Z, como Actor::face_towards
181 (None, _) => self.motion_facing(i),
182 };
183 ActorSample {
184 gx: a.gx + dx * f,
185 gz: a.gz + dz * f,
186 clip: a.clip.unwrap_or(if moving { Clip::Walk } else { Clip::Idle }),
187 facing,
188 }
189 }
190
191 /// Rumbo del último segmento *con movimiento* hasta el índice `i` (para
192 /// mantener la orientación cuando el actor está detenido); `0` si nunca se
193 /// movió.
194 fn motion_facing(&self, i: usize) -> f32 {
195 for j in (0..=i).rev() {
196 if j + 1 < self.keys.len() {
197 let (a, b) = (&self.keys[j], &self.keys[j + 1]);
198 let (dx, dz) = (b.gx - a.gx, b.gz - a.gz);
199 if dx.hypot(dz) > MOVING_EPS {
200 return dx.atan2(dz);
201 }
202 }
203 }
204 0.0
205 }
206}
207
208/// Un **plano** de cámara: una [`CameraTrack`] que arranca en `start` (segundos
209/// absolutos de la secuencia). Sus keys son **relativas** al plano (`t=0` =
210/// inicio del plano), así un plano es autocontenido y reubicable.
211#[derive(Debug, Clone)]
212pub struct Shot {
213 pub start: f32,
214 pub track: CameraTrack,
215}
216
217impl Shot {
218 pub fn new(start: f32, track: CameraTrack) -> Self {
219 Self { start, track }
220 }
221}
222
223/// El **guion completo**: el reparto (un [`ActorScript`] por actor), los planos
224/// de cámara (con cortes duros entre ellos) y la duración total. Reproducir =
225/// para cada `t`: `camera(t)` + `actor.sample(t)` por actor.
226#[derive(Debug, Clone, Default)]
227pub struct Sequence {
228 pub actors: Vec<ActorScript>,
229 shots: Vec<Shot>,
230 pub duration: f32,
231}
232
233impl Sequence {
234 /// Crea la secuencia (ordena los planos por inicio).
235 pub fn new(actors: Vec<ActorScript>, mut shots: Vec<Shot>, duration: f32) -> Self {
236 shots.sort_by(|a, b| a.start.total_cmp(&b.start));
237 Self { actors, shots, duration }
238 }
239
240 /// Cantidad de cuadros a `fps` para la duración total.
241 pub fn frames(&self, fps: u32) -> u32 {
242 (self.duration * fps as f32).round() as u32
243 }
244
245 /// La cámara en `t`: el plano activo (el último cuyo `start ≤ t`) muestreado a
246 /// `t − start`. El salto entre planos es un **corte duro** (sin interpolar).
247 pub fn camera(&self, t: f32) -> Camera3d {
248 let shot = self
249 .shots
250 .iter()
251 .rev()
252 .find(|s| s.start <= t)
253 .or_else(|| self.shots.first());
254 match shot {
255 Some(s) => s.track.sample(t - s.start),
256 None => Camera3d::default(),
257 }
258 }
259
260 /// Los **"beats del guion"**: los instantes (segundos, ordenados, sin repetir)
261 /// que merecen un acento musical — los **cortes de cámara** (inicio de cada plano
262 /// salvo el primero) y los **gestos** de los actores. Es lo que deja que la banda
263 /// sonora caiga *sobre la acción* en vez de sólo compartir duración. Dos tiempos a
264 /// menos de `EPS` se consideran el mismo acento.
265 pub fn beat_times(&self) -> Vec<f32> {
266 const EPS: f32 = 0.05;
267 let mut ts: Vec<f32> = Vec::new();
268 // Cortes de cámara (el primer plano no es un corte).
269 for s in self.shots.iter().skip(1) {
270 ts.push(s.start);
271 }
272 // Gestos de cada actor.
273 for a in &self.actors {
274 ts.extend(a.emote_onsets());
275 }
276 ts.retain(|&t| t >= 0.0 && t <= self.duration + EPS);
277 ts.sort_by(f32::total_cmp);
278 ts.dedup_by(|a, b| (*a - *b).abs() < EPS);
279 ts
280 }
281
282 /// Posición de grilla (sin altura) del **centroide** del reparto en `t`,
283 /// útil para apuntar la cámara al grupo. `None` si no hay actores.
284 pub fn cast_centroid(&self, t: f32) -> Option<Vec3> {
285 if self.actors.is_empty() {
286 return None;
287 }
288 let mut sx = 0.0;
289 let mut sz = 0.0;
290 for a in &self.actors {
291 let s = a.sample(t);
292 sx += s.gx;
293 sz += s.gz;
294 }
295 let n = self.actors.len() as f32;
296 Some(Vec3::new(sx / n, 0.0, sz / n))
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use std::f32::consts::FRAC_PI_2;
304
305 #[test]
306 fn camina_y_luego_emota() {
307 // De (0,0) a (10,0) en 2 s, después se queda saludando.
308 let s = ActorScript::new(vec![
309 ActorKey::at(0.0, 0.0, 0.0),
310 ActorKey::at(2.0, 10.0, 0.0).play(Clip::Wave).facing(FRAC_PI_2),
311 ]);
312 // A mitad del trayecto: posición media, caminando, mirando a +X.
313 let m = s.sample(1.0);
314 assert!((m.gx - 5.0).abs() < 1e-4);
315 assert_eq!(m.clip, Clip::Walk);
316 assert!((m.facing - FRAC_PI_2).abs() < 1e-4, "mueve en +X → yaw=π/2");
317 // Al final: quieto, saludando, mirando a donde dijimos.
318 let e = s.sample(3.0);
319 assert_eq!((e.gx, e.clip), (10.0, Clip::Wave));
320 assert!((e.facing - FRAC_PI_2).abs() < 1e-4);
321 }
322
323 #[test]
324 fn frame_rate_cuantiza_el_tiempo_en_doses() {
325 // Sin tasa propia: fluido (t pasa tal cual).
326 let fluido = ActorScript::new(vec![ActorKey::at(0.0, 0.0, 0.0)]);
327 assert_eq!(fluido.quantize(0.137), 0.137);
328 assert_eq!(fluido.frame_rate(), None);
329
330 // A 12 fps: el tiempo cae al inicio del cuadro (1/12 ≈ 0.0833).
331 let heroe = fluido.clone().with_frame_rate(Some(12));
332 assert_eq!(heroe.frame_rate(), Some(12));
333 // t=0.13 → cuadro 1 (0.0833); t=0.17 → cuadro 2 (0.1667).
334 assert!((heroe.quantize(0.13) - 1.0 / 12.0).abs() < 1e-6);
335 assert!((heroe.quantize(0.17) - 2.0 / 12.0).abs() < 1e-6);
336 // Dentro del mismo cuadro, dos tiempos distintos dan EL MISMO sample → "doses".
337 assert_eq!(heroe.quantize(0.090), heroe.quantize(0.160));
338 assert_eq!(heroe.sample(heroe.quantize(0.090)).gx, heroe.sample(heroe.quantize(0.160)).gx);
339
340 // 0 fps se trata como sin tasa (fluido).
341 assert_eq!(fluido.clone().with_frame_rate(Some(0)).frame_rate(), None);
342 }
343
344 #[test]
345 fn beats_del_guion_son_cortes_y_gestos() {
346 use llimphi_3d::CamKey;
347 // Actor que camina y a los 3 s saluda; otro que a los 4 s señala.
348 let a1 = ActorScript::new(vec![
349 ActorKey::at(0.0, 0.0, 0.0),
350 ActorKey::at(3.0, 5.0, 0.0).play(Clip::Wave),
351 ]);
352 let a2 = ActorScript::new(vec![
353 ActorKey::at(0.0, 2.0, 0.0),
354 ActorKey::at(4.0, 6.0, 0.0).play(Clip::Point),
355 ]);
356 let s0 = CameraTrack::new(vec![CamKey::look(0.0, Vec3::ZERO, Vec3::Z, 50.0)]);
357 let s1 = CameraTrack::new(vec![CamKey::look(0.0, Vec3::Y, Vec3::Z, 50.0)]);
358 // Dos planos: el corte está en 2.5 s (el primero no cuenta).
359 let seq = Sequence::new(vec![a1, a2], vec![Shot::new(0.0, s0), Shot::new(2.5, s1)], 5.0);
360 let beats = seq.beat_times();
361 // Esperado: corte 2.5, saludo 3.0, señal 4.0 (ordenados, sin el inicio).
362 assert_eq!(beats.len(), 3);
363 assert!((beats[0] - 2.5).abs() < 1e-4);
364 assert!((beats[1] - 3.0).abs() < 1e-4);
365 assert!((beats[2] - 4.0).abs() < 1e-4);
366 }
367
368 #[test]
369 fn cortes_de_camara_son_duros() {
370 use llimphi_3d::CamKey;
371 let near = CameraTrack::new(vec![CamKey::look(0.0, Vec3::ZERO, Vec3::Z, 50.0)]);
372 let far = CameraTrack::new(vec![CamKey::look(0.0, Vec3::new(100.0, 0.0, 0.0), Vec3::Z, 50.0)]);
373 let seq = Sequence::new(vec![], vec![Shot::new(0.0, near), Shot::new(1.0, far)], 2.0);
374 // Antes del corte: plano cercano (eye en origen).
375 assert!(seq.camera(0.5).eye.x.abs() < 1e-3);
376 // Después del corte (t≥1): plano lejano, sin interpolación intermedia.
377 assert!((seq.camera(1.5).eye.x - 100.0).abs() < 1e-3);
378 }
379}