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//! **¿La UI de wawa se ve como la de Linux?**
2//!
3//! Llimphi tiene dos rasterizadores: `vello` (GPU/compute, lo que corre en
4//! Linux) y `vello_cpu` vía `llimphi-cpu` (lo que corre en el kernel bare-metal
5//! de wawa, y también en el camino Android sin compute). El compositor es el
6//! mismo —`paint` emite las mismas órdenes de dibujo— pero **nadie había
7//! comparado nunca la salida**. La única forma de enterarse de una divergencia
8//! era bootear wawa y mirar la pantalla.
9//!
10//! No se puede comparar en un solo binario: los backends de `llimphi-text`
11//! (`cpu` y `vello`) son excluyentes y `llimphi-wawa` vive en un workspace
12//! aparte justamente por eso. Así que la comparación es en dos mitades sobre un
13//! árbol compartido (`escena_patron.rs`, incluido por los dos con `include!`):
14//! el volcador de wawa escribe RGBA8 crudo, esto lo lee y lo compara contra su
15//! propio render por GPU.
16//!
17//! ```bash
18//! ./scripts/check-llimphi-cpu-vs-gpu.sh
19//! ```
20//!
21//! **Sin los volcados, este test se saltea** con un mensaje que dice cómo
22//! generarlos — igual que `llimphi-image/tests/icc_reales.rs` con los perfiles
23//! del sistema. Un test que exigiera un artefacto de otro workspace rompería
24//! `cargo test -p llimphi-test` para cualquiera.
25//!
26//! ## Qué se afirma y qué no
27//!
28//! **No** se afirma igualdad bit a bit, y sería un error afirmarla: son dos
29//! rasterizadores distintos y el antialiasing tiene todo el derecho a diferir.
30//! Lo que se afirma es que la diferencia está **acotada y caracterizada**, con
31//! un umbral por patrón puesto sobre lo medido y escrito acá abajo. Si mañana
32//! alguien toca el compositor y wawa empieza a pintar otra cosa, esto lo dice.
33
34use llimphi_compositor::paint;
35use llimphi_test::{default_bg, diff, with_harness, Rendered};
36
37include!("../../escena_patron.rs");
38
39/// Umbrales por patrón, **puestos sobre lo medido el 2026-08-05** (llvmpipe
40/// contra `vello_cpu`, 200×150 = 30.000 px). El par es `(máximo delta de canal
41/// tolerado, fracción máxima de píxeles que puede superarlo)`.
42///
43/// Lo medido, que salió mucho mejor de lo que se suponía:
44///
45/// | patrón | Δ0 | Δ1 | Δ4 | Δ16 | Δ64 | Δ128 | Δ máx |
46/// |---|---:|---:|---:|---:|---:|---:|---:|
47/// | `rects` | 0 | 0 | 0 | 0 | 0 | 0 | **0** |
48/// | `alpha` | 0 | 0 | 0 | 0 | 0 | 0 | **0** |
49/// | `sombra` | 3.369 | 14 | 0 | 0 | 0 | 0 | 2 |
50/// | `radios` | 148 | 92 | 60 | 36 | 0 | 0 | 26 |
51/// | `texto` | 1.973 | 1.877 | 1.734 | 1.364 | 729 | 430 | 216 |
52///
53/// O sea: **rellenos y composición de capas son bit-idénticos** entre los dos
54/// motores —incluido un `alpha(0.5)` con capa y un fill con alpha 128—, la
55/// sombra difiere ±1 en el área difusa, y el AA de las curvas se separa hasta
56/// 26/255 sólo en la orla. Lo único que diverge de verdad es el **texto**.
57///
58/// Por eso los umbrales de los cuatro primeros son duros —dos de ellos exigen
59/// igualdad bit a bit—: un umbral que nada roza no es un gate. Sólo el texto
60/// lleva holgura, y con su número escrito al lado.
61const UMBRALES: &[(&str, u8, f64)] = &[
62 ("rects", 0, 0.0),
63 ("alpha", 0, 0.0),
64 ("sombra", 4, 0.0),
65 ("radios", 64, 0.0),
66 // 430 px sobre Δ128 = 1,43 %. El tope al 3 % deja margen para una versión
67 // nueva de `vello_cpu` sin volverse decorativo.
68 ("texto", 128, 0.03),
69 // La escalera de tamaños. Los topes son generosos a propósito: estos
70 // patrones no están para hacer de gate sino para **medir** —la pregunta que
71 // contestan es si la divergencia del texto es de la orla o sistemática— y
72 // el gate del texto ya lo hace `texto` de arriba.
73 ("texto_11", 128, 0.05),
74 ("texto_18", 128, 0.05),
75 ("texto_48", 128, 0.10),
76 ("texto_96", 128, 0.20),
77 ("texto_ascii", 128, 0.20),
78 ("texto_acentos", 128, 0.20),
79 ("texto_raya", 128, 0.20),
80];
81
82/// Tinta total de un cuadro (suma de RGB). Complementa al diff por píxel: una
83/// diferencia global de brillo —el texto que sale más flaco de un lado— se
84/// reparte en muchos píxeles con delta chico y puede colarse bajo cualquier
85/// tolerancia por píxel, pero acá se ve de una.
86fn tinta(r: &Rendered) -> u64 {
87 r.rgba
88 .chunks_exact(4)
89 .map(|p| p[0] as u64 + p[1] as u64 + p[2] as u64)
90 .sum()
91}
92
93fn cpu(dir: &std::path::Path, nombre: &str) -> Option<Rendered> {
94 let bytes = std::fs::read(dir.join(format!("{nombre}.rgba"))).ok()?;
95 if bytes.len() != (ANCHO * ALTO * 4) as usize {
96 panic!(
97 "{nombre}.rgba mide {} bytes y se esperaban {}: el volcado y el \
98 patrón no están hablando del mismo lienzo",
99 bytes.len(),
100 ANCHO * ALTO * 4
101 );
102 }
103 Some(Rendered { w: ANCHO, h: ALTO, rgba: bytes })
104}
105
106#[test]
107fn el_rasterizador_de_wawa_pinta_lo_mismo_que_el_de_linux() {
108 let Ok(dir) = std::env::var("LLIMPHI_CPU_DUMP") else {
109 eprintln!(
110 "salteado: falta el volcado del camino CPU. Generalo con\n \
111 ./scripts/check-llimphi-cpu-vs-gpu.sh"
112 );
113 return;
114 };
115 let dir = std::path::PathBuf::from(dir);
116
117 with_harness(|h| {
118 let mut probados = 0;
119 for (nombre, vista) in patrones::<()>() {
120 let Some(esperado) = cpu(&dir, nombre) else {
121 continue;
122 };
123 let obtenido = h.render_con(vista, ANCHO, ALTO, default_bg(), |s, m, c, ts| {
124 paint(s, m, c, ts, None, None);
125 });
126 let (_, tol, frac_max) = UMBRALES
127 .iter()
128 .find(|(n, _, _)| *n == nombre)
129 .copied()
130 .unwrap_or_else(|| panic!("el patrón «{nombre}» no tiene umbral declarado"));
131
132 // Escalera de tolerancias: caracteriza la divergencia en vez de
133 // reducirla a un sí/no. Es lo que permite poner un umbral sobre lo
134 // medido y no sobre una corazonada.
135 let escalera: Vec<String> = [0u8, 1, 4, 16, 64, 128]
136 .iter()
137 .map(|t| {
138 let (_, s) = diff(&esperado, &obtenido, *t).expect("mismo tamaño");
139 format!("Δ{t}:{}", s.differing_pixels)
140 })
141 .collect();
142 println!("{nombre:8} · {}", escalera.join(" "));
143 let (_, stats) = diff(&esperado, &obtenido, tol).expect("mismo tamaño");
144 let frac = stats.differing_pixels as f64 / (ANCHO * ALTO) as f64;
145 // La evidencia por texto de la Regla 8: el número queda impreso
146 // corra o falle, así que la tabla del plan se puede regenerar sin
147 // mirar una imagen.
148 println!(
149 "{nombre:8} · {} px sobre Δ{tol} ({:.2} %) · Δ máximo {}",
150 stats.differing_pixels,
151 frac * 100.0,
152 stats.max_delta
153 );
154 assert!(
155 frac <= frac_max,
156 "{nombre}: {:.2} % de los píxeles difieren más de Δ{tol} \
157 (tope {:.2} %). Los dos rasterizadores se separaron.",
158 frac * 100.0,
159 frac_max * 100.0
160 );
161
162 // Tinta total. Medido: el texto sale **2,7 % más flaco** por CPU que
163 // por GPU —misma posición, menos cobertura de glifo—, y el resto de
164 // los patrones queda por debajo del 0,2 %. Que el mejor
165 // corrimiento del texto sea (0,0) es lo que descarta que sea un
166 // problema de posicionado: la letra está donde va, pinta distinto.
167 let (t_cpu, t_gpu) = (tinta(&esperado) as f64, tinta(&obtenido) as f64);
168 let desvio = if t_gpu > 0.0 { (t_cpu - t_gpu).abs() / t_gpu } else { 0.0 };
169 println!("{nombre:8} · tinta CPU/GPU {:+.2} %", (t_cpu / t_gpu - 1.0) * 100.0);
170 let tope_tinta = if nombre == "texto" { 0.06 } else { 0.005 };
171 assert!(
172 desvio <= tope_tinta,
173 "{nombre}: la tinta total se corrió {:.2} % (tope {:.2} %) — \
174 un lado está pintando más grueso que el otro",
175 desvio * 100.0,
176 tope_tinta * 100.0
177 );
178 probados += 1;
179 }
180 // Guarda del arnés: con el directorio puesto pero vacío, el bucle no
181 // compararía nada y el test pasaría diciendo que todo está bien.
182 assert_eq!(
183 probados,
184 patrones::<()>().len(),
185 "faltan volcados del camino CPU en {}",
186 dir.display()
187 );
188 });
189}