tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-hybrid/tests/hybrid_vs_cpu.rs Rust · 267 líneas · 9.8 KB Historia Quién tocó qué crudo

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

1//! **El camino del Mali contra el de referencia: ¿pintan lo mismo?**
2//!
3//! `llimphi-hybrid` reproduce la `Escena` grabada contra `vello_hybrid`, el
4//! rasterizador CPU+GPU **sin compute shaders**. Es el único camino a píxeles en
5//! GPUs cuyo compilador no digiere el compute de vello — el Mali-G57 del
6//! teléfono, medido en `android/DIAGNOSTICO.md`.
7//!
8//! El crate ya traía 11 tests, y son buenos — pero todos preguntan lo mismo:
9//! *¿hace lo que dice?* «La capa con alfa atenúa», «el trazo deja hueco
10//! adentro», «la máscara de luminancia recorta por brillo». Son afirmaciones
11//! **absolutas**, verificadas contra la intención.
12//!
13//! Lo que ninguno pregunta es *¿hace lo MISMO?*. Un `push_layer` que aplicara
14//! el alfa un 10 % distinto sigue atenuando, y sigue recortando, y pasa los
15//! once — pero el usuario del teléfono vería otra pantalla que el de
16//! escritorio. Esa comparación es la que faltaba, y necesita el otro
17//! rasterizador al lado, no una lista de propiedades.
18//!
19//! Acá los dos pintores consumen la **misma `Escena`**, así que no hay
20//! compositor ni layout de por medio: lo único que se compara es el
21//! rasterizado. Y como la `Escena` se arma a mano con la API de `llimphi-cpu`,
22//! este test no necesita ninguno de los backends excluyentes de `llimphi-text`
23//! y vive tranquilo dentro del workspace.
24//!
25//! **Sin glifos, y por la misma razón que el cable no lleva texto**: emitir una
26//! orden de texto pide un shaper, y el shaper vive detrás de un backend de
27//! `llimphi-text` que este build no tiene. El texto de wawa ya está comparado
28//! contra el de Linux en `llimphi-test/tests/cpu_vs_gpu.rs`.
29//!
30//! Certificado por texto (§8): conteos de píxeles, llvmpipe.
31
32use llimphi_cpu::escena::Escena;
33use llimphi_cpu::lienzo::FormatoPixel;
34use llimphi_cpu::pintor::Pintor as PintorCpu;
35use llimphi_hybrid::Pintor as PintorHybrid;
36use llimphi_cpu::kurbo::{Affine, Rect, RoundedRect, Stroke};
37use llimphi_cpu::peniko::color::{AlphaColor, Srgb};
38use llimphi_cpu::peniko::{BlendMode, Fill};
39
40const ANCHO: u32 = 160;
41const ALTO: u32 = 120;
42const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
43
44fn c(r: u8, g: u8, b: u8, a: u8) -> AlphaColor<Srgb> {
45    AlphaColor::from_rgba8(r, g, b, a)
46}
47
48/// La escena de prueba, armada con las órdenes que una UI de verdad emite: un
49/// relleno alineado a la grilla (sin AA), uno redondeado y su trazo (AA de
50/// curva), una capa con alfa (composición) y un rect difuso (blur).
51///
52/// Cada rasgo va en una zona distinta del lienzo para que, si divergen, el
53/// conteo por franja diga en cuál.
54fn escena_patron() -> Escena {
55    let mut e = Escena::new();
56
57    // 1. Relleno alineado a la grilla.
58    e.fill(
59        Fill::NonZero,
60        Affine::IDENTITY,
61        c(220, 60, 60, 255),
62        None,
63        &Rect::new(8.0, 8.0, 68.0, 34.0),
64    );
65
66    // 2. Redondeado + trazo: acá entra el antialiasing de curvas.
67    let rr = RoundedRect::new(80.0, 8.0, 150.0, 40.0, 9.0);
68    e.fill(Fill::NonZero, Affine::IDENTITY, c(60, 200, 120, 255), None, &rr);
69    e.stroke(
70        &Stroke::new(2.0),
71        Affine::IDENTITY,
72        c(240, 220, 60, 255),
73        None,
74        &rr,
75    );
76
77    // 3. Capa con alfa: componer es donde una diferencia de orden se ve entera.
78    e.push_layer(
79        Fill::NonZero,
80        BlendMode::default(),
81        0.5,
82        Affine::IDENTITY,
83        &Rect::new(8.0, 50.0, 150.0, 84.0),
84    );
85    e.fill(
86        Fill::NonZero,
87        Affine::IDENTITY,
88        c(240, 80, 40, 255),
89        None,
90        &Rect::new(8.0, 50.0, 150.0, 84.0),
91    );
92    e.fill(
93        Fill::NonZero,
94        Affine::IDENTITY,
95        c(40, 240, 200, 255),
96        None,
97        &Rect::new(20.0, 58.0, 80.0, 76.0),
98    );
99    e.pop_layer();
100
101    // 4. Rect difuso: el rasgo con más aritmética.
102    e.draw_blurred_rounded_rect(
103        Affine::IDENTITY,
104        Rect::new(30.0, 92.0, 120.0, 112.0),
105        c(200, 200, 255, 200),
106        8.0,
107        4.0,
108    );
109
110    e
111}
112
113fn por_cpu(e: &Escena) -> Vec<u8> {
114    let mut pintor = PintorCpu::new(ANCHO as u16, ALTO as u16);
115    let mut lienzo = pintor.lienzo_nuevo();
116    pintor.pintar(e, &mut lienzo);
117    let mut px = vec![0u8; (ANCHO * ALTO * 4) as usize];
118    llimphi_cpu::volcar(&lienzo, &mut px, (ANCHO * 4) as usize, FormatoPixel::Rgba8);
119    px
120}
121
122fn por_hybrid(e: &Escena) -> Vec<u8> {
123    // El adapter de software (llvmpipe), igual que el arnés de `llimphi-test`:
124    // así el número es el mismo en cualquier máquina y en CI.
125    let hal = pollster::block_on(llimphi_hal::Hal::new_headless(true)).expect("hal headless");
126    let target = hal.device.create_texture(&wgpu::TextureDescriptor {
127        label: Some("hybrid-target"),
128        size: wgpu::Extent3d { width: ANCHO, height: ALTO, depth_or_array_layers: 1 },
129        mip_level_count: 1,
130        sample_count: 1,
131        dimension: wgpu::TextureDimension::D2,
132        format: FMT,
133        usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
134        view_formats: &[],
135    });
136    let vista = target.create_view(&wgpu::TextureViewDescriptor::default());
137    let backend = hal.adapter.get_info().backend;
138    let mut pintor = PintorHybrid::new(&hal.device, backend, FMT, ANCHO, ALTO);
139    let mut enc = hal
140        .device
141        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
142    pintor
143        .pintar(
144            e,
145            &hal.device,
146            &hal.queue,
147            &mut enc,
148            &vista,
149            c(0, 0, 0, 255),
150        )
151        .expect("hybrid rindió");
152    hal.queue.submit(std::iter::once(enc.finish()));
153
154    // Readback con el padding de fila que exige wgpu.
155    let unpadded = ANCHO as usize * 4;
156    let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
157    let padded = unpadded.div_ceil(align) * align;
158    let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
159        label: Some("hybrid-readback"),
160        size: (padded * ALTO as usize) as u64,
161        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
162        mapped_at_creation: false,
163    });
164    let mut enc = hal
165        .device
166        .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
167    enc.copy_texture_to_buffer(
168        wgpu::TexelCopyTextureInfo {
169            texture: &target,
170            mip_level: 0,
171            origin: wgpu::Origin3d::ZERO,
172            aspect: wgpu::TextureAspect::All,
173        },
174        wgpu::TexelCopyBufferInfo {
175            buffer: &buf,
176            layout: wgpu::TexelCopyBufferLayout {
177                offset: 0,
178                bytes_per_row: Some(padded as u32),
179                rows_per_image: Some(ALTO),
180            },
181        },
182        wgpu::Extent3d { width: ANCHO, height: ALTO, depth_or_array_layers: 1 },
183    );
184    hal.queue.submit(std::iter::once(enc.finish()));
185    let slice = buf.slice(..);
186    let (tx, rx) = std::sync::mpsc::channel();
187    slice.map_async(wgpu::MapMode::Read, move |r| {
188        let _ = tx.send(r);
189    });
190    let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
191    rx.recv().unwrap().unwrap();
192    let data = slice.get_mapped_range();
193    let mut out = Vec::with_capacity((ANCHO * ALTO * 4) as usize);
194    for fila in 0..ALTO as usize {
195        let s = fila * padded;
196        out.extend_from_slice(&data[s..s + unpadded]);
197    }
198    drop(data);
199    buf.unmap();
200    out
201}
202
203/// Píxeles cuyo peor canal difiere más de `tol`.
204fn distintos(a: &[u8], b: &[u8], tol: u8) -> usize {
205    a.chunks_exact(4)
206        .zip(b.chunks_exact(4))
207        .filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol))
208        .count()
209}
210
211/// Tinta total: agarra el desvío global de brillo que una tolerancia por píxel
212/// puede dejar pasar repartido en muchos píxeles con delta chico.
213fn tinta(v: &[u8]) -> u64 {
214    v.chunks_exact(4)
215        .map(|p| p[0] as u64 + p[1] as u64 + p[2] as u64)
216        .sum()
217}
218
219#[test]
220fn el_rasterizador_sin_compute_pinta_lo_mismo_que_el_de_cpu() {
221    let e = escena_patron();
222    let cpu = por_cpu(&e);
223    let hybrid = por_hybrid(&e);
224    assert_eq!(cpu.len(), hybrid.len());
225
226    let total = (ANCHO * ALTO) as usize;
227    let escalera: Vec<String> = [0u8, 1, 4, 16, 64, 128]
228        .iter()
229        .map(|t| format!("Δ{t}:{}", distintos(&cpu, &hybrid, *t)))
230        .collect();
231    let (t_cpu, t_hy) = (tinta(&cpu) as f64, tinta(&hybrid) as f64);
232    // Evidencia por texto: los números quedan impresos corra o falle.
233    println!("hybrid vs cpu · {}", escalera.join(" "));
234    println!("hybrid vs cpu · tinta {:+.2} %", (t_hy / t_cpu - 1.0) * 100.0);
235
236    // Guarda del arnés: si la escena no pintara nada, dos lienzos negros
237    // coincidirían perfecto y el test firmaría cualquier cosa.
238    let pintados = cpu
239        .chunks_exact(4)
240        .filter(|p| p[0] > 8 || p[1] > 8 || p[2] > 8)
241        .count();
242    assert!(
243        pintados > total / 8,
244        "la escena sólo pintó {pintados} de {total} px; el test no compara un dibujo"
245    );
246
247    // **Umbral sobre lo medido, y salió mejor de lo que se suponía.** Medido el
248    // 2026-08-05: `Δ0:236 Δ1:0` — o sea que 236 de 19.200 px difieren, y
249    // **ninguno por más de 1/255**. Dos rasterizadores distintos, uno por CPU y
250    // otro por GPU sin compute, y la diferencia es redondeo.
251    //
252    // Por eso el umbral es duro: **cero píxeles pueden diferir más de Δ1**. Un
253    // tope del 5 % sobre Δ16, que era lo prudente antes de medir, no habría
254    // agarrado nunca nada — y un umbral que nada roza no es un gate.
255    let sobre = distintos(&cpu, &hybrid, 1);
256    assert_eq!(
257        sobre, 0,
258        "{sobre} de {total} px difieren más de Δ1 — el camino del Mali se \
259         separó del de referencia"
260    );
261    let desvio = (t_hy - t_cpu).abs() / t_cpu;
262    assert!(
263        desvio <= 0.002,
264        "la tinta total se corrió {:.2} % — un lado pinta más grueso que el otro",
265        desvio * 100.0
266    );
267}