tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ 92dd0e5bfb2ca4456006da99437f4100e156d856
llimphi-voxel/src/director.rs
Historia
Quién tocó qué
crudo
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
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}