tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ main llimphi-voxel-app/src/film.rs Rust · 673 siq'ikuna · 29.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0) b575c63f1 · Sergio ·

1//! Modo `--film`: *filma* una escena voxel guionada a un video, la rebanada
2//! vertical de la idea "generar escenas controladas, mover personajes y filmar".
3//!
4//! Junta las cuatro piezas nuevas en un pipeline punta a punta, **sin pantalla**:
5//!
6//! 1. un **reparto** de [`Actor`]es (muñecos articulados) que caminan un trayecto
7//!    sobre el terreno (posa la malla por frame con `Renderer3d::set_geometry`);
8//! 2. una **cámara guionada** por [`CameraTrack`] (keyframes interpolados suave);
9//! 3. el render headless de cada cuadro a PNG (reusa [`crate::write_png`]);
10//! 4. el muxeo de la secuencia a video con [`foreign_av::encode_frames`].
11//!
12//! Es determinista (todo en función del índice de frame) → reproducible. La
13//! *dirección* (quién camina a dónde, dónde corta la cámara) vive aquí, en la capa
14//! de contenido; el motor sólo provee cámara/escena/actor genéricos.
15
16use foreign_vox::{VoxModel, Voxel};
17use llimphi_3d::glam::Vec3;
18use llimphi_3d::{
19    Atmosphere, CamKey, Camera3d, CameraTrack, Hud, HudQuad, Renderer3d, Scene3d, VoxelRenderer,
20};
21use llimphi_ui::llimphi_hal::{wgpu, Hal};
22use llimphi_ui::llimphi_raster::peniko::Color;
23use llimphi_ui::llimphi_raster::{vello, Renderer};
24use llimphi_voxel::{
25    forward_h, right_h, Actor, ActorKey, ActorScript, Age, BirthSequence, Clip, Egg, Hatchling,
26    Sequence, Shot,
27};
28
29use crate::world::{World, FMT};
30use crate::{DIM_XZ, SEED};
31
32/// Resolución y cadencia del film (16:9, 30 fps). Por defecto 960×540 @2×; se puede
33/// subir a 720p/1080p/4K por flag (ver [`film_dims`]).
34const W: u32 = 960;
35const H: u32 = 540;
36const FPS: u32 = 30;
37/// Factor de **supersampling** (SSAA): se renderiza a `SS×` y se baja promediando
38/// → antialias de los bordes duros del ray-march. El film/vox lo usan; `--poses`
39/// queda a 1× (su HUD se mide en pixels de pantalla).
40const SS: u32 = 2;
41
42/// Resolución de salida elegida por flag de línea de comandos. Devuelve `(w, h, ss)`
43/// — el ancho/alto **finales** del video y el factor de supersampling. En 4K se baja
44/// `ss` a 1 (un buffer 4K×2 = 8K sería ~250 MB y pocas GPUs lo aguantan); en cine
45/// "real" el ray-march ya queda nítido por la resolución nativa.
46/// - `--4k`            → 3840×2160 @1×
47/// - `--1080`/`--1080p`→ 1920×1080 @2×
48/// - `--720`/`--720p`  → 1280×720  @2×
49/// - (default)         → 960×540   @2×
50fn film_dims() -> (u32, u32, u32) {
51    let args: Vec<String> = std::env::args().collect();
52    let has = |f: &str| args.iter().any(|a| a == f);
53    if has("--4k") {
54        (3840, 2160, 1)
55    } else if has("--1080") || has("--1080p") {
56        (1920, 1080, 2)
57    } else if has("--720") || has("--720p") {
58        (1280, 720, 2)
59    } else {
60        (W, H, SS)
61    }
62}
63/// Carpeta de cuadros y salida del video.
64const FRAME_DIR: &str = "/tmp/voxel_film";
65const OUT: &str = "/tmp/voxel_film.mkv";
66/// Carpeta de cuadros y salida del corto de nacimiento (`--born`).
67const BORN_DIR: &str = "/tmp/voxel_born";
68const BORN_OUT: &str = "/tmp/voxel_born.mkv";
69
70/// Paleta del reparto (piel, remera, pantalón) — tres figuras distinguibles.
71const CAST: [([f32; 3], [f32; 3], [f32; 3]); 3] = [
72    ([0.90, 0.72, 0.58], [0.82, 0.28, 0.26], [0.20, 0.20, 0.28]), // remera roja
73    ([0.86, 0.68, 0.54], [0.22, 0.55, 0.78], [0.18, 0.20, 0.24]), // remera azul
74    ([0.92, 0.78, 0.62], [0.92, 0.80, 0.30], [0.26, 0.22, 0.20]), // remera amarilla
75];
76
77/// Filma el guion ([`screenplay`]) y escribe el video. Reproduce la [`Sequence`]
78/// cuadro a cuadro (determinista): cada actor se posa según su `ActorScript`
79/// (con cross-fade de clips), la cámara sale de los planos con cortes duros.
80pub fn film() {
81    let hal = pollster::block_on(Hal::new(None)).expect("hal");
82    let mut renderer = Renderer::new(&hal).expect("renderer");
83    let mut world = World::build(&hal.device, &hal.queue, DIM_XZ, SEED);
84    world.show_monument(false); // película de personajes: sin el cubo flotante
85
86    let (seq, mut cast) = screenplay(&world);
87    // Un `Renderer3d` por actor (su malla se re-sube cada frame).
88    let mut actor_r: Vec<Renderer3d> = cast.iter().map(|_| Renderer3d::new(&hal.device, FMT)).collect();
89
90    let (w, h, ss) = film_dims();
91    let (ssw, ssh) = (w * ss, h * ss);
92    if (w, h, ss) != (W, H, SS) {
93        eprintln!("film: resolución {w}x{h} @{ss}× (supersampled a {ssw}x{ssh})");
94    }
95    let inter = make_target(&hal, ssw, ssh); // render a ss× para SSAA
96    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
97
98    prepare_dir(FRAME_DIR);
99    let frames = seq.frames(FPS);
100    let dt = 1.0 / FPS as f32;
101    for f in 0..frames {
102        let t = f as f32 / FPS as f32;
103
104        // Cada actor obedece su guion: posición sobre el relieve, rumbo y clip
105        // (el cambio de clip dispara el cross-fade interno del `Actor`).
106        for (a, script) in cast.iter_mut().zip(&seq.actors) {
107            let s = script.sample(t);
108            a.pos = world.ground_at(s.gx as u32, s.gz as u32);
109            a.facing = s.facing;
110            a.set_clip(s.clip);
111            a.advance(dt);
112            // IK de pies: cada pie apoya en SU cota, no los dos en la del centro —
113            // en un desnivel, uno flotaba y el otro se enterraba.
114            a.apoyar_en(|x, z| world.ground_y(x, z));
115        }
116        for (a, r) in cast.iter().zip(actor_r.iter_mut()) {
117            let (v, i) = a.mesh();
118            r.set_geometry(&hal.device, &v, &i);
119            r.set_model(a.model());
120        }
121
122        world.tick(&hal.queue, dt); // la manada de fondo deambula
123        world.animate(t * 0.5); // el monumento gira
124        let camera = seq.camera(t);
125
126        // IK de mirada: los actores siguen la cámara con la cabeza (dentro del rango
127        // creíble) → "conscientes del lente". Re-malla con la cabeza ya orientada.
128        for (a, r) in cast.iter_mut().zip(actor_r.iter_mut()) {
129            a.look_at(Some(camera.eye));
130            // Y el que señala, señala a la cámara: el brazo apunta al lente en vez de
131            // quedarse en el ángulo fijo del clip.
132            a.apuntar_a((a.clip == llimphi_voxel::Clip::Point).then_some(camera.eye));
133            let (v, i) = a.mesh();
134            r.set_geometry(&hal.device, &v, &i);
135        }
136
137        let refs: Vec<&Renderer3d> = actor_r.iter().collect();
138        render_frame(&hal, &mut renderer, &mut world, &camera, &refs, &inter_view, (ssw, ssh));
139        crate::write_png_downsampled(&hal, &inter, ssw, ssh, ss, &frame_path(FRAME_DIR, f));
140        if f % 15 == 0 {
141            eprintln!("film: cuadro {f}/{frames}");
142        }
143    }
144
145    // --- Banda sonora: compone+sintetiza un WAV con takiy para muxear, con acentos
146    // sobre los **beats del guion** (cortes de cámara + gestos) → música sincronizada.
147    let audio = "/tmp/voxel_film.wav";
148    let beats = seq.beat_times();
149    let secs = crate::soundtrack::render_to(audio, &beats);
150    let beat_list: Vec<String> = beats.iter().map(|t| format!("{t:.2}s")).collect();
151    eprintln!(
152        "film: banda sonora {audio} ({secs:.1}s, {} acentos sobre la acción: {})",
153        beats.len(),
154        beat_list.join(", ")
155    );
156
157    // --- Muxeo a video (video AV1 + audio Opus). Si no hay ffmpeg, deja los PNG.
158    let pattern = format!("{FRAME_DIR}/frame_%04d.png");
159    match foreign_av::encode_frames(&pattern, FPS, 30, Some(std::path::Path::new(audio)), OUT) {
160        Ok(()) => eprintln!("film: video escrito {OUT} ({frames} cuadros, {w}x{h}@{FPS}, con audio)"),
161        Err(e) => eprintln!(
162            "film: cuadros en {FRAME_DIR}/ pero ffmpeg falló ({e:?}); \
163             puedes muxear a mano: ffmpeg -framerate {FPS} -i {pattern} -i {audio} -c:v libsvtav1 -c:a libopus {OUT}"
164        ),
165    }
166}
167
168/// Modo `--born`: filma la **secuencia de nacimiento** del corto *"nace en el
169/// desierto"*. Reúne las piezas del corto **sobre terreno real**: un mundo de
170/// desierto ([`World::build_desert`]), un huevo ([`Egg`]) posado sobre la arena, y
171/// la cámara guionada por [`BirthSequence`] que cae del cielo, ve el huevo,
172/// aterriza en el punto de vista del recién nacido y sale a tercera persona
173/// mientras el huevo eclosiona y nace el niño. Determinista (todo en función del
174/// tiempo) → reproducible; muxea a video con banda sonora cuyo acento cae en el
175/// instante del nacimiento.
176pub fn born() {
177    let hal = pollster::block_on(Hal::new(None)).expect("hal");
178    let mut renderer = Renderer::new(&hal).expect("renderer");
179    let mut world = World::build_desert(&hal.device, &hal.queue, DIM_XZ, SEED);
180
181    // El huevo, posado sobre la columna central del desierto, mirando a −Z (de
182    // frente a la cámara de seguimiento, que termina detrás del sujeto).
183    let (cx, cz) = (DIM_XZ / 2, DIM_XZ / 2);
184    let egg_pos = world.ground_at(cx, cz);
185    let mut egg = Egg::new(egg_pos, 1.4, Hatchling::human(Age::Baby));
186    egg.facing = std::f32::consts::PI;
187    egg.shell = [0.95, 0.92, 0.84];
188
189    // La secuencia: caída lenta desde bien alto (muestra el llano del desierto) +
190    // una salida holgada. Total ≈ 5.6 s → calza con la banda sonora.
191    let mut seq = BirthSequence::new(egg);
192    seq.sky_height = 72.0;
193    seq.t_land = 3.0;
194    seq.t_pull = 1.6;
195    seq.follow_distance = 4.5;
196    seq.follow_height = 1.4;
197
198    // Un renderer para el cascarón y otro para el recién nacido (mallas re-subidas).
199    let mut egg_r = Renderer3d::new(&hal.device, FMT);
200    let mut baby_r = Renderer3d::new(&hal.device, FMT);
201    let mut newborn = seq.newborn();
202    newborn.set_clip(Clip::Idle);
203
204    let (w, h, ss) = film_dims();
205    let (ssw, ssh) = (w * ss, h * ss);
206    if (w, h, ss) != (W, H, SS) {
207        eprintln!("born: resolución {w}x{h} @{ss}× (supersampled a {ssw}x{ssh})");
208    }
209    let inter = make_target(&hal, ssw, ssh);
210    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
211
212    prepare_dir(BORN_DIR);
213    let dur = seq.duration();
214    let frames = (dur * FPS as f32).ceil() as u32;
215    let dt = 1.0 / FPS as f32;
216    // El bebé se dibuja recién cuando la cámara salió del sujeto: durante el
217    // aterrizaje la cámara está *dentro* de su cabeza (1ª persona) y veríamos sus
218    // caras internas.
219    let show_baby_from = seq.t_land + 0.3 * seq.t_pull;
220
221    for f in 0..frames {
222        let t = f as f32 / FPS as f32;
223        let camera = seq.camera(t);
224
225        // Cascarón: el progreso de eclosión lo guiona la secuencia (se abre al
226        // aterrizar).
227        egg.hatch = seq.hatch(t);
228        let (ev, ei) = egg.mesh();
229        egg_r.set_geometry(&hal.device, &ev, &ei);
230        egg_r.set_model(egg.model());
231
232        let mut meshes: Vec<&Renderer3d> = vec![&egg_r];
233        if t >= show_baby_from {
234            // Emerge: sale del cascarón hacia un **costado** (si no, el cuenco —entre
235            // la cámara de seguimiento y el niño— lo taparía) caminando mientras dura
236            // la salida, luego queda quieto. Sigue el lente con la cabeza.
237            let e = ((t - show_baby_from) / (seq.t_pull + 0.5)).clamp(0.0, 1.0);
238            let step = right_h(seq.egg.facing) * (e * 1.5) + forward_h(seq.egg.facing) * (e * 0.5);
239            newborn.pos = seq.egg.pos + step;
240            newborn.set_clip(if e < 1.0 { Clip::Walk } else { Clip::Idle });
241            newborn.advance(dt);
242            newborn.look_at(Some(camera.eye));
243            let (bv, bi) = newborn.mesh();
244            baby_r.set_geometry(&hal.device, &bv, &bi);
245            baby_r.set_model(newborn.model());
246            meshes.push(&baby_r);
247        }
248
249        render_frame(&hal, &mut renderer, &mut world, &camera, &meshes, &inter_view, (ssw, ssh));
250        crate::write_png_downsampled(&hal, &inter, ssw, ssh, ss, &frame_path(BORN_DIR, f));
251        if f % 15 == 0 {
252            eprintln!("born: cuadro {f}/{frames}");
253        }
254    }
255
256    // Banda sonora: acentos en el nacimiento (aterrizaje) y al asentarse el plano
257    // de seguimiento → la música cae sobre la acción.
258    let audio = "/tmp/voxel_born.wav";
259    let beats = vec![seq.t_land, seq.t_land + seq.t_pull];
260    let secs = crate::soundtrack::render_to(audio, &beats);
261    eprintln!("born: banda sonora {audio} ({secs:.1}s, acentos en {beats:?})");
262
263    let pattern = format!("{BORN_DIR}/frame_%04d.png");
264    match foreign_av::encode_frames(&pattern, FPS, 30, Some(std::path::Path::new(audio)), BORN_OUT) {
265        Ok(()) => eprintln!("born: video escrito {BORN_OUT} ({frames} cuadros, {w}x{h}@{FPS}, con audio)"),
266        Err(e) => eprintln!(
267            "born: cuadros en {BORN_DIR}/ pero ffmpeg falló ({e:?}); \
268             muxea a mano: ffmpeg -framerate {FPS} -i {pattern} -i {audio} -c:v libsvtav1 -c:a libopus {BORN_OUT}"
269        ),
270    }
271}
272
273/// El **guion** de la película (la "dirección", editable): tres actores entran
274/// caminando por un llano, se detienen en fila, se giran hacia la cámara y cada
275/// uno hace un gesto distinto (saludar / festejar / señalar). La cámara tiene dos
276/// planos con un **corte duro**: un establishing que entra durante la caminata, y
277/// un plano corto que empuja sobre el trío mientras gesticulan. Devuelve la
278/// [`Sequence`] (data) + los [`Actor`]es con su color (estado visual).
279fn screenplay(world: &World) -> (Sequence, Vec<Actor>) {
280    use std::f32::consts::{FRAC_PI_2, PI};
281
282    let span = 16.0_f32;
283    let (gx0, lanes) = find_flat_strip(world, span);
284    let gx0 = gx0 as f32;
285    let gx_start = gx0 + 3.0;
286    let gx_stop = gx0 + span - 3.0;
287    let (t_walk, t_turn, dur) = (2.6_f32, 3.0_f32, 5.6_f32);
288    let emotes = [Clip::Wave, Clip::Cheer, Clip::Point];
289
290    // Estado visual (color) + guion. Trío junto: carriles Z apretados (±2.5)
291    // alrededor del centro de la franja, para que entren los tres sin que el más
292    // cercano se coma el lente.
293    let center_z = lanes[1] as f32;
294    let offsets = [-2.5_f32, 0.0, 2.5];
295    let mut actors = Vec::with_capacity(3);
296    let mut scripts = Vec::with_capacity(3);
297    for ((&off, (skin, shirt, pants)), emote) in offsets.iter().zip(CAST).zip(emotes) {
298        let gzf = center_z + off;
299        let pos = world.ground_at(gx_start as u32, gzf as u32);
300        actors.push(Actor::new(pos, FRAC_PI_2).with_colors(skin, shirt, pants));
301        scripts.push(ActorScript::new(vec![
302            ActorKey::at(0.0, gx_start, gzf),                          // arranca a la izquierda
303            ActorKey::at(t_walk, gx_stop, gzf).facing(FRAC_PI_2),      // camina hasta la marca (mira +X)
304            ActorKey::at(t_turn, gx_stop, gzf).play(emote).facing(PI), // gira a cámara y gesticula
305            ActorKey::at(dur, gx_stop, gzf).play(emote).facing(PI),
306        ]));
307    }
308
309    // Dos focos: el plano 1 mira el punto medio de la caminata; el 2, la marca de
310    // llegada donde gesticulan. Cámara cerca (los actores grandes, sin que el
311    // relieve los oculte), a la altura del pecho.
312    let focus_walk = world.ground_at(((gx_start + gx_stop) * 0.5) as u32, lanes[1]) + Vec3::new(0.0, 1.0, 0.0);
313    let focus_emote = world.ground_at(gx_stop as u32, lanes[1]) + Vec3::new(0.0, 1.0, 0.0);
314    let cut = 2.8_f32;
315    let shot1 = CameraTrack::new(vec![
316        CamKey::look(0.0, focus_walk + Vec3::new(-3.5, 4.5, -13.0), focus_walk, 50.0),
317        CamKey::look(t_walk + 0.2, focus_walk + Vec3::new(-1.0, 3.0, -10.0), focus_walk, 46.0),
318    ]);
319    let shot2 = CameraTrack::new(vec![
320        CamKey::look(0.0, focus_emote + Vec3::new(0.4, 2.8, -11.0), focus_emote, 42.0),
321        CamKey::look(dur - cut, focus_emote + Vec3::new(1.6, 2.3, -9.0), focus_emote, 39.0),
322    ]);
323    let seq = Sequence::new(scripts, vec![Shot::new(0.0, shot1), Shot::new(cut, shot2)], dur);
324    (seq, actors)
325}
326
327/// Modo `--poses`: vuelca **un** PNG (`/tmp/actor_clips.png`) con una fila de
328/// actores, cada uno en un [`Clip`] distinto y **etiquetado** (reusa el texto del
329/// HUD), para verificar de un vistazo la librería de animación. Vista frontal:
330/// los actores miran a la cámara.
331pub fn poses_shot() {
332    let hal = pollster::block_on(Hal::new(None)).expect("hal");
333    let mut renderer = Renderer::new(&hal).expect("renderer");
334    let mut world = World::build(&hal.device, &hal.queue, DIM_XZ, SEED);
335    world.show_monument(false); // no tapar la fila con el cubo flotante
336
337    let clips = [Clip::Idle, Clip::Walk, Clip::Run, Clip::Wave, Clip::Point, Clip::Cheer];
338    let labels = ["IDLE", "WALK", "RUN", "WAVE", "POINT", "CHEER"];
339    let shirts: [[f32; 3]; 6] = [
340        [0.82, 0.28, 0.26],
341        [0.22, 0.55, 0.78],
342        [0.92, 0.80, 0.30],
343        [0.30, 0.70, 0.40],
344        [0.70, 0.40, 0.78],
345        [0.85, 0.50, 0.25],
346    ];
347    let span = 14.0_f32;
348    let (gx0, lanes) = find_flat_strip(&world, span);
349    let gz = lanes[1];
350    let n = clips.len();
351
352    let mut cast: Vec<Actor> = Vec::with_capacity(n);
353    let mut actor_r: Vec<Renderer3d> = Vec::with_capacity(n);
354    for k in 0..n {
355        let gx = gx0 as f32 + (k as f32 + 0.5) * span / n as f32;
356        let pos = world.ground_at(gx as u32, gz);
357        let mut a = Actor::new(pos, std::f32::consts::PI) // mira al -Z (a la cámara)
358            .with_colors([0.88, 0.70, 0.56], shirts[k], [0.18, 0.20, 0.28]);
359        a.set_clip(clips[k]);
360        a.advance(1.0); // una pose representativa (no la inicial neutra)
361        cast.push(a);
362        actor_r.push(Renderer3d::new(&hal.device, FMT));
363    }
364    for (a, r) in cast.iter().zip(actor_r.iter_mut()) {
365        let (v, i) = a.mesh();
366        r.set_geometry(&hal.device, &v, &i);
367        r.set_model(a.model());
368    }
369
370    // Cámara frontal centrada en la fila, cerca y a la altura del pecho.
371    let mid = world.ground_at((gx0 as f32 + span * 0.5) as u32, gz) + Vec3::new(0.0, 0.9, 0.0);
372    let mut camera = Camera3d::orbit(mid, std::f32::consts::PI, 0.16, span * 0.78);
373    camera.fovy_rad = 48_f32.to_radians();
374
375    let inter = make_target(&hal, W, H); // poses queda 1× (HUD en pixels de pantalla)
376    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
377    world.tick(&hal.queue, 0.0);
378    world.animate(0.6);
379
380    renderer
381        .render_to_view(&hal, &vello::Scene::new(), &inter_view, W, H, Color::from_rgba8(0, 0, 0, 255))
382        .expect("base");
383    let mut enc = hal
384        .device
385        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("poses") });
386    let refs: Vec<&Renderer3d> = actor_r.iter().collect();
387    world.render_with(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), &camera, &refs);
388
389    // Etiquetas: proyectar la cabeza de cada actor a pantalla y poner el nombre.
390    let vp = camera.view_proj(W as f32 / H as f32);
391    let mut quads: Vec<HudQuad> = Vec::new();
392    for (a, label) in cast.iter().zip(labels) {
393        let ndc = vp.project_point3(a.pos + Vec3::new(0.0, 2.15, 0.0));
394        let sx = (ndc.x * 0.5 + 0.5) * W as f32;
395        let sy = (1.0 - (ndc.y * 0.5 + 0.5)) * H as f32;
396        let px = 2.0;
397        let tw = HudQuad::text_width(label, px);
398        let (x, y) = (sx - tw * 0.5, sy);
399        quads.push(HudQuad { x: x - 4.0, y: y - 4.0, w: tw + 8.0, h: 7.0 * px + 8.0, color: [0.0, 0.0, 0.0, 0.5] });
400        quads.extend(HudQuad::text(label, x, y, px, [0.95, 0.97, 1.0, 0.96]));
401    }
402    let mut hud = Hud::new(&hal.device, FMT);
403    hud.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), &quads);
404
405    hal.queue.submit(std::iter::once(enc.finish()));
406    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
407    crate::write_png(&hal, &inter, W, H, "/tmp/actor_clips.png");
408    eprintln!("poses: /tmp/actor_clips.png ({n} clips)");
409}
410
411/// Modo `--vox`: prueba el **puente MagicaVoxel** punta a punta. Genera un modelo
412/// reconocible (un golem), lo **escribe** a `.vox` (`foreign_vox::write`), lo
413/// **reimporta** (`llimphi_voxel::load_grid` → bytes → `VoxModel` → `VoxelGrid`)
414/// y lo renderiza orbitando a `/tmp/vox_import.png`. Así el PNG demuestra el
415/// camino real de carga sobre bytes `.vox` codificados a spec.
416pub fn vox_shot() {
417    let hal = pollster::block_on(Hal::new(None)).expect("hal");
418    let mut renderer = Renderer::new(&hal).expect("renderer");
419
420    let model = golem_model();
421    let path = "/tmp/vox_demo.vox";
422    std::fs::write(path, foreign_vox::write(&model)).expect("escribir .vox");
423    let grid = llimphi_voxel::load_grid(path).expect("importar .vox");
424    let dim = grid.dim();
425
426    let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
427    vr.sun_dir = [0.45, 0.8, -0.4]; // sol al frente (ilumina la cara hacia cámara)
428    vr.atmosphere = Atmosphere {
429        sky_zenith: [60, 110, 190],
430        sky_horizon: [202, 216, 236],
431        fog_density: 0.0, // modelo chico: sin niebla
432        god_rays: 0.0,
433    };
434    let mut scene = Scene3d::new();
435
436    let d = dim.iter().copied().max().unwrap_or(16) as f32;
437    // Vista 3/4 frontal (yaw ~205° mira la cara con los ojos, levemente de costado).
438    let camera = Camera3d::orbit(Vec3::ZERO, 205_f32.to_radians(), 12_f32.to_radians(), d * 1.9);
439
440    let (w, h, ss) = film_dims();
441    let (ssw, ssh) = (w * ss, h * ss);
442    let inter = make_target(&hal, ssw, ssh); // ss× para SSAA
443    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
444    renderer
445        .render_to_view(&hal, &vello::Scene::new(), &inter_view, ssw, ssh, Color::from_rgba8(0, 0, 0, 255))
446        .expect("base");
447    let mut enc = hal
448        .device
449        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("vox") });
450    scene.render(&hal.device, &hal.queue, &mut enc, &inter_view, (ssw, ssh), &camera, Some(&vr), &[]);
451    hal.queue.submit(std::iter::once(enc.finish()));
452    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
453    crate::write_png_downsampled(&hal, &inter, ssw, ssh, ss, "/tmp/vox_import.png");
454    eprintln!(
455        "vox: {} ({} voxels) → {path} → /tmp/vox_import.png (grid {}x{}x{})",
456        "golem",
457        model.voxels.len(),
458        dim[0],
459        dim[1],
460        dim[2]
461    );
462}
463
464/// Un golem voxel de demo (espacio `.vox`, `z` arriba): piernas, torso, brazos,
465/// cabeza con ojos y una antena — pensado para leerse como un **asset diseñado**
466/// (no terreno) al importarlo.
467fn golem_model() -> VoxModel {
468    let mut m = VoxModel::new([11, 7, 17]);
469    // Paleta: cuerpo teal, cabeza gris, ojos oscuros, piernas azul, antena naranja.
470    m.palette[1] = [60, 150, 140, 255];
471    m.palette[2] = [205, 208, 214, 255];
472    m.palette[3] = [25, 28, 34, 255];
473    m.palette[4] = [40, 60, 110, 255];
474    m.palette[5] = [232, 130, 40, 255];
475
476    let v = &mut m.voxels;
477    fill_box(v, 2..4, 2..5, 0..4, 4); // pierna izq
478    fill_box(v, 7..9, 2..5, 0..4, 4); // pierna der
479    fill_box(v, 2..9, 1..6, 4..10, 1); // torso
480    fill_box(v, 0..2, 2..5, 5..10, 1); // brazo izq
481    fill_box(v, 9..11, 2..5, 5..10, 1); // brazo der
482    fill_box(v, 3..8, 1..6, 10..15, 2); // cabeza
483    v.push(Voxel { x: 4, y: 0, z: 12, i: 3 }); // ojo izq (cara y=0)
484    v.push(Voxel { x: 6, y: 0, z: 12, i: 3 }); // ojo der
485    fill_box(v, 5..6, 3..4, 15..17, 5); // antena
486    m
487}
488
489/// Apila los voxels de una caja `[xr]×[yr]×[zr]` con índice de color `i`.
490fn fill_box(
491    v: &mut Vec<Voxel>,
492    xr: std::ops::Range<u8>,
493    yr: std::ops::Range<u8>,
494    zr: std::ops::Range<u8>,
495    i: u8,
496) {
497    for z in zr.clone() {
498        for y in yr.clone() {
499            for x in xr.clone() {
500                v.push(Voxel { x, y, z, i });
501            }
502        }
503    }
504}
505
506/// Render de un cuadro: limpia la intermedia (base negra; el cielo lo pinta la
507/// atmósfera del voxel en los misses) y compone terreno + monumento + actores
508/// en el pase de [`World::render_with`].
509#[allow(clippy::too_many_arguments)]
510fn render_frame(
511    hal: &Hal,
512    renderer: &mut Renderer,
513    world: &mut World,
514    camera: &llimphi_3d::Camera3d,
515    actors: &[&Renderer3d],
516    inter_view: &wgpu::TextureView,
517    (w, h): (u32, u32),
518) {
519    renderer
520        .render_to_view(hal, &vello::Scene::new(), inter_view, w, h, Color::from_rgba8(0, 0, 0, 255))
521        .expect("base");
522    let mut enc = hal
523        .device
524        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("film") });
525    world.render_with(&hal.device, &hal.queue, &mut enc, inter_view, (w, h), camera, actors);
526    hal.queue.submit(std::iter::once(enc.finish()));
527    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
528}
529
530/// **El paseo** (`--paseo`): una caminata por el mundo completo, grabada.
531///
532/// Existe porque «mostrámelo en vivo» no se contesta con un PNG: lo que hay que
533/// ver es el **anillo cambiando de técnica** mientras uno camina — el ciervo que
534/// era un puñado de cubos a treinta metros y es un animal con cara a diez, el
535/// árbol que se redondea al acercarse, la gente que cruza. Un cuadro fijo no
536/// muestra eso; una caminata sí.
537///
538/// Es el mismo mundo del modo interactivo (`--world`), la misma física de
539/// caminata y el mismo reparto por cuadro. Sale un `.mkv` sin audio.
540pub fn paseo() {
541    const SEGUNDOS: f32 = 9.0;
542    let hal = pollster::block_on(Hal::new(None)).expect("hal");
543    let mut renderer = Renderer::new(&hal).expect("renderer");
544    let mut world = World::build_todo(&hal.device, &hal.queue, DIM_XZ, SEED);
545    world.show_monument(false);
546
547    // Sin supersampling y a 960×540: el paseo es una CAMINATA de nueve segundos,
548    // no un plano de cine. A 1920×1080 con SSAA cada cuadro tardaba cinco segundos
549    // — media hora de render para mirar si un ciervo cambia de técnica.
550    let (w, h, ss) = (W, H, 1u32);
551    let (ssw, ssh) = (w * ss, h * ss);
552    let inter = make_target(&hal, ssw, ssh);
553    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
554    prepare_dir(FRAME_DIR);
555
556    let frames = (SEGUNDOS * FPS as f32) as u32;
557    let dt = 1.0 / FPS as f32;
558    let mut peor = (0usize, 0usize);
559    for f in 0..frames {
560        let t = f as f32 / FPS as f32;
561        // Rumbo que barre despacio: se camina en arco, así el anillo entra y sale
562        // de sujetos distintos en vez de recorrer un pasillo.
563        let yaw = 0.55 + (t * 0.22).sin() * 0.9;
564        let fwd = llimphi_voxel::forward_h(yaw);
565        let ojo = world.step_player(fwd, false, dt);
566        world.tick(&hal.queue, dt);
567        let (n, prims) = world.cuenta_sdf();
568        if prims > peor.1 {
569            peor = (n, prims);
570        }
571        // Mirada: al frente y apenas abajo (el catálogo vive a la altura del pasto).
572        let camera = llimphi_3d::Camera3d {
573            eye: ojo,
574            target: ojo + llimphi_voxel::look_dir(yaw, -0.12),
575            ..llimphi_3d::Camera3d::default()
576        };
577        render_frame(&hal, &mut renderer, &mut world, &camera, &[], &inter_view, (ssw, ssh));
578        crate::write_png_downsampled(&hal, &inter, ssw, ssh, ss, &frame_path(FRAME_DIR, f));
579        if f % 30 == 0 {
580            eprintln!("paseo: cuadro {f}/{frames} — anillo {n} seres / {prims} prims");
581        }
582    }
583    eprintln!(
584        "paseo: pico del anillo {} seres / {} primitivas (de {} / {})",
585        peor.0,
586        peor.1,
587        llimphi_3d::VOXEL_MAX_SERS,
588        llimphi_3d::VOXEL_MAX_SER_PRIMS
589    );
590
591    let salida = "/tmp/voxel_paseo.mkv";
592    let pattern = format!("{FRAME_DIR}/frame_%04d.png");
593    match foreign_av::encode_frames(&pattern, FPS, 30, None, salida) {
594        Ok(()) => eprintln!("paseo: video escrito {salida} ({frames} cuadros, {w}x{h}@{FPS})"),
595        Err(e) => eprintln!(
596            "paseo: no se pudo muxear ({e}); quedan los PNG en {FRAME_DIR}\n\
597             a mano: ffmpeg -framerate {FPS} -i {pattern} -c:v libsvtav1 {salida}"
598        ),
599    }
600}
601
602/// Crea la textura intermedia `w×h` (mismo descriptor que el modo `--shot`).
603fn make_target(hal: &Hal, w: u32, h: u32) -> wgpu::Texture {
604    hal.device.create_texture(&wgpu::TextureDescriptor {
605        label: Some("film-inter"),
606        size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
607        mip_level_count: 1,
608        sample_count: 1,
609        dimension: wgpu::TextureDimension::D2,
610        format: FMT,
611        usage: wgpu::TextureUsages::STORAGE_BINDING
612            | wgpu::TextureUsages::TEXTURE_BINDING
613            | wgpu::TextureUsages::RENDER_ATTACHMENT
614            | wgpu::TextureUsages::COPY_SRC,
615        view_formats: &[],
616    })
617}
618
619/// Ruta del cuadro `f` (`frame_0000.png`, …).
620fn frame_path(dir: &str, f: u32) -> String {
621    format!("{dir}/frame_{f:04}.png")
622}
623
624/// Elige el tramo **más plano** del mundo (mínima diferencia de altura a lo largo
625/// de tres carriles Z, todos sobre el nivel del mar): así los actores caminan/
626/// gesticulan en fila sin que el relieve los hunda u ocluya. Si no hay tierra
627/// (mundo todo agua), cae a un default central.
628fn find_flat_strip(world: &World, span: f32) -> (u32, [u32; 3]) {
629    let dy = (DIM_XZ * 4 / 10).max(48) as f32;
630    let land_min = (0.30 - 0.5) * dy + 2.0;
631    let walk = span.ceil() as u32;
632    let dim = DIM_XZ;
633    let (lo, hi) = (12u32, dim.saturating_sub(12));
634    let mut best: Option<(u32, [u32; 3])> = None;
635    let mut best_spread = f32::MAX;
636    for oz in (lo..hi.saturating_sub(10)).step_by(4) {
637        let lanes = [oz, oz + 5, oz + 10];
638        for ox in (lo..hi.saturating_sub(walk)).step_by(4) {
639            let mut lo_y = f32::MAX;
640            let mut hi_y = f32::MIN;
641            let mut land = true;
642            'sample: for &gz in &lanes {
643                for s in 0..=6 {
644                    let gx = ox + walk * s / 6;
645                    let y = world.ground_at(gx, gz).y;
646                    if y < land_min {
647                        land = false;
648                        break 'sample;
649                    }
650                    lo_y = lo_y.min(y);
651                    hi_y = hi_y.max(y);
652                }
653            }
654            if land && (hi_y - lo_y) < best_spread {
655                best_spread = hi_y - lo_y;
656                best = Some((ox, lanes));
657            }
658        }
659    }
660    best.unwrap_or((dim / 3, [dim / 2 - 5, dim / 2, dim / 2 + 5]))
661}
662
663/// Asegura `dir` creado y vacío de PNGs viejos.
664fn prepare_dir(dir: &str) {
665    let _ = std::fs::create_dir_all(dir);
666    if let Ok(rd) = std::fs::read_dir(dir) {
667        for e in rd.flatten() {
668            if e.path().extension().is_some_and(|x| x == "png") {
669                let _ = std::fs::remove_file(e.path());
670            }
671        }
672    }
673}