gioser

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Sutichaykuna

⎇ 168cd187519c0ad575d3ce63baee2f164c38d9d1 llimphi-gpu-bench/src/main.rs Rust · 1207 siq'ikuna · 44.3 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

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}