gioser

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 llimphi-voxel-app/README.md Markdown · 283 líneas · 12.2 KB Historia crudo

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1# llimphi-voxel-app — máquina de cine voxel (machinima)
2
3App showcase del motor voxel 3D de Llimphi, convertida en una **máquina de cine
4voxel**: generar escenas controladas, mover personajes y **filmar una película**
5— sin pantalla, de forma determinista, a un video con sonido.
6
7No es un clon de juego: es la prueba de que el motor (`llimphi-3d`) + la capa de
8dinámica (`llimphi-voxel`) alcanzan para *dirigir* una escena. El diseño y el
9porqué están en `01_yachay/dominium/MOTOR-VOXEL.md` §12; este README es la **guía
10práctica**: cómo correrla y cómo escribir tu propia película.
11
12---
13
14## Los modos
15
16```bash
17cargo run -p llimphi-voxel-app --release -- --film    # filma el guion → /tmp/voxel_film.mkv (AV1+Opus, con audio)
18cargo run -p llimphi-voxel-app --release -- --born    # corto "nace en el desierto" → /tmp/voxel_born.mkv (AV1+Opus)
19cargo run -p llimphi-voxel-app --release -- --poses   # catálogo de clips de actor → /tmp/actor_clips.png
20cargo run -p llimphi-voxel-app --release -- --vox     # importa un .vox y lo renderiza → /tmp/vox_import.png
21cargo run -p llimphi-voxel-app --release              # ventana interactiva (orbitar / explorar en 1ª persona)
22mpv /tmp/voxel_film.mkv                                # ver la película
23```
24
25El modo **`--born`** filma la *secuencia de nacimiento*: un mundo de **desierto
26llano** (`World::build_desert`, vía `WorldRecipe::desert` aplanada), un **huevo**
27(`Egg`) posado sobre la arena, y la cámara guionada por `BirthSequence` que **cae
28del cielo**, ve el huevo, **aterriza en el punto de vista** del recién nacido y
29**sale a tercera persona** mientras el huevo eclosiona y el niño emerge hacia un
30costado del cascarón. Banda sonora con el acento en el instante del nacimiento.
31Une las cuatro piezas del corto (creador de mundos · personaje por edad · huevo/
32eclosión · modos de cámara) en un pipeline punta a punta, determinista y sin
33pantalla.
34
35**Resolución de salida** (aplica a `--film`, `--born` y `--vox`): por defecto 960×540; subila con
36un flag extra `--720` / `--1080` / `--4k` — p.ej.
37`… --film --1080` rinde 1920×1080. En 4K el supersampling baja a 1× (a resolución
38nativa el ray-march ya queda nítido); el resto rinde a 2× y baja a la final.
39
40Todo el render de cine es **headless** (no necesita display): se rinde cada
41cuadro a una textura, se baja por supersampling a PNG, y `ffmpeg` los junta.
42
43---
44
45## Arquitectura en capas
46
47```
48                 takiy-core/synth ─ banda sonora (Score → WAV)
49                          │
50 foreign-vox ── .vox ─────┤
51 foreign-av  ── ffmpeg ───┤
52                          ▼
53   app (este crate)  ── screenplay() + soundtrack() + golem_model()
54        │  Sequence (dirección) · Actor (reparto) · World (escenario)
55        ▼
56   llimphi-voxel  ── Actor/Clip · director (ActorScript/Shot/Sequence) · terrain · vox import
57        ▼
58   llimphi-3d     ── Scene3d · Camera3d · CameraTrack · VoxelRenderer · Renderer3d · Hud
59        ▼
60        wgpu (Vulkan/Metal/DX12/GL/WebGPU)
61```
62
63El motor (`llimphi-3d`) es **general** y no sabe de cine; la dirección vive en
64`llimphi-voxel::director` (data) y el **guion concreto** en la app (`film.rs`).
65
66---
67
68## Escribir una película (el guion)
69
70Una película es un [`Sequence`]: un reparto de **guiones de actor**
71(`ActorScript`) + una lista de **planos de cámara** (`Shot`) + la duración. Todo
72es determinista por tiempo: `t` fija el estado de la escena.
73
74### 1. El guion de cada actor — `ActorScript`
75
76Keyframes `(t, posición de grilla, clip?, rumbo?)`. La posición interpola; el
77clip y el rumbo son automáticos si no se fijan (camina si se mueve, mira hacia
78donde va).
79
80```rust
81use llimphi_voxel::{ActorScript, ActorKey, Clip};
82use std::f32::consts::{FRAC_PI_2, PI};
83
84let script = ActorScript::new(vec![
85    ActorKey::at(0.0, 34.0, 50.0),                          // arranca aquí (en grilla)
86    ActorKey::at(2.6, 50.0, 50.0).facing(FRAC_PI_2),        // camina hasta aquí mirando +X
87    ActorKey::at(3.0, 50.0, 50.0).play(Clip::Wave).facing(PI), // gira a cámara y saluda
88    ActorKey::at(5.6, 50.0, 50.0).play(Clip::Wave).facing(PI), // sostiene hasta el final
89]);
90// script.sample(t) → ActorSample { gx, gz, facing, clip }
91```
92
93- **`.facing(yaw)`** en dos keys consecutivas → el actor **gira suave** entre
94  ambas (interpolación por el ángulo más corto). En una sola → mantiene ese rumbo.
95- **`.play(clip)`** fuerza un clip (saludar, festejar…); sin él, el director
96  elige `Walk` si el segmento se mueve o `Idle` si está quieto.
97- Posición en **grilla** (`gx, gz`): la app la posa sobre el relieve con
98  `World::ground_at` cada cuadro.
99
100### 2. Los planos de cámara — `Shot` + `CameraTrack`
101
102Una `CameraTrack` interpola poses `(t, eye, target, fov)` con suavizado; un
103`Shot` la ancla a un instante. Varios planos = **cortes duros** (cambia de plano
104sin interpolar entre ellos).
105
106```rust
107use llimphi_3d::{CameraTrack, CamKey, glam::Vec3};
108use llimphi_voxel::{Sequence, Shot};
109
110let focus = Vec3::new(0.0, 8.0, 0.0); // a dónde mira (mundo, centrado en el origen)
111
112let establishing = CameraTrack::new(vec![
113    CamKey::look(0.0, focus + Vec3::new(-6.0, 9.0, -30.0), focus, 52.0), // grúa lejos
114    CamKey::look(2.8, focus + Vec3::new(-2.0, 3.5, -17.0), focus, 44.0), // entra
115]);
116let closeup = CameraTrack::new(vec![
117    CamKey::look(0.0, focus + Vec3::new(0.0, 2.6, -11.0), focus, 40.0),  // plano corto
118    CamKey::look(2.8, focus + Vec3::new(1.4, 2.1, -9.0), focus, 37.0),   // empuja
119]);
120
121let seq = Sequence::new(
122    vec![script],                                    // reparto
123    vec![Shot::new(0.0, establishing), Shot::new(2.8, closeup)], // corte a los 2.8 s
124    5.6,                                             // duración
125);
126// seq.camera(t) → Camera3d  ·  seq.frames(fps) → nº de cuadros  ·  seq.cast_centroid(t)
127```
128
129Las `CamKey` se construyen **después** de conocer el terreno (la `Y` del foco
130sale de `World::ground_at`), por eso el guion se arma en `screenplay()` con el
131mundo ya construido.
132
133### 3. Reproducir — el bucle de `--film`
134
135```rust
136for f in 0..seq.frames(FPS) {
137    let t = f as f32 / FPS as f32;
138    for (actor, script) in cast.iter_mut().zip(&seq.actors) {
139        let s = script.sample(t);
140        actor.pos = world.ground_at(s.gx as u32, s.gz as u32); // posar sobre el relieve
141        actor.facing = s.facing;
142        actor.set_clip(s.clip);   // dispara el cross-fade si cambió de clip
143        actor.advance(dt);        // avanza la animación
144    }
145    // … subir las mallas de los actores, render con seq.camera(t), volcar PNG …
146}
147```
148
149**El guion canónico vive en `src/film.rs::screenplay()`** — cópialo y editalo
150para tu propia escena.
151
152---
153
154## El reparto — `Actor` y la librería de clips
155
156Un [`Actor`] es un muñeco de cajas articuladas (cabeza/torso/2 brazos/2 piernas)
157que produce una **malla** por cuadro; la app la sube a un `Renderer3d` y la
158compone con el terreno en `Scene3d` (oclusión correcta).
159
160```rust
161use llimphi_voxel::{Actor, Clip};
162use llimphi_3d::glam::Vec3;
163
164let mut a = Actor::new(Vec3::ZERO, 0.0)
165    .with_colors([0.88,0.70,0.56], [0.82,0.28,0.26], [0.18,0.20,0.28]); // piel, remera, pantalón
166a.set_clip(Clip::Run);   // cambia con cross-fade suave (no corta)
167a.advance(dt);           // avanza la fase a la cadencia del clip
168let (verts, idx) = a.mesh();   // malla local de la pose actual
169let model = a.model();         // matriz de ubicación (pos + rumbo)
170```
171
172Clips disponibles: `Idle`, `Walk`, `Run`, `Wave`, `Point`, `Cheer`.
173
174### Agregar un clip nuevo
175
176Un clip es una **función `fase → Pose`** (los ángulos de todas las
177articulaciones). Para sumar una animación, edita `llimphi-voxel/src/actor.rs`:
178
1791. Agrega la variante a `enum Clip`.
1802. Dale una cadencia en `Clip::cadence`.
1813. Devuelve su `Pose` en `Clip::pose` (mezclando `leg_l/r`, `arm_l/r`,
182   `arm_l/r_out` —apertura lateral—, `head_pitch`, `bob`, `lean`).
183
184No se toca el render: `Actor::mesh` hornea cualquier `Pose`. Verificalo con
185`--poses` (vuelca una fila etiquetada con todos los clips).
186
187---
188
189## Importar assets diseñados — `.vox` (MagicaVoxel)
190
191Diseñás un set o personaje en MagicaVoxel y lo metés a la escena:
192
193```rust
194// Un archivo .vox → VoxelGrid del motor (remapea z-arriba → y-arriba):
195let grid = llimphi_voxel::load_grid("mi_modelo.vox")?;
196
197// O componer un set metiendo piezas en un grid existente:
198let model = foreign_vox::parse(&std::fs::read("pieza.vox")?)?[0].clone();
199llimphi_voxel::stamp(&mut grid, &model, [origin_x, origin_y, origin_z]);
200```
201
202`foreign_vox::{parse, write}` también permite **exportar** una escena voxel a
203`.vox` para editarla afuera. El modo `--vox` genera un golem programático
204(`golem_model()`), lo escribe a `.vox` y lo reimporta — el camino real de carga.
205
206> Nota: si el `.vox` no trae chunk `RGBA`, se usa una paleta de fallback (rampa
207> HSV). Los exportes de MagicaVoxel siempre incluyen `RGBA`, así que entran bien.
208
209---
210
211## La banda sonora — `takiy`
212
213`src/soundtrack.rs` compone un `Score` con `takiy-core` (progresión, pads, bajo,
214melodía) y lo sintetiza a WAV con `takiy-synth`:
215
216```rust
217use takiy_core::{Score, Track, ScoreNote, Pitch, Chord, ChordQuality, PitchClass};
218use takiy_synth::{OscRenderer, Renderer, Waveform, Adsr, write_wav};
219
220let mut score = Score::new(86.0); // bpm
221let mut lead = Track::new("lead");
222lead.add(ScoreNote::new(Pitch::from_midi(72).unwrap(), 0.0, 1.0, 78)); // (pitch, start, dur, vel)
223score.add_track(lead);
224
225let buf = OscRenderer { sample_rate: 44_100, waveform: Waveform::Triangle, envelope: Adsr::DEFAULT }
226    .render(&score);
227write_wav(&buf, "/tmp/banda.wav")?;
228```
229
230`--film` muxea ese WAV al video con `foreign_av::encode_frames(pattern, fps, crf,
231Some(audio_path), out)` → pista **Opus**, recortada a la duración del video
232(`-shortest`).
233
234---
235
236## El render (cómo se ve bien sin pantalla)
237
238- **Headless**: cada cuadro se rinde a una textura intermedia y se lee de vuelta
239  a PNG (`crate::write_png_downsampled`); `--film` arma la secuencia
240  `frame_%04d.png` y la pasa a `foreign_av::encode_frames` (AV1 + Opus).
241- **Supersampling (SSAA)**: `--film`/`--vox` renderizan a **2×** y bajan
242  promediando bloques 2×2 → antialias de los bordes duros del ray-march.
243- **Luz con color**: el sol se tiñe por su elevación (cálido al ras → blanco en
244  lo alto) y el ambiente por el color de cielo (rebote frío). Se controla el
245  *mood* moviendo `VoxelRenderer::sun_dir` y `Atmosphere::{sky_zenith, sky_horizon}`.
246- **HUD/texto**: `llimphi_3d::{Hud, HudQuad}` pinta overlays screen-space (mira,
247  texto con fuente bitmap 5×7) — lo usa `--poses` para las etiquetas.
248
249---
250
251## Gotchas (aprendidos filmando)
252
253- **Encuadre del trío**: si los actores están muy separados en Z y la cámara
254  cerca, el del carril cercano se come el lente. Apretá la separación (±2.5) o
255  aleja la cámara (≥9 unidades).
256- **Tierra firme y plana**: el terreno es procedural; `find_flat_strip` (en
257  `film.rs`) elige un llano sobre el nivel del mar para que no caminen sobre el
258  agua ni los hunda el relieve.
259- **El monumento**: el escenario trae un cubo-malla flotante de demo; apagalo con
260  `World::show_monument(false)` cuando filmas personajes.
261- **`sun_dir` y caras a cámara**: el sol ilumina las caras que lo miran; si tu
262  cámara mira la cara opuesta al sol, esa cara queda sólo con ambiente. Orientá
263  `sun_dir` hacia la cámara para un *hero shot*.
264- **`ffmpeg`**: hace falta en el PATH con `libsvtav1` + `libopus`. Sin él, los
265  PNG quedan igual en `/tmp/voxel_film/` y se avisa cómo muxear a mano.
266- **Determinismo**: todo es función de `t` (y de semillas LCG). El mismo guion
267  produce el mismo video — ideal para iterar.
268
269---
270
271## Mapa de archivos
272
273| Archivo | Qué tiene |
274|---|---|
275| `src/main.rs` | flags (`--film/--born/--poses/--vox`), bucle interactivo, `write_png[_downsampled]` |
276| `src/film.rs` | `screenplay()` (el guion), `--film`/`--born`/`--poses`/`--vox`, `golem_model()`, SSAA |
277| `src/world.rs` | escenario: terreno + atmósfera + monumento + manada; `build_desert`, `render_with`, `ground_at` |
278| `src/soundtrack.rs` | la banda sonora (takiy) |
279| `llimphi-voxel/src/{actor,director,vox}.rs` | reparto, timeline, import `.vox` (reusables) |
280| `llimphi-3d/src/{scene,camera,cinema,voxel_renderer,renderer,hud}.rs` | el motor 3D general |
281
282[`Actor`]: ../llimphi-voxel/src/actor.rs
283[`Sequence`]: ../llimphi-voxel/src/director.rs

README.md

llimphi-voxel-app — máquina de cine voxel (machinima)

App showcase del motor voxel 3D de Llimphi, convertida en una máquina de cine voxel: generar escenas controladas, mover personajes y filmar una película — sin pantalla, de forma determinista, a un video con sonido.

No es un clon de juego: es la prueba de que el motor (llimphi-3d) + la capa de dinámica (llimphi-voxel) alcanzan para dirigir una escena. El diseño y el porqué están en 01_yachay/dominium/MOTOR-VOXEL.md §12; este README es la guía práctica: cómo correrla y cómo escribir tu propia película.


Los modos

cargo run -p llimphi-voxel-app --release -- --film    # filma el guion → /tmp/voxel_film.mkv (AV1+Opus, con audio)
cargo run -p llimphi-voxel-app --release -- --born    # corto "nace en el desierto" → /tmp/voxel_born.mkv (AV1+Opus)
cargo run -p llimphi-voxel-app --release -- --poses   # catálogo de clips de actor → /tmp/actor_clips.png
cargo run -p llimphi-voxel-app --release -- --vox     # importa un .vox y lo renderiza → /tmp/vox_import.png
cargo run -p llimphi-voxel-app --release              # ventana interactiva (orbitar / explorar en 1ª persona)
mpv /tmp/voxel_film.mkv                                # ver la película

El modo --born filma la secuencia de nacimiento: un mundo de desierto llano (World::build_desert, vía WorldRecipe::desert aplanada), un huevo (Egg) posado sobre la arena, y la cámara guionada por BirthSequence que cae del cielo, ve el huevo, aterriza en el punto de vista del recién nacido y sale a tercera persona mientras el huevo eclosiona y el niño emerge hacia un costado del cascarón. Banda sonora con el acento en el instante del nacimiento. Une las cuatro piezas del corto (creador de mundos · personaje por edad · huevo/ eclosión · modos de cámara) en un pipeline punta a punta, determinista y sin pantalla.

Resolución de salida (aplica a --film, --born y --vox): por defecto 960×540; subila con un flag extra --720 / --1080 / --4k — p.ej. … --film --1080 rinde 1920×1080. En 4K el supersampling baja a 1× (a resolución nativa el ray-march ya queda nítido); el resto rinde a 2× y baja a la final.

Todo el render de cine es headless (no necesita display): se rinde cada cuadro a una textura, se baja por supersampling a PNG, y ffmpeg los junta.


Arquitectura en capas

                 takiy-core/synth ─ banda sonora (Score → WAV)
                          │
 foreign-vox ── .vox ─────┤
 foreign-av  ── ffmpeg ───┤
                          ▼
   app (este crate)  ── screenplay() + soundtrack() + golem_model()
        │  Sequence (dirección) · Actor (reparto) · World (escenario)
        ▼
   llimphi-voxel  ── Actor/Clip · director (ActorScript/Shot/Sequence) · terrain · vox import
        ▼
   llimphi-3d     ── Scene3d · Camera3d · CameraTrack · VoxelRenderer · Renderer3d · Hud
        ▼
        wgpu (Vulkan/Metal/DX12/GL/WebGPU)

El motor (llimphi-3d) es general y no sabe de cine; la dirección vive en llimphi-voxel::director (data) y el guion concreto en la app (film.rs).


Escribir una película (el guion)

Una película es un Sequence: un reparto de guiones de actor (ActorScript) + una lista de planos de cámara (Shot) + la duración. Todo es determinista por tiempo: t fija el estado de la escena.

1. El guion de cada actor — ActorScript

Keyframes (t, posición de grilla, clip?, rumbo?). La posición interpola; el clip y el rumbo son automáticos si no se fijan (camina si se mueve, mira hacia donde va).

use llimphi_voxel::{ActorScript, ActorKey, Clip};
use std::f32::consts::{FRAC_PI_2, PI};

let script = ActorScript::new(vec![
    ActorKey::at(0.0, 34.0, 50.0),                          // arranca aquí (en grilla)
    ActorKey::at(2.6, 50.0, 50.0).facing(FRAC_PI_2),        // camina hasta aquí mirando +X
    ActorKey::at(3.0, 50.0, 50.0).play(Clip::Wave).facing(PI), // gira a cámara y saluda
    ActorKey::at(5.6, 50.0, 50.0).play(Clip::Wave).facing(PI), // sostiene hasta el final
]);
// script.sample(t) → ActorSample { gx, gz, facing, clip }

2. Los planos de cámara — Shot + CameraTrack

Una CameraTrack interpola poses (t, eye, target, fov) con suavizado; un Shot la ancla a un instante. Varios planos = cortes duros (cambia de plano sin interpolar entre ellos).

use llimphi_3d::{CameraTrack, CamKey, glam::Vec3};
use llimphi_voxel::{Sequence, Shot};

let focus = Vec3::new(0.0, 8.0, 0.0); // a dónde mira (mundo, centrado en el origen)

let establishing = CameraTrack::new(vec![
    CamKey::look(0.0, focus + Vec3::new(-6.0, 9.0, -30.0), focus, 52.0), // grúa lejos
    CamKey::look(2.8, focus + Vec3::new(-2.0, 3.5, -17.0), focus, 44.0), // entra
]);
let closeup = CameraTrack::new(vec![
    CamKey::look(0.0, focus + Vec3::new(0.0, 2.6, -11.0), focus, 40.0),  // plano corto
    CamKey::look(2.8, focus + Vec3::new(1.4, 2.1, -9.0), focus, 37.0),   // empuja
]);

let seq = Sequence::new(
    vec![script],                                    // reparto
    vec![Shot::new(0.0, establishing), Shot::new(2.8, closeup)], // corte a los 2.8 s
    5.6,                                             // duración
);
// seq.camera(t) → Camera3d  ·  seq.frames(fps) → nº de cuadros  ·  seq.cast_centroid(t)

Las CamKey se construyen después de conocer el terreno (la Y del foco sale de World::ground_at), por eso el guion se arma en screenplay() con el mundo ya construido.

3. Reproducir — el bucle de --film

for f in 0..seq.frames(FPS) {
    let t = f as f32 / FPS as f32;
    for (actor, script) in cast.iter_mut().zip(&seq.actors) {
        let s = script.sample(t);
        actor.pos = world.ground_at(s.gx as u32, s.gz as u32); // posar sobre el relieve
        actor.facing = s.facing;
        actor.set_clip(s.clip);   // dispara el cross-fade si cambió de clip
        actor.advance(dt);        // avanza la animación
    }
    // … subir las mallas de los actores, render con seq.camera(t), volcar PNG …
}

El guion canónico vive en src/film.rs::screenplay() — cópialo y editalo para tu propia escena.


El reparto — Actor y la librería de clips

Un Actor es un muñeco de cajas articuladas (cabeza/torso/2 brazos/2 piernas) que produce una malla por cuadro; la app la sube a un Renderer3d y la compone con el terreno en Scene3d (oclusión correcta).

use llimphi_voxel::{Actor, Clip};
use llimphi_3d::glam::Vec3;

let mut a = Actor::new(Vec3::ZERO, 0.0)
    .with_colors([0.88,0.70,0.56], [0.82,0.28,0.26], [0.18,0.20,0.28]); // piel, remera, pantalón
a.set_clip(Clip::Run);   // cambia con cross-fade suave (no corta)
a.advance(dt);           // avanza la fase a la cadencia del clip
let (verts, idx) = a.mesh();   // malla local de la pose actual
let model = a.model();         // matriz de ubicación (pos + rumbo)

Clips disponibles: Idle, Walk, Run, Wave, Point, Cheer.

Agregar un clip nuevo

Un clip es una función fase → Pose (los ángulos de todas las articulaciones). Para sumar una animación, edita llimphi-voxel/src/actor.rs:

  1. Agrega la variante a enum Clip.
  2. Dale una cadencia en Clip::cadence.
  3. Devuelve su Pose en Clip::pose (mezclando leg_l/r, arm_l/r, arm_l/r_out —apertura lateral—, head_pitch, bob, lean).

No se toca el render: Actor::mesh hornea cualquier Pose. Verificalo con --poses (vuelca una fila etiquetada con todos los clips).


Importar assets diseñados — .vox (MagicaVoxel)

Diseñás un set o personaje en MagicaVoxel y lo metés a la escena:

// Un archivo .vox → VoxelGrid del motor (remapea z-arriba → y-arriba):
let grid = llimphi_voxel::load_grid("mi_modelo.vox")?;

// O componer un set metiendo piezas en un grid existente:
let model = foreign_vox::parse(&std::fs::read("pieza.vox")?)?[0].clone();
llimphi_voxel::stamp(&mut grid, &model, [origin_x, origin_y, origin_z]);

foreign_vox::{parse, write} también permite exportar una escena voxel a .vox para editarla afuera. El modo --vox genera un golem programático (golem_model()), lo escribe a .vox y lo reimporta — el camino real de carga.

Nota: si el .vox no trae chunk RGBA, se usa una paleta de fallback (rampa HSV). Los exportes de MagicaVoxel siempre incluyen RGBA, así que entran bien.


La banda sonora — takiy

src/soundtrack.rs compone un Score con takiy-core (progresión, pads, bajo, melodía) y lo sintetiza a WAV con takiy-synth:

use takiy_core::{Score, Track, ScoreNote, Pitch, Chord, ChordQuality, PitchClass};
use takiy_synth::{OscRenderer, Renderer, Waveform, Adsr, write_wav};

let mut score = Score::new(86.0); // bpm
let mut lead = Track::new("lead");
lead.add(ScoreNote::new(Pitch::from_midi(72).unwrap(), 0.0, 1.0, 78)); // (pitch, start, dur, vel)
score.add_track(lead);

let buf = OscRenderer { sample_rate: 44_100, waveform: Waveform::Triangle, envelope: Adsr::DEFAULT }
    .render(&score);
write_wav(&buf, "/tmp/banda.wav")?;

--film muxea ese WAV al video con foreign_av::encode_frames(pattern, fps, crf, Some(audio_path), out) → pista Opus, recortada a la duración del video (-shortest).


El render (cómo se ve bien sin pantalla)


Gotchas (aprendidos filmando)


Mapa de archivos

ArchivoQué tiene
src/main.rsflags (--film/--born/--poses/--vox), bucle interactivo, write_png[_downsampled]
src/film.rsscreenplay() (el guion), --film/--born/--poses/--vox, golem_model(), SSAA
src/world.rsescenario: terreno + atmósfera + monumento + manada; build_desert, render_with, ground_at
src/soundtrack.rsla banda sonora (takiy)
llimphi-voxel/src/{actor,director,vox}.rsreparto, timeline, import .vox (reusables)
llimphi-3d/src/{scene,camera,cinema,voxel_renderer,renderer,hud}.rsel motor 3D general