tawasuyu

tawasuyu/llimphi

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

Rust 614Markdown 274TOML 149RON 310606 Definiciones

⎇ main llimphi-test/tests/cpu_vs_gpu.rs Rust · 189 líneas · 8.4 KB Historia Quién tocó qué crudo

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

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}