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//! **Showreel** del estudio de voxels — el vuelo a hora dorada que la ficha de
2//! tawasuyu.net promete («Vuelo sobre un mundo de voxels a hora dorada,
3//! renderizado por el motor propio»).
4//!
5//! No es eye-candy abstracto: el mundo es el terreno procedural REAL de
6//! [`llimphi_voxel::terrain`] y lo pinta el `VoxelRenderer` REAL de
7//! `llimphi-3d` — el mismo ray-march DDA que usa el studio. Lo único que agrega
8//! el reel es coreografía: la cámara ([`Camera3d::fly`]) recorre el paisaje
9//! siguiendo el relieve para no meterse dentro de la roca, mientras el sol baja
10//! del mediodía a la hora dorada y la niebla se espesa con él.
11//!
12//! Es el hermano en movimiento de `terrain_flythrough` (que sigue siendo el
13//! demo corto de M6, con su propia firma): éste respeta la convención de la
14//! casa para showreels, que es lo que `scripts/showreels.sh` sabe correr.
15//!
16//! ```text
17//! cargo run -p llimphi-voxel --example voxel_showreel --release -- [out_dir] [n] [W] [H]
18//! ```
19//! Defaults: `out_dir=showreel_frames_voxel`, `n=240`, `W=1600`, `H=900`.
20//!
21//! Render **headless y determinista**: el frame `i` de `N` sale de `t = i/(N-1)`
22//! y nada más — sin reloj, sin runtime, sin winit. La misma corrida da los
23//! mismos PNG.
24
25use std::fs::{create_dir_all, File};
26use std::io::BufWriter;
27
28use llimphi_3d::glam::Vec3;
29use llimphi_3d::{Atmosphere, Camera3d, VoxelRenderer};
30use llimphi_hal::{wgpu, Hal};
31use llimphi_raster::peniko::Color;
32use llimphi_raster::{vello, Renderer};
33use llimphi_voxel::terrain;
34
35const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
36
37/// Tamaño del mundo en XZ. Más grande = vuelo más largo sin repetirse, y más
38/// caro por frame: 192 es el punto donde el reel entero entra en minutos.
39const DIM_XZ: u32 = 192;
40const SEED: u32 = 1337;
41
42/// Suavizado en los bordes: el reel abre y cierra en negro en vez de cortar.
43fn fade(t: f32) -> f32 {
44 let entra = (t / 0.06).clamp(0.0, 1.0);
45 let sale = ((1.0 - t) / 0.06).clamp(0.0, 1.0);
46 entra * sale
47}
48
49/// Interpolación suave (smoothstep) de `a` a `b`.
50fn mezcla(a: f32, b: f32, k: f32) -> f32 {
51 let k = k.clamp(0.0, 1.0);
52 let k = k * k * (3.0 - 2.0 * k);
53 a + (b - a) * k
54}
55
56fn mezcla_rgb(a: [u8; 3], b: [u8; 3], k: f32) -> [u8; 3] {
57 [
58 mezcla(a[0] as f32, b[0] as f32, k) as u8,
59 mezcla(a[1] as f32, b[1] as f32, k) as u8,
60 mezcla(a[2] as f32, b[2] as f32, k) as u8,
61 ]
62}
63
64fn main() {
65 let mut args = std::env::args().skip(1);
66 let out_dir = args
67 .next()
68 .unwrap_or_else(|| "showreel_frames_voxel".to_string());
69 let n: usize = args.next().and_then(|v| v.parse().ok()).unwrap_or(240);
70 let w: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(1600);
71 let h: u32 = args.next().and_then(|v| v.parse().ok()).unwrap_or(900);
72 create_dir_all(&out_dir).expect("mkdir out_dir");
73
74 let dy: u32 = (DIM_XZ * 4 / 10).max(48);
75 let dim = [DIM_XZ, dy, DIM_XZ];
76
77 let hal = pollster::block_on(Hal::new(None)).expect("hal");
78 let mut renderer = Renderer::new(&hal).expect("renderer");
79
80 let grid = terrain(dim, SEED);
81 let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &grid);
82
83 let destino = hal.device.create_texture(&wgpu::TextureDescriptor {
84 label: Some("showreel-voxel"),
85 size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
86 mip_level_count: 1,
87 sample_count: 1,
88 dimension: wgpu::TextureDimension::D2,
89 format: FMT,
90 usage: wgpu::TextureUsages::STORAGE_BINDING
91 | wgpu::TextureUsages::TEXTURE_BINDING
92 | wgpu::TextureUsages::RENDER_ATTACHMENT
93 | wgpu::TextureUsages::COPY_SRC,
94 view_formats: &[],
95 });
96 let vista = destino.create_view(&wgpu::TextureViewDescriptor::default());
97
98 let (dxf, dyf, dzf) = (dim[0] as f32, dim[1] as f32, dim[2] as f32);
99 let half_z = dzf * 0.5;
100
101 // Altura del terreno (en y de MUNDO) mirando una ventana hacia adelante,
102 // para que el vuelo suba por encima de los picos que vienen y no los corte.
103 let suelo = |gx: f32, gz: f32| -> f32 {
104 let mut hmax = 0u32;
105 let gxi = gx.clamp(0.0, dxf - 1.0) as u32;
106 // 28 celdas de ventana: hay que subir por encima del pico que VIENE, no
107 // del que se tiene debajo, o la cámara se mete adentro de la roca.
108 for dz in 0..28u32 {
109 let gzi = (gz as i32 + dz as i32).clamp(0, dim[2] as i32 - 1) as u32;
110 if let Some(altura) = grid.height_at(gxi, gzi) {
111 hmax = hmax.max(altura);
112 }
113 }
114 hmax as f32 - dyf * 0.5 // grid y → mundo y (grilla centrada en el origen)
115 };
116
117 for i in 0..n {
118 let t = if n > 1 { i as f32 / (n - 1) as f32 } else { 0.0 };
119
120 // ── la hora del día ──────────────────────────────────────────────────
121 // El sol baja del mediodía alto a la hora dorada rasante: el cielo se
122 // enfría arriba y se enciende en el horizonte, y la niebla se espesa.
123 let sol_altura = mezcla(0.62, 0.16, t);
124 vr.sun_dir = [mezcla(0.45, 0.86, t), sol_altura, mezcla(0.55, 0.14, t)];
125 vr.atmosphere = Atmosphere {
126 sky_zenith: mezcla_rgb([64, 118, 196], [38, 62, 128], t),
127 sky_horizon: mezcla_rgb([202, 218, 236], [246, 186, 118], t),
128 fog_density: mezcla(0.78, 1.35, t) / DIM_XZ as f32,
129 god_rays: mezcla(0.0, 0.55, t),
130 };
131
132 // ── el vuelo ────────────────────────────────────────────────────────
133 // Avanza en +Z (yaw=0 mira +Z) describiendo una curva suave en X, y va
134 // bajando: empieza mirando el paisaje entero y termina rasante.
135 // El recorrido se queda adentro del mundo: si la cámara se acerca al
136 // borde de la grilla, el ray-march sale por el costado y el frame se
137 // parte en dos con el vacío del fondo.
138 // Y no lo cruza entero: pasado el borde no hay terreno que mirar, y el
139 // último tercio del reel sería cielo vacío. Medido: con [-0.7, +0.7] el
140 // cierre queda en un cuadro sin mundo.
141 let pz = (-0.45 + 0.75 * t) * half_z;
142 let px = (t * std::f32::consts::PI).sin() * dxf * 0.10;
143 let gx = px + dxf * 0.5;
144 let gz = pz + dzf * 0.5;
145 // El despeje baja poco: rasante queda lindo hasta que un pico se come
146 // el cuadro entero. Medido en metal — con 0.11 el reel termina contra
147 // una pared de roca.
148 let despeje = mezcla(0.30, 0.21, t) * dyf + 8.0;
149 let py = suelo(gx, gz) + despeje;
150 // El giro es corto a propósito: con la cámara mirando muy de costado
151 // el rayo se sale por el borde de la grilla y el vacío del fondo entra
152 // por una esquina del cuadro, como un triángulo liso.
153 let yaw = (px / dxf) * -0.30;
154 // Mirando abajo: con el horizonte a media altura el cuadro es medio
155 // cielo, y lo que se está mostrando es el mundo.
156 let pitch = mezcla(-0.40, -0.28, t);
157 let camera = Camera3d::fly(Vec3::new(px, py, pz), yaw, pitch);
158
159 // ── el frame ────────────────────────────────────────────────────────
160 // El fundido de entrada/salida se hace pintando negro debajo y dejando
161 // que el voxel-pass componga encima: la base vello es el telón.
162 let base = vello::Scene::new();
163 renderer
164 .render_to_view(&hal, &base, &vista, w, h, Color::from_rgba8(0, 0, 0, 255))
165 .expect("render base");
166
167 let mut enc = hal
168 .device
169 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") });
170 vr.render(&hal.device, &hal.queue, &mut enc, &vista, (w, h), &camera);
171 hal.queue.submit(std::iter::once(enc.finish()));
172 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
173
174 let ruta = format!("{out_dir}/frame_{i:04}.png");
175 escribir_png(&hal, &destino, &ruta, w, h, fade(t));
176 }
177
178 eprintln!("voxel_showreel: {n} frames en {out_dir}/ ({w}x{h})");
179}
180
181/// Baja la textura a CPU, aplica el fundido a negro y la escribe como PNG.
182fn escribir_png(hal: &Hal, target: &wgpu::Texture, path: &str, w: u32, h: u32, brillo: f32) {
183 let sin_pad = (w * 4) as usize;
184 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
185 let con_pad = sin_pad.div_ceil(align) * align;
186 let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
187 label: Some("readback"),
188 size: (con_pad * h as usize) as u64,
189 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
190 mapped_at_creation: false,
191 });
192 let mut enc = hal
193 .device
194 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
195 enc.copy_texture_to_buffer(
196 wgpu::TexelCopyTextureInfo {
197 texture: target,
198 mip_level: 0,
199 origin: wgpu::Origin3d::ZERO,
200 aspect: wgpu::TextureAspect::All,
201 },
202 wgpu::TexelCopyBufferInfo {
203 buffer: &buf,
204 layout: wgpu::TexelCopyBufferLayout {
205 offset: 0,
206 bytes_per_row: Some(con_pad as u32),
207 rows_per_image: Some(h),
208 },
209 },
210 wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
211 );
212 hal.queue.submit(std::iter::once(enc.finish()));
213
214 let slice = buf.slice(..);
215 let (tx, rx) = std::sync::mpsc::channel();
216 slice.map_async(wgpu::MapMode::Read, move |r| {
217 let _ = tx.send(r);
218 });
219 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
220 rx.recv().expect("map_async").expect("mapeo");
221
222 let datos = slice.get_mapped_range();
223 let mut pixeles = Vec::with_capacity(sin_pad * h as usize);
224 for fila in 0..h as usize {
225 let desde = fila * con_pad;
226 pixeles.extend_from_slice(&datos[desde..desde + sin_pad]);
227 }
228 drop(datos);
229 buf.unmap();
230
231 if brillo < 0.999 {
232 for px in pixeles.chunks_exact_mut(4) {
233 px[0] = (px[0] as f32 * brillo) as u8;
234 px[1] = (px[1] as f32 * brillo) as u8;
235 px[2] = (px[2] as f32 * brillo) as u8;
236 }
237 }
238
239 let archivo = File::create(path).expect("crear png");
240 let mut cod = png::Encoder::new(BufWriter::new(archivo), w, h);
241 cod.set_color(png::ColorType::Rgba);
242 cod.set_depth(png::BitDepth::Eight);
243 cod.write_header()
244 .expect("cabecera png")
245 .write_image_data(&pixeles)
246 .expect("datos png");
247}