tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ 92dd0e5bfb2ca4456006da99437f4100e156d856 llimphi-voxel/src/director.rs Rust · 327 líneas · 12.2 KB Historia Quién tocó qué crudo

refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel ccab39f14 · Sergio ·

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}
90
91impl ActorScript {
92    /// Crea el guion (ordena las keys por `t`).
93    pub fn new(mut keys: Vec<ActorKey>) -> Self {
94        keys.sort_by(|a, b| a.t.total_cmp(&b.t));
95        Self { keys }
96    }
97
98    /// Tiempo de la última key (duración del guion del actor).
99    pub fn duration(&self) -> f32 {
100        self.keys.last().map(|k| k.t).unwrap_or(0.0)
101    }
102
103    /// Instantes (segundos) en que este actor **arranca un gesto** (clip emote
104    /// explícito en una key). Son momentos expresivos del guion — el director los usa
105    /// para acentuar la música.
106    pub fn emote_onsets(&self) -> Vec<f32> {
107        self.keys.iter().filter(|k| k.clip.is_some_and(|c| c.is_emote())).map(|k| k.t).collect()
108    }
109
110    /// El estado dictado en `t`: posición interpolada + clip + rumbo. Antes/
111    /// después del rango, clampa a la primera/última key (quieto).
112    pub fn sample(&self, t: f32) -> ActorSample {
113        let keys = &self.keys;
114        if keys.is_empty() {
115            return ActorSample { gx: 0.0, gz: 0.0, facing: 0.0, clip: Clip::Idle };
116        }
117        let last = keys.len() - 1;
118        // Extremos: quieto en la primera/última key.
119        if t <= keys[0].t || last == 0 {
120            let k = &keys[0];
121            return ActorSample {
122                gx: k.gx,
123                gz: k.gz,
124                facing: k.face.unwrap_or(0.0),
125                clip: k.clip.unwrap_or(Clip::Idle),
126            };
127        }
128        if t >= keys[last].t {
129            let k = &keys[last];
130            return ActorSample {
131                gx: k.gx,
132                gz: k.gz,
133                facing: k.face.unwrap_or_else(|| self.motion_facing(last.saturating_sub(1))),
134                clip: k.clip.unwrap_or(Clip::Idle),
135            };
136        }
137        // Segmento `[i, i+1]` que contiene a `t`.
138        let i = keys.iter().rposition(|k| k.t <= t).unwrap_or(0).min(last - 1);
139        let (a, b) = (&keys[i], &keys[i + 1]);
140        let f = ((t - a.t) / (b.t - a.t).max(1e-6)).clamp(0.0, 1.0);
141        let (dx, dz) = (b.gx - a.gx, b.gz - a.gz);
142        let moving = dx.hypot(dz) > MOVING_EPS;
143        // Rumbo: si el guion lo fija en ambas keys, **gira suave** entre ambas
144        // (por el camino más corto); si sólo en la de salida, lo mantiene; si en
145        // ninguna, automático (dirección de marcha, o la última al detenerse).
146        let facing = match (a.face, b.face) {
147            (Some(fa), Some(fb)) => fa + wrap_pi(fb - fa) * f,
148            (Some(fa), None) => fa,
149            (None, _) if moving => dx.atan2(dz), // yaw=0 → +Z, como Actor::face_towards
150            (None, _) => self.motion_facing(i),
151        };
152        ActorSample {
153            gx: a.gx + dx * f,
154            gz: a.gz + dz * f,
155            clip: a.clip.unwrap_or(if moving { Clip::Walk } else { Clip::Idle }),
156            facing,
157        }
158    }
159
160    /// Rumbo del último segmento *con movimiento* hasta el índice `i` (para
161    /// mantener la orientación cuando el actor está detenido); `0` si nunca se
162    /// movió.
163    fn motion_facing(&self, i: usize) -> f32 {
164        for j in (0..=i).rev() {
165            if j + 1 < self.keys.len() {
166                let (a, b) = (&self.keys[j], &self.keys[j + 1]);
167                let (dx, dz) = (b.gx - a.gx, b.gz - a.gz);
168                if dx.hypot(dz) > MOVING_EPS {
169                    return dx.atan2(dz);
170                }
171            }
172        }
173        0.0
174    }
175}
176
177/// Un **plano** de cámara: una [`CameraTrack`] que arranca en `start` (segundos
178/// absolutos de la secuencia). Sus keys son **relativas** al plano (`t=0` =
179/// inicio del plano), así un plano es autocontenido y reubicable.
180#[derive(Debug, Clone)]
181pub struct Shot {
182    pub start: f32,
183    pub track: CameraTrack,
184}
185
186impl Shot {
187    pub fn new(start: f32, track: CameraTrack) -> Self {
188        Self { start, track }
189    }
190}
191
192/// El **guion completo**: el reparto (un [`ActorScript`] por actor), los planos
193/// de cámara (con cortes duros entre ellos) y la duración total. Reproducir =
194/// para cada `t`: `camera(t)` + `actor.sample(t)` por actor.
195#[derive(Debug, Clone, Default)]
196pub struct Sequence {
197    pub actors: Vec<ActorScript>,
198    shots: Vec<Shot>,
199    pub duration: f32,
200}
201
202impl Sequence {
203    /// Crea la secuencia (ordena los planos por inicio).
204    pub fn new(actors: Vec<ActorScript>, mut shots: Vec<Shot>, duration: f32) -> Self {
205        shots.sort_by(|a, b| a.start.total_cmp(&b.start));
206        Self { actors, shots, duration }
207    }
208
209    /// Cantidad de cuadros a `fps` para la duración total.
210    pub fn frames(&self, fps: u32) -> u32 {
211        (self.duration * fps as f32).round() as u32
212    }
213
214    /// La cámara en `t`: el plano activo (el último cuyo `start ≤ t`) muestreado a
215    /// `t − start`. El salto entre planos es un **corte duro** (sin interpolar).
216    pub fn camera(&self, t: f32) -> Camera3d {
217        let shot = self
218            .shots
219            .iter()
220            .rev()
221            .find(|s| s.start <= t)
222            .or_else(|| self.shots.first());
223        match shot {
224            Some(s) => s.track.sample(t - s.start),
225            None => Camera3d::default(),
226        }
227    }
228
229    /// Los **"beats del guion"**: los instantes (segundos, ordenados, sin repetir)
230    /// que merecen un acento musical — los **cortes de cámara** (inicio de cada plano
231    /// salvo el primero) y los **gestos** de los actores. Es lo que deja que la banda
232    /// sonora caiga *sobre la acción* en vez de sólo compartir duración. Dos tiempos a
233    /// menos de `EPS` se consideran el mismo acento.
234    pub fn beat_times(&self) -> Vec<f32> {
235        const EPS: f32 = 0.05;
236        let mut ts: Vec<f32> = Vec::new();
237        // Cortes de cámara (el primer plano no es un corte).
238        for s in self.shots.iter().skip(1) {
239            ts.push(s.start);
240        }
241        // Gestos de cada actor.
242        for a in &self.actors {
243            ts.extend(a.emote_onsets());
244        }
245        ts.retain(|&t| t >= 0.0 && t <= self.duration + EPS);
246        ts.sort_by(f32::total_cmp);
247        ts.dedup_by(|a, b| (*a - *b).abs() < EPS);
248        ts
249    }
250
251    /// Posición de grilla (sin altura) del **centroide** del reparto en `t`,
252    /// útil para apuntar la cámara al grupo. `None` si no hay actores.
253    pub fn cast_centroid(&self, t: f32) -> Option<Vec3> {
254        if self.actors.is_empty() {
255            return None;
256        }
257        let mut sx = 0.0;
258        let mut sz = 0.0;
259        for a in &self.actors {
260            let s = a.sample(t);
261            sx += s.gx;
262            sz += s.gz;
263        }
264        let n = self.actors.len() as f32;
265        Some(Vec3::new(sx / n, 0.0, sz / n))
266    }
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272    use std::f32::consts::FRAC_PI_2;
273
274    #[test]
275    fn camina_y_luego_emota() {
276        // De (0,0) a (10,0) en 2 s, después se queda saludando.
277        let s = ActorScript::new(vec![
278            ActorKey::at(0.0, 0.0, 0.0),
279            ActorKey::at(2.0, 10.0, 0.0).play(Clip::Wave).facing(FRAC_PI_2),
280        ]);
281        // A mitad del trayecto: posición media, caminando, mirando a +X.
282        let m = s.sample(1.0);
283        assert!((m.gx - 5.0).abs() < 1e-4);
284        assert_eq!(m.clip, Clip::Walk);
285        assert!((m.facing - FRAC_PI_2).abs() < 1e-4, "mueve en +X → yaw=π/2");
286        // Al final: quieto, saludando, mirando a donde dijimos.
287        let e = s.sample(3.0);
288        assert_eq!((e.gx, e.clip), (10.0, Clip::Wave));
289        assert!((e.facing - FRAC_PI_2).abs() < 1e-4);
290    }
291
292    #[test]
293    fn beats_del_guion_son_cortes_y_gestos() {
294        use llimphi_3d::CamKey;
295        // Actor que camina y a los 3 s saluda; otro que a los 4 s señala.
296        let a1 = ActorScript::new(vec![
297            ActorKey::at(0.0, 0.0, 0.0),
298            ActorKey::at(3.0, 5.0, 0.0).play(Clip::Wave),
299        ]);
300        let a2 = ActorScript::new(vec![
301            ActorKey::at(0.0, 2.0, 0.0),
302            ActorKey::at(4.0, 6.0, 0.0).play(Clip::Point),
303        ]);
304        let s0 = CameraTrack::new(vec![CamKey::look(0.0, Vec3::ZERO, Vec3::Z, 50.0)]);
305        let s1 = CameraTrack::new(vec![CamKey::look(0.0, Vec3::Y, Vec3::Z, 50.0)]);
306        // Dos planos: el corte está en 2.5 s (el primero no cuenta).
307        let seq = Sequence::new(vec![a1, a2], vec![Shot::new(0.0, s0), Shot::new(2.5, s1)], 5.0);
308        let beats = seq.beat_times();
309        // Esperado: corte 2.5, saludo 3.0, señal 4.0 (ordenados, sin el inicio).
310        assert_eq!(beats.len(), 3);
311        assert!((beats[0] - 2.5).abs() < 1e-4);
312        assert!((beats[1] - 3.0).abs() < 1e-4);
313        assert!((beats[2] - 4.0).abs() < 1e-4);
314    }
315
316    #[test]
317    fn cortes_de_camara_son_duros() {
318        use llimphi_3d::CamKey;
319        let near = CameraTrack::new(vec![CamKey::look(0.0, Vec3::ZERO, Vec3::Z, 50.0)]);
320        let far = CameraTrack::new(vec![CamKey::look(0.0, Vec3::new(100.0, 0.0, 0.0), Vec3::Z, 50.0)]);
321        let seq = Sequence::new(vec![], vec![Shot::new(0.0, near), Shot::new(1.0, far)], 2.0);
322        // Antes del corte: plano cercano (eye en origen).
323        assert!(seq.camera(0.5).eye.x.abs() < 1e-3);
324        // Después del corte (t≥1): plano lejano, sin interpolación intermedia.
325        assert!((seq.camera(1.5).eye.x - 100.0).abs() < 1e-3);
326    }
327}