//! **El texto por GPU-directo contra el texto por vello: ¿dicen lo mismo?** //! //! El camino GPU-directo (`GpuBatch`) estaba fichado como «sin texto» en //! `PLAN-MOTOR-PROFUNDO.md` §4a, y era el último hueco real de esa iniciativa: //! el batcher instanciado sabe de quads, no de contornos. Ahora sí sabe — //! `llimphi-glifos` rasteriza cada glifo una vez a un atlas de cobertura y //! `GpuBatch::add_glyph` lo pinta como un quad texturado— y esto es lo que //! certifica que sepa **lo correcto**. //! //! El oráculo es el de siempre en esta tanda: no una imagen bendecida, sino //! **la otra manera de calcular lo mismo**. Los dos caminos parten del **mismo //! `parley::Layout`** —el que arma `llimphi_text::Typesetter`— así que el //! shaping, el line-break y las posiciones de glifo son idénticos por //! construcción. Lo único que se compara es lo que pasa después: vello rellena //! contornos, el atlas estampa mapas de cobertura. //! //! Certificado por texto (§8): conteos de píxeles y tinta, llvmpipe. use llimphi_glifos::{quads_de_layout, AtlasGlifos, QuadGlifo}; use llimphi_hal::wgpu; use llimphi_raster::gpu::{GpuBatch, GpuPipelines}; use llimphi_raster::gpu_atlas::AtlasTextura; use llimphi_test::{diff, with_harness, Harness, Rendered}; use llimphi_text::peniko::Color; use llimphi_text::{layout_block, TextBlock, Typesetter}; const ANCHO: u32 = 320; const ALTO: u32 = 120; const ORIGEN: (f64, f64) = (12.0, 10.0); const TAM: f32 = 18.0; fn fondo() -> Color { Color::from_rgba8(12, 12, 18, 255) } fn tinta_color() -> Color { Color::from_rgba8(235, 235, 245, 255) } /// El mismo layout para los dos caminos. Que se arme una sola vez y se preste /// es el corazón del test: si cada lado shapeara por su cuenta, una diferencia /// podría venir del shaping y no del rasterizado, y el número no diría nada. fn armar(texto: &str) -> LayoutParley { let mut ts = Typesetter::new(); let bloque = TextBlock::simple(texto, TAM, tinta_color(), ORIGEN); layout_block(&mut ts, &bloque) } type LayoutParley = llimphi_text::parley::Layout<()>; /// Camino A: vello rellena los contornos, que es lo que hace hoy toda la UI. fn por_vello(h: &mut Harness, layout: &LayoutParley) -> Rendered { h.render_con( llimphi_compositor::View::<()>::new(Default::default()), ANCHO, ALTO, fondo(), |escena, _m, _c, _ts| { llimphi_text::draw_layout(escena, layout, tinta_color(), ORIGEN); }, ) } /// Camino B: el atlas de cobertura + quads instanciados. /// /// Sube **sólo la región sucia** y no el atlas entero, que es lo que hace una /// app de verdad: un atlas de 512² son 256 KiB, y mandarlos por frame para /// agregar una letra anularía el punto de cachear. Que el test recorra ese /// camino y no el fácil es lo que hace que un error de origen en la subida /// parcial se vea acá y no en metal. fn por_atlas( h: &mut Harness, quads: &[QuadGlifo], atlas: &AtlasGlifos, sucio: (u32, u32, u32, u32), ) -> Rendered { h.render_gpu(ANCHO, ALTO, fondo(), |device, queue, enc, vista, vp| { let pipelines = GpuPipelines::new(device, wgpu::TextureFormat::Rgba8Unorm); let mut tex = AtlasTextura::new(device, &pipelines, atlas.lado()); tex.subir(queue, atlas.pixeles(), sucio); let mut batch = GpuBatch::new(&pipelines); batch.atlas(&tex); for q in quads { batch.add_glyph(q.x, q.y, q.w, q.h, (q.u0, q.v0, q.u1, q.v1), tinta_color()); } batch.flush(device, queue, enc, vista, vp, wgpu::LoadOp::Load); }) } /// Tinta total (suma RGB). Agarra el desvío global que una tolerancia por píxel /// deja pasar repartido: un texto sistemáticamente más flaco de un lado. fn tinta(r: &Rendered) -> u64 { r.rgba .chunks_exact(4) .map(|p| p[0] as u64 + p[1] as u64 + p[2] as u64) .sum() } /// Cuánto tiene que apartarse del fondo un píxel para contar como tinta. /// /// No es 1 ni 8. Con un umbral bajo, la caja la fija la columna de /// antialiasing más tenue del borde del glifo —y ahí los dos rasterizadores /// difieren legítimamente: medido, uno pone 9/255 donde el otro pone 0, y la /// caja se corre un píxel sin que nada se haya movido. La caja está para /// contestar «¿está la letra en otro lado?», que es una pregunta de píxeles /// enteros, así que el umbral se pone donde hay glifo de verdad (~19 % de /// cobertura) y no donde hay orla. const TINTA_MINIMA: u8 = 48; /// Caja de lo que se apartó del fondo. Si los dos caminos ponen el texto en /// lugares distintos, esto lo dice y el conteo de píxeles no. fn caja(r: &Rendered) -> Option<(u32, u32, u32, u32)> { let f = fondo().to_rgba8().to_u8_array(); let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0u32, 0u32); for y in 0..r.h { for x in 0..r.w { let i = ((y * r.w + x) * 4) as usize; let p = &r.rgba[i..i + 3]; if p.iter().zip(f.iter()).any(|(a, b)| a.abs_diff(*b) > TINTA_MINIMA) { x0 = x0.min(x); y0 = y0.min(y); x1 = x1.max(x); y1 = y1.max(y); } } } (x0 != u32::MAX).then_some((x0, y0, x1, y1)) } /// ¿Las dos cajas son la misma, hasta `tol` px por lado? /// /// La tolerancia no es pereza, es lo que se midió: con umbral fijo, la caja la /// puede fijar un píxel de orla que en un rasterizador da 49/255 y en el otro /// 47, y entonces se corre un renglón sin que nada se haya movido de lugar. /// Probado con tres umbrales distintos (8, 32, 48) y en los tres pasa lo mismo, /// en un borde u otro. La caja está para contestar «¿se fue la letra a otro /// lado?» —un desplazamiento entero y sistemático— y para eso ±1 alcanza; el /// grano fino lo cubre la escalera de deltas, que exige **cero** píxeles /// separados por más de 64/255. fn cajas_coinciden(a: Option<(u32, u32, u32, u32)>, b: Option<(u32, u32, u32, u32)>, tol: u32) -> bool { match (a, b) { (Some(p), Some(q)) => { p.0.abs_diff(q.0) <= tol && p.1.abs_diff(q.1) <= tol && p.2.abs_diff(q.2) <= tol && p.3.abs_diff(q.3) <= tol } (None, None) => true, _ => false, } } fn escalera(a: &Rendered, b: &Rendered) -> Vec { [0u8, 1, 4, 16, 64, 128] .iter() .map(|t| diff(a, b, *t).expect("mismo tamaño").1.differing_pixels as u32) .collect() } #[test] fn el_atlas_pinta_el_mismo_texto_que_vello() { let texto = "Hamburgefonstiv 0123 — jgpqy"; let layout = armar(texto); let mut atlas = AtlasGlifos::new(512); let mut quads = Vec::new(); let n = quads_de_layout( &mut atlas, &layout, (ORIGEN.0 as f32, ORIGEN.1 as f32), &mut quads, ); println!( "atlas · {n} quads · {} entradas · rasterizados {} · aciertos {} · fallos {}", atlas.entradas(), atlas.rasterizados, atlas.aciertos, atlas.fallos ); // Guarda del arnés: sin quads los dos lienzos serían el fondo pelado y el // test firmaría cualquier cosa. El texto tiene 28 caracteres, de los que // tres son espacios sin mapa. assert!(n >= 20, "sólo salieron {n} quads del layout"); assert_eq!(atlas.fallos, 0, "el atlas se llenó y descartó glifos"); let s = atlas.tomar_sucio().expect("rasterizar ensucia el atlas"); println!("sucio · ({}, {}) {}×{}", s.x, s.y, s.ancho, s.alto); // No es el atlas entero: si lo fuera, la subida parcial no estaría probada. assert!( s.ancho < atlas.lado() || s.alto < atlas.lado(), "lo sucio cubre el atlas entero" ); with_harness(|h| { let v = por_vello(h, &layout); let a = por_atlas(h, &quads, &atlas, (s.x, s.y, s.ancho, s.alto)); let esc = escalera(&v, &a); let (t_v, t_a) = (tinta(&v) as f64, tinta(&a) as f64); println!( "vello vs atlas · Δ0:{} Δ1:{} Δ4:{} Δ16:{} Δ64:{} Δ128:{}", esc[0], esc[1], esc[2], esc[3], esc[4], esc[5] ); println!("vello vs atlas · tinta {:+.2} %", (t_a / t_v - 1.0) * 100.0); println!("caja vello {:?} · atlas {:?}", caja(&v), caja(&a)); // Que la caja coincida es lo que separa «el AA difiere» de «el texto // está en otro lado». Un glifo corrido un píxel no cambia mucho el // conteo total y cambia la caja de una. assert!( cajas_coinciden(caja(&v), caja(&a), 1), "los dos caminos no ponen el texto en el mismo lugar: {:?} vs {:?}", caja(&v), caja(&a) ); // Umbrales sobre lo MEDIDO (2026-08-05, llvmpipe, 320×120 = 38.400 px), // y salió mucho mejor de lo que se suponía: // // Δ0:1425 Δ1:1262 Δ4:1020 Δ16:417 Δ64:**0** Δ128:**0** · tinta +0,31 % // // Dos rasterizadores de contornos independientes —vello analítico por // compute, zeno por scanline— y **ningún píxel difiere más de 64/255**. // Lo que queda es la orla del glifo, que es exactamente donde dos // rasterizadores tienen derecho a no coincidir. // // Por eso el gate es duro y no una holgura decorativa: **cero** píxeles // sobre Δ64. Un tope generoso puesto antes de medir —el 0,8 % sobre // Δ128 que parecía prudente— no habría agarrado nunca nada. let total = (ANCHO * ALTO) as f64; assert_eq!( esc[4], 0, "{} px difieren más de Δ64 — el atlas dejó de pintar lo que pinta vello", esc[4] ); // Y un segundo piso sobre la orla: 417 px sobre Δ16 = 1,09 %. El tope al // 2 % deja margen para una versión nueva de zeno o de vello sin volverse // inerte. let frac_16 = esc[3] as f64 / total; assert!( frac_16 <= 0.02, "{:.2} % de los píxeles difieren más de Δ16 (tope 2 %) — la orla de \ los glifos se separó más de lo medido", frac_16 * 100.0 ); let desvio = (t_a - t_v).abs() / t_v; assert!( desvio <= 0.01, "la tinta se corrió {:.2} % (tope 1 %) — un camino pinta más \ grueso que el otro", desvio * 100.0 ); }); } #[test] fn el_atlas_se_sube_de_a_pedazos_sin_que_el_texto_cambie() { // El caso de una app de verdad, y el único que ejerce un origen distinto de // (0,0) en la subida: el primer frame llena el atlas con lo que se ve, y el // siguiente agrega glifos nuevos **al lado** de los que ya estaban. Ahí la // región sucia es un rect chico en medio del atlas, y subirlo en el lugar // equivocado deja a las letras viejas muestreando basura. // // Sin este test, la subida parcial se probaría siempre con `sucio` pegado // al origen, donde un error de coordenada no se nota. let corto = armar("abc"); let largo = armar("abc — xyz 0123"); let mut atlas = AtlasGlifos::new(512); let mut descartar = Vec::new(); quads_de_layout( &mut atlas, &corto, (ORIGEN.0 as f32, ORIGEN.1 as f32), &mut descartar, ); let s1 = atlas.tomar_sucio().expect("el primer frame ensucia"); let mut quads = Vec::new(); quads_de_layout( &mut atlas, &largo, (ORIGEN.0 as f32, ORIGEN.1 as f32), &mut quads, ); let s2 = atlas.tomar_sucio().expect("los glifos nuevos ensucian"); println!( "pedazo 1 · ({}, {}) {}×{} · pedazo 2 · ({}, {}) {}×{}", s1.x, s1.y, s1.ancho, s1.alto, s2.x, s2.y, s2.ancho, s2.alto ); // Lo que hace que este test valga: el segundo pedazo NO arranca en el // origen. Si arrancara, un `subir` que ignorara la coordenada pasaría igual. assert!( s2.x > 0, "el segundo pedazo arranca en x=0: no prueba el origen desplazado" ); with_harness(|h| { let v = por_vello(h, &largo); let a = h.render_gpu(ANCHO, ALTO, fondo(), |device, queue, enc, vista, vp| { let pipelines = GpuPipelines::new(device, wgpu::TextureFormat::Rgba8Unorm); let mut tex = AtlasTextura::new(device, &pipelines, atlas.lado()); // Dos subidas parciales, en el orden en que ocurrieron. tex.subir(queue, atlas.pixeles(), (s1.x, s1.y, s1.ancho, s1.alto)); tex.subir(queue, atlas.pixeles(), (s2.x, s2.y, s2.ancho, s2.alto)); let mut batch = GpuBatch::new(&pipelines); batch.atlas(&tex); for q in &quads { batch.add_glyph(q.x, q.y, q.w, q.h, (q.u0, q.v0, q.u1, q.v1), tinta_color()); } batch.flush(device, queue, enc, vista, vp, wgpu::LoadOp::Load); }); let esc = escalera(&v, &a); println!( "por pedazos vs vello · Δ0:{} Δ1:{} Δ4:{} Δ16:{} Δ64:{}", esc[0], esc[1], esc[2], esc[3], esc[4] ); assert!( cajas_coinciden(caja(&v), caja(&a), 1), "el texto no cayó en el mismo lugar: {:?} vs {:?}", caja(&v), caja(&a) ); assert_eq!( esc[4], 0, "{} px difieren más de Δ64 — la subida de a pedazos no reconstruye \ el mismo atlas", esc[4] ); }); } #[test] fn el_mismo_glifo_dos_veces_se_rasteriza_una_sola() { // El punto entero del atlas. Si esto fallara, el camino directo estaría // rasterizando por frame y saldría más caro que vello, no más barato. let layout = armar("aaaa aaaa"); let mut atlas = AtlasGlifos::new(256); let mut quads = Vec::new(); quads_de_layout( &mut atlas, &layout, (ORIGEN.0 as f32, ORIGEN.1 as f32), &mut quads, ); println!( "aaaa · {} quads · {} entradas · rasterizados {} · aciertos {}", quads.len(), atlas.entradas(), atlas.rasterizados, atlas.aciertos ); assert_eq!(quads.len(), 8, "ocho aes visibles y un espacio sin mapa"); // Ocho aes, pero no ocho rasterizados: las que caen en la misma fracción // subpíxel comparten entrada. Cuántas fracciones distintas hay depende del // avance de la fuente, así que lo que se exige es que sean MENOS que los // glifos y no más que las fracciones posibles. assert!( atlas.rasterizados < 8, "se rasterizaron {} veces para 8 glifos iguales: el caché no está \ acertando", atlas.rasterizados ); assert!(atlas.aciertos > 0, "ningún pedido se resolvió del caché"); // Y pedir el mismo layout de nuevo no rasteriza nada: es el caso del frame // siguiente, que es donde el atlas se paga. let antes = atlas.rasterizados; let mut otros = Vec::new(); quads_de_layout( &mut atlas, &layout, (ORIGEN.0 as f32, ORIGEN.1 as f32), &mut otros, ); assert_eq!( atlas.rasterizados, antes, "el segundo frame volvió a rasterizar" ); assert_eq!(quads, otros, "el mismo layout dio quads distintos"); } #[test] fn sin_atlas_el_texto_no_se_pinta_pero_tampoco_revienta() { // Pedir texto antes de tener el atlas listo es un error del caller, y la // respuesta correcta es que falte el texto —síntoma leíble— y no que se // caiga la ventana de una app. Se verifica que el frame salga igual al // fondo pelado. with_harness(|h| { let vacio = h.render_gpu(ANCHO, ALTO, fondo(), |_d, _q, _e, _v, _vp| {}); let sin = h.render_gpu(ANCHO, ALTO, fondo(), |device, queue, enc, vista, vp| { let pipelines = GpuPipelines::new(device, wgpu::TextureFormat::Rgba8Unorm); let mut batch = GpuBatch::new(&pipelines); for i in 0..10 { batch.add_glyph( 10.0 + i as f32 * 12.0, 20.0, 10.0, 14.0, (0.0, 0.0, 0.1, 0.1), tinta_color(), ); } // 10 quads acumulados y ningún atlas: no se dibuja nada. assert_eq!(batch.primitive_count(), 10); batch.flush(device, queue, enc, vista, vp, wgpu::LoadOp::Load); }); let (_, st) = diff(&vacio, &sin, 0).expect("mismo tamaño"); assert_eq!( st.differing_pixels, 0, "sin atlas se pintó algo: {} px", st.differing_pixels ); }); }