//! **El camino del Mali contra el de referencia: ¿pintan lo mismo?** //! //! `llimphi-hybrid` reproduce la `Escena` grabada contra `vello_hybrid`, el //! rasterizador CPU+GPU **sin compute shaders**. Es el único camino a píxeles en //! GPUs cuyo compilador no digiere el compute de vello — el Mali-G57 del //! teléfono, medido en `android/DIAGNOSTICO.md`. //! //! El crate ya traía 11 tests, y son buenos — pero todos preguntan lo mismo: //! *¿hace lo que dice?* «La capa con alfa atenúa», «el trazo deja hueco //! adentro», «la máscara de luminancia recorta por brillo». Son afirmaciones //! **absolutas**, verificadas contra la intención. //! //! Lo que ninguno pregunta es *¿hace lo MISMO?*. Un `push_layer` que aplicara //! el alfa un 10 % distinto sigue atenuando, y sigue recortando, y pasa los //! once — pero el usuario del teléfono vería otra pantalla que el de //! escritorio. Esa comparación es la que faltaba, y necesita el otro //! rasterizador al lado, no una lista de propiedades. //! //! Acá los dos pintores consumen la **misma `Escena`**, así que no hay //! compositor ni layout de por medio: lo único que se compara es el //! rasterizado. Y como la `Escena` se arma a mano con la API de `llimphi-cpu`, //! este test no necesita ninguno de los backends excluyentes de `llimphi-text` //! y vive tranquilo dentro del workspace. //! //! **Sin glifos, y por la misma razón que el cable no lleva texto**: emitir una //! orden de texto pide un shaper, y el shaper vive detrás de un backend de //! `llimphi-text` que este build no tiene. El texto de wawa ya está comparado //! contra el de Linux en `llimphi-test/tests/cpu_vs_gpu.rs`. //! //! Certificado por texto (§8): conteos de píxeles, llvmpipe. use llimphi_cpu::escena::Escena; use llimphi_cpu::lienzo::FormatoPixel; use llimphi_cpu::pintor::Pintor as PintorCpu; use llimphi_hybrid::Pintor as PintorHybrid; use llimphi_cpu::kurbo::{Affine, Rect, RoundedRect, Stroke}; use llimphi_cpu::peniko::color::{AlphaColor, Srgb}; use llimphi_cpu::peniko::{BlendMode, Fill}; const ANCHO: u32 = 160; const ALTO: u32 = 120; const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; fn c(r: u8, g: u8, b: u8, a: u8) -> AlphaColor { AlphaColor::from_rgba8(r, g, b, a) } /// La escena de prueba, armada con las órdenes que una UI de verdad emite: un /// relleno alineado a la grilla (sin AA), uno redondeado y su trazo (AA de /// curva), una capa con alfa (composición) y un rect difuso (blur). /// /// Cada rasgo va en una zona distinta del lienzo para que, si divergen, el /// conteo por franja diga en cuál. fn escena_patron() -> Escena { let mut e = Escena::new(); // 1. Relleno alineado a la grilla. e.fill( Fill::NonZero, Affine::IDENTITY, c(220, 60, 60, 255), None, &Rect::new(8.0, 8.0, 68.0, 34.0), ); // 2. Redondeado + trazo: acá entra el antialiasing de curvas. let rr = RoundedRect::new(80.0, 8.0, 150.0, 40.0, 9.0); e.fill(Fill::NonZero, Affine::IDENTITY, c(60, 200, 120, 255), None, &rr); e.stroke( &Stroke::new(2.0), Affine::IDENTITY, c(240, 220, 60, 255), None, &rr, ); // 3. Capa con alfa: componer es donde una diferencia de orden se ve entera. e.push_layer( Fill::NonZero, BlendMode::default(), 0.5, Affine::IDENTITY, &Rect::new(8.0, 50.0, 150.0, 84.0), ); e.fill( Fill::NonZero, Affine::IDENTITY, c(240, 80, 40, 255), None, &Rect::new(8.0, 50.0, 150.0, 84.0), ); e.fill( Fill::NonZero, Affine::IDENTITY, c(40, 240, 200, 255), None, &Rect::new(20.0, 58.0, 80.0, 76.0), ); e.pop_layer(); // 4. Rect difuso: el rasgo con más aritmética. e.draw_blurred_rounded_rect( Affine::IDENTITY, Rect::new(30.0, 92.0, 120.0, 112.0), c(200, 200, 255, 200), 8.0, 4.0, ); e } fn por_cpu(e: &Escena) -> Vec { let mut pintor = PintorCpu::new(ANCHO as u16, ALTO as u16); let mut lienzo = pintor.lienzo_nuevo(); pintor.pintar(e, &mut lienzo); let mut px = vec![0u8; (ANCHO * ALTO * 4) as usize]; llimphi_cpu::volcar(&lienzo, &mut px, (ANCHO * 4) as usize, FormatoPixel::Rgba8); px } fn por_hybrid(e: &Escena) -> Vec { // El adapter de software (llvmpipe), igual que el arnés de `llimphi-test`: // así el número es el mismo en cualquier máquina y en CI. let hal = pollster::block_on(llimphi_hal::Hal::new_headless(true)).expect("hal headless"); let target = hal.device.create_texture(&wgpu::TextureDescriptor { label: Some("hybrid-target"), size: wgpu::Extent3d { width: ANCHO, height: ALTO, depth_or_array_layers: 1 }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: FMT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let vista = target.create_view(&wgpu::TextureViewDescriptor::default()); let backend = hal.adapter.get_info().backend; let mut pintor = PintorHybrid::new(&hal.device, backend, FMT, ANCHO, ALTO); let mut enc = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); pintor .pintar( e, &hal.device, &hal.queue, &mut enc, &vista, c(0, 0, 0, 255), ) .expect("hybrid rindió"); hal.queue.submit(std::iter::once(enc.finish())); // Readback con el padding de fila que exige wgpu. let unpadded = ANCHO as usize * 4; let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; let padded = unpadded.div_ceil(align) * align; let buf = hal.device.create_buffer(&wgpu::BufferDescriptor { label: Some("hybrid-readback"), size: (padded * ALTO as usize) as u64, usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let mut enc = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); enc.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: &target, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded as u32), rows_per_image: Some(ALTO), }, }, wgpu::Extent3d { width: ANCHO, height: ALTO, depth_or_array_layers: 1 }, ); hal.queue.submit(std::iter::once(enc.finish())); let slice = buf.slice(..); let (tx, rx) = std::sync::mpsc::channel(); slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); let _ = hal.device.poll(wgpu::PollType::wait_indefinitely()); rx.recv().unwrap().unwrap(); let data = slice.get_mapped_range(); let mut out = Vec::with_capacity((ANCHO * ALTO * 4) as usize); for fila in 0..ALTO as usize { let s = fila * padded; out.extend_from_slice(&data[s..s + unpadded]); } drop(data); buf.unmap(); out } /// Píxeles cuyo peor canal difiere más de `tol`. fn distintos(a: &[u8], b: &[u8], tol: u8) -> usize { a.chunks_exact(4) .zip(b.chunks_exact(4)) .filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol)) .count() } /// Tinta total: agarra el desvío global de brillo que una tolerancia por píxel /// puede dejar pasar repartido en muchos píxeles con delta chico. fn tinta(v: &[u8]) -> u64 { v.chunks_exact(4) .map(|p| p[0] as u64 + p[1] as u64 + p[2] as u64) .sum() } #[test] fn el_rasterizador_sin_compute_pinta_lo_mismo_que_el_de_cpu() { let e = escena_patron(); let cpu = por_cpu(&e); let hybrid = por_hybrid(&e); assert_eq!(cpu.len(), hybrid.len()); let total = (ANCHO * ALTO) as usize; let escalera: Vec = [0u8, 1, 4, 16, 64, 128] .iter() .map(|t| format!("Δ{t}:{}", distintos(&cpu, &hybrid, *t))) .collect(); let (t_cpu, t_hy) = (tinta(&cpu) as f64, tinta(&hybrid) as f64); // Evidencia por texto: los números quedan impresos corra o falle. println!("hybrid vs cpu · {}", escalera.join(" ")); println!("hybrid vs cpu · tinta {:+.2} %", (t_hy / t_cpu - 1.0) * 100.0); // Guarda del arnés: si la escena no pintara nada, dos lienzos negros // coincidirían perfecto y el test firmaría cualquier cosa. let pintados = cpu .chunks_exact(4) .filter(|p| p[0] > 8 || p[1] > 8 || p[2] > 8) .count(); assert!( pintados > total / 8, "la escena sólo pintó {pintados} de {total} px; el test no compara un dibujo" ); // **Umbral sobre lo medido, y salió mejor de lo que se suponía.** Medido el // 2026-08-05: `Δ0:236 Δ1:0` — o sea que 236 de 19.200 px difieren, y // **ninguno por más de 1/255**. Dos rasterizadores distintos, uno por CPU y // otro por GPU sin compute, y la diferencia es redondeo. // // Por eso el umbral es duro: **cero píxeles pueden diferir más de Δ1**. Un // tope del 5 % sobre Δ16, que era lo prudente antes de medir, no habría // agarrado nunca nada — y un umbral que nada roza no es un gate. let sobre = distintos(&cpu, &hybrid, 1); assert_eq!( sobre, 0, "{sobre} de {total} px difieren más de Δ1 — el camino del Mali se \ separó del de referencia" ); let desvio = (t_hy - t_cpu).abs() / t_cpu; assert!( desvio <= 0.002, "la tinta total se corrió {:.2} % — un lado pinta más grueso que el otro", desvio * 100.0 ); }