tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definitions

⎇ main llimphi-voxel-studio/src/render.rs Rust · 312 lines · 13.5 KB History Who touched what raw

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

1//! **Exportar una escena a video**: reproduce la [`SceneSpec`] cuadro a cuadro
2//! *headless* (con su propio `Hal`, fuera del contexto GPU de la ventana) — posa
3//! los actores y resuelve la cámara del plano vigente igual que el preview en vivo —
4//! vuelca PNGs y los muxea a un `.mkv` con `foreign-av` (ffmpeg). Pensado para correr
5//! en un worker (`Handle::spawn`): es largo y bloqueante.
6
7use std::path::Path;
8
9use llimphi_3d::glam::Vec3;
10use llimphi_ui::llimphi_hal::{wgpu, Hal};
11use llimphi_ui::llimphi_raster::peniko::Color;
12use llimphi_ui::llimphi_raster::{vello, Renderer};
13use llimphi_voxel::{world_dim, MundoRender, Project, SceneSpec, PREVIEW_DIM_XZ};
14use llimphi_voxel_render::Escenario;
15
16use crate::preview::{WorldPreview, FMT};
17
18const W: u32 = 1280;
19const H: u32 = 720;
20const FPS: u32 = 30;
21/// Carpeta de cuadros, banda sonora y archivo de salida.
22const FRAME_DIR: &str = "/tmp/voxel_studio_film";
23const AUDIO: &str = "/tmp/voxel_studio_film.wav";
24const OUT: &str = "/tmp/voxel_studio_film.mkv";
25/// Turntable (vitrina de un mundo girando) — para el GIF del README.
26const TURN_DIR: &str = "/tmp/voxel_studio_turn";
27const TURN_OUT: &str = "/tmp/voxel_studio_turn.mkv";
28
29/// **Turntable de un mundo**: orbita el relieve 360° (sin actores) y muxea a un
30/// `.mkv` — la vitrina del motor voxel para el README. Headless.
31pub fn turntable(mr: &MundoRender) -> Result<String, String> {
32    let hal = pollster::block_on(Hal::new(None)).map_err(|e| format!("gpu: {e:?}"))?;
33    let mut renderer = Renderer::new(&hal).map_err(|e| format!("renderer: {e:?}"))?;
34    let dim = world_dim(PREVIEW_DIM_XZ);
35    let mut preview = WorldPreview::build(&hal.device, &hal.queue, mr, dim, 1, None);
36    let center = Vec3::new(dim[0] as f32 * 0.5, dim[1] as f32 * 0.32, dim[2] as f32 * 0.5);
37
38    let target = make_target(&hal);
39    let view = target.create_view(&wgpu::TextureViewDescriptor::default());
40
41    std::fs::create_dir_all(TURN_DIR).map_err(|e| e.to_string())?;
42    if let Ok(rd) = std::fs::read_dir(TURN_DIR) {
43        for e in rd.flatten() {
44            if e.path().extension().is_some_and(|x| x == "png") {
45                let _ = std::fs::remove_file(e.path());
46            }
47        }
48    }
49
50    const N: u32 = 120; // 4 s @ 30 fps, una vuelta completa
51    let empty = vello::Scene::new();
52    let sky = Color::from_rgba8(150, 186, 224, 255);
53    for f in 0..N {
54        let yaw = (f as f32 / N as f32) * std::f32::consts::TAU + 0.6;
55        let camera = llimphi_3d::Camera3d::orbit(center, yaw, 0.42, dim[0] as f32 * 1.5);
56        renderer
57            .render_to_view(&hal, &empty, &view, W, H, sky)
58            .map_err(|e| format!("clear: {e:?}"))?;
59        let mut enc = hal
60            .device
61            .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("turn") });
62        preview.render(
63            &hal.device, &hal.queue, &mut enc, &view, (W, H), (0.0, 0.0, W as f32, H as f32),
64            &camera,
65        );
66        hal.queue.submit(std::iter::once(enc.finish()));
67        let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
68        write_png(&hal, &target, &format!("{TURN_DIR}/frame_{f:04}.png"));
69    }
70    let pattern = format!("{TURN_DIR}/frame_%04d.png");
71    foreign_av::encode_frames(&pattern, FPS, 30, None, TURN_OUT)
72        .map_err(|e| format!("ffmpeg: {e:?}"))?;
73    Ok(TURN_OUT.to_string())
74}
75
76/// Vuelo sobre un **mundo infinito** — para el GIF del README.
77const FLY_DIR: &str = "/tmp/voxel_studio_fly";
78const FLY_OUT: &str = "/tmp/voxel_studio_fly.mkv";
79
80/// **Flythrough de un mundo infinito**: como el relieve es función pura de mundo,
81/// regenera la ventana en un origen que avanza cada frame (el terreno scrollea sin
82/// fin) mientras la cámara vuela hacia adelante, baja, mirando un poco abajo. La
83/// niebla densa funde el horizonte y los bordes de la ventana. Headless → `.mkv`.
84pub fn flythrough(mr: &MundoRender) -> Result<String, String> {
85    let hal = pollster::block_on(Hal::new(None)).map_err(|e| format!("gpu: {e:?}"))?;
86    let mut renderer = Renderer::new(&hal).map_err(|e| format!("renderer: {e:?}"))?;
87    let dim = [128u32, 56, 128]; // TODO eje ≤128 (tope del VoxelRenderer); la ventana scrollea en Z
88    let mut preview = WorldPreview::build(&hal.device, &hal.queue, mr, dim, 1, None);
89    let target = make_target(&hal);
90    let view = target.create_view(&wgpu::TextureViewDescriptor::default());
91
92    std::fs::create_dir_all(FLY_DIR).map_err(|e| e.to_string())?;
93    if let Ok(rd) = std::fs::read_dir(FLY_DIR) {
94        for e in rd.flatten() {
95            if e.path().extension().is_some_and(|x| x == "png") {
96                let _ = std::fs::remove_file(e.path());
97            }
98        }
99    }
100
101    const N: u32 = 150; // 5 s @ 30 fps
102    let speed = 1.5_f32; // voxels de mundo por frame
103    let fog = 0.55 / dim[2] as f32; // ligera (como el turntable): el relieve se ve nítido
104    let cx = dim[0] / 2;
105    let empty = vello::Scene::new();
106    // Cielo de atardecer cálido detrás de los misses (la atmósfera del voxel lo
107    // pinta encima, pero el clear cálido evita un borde frío si la niebla afloja).
108    let sky = Color::from_rgba8(236, 206, 168, 255);
109
110    for f in 0..N {
111        let cam_z = f as f32 * speed;
112        // La ventana scrollea (origen avanza en Z) → el mundo fluye hacia el ojo.
113        let oz = cam_z as i32;
114        preview.set_window(&hal.device, &hal.queue, mr, [0, oz], fog);
115        // **Hora dorada**: sol bajo y cálido (sombras largas que rastrillan las dunas
116        // y los cactus) + cielo de atardecer + god rays cruzando la niebla. Es el
117        // sello "anti-Minecraft" del studio, mucho más cinematográfico que el mediodía
118        // plano. Se aplica tras `set_window` (que resetea estas perillas).
119        preview.set_lighting(
120            [0.55, 0.30, -0.18], // sol bajo (y=0.30 → cálido), de costado-atrás
121            llimphi_3d::Atmosphere {
122                sky_zenith: [60, 104, 176],   // azul profundo arriba
123                sky_horizon: [240, 196, 146], // dorado/arena en el horizonte
124                fog_density: 0.85 / dim[2] as f32,
125                god_rays: 0.9,
126            },
127        );
128
129        // Vuelo bajo hacia adelante, ¡EN COORDENADAS CENTRADAS! El shader hace
130        // `ro = cam_eye + dim/2` → espera el volumen centrado en el origen.
131        // Cámara en el centro X, a 1/4 de la ventana en Z, mirando +Z.
132        let cz_grid = dim[2] as f32 * 0.25;
133        let ground = preview.ground_at(cx, cz_grid as u32).y;
134        let half = Vec3::new(dim[0] as f32, dim[1] as f32, dim[2] as f32) * 0.5;
135        let eye = Vec3::new(dim[0] as f32 * 0.5, ground + 14.0, cz_grid) - half;
136        let mut camera = llimphi_3d::Camera3d::fly(eye, 0.0, -0.28); // mira +Z, ~16° abajo
137        camera.fovy_rad = 64_f32.to_radians();
138
139        renderer
140            .render_to_view(&hal, &empty, &view, W, H, sky)
141            .map_err(|e| format!("clear: {e:?}"))?;
142        let mut enc = hal
143            .device
144            .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("fly") });
145        preview.render(
146            &hal.device, &hal.queue, &mut enc, &view, (W, H), (0.0, 0.0, W as f32, H as f32),
147            &camera,
148        );
149        hal.queue.submit(std::iter::once(enc.finish()));
150        let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
151        write_png(&hal, &target, &format!("{FLY_DIR}/frame_{f:04}.png"));
152    }
153    let pattern = format!("{FLY_DIR}/frame_%04d.png");
154    foreign_av::encode_frames(&pattern, FPS, 30, None, FLY_OUT).map_err(|e| format!("ffmpeg: {e:?}"))?;
155    Ok(FLY_OUT.to_string())
156}
157
158/// Textura intermedia W×H para los renders headless.
159fn make_target(hal: &Hal) -> wgpu::Texture {
160    hal.device.create_texture(&wgpu::TextureDescriptor {
161        label: Some("render-target"),
162        size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
163        mip_level_count: 1,
164        sample_count: 1,
165        dimension: wgpu::TextureDimension::D2,
166        format: FMT,
167        usage: wgpu::TextureUsages::STORAGE_BINDING
168            | wgpu::TextureUsages::TEXTURE_BINDING
169            | wgpu::TextureUsages::RENDER_ATTACHMENT
170            | wgpu::TextureUsages::COPY_SRC,
171        view_formats: &[],
172    })
173}
174
175/// Exporta `scene` (de `project`) a un video. Devuelve la ruta del `.mkv` o un
176/// mensaje de error. Bloqueante.
177///
178/// El bucle de cuadro **no vive acá**: es [`Escenario`], en
179/// `llimphi-voxel-render`, y es el mismo que usa el taller de películas para
180/// filmar una toma de voxel dentro de una película de `ende`. Acá quedó lo que
181/// de verdad es del studio: dónde escribir, a qué resolución, y con qué banda.
182pub fn export_scene(project: &Project, scene: &SceneSpec) -> Result<String, String> {
183    let mr = project
184        .render_mundo(scene.mundo)
185        .ok_or("la escena no tiene un mundo de fondo válido")?;
186
187    let hal = pollster::block_on(Hal::new(None)).map_err(|e| format!("gpu: {e:?}"))?;
188    let mut renderer = Renderer::new(&hal).map_err(|e| format!("renderer: {e:?}"))?;
189    let mut escenario = Escenario::preparar(&hal.device, &hal.queue, project, scene)?;
190
191    let target = hal.device.create_texture(&wgpu::TextureDescriptor {
192        label: Some("film-target"),
193        size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
194        mip_level_count: 1,
195        sample_count: 1,
196        dimension: wgpu::TextureDimension::D2,
197        format: FMT,
198        usage: wgpu::TextureUsages::STORAGE_BINDING
199            | wgpu::TextureUsages::TEXTURE_BINDING
200            | wgpu::TextureUsages::RENDER_ATTACHMENT
201            | wgpu::TextureUsages::COPY_SRC,
202        view_formats: &[],
203    });
204    let view = target.create_view(&wgpu::TextureViewDescriptor::default());
205
206    prepare_dir()?;
207    let frames = (scene.duration * FPS as f32).round().max(1.0) as u32;
208    let empty = vello::Scene::new();
209    let sky = Color::from_rgba8(150, 186, 224, 255);
210
211    for f in 0..frames {
212        let t = f as f32 / FPS as f32;
213
214        // Limpiar el target (fondo cielo) antes de componer el mundo.
215        renderer
216            .render_to_view(&hal, &empty, &view, W, H, sky)
217            .map_err(|e| format!("clear: {e:?}"))?;
218
219        let mut enc = hal
220            .device
221            .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("film") });
222        escenario.cuadro(
223            &hal.device, &hal.queue, &mut enc, &view, (W, H), (0.0, 0.0, W as f32, H as f32), t,
224        );
225        hal.queue.submit(std::iter::once(enc.finish()));
226        let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
227
228        write_png(&hal, &target, &format!("{FRAME_DIR}/frame_{f:04}.png"));
229    }
230
231    // Banda sonora compuesta a la medida de la escena (bioma → ánimo, duración →
232    // compases) y sincronizada a los beats del guion (cortes + gestos).
233    let beats = scene.beat_times();
234    crate::soundtrack::render_for_scene(AUDIO, scene, &mr.bioma, mr.seed, &beats);
235
236    let pattern = format!("{FRAME_DIR}/frame_%04d.png");
237    foreign_av::encode_frames(&pattern, FPS, 32, Some(Path::new(AUDIO)), OUT)
238        .map_err(|e| format!("ffmpeg falló ({e:?}); los PNG quedaron en {FRAME_DIR}/"))?;
239    Ok(OUT.to_string())
240}
241
242/// Crea la carpeta de cuadros y borra los PNG viejos (escenas más cortas no deben
243/// dejar cuadros sobrantes que ffmpeg muxearía).
244fn prepare_dir() -> Result<(), String> {
245    std::fs::create_dir_all(FRAME_DIR).map_err(|e| e.to_string())?;
246    if let Ok(rd) = std::fs::read_dir(FRAME_DIR) {
247        for e in rd.flatten() {
248            if e.path().extension().is_some_and(|x| x == "png") {
249                let _ = std::fs::remove_file(e.path());
250            }
251        }
252    }
253    Ok(())
254}
255
256/// Lee la textura a CPU y la vuelca como PNG RGBA8.
257fn write_png(hal: &Hal, target: &wgpu::Texture, path: &str) {
258    let unpadded = (W * 4) as usize;
259    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
260    let padded = unpadded.div_ceil(align) * align;
261    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
262        label: Some("readback"),
263        size: (padded * H as usize) as u64,
264        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
265        mapped_at_creation: false,
266    });
267    let mut enc = hal
268        .device
269        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
270    enc.copy_texture_to_buffer(
271        wgpu::TexelCopyTextureInfo {
272            texture: target,
273            mip_level: 0,
274            origin: wgpu::Origin3d::ZERO,
275            aspect: wgpu::TextureAspect::All,
276        },
277        wgpu::TexelCopyBufferInfo {
278            buffer: &buf,
279            layout: wgpu::TexelCopyBufferLayout {
280                offset: 0,
281                bytes_per_row: Some(padded as u32),
282                rows_per_image: Some(H),
283            },
284        },
285        wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
286    );
287    hal.queue.submit(std::iter::once(enc.finish()));
288    let slice = buf.slice(..);
289    let (tx, rx) = std::sync::mpsc::channel();
290    slice.map_async(wgpu::MapMode::Read, move |r| {
291        let _ = tx.send(r);
292    });
293    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
294    rx.recv().unwrap().unwrap();
295    let data = slice.get_mapped_range();
296    let mut pixels = Vec::with_capacity((W * H * 4) as usize);
297    for row in 0..H as usize {
298        let s = row * padded;
299        pixels.extend_from_slice(&data[s..s + unpadded]);
300    }
301    drop(data);
302    buf.unmap();
303
304    use std::io::BufWriter;
305    let Ok(file) = std::fs::File::create(path) else { return };
306    let mut e = png::Encoder::new(BufWriter::new(file), W, H);
307    e.set_color(png::ColorType::Rgba);
308    e.set_depth(png::BitDepth::Eight);
309    if let Ok(mut w) = e.write_header() {
310        let _ = w.write_image_data(&pixels);
311    }
312}