gioser

tawasuyu/llimphi

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

Rust 484Markdown 258TOML 131RON 38118 Sutichaykuna

⎇ main llimphi-voxel/examples/voxel_showreel.rs Rust · 247 siq'ikuna · 10.6 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0) b575c63f1 · Sergio ·

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}