tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8
llimphi-voxel-app/src/main.rs
Historia
Quién tocó qué
crudo
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}