tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definitions

⎇ main llimphi-test/tests/texto_gpu_directo.rs Rust · 415 lines · 16.3 KB History Who touched what raw

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

1//! **El texto por GPU-directo contra el texto por vello: ¿dicen lo mismo?**
2//!
3//! El camino GPU-directo (`GpuBatch`) estaba fichado como «sin texto» en
4//! `PLAN-MOTOR-PROFUNDO.md` §4a, y era el último hueco real de esa iniciativa:
5//! el batcher instanciado sabe de quads, no de contornos. Ahora sí sabe —
6//! `llimphi-glifos` rasteriza cada glifo una vez a un atlas de cobertura y
7//! `GpuBatch::add_glyph` lo pinta como un quad texturado— y esto es lo que
8//! certifica que sepa **lo correcto**.
9//!
10//! El oráculo es el de siempre en esta tanda: no una imagen bendecida, sino
11//! **la otra manera de calcular lo mismo**. Los dos caminos parten del **mismo
12//! `parley::Layout`** —el que arma `llimphi_text::Typesetter`— así que el
13//! shaping, el line-break y las posiciones de glifo son idénticos por
14//! construcción. Lo único que se compara es lo que pasa después: vello rellena
15//! contornos, el atlas estampa mapas de cobertura.
16//!
17//! Certificado por texto (§8): conteos de píxeles y tinta, llvmpipe.
18
19use llimphi_glifos::{quads_de_layout, AtlasGlifos, QuadGlifo};
20use llimphi_hal::wgpu;
21use llimphi_raster::gpu::{GpuBatch, GpuPipelines};
22use llimphi_raster::gpu_atlas::AtlasTextura;
23use llimphi_test::{diff, with_harness, Harness, Rendered};
24use llimphi_text::peniko::Color;
25use llimphi_text::{layout_block, TextBlock, Typesetter};
26
27const ANCHO: u32 = 320;
28const ALTO: u32 = 120;
29const ORIGEN: (f64, f64) = (12.0, 10.0);
30const TAM: f32 = 18.0;
31
32fn fondo() -> Color {
33    Color::from_rgba8(12, 12, 18, 255)
34}
35
36fn tinta_color() -> Color {
37    Color::from_rgba8(235, 235, 245, 255)
38}
39
40/// El mismo layout para los dos caminos. Que se arme una sola vez y se preste
41/// es el corazón del test: si cada lado shapeara por su cuenta, una diferencia
42/// podría venir del shaping y no del rasterizado, y el número no diría nada.
43fn armar(texto: &str) -> LayoutParley {
44    let mut ts = Typesetter::new();
45    let bloque = TextBlock::simple(texto, TAM, tinta_color(), ORIGEN);
46    layout_block(&mut ts, &bloque)
47}
48
49type LayoutParley = llimphi_text::parley::Layout<()>;
50
51/// Camino A: vello rellena los contornos, que es lo que hace hoy toda la UI.
52fn por_vello(h: &mut Harness, layout: &LayoutParley) -> Rendered {
53    h.render_con(
54        llimphi_compositor::View::<()>::new(Default::default()),
55        ANCHO,
56        ALTO,
57        fondo(),
58        |escena, _m, _c, _ts| {
59            llimphi_text::draw_layout(escena, layout, tinta_color(), ORIGEN);
60        },
61    )
62}
63
64/// Camino B: el atlas de cobertura + quads instanciados.
65///
66/// Sube **sólo la región sucia** y no el atlas entero, que es lo que hace una
67/// app de verdad: un atlas de 512² son 256 KiB, y mandarlos por frame para
68/// agregar una letra anularía el punto de cachear. Que el test recorra ese
69/// camino y no el fácil es lo que hace que un error de origen en la subida
70/// parcial se vea acá y no en metal.
71fn por_atlas(
72    h: &mut Harness,
73    quads: &[QuadGlifo],
74    atlas: &AtlasGlifos,
75    sucio: (u32, u32, u32, u32),
76) -> Rendered {
77    h.render_gpu(ANCHO, ALTO, fondo(), |device, queue, enc, vista, vp| {
78        let pipelines = GpuPipelines::new(device, wgpu::TextureFormat::Rgba8Unorm);
79        let mut tex = AtlasTextura::new(device, &pipelines, atlas.lado());
80        tex.subir(queue, atlas.pixeles(), sucio);
81        let mut batch = GpuBatch::new(&pipelines);
82        batch.atlas(&tex);
83        for q in quads {
84            batch.add_glyph(q.x, q.y, q.w, q.h, (q.u0, q.v0, q.u1, q.v1), tinta_color());
85        }
86        batch.flush(device, queue, enc, vista, vp, wgpu::LoadOp::Load);
87    })
88}
89
90/// Tinta total (suma RGB). Agarra el desvío global que una tolerancia por píxel
91/// deja pasar repartido: un texto sistemáticamente más flaco de un lado.
92fn tinta(r: &Rendered) -> u64 {
93    r.rgba
94        .chunks_exact(4)
95        .map(|p| p[0] as u64 + p[1] as u64 + p[2] as u64)
96        .sum()
97}
98
99/// Cuánto tiene que apartarse del fondo un píxel para contar como tinta.
100///
101/// No es 1 ni 8. Con un umbral bajo, la caja la fija la columna de
102/// antialiasing más tenue del borde del glifo —y ahí los dos rasterizadores
103/// difieren legítimamente: medido, uno pone 9/255 donde el otro pone 0, y la
104/// caja se corre un píxel sin que nada se haya movido. La caja está para
105/// contestar «¿está la letra en otro lado?», que es una pregunta de píxeles
106/// enteros, así que el umbral se pone donde hay glifo de verdad (~19 % de
107/// cobertura) y no donde hay orla.
108const TINTA_MINIMA: u8 = 48;
109
110/// Caja de lo que se apartó del fondo. Si los dos caminos ponen el texto en
111/// lugares distintos, esto lo dice y el conteo de píxeles no.
112fn caja(r: &Rendered) -> Option<(u32, u32, u32, u32)> {
113    let f = fondo().to_rgba8().to_u8_array();
114    let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0u32, 0u32);
115    for y in 0..r.h {
116        for x in 0..r.w {
117            let i = ((y * r.w + x) * 4) as usize;
118            let p = &r.rgba[i..i + 3];
119            if p.iter().zip(f.iter()).any(|(a, b)| a.abs_diff(*b) > TINTA_MINIMA) {
120                x0 = x0.min(x);
121                y0 = y0.min(y);
122                x1 = x1.max(x);
123                y1 = y1.max(y);
124            }
125        }
126    }
127    (x0 != u32::MAX).then_some((x0, y0, x1, y1))
128}
129
130/// ¿Las dos cajas son la misma, hasta `tol` px por lado?
131///
132/// La tolerancia no es pereza, es lo que se midió: con umbral fijo, la caja la
133/// puede fijar un píxel de orla que en un rasterizador da 49/255 y en el otro
134/// 47, y entonces se corre un renglón sin que nada se haya movido de lugar.
135/// Probado con tres umbrales distintos (8, 32, 48) y en los tres pasa lo mismo,
136/// en un borde u otro. La caja está para contestar «¿se fue la letra a otro
137/// lado?» —un desplazamiento entero y sistemático— y para eso ±1 alcanza; el
138/// grano fino lo cubre la escalera de deltas, que exige **cero** píxeles
139/// separados por más de 64/255.
140fn cajas_coinciden(a: Option<(u32, u32, u32, u32)>, b: Option<(u32, u32, u32, u32)>, tol: u32) -> bool {
141    match (a, b) {
142        (Some(p), Some(q)) => {
143            p.0.abs_diff(q.0) <= tol
144                && p.1.abs_diff(q.1) <= tol
145                && p.2.abs_diff(q.2) <= tol
146                && p.3.abs_diff(q.3) <= tol
147        }
148        (None, None) => true,
149        _ => false,
150    }
151}
152
153fn escalera(a: &Rendered, b: &Rendered) -> Vec<u32> {
154    [0u8, 1, 4, 16, 64, 128]
155        .iter()
156        .map(|t| diff(a, b, *t).expect("mismo tamaño").1.differing_pixels as u32)
157        .collect()
158}
159
160#[test]
161fn el_atlas_pinta_el_mismo_texto_que_vello() {
162    let texto = "Hamburgefonstiv 0123 — jgpqy";
163    let layout = armar(texto);
164
165    let mut atlas = AtlasGlifos::new(512);
166    let mut quads = Vec::new();
167    let n = quads_de_layout(
168        &mut atlas,
169        &layout,
170        (ORIGEN.0 as f32, ORIGEN.1 as f32),
171        &mut quads,
172    );
173    println!(
174        "atlas · {n} quads · {} entradas · rasterizados {} · aciertos {} · fallos {}",
175        atlas.entradas(),
176        atlas.rasterizados,
177        atlas.aciertos,
178        atlas.fallos
179    );
180    // Guarda del arnés: sin quads los dos lienzos serían el fondo pelado y el
181    // test firmaría cualquier cosa. El texto tiene 28 caracteres, de los que
182    // tres son espacios sin mapa.
183    assert!(n >= 20, "sólo salieron {n} quads del layout");
184    assert_eq!(atlas.fallos, 0, "el atlas se llenó y descartó glifos");
185
186    let s = atlas.tomar_sucio().expect("rasterizar ensucia el atlas");
187    println!("sucio · ({}, {}) {}×{}", s.x, s.y, s.ancho, s.alto);
188    // No es el atlas entero: si lo fuera, la subida parcial no estaría probada.
189    assert!(
190        s.ancho < atlas.lado() || s.alto < atlas.lado(),
191        "lo sucio cubre el atlas entero"
192    );
193
194    with_harness(|h| {
195        let v = por_vello(h, &layout);
196        let a = por_atlas(h, &quads, &atlas, (s.x, s.y, s.ancho, s.alto));
197
198        let esc = escalera(&v, &a);
199        let (t_v, t_a) = (tinta(&v) as f64, tinta(&a) as f64);
200        println!(
201            "vello vs atlas · Δ0:{} Δ1:{} Δ4:{} Δ16:{} Δ64:{} Δ128:{}",
202            esc[0], esc[1], esc[2], esc[3], esc[4], esc[5]
203        );
204        println!("vello vs atlas · tinta {:+.2} %", (t_a / t_v - 1.0) * 100.0);
205        println!("caja vello {:?} · atlas {:?}", caja(&v), caja(&a));
206
207        // Que la caja coincida es lo que separa «el AA difiere» de «el texto
208        // está en otro lado». Un glifo corrido un píxel no cambia mucho el
209        // conteo total y cambia la caja de una.
210        assert!(
211            cajas_coinciden(caja(&v), caja(&a), 1),
212            "los dos caminos no ponen el texto en el mismo lugar: {:?} vs {:?}",
213            caja(&v),
214            caja(&a)
215        );
216
217        // Umbrales sobre lo MEDIDO (2026-08-05, llvmpipe, 320×120 = 38.400 px),
218        // y salió mucho mejor de lo que se suponía:
219        //
220        //   Δ0:1425 Δ1:1262 Δ4:1020 Δ16:417 Δ64:**0** Δ128:**0** · tinta +0,31 %
221        //
222        // Dos rasterizadores de contornos independientes —vello analítico por
223        // compute, zeno por scanline— y **ningún píxel difiere más de 64/255**.
224        // Lo que queda es la orla del glifo, que es exactamente donde dos
225        // rasterizadores tienen derecho a no coincidir.
226        //
227        // Por eso el gate es duro y no una holgura decorativa: **cero** píxeles
228        // sobre Δ64. Un tope generoso puesto antes de medir —el 0,8 % sobre
229        // Δ128 que parecía prudente— no habría agarrado nunca nada.
230        let total = (ANCHO * ALTO) as f64;
231        assert_eq!(
232            esc[4], 0,
233            "{} px difieren más de Δ64 — el atlas dejó de pintar lo que pinta vello",
234            esc[4]
235        );
236        // Y un segundo piso sobre la orla: 417 px sobre Δ16 = 1,09 %. El tope al
237        // 2 % deja margen para una versión nueva de zeno o de vello sin volverse
238        // inerte.
239        let frac_16 = esc[3] as f64 / total;
240        assert!(
241            frac_16 <= 0.02,
242            "{:.2} % de los píxeles difieren más de Δ16 (tope 2 %) — la orla de \
243             los glifos se separó más de lo medido",
244            frac_16 * 100.0
245        );
246        let desvio = (t_a - t_v).abs() / t_v;
247        assert!(
248            desvio <= 0.01,
249            "la tinta se corrió {:.2} % (tope 1 %) — un camino pinta más \
250             grueso que el otro",
251            desvio * 100.0
252        );
253    });
254}
255
256#[test]
257fn el_atlas_se_sube_de_a_pedazos_sin_que_el_texto_cambie() {
258    // El caso de una app de verdad, y el único que ejerce un origen distinto de
259    // (0,0) en la subida: el primer frame llena el atlas con lo que se ve, y el
260    // siguiente agrega glifos nuevos **al lado** de los que ya estaban. Ahí la
261    // región sucia es un rect chico en medio del atlas, y subirlo en el lugar
262    // equivocado deja a las letras viejas muestreando basura.
263    //
264    // Sin este test, la subida parcial se probaría siempre con `sucio` pegado
265    // al origen, donde un error de coordenada no se nota.
266    let corto = armar("abc");
267    let largo = armar("abc — xyz 0123");
268
269    let mut atlas = AtlasGlifos::new(512);
270    let mut descartar = Vec::new();
271    quads_de_layout(
272        &mut atlas,
273        &corto,
274        (ORIGEN.0 as f32, ORIGEN.1 as f32),
275        &mut descartar,
276    );
277    let s1 = atlas.tomar_sucio().expect("el primer frame ensucia");
278
279    let mut quads = Vec::new();
280    quads_de_layout(
281        &mut atlas,
282        &largo,
283        (ORIGEN.0 as f32, ORIGEN.1 as f32),
284        &mut quads,
285    );
286    let s2 = atlas.tomar_sucio().expect("los glifos nuevos ensucian");
287    println!(
288        "pedazo 1 · ({}, {}) {}×{} · pedazo 2 · ({}, {}) {}×{}",
289        s1.x, s1.y, s1.ancho, s1.alto, s2.x, s2.y, s2.ancho, s2.alto
290    );
291    // Lo que hace que este test valga: el segundo pedazo NO arranca en el
292    // origen. Si arrancara, un `subir` que ignorara la coordenada pasaría igual.
293    assert!(
294        s2.x > 0,
295        "el segundo pedazo arranca en x=0: no prueba el origen desplazado"
296    );
297
298    with_harness(|h| {
299        let v = por_vello(h, &largo);
300        let a = h.render_gpu(ANCHO, ALTO, fondo(), |device, queue, enc, vista, vp| {
301            let pipelines = GpuPipelines::new(device, wgpu::TextureFormat::Rgba8Unorm);
302            let mut tex = AtlasTextura::new(device, &pipelines, atlas.lado());
303            // Dos subidas parciales, en el orden en que ocurrieron.
304            tex.subir(queue, atlas.pixeles(), (s1.x, s1.y, s1.ancho, s1.alto));
305            tex.subir(queue, atlas.pixeles(), (s2.x, s2.y, s2.ancho, s2.alto));
306            let mut batch = GpuBatch::new(&pipelines);
307            batch.atlas(&tex);
308            for q in &quads {
309                batch.add_glyph(q.x, q.y, q.w, q.h, (q.u0, q.v0, q.u1, q.v1), tinta_color());
310            }
311            batch.flush(device, queue, enc, vista, vp, wgpu::LoadOp::Load);
312        });
313        let esc = escalera(&v, &a);
314        println!(
315            "por pedazos vs vello · Δ0:{} Δ1:{} Δ4:{} Δ16:{} Δ64:{}",
316            esc[0], esc[1], esc[2], esc[3], esc[4]
317        );
318        assert!(
319            cajas_coinciden(caja(&v), caja(&a), 1),
320            "el texto no cayó en el mismo lugar: {:?} vs {:?}",
321            caja(&v),
322            caja(&a)
323        );
324        assert_eq!(
325            esc[4], 0,
326            "{} px difieren más de Δ64 — la subida de a pedazos no reconstruye \
327             el mismo atlas",
328            esc[4]
329        );
330    });
331}
332
333#[test]
334fn el_mismo_glifo_dos_veces_se_rasteriza_una_sola() {
335    // El punto entero del atlas. Si esto fallara, el camino directo estaría
336    // rasterizando por frame y saldría más caro que vello, no más barato.
337    let layout = armar("aaaa aaaa");
338    let mut atlas = AtlasGlifos::new(256);
339    let mut quads = Vec::new();
340    quads_de_layout(
341        &mut atlas,
342        &layout,
343        (ORIGEN.0 as f32, ORIGEN.1 as f32),
344        &mut quads,
345    );
346    println!(
347        "aaaa · {} quads · {} entradas · rasterizados {} · aciertos {}",
348        quads.len(),
349        atlas.entradas(),
350        atlas.rasterizados,
351        atlas.aciertos
352    );
353    assert_eq!(quads.len(), 8, "ocho aes visibles y un espacio sin mapa");
354    // Ocho aes, pero no ocho rasterizados: las que caen en la misma fracción
355    // subpíxel comparten entrada. Cuántas fracciones distintas hay depende del
356    // avance de la fuente, así que lo que se exige es que sean MENOS que los
357    // glifos y no más que las fracciones posibles.
358    assert!(
359        atlas.rasterizados < 8,
360        "se rasterizaron {} veces para 8 glifos iguales: el caché no está \
361         acertando",
362        atlas.rasterizados
363    );
364    assert!(atlas.aciertos > 0, "ningún pedido se resolvió del caché");
365
366    // Y pedir el mismo layout de nuevo no rasteriza nada: es el caso del frame
367    // siguiente, que es donde el atlas se paga.
368    let antes = atlas.rasterizados;
369    let mut otros = Vec::new();
370    quads_de_layout(
371        &mut atlas,
372        &layout,
373        (ORIGEN.0 as f32, ORIGEN.1 as f32),
374        &mut otros,
375    );
376    assert_eq!(
377        atlas.rasterizados, antes,
378        "el segundo frame volvió a rasterizar"
379    );
380    assert_eq!(quads, otros, "el mismo layout dio quads distintos");
381}
382
383#[test]
384fn sin_atlas_el_texto_no_se_pinta_pero_tampoco_revienta() {
385    // Pedir texto antes de tener el atlas listo es un error del caller, y la
386    // respuesta correcta es que falte el texto —síntoma leíble— y no que se
387    // caiga la ventana de una app. Se verifica que el frame salga igual al
388    // fondo pelado.
389    with_harness(|h| {
390        let vacio = h.render_gpu(ANCHO, ALTO, fondo(), |_d, _q, _e, _v, _vp| {});
391        let sin = h.render_gpu(ANCHO, ALTO, fondo(), |device, queue, enc, vista, vp| {
392            let pipelines = GpuPipelines::new(device, wgpu::TextureFormat::Rgba8Unorm);
393            let mut batch = GpuBatch::new(&pipelines);
394            for i in 0..10 {
395                batch.add_glyph(
396                    10.0 + i as f32 * 12.0,
397                    20.0,
398                    10.0,
399                    14.0,
400                    (0.0, 0.0, 0.1, 0.1),
401                    tinta_color(),
402                );
403            }
404            // 10 quads acumulados y ningún atlas: no se dibuja nada.
405            assert_eq!(batch.primitive_count(), 10);
406            batch.flush(device, queue, enc, vista, vp, wgpu::LoadOp::Load);
407        });
408        let (_, st) = diff(&vacio, &sin, 0).expect("mismo tamaño");
409        assert_eq!(
410            st.differing_pixels, 0,
411            "sin atlas se pintó algo: {} px",
412            st.differing_pixels
413        );
414    });
415}