tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-voxel/examples/terrain_streaming.rs Rust · 352 líneas · 15.5 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! Demo headless de M6 — **streaming toroidal**: una ventana voxel acotada que
2//! se desliza por un mundo procedural **ilimitado** ([`WorldStream`]) re-subiendo
3//! a la GPU **sólo la franja de bricks que entra** (no la ventana entera, ni
4//! reconstruyendo el renderer): la textura del brick pool es un **ring buffer**
5//! (`world_brick mod cdim`) y el shader envuelve la celda lógica con un offset de
6//! origen ([`VoxelRenderer::scroll_to`]).
7//!
8//! La cámara se queda quieta en el **centro** de la ventana mirando adelante; lo
9//! que avanza es el **foco de mundo** (`focus_z`), que marcha mucho más allá del
10//! tamaño de la ventana. Cada cuadro [`WorldStream::follow`] reubica la ventana y
11//! `scroll_to` sube sólo la franja → cada PNG es **paisaje nuevo y distinto** sin
12//! "muro" ni repetición, y el reporte muestra que se suben **KiB**, no MiB.
13//!
14//! **Prueba de paridad**: en el último cuadro se compara el render scrolleado
15//! contra un renderer **reconstruido de cero** en ese mismo origen — deben dar
16//! la **misma imagen** (el toroidal no degrada el contenido). Falla con assert si
17//! divergen.
18//!
19//! `cargo run -p llimphi-voxel --example terrain_streaming --release -- [dim_xz] [seed] [frames]`
20//! → escribe /tmp/m6_stream_##.png
21
22use std::fs::File;
23use std::io::BufWriter;
24
25use llimphi_3d::glam::Vec3;
26use llimphi_3d::{Atmosphere, Camera3d, VoxelGrid, VoxelRenderer};
27use llimphi_hal::{wgpu, Hal};
28use llimphi_raster::peniko::Color;
29use llimphi_raster::{vello, Renderer};
30use llimphi_voxel::{fill_terrain_window, WorldStream};
31
32const W: u32 = 960;
33const H: u32 = 540;
34const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
35
36fn main() {
37    let dim_xz: u32 = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(160);
38    let seed: u32 = std::env::args().nth(2).and_then(|s| s.parse().ok()).unwrap_or(1337);
39    let frames: u32 = std::env::args().nth(3).and_then(|s| s.parse().ok()).unwrap_or(6);
40    let dy: u32 = (dim_xz * 4 / 10).max(48);
41    let dim = [dim_xz, dy, dim_xz];
42
43    let hal = pollster::block_on(Hal::new(None)).expect("hal");
44    let mut renderer = Renderer::new(&hal).expect("renderer");
45
46    // Ventana de mundo (paso = lado de brick). Centro inicial en mundo (0,0).
47    let step = llimphi_3d::VOXEL_BRICK;
48    let mut stream = WorldStream::new(dim, seed, 0, 0, step);
49
50    // El renderer se construye UNA vez, desde un grid en **mundo (0,0)** (donde
51    // `brick_origin = 0` es consistente con el ring buffer: celda física P ⟺
52    // world_brick ≡ P mod cdim). El primer `scroll_to` lo lleva al origen real
53    // del stream; de ahí en más, sólo franjas.
54    let mut zero = VoxelGrid::new(dim);
55    fill_terrain_window(&mut zero, [0, 0], seed);
56    let mut vr = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &zero);
57    vr.sun_dir = [0.55, 0.5, 0.32];
58    vr.atmosphere = Atmosphere {
59        sky_zenith: [64, 118, 196],
60        sky_horizon: [202, 218, 236],
61        fog_density: 0.7 / dim_xz as f32,
62        god_rays: 0.0,
63    };
64    let (_, total_bricks) = vr.brick_usage();
65    let full_pool_kib = vr.memory_bytes().0 / 1024;
66
67    let inter = make_target(&hal);
68    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
69
70    let mut last_pixels: Vec<u8> = Vec::new();
71
72    for i in 0..frames {
73        // El foco de mundo marcha en +Z; en pocos cuadros recorre varias ventanas
74        // (cada PNG es mundo nuevo) pero cada paso entra sólo ~¼ de ventana, así
75        // se ve que el scroll sube una **franja**, no el mundo entero.
76        let focus_z = i as i32 * (dim_xz as i32 / 4);
77        stream.follow(0, focus_z);
78        // Streaming toroidal: sube sólo la franja de bricks que entró.
79        let uploaded = vr.scroll_to(&hal.device, &hal.queue, stream.origin_voxel(), stream.grid());
80
81        // Cámara: sobre los picos de la ventana, atrás, mirando +Z hacia abajo.
82        let camera = camera_for(stream.grid(), dim);
83
84        last_pixels = render_to_pixels(&hal, &mut renderer, &inter, &inter_view, &mut vr, &camera);
85        let out = format!("/tmp/m6_stream_{i:02}.png");
86        encode_png(&last_pixels, W, H, &out);
87
88        let [ox, oz] = stream.origin();
89        let (used, _) = vr.brick_usage();
90        eprintln!(
91            "{out} — foco_z={focus_z}, origen=({ox},{oz}), subido {} KiB de {} KiB de ventana ({}/{} bricks vivos)",
92            uploaded / 1024,
93            full_pool_kib,
94            used,
95            total_bricks,
96        );
97    }
98
99    // --- Paridad: el render scrolleado del último cuadro debe coincidir con un
100    // renderer RECONSTRUIDO de cero en ese mismo origen (mismo contenido lógico).
101    let camera = camera_for(stream.grid(), dim);
102
103    let mut fresh = VoxelRenderer::new(&hal.device, &hal.queue, FMT, stream.grid());
104    fresh.sun_dir = vr.sun_dir;
105    fresh.atmosphere = vr.atmosphere;
106    let fresh_pixels = render_to_pixels(&hal, &mut renderer, &inter, &inter_view, &mut fresh, &camera);
107
108    let (max_d, mean_d) = diff(&last_pixels, &fresh_pixels);
109    encode_png(&fresh_pixels, W, H, "/tmp/m6_stream_fresh.png");
110    eprintln!(
111        "PARIDAD scroll-vs-rebuild: max |Δ|={max_d}, media |Δ|={mean_d:.3} (0 = idéntico) → /tmp/m6_stream_fresh.png"
112    );
113    assert!(
114        max_d <= 2,
115        "el streaming toroidal divergió del rebuild (max |Δ|={max_d})"
116    );
117    eprintln!("PARIDAD OK — el toroidal rinde idéntico al rebuild, subiendo sólo la franja.");
118
119    // --- Pool-grow: arrancar con un pool minúsculo (grid vacío) y scrollear a una
120    // ventana densa **lejana** (sin solape → todo entra, sin bulk stale) fuerza al
121    // brick pool a crecer. La paridad vs un rebuild prueba que creció sin huecos.
122    let far = [dim_xz as i32 * 2, 0, dim_xz as i32 * 2]; // brick-aligned, lejos del origen
123    let mut far_grid = VoxelGrid::new(dim);
124    fill_terrain_window(&mut far_grid, [far[0], far[2]], seed);
125
126    let mut tiny = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &VoxelGrid::new(dim));
127    tiny.sun_dir = vr.sun_dir;
128    tiny.atmosphere = vr.atmosphere;
129    let cap0 = tiny.pool_capacity();
130    tiny.scroll_to(&hal.device, &hal.queue, far, &far_grid);
131    let cap1 = tiny.pool_capacity();
132
133    let cam = camera_for(&far_grid, dim);
134    let tiny_px = render_to_pixels(&hal, &mut renderer, &inter, &inter_view, &mut tiny, &cam);
135    let mut far_fresh = VoxelRenderer::new(&hal.device, &hal.queue, FMT, &far_grid);
136    far_fresh.sun_dir = vr.sun_dir;
137    far_fresh.atmosphere = vr.atmosphere;
138    let far_fresh_px = render_to_pixels(&hal, &mut renderer, &inter, &inter_view, &mut far_fresh, &cam);
139    let (gmax, _) = diff(&tiny_px, &far_fresh_px);
140    encode_png(&tiny_px, W, H, "/tmp/m6_stream_grow.png");
141    eprintln!("POOL-GROW: pool {cap0} → {cap1} slots, render vs rebuild max|Δ|={gmax}");
142    assert!(cap1 > cap0, "el pool no creció (arrancó con capacidad suficiente)");
143    assert!(gmax <= 2, "el pool creció con huecos (max|Δ|={gmax})");
144    eprintln!("POOL-GROW OK — el pool creció y la ventana densa quedó completa, sin huecos.");
145
146    // --- Persistencia de ediciones: un pilar magenta editado sobre la columna de
147    // mundo (0,0) debe seguir ahí tras alejarse miles de voxels y volver (el
148    // terreno se regenera desde la semilla, el overlay de `edits` lo re-aplica).
149    let mut s = WorldStream::new(dim, seed, 0, 0, llimphi_3d::VOXEL_BRICK);
150    let (lx0, lz0) = s.world_to_local(0, 0).unwrap();
151    let gh = s.grid().height_at(lx0, lz0).unwrap_or(dim[1] / 2) as i32;
152    // Torre magenta gruesa (5×5) y alta sobre la columna de mundo (0,0): el
153    // terreno nunca pone magenta, así el píxel es inequívocamente la edición.
154    let top = (gh + 22).min(dim[1] as i32 - 1);
155    for wy in (gh + 1)..top {
156        for dx in -2..=2 {
157            for dz in -2..=2 {
158                s.edit(dx, wy, dz, Some([240, 40, 220]));
159            }
160        }
161    }
162    // Cámara apuntada a la torre (mundo centrado en el origen → columna (0,0) cae
163    // en el centro; `y` de mundo = `y` de grilla − dim_y/2).
164    let half_y = dim[1] as f32 * 0.5;
165    let tower_mid = (gh + 11) as f32 - half_y;
166    let pcam = Camera3d {
167        eye: Vec3::new(14.0, tower_mid + 10.0, -(dim[2] as f32) * 0.32),
168        target: Vec3::new(0.0, tower_mid, 0.0),
169        ..Camera3d::default()
170    };
171    // Se reconstruye el renderer desde `s.grid()` en cada toma: aquí probamos la
172    // PERSISTENCIA (la edición sobrevive el regen), no el upload incremental (ya
173    // verificado arriba). Subir la torre por scroll caería en bricks "bulk" que el
174    // toroidal no re-sube (asume terreno determinista; la edición rompe eso).
175    let mut render_grid = |g: &VoxelGrid| {
176        let mut r = VoxelRenderer::new(&hal.device, &hal.queue, FMT, g);
177        r.sun_dir = vr.sun_dir;
178        r.atmosphere = vr.atmosphere;
179        render_to_pixels(&hal, &mut renderer, &inter, &inter_view, &mut r, &pcam)
180    };
181    let before = render_grid(s.grid());
182    encode_png(&before, W, H, "/tmp/m6_persist_before.png");
183
184    // Alejarse miles de voxels (el terreno se regenera, la torre sale de ventana)
185    // y volver al origen (se regenera de nuevo + overlay re-aplica la torre).
186    s.follow(4000, 4000);
187    s.follow(0, 0);
188    let after = render_grid(s.grid());
189    encode_png(&after, W, H, "/tmp/m6_persist_after.png");
190
191    // Conteo de píxeles magenta (la torre) en ambas tomas: deben ser ~iguales.
192    // Magenta = rojo y azul ambos claramente por encima del verde (robusto a la
193    // luz coloreada/AO, que baja los valores absolutos pero no esa relación).
194    let magenta = |px: &[u8]| -> u32 {
195        px.chunks_exact(4)
196            .filter(|c| {
197                let (r, g, b) = (c[0] as i32, c[1] as i32, c[2] as i32);
198                r > g + 40 && b > g + 40
199            })
200            .count() as u32
201    };
202    let (mb, ma) = (magenta(&before), magenta(&after));
203    eprintln!("PERSISTENCIA: pilar magenta = {mb} px antes / {ma} px tras alejarse+volver ({} ediciones)", s.edit_count());
204    assert!(mb > 200, "el pilar debería verse antes ({mb} px)");
205    assert!(ma * 100 >= mb * 90, "el pilar se perdió al volver ({mb}→{ma} px)");
206    eprintln!("PERSISTENCIA OK — la edición sobrevivió el regen del streaming.");
207
208    // --- CAS a disco: guardar el blob de ediciones en un archivo nombrado por su
209    // BLAKE3 y recargarlo en un mundo FRESCO (simula reiniciar el programa).
210    let blob = s.export_edits();
211    let addr = blake3::hash(&blob).to_hex();
212    let dir = std::path::Path::new("/tmp/m6_cas");
213    std::fs::create_dir_all(dir).ok();
214    let path = dir.join(format!("{addr}.edits"));
215    std::fs::write(&path, &blob).expect("escribir blob");
216
217    let mut loaded = WorldStream::new(dim, seed, 0, 0, llimphi_3d::VOXEL_BRICK);
218    let from_disk = std::fs::read(&path).expect("leer blob");
219    let n = loaded.import_edits(&from_disk).expect("blob válido");
220    let cas = magenta(&render_grid(loaded.grid()));
221    encode_png(&render_grid(loaded.grid()), W, H, "/tmp/m6_persist_cas.png");
222    eprintln!("CAS A DISCO: {n} ediciones desde {} ({} bytes), torre = {cas} px", path.display(), blob.len());
223    assert!(cas * 100 >= mb * 90, "la torre no se restauró desde disco ({mb}→{cas} px)");
224    eprintln!("CAS OK — las ediciones se restauraron desde un archivo direccionado por BLAKE3.");
225}
226
227/// Cámara de la ventana: posada sobre los **picos** del terreno (muestreo de
228/// alturas), atrás del centro y mirando hacia adelante y abajo — encuadra el
229/// relieve sin importar si el centro cae en agua o en una cima.
230fn camera_for(grid: &VoxelGrid, dim: [u32; 3]) -> Camera3d {
231    let (dx, dy, dz) = (dim[0], dim[1], dim[2]);
232    let mut hmax = 0u32;
233    for z in (0..dz).step_by(4) {
234        for x in (0..dx).step_by(4) {
235            if let Some(h) = grid.height_at(x, z) {
236                hmax = hmax.max(h);
237            }
238        }
239    }
240    let (dyf, dzf) = (dy as f32, dz as f32);
241    let eye_y = (hmax as f32 - dyf * 0.5) + dyf * 0.28 + 8.0;
242    Camera3d::fly(Vec3::new(0.0, eye_y, -dzf * 0.42), 0.0, -0.30)
243}
244
245/// Diferencia entre dos buffers RGBA: `(max abs por canal, media abs)`.
246fn diff(a: &[u8], b: &[u8]) -> (u8, f64) {
247    let mut max = 0u8;
248    let mut sum = 0u64;
249    for (x, y) in a.iter().zip(b.iter()) {
250        let d = x.abs_diff(*y);
251        max = max.max(d);
252        sum += d as u64;
253    }
254    (max, sum as f64 / a.len().max(1) as f64)
255}
256
257fn make_target(hal: &Hal) -> wgpu::Texture {
258    hal.device.create_texture(&wgpu::TextureDescriptor {
259        label: Some("inter"),
260        size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
261        mip_level_count: 1,
262        sample_count: 1,
263        dimension: wgpu::TextureDimension::D2,
264        format: FMT,
265        usage: wgpu::TextureUsages::STORAGE_BINDING
266            | wgpu::TextureUsages::TEXTURE_BINDING
267            | wgpu::TextureUsages::RENDER_ATTACHMENT
268            | wgpu::TextureUsages::COPY_SRC,
269        view_formats: &[],
270    })
271}
272
273/// Rinde el voxel renderer a `inter` (sobre un fondo negro de vello) y devuelve
274/// los píxeles RGBA planos.
275fn render_to_pixels(
276    hal: &Hal,
277    renderer: &mut Renderer,
278    inter: &wgpu::Texture,
279    inter_view: &wgpu::TextureView,
280    vr: &mut VoxelRenderer,
281    camera: &Camera3d,
282) -> Vec<u8> {
283    let base = vello::Scene::new();
284    renderer
285        .render_to_view(hal, &base, inter_view, W, H, Color::from_rgba8(0, 0, 0, 255))
286        .expect("render base");
287    let mut enc = hal
288        .device
289        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("voxel-pass") });
290    vr.render(&hal.device, &hal.queue, &mut enc, inter_view, (W, H), camera);
291    hal.queue.submit(std::iter::once(enc.finish()));
292    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
293    readback(hal, inter)
294}
295
296fn readback(hal: &Hal, target: &wgpu::Texture) -> Vec<u8> {
297    let unpadded = (W * 4) as usize;
298    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
299    let padded = unpadded.div_ceil(align) * align;
300    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
301        label: Some("readback"),
302        size: (padded * H as usize) as u64,
303        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
304        mapped_at_creation: false,
305    });
306    let mut enc = hal
307        .device
308        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
309    enc.copy_texture_to_buffer(
310        wgpu::TexelCopyTextureInfo {
311            texture: target,
312            mip_level: 0,
313            origin: wgpu::Origin3d::ZERO,
314            aspect: wgpu::TextureAspect::All,
315        },
316        wgpu::TexelCopyBufferInfo {
317            buffer: &buf,
318            layout: wgpu::TexelCopyBufferLayout {
319                offset: 0,
320                bytes_per_row: Some(padded as u32),
321                rows_per_image: Some(H),
322            },
323        },
324        wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
325    );
326    hal.queue.submit(std::iter::once(enc.finish()));
327    let slice = buf.slice(..);
328    let (tx, rx) = std::sync::mpsc::channel();
329    slice.map_async(wgpu::MapMode::Read, move |r| {
330        let _ = tx.send(r);
331    });
332    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
333    rx.recv().unwrap().unwrap();
334    let data = slice.get_mapped_range();
335    let mut pixels = Vec::with_capacity((W * H * 4) as usize);
336    for row in 0..H as usize {
337        let s = row * padded;
338        pixels.extend_from_slice(&data[s..s + unpadded]);
339    }
340    drop(data);
341    buf.unmap();
342    pixels
343}
344
345fn encode_png(pixels: &[u8], w: u32, h: u32, path: &str) {
346    let file = File::create(path).expect("png");
347    let mut enc = png::Encoder::new(BufWriter::new(file), w, h);
348    enc.set_color(png::ColorType::Rgba);
349    enc.set_depth(png::BitDepth::Eight);
350    let mut wtr = enc.write_header().unwrap();
351    wtr.write_image_data(pixels).unwrap();
352}