tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
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}