//! **Showreel** del estudio de voxels — el vuelo a hora dorada que la ficha de //! tawasuyu.net promete («Vuelo sobre un mundo de voxels a hora dorada, //! renderizado por el motor propio»). //! //! No es eye-candy abstracto: el mundo es el terreno procedural REAL de //! [`llimphi_voxel::terrain`] y lo pinta el `VoxelRenderer` REAL de //! `llimphi-3d` — el mismo ray-march DDA que usa el studio. Lo único que agrega //! el reel es coreografía: la cámara ([`Camera3d::fly`]) recorre el paisaje //! siguiendo el relieve para no meterse dentro de la roca, mientras el sol baja //! del mediodía a la hora dorada y la niebla se espesa con él. //! //! Es el hermano en movimiento de `terrain_flythrough` (que sigue siendo el //! demo corto de M6, con su propia firma): éste respeta la convención de la //! casa para showreels, que es lo que `scripts/showreels.sh` sabe correr. //! //! ```text //! cargo run -p llimphi-voxel --example voxel_showreel --release -- [out_dir] [n] [W] [H] //! ``` //! Defaults: `out_dir=showreel_frames_voxel`, `n=240`, `W=1600`, `H=900`. //! //! Render **headless y determinista**: el frame `i` de `N` sale de `t = i/(N-1)` //! y nada más — sin reloj, sin runtime, sin winit. La misma corrida da los //! mismos PNG. use std::fs::{create_dir_all, File}; use std::io::BufWriter; use llimphi_3d::glam::Vec3; use llimphi_3d::{Atmosphere, Camera3d, VoxelRenderer}; use llimphi_hal::{wgpu, Hal}; use llimphi_raster::peniko::Color; use llimphi_raster::{vello, Renderer}; use llimphi_voxel::terrain; const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; /// Tamaño del mundo en XZ. Más grande = vuelo más largo sin repetirse, y más /// caro por frame: 192 es el punto donde el reel entero entra en minutos. const DIM_XZ: u32 = 192; const SEED: u32 = 1337; /// Suavizado en los bordes: el reel abre y cierra en negro en vez de cortar. fn fade(t: f32) -> f32 { let entra = (t / 0.06).clamp(0.0, 1.0); let sale = ((1.0 - t) / 0.06).clamp(0.0, 1.0); entra * sale } /// Interpolación suave (smoothstep) de `a` a `b`. fn mezcla(a: f32, b: f32, k: f32) -> f32 { let k = k.clamp(0.0, 1.0); let k = k * k * (3.0 - 2.0 * k); a + (b - a) * k } fn mezcla_rgb(a: [u8; 3], b: [u8; 3], k: f32) -> [u8; 3] { [ mezcla(a[0] as f32, b[0] as f32, k) as u8, mezcla(a[1] as f32, b[1] as f32, k) as u8, mezcla(a[2] as f32, b[2] as f32, k) as u8, ] } fn main() { let mut args = std::env::args().skip(1); let out_dir = args .next() .unwrap_or_else(|| "showreel_frames_voxel".to_string()); let n: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(240); let w: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(1600); let h: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(900); create_dir_all(&out_dir).expect("mkdir out_dir"); let dy: u32 = (DIM_XZ * 4 / 10).max(48); let dim = [DIM_XZ, dy, DIM_XZ]; let hal = pollster::block_on(Hal::new(None)).expect("hal"); let mut renderer = Renderer::new(&hal).expect("renderer"); let grid = terrain(dim, SEED); let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid); let destino = hal.device.create_texture(&wgpu::TextureDescriptor { label: Some("showreel-voxel"), size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: FMT, usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let vista = destino.create_view(&wgpu::TextureViewDescriptor::default()); let (dxf, dyf, dzf) = (dim[0] as f32, dim[1] as f32, dim[2] as f32); let half_z = dzf * 0.5; // Altura del terreno (en y de MUNDO) mirando una ventana hacia adelante, // para que el vuelo suba por encima de los picos que vienen y no los corte. let suelo = |gx: f32, gz: f32| -> f32 { let mut hmax = 0u32; let gxi = gx.clamp(0.0, dxf - 1.0) as u32; // 28 celdas de ventana: hay que subir por encima del pico que VIENE, no // del que se tiene debajo, o la cámara se mete adentro de la roca. for dz in 0..28u32 { let gzi = (gz as i32 + dz as i32).clamp(0, dim[2] as i32 - 1) as u32; if let Some(altura) = grid.height_at(gxi, gzi) { hmax = hmax.max(altura); } } hmax as f32 - dyf * 0.5 // grid y → mundo y (grilla centrada en el origen) }; for i in 0..n { let t = if n > 1 { i as f32 / (n - 1) as f32 } else { 0.0 }; // ── la hora del día ────────────────────────────────────────────────── // El sol baja del mediodía alto a la hora dorada rasante: el cielo se // enfría arriba y se enciende en el horizonte, y la niebla se espesa. let sol_altura = mezcla(0.62, 0.16, t); vr.sun_dir = [mezcla(0.45, 0.86, t), sol_altura, mezcla(0.55, 0.14, t)]; vr.atmosphere = Atmosphere { sky_zenith: mezcla_rgb([64, 118, 196], [38, 62, 128], t), sky_horizon: mezcla_rgb([202, 218, 236], [246, 186, 118], t), fog_density: mezcla(0.78, 1.35, t) / DIM_XZ as f32, god_rays: mezcla(0.0, 0.55, t), }; // ── el vuelo ──────────────────────────────────────────────────────── // Avanza en +Z (yaw=0 mira +Z) describiendo una curva suave en X, y va // bajando: empieza mirando el paisaje entero y termina rasante. // El recorrido se queda adentro del mundo: si la cámara se acerca al // borde de la grilla, el ray-march sale por el costado y el frame se // parte en dos con el vacío del fondo. // Y no lo cruza entero: pasado el borde no hay terreno que mirar, y el // último tercio del reel sería cielo vacío. Medido: con [-0.7, +0.7] el // cierre queda en un cuadro sin mundo. let pz = (-0.45 + 0.75 * t) * half_z; let px = (t * std::f32::consts::PI).sin() * dxf * 0.10; let gx = px + dxf * 0.5; let gz = pz + dzf * 0.5; // El despeje baja poco: rasante queda lindo hasta que un pico se come // el cuadro entero. Medido en metal — con 0.11 el reel termina contra // una pared de roca. let despeje = mezcla(0.30, 0.21, t) * dyf + 8.0; let py = suelo(gx, gz) + despeje; // El giro es corto a propósito: con la cámara mirando muy de costado // el rayo se sale por el borde de la grilla y el vacío del fondo entra // por una esquina del cuadro, como un triángulo liso. let yaw = (px / dxf) * -0.30; // Mirando abajo: con el horizonte a media altura el cuadro es medio // cielo, y lo que se está mostrando es el mundo. let pitch = mezcla(-0.40, -0.28, t); let camera = Camera3d::fly(Vec3::new(px, py, pz), yaw, pitch); // ── el frame ──────────────────────────────────────────────────────── // El fundido de entrada/salida se hace pintando negro debajo y dejando // que el voxel-pass componga encima: la base vello es el telón. let base = vello::Scene::new(); renderer .render_to_view(&hal, &base, &vista, w, h, Color::from_rgba8(0, 0, 0, 255)) .expect("render base"); let mut enc = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") }); vr.render(&hal.device, &hal.queue, &mut enc, &vista, (w, h), &camera); hal.queue.submit(std::iter::once(enc.finish())); let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); let ruta = format!("{out_dir}/frame_{i:04}.png"); escribir_png(&hal, &destino, &ruta, w, h, fade(t)); } eprintln!("voxel_showreel: {n} frames en {out_dir}/ ({w}x{h})"); } /// Baja la textura a CPU, aplica el fundido a negro y la escribe como PNG. fn escribir_png(hal: &Hal, target: &wgpu::Texture, path: &str, w: u32, h: u32, brillo: f32) { let sin_pad = (w * 4) as usize; let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; let con_pad = sin_pad.div_ceil(align) * align; let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { label: Some("readback"), size: (con_pad * h as usize) as u64, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let mut enc = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); enc.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(con_pad as u32), rows_per_image: Some(h), }, }, wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }, ); hal.queue.submit(std::iter::once(enc.finish())); let slice = buf.slice(..); let (tx, rx) = std::sync::mpsc::channel(); slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); rx.recv().expect("map_async").expect("mapeo"); let datos = slice.get_mapped_range(); let mut pixeles = Vec::with_capacity(sin_pad * h as usize); for fila in 0..h as usize { let desde = fila * con_pad; pixeles.extend_from_slice(&datos[desde..desde + sin_pad]); } drop(datos); buf.unmap(); if brillo < 0.999 { for px in pixeles.chunks_exact_mut(4) { px[0] = (px[0] as f32 * brillo) as u8; px[1] = (px[1] as f32 * brillo) as u8; px[2] = (px[2] as f32 * brillo) as u8; } } let archivo = File::create(path).expect("crear png"); let mut cod = png::Encoder::new(BufWriter::new(archivo), w, h); cod.set_color(png::ColorType::Rgba); cod.set_depth(png::BitDepth::Eight); cod.write_header() .expect("cabecera png") .write_image_data(&pixeles) .expect("datos png"); }