tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! `llimphi-gpu-bench` — binario standalone para validar el SDD
2//! `02_ruway/llimphi/SDD.md` §"GPU directo wgpu" en una máquina con GPU
3//! real.
4//!
5//! Hace cuatro cosas en orden y lo imprime todo a stdout en formato
6//! markdown / tabla copy-paste friendly:
7//!
8//! 1. **Header del sistema** — versión, hora, OS, GPU detectado.
9//! 2. **Info del adapter wgpu** — backend (Vulkan/Metal/DX12/GL),
10//! device name, vendor, limits relevantes.
11//! 3. **Spike vello vs GPU directo** — para N ∈ {25K, 50K, 100K, 200K,
12//! 500K, 1M}. Mide ms/frame de cada uno y el factor. Evalúa el
13//! criterio del SDD: ≥5× a 500K → PASA; < → ABORTAR.
14//! 4. **Escalado GPU directo solo** — para N ∈ {100K, 500K, 1M, 2M,
15//! 5M, 10M}. Mide ms/frame, fps equivalente, Mprim/s. Evalúa el
16//! objetivo de 60 fps @ 1M.
17//! 5. **PNGs de verificación visual** — exporta 2 archivos al cwd:
18//! `bench_vello_100k.png` y `bench_directo_100k.png`. La forma del
19//! cielo de puntos debe coincidir entre los dos (LCG determinista).
20//!
21//! Pegar el output completo en chat para la verificación.
22//!
23//! Corre con: `cargo run -p llimphi-gpu-bench --release`.
24
25mod scissor;
26mod texto;
27
28use std::fs::File;
29use std::io::{BufWriter, Write};
30use std::time::Instant;
31
32use llimphi_hal::{wgpu, Hal};
33use llimphi_raster::kurbo::{Affine, Rect};
34use llimphi_raster::peniko::{color::palette, Color, Fill};
35use llimphi_raster::{units_per_chunk, vello, GpuBatch, GpuPipelines, RECT_INSTANCE_STRIDE};
36
37const W: u32 = 1024;
38const H: u32 = 1024;
39const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
40const WARMUP: usize = 5;
41const MEASURED: usize = 15;
42
43const SPIKE_SIZES: &[u32] = &[25_000, 50_000, 100_000, 200_000, 500_000, 1_000_000];
44const SCALE_SIZES: &[u32] = &[100_000, 500_000, 1_000_000, 2_000_000, 5_000_000, 10_000_000];
45
46/// Overrides via env vars (para correr en hosts limitados sin tumbar el
47/// binario). En GPU real ignorarlos y dejar los defaults.
48///
49/// - `LLIMPHI_BENCH_SPIKE_MAX=N` — recorta SPIKE_SIZES a los ≤ N.
50/// - `LLIMPHI_BENCH_SCALE_MAX=N` — idem SCALE_SIZES.
51/// - `LLIMPHI_BENCH_SKIP_VELLO=1` — saltea totalmente la columna vello
52/// (útil si vello revienta con SIGSEGV en este host).
53fn spike_sizes() -> Vec<u32> {
54 let max = std::env::var("LLIMPHI_BENCH_SPIKE_MAX")
55 .ok()
56 .and_then(|v| v.parse::<u32>().ok())
57 .unwrap_or(u32::MAX);
58 SPIKE_SIZES.iter().copied().filter(|&n| n <= max).collect()
59}
60
61fn scale_sizes() -> Vec<u32> {
62 let max = std::env::var("LLIMPHI_BENCH_SCALE_MAX")
63 .ok()
64 .and_then(|v| v.parse::<u32>().ok())
65 .unwrap_or(u32::MAX);
66 SCALE_SIZES.iter().copied().filter(|&n| n <= max).collect()
67}
68
69fn skip_vello() -> bool {
70 std::env::var("LLIMPHI_BENCH_SKIP_VELLO").ok().as_deref() == Some("1")
71}
72
73fn main() {
74 print_header();
75 let hal = pollster::block_on(Hal::new(None)).expect("hal");
76 print_adapter(&hal);
77
78 let (target, view) = make_target(&hal.device);
79
80 let pipelines = GpuPipelines::new(&hal.device, FMT);
81 let mut vello_renderer = vello::Renderer::new(
82 &hal.device,
83 vello::RendererOptions {
84 use_cpu: false,
85 antialiasing_support: vello::AaSupport {
86 area: true,
87 msaa8: false,
88 msaa16: false,
89 },
90 num_init_threads: None,
91 pipeline_cache: None,
92 },
93 )
94 .expect("vello renderer");
95
96 println!("## Spike vello vs GPU directo");
97 println!();
98 println!("Target: {W}×{H} Rgba8Unorm, headless. Cada N corre {WARMUP} warmup + {MEASURED} medidos, reporta mediana.");
99 println!();
100 println!("| N | vello ms | directo ms | factor | nota |");
101 println!("|---:|---:|---:|---:|---|");
102 let mut spike_rows: Vec<SpikeRow> = Vec::new();
103 let skip_v = skip_vello();
104 for n in spike_sizes() {
105 let row = bench_spike(&hal, &mut vello_renderer, &pipelines, &view, n, skip_v);
106 let note = if row.vello_crashed {
107 "vello SIGSEGV/error"
108 } else if let Some(f) = row.factor {
109 if f >= 5.0 { "≥5×" } else { "<5×" }
110 } else {
111 "-"
112 };
113 let vello_str = if row.vello_crashed {
114 "—".to_string()
115 } else {
116 format!("{:.2}", row.vello_ms.unwrap_or(0.0))
117 };
118 let factor_str = match row.factor {
119 Some(f) => format!("{:.2}×", f),
120 None => "—".to_string(),
121 };
122 println!(
123 "| {} | {} | {:.2} | {} | {} |",
124 fmt_int(n),
125 vello_str,
126 row.directo_ms,
127 factor_str,
128 note
129 );
130 let _ = std::io::stdout().flush();
131 spike_rows.push(row);
132 }
133 println!();
134 print_spike_verdict(&spike_rows);
135
136 println!("## Escalado GPU directo");
137 println!();
138 println!("API real (`GpuPipelines` + `GpuBatch::add_rect`). Sólo se mide el lado GPU directo — vello no llega aquí.");
139 println!();
140 println!("| N | ms / frame | fps (1000/ms) | Mprim/s |");
141 println!("|---:|---:|---:|---:|");
142 let mut scale_rows: Vec<ScaleRow> = Vec::new();
143 for n in scale_sizes() {
144 let ms = bench_directo(&hal, &pipelines, &view, n);
145 let fps = 1000.0 / ms;
146 let mps = (n as f64 / 1_000_000.0) / (ms / 1000.0);
147 println!(
148 "| {} | {:.2} | {:.1} | {:.2} |",
149 fmt_int(n),
150 ms,
151 fps,
152 mps
153 );
154 let _ = std::io::stdout().flush();
155 scale_rows.push(ScaleRow { n, ms, fps, mps });
156 }
157 println!();
158 print_scale_verdict(&scale_rows);
159
160 // ----------------------------------------------------------------
161 // Texto. El atlas de glifos (2026-08-05) está certificado como
162 // CORRECTO —`llimphi-test/tests/texto_gpu_directo.rs`, Δ64:0— pero
163 // nunca se midió si CONVIENE. Sin ese número sería justo lo que la
164 // Iniciativa #4 viene a curar: algo construido que nadie usa porque
165 // nadie sabe si paga.
166 // ----------------------------------------------------------------
167 let gpu_real =
168 hal.adapter.get_info().device_type != wgpu::DeviceType::Cpu;
169 let filas_texto = texto::correr(
170 &hal,
171 &mut vello_renderer,
172 &pipelines,
173 &view,
174 W,
175 H,
176 // Más muestras que el resto del bench, y no por gusto: con las 15 de
177 // `MEASURED` la tabla salía NO MONÓTONA —7.200 glifos más lentos que
178 // 12.000—, que es la firma de que el ruido de la máquina pesa más que
179 // lo que se mide. Un cuadro de texto cuesta milisegundos; los rects,
180 // decenas.
181 texto::WARMUP,
182 texto::MEDIDOS,
183 );
184 texto::imprimir(&filas_texto, gpu_real);
185 let _ = std::io::stdout().flush();
186
187 // ----------------------------------------------------------------
188 // Scissor. El 14,8 % → 97,9 % que figura en el plan salió de una
189 // medición ad-hoc que nunca quedó en el repo: no se podía repetir.
190 // Acá es un bench, con su control.
191 // ----------------------------------------------------------------
192 let filas_scissor = scissor::correr(&hal, &pipelines);
193 scissor::imprimir(&filas_scissor);
194 let _ = std::io::stdout().flush();
195
196 // ----------------------------------------------------------------
197 // Variantes persistentes: el rebuild del batch/scene por frame es
198 // el peor caso. En apps reales (cosmos starfield Gaia, tinkuy
199 // particles iniciales, nakui viewport estático) los datos no
200 // cambian por frame — se uploadean UNA vez y el bucle solo redraw.
201 // Estos benches lo miden.
202 // ----------------------------------------------------------------
203 println!("## Persistente — datos fijos, sólo redraw por frame");
204 println!();
205 println!("Setup (LCG + write_buffer / Scene fill) fuera de la medición; el bucle medido sólo emite render_pass + draw + submit + wait.");
206 println!();
207 println!("### vello (Scene reutilizada sin reset)");
208 println!();
209 println!("| N | ms / frame | fps (1000/ms) |");
210 println!("|---:|---:|---:|");
211 let mut vello_persist_rows: Vec<(u32, f64)> = Vec::new();
212 let skip_v = skip_vello();
213 for n in scale_sizes() {
214 if skip_v {
215 println!("| {} | skipped | — |", fmt_int(n));
216 continue;
217 }
218 let attempt = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
219 bench_vello_persistent(&hal, &mut vello_renderer, &view, n)
220 }));
221 match attempt {
222 Ok(ms) => {
223 let fps = 1000.0 / ms;
224 println!("| {} | {:.2} | {:.1} |", fmt_int(n), ms, fps);
225 let _ = std::io::stdout().flush();
226 vello_persist_rows.push((n, ms));
227 }
228 Err(_) => {
229 println!("| {} | crash | — |", fmt_int(n));
230 }
231 }
232 }
233 println!();
234 println!("### GPU directo (buffer + bind group persistentes)");
235 println!();
236 println!("| N | ms / frame | fps (1000/ms) | Mprim/s | px pintados |");
237 println!("|---:|---:|---:|---:|---:|");
238 let mut directo_persist_rows: Vec<ScaleRow> = Vec::new();
239 for n in scale_sizes() {
240 let (ms, pintados) = bench_directo_persistent(&hal, &pipelines, &target, &view, n);
241 let fps = 1000.0 / ms;
242 let mps = (n as f64 / 1_000_000.0) / (ms / 1000.0);
243 println!(
244 "| {} | {:.2} | {:.1} | {:.2} | {} |",
245 fmt_int(n),
246 ms,
247 fps,
248 mps,
249 if pintados == 0 {
250 "⚠️ 0 px — NO DIBUJÓ".to_string()
251 } else {
252 fmt_int(pintados.min(u32::MAX as u64) as u32)
253 }
254 );
255 let _ = std::io::stdout().flush();
256 directo_persist_rows.push(ScaleRow { n, ms, fps, mps });
257 }
258 println!();
259 print_persistent_verdict(&directo_persist_rows, &vello_persist_rows);
260
261 println!("## Validación visual");
262 println!();
263 let png_vello = "bench_vello_100k.png";
264 let png_directo = "bench_directo_100k.png";
265 if let Err(e) = export_vello_png(&hal, &mut vello_renderer, &target, &view, 100_000, png_vello)
266 {
267 println!("vello PNG fallo: {e}");
268 } else {
269 println!("- vello 100K → `{}` ({W}×{H})", png_vello);
270 }
271 if let Err(e) =
272 export_directo_png(&hal, &pipelines, &target, &view, 100_000, png_directo)
273 {
274 println!("directo PNG fallo: {e}");
275 } else {
276 println!("- directo 100K → `{}` ({W}×{H})", png_directo);
277 }
278 println!();
279 println!("Las dos imágenes deben mostrar la misma constelación de puntos (LCG determinista).");
280 println!("Mirar en visor: si vello tiene halo AA suave y directo tiene pixeles hard-edged, todo bien.");
281 println!();
282
283 println!("## Resumen");
284 println!();
285 print_summary(
286 &spike_rows,
287 &scale_rows,
288 &directo_persist_rows,
289 &vello_persist_rows,
290 );
291}
292
293// ============================================================
294// IO / header
295// ============================================================
296
297fn print_header() {
298 println!("# llimphi-gpu-bench");
299 println!();
300 println!("Validación de Fase 0 del SDD `02_ruway/llimphi/SDD.md` §\"GPU directo wgpu\".");
301 println!("Criterio: factor ≥ 5× a 500K Y ≥ 60 fps @ 1M en GPU mid (Radeon 5500M, Iris Xe).");
302 println!();
303 println!("- crate version: {}", env!("CARGO_PKG_VERSION"));
304 println!("- host OS: {}", std::env::consts::OS);
305 println!("- host arch: {}", std::env::consts::ARCH);
306 println!();
307}
308
309fn print_adapter(hal: &Hal) {
310 let info = hal.adapter.get_info();
311 let limits = hal.adapter.limits();
312 println!("## Adapter wgpu");
313 println!();
314 println!("- backend: `{:?}`", info.backend);
315 println!("- device name: `{}`", info.name);
316 println!("- vendor: `0x{:04x}`", info.vendor);
317 println!("- device id: `0x{:04x}`", info.device);
318 println!("- device type: `{:?}`", info.device_type);
319 println!("- driver: `{}`", info.driver);
320 println!("- driver info: `{}`", info.driver_info);
321 println!();
322 println!("Limits relevantes:");
323 println!();
324 println!("- max texture 2D: {}", limits.max_texture_dimension_2d);
325 println!("- max buffer size: {} MB", limits.max_buffer_size / (1024 * 1024));
326 println!("- max storage buffer binding: {} MB", limits.max_storage_buffer_binding_size / (1024 * 1024));
327 println!();
328 let is_software = matches!(
329 info.device_type,
330 wgpu::DeviceType::Cpu
331 ) || info.driver.to_lowercase().contains("llvmpipe")
332 || info.driver.to_lowercase().contains("software")
333 || info.name.to_lowercase().contains("llvmpipe")
334 || info.name.to_lowercase().contains("swiftshader");
335 if is_software {
336 println!("⚠️ Adapter parece software (`{}`). Los números no reflejan GPU real.", info.name);
337 println!();
338 }
339}
340
341fn fmt_int(n: u32) -> String {
342 let s = n.to_string();
343 let mut out = String::new();
344 for (i, c) in s.chars().rev().enumerate() {
345 if i > 0 && i % 3 == 0 {
346 out.push('_');
347 }
348 out.push(c);
349 }
350 out.chars().rev().collect()
351}
352
353// ============================================================
354// Benches
355// ============================================================
356
357struct SpikeRow {
358 n: u32,
359 vello_ms: Option<f64>,
360 vello_crashed: bool,
361 directo_ms: f64,
362 factor: Option<f64>,
363}
364
365struct ScaleRow {
366 n: u32,
367 ms: f64,
368 fps: f64,
369 mps: f64,
370}
371
372fn bench_spike(
373 hal: &Hal,
374 vello_renderer: &mut vello::Renderer,
375 pipelines: &GpuPipelines,
376 view: &wgpu::TextureView,
377 n: u32,
378 skip_vello: bool,
379) -> SpikeRow {
380 let directo_ms = bench_directo(hal, pipelines, view, n);
381 if skip_vello {
382 return SpikeRow {
383 n,
384 vello_ms: None,
385 vello_crashed: true, // tratamos "skipped" como "no llegó"
386 directo_ms,
387 factor: None,
388 };
389 }
390 // catch_unwind sólo atrapa panics, no SIGSEGV. En vello pre-200K
391 // este path debería ser suficiente; si el binario muere igual,
392 // re-correr con `LLIMPHI_BENCH_SKIP_VELLO=1`.
393 let vello_attempt = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
394 bench_vello(hal, vello_renderer, view, n)
395 }));
396 match vello_attempt {
397 Ok(ms) => {
398 let factor = ms / directo_ms;
399 SpikeRow {
400 n,
401 vello_ms: Some(ms),
402 vello_crashed: false,
403 directo_ms,
404 factor: Some(factor),
405 }
406 }
407 Err(_) => SpikeRow {
408 n,
409 vello_ms: None,
410 vello_crashed: true,
411 directo_ms,
412 factor: None,
413 },
414 }
415}
416
417fn bench_vello(
418 hal: &Hal,
419 renderer: &mut vello::Renderer,
420 view: &wgpu::TextureView,
421 n: u32,
422) -> f64 {
423 let mut scene = vello::Scene::new();
424 let mut samples: Vec<f64> = Vec::with_capacity(MEASURED);
425 for frame in 0..(WARMUP + MEASURED) {
426 let t0 = Instant::now();
427 scene.reset();
428 let mut state: u32 = 0x1234_5678;
429 for _ in 0..n {
430 let (x, y, rgba) = lcg_point(&mut state);
431 let r = (rgba & 0xFF) as u8;
432 let g = ((rgba >> 8) & 0xFF) as u8;
433 let b = ((rgba >> 16) & 0xFF) as u8;
434 let a = ((rgba >> 24) & 0xFF) as u8;
435 let xf = x as f64;
436 let yf = y as f64;
437 scene.fill(
438 Fill::NonZero,
439 Affine::IDENTITY,
440 Color::from_rgba8(r, g, b, a),
441 None,
442 &Rect::new(xf, yf, xf + POINT_PX as f64, yf + POINT_PX as f64),
443 );
444 }
445 renderer
446 .render_to_texture(
447 &hal.device,
448 &hal.queue,
449 &scene,
450 view,
451 &vello::RenderParams {
452 base_color: palette::css::BLACK,
453 width: W,
454 height: H,
455 antialiasing_method: vello::AaConfig::Area,
456 },
457 )
458 .expect("vello render");
459 hal.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
460 let dt = t0.elapsed().as_secs_f64() * 1000.0;
461 if frame >= WARMUP {
462 samples.push(dt);
463 }
464 }
465 median(&mut samples)
466}
467
468/// Cuántos píxeles del target quedaron distintos del negro opaco con que se
469/// limpia el pase. Es la contraprueba barata de que el draw llegó a la pantalla:
470/// texto, no un PNG que haya que mirar (Regla 8 del repo).
471fn contar_pixeles_pintados(hal: &Hal, target: &wgpu::Texture) -> u64 {
472 let unpadded = (W * 4) as usize;
473 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
474 let padded = unpadded.div_ceil(align) * align;
475 let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
476 label: Some("verificacion-readback"),
477 size: (padded * H as usize) as u64,
478 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
479 mapped_at_creation: false,
480 });
481 let mut encoder = hal
482 .device
483 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
484 encoder.copy_texture_to_buffer(
485 wgpu::TexelCopyTextureInfo {
486 texture: target,
487 mip_level: 0,
488 origin: wgpu::Origin3d::ZERO,
489 aspect: wgpu::TextureAspect::All,
490 },
491 wgpu::TexelCopyBufferInfo {
492 buffer: &buf,
493 layout: wgpu::TexelCopyBufferLayout {
494 offset: 0,
495 bytes_per_row: Some(padded as u32),
496 rows_per_image: Some(H),
497 },
498 },
499 wgpu::Extent3d {
500 width: W,
501 height: H,
502 depth_or_array_layers: 1,
503 },
504 );
505 hal.queue.submit(std::iter::once(encoder.finish()));
506 let slice = buf.slice(..);
507 let (tx, rx) = std::sync::mpsc::channel();
508 slice.map_async(wgpu::MapMode::Read, move |r| {
509 let _ = tx.send(r);
510 });
511 hal.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
512 if rx.recv().is_err() {
513 return 0;
514 }
515 let data = slice.get_mapped_range();
516 let mut n = 0u64;
517 for fila in 0..H as usize {
518 let base = fila * padded;
519 for px in 0..W as usize {
520 let o = base + px * 4;
521 // Distinto del negro en cualquiera de los tres canales de color.
522 if data[o] != 0 || data[o + 1] != 0 || data[o + 2] != 0 {
523 n += 1;
524 }
525 }
526 }
527 drop(data);
528 buf.unmap();
529 n
530}
531
532fn bench_directo(
533 hal: &Hal,
534 pipelines: &GpuPipelines,
535 view: &wgpu::TextureView,
536 n: u32,
537) -> f64 {
538 let mut samples: Vec<f64> = Vec::with_capacity(MEASURED);
539 for frame in 0..(WARMUP + MEASURED) {
540 let t0 = Instant::now();
541 let mut batch = GpuBatch::new(pipelines);
542 let mut state: u32 = 0x1234_5678;
543 for _ in 0..n {
544 let (x, y, rgba) = lcg_point(&mut state);
545 let r = (rgba & 0xFF) as u8;
546 let g = ((rgba >> 8) & 0xFF) as u8;
547 let b = ((rgba >> 16) & 0xFF) as u8;
548 let a = ((rgba >> 24) & 0xFF) as u8;
549 batch.add_rect(x, y, POINT_PX, POINT_PX, Color::from_rgba8(r, g, b, a));
550 }
551 let mut encoder = hal.device.create_command_encoder(
552 &wgpu::CommandEncoderDescriptor {
553 label: Some("bench-directo-enc"),
554 },
555 );
556 batch.flush(
557 &hal.device,
558 &hal.queue,
559 &mut encoder,
560 view,
561 (W as f32, H as f32),
562 wgpu::LoadOp::Clear(wgpu::Color::BLACK),
563 );
564 hal.queue.submit(std::iter::once(encoder.finish()));
565 hal.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
566 let dt = t0.elapsed().as_secs_f64() * 1000.0;
567 if frame >= WARMUP {
568 samples.push(dt);
569 }
570 }
571 median(&mut samples)
572}
573
574/// Vello persistente: la Scene se construye UNA vez (fill N rects) y
575/// el bucle medido sólo invoca `render_to_texture`. Sin `scene.reset()`.
576fn bench_vello_persistent(
577 hal: &Hal,
578 renderer: &mut vello::Renderer,
579 view: &wgpu::TextureView,
580 n: u32,
581) -> f64 {
582 let mut scene = vello::Scene::new();
583 scene.reset();
584 let mut state: u32 = 0x1234_5678;
585 for _ in 0..n {
586 let (x, y, rgba) = lcg_point(&mut state);
587 let r = (rgba & 0xFF) as u8;
588 let g = ((rgba >> 8) & 0xFF) as u8;
589 let b = ((rgba >> 16) & 0xFF) as u8;
590 let a = ((rgba >> 24) & 0xFF) as u8;
591 let xf = x as f64;
592 let yf = y as f64;
593 scene.fill(
594 Fill::NonZero,
595 Affine::IDENTITY,
596 Color::from_rgba8(r, g, b, a),
597 None,
598 &Rect::new(xf, yf, xf + POINT_PX as f64, yf + POINT_PX as f64),
599 );
600 }
601 let mut samples: Vec<f64> = Vec::with_capacity(MEASURED);
602 for frame in 0..(WARMUP + MEASURED) {
603 let t0 = Instant::now();
604 renderer
605 .render_to_texture(
606 &hal.device,
607 &hal.queue,
608 &scene,
609 view,
610 &vello::RenderParams {
611 base_color: palette::css::BLACK,
612 width: W,
613 height: H,
614 antialiasing_method: vello::AaConfig::Area,
615 },
616 )
617 .expect("vello render");
618 hal.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
619 let dt = t0.elapsed().as_secs_f64() * 1000.0;
620 if frame >= WARMUP {
621 samples.push(dt);
622 }
623 }
624 median(&mut samples)
625}
626
627/// GPU directo persistente: instance buffer + uniform buffer + bind
628/// group se construyen UNA vez. Bucle medido sólo abre render_pass,
629/// hace `draw(0..6, 0..n)` y submit.
630///
631/// Replica el layout que pinta `GpuBatch::add_rect` por debajo
632/// (instance stride 20 B = [x:f32, y:f32, w:f32, h:f32, rgba:u32]),
633/// usando el `rects` pipeline + `bind_layout` expuestos por
634/// `GpuPipelines`.
635///
636/// **El attachment tiene que ser multisample.** Los cuatro pipelines de
637/// `GpuPipelines` se compilan con `MSAA_SAMPLES = 4`, así que un pase contra
638/// una textura de un solo sample es incompatible y wgpu lo **rechaza entero**:
639/// no dibuja nada y el bucle termina midiendo un submit vacío. Así estuvo hasta
640/// 2026-08-04, y por eso esta tabla reportaba cifras imposibles —23.000 fps,
641/// 178.000 Mprim/s a 10M— con un «PASA» que no significaba nada. Ahora se
642/// rasteriza al MSAA y se resuelve a un scratch, que es exactamente lo que hace
643/// `GpuBatch::flush` por dentro.
644fn bench_directo_persistent(
645 hal: &Hal,
646 pipelines: &GpuPipelines,
647 target: &wgpu::Texture,
648 view: &wgpu::TextureView,
649 n: u32,
650) -> (f64, u64) {
651 // Empaquetar instancias UNA vez, con el stride que declara el propio
652 // `llimphi-raster`. Escribirlo a mano acá fue el bug: cuando el color del
653 // vértice se ensanchó a `Float32x4` el stride pasó de 20 a 32 B, esto siguió
654 // en 20, y wgpu rechazó cada draw por buffer corto — sin que nadie se
655 // enterara, porque el bench sólo miraba el reloj.
656 let mut bytes = Vec::with_capacity(n as usize * RECT_INSTANCE_STRIDE as usize);
657 let mut state: u32 = 0x1234_5678;
658 for _ in 0..n {
659 let (x, y, rgba) = lcg_point(&mut state);
660 bytes.extend_from_slice(&x.to_ne_bytes());
661 bytes.extend_from_slice(&y.to_ne_bytes());
662 bytes.extend_from_slice(&POINT_PX.to_ne_bytes());
663 bytes.extend_from_slice(&POINT_PX.to_ne_bytes());
664 // Color como 4 × f32 en 0..1, desempaquetando el rgba del LCG.
665 for canal in [rgba & 0xFF, (rgba >> 8) & 0xFF, (rgba >> 16) & 0xFF, (rgba >> 24) & 0xFF] {
666 bytes.extend_from_slice(&(canal as f32 / 255.0).to_ne_bytes());
667 }
668 }
669 assert_eq!(
670 bytes.len() as u32 / n,
671 RECT_INSTANCE_STRIDE,
672 "el empaquetado del bench dejó de casar con el layout de llimphi-raster"
673 );
674 // Techo duro del dispositivo: **un** buffer de instancias no puede pasar
675 // `max_buffer_size`. A 32 B por rect eso son ~8,4 M de primitivas en una
676 // Iris Xe (256 MB). Hasta 2026-08-05 esta fila se saltaba con «no entra» —
677 // honesto, pero era el bench declarando un techo del camino directo que en
678 // realidad era del bench. Ahora se parte en lotes con la MISMA aritmética
679 // que usa `GpuBatch::flush` (`units_per_chunk`), así el bench no puede
680 // volver a divergir del motor como pasó con el stride.
681 let techo = hal.device.limits().max_buffer_size;
682 let por_lote = units_per_chunk(RECT_INSTANCE_STRIDE, techo, 1) as usize;
683 let lotes: Vec<(wgpu::Buffer, u32)> = bytes
684 .chunks(por_lote * RECT_INSTANCE_STRIDE as usize)
685 .map(|trozo| {
686 let b = hal.device.create_buffer(&wgpu::BufferDescriptor {
687 label: Some("persist-rects"),
688 size: trozo.len() as u64,
689 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
690 mapped_at_creation: false,
691 });
692 hal.queue.write_buffer(&b, 0, trozo);
693 (b, (trozo.len() / RECT_INSTANCE_STRIDE as usize) as u32)
694 })
695 .collect();
696 if lotes.len() > 1 {
697 println!(
698 " <sub>{} en {} lotes de ≤{} (techo {} MB)</sub>",
699 fmt_int(n),
700 lotes.len(),
701 fmt_int(por_lote as u32),
702 techo / (1024 * 1024)
703 );
704 }
705
706 // Uniforms. **32 bytes, no 16**, y ésta es la SEGUNDA vez que este bench se
707 // desincroniza del motor de la misma manera (la primera fue el stride de
708 // instancia, ver el comentario de arriba).
709 //
710 // El uniform del shader creció de 16 a 32 B el 2026-08-05, cuando el scissor
711 // sumó `origin` (la esquina del rect sucio, para que el vertex shader corra
712 // las coordenadas) y `line_width` se fue a viajar por instancia. Este bench
713 // siguió escribiendo 16, y wgpu rechazó **todos** los draws del camino
714 // persistente con «bound with size 16 where the shader expects 32». Como los
715 // errores de wgpu acá son no-fatales, el bucle igual cronometró: reportaba
716 // **37.844 fps y 427.186 Mprim/s**, o sea la nada medida con precisión
717 // admirable. Lo que lo delató no fue el tiempo —era espectacular— sino la
718 // columna `px pintados`, constante en 603.014 para todos los N.
719 //
720 // Layout, igual que `Uniforms` en el WGSL de `llimphi-raster::gpu`:
721 // dst_size: vec2<f32> — tamaño del target
722 // origin: vec2<f32> — esquina del rect sucio; (0,0) sin scissor
723 // _pad0: vec4<f32> — relleno hasta 32 B
724 let u_data: [f32; 8] = [W as f32, H as f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
725 let mut u_bytes = Vec::with_capacity(32);
726 for v in u_data {
727 u_bytes.extend_from_slice(&v.to_ne_bytes());
728 }
729 let uniforms = hal.device.create_buffer(&wgpu::BufferDescriptor {
730 label: Some("persist-uniforms"),
731 size: 32,
732 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
733 mapped_at_creation: false,
734 });
735 hal.queue.write_buffer(&uniforms, 0, &u_bytes);
736
737 let bind_group = hal.device.create_bind_group(&wgpu::BindGroupDescriptor {
738 label: Some("persist-bg"),
739 layout: &pipelines.bind_layout,
740 entries: &[wgpu::BindGroupEntry {
741 binding: 0,
742 resource: uniforms.as_entire_binding(),
743 }],
744 });
745
746 // Attachment multisample + resolve al `view` single-sample. Se crea UNA
747 // vez, fuera del bucle: lo que se mide es el redraw, no la reserva.
748 let msaa_tex = hal.device.create_texture(&wgpu::TextureDescriptor {
749 label: Some("persist-msaa"),
750 size: wgpu::Extent3d {
751 width: W,
752 height: H,
753 depth_or_array_layers: 1,
754 },
755 mip_level_count: 1,
756 sample_count: 4,
757 dimension: wgpu::TextureDimension::D2,
758 format: FMT,
759 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
760 view_formats: &[],
761 });
762 let msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default());
763
764 // **Borrar el target a negro ANTES de medir.** Sin esto, `px pintados`
765 // cuenta lo que dejó la corrida anterior y un pase que wgpu rechaza entero
766 // sigue reportando cientos de miles de píxeles «pintados». Fue justo lo que
767 // pasó con el uniform de 16 B: el número quedó clavado en 603.014 para todos
768 // los N y la tabla parecía sana. Con el borrado, un pase que no dibuja deja
769 // el lienzo negro y el conteo cae a cero, que es una señal que no se puede
770 // leer mal.
771 limpiar_target(hal, view);
772
773 // Asegurar que toda la escritura previa esté en la GPU antes de
774 // empezar a medir frames — si no, el primer frame paga el upload.
775 hal.queue.submit(std::iter::empty::<wgpu::CommandBuffer>());
776 hal.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
777
778 let mut samples: Vec<f64> = Vec::with_capacity(MEASURED);
779 for frame in 0..(WARMUP + MEASURED) {
780 let t0 = Instant::now();
781 let mut encoder = hal.device.create_command_encoder(
782 &wgpu::CommandEncoderDescriptor {
783 label: Some("persist-enc"),
784 },
785 );
786 {
787 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
788 label: Some("persist-pass"),
789 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
790 view: &msaa_view,
791 resolve_target: Some(view),
792 depth_slice: None,
793 ops: wgpu::Operations {
794 load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
795 store: wgpu::StoreOp::Store,
796 },
797 })],
798 depth_stencil_attachment: None,
799 timestamp_writes: None,
800 occlusion_query_set: None,
801 multiview_mask: None,
802 });
803 pass.set_pipeline(&pipelines.rects);
804 pass.set_bind_group(0, &bind_group, &[]);
805 for (buf, cuantos) in &lotes {
806 pass.set_vertex_buffer(0, buf.slice(..));
807 pass.draw(0..6, 0..*cuantos);
808 }
809 }
810 hal.queue.submit(std::iter::once(encoder.finish()));
811 hal.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
812 let dt = t0.elapsed().as_secs_f64() * 1000.0;
813 if frame >= WARMUP {
814 samples.push(dt);
815 }
816 }
817 // **Verificar que se dibujó algo.** Un bench que sólo cronometra puede estar
818 // midiendo la nada con una precisión admirable: si el pase no emite píxeles
819 // —porque wgpu lo rechazó, porque el buffer no casa con el layout, porque
820 // el viewport está mal— el bucle igual devuelve un número, y encima
821 // buenísimo. Ese fue exactamente el caso hasta 2026-08-04. El conteo va al
822 // lado del tiempo para que la tabla se pueda desestimar de un vistazo.
823 let pintados = contar_pixeles_pintados(hal, target);
824 // Y ahora sí se JUZGA, no sólo se imprime. Una tabla con un número al lado
825 // que nadie mira no protege de nada: el uniform desincronizado sobrevivió
826 // exactamente así, con la columna a la vista.
827 assert!(
828 pintados > 0,
829 "el camino persistente no pintó UN solo píxel con N={n}, así que los \
830 milisegundos que acaba de medir son la nada. Casi siempre es que el \
831 bench se desincronizó del motor —el stride de instancia o el tamaño del \
832 uniform— y wgpu rechaza los draws sin ser fatal. Corré sin tapar stderr: \
833 `llimphi-hal` imprime el error de validación con el número exacto."
834 );
835 (median(&mut samples), pintados)
836}
837
838/// Deja el target en negro opaco. Es la línea de base de
839/// [`contar_pixeles_pintados`]: sin esto, el conteo mide sedimento de la corrida
840/// anterior en vez del cuadro que se acaba de dibujar.
841fn limpiar_target(hal: &Hal, view: &wgpu::TextureView) {
842 let mut enc = hal
843 .device
844 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
845 label: Some("limpiar-target"),
846 });
847 {
848 let _p = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
849 label: Some("limpiar-pass"),
850 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
851 view,
852 resolve_target: None,
853 depth_slice: None,
854 ops: wgpu::Operations {
855 load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
856 store: wgpu::StoreOp::Store,
857 },
858 })],
859 depth_stencil_attachment: None,
860 timestamp_writes: None,
861 occlusion_query_set: None,
862 multiview_mask: None,
863 });
864 }
865 hal.queue.submit(std::iter::once(enc.finish()));
866 hal.device
867 .poll(wgpu::PollType::wait_indefinitely())
868 .expect("poll");
869}
870
871fn lcg_point(state: &mut u32) -> (f32, f32, u32) {
872 // **Los bits ALTOS, no el módulo.** En un LCG mod 2^32 el bit k tiene
873 // período 2^k, así que `state % 1024` —los 10 bits bajos— se repite cada
874 // 1024 pasos: el bench dibujaba las mismas ~1.500 posiciones una y otra vez
875 // sin importar si le pedías 100 mil puntos o 5 millones. Se veía en que el
876 // conteo de píxeles pintados daba 9.198 para TODAS las tallas. Con los bits
877 // altos el reparto cubre el lienzo de verdad, que es lo que hace que el
878 // número de fill-rate signifique algo.
879 *state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
880 let x = ((*state >> 16) % W) as f32;
881 *state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
882 let y = ((*state >> 16) % H) as f32;
883 *state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
884 // Colores: piso 128 por canal para que las PNGs de verificación
885 // se vean (sin esto el LCG produce muchos negros casi puros, y
886 // los puntos quedan invisibles en pantalla aunque estén pintados).
887 let r = 128 | ((*state >> 0) & 0x7F) as u8;
888 let g = 128 | ((*state >> 8) & 0x7F) as u8;
889 let b = 128 | ((*state >> 16) & 0x7F) as u8;
890 let rgba = (r as u32) | ((g as u32) << 8) | ((b as u32) << 16) | 0xFF00_0000;
891 (x, y, rgba)
892}
893
894const POINT_PX: f32 = 2.5;
895
896fn median(samples: &mut [f64]) -> f64 {
897 samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
898 samples[samples.len() / 2]
899}
900
901// ============================================================
902// Veredictos
903// ============================================================
904
905fn print_spike_verdict(rows: &[SpikeRow]) {
906 let at_500k = rows.iter().find(|r| r.n == 500_000);
907 match at_500k {
908 Some(r) if r.vello_crashed => {
909 println!("**Veredicto Fase 0:** Vello revienta antes de 500K → directo es el único path posible en ese régimen. PASA cualitativo.");
910 }
911 Some(r) => match r.factor {
912 Some(f) if f >= 5.0 => {
913 println!("**Veredicto Fase 0:** factor a 500K = {:.2}× ≥ 5 → **PASA** (criterio SDD cumplido).", f);
914 }
915 Some(f) => {
916 println!("**Veredicto Fase 0:** factor a 500K = {:.2}× < 5 → **ABORTAR** según criterio literal del SDD.", f);
917 println!("Pero ver si vello revienta a tamaños mayores — eso cambia el veredicto cualitativamente.");
918 }
919 None => {
920 println!("**Veredicto Fase 0:** sin datos para 500K (vello crashed o N no medido). Revisar tabla arriba.");
921 }
922 },
923 None => {
924 println!("**Veredicto Fase 0:** no se midió 500K en este run. Revisar tabla arriba.");
925 }
926 }
927 println!();
928}
929
930fn print_persistent_verdict(
931 directo: &[ScaleRow],
932 vello: &[(u32, f64)],
933) {
934 let d_1m = directo.iter().find(|r| r.n == 1_000_000);
935 let v_1m = vello.iter().find(|(n, _)| *n == 1_000_000);
936 match d_1m {
937 Some(r) if r.fps >= 60.0 => {
938 println!(
939 "**Veredicto persistente @ 1M:** directo {:.1} fps ≥ 60 → **PASA**.",
940 r.fps
941 );
942 }
943 Some(r) => {
944 println!(
945 "**Veredicto persistente @ 1M:** directo {:.1} fps < 60 → falla incluso sin rebuild.",
946 r.fps
947 );
948 }
949 None => println!("**Veredicto:** sin datos a 1M."),
950 }
951 if let (Some(d), Some((_, v_ms))) = (d_1m, v_1m) {
952 let factor = v_ms / d.ms;
953 println!(
954 "**Factor persistente @ 1M:** vello {:.1} ms / directo {:.1} ms = {:.2}× ({})",
955 v_ms,
956 d.ms,
957 factor,
958 if factor >= 5.0 { "≥5×" } else { "<5×" }
959 );
960 }
961 println!();
962}
963
964fn print_scale_verdict(rows: &[ScaleRow]) {
965 let at_1m = rows.iter().find(|r| r.n == 1_000_000);
966 match at_1m {
967 Some(r) if r.fps >= 60.0 => {
968 println!("**Veredicto Fase 0 (objetivo 60 fps @ 1M):** {:.1} fps ≥ 60 → **PASA**.", r.fps);
969 }
970 Some(r) => {
971 println!("**Veredicto Fase 0 (objetivo 60 fps @ 1M):** {:.1} fps < 60 → marginal. ¿Es CPU-bound el bench (write_buffer de 12-20 MB por frame)? Probar también con `mapped_at_creation` para sacar el camino más rápido.", r.fps);
972 }
973 None => {
974 println!("**Veredicto:** sin datos para 1M.");
975 }
976 }
977 println!();
978}
979
980fn print_summary(
981 spike: &[SpikeRow],
982 scale: &[ScaleRow],
983 persist_directo: &[ScaleRow],
984 persist_vello: &[(u32, f64)],
985) {
986 println!("Copiar lo que sigue al chat:");
987 println!();
988 println!("```");
989 println!("rebuild por frame — vello vs directo:");
990 for r in spike {
991 let v = match (r.vello_crashed, r.vello_ms) {
992 (true, _) => "crash".to_string(),
993 (_, Some(ms)) => format!("{:.1}ms", ms),
994 _ => "-".to_string(),
995 };
996 let f = r
997 .factor
998 .map(|x| format!("{:.2}x", x))
999 .unwrap_or_else(|| "-".to_string());
1000 println!(" {:>10} vello={:>10} directo={:>7.1}ms factor={}", fmt_int(r.n), v, r.directo_ms, f);
1001 }
1002 println!();
1003 println!("rebuild por frame — escalado directo:");
1004 for r in scale {
1005 println!(" {:>10} {:>7.1}ms {:>5.1}fps {:>5.2}Mprim/s", fmt_int(r.n), r.ms, r.fps, r.mps);
1006 }
1007 println!();
1008 println!("persistente (datos fijos, sólo redraw):");
1009 for r in persist_directo {
1010 let v_ms = persist_vello
1011 .iter()
1012 .find(|(n, _)| *n == r.n)
1013 .map(|(_, ms)| format!("{:>7.1}ms", ms))
1014 .unwrap_or_else(|| " —".to_string());
1015 let factor = persist_vello
1016 .iter()
1017 .find(|(n, _)| *n == r.n)
1018 .map(|(_, vms)| format!("factor={:.2}x", vms / r.ms))
1019 .unwrap_or_else(|| "factor= — ".to_string());
1020 println!(
1021 " {:>10} vello={} directo={:>7.1}ms {} {:>5.1}fps {:>5.2}Mprim/s",
1022 fmt_int(r.n),
1023 v_ms,
1024 r.ms,
1025 factor,
1026 r.fps,
1027 r.mps,
1028 );
1029 }
1030 println!("```");
1031}
1032
1033// ============================================================
1034// Textura destino + PNG export
1035// ============================================================
1036
1037fn make_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) {
1038 let tex = device.create_texture(&wgpu::TextureDescriptor {
1039 label: Some("bench-target"),
1040 size: wgpu::Extent3d {
1041 width: W,
1042 height: H,
1043 depth_or_array_layers: 1,
1044 },
1045 mip_level_count: 1,
1046 sample_count: 1,
1047 dimension: wgpu::TextureDimension::D2,
1048 format: FMT,
1049 // RENDER_ATTACHMENT para el directo, STORAGE_BINDING para vello,
1050 // TEXTURE_BINDING + COPY_SRC para poder leer (PNG export).
1051 usage: wgpu::TextureUsages::RENDER_ATTACHMENT
1052 | wgpu::TextureUsages::STORAGE_BINDING
1053 | wgpu::TextureUsages::TEXTURE_BINDING
1054 | wgpu::TextureUsages::COPY_SRC,
1055 view_formats: &[],
1056 });
1057 let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
1058 (tex, view)
1059}
1060
1061fn export_vello_png(
1062 hal: &Hal,
1063 renderer: &mut vello::Renderer,
1064 target: &wgpu::Texture,
1065 view: &wgpu::TextureView,
1066 n: u32,
1067 path: &str,
1068) -> Result<(), String> {
1069 let mut scene = vello::Scene::new();
1070 let mut state: u32 = 0x1234_5678;
1071 for _ in 0..n {
1072 let (x, y, rgba) = lcg_point(&mut state);
1073 let r = (rgba & 0xFF) as u8;
1074 let g = ((rgba >> 8) & 0xFF) as u8;
1075 let b = ((rgba >> 16) & 0xFF) as u8;
1076 let a = ((rgba >> 24) & 0xFF) as u8;
1077 scene.fill(
1078 Fill::NonZero,
1079 Affine::IDENTITY,
1080 Color::from_rgba8(r, g, b, a),
1081 None,
1082 &Rect::new(x as f64, y as f64, x as f64 + POINT_PX as f64, y as f64 + POINT_PX as f64),
1083 );
1084 }
1085 renderer
1086 .render_to_texture(
1087 &hal.device,
1088 &hal.queue,
1089 &scene,
1090 view,
1091 &vello::RenderParams {
1092 base_color: palette::css::BLACK,
1093 width: W,
1094 height: H,
1095 antialiasing_method: vello::AaConfig::Area,
1096 },
1097 )
1098 .map_err(|e| format!("{e:?}"))?;
1099 write_texture_png(hal, target, path)
1100}
1101
1102fn export_directo_png(
1103 hal: &Hal,
1104 pipelines: &GpuPipelines,
1105 target: &wgpu::Texture,
1106 view: &wgpu::TextureView,
1107 n: u32,
1108 path: &str,
1109) -> Result<(), String> {
1110 let mut batch = GpuBatch::new(pipelines);
1111 let mut state: u32 = 0x1234_5678;
1112 for _ in 0..n {
1113 let (x, y, rgba) = lcg_point(&mut state);
1114 let r = (rgba & 0xFF) as u8;
1115 let g = ((rgba >> 8) & 0xFF) as u8;
1116 let b = ((rgba >> 16) & 0xFF) as u8;
1117 let a = ((rgba >> 24) & 0xFF) as u8;
1118 batch.add_rect(x, y, POINT_PX, POINT_PX, Color::from_rgba8(r, g, b, a));
1119 }
1120 let mut encoder = hal.device.create_command_encoder(
1121 &wgpu::CommandEncoderDescriptor {
1122 label: Some("png-directo-enc"),
1123 },
1124 );
1125 batch.flush(
1126 &hal.device,
1127 &hal.queue,
1128 &mut encoder,
1129 view,
1130 (W as f32, H as f32),
1131 wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1132 );
1133 hal.queue.submit(std::iter::once(encoder.finish()));
1134 hal.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
1135 write_texture_png(hal, target, path)
1136}
1137
1138/// Copia la textura a un buffer mapeable + lee + escribe PNG.
1139fn write_texture_png(hal: &Hal, target: &wgpu::Texture, path: &str) -> Result<(), String> {
1140 // wgpu pide stride alineado a 256 B en COPY_TEXTURE_TO_BUFFER.
1141 let unpadded = (W * 4) as usize;
1142 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
1143 let padded = ((unpadded + align - 1) / align) * align;
1144 let buf_size = (padded * H as usize) as u64;
1145
1146 let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
1147 label: Some("png-readback"),
1148 size: buf_size,
1149 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
1150 mapped_at_creation: false,
1151 });
1152 let mut encoder = hal.device.create_command_encoder(
1153 &wgpu::CommandEncoderDescriptor {
1154 label: Some("png-copy-enc"),
1155 },
1156 );
1157 encoder.copy_texture_to_buffer(
1158 wgpu::TexelCopyTextureInfo {
1159 texture: target,
1160 mip_level: 0,
1161 origin: wgpu::Origin3d::ZERO,
1162 aspect: wgpu::TextureAspect::All,
1163 },
1164 wgpu::TexelCopyBufferInfo {
1165 buffer: &buf,
1166 layout: wgpu::TexelCopyBufferLayout {
1167 offset: 0,
1168 bytes_per_row: Some(padded as u32),
1169 rows_per_image: Some(H),
1170 },
1171 },
1172 wgpu::Extent3d {
1173 width: W,
1174 height: H,
1175 depth_or_array_layers: 1,
1176 },
1177 );
1178 hal.queue.submit(std::iter::once(encoder.finish()));
1179
1180 let slice = buf.slice(..);
1181 let (tx, rx) = std::sync::mpsc::channel();
1182 slice.map_async(wgpu::MapMode::Read, move |r| {
1183 let _ = tx.send(r);
1184 });
1185 hal.device.poll(wgpu::PollType::wait_indefinitely()).expect("poll");
1186 rx.recv().map_err(|e| e.to_string())?.map_err(|e| e.to_string())?;
1187 let data = slice.get_mapped_range();
1188
1189 // Desempaquetar las filas (skip padding) y escribir PNG.
1190 let mut pixels = Vec::with_capacity((W * H * 4) as usize);
1191 for row in 0..H {
1192 let start = row as usize * padded;
1193 let end = start + unpadded;
1194 pixels.extend_from_slice(&data[start..end]);
1195 }
1196 drop(data);
1197 buf.unmap();
1198
1199 let file = File::create(path).map_err(|e| e.to_string())?;
1200 let writer = BufWriter::new(file);
1201 let mut encoder = png::Encoder::new(writer, W, H);
1202 encoder.set_color(png::ColorType::Rgba);
1203 encoder.set_depth(png::BitDepth::Eight);
1204 let mut w = encoder.write_header().map_err(|e| e.to_string())?;
1205 w.write_image_data(&pixels).map_err(|e| e.to_string())?;
1206 Ok(())
1207}