tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
llimphi-voxel-app/src/film.rs
Historia
Quién tocó qué
crudo
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}