tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ main llimphi-voxel-app/src/main.rs Rust · 683 siq'ikuna · 26.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

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

1//! # llimphi-voxel-app — showcase del motor voxel 3D
2//!
3//! Un mundo voxel procedural con dos modos:
4//!
5//! - **Órbita** (default): se mira el continente desde afuera, con atmósfera
6//!   (cielo + niebla) y un **monumento-malla** flotante que gira — prueba en
7//!   vivo que voxels (ray-march) y triángulos conviven con oclusión correcta
8//!   ([`llimphi_3d::Scene3d`]).
9//! - **Explorar** (Tab): cámara en **primera persona** caminando el terreno con
10//!   **gravedad y colisión** ([`llimphi_voxel::Player`]) — el bucle canónico de
11//!   un juego voxel: caminar, mirar, romper, construir.
12//!
13//! Capas: `llimphi-voxel-app → llimphi-voxel (terreno/jugador/picking) →
14//! llimphi-3d (motor) → wgpu`. El contenido vive en [`world`]; el motor no sabe
15//! nada de juegos.
16//!
17//! ```bash
18//! cargo run -p llimphi-voxel-app --release --  --world  # MUNDO COMPLETO, a pie
19//! cargo run -p llimphi-voxel-app --release             # showcase (órbita+monumento)
20//! cargo run -p llimphi-voxel-app --release -- --shot   # PNG headless a /tmp
21//! cargo run -p llimphi-voxel-app --release -- --paseo  # VIDEO de la caminata
22//! ```
23//! **`--world`**: arranca caminando en primera persona por un mundo con TODO lo
24//! del motor junto — colinas, flora (árboles/arbustos/flores), minerales que
25//! asoman en los acantilados, un **pueblo** procedural (casas de muchos estilos +
26//! plazas) y **fauna** variada (vacas, ovejas, ciervos, zorros, caballos,
27//! gallinas, mariposas…). Todo por semilla; caminas entre ellos.
28//!
29//! - **Tab**: alterna explorar (primera persona) ↔ órbita (desde afuera).
30//! - **Arrastrar**: mira / orbita. **Rueda**: zoom (órbita).
31//! - **WASD**: caminar. **Espacio**: saltar.
32//! - **b**: romper (donde mira la cámara). **g**: construir. **c**: desmoronar
33//!   (el pedazo se desprende y sube como ceniza).
34
35use std::sync::{Arc, Mutex};
36use std::time::Duration;
37
38use llimphi_3d::glam::Vec3;
39use llimphi_3d::Camera3d;
40use llimphi_ui::llimphi_hal::{wgpu, Hal};
41use llimphi_ui::llimphi_layout::taffy::prelude::{
42    percent, Position, Size, Style,
43};
44use llimphi_ui::llimphi_layout::LayoutTree;
45use llimphi_ui::llimphi_raster::peniko::Color;
46use llimphi_ui::llimphi_raster::{vello, Renderer};
47use llimphi_ui::{
48    mount, paint_gpu, App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View,
49    WheelDelta,
50};
51use llimphi_voxel::{forward_h, look_dir, right_h};
52
53mod film;
54mod soundtrack;
55mod mapa_live;
56mod world;
57use world::{World, FMT};
58
59const DIM_XZ: u32 = 192;
60const SEED: u32 = 1337;
61/// Paso de física por frame (el `Tick` periódico corre a ~30 Hz).
62const DT: f32 = 1.0 / 30.0;
63
64#[derive(Clone, Copy, PartialEq, Eq)]
65enum Mode {
66    Orbit,
67    Explore,
68}
69
70/// Dirección de caminata pulsada (WASD).
71#[derive(Clone, Copy)]
72enum Walk {
73    Fwd,
74    Back,
75    Left,
76    Right,
77}
78
79/// Snapshot del estado de teclas de movimiento (lo lee el paso de física).
80#[derive(Clone, Copy, Default)]
81struct Input {
82    fwd: bool,
83    back: bool,
84    left: bool,
85    right: bool,
86    jump: bool,
87    down: bool,
88}
89
90/// Qué hacer con el pedazo de mundo que el rayo señala.
91#[derive(Clone, Copy, PartialEq, Eq, Debug)]
92enum Accion {
93    /// Cavar un cráter (tecla `b`).
94    Romper,
95    /// Poner un bloque en la cara golpeada (tecla `g`).
96    Construir,
97    /// Desmoronarlo: los voxels se desprenden y suben como ceniza (tecla `c`).
98    Ceniza,
99}
100
101#[derive(Clone)]
102enum Msg {
103    Orbit(f32, f32),
104    Zoom(f32),
105    Edit(Accion),
106    ToggleMode,
107    ToggleFly,
108    Move(Walk, bool),
109    Jump(bool),
110    /// Bajar (vuelo): Shift/Ctrl mantenido.
111    Down(bool),
112    Tick,
113}
114
115struct Model {
116    /// `true` = mundo COMPLETO (pueblo + flora + minerales + fauna) y arranque en
117    /// primera persona; `false` = el showcase clásico (terreno + monumento).
118    todo: bool,
119    mode: Mode,
120    yaw: f32,
121    pitch: f32,
122    dist: f32,
123    angle: f32,
124    input: Input,
125    /// Vuelo libre (creativo): sin gravedad ni colisión, se mueve hacia donde mira.
126    /// Se alterna con Tab. En caminata (`false`), gravedad + salto.
127    flying: bool,
128    /// Mundo perezoso: se construye en la 1ª pintada GPU (ahí hay device/queue).
129    world: Arc<Mutex<Option<World>>>,
130    /// Cola de ediciones a aplicar en el próximo frame. El rayo se deriva de la
131    /// cámara *de ese frame*.
132    edits: Arc<Mutex<Vec<Accion>>>,
133    /// Pedido de reposar al jugador sobre el terreno (al entrar a explorar).
134    respawn: Arc<Mutex<bool>>,
135}
136
137/// Construye el mundo según el modo: completo (pueblo+flora+minerales+fauna) o
138/// el showcase clásico. Perezoso: se llama en la 1ª pintada GPU.
139fn build_world(todo: bool, device: &wgpu::Device, queue: &wgpu::Queue) -> World {
140    // Mundo desde un mapa real (--map): ventana MÁS GRANDE que el mundo procedural
141    // (se ve más territorio, sobre todo al volar). El streaming toroidal la mantiene
142    // barata. `--side N` la cambia (ojo con la GPU).
143    let mapa_xz = std::env::args()
144        .position(|a| a == "--side")
145        .and_then(|p| std::env::args().nth(p + 1))
146        .and_then(|s| s.parse::<u32>().ok())
147        .unwrap_or(320);
148    let mapa_dy = (mapa_xz * 4 / 10).max(64);
149    if let Some((mapa, dim)) = mapa_live::cargar([mapa_xz, mapa_dy, mapa_xz]) {
150        return World::build_mapa(device, queue, mapa, dim);
151    }
152    if todo {
153        World::build_todo(device, queue, DIM_XZ, SEED)
154    } else {
155        World::build(device, queue, DIM_XZ, SEED)
156    }
157}
158
159/// Cámara de órbita a partir del estado del modelo.
160fn camera_of(yaw: f32, pitch: f32, dist: f32) -> Camera3d {
161    Camera3d::orbit(Vec3::new(0.0, focus_y(), 0.0), yaw, pitch, dist)
162}
163
164/// `dim` del mundo (igual que `World::build`): grilla `[0, dim]`.
165fn world_dim() -> Vec3 {
166    let dy = (DIM_XZ * 4 / 10).max(48) as f32;
167    Vec3::new(DIM_XZ as f32, dy, DIM_XZ as f32)
168}
169
170/// `y` del centro de órbita (sobre el nivel medio del mundo).
171fn focus_y() -> f32 {
172    let dy = (DIM_XZ * 4 / 10).max(48) as f32;
173    dy * 0.10
174}
175
176struct VoxelApp;
177
178impl App for VoxelApp {
179    type Model = Model;
180    type Msg = Msg;
181
182    fn title() -> &'static str {
183        "llimphi-voxel-app"
184    }
185    fn initial_size() -> (u32, u32) {
186        (1000, 720)
187    }
188
189    fn init(handle: &Handle<Msg>) -> Model {
190        handle.spawn_periodic(Duration::from_millis(33), || Msg::Tick);
191        // El mundo completo arranca ya caminando en primera persona.
192        let todo = std::env::args().any(|a| a == "--world" || a == "--all" || a == "--map");
193        Model {
194            todo,
195            mode: if todo { Mode::Explore } else { Mode::Orbit },
196            yaw: 35_f32.to_radians(),
197            pitch: if todo { 0.0 } else { 22_f32.to_radians() },
198            dist: DIM_XZ as f32 * 1.5,
199            angle: 0.0,
200            input: Input::default(),
201            flying: false,
202            world: Arc::new(Mutex::new(None)),
203            edits: Arc::new(Mutex::new(Vec::new())),
204            respawn: Arc::new(Mutex::new(false)),
205        }
206    }
207
208    fn on_wheel(_m: &Model, delta: WheelDelta, _c: (f32, f32), _mods: Modifiers) -> Option<Msg> {
209        Some(Msg::Zoom(delta.y))
210    }
211
212    fn on_key(m: &Model, ev: &KeyEvent) -> Option<Msg> {
213        let pressed = matches!(ev.state, KeyState::Pressed);
214        match &ev.key {
215            // Tab: en exploración alterna VOLAR/caminar; en el showcase, órbita.
216            Key::Named(NamedKey::Tab) if pressed && !ev.repeat => {
217                Some(if m.mode == Mode::Explore { Msg::ToggleFly } else { Msg::ToggleMode })
218            }
219            Key::Named(NamedKey::Space) => Some(Msg::Jump(pressed)), // saltar / subir (vuelo)
220            Key::Named(NamedKey::Shift) => Some(Msg::Down(pressed)), // bajar (vuelo)
221            // Flechas = movimiento (además de WASD).
222            Key::Named(NamedKey::ArrowUp) => Some(Msg::Move(Walk::Fwd, pressed)),
223            Key::Named(NamedKey::ArrowDown) => Some(Msg::Move(Walk::Back, pressed)),
224            Key::Named(NamedKey::ArrowLeft) => Some(Msg::Move(Walk::Left, pressed)),
225            Key::Named(NamedKey::ArrowRight) => Some(Msg::Move(Walk::Right, pressed)),
226            Key::Character(c) => match c.to_ascii_lowercase().as_str() {
227                "w" => Some(Msg::Move(Walk::Fwd, pressed)),
228                "s" => Some(Msg::Move(Walk::Back, pressed)),
229                "a" => Some(Msg::Move(Walk::Left, pressed)),
230                "d" => Some(Msg::Move(Walk::Right, pressed)),
231                "b" if pressed && !ev.repeat => Some(Msg::Edit(Accion::Romper)),
232                "g" if pressed && !ev.repeat => Some(Msg::Edit(Accion::Construir)),
233                // Desmoronar: el pedazo apuntado se desprende y sube como ceniza.
234                "c" if pressed && !ev.repeat => Some(Msg::Edit(Accion::Ceniza)),
235                _ => None,
236            },
237            _ => None,
238        }
239    }
240
241    fn update(mut model: Model, msg: Msg, _handle: &Handle<Msg>) -> Model {
242        match msg {
243            Msg::Orbit(dx, dy) => {
244                model.yaw -= dx * 0.008;
245                let lim = std::f32::consts::FRAC_PI_2 - 0.05;
246                model.pitch = (model.pitch + dy * 0.008).clamp(-lim, lim);
247            }
248            Msg::Zoom(dy) => {
249                let f = (1.0 + dy * 0.1).clamp(0.5, 1.5);
250                model.dist = (model.dist * f).clamp(DIM_XZ as f32 * 0.6, DIM_XZ as f32 * 3.0);
251            }
252            Msg::Edit(accion) => model.edits.lock().unwrap().push(accion),
253            Msg::ToggleMode => {
254                model.mode = match model.mode {
255                    Mode::Orbit => {
256                        *model.respawn.lock().unwrap() = true;
257                        Mode::Explore
258                    }
259                    Mode::Explore => Mode::Orbit,
260                };
261            }
262            Msg::Move(w, on) => match w {
263                Walk::Fwd => model.input.fwd = on,
264                Walk::Back => model.input.back = on,
265                Walk::Left => model.input.left = on,
266                Walk::Right => model.input.right = on,
267            },
268            Msg::Jump(on) => model.input.jump = on,
269            Msg::Down(on) => model.input.down = on,
270            Msg::ToggleFly => model.flying = !model.flying,
271            Msg::Tick => model.angle += 0.01,
272        }
273        model
274    }
275
276    fn view(model: &Model) -> View<Msg> {
277        let mode = model.mode;
278        let todo = model.todo;
279        let (yaw, pitch, dist, angle, input) =
280            (model.yaw, model.pitch, model.dist, model.angle, model.input);
281        let flying = model.flying;
282        let world = model.world.clone();
283        let edits = model.edits.clone();
284        let respawn = model.respawn.clone();
285
286        let canvas = View::new(fill_absolute())
287            .gpu_paint_with(move |device, queue, encoder, target, _rect, vp| {
288                let mut guard = world.lock().unwrap();
289                let w = guard.get_or_insert_with(|| build_world(todo, device, queue));
290
291                // Cámara del frame: en explorar, primero avanza la física.
292                let camera = match mode {
293                    Mode::Explore => {
294                        if std::mem::take(&mut *respawn.lock().unwrap()) {
295                            w.respawn_player();
296                        }
297                        // Adelante: al VOLAR sigues la mirada (3D, subes/bajás según a
298                        // dónde apuntas); al caminar, sólo el plano horizontal.
299                        let fwd = if flying { look_dir(yaw, pitch) } else { forward_h(yaw) };
300                        let mut wish = Vec3::ZERO;
301                        if input.fwd {
302                            wish += fwd;
303                        }
304                        if input.back {
305                            wish -= fwd;
306                        }
307                        if input.right {
308                            wish += right_h(yaw);
309                        }
310                        if input.left {
311                            wish -= right_h(yaw);
312                        }
313                        if flying {
314                            // Space sube, Shift baja (además del pitch de la mirada).
315                            if input.jump {
316                                wish += Vec3::Y;
317                            }
318                            if input.down {
319                                wish -= Vec3::Y;
320                            }
321                            w.fly_player(wish, DT);
322                        } else {
323                            w.step_player(wish, input.jump, DT);
324                            w.reset_mapa_fog(); // niebla base al ras del suelo
325                        }
326                        // Streaming toroidal: desliza la ventana del mapa si el jugador
327                        // se alejó del centro → mundo sin borde (no-op fuera de --map).
328                        w.follow_mapa(device, queue);
329                        Camera3d::fly(w.eye(), yaw, pitch)
330                    }
331                    Mode::Orbit => camera_of(yaw, pitch, dist),
332                };
333
334                // Ediciones: el rayo sale de la cámara de este frame (en órbita,
335                // desde afuera; en explorar, desde el ojo del jugador).
336                let pending = std::mem::take(&mut *edits.lock().unwrap());
337                if !pending.is_empty() {
338                    let d = w.dim();
339                    let ro = camera.eye + Vec3::new(d[0] as f32, d[1] as f32, d[2] as f32) * 0.5;
340                    let rd = (camera.target - camera.eye).normalize();
341                    for accion in pending {
342                        match accion {
343                            Accion::Ceniza => {
344                                w.desprender_ceniza(queue, [ro.x, ro.y, ro.z], [rd.x, rd.y, rd.z]);
345                            }
346                            Accion::Romper => {
347                                w.apply_edit(queue, [ro.x, ro.y, ro.z], [rd.x, rd.y, rd.z], false);
348                            }
349                            Accion::Construir => {
350                                w.apply_edit(queue, [ro.x, ro.y, ro.z], [rd.x, rd.y, rd.z], true);
351                            }
352                        }
353                    }
354                }
355
356                w.tick(queue, DT); // la manada deambula y el anillo SDF se reparte
357                w.animate(angle);
358                w.render(device, queue, encoder, target, vp, &camera);
359                if mode == Mode::Explore {
360                    w.draw_hud(device, queue, encoder, target, vp);
361                }
362            })
363            .draggable(|phase, dx, dy| match phase {
364                DragPhase::Move => Some(Msg::Orbit(dx, dy)),
365                DragPhase::End => None,
366            });
367
368        View::new(root()).children(vec![canvas])
369    }
370}
371
372/// Raíz a pantalla completa que aloja el canvas 3D.
373fn root() -> Style {
374    Style {
375        size: Size {
376            width: percent(1.0_f32),
377            height: percent(1.0_f32),
378        },
379        ..Default::default()
380    }
381}
382
383/// Canvas a pantalla completa, posicionado absoluto.
384fn fill_absolute() -> Style {
385    Style {
386        position: Position::Absolute,
387        size: Size {
388            width: percent(1.0_f32),
389            height: percent(1.0_f32),
390        },
391        ..Default::default()
392    }
393}
394
395fn main() {
396    let args: Vec<String> = std::env::args().collect();
397    if args.iter().any(|a| a == "--film") {
398        film::film();
399        return;
400    }
401    if args.iter().any(|a| a == "--paseo") {
402        film::paseo();
403        return;
404    }
405    if args.iter().any(|a| a == "--born") {
406        film::born();
407        return;
408    }
409    if args.iter().any(|a| a == "--poses") {
410        film::poses_shot();
411        return;
412    }
413    if args.iter().any(|a| a == "--vox") {
414        film::vox_shot();
415        return;
416    }
417    if args.iter().any(|a| a == "--shot") {
418        let todo = args.iter().any(|a| a == "--world" || a == "--all");
419        shot(todo);
420        return;
421    }
422    llimphi_ui::run::<VoxelApp>();
423}
424
425/// Render headless de la escena por el compositor real (mount → paint_gpu),
426/// para verificar sin pantalla que el nodo `gpu_paint_with` de la app corre y
427/// produce el mundo. Vuelca dos PNG: la vista órbita y la primera persona.
428fn shot(todo: bool) {
429    let hal = pollster::block_on(Hal::new(None)).expect("hal");
430    let mut renderer = Renderer::new(&hal).expect("renderer");
431
432    // Mundo compartido entre las dos tomas (se construye en la 1ª).
433    let world: Arc<Mutex<Option<World>>> = Arc::new(Mutex::new(None));
434
435    // --- Toma 1: órbita (con un cráter, prueba romper desde afuera) ---
436    let cam_orbit = Camera3d::orbit(
437        Vec3::new(0.0, focus_y(), 0.0),
438        35_f32.to_radians(),
439        22_f32.to_radians(),
440        DIM_XZ as f32 * 1.5,
441    );
442    shot_one(&hal, &mut renderer, &world, cam_orbit, !todo, todo, "/tmp/voxel_app.png");
443
444    // --- Toma 2: primera persona, parado en un mirador del borde, mirando al
445    // bicho más cercano (encuadra la manada que deambula el terreno) ---
446    let cam_fps = {
447        let mut guard = world.lock().unwrap();
448        let w = guard.as_mut().expect("mundo ya construido en la toma 1");
449        // En el mundo completo, usamos el spawn abierto que ya eligió build_todo.
450        if !todo {
451            w.spawn_player_at(DIM_XZ / 5, DIM_XZ / 5);
452        }
453        // En --map, CAMINAMOS ~320 pasos hacia adelante para probar el streaming:
454        // la ventana debe deslizarse y mostrar terreno continuo (sin borde/void).
455        let mapa_mode = std::env::args().any(|a| a == "--map");
456        if mapa_mode {
457            // Caminar ~300 pasos (streaming) y luego SUBIR volando ~55 voxels: a más
458            // altura, más rápido y menos niebla → la vista lejana se abre.
459            let fwd = forward_h(0.0);
460            for _ in 0..300 {
461                w.step_player(fwd, false, DT);
462                w.follow_mapa(&hal.device, &hal.queue);
463            }
464            for _ in 0..30 {
465                w.fly_player(Vec3::Y, DT); // subir a altura moderada
466            }
467            let eye = w.eye();
468            eprintln!("vuelo: subió a ojo local y={:.0}; origen {:?}", eye.y, w.origin_debug());
469            // Mirar hacia adelante y algo abajo desde la altura → panorámica.
470            Camera3d { eye, target: eye + look_dir(0.0, -0.5), ..Camera3d::default() }
471        } else {
472            let mut eye = Vec3::ZERO;
473            for _ in 0..4 {
474                eye = w.step_player(Vec3::ZERO, false, DT);
475            }
476            // Un cuadro de vida ANTES de elegir a quién mirar: el anillo SDF se
477            // reparte con el ojo del jugador, y sin esto `mirar_sdf` no tiene a
478            // nadie todavía (la primera toma miró una caja tapada por un arbusto).
479            w.tick(&hal.queue, DT);
480            // Mirar a lo que vinimos a ver: el ser analítico más cercano (y si no
481            // hay ninguno, la caja que deambula, como antes).
482            let target = w
483                .mirar_sdf(eye)
484                .or_else(|| w.nearest_critter(eye))
485                .unwrap_or(eye + Vec3::Z);
486            // Cámara de FOTO: unos pasos atrás y arriba del sujeto. El retroceso
487            // es HORIZONTAL: restando la dirección de mirada, un árbol que queda
488            // casi encima mandaba la cámara para abajo y la toma salía de puro
489            // cielo (así salió la primera).
490            let mut hor = eye - target;
491            hor.y = 0.0;
492            let hor = if hor.length_squared() < 1.0 {
493                Vec3::new(0.0, 0.0, -1.0)
494            } else {
495                hor.normalize()
496            };
497            let mut ojo = target + hor * 9.0 + Vec3::Y * 3.0;
498            // Y por encima del relieve: si el retroceso cae en una loma, la cámara
499            // termina adentro de la tierra.
500            if let Some(h) = w.altura_en(ojo.x, ojo.z) {
501                ojo.y = ojo.y.max(h + 2.0);
502            }
503            Camera3d { eye: ojo, target, ..Camera3d::default() }
504        }
505    };
506    shot_one(&hal, &mut renderer, &world, cam_fps, false, todo, "/tmp/voxel_app_fps.png");
507}
508
509/// Renderiza una toma con `camera` a `path`. Si `edit`, cava un cráter por el
510/// camino real (`apply_edit`) para verificar también la mecánica de romper.
511fn shot_one(
512    hal: &Hal,
513    renderer: &mut Renderer,
514    world: &Arc<Mutex<Option<World>>>,
515    camera: Camera3d,
516    edit: bool,
517    todo: bool,
518    path: &str,
519) {
520    const W: u32 = 1000;
521    const H: u32 = 720;
522    let world = world.clone();
523    let ro = camera.eye + world_dim() * 0.5;
524    let rd = (camera.target - camera.eye).normalize();
525    let canvas: View<Msg> = View::new(fill_absolute()).gpu_paint_with(
526        move |device, queue, encoder, target, _rect, vp| {
527            let mut guard = world.lock().unwrap();
528            let w = guard.get_or_insert_with(|| build_world(todo, device, queue));
529            if edit {
530                w.apply_edit(queue, [ro.x, ro.y, ro.z], [rd.x, rd.y, rd.z], false);
531            }
532            // Un cuadro de vida ANTES de rendir: es el que reparte el anillo SDF
533            // (quién es ser analítico y quién sigue siendo su estampa de voxels).
534            // Sin esto la toma headless salía con el mundo entero en voxels y no
535            // certificaba nada de lo que vino a certificar.
536            w.tick(queue, DT);
537            let (n, prims) = w.cuenta_sdf();
538            eprintln!("  anillo SDF: {n} seres, {prims} primitivas");
539            w.animate(0.6);
540            w.render(device, queue, encoder, target, vp, &camera);
541            // La toma de primera persona (`!edit`) lleva el HUD, como en vivo.
542            if !edit {
543                w.draw_hud(device, queue, encoder, target, vp);
544            }
545        },
546    );
547    let view: View<Msg> = View::new(root()).children(vec![canvas]);
548
549    let mut layout = LayoutTree::new();
550    let mounted = mount(&mut layout, view);
551    let computed = layout.compute(mounted.root, (W as f32, H as f32)).expect("layout");
552
553    let inter = hal.device.create_texture(&wgpu::TextureDescriptor {
554        label: Some("inter"),
555        size: wgpu::Extent3d { width: W, height: H, depth_or_array_layers: 1 },
556        mip_level_count: 1,
557        sample_count: 1,
558        dimension: wgpu::TextureDimension::D2,
559        format: FMT,
560        usage: wgpu::TextureUsages::STORAGE_BINDING
561            | wgpu::TextureUsages::TEXTURE_BINDING
562            | wgpu::TextureUsages::RENDER_ATTACHMENT
563            | wgpu::TextureUsages::COPY_SRC,
564        view_formats: &[],
565    });
566    let inter_view = inter.create_view(&wgpu::TextureViewDescriptor::default());
567    renderer
568        .render_to_view(hal, &vello::Scene::new(), &inter_view, W, H, Color::from_rgba8(0, 0, 0, 255))
569        .expect("base");
570
571    let mut enc = hal
572        .device
573        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("gpu") });
574    let any = paint_gpu(&mounted, &computed, &hal.device, &hal.queue, &mut enc, &inter_view, (W, H));
575    hal.queue.submit(std::iter::once(enc.finish()));
576    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
577    assert!(any, "el gpu_painter de la app no corrió");
578
579    write_png(hal, &inter, W, H, path);
580    eprintln!("voxel_app: escrito {path} ({W}x{H})");
581}
582
583fn write_png(hal: &Hal, target: &wgpu::Texture, w: u32, h: u32, path: &str) {
584    let pixels = readback_rgba(hal, target, w, h);
585    encode_png(&pixels, w, h, path);
586}
587
588/// Lee de vuelta una textura RGBA8 `w×h` a un `Vec<u8>` plano (sin padding).
589fn readback_rgba(hal: &Hal, target: &wgpu::Texture, w: u32, h: u32) -> Vec<u8> {
590    let unpadded = (w * 4) as usize;
591    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
592    let padded = unpadded.div_ceil(align) * align;
593    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
594        label: Some("readback"),
595        size: (padded * h as usize) as u64,
596        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
597        mapped_at_creation: false,
598    });
599    let mut enc = hal
600        .device
601        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
602    enc.copy_texture_to_buffer(
603        wgpu::TexelCopyTextureInfo {
604            texture: target,
605            mip_level: 0,
606            origin: wgpu::Origin3d::ZERO,
607            aspect: wgpu::TextureAspect::All,
608        },
609        wgpu::TexelCopyBufferInfo {
610            buffer: &buf,
611            layout: wgpu::TexelCopyBufferLayout {
612                offset: 0,
613                bytes_per_row: Some(padded as u32),
614                rows_per_image: Some(h),
615            },
616        },
617        wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
618    );
619    hal.queue.submit(std::iter::once(enc.finish()));
620    let slice = buf.slice(..);
621    let (tx, rx) = std::sync::mpsc::channel();
622    slice.map_async(wgpu::MapMode::Read, move |r| {
623        let _ = tx.send(r);
624    });
625    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
626    rx.recv().unwrap().unwrap();
627    let data = slice.get_mapped_range();
628    let mut pixels = Vec::with_capacity((w * h * 4) as usize);
629    for row in 0..h as usize {
630        let s = row * padded;
631        pixels.extend_from_slice(&data[s..s + unpadded]);
632    }
633    drop(data);
634    buf.unmap();
635    pixels
636}
637
638/// Codifica RGBA8 plano `w×h` a un PNG en `path`.
639fn encode_png(pixels: &[u8], w: u32, h: u32, path: &str) {
640    use std::fs::File;
641    use std::io::BufWriter;
642    let file = File::create(path).expect("png");
643    let mut enc = png::Encoder::new(BufWriter::new(file), w, h);
644    enc.set_color(png::ColorType::Rgba);
645    enc.set_depth(png::BitDepth::Eight);
646    let mut wtr = enc.write_header().unwrap();
647    wtr.write_image_data(pixels).unwrap();
648}
649
650/// Lee una textura `src_w×src_h` y la **baja por supersampling** (promedio de
651/// bloques `factor×factor`) a `(src_w/factor)×(src_h/factor)` antes de escribir el
652/// PNG — antialias de los bordes duros del ray-march (SSAA). `factor=1` = directo.
653fn write_png_downsampled(hal: &Hal, target: &wgpu::Texture, src_w: u32, src_h: u32, factor: u32, path: &str) {
654    let src = readback_rgba(hal, target, src_w, src_h);
655    let f = factor.max(1);
656    if f == 1 {
657        encode_png(&src, src_w, src_h, path);
658        return;
659    }
660    let (dw, dh) = (src_w / f, src_h / f);
661    let mut dst = vec![0u8; (dw * dh * 4) as usize];
662    let n = (f * f) as u32;
663    for dy in 0..dh {
664        for dx in 0..dw {
665            let mut acc = [0u32; 4];
666            for sy in 0..f {
667                for sx in 0..f {
668                    let px = (dx * f + sx) as usize;
669                    let py = (dy * f + sy) as usize;
670                    let o = (py * src_w as usize + px) * 4;
671                    for c in 0..4 {
672                        acc[c] += src[o + c] as u32;
673                    }
674                }
675            }
676            let o = ((dy * dw + dx) * 4) as usize;
677            for c in 0..4 {
678                dst[o + c] = (acc[c] / n) as u8;
679            }
680        }
681    }
682    encode_png(&dst, dw, dh, path);
683}