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 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}