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/src/film.rs Rust · 595 líneas · 26.0 KB Historia Quién tocó qué 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//! 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        }
113        for (a, r) in cast.iter().zip(actor_r.iter_mut()) {
114            let (v, i) = a.mesh();
115            r.set_geometry(&hal.device, &v, &i);
116            r.set_model(a.model());
117        }
118
119        world.tick(dt); // la manada de fondo deambula
120        world.animate(t * 0.5); // el monumento gira
121        let camera = seq.camera(t);
122
123        // IK de mirada: los actores siguen la cámara con la cabeza (dentro del rango
124        // creíble) → "conscientes del lente". Re-malla con la cabeza ya orientada.
125        for (a, r) in cast.iter_mut().zip(actor_r.iter_mut()) {
126            a.look_at(Some(camera.eye));
127            let (v, i) = a.mesh();
128            r.set_geometry(&hal.device, &v, &i);
129        }
130
131        let refs: Vec<&Renderer3d> = actor_r.iter().collect();
132        render_frame(&hal, &mut renderer, &mut world, &camera, &refs, &inter_view, (ssw, ssh));
133        crate::write_png_downsampled(&hal, &inter, ssw, ssh, ss, &frame_path(FRAME_DIR, f));
134        if f % 15 == 0 {
135            eprintln!("film: cuadro {f}/{frames}");
136        }
137    }
138
139    // --- Banda sonora: compone+sintetiza un WAV con takiy para muxear, con acentos
140    // sobre los **beats del guion** (cortes de cámara + gestos) → música sincronizada.
141    let audio = "/tmp/voxel_film.wav";
142    let beats = seq.beat_times();
143    let secs = crate::soundtrack::render_to(audio, &beats);
144    let beat_list: Vec<String> = beats.iter().map(|t| format!("{t:.2}s")).collect();
145    eprintln!(
146        "film: banda sonora {audio} ({secs:.1}s, {} acentos sobre la acción: {})",
147        beats.len(),
148        beat_list.join(", ")
149    );
150
151    // --- Muxeo a video (video AV1 + audio Opus). Si no hay ffmpeg, deja los PNG.
152    let pattern = format!("{FRAME_DIR}/frame_%04d.png");
153    match foreign_av::encode_frames(&pattern, FPS, 30, Some(std::path::Path::new(audio)), OUT) {
154        Ok(()) => eprintln!("film: video escrito {OUT} ({frames} cuadros, {w}x{h}@{FPS}, con audio)"),
155        Err(e) => eprintln!(
156            "film: cuadros en {FRAME_DIR}/ pero ffmpeg falló ({e:?}); \
157             puedes muxear a mano: ffmpeg -framerate {FPS} -i {pattern} -i {audio} -c:v libsvtav1 -c:a libopus {OUT}"
158        ),
159    }
160}
161
162/// Modo `--born`: filma la **secuencia de nacimiento** del corto *"nace en el
163/// desierto"*. Reúne las piezas del corto **sobre terreno real**: un mundo de
164/// desierto ([`World::build_desert`]), un huevo ([`Egg`]) posado sobre la arena, y
165/// la cámara guionada por [`BirthSequence`] que cae del cielo, ve el huevo,
166/// aterriza en el punto de vista del recién nacido y sale a tercera persona
167/// mientras el huevo eclosiona y nace el niño. Determinista (todo en función del
168/// tiempo) → reproducible; muxea a video con banda sonora cuyo acento cae en el
169/// instante del nacimiento.
170pub fn born() {
171    let hal = pollster::block_on(Hal::new(None)).expect("hal");
172    let mut renderer = Renderer::new(&hal).expect("renderer");
173    let mut world = World::build_desert(&hal.device, &hal.queue, DIM_XZ, SEED);
174
175    // El huevo, posado sobre la columna central del desierto, mirando a −Z (de
176    // frente a la cámara de seguimiento, que termina detrás del sujeto).
177    let (cx, cz) = (DIM_XZ / 2, DIM_XZ / 2);
178    let egg_pos = world.ground_at(cx, cz);
179    let mut egg = Egg::new(egg_pos, 1.4, Hatchling::human(Age::Baby));
180    egg.facing = std::f32::consts::PI;
181    egg.shell = [0.95, 0.92, 0.84];
182
183    // La secuencia: caída lenta desde bien alto (muestra el llano del desierto) +
184    // una salida holgada. Total ≈ 5.6 s → calza con la banda sonora.
185    let mut seq = BirthSequence::new(egg);
186    seq.sky_height = 72.0;
187    seq.t_land = 3.0;
188    seq.t_pull = 1.6;
189    seq.follow_distance = 4.5;
190    seq.follow_height = 1.4;
191
192    // Un renderer para el cascarón y otro para el recién nacido (mallas re-subidas).
193    let mut egg_r = Renderer3d::new(&hal.device, FMT);
194    let mut baby_r = Renderer3d::new(&hal.device, FMT);
195    let mut newborn = seq.newborn();
196    newborn.set_clip(Clip::Idle);
197
198    let (w, h, ss) = film_dims();
199    let (ssw, ssh) = (w * ss, h * ss);
200    if (w, h, ss) != (W, H, SS) {
201        eprintln!("born: resolución {w}x{h} @{ss}× (supersampled a {ssw}x{ssh})");
202    }
203    let inter = make_target(&hal, ssw, ssh);
204    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
205
206    prepare_dir(BORN_DIR);
207    let dur = seq.duration();
208    let frames = (dur * FPS as f32).ceil() as u32;
209    let dt = 1.0 / FPS as f32;
210    // El bebé se dibuja recién cuando la cámara salió del sujeto: durante el
211    // aterrizaje la cámara está *dentro* de su cabeza (1ª persona) y veríamos sus
212    // caras internas.
213    let show_baby_from = seq.t_land + 0.3 * seq.t_pull;
214
215    for f in 0..frames {
216        let t = f as f32 / FPS as f32;
217        let camera = seq.camera(t);
218
219        // Cascarón: el progreso de eclosión lo guiona la secuencia (se abre al
220        // aterrizar).
221        egg.hatch = seq.hatch(t);
222        let (ev, ei) = egg.mesh();
223        egg_r.set_geometry(&hal.device, &ev, &ei);
224        egg_r.set_model(egg.model());
225
226        let mut meshes: Vec<&Renderer3d> = vec![&egg_r];
227        if t >= show_baby_from {
228            // Emerge: sale del cascarón hacia un **costado** (si no, el cuenco —entre
229            // la cámara de seguimiento y el niño— lo taparía) caminando mientras dura
230            // la salida, luego queda quieto. Sigue el lente con la cabeza.
231            let e = ((t - show_baby_from) / (seq.t_pull + 0.5)).clamp(0.0, 1.0);
232            let step = right_h(seq.egg.facing) * (e * 1.5) + forward_h(seq.egg.facing) * (e * 0.5);
233            newborn.pos = seq.egg.pos + step;
234            newborn.set_clip(if e < 1.0 { Clip::Walk } else { Clip::Idle });
235            newborn.advance(dt);
236            newborn.look_at(Some(camera.eye));
237            let (bv, bi) = newborn.mesh();
238            baby_r.set_geometry(&hal.device, &bv, &bi);
239            baby_r.set_model(newborn.model());
240            meshes.push(&baby_r);
241        }
242
243        render_frame(&hal, &mut renderer, &mut world, &camera, &meshes, &inter_view, (ssw, ssh));
244        crate::write_png_downsampled(&hal, &inter, ssw, ssh, ss, &frame_path(BORN_DIR, f));
245        if f % 15 == 0 {
246            eprintln!("born: cuadro {f}/{frames}");
247        }
248    }
249
250    // Banda sonora: acentos en el nacimiento (aterrizaje) y al asentarse el plano
251    // de seguimiento → la música cae sobre la acción.
252    let audio = "/tmp/voxel_born.wav";
253    let beats = vec![seq.t_land, seq.t_land + seq.t_pull];
254    let secs = crate::soundtrack::render_to(audio, &beats);
255    eprintln!("born: banda sonora {audio} ({secs:.1}s, acentos en {beats:?})");
256
257    let pattern = format!("{BORN_DIR}/frame_%04d.png");
258    match foreign_av::encode_frames(&pattern, FPS, 30, Some(std::path::Path::new(audio)), BORN_OUT) {
259        Ok(()) => eprintln!("born: video escrito {BORN_OUT} ({frames} cuadros, {w}x{h}@{FPS}, con audio)"),
260        Err(e) => eprintln!(
261            "born: cuadros en {BORN_DIR}/ pero ffmpeg falló ({e:?}); \
262             muxea a mano: ffmpeg -framerate {FPS} -i {pattern} -i {audio} -c:v libsvtav1 -c:a libopus {BORN_OUT}"
263        ),
264    }
265}
266
267/// El **guion** de la película (la "dirección", editable): tres actores entran
268/// caminando por un llano, se detienen en fila, se giran hacia la cámara y cada
269/// uno hace un gesto distinto (saludar / festejar / señalar). La cámara tiene dos
270/// planos con un **corte duro**: un establishing que entra durante la caminata, y
271/// un plano corto que empuja sobre el trío mientras gesticulan. Devuelve la
272/// [`Sequence`] (data) + los [`Actor`]es con su color (estado visual).
273fn screenplay(world: &World) -> (Sequence, Vec<Actor>) {
274    use std::f32::consts::{FRAC_PI_2, PI};
275
276    let span = 16.0_f32;
277    let (gx0, lanes) = find_flat_strip(world, span);
278    let gx0 = gx0 as f32;
279    let gx_start = gx0 + 3.0;
280    let gx_stop = gx0 + span - 3.0;
281    let (t_walk, t_turn, dur) = (2.6_f32, 3.0_f32, 5.6_f32);
282    let emotes = [Clip::Wave, Clip::Cheer, Clip::Point];
283
284    // Estado visual (color) + guion. Trío junto: carriles Z apretados (±2.5)
285    // alrededor del centro de la franja, para que entren los tres sin que el más
286    // cercano se coma el lente.
287    let center_z = lanes[1] as f32;
288    let offsets = [-2.5_f32, 0.0, 2.5];
289    let mut actors = Vec::with_capacity(3);
290    let mut scripts = Vec::with_capacity(3);
291    for ((&off, (skin, shirt, pants)), emote) in offsets.iter().zip(CAST).zip(emotes) {
292        let gzf = center_z + off;
293        let pos = world.ground_at(gx_start as u32, gzf as u32);
294        actors.push(Actor::new(pos, FRAC_PI_2).with_colors(skin, shirt, pants));
295        scripts.push(ActorScript::new(vec![
296            ActorKey::at(0.0, gx_start, gzf),                          // arranca a la izquierda
297            ActorKey::at(t_walk, gx_stop, gzf).facing(FRAC_PI_2),      // camina hasta la marca (mira +X)
298            ActorKey::at(t_turn, gx_stop, gzf).play(emote).facing(PI), // gira a cámara y gesticula
299            ActorKey::at(dur, gx_stop, gzf).play(emote).facing(PI),
300        ]));
301    }
302
303    // Dos focos: el plano 1 mira el punto medio de la caminata; el 2, la marca de
304    // llegada donde gesticulan. Cámara cerca (los actores grandes, sin que el
305    // relieve los oculte), a la altura del pecho.
306    let focus_walk = world.ground_at(((gx_start + gx_stop) * 0.5) as u32, lanes[1]) + Vec3::new(0.0, 1.0, 0.0);
307    let focus_emote = world.ground_at(gx_stop as u32, lanes[1]) + Vec3::new(0.0, 1.0, 0.0);
308    let cut = 2.8_f32;
309    let shot1 = CameraTrack::new(vec![
310        CamKey::look(0.0, focus_walk + Vec3::new(-3.5, 4.5, -13.0), focus_walk, 50.0),
311        CamKey::look(t_walk + 0.2, focus_walk + Vec3::new(-1.0, 3.0, -10.0), focus_walk, 46.0),
312    ]);
313    let shot2 = CameraTrack::new(vec![
314        CamKey::look(0.0, focus_emote + Vec3::new(0.4, 2.8, -11.0), focus_emote, 42.0),
315        CamKey::look(dur - cut, focus_emote + Vec3::new(1.6, 2.3, -9.0), focus_emote, 39.0),
316    ]);
317    let seq = Sequence::new(scripts, vec![Shot::new(0.0, shot1), Shot::new(cut, shot2)], dur);
318    (seq, actors)
319}
320
321/// Modo `--poses`: vuelca **un** PNG (`/tmp/actor_clips.png`) con una fila de
322/// actores, cada uno en un [`Clip`] distinto y **etiquetado** (reusa el texto del
323/// HUD), para verificar de un vistazo la librería de animación. Vista frontal:
324/// los actores miran a la cámara.
325pub fn poses_shot() {
326    let hal = pollster::block_on(Hal::new(None)).expect("hal");
327    let mut renderer = Renderer::new(&hal).expect("renderer");
328    let mut world = World::build(&hal.device, &hal.queue, DIM_XZ, SEED);
329    world.show_monument(false); // no tapar la fila con el cubo flotante
330
331    let clips = [Clip::Idle, Clip::Walk, Clip::Run, Clip::Wave, Clip::Point, Clip::Cheer];
332    let labels = ["IDLE", "WALK", "RUN", "WAVE", "POINT", "CHEER"];
333    let shirts: [[f32; 3]; 6] = [
334        [0.82, 0.28, 0.26],
335        [0.22, 0.55, 0.78],
336        [0.92, 0.80, 0.30],
337        [0.30, 0.70, 0.40],
338        [0.70, 0.40, 0.78],
339        [0.85, 0.50, 0.25],
340    ];
341    let span = 14.0_f32;
342    let (gx0, lanes) = find_flat_strip(&world, span);
343    let gz = lanes[1];
344    let n = clips.len();
345
346    let mut cast: Vec<Actor> = Vec::with_capacity(n);
347    let mut actor_r: Vec<Renderer3d> = Vec::with_capacity(n);
348    for k in 0..n {
349        let gx = gx0 as f32 + (k as f32 + 0.5) * span / n as f32;
350        let pos = world.ground_at(gx as u32, gz);
351        let mut a = Actor::new(pos, std::f32::consts::PI) // mira al -Z (a la cámara)
352            .with_colors([0.88, 0.70, 0.56], shirts[k], [0.18, 0.20, 0.28]);
353        a.set_clip(clips[k]);
354        a.advance(1.0); // una pose representativa (no la inicial neutra)
355        cast.push(a);
356        actor_r.push(Renderer3d::new(&hal.device, FMT));
357    }
358    for (a, r) in cast.iter().zip(actor_r.iter_mut()) {
359        let (v, i) = a.mesh();
360        r.set_geometry(&hal.device, &v, &i);
361        r.set_model(a.model());
362    }
363
364    // Cámara frontal centrada en la fila, cerca y a la altura del pecho.
365    let mid = world.ground_at((gx0 as f32 + span * 0.5) as u32, gz) + Vec3::new(0.0, 0.9, 0.0);
366    let mut camera = Camera3d::orbit(mid, std::f32::consts::PI, 0.16, span * 0.78);
367    camera.fovy_rad = 48_f32.to_radians();
368
369    let inter = make_target(&hal, W, H); // poses queda 1× (HUD en pixels de pantalla)
370    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
371    world.tick(0.0);
372    world.animate(0.6);
373
374    renderer
375        .render_to_view(&hal, &vello::Scene::new(), &inter_view, W, H, Color::from_rgba8(0, 0, 0, 255))
376        .expect("base");
377    let mut enc = hal
378        .device
379        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("poses") });
380    let refs: Vec<&Renderer3d> = actor_r.iter().collect();
381    world.render_with(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), &camera, &refs);
382
383    // Etiquetas: proyectar la cabeza de cada actor a pantalla y poner el nombre.
384    let vp = camera.view_proj(W as f32 / H as f32);
385    let mut quads: Vec<HudQuad> = Vec::new();
386    for (a, label) in cast.iter().zip(labels) {
387        let ndc = vp.project_point3(a.pos + Vec3::new(0.0, 2.15, 0.0));
388        let sx = (ndc.x * 0.5 + 0.5) * W as f32;
389        let sy = (1.0 - (ndc.y * 0.5 + 0.5)) * H as f32;
390        let px = 2.0;
391        let tw = HudQuad::text_width(label, px);
392        let (x, y) = (sx - tw * 0.5, sy);
393        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] });
394        quads.extend(HudQuad::text(label, x, y, px, [0.95, 0.97, 1.0, 0.96]));
395    }
396    let mut hud = Hud::new(&hal.device, FMT);
397    hud.render(&hal.device, &hal.queue, &mut enc, &inter_view, (W, H), &quads);
398
399    hal.queue.submit(std::iter::once(enc.finish()));
400    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
401    crate::write_png(&hal, &inter, W, H, "/tmp/actor_clips.png");
402    eprintln!("poses: /tmp/actor_clips.png ({n} clips)");
403}
404
405/// Modo `--vox`: prueba el **puente MagicaVoxel** punta a punta. Genera un modelo
406/// reconocible (un golem), lo **escribe** a `.vox` (`foreign_vox::write`), lo
407/// **reimporta** (`llimphi_voxel::load_grid` → bytes → `VoxModel` → `VoxelGrid`)
408/// y lo renderiza orbitando a `/tmp/vox_import.png`. Así el PNG demuestra el
409/// camino real de carga sobre bytes `.vox` codificados a spec.
410pub fn vox_shot() {
411    let hal = pollster::block_on(Hal::new(None)).expect("hal");
412    let mut renderer = Renderer::new(&hal).expect("renderer");
413
414    let model = golem_model();
415    let path = "/tmp/vox_demo.vox";
416    std::fs::write(path, foreign_vox::write(&model)).expect("escribir .vox");
417    let grid = llimphi_voxel::load_grid(path).expect("importar .vox");
418    let dim = grid.dim();
419
420    let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
421    vr.sun_dir = [0.45, 0.8, -0.4]; // sol al frente (ilumina la cara hacia cámara)
422    vr.atmosphere = Atmosphere {
423        sky_zenith: [60, 110, 190],
424        sky_horizon: [202, 216, 236],
425        fog_density: 0.0, // modelo chico: sin niebla
426        god_rays: 0.0,
427    };
428    let mut scene = Scene3d::new();
429
430    let d = dim.iter().copied().max().unwrap_or(16) as f32;
431    // Vista 3/4 frontal (yaw ~205° mira la cara con los ojos, levemente de costado).
432    let camera = Camera3d::orbit(Vec3::ZERO, 205_f32.to_radians(), 12_f32.to_radians(), d * 1.9);
433
434    let (w, h, ss) = film_dims();
435    let (ssw, ssh) = (w * ss, h * ss);
436    let inter = make_target(&hal, ssw, ssh); // ss× para SSAA
437    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
438    renderer
439        .render_to_view(&hal, &vello::Scene::new(), &inter_view, ssw, ssh, Color::from_rgba8(0, 0, 0, 255))
440        .expect("base");
441    let mut enc = hal
442        .device
443        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("vox") });
444    scene.render(&hal.device, &hal.queue, &mut enc, &inter_view, (ssw, ssh), &camera, Some(&vr), &[]);
445    hal.queue.submit(std::iter::once(enc.finish()));
446    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
447    crate::write_png_downsampled(&hal, &inter, ssw, ssh, ss, "/tmp/vox_import.png");
448    eprintln!(
449        "vox: {} ({} voxels) → {path} → /tmp/vox_import.png (grid {}x{}x{})",
450        "golem",
451        model.voxels.len(),
452        dim[0],
453        dim[1],
454        dim[2]
455    );
456}
457
458/// Un golem voxel de demo (espacio `.vox`, `z` arriba): piernas, torso, brazos,
459/// cabeza con ojos y una antena — pensado para leerse como un **asset diseñado**
460/// (no terreno) al importarlo.
461fn golem_model() -> VoxModel {
462    let mut m = VoxModel::new([11, 7, 17]);
463    // Paleta: cuerpo teal, cabeza gris, ojos oscuros, piernas azul, antena naranja.
464    m.palette[1] = [60, 150, 140, 255];
465    m.palette[2] = [205, 208, 214, 255];
466    m.palette[3] = [25, 28, 34, 255];
467    m.palette[4] = [40, 60, 110, 255];
468    m.palette[5] = [232, 130, 40, 255];
469
470    let v = &mut m.voxels;
471    fill_box(v, 2..4, 2..5, 0..4, 4); // pierna izq
472    fill_box(v, 7..9, 2..5, 0..4, 4); // pierna der
473    fill_box(v, 2..9, 1..6, 4..10, 1); // torso
474    fill_box(v, 0..2, 2..5, 5..10, 1); // brazo izq
475    fill_box(v, 9..11, 2..5, 5..10, 1); // brazo der
476    fill_box(v, 3..8, 1..6, 10..15, 2); // cabeza
477    v.push(Voxel { x: 4, y: 0, z: 12, i: 3 }); // ojo izq (cara y=0)
478    v.push(Voxel { x: 6, y: 0, z: 12, i: 3 }); // ojo der
479    fill_box(v, 5..6, 3..4, 15..17, 5); // antena
480    m
481}
482
483/// Apila los voxels de una caja `[xr]×[yr]×[zr]` con índice de color `i`.
484fn fill_box(
485    v: &mut Vec<Voxel>,
486    xr: std::ops::Range<u8>,
487    yr: std::ops::Range<u8>,
488    zr: std::ops::Range<u8>,
489    i: u8,
490) {
491    for z in zr.clone() {
492        for y in yr.clone() {
493            for x in xr.clone() {
494                v.push(Voxel { x, y, z, i });
495            }
496        }
497    }
498}
499
500/// Render de un cuadro: limpia la intermedia (base negra; el cielo lo pinta la
501/// atmósfera del voxel en los misses) y compone terreno + monumento + actores
502/// en el pase de [`World::render_with`].
503#[allow(clippy::too_many_arguments)]
504fn render_frame(
505    hal: &Hal,
506    renderer: &mut Renderer,
507    world: &mut World,
508    camera: &llimphi_3d::Camera3d,
509    actors: &[&Renderer3d],
510    inter_view: &wgpu::TextureView,
511    (w, h): (u32, u32),
512) {
513    renderer
514        .render_to_view(hal, &vello::Scene::new(), inter_view, w, h, Color::from_rgba8(0, 0, 0, 255))
515        .expect("base");
516    let mut enc = hal
517        .device
518        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("film") });
519    world.render_with(&hal.device, &hal.queue, &mut enc, inter_view, (w, h), camera, actors);
520    hal.queue.submit(std::iter::once(enc.finish()));
521    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
522}
523
524/// Crea la textura intermedia `w×h` (mismo descriptor que el modo `--shot`).
525fn make_target(hal: &Hal, w: u32, h: u32) -> wgpu::Texture {
526    hal.device.create_texture(&wgpu::TextureDescriptor {
527        label: Some("film-inter"),
528        size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
529        mip_level_count: 1,
530        sample_count: 1,
531        dimension: wgpu::TextureDimension::D2,
532        format: FMT,
533        usage: wgpu::TextureUsages::STORAGE_BINDING
534            | wgpu::TextureUsages::TEXTURE_BINDING
535            | wgpu::TextureUsages::RENDER_ATTACHMENT
536            | wgpu::TextureUsages::COPY_SRC,
537        view_formats: &[],
538    })
539}
540
541/// Ruta del cuadro `f` (`frame_0000.png`, …).
542fn frame_path(dir: &str, f: u32) -> String {
543    format!("{dir}/frame_{f:04}.png")
544}
545
546/// Elige el tramo **más plano** del mundo (mínima diferencia de altura a lo largo
547/// de tres carriles Z, todos sobre el nivel del mar): así los actores caminan/
548/// gesticulan en fila sin que el relieve los hunda u ocluya. Si no hay tierra
549/// (mundo todo agua), cae a un default central.
550fn find_flat_strip(world: &World, span: f32) -> (u32, [u32; 3]) {
551    let dy = (DIM_XZ * 4 / 10).max(48) as f32;
552    let land_min = (0.30 - 0.5) * dy + 2.0;
553    let walk = span.ceil() as u32;
554    let dim = DIM_XZ;
555    let (lo, hi) = (12u32, dim.saturating_sub(12));
556    let mut best: Option<(u32, [u32; 3])> = None;
557    let mut best_spread = f32::MAX;
558    for oz in (lo..hi.saturating_sub(10)).step_by(4) {
559        let lanes = [oz, oz + 5, oz + 10];
560        for ox in (lo..hi.saturating_sub(walk)).step_by(4) {
561            let mut lo_y = f32::MAX;
562            let mut hi_y = f32::MIN;
563            let mut land = true;
564            'sample: for &gz in &lanes {
565                for s in 0..=6 {
566                    let gx = ox + walk * s / 6;
567                    let y = world.ground_at(gx, gz).y;
568                    if y < land_min {
569                        land = false;
570                        break 'sample;
571                    }
572                    lo_y = lo_y.min(y);
573                    hi_y = hi_y.max(y);
574                }
575            }
576            if land && (hi_y - lo_y) < best_spread {
577                best_spread = hi_y - lo_y;
578                best = Some((ox, lanes));
579            }
580        }
581    }
582    best.unwrap_or((dim / 3, [dim / 2 - 5, dim / 2, dim / 2 + 5]))
583}
584
585/// Asegura `dir` creado y vacío de PNGs viejos.
586fn prepare_dir(dir: &str) {
587    let _ = std::fs::create_dir_all(dir);
588    if let Ok(rd) = std::fs::read_dir(dir) {
589        for e in rd.flatten() {
590            if e.path().extension().is_some_and(|x| x == "png") {
591                let _ = std::fs::remove_file(e.path());
592            }
593        }
594    }
595}