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//! **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}