tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo (pin tawasuyu.git v0.3.0)
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}