gioser

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definiciones

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