// ============================================================================= // pruebas :: que los píxeles salgan, y contarlos como texto // ----------------------------------------------------------------------------- // La Regla 8 del repo dice que renderizar a PNG y MIRARLO es el último recurso, // no el primero, porque cuesta tokens. Estas pruebas son la forma cara-cero de // la misma evidencia: se rinde de verdad contra la GPU, se lee el framebuffer // de vuelta y se cuentan los píxeles POR COLOR. Un histograma es una aserción; // una captura es una opinión. // // Todas necesitan una GPU real. Si no hay adaptador —CI headless sin // Vulkan/GL— se saltean con un aviso en vez de fallar: un test rojo por falta // de hardware enseña a ignorar los rojos, que es peor que no tener el test. // ============================================================================= use std::collections::HashMap; use llimphi_cpu::kurbo::{Affine, Rect, RoundedRect}; use llimphi_cpu::peniko::color::{AlphaColor, Srgb}; use llimphi_cpu::peniko::Fill; use llimphi_cpu::Escena; use crate::Pintor; const ANCHO: u32 = 256; const ALTO: u32 = 256; fn color(r: u8, g: u8, b: u8) -> AlphaColor { AlphaColor::::from_rgba8(r, g, b, 255) } /// El stack GPU mínimo para rendir a una textura y leerla de vuelta. /// /// Devuelve `None` si la máquina no ofrece adaptador — ver el encabezado. struct Banco { device: wgpu::Device, queue: wgpu::Queue, /// El backend elegido: el `Pintor` lo necesita para configurar el atlas — /// ver `ajustes_para` en `pintor.rs`. backend: wgpu::Backend, } impl Banco { fn nuevo() -> Option { let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); // `WGPU_BACKEND=gl` para reproducir el camino de Android desde la // laptop. No es un lujo: el defecto de bordes que persigue // `el_relleno_a_sangre_cubre_los_bordes_laterales` **no aparece** por // Vulkan y el teléfono va por GLES, así que sin esta perilla el test // corre siempre por el backend que no tiene el problema. if let Some(b) = wgpu::Backends::from_env() { desc.backends = b; } let instancia = wgpu::Instance::new(desc); let adapter = pollster::block_on(instancia.request_adapter(&wgpu::RequestAdapterOptions::default())) .ok()?; // Se anuncia el adaptador elegido: un `WGPU_BACKEND` mal escrito se // ignora en silencio, y entonces «probé por GLES» sería mentira. let info = adapter.get_info(); eprintln!( "banco sobre {:?} · {} · {}", info.backend, info.name, info.driver_info ); let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("llimphi-hybrid-pruebas"), ..Default::default() })) .ok()?; // Sin esto wgpu se traga los errores de validación y una prueba que // falla no dice por qué: el síntoma llega como píxeles que no están. device.on_uncaptured_error(std::sync::Arc::new(|e| eprintln!("WGPU ERROR: {e}"))); eprintln!( " límites: max_texture_array_layers={} max_texture_dimension_2d={}", adapter.limits().max_texture_array_layers, adapter.limits().max_texture_dimension_2d ); Some(Self { device, queue, backend: info.backend, }) } /// Rinde la escena y devuelve el framebuffer en RGBA8, fila por fila sin /// relleno (el padding a 256 bytes que exige `copy_texture_to_buffer` se /// recorta acá para que el llamador cuente píxeles y no bytes de alineación). fn rendir(&self, escena: &Escena) -> Vec { self.rendir_en(escena, ANCHO, ALTO) } /// Igual que `rendir`, pero a la medida que se le pida. /// /// Existe porque el tamaño **importa**: 256 es múltiplo de 16 y de 64, así /// que el caso cómodo esconde los dos problemas de borde que hay acá —el /// de vello_hybrid, que se mide en `el_relleno_a_sangre_cubre_los_bordes`, /// y el de la alineación a 256 B que exige `copy_texture_to_buffer`. fn rendir_en(&self, escena: &Escena, ancho: u32, alto: u32) -> Vec { // Negro OPACO como base: así un píxel negro significa «nadie pintó // acá», que es lo que afirman casi todas las pruebas de este archivo. self.rendir_con_base(escena, ancho, alto, color(0, 0, 0)) } /// Igual, pero eligiendo el `color_base` que recibe `Pintor::pintar`. /// /// Lo necesita `el_color_base_pinta_donde_la_escena_no_llega`, que tiene que /// poder distinguir el color del clear del color que pinta la escena — con /// los dos iguales, un clear que no hace nada se ve idéntico a uno que sí. fn rendir_con_base(&self, escena: &Escena, ancho: u32, alto: u32, base: AlphaColor) -> Vec { let formato = wgpu::TextureFormat::Rgba8Unorm; let textura = self.device.create_texture(&wgpu::TextureDescriptor { label: Some("destino"), size: wgpu::Extent3d { width: ancho, height: alto, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: formato, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let vista = textura.create_view(&wgpu::TextureViewDescriptor::default()); // `copy_texture_to_buffer` exige que `bytes_per_row` sea múltiplo de // 256, y un ancho cualquiera no lo cumple (1080 * 4 = 4320, que no lo // es). Se redondea para arriba y el relleno se recorta al leer, para // que quien cuente píxeles no tenga que saber nada de alineación. let bytes_utiles = ancho * 4; let bytes_por_fila = bytes_utiles.div_ceil(256) * 256; let lectura = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("lectura"), size: u64::from(bytes_por_fila * alto), usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, mapped_at_creation: false, }); let mut pintor = Pintor::new(&self.device, self.backend, formato, ancho, alto); let mut encoder = self .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); pintor .pintar( escena, &self.device, &self.queue, &mut encoder, &vista, base, ) .expect("vello_hybrid no debe fallar al rendir"); encoder.copy_texture_to_buffer( textura.as_image_copy(), wgpu::TexelCopyBufferInfo { buffer: &lectura, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(bytes_por_fila), rows_per_image: Some(alto), }, }, wgpu::Extent3d { width: ancho, height: alto, depth_or_array_layers: 1, }, ); self.queue.submit(std::iter::once(encoder.finish())); let rebanada = lectura.slice(..); rebanada.map_async(wgpu::MapMode::Read, |_| {}); // Sin `submission_index` espera a que se vacíe TODO lo encolado, que es // justo lo que hace falta acá; sin `timeout`, indefinidamente. Un test // que se cuelga se diagnostica; uno que lee un buffer a medio copiar da // un histograma casi correcto, que es mucho peor. self.device .poll(wgpu::PollType::Wait { submission_index: None, timeout: None, }) .expect("la GPU debe terminar la copia"); // `to_vec` copia y el préstamo muere en el mismo statement, así que // `unmap` no tiene con qué chocar. let datos = rebanada.get_mapped_range().to_vec(); lectura.unmap(); // Recortar el relleno de alineación: el llamador recibe filas contiguas // de `ancho * 4` bytes, como si wgpu no tuviera esa exigencia. if bytes_por_fila == bytes_utiles { return datos; } let mut apretado = Vec::with_capacity((bytes_utiles * alto) as usize); for fila in datos .chunks_exact(bytes_por_fila as usize) .take(alto as usize) { apretado.extend_from_slice(&fila[..bytes_utiles as usize]); } apretado } } /// Cuántos píxeles hay de cada color RGB (se ignora el alfa, que en estas /// escenas es siempre opaco). fn histograma(pixeles: &[u8]) -> HashMap<(u8, u8, u8), usize> { let mut h = HashMap::new(); for p in pixeles.chunks_exact(4) { *h.entry((p[0], p[1], p[2])).or_insert(0) += 1; } h } /// El caso base: tres rectángulos de colores planos sobre un fondo. /// /// Es a propósito la misma escena que el probe que corrió en el Mali a 119 fps, /// para que el número de acá y el del teléfono se puedan comparar de frente. #[test] fn rellenos_planos_llegan_a_los_pixeles() { let Some(banco) = Banco::nuevo() else { eprintln!("sin adaptador wgpu: se saltea"); return; }; let fondo = color(60, 61, 63); let (a, b) = (color(200, 60, 90), color(60, 170, 120)); let mut escena = Escena::new(); let todo = Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO)); escena.fill(Fill::NonZero, Affine::IDENTITY, fondo, None, &todo); // Coordenadas enteras: sin bordes fraccionarios no hay antialiasing, y el // histograma da cuentas exactas en vez de una nube de tonos intermedios. escena.fill( Fill::NonZero, Affine::IDENTITY, a, None, &Rect::new(32.0, 32.0, 224.0, 96.0), ); escena.fill( Fill::NonZero, Affine::IDENTITY, b, None, &Rect::new(32.0, 128.0, 224.0, 192.0), ); let h = histograma(&banco.rendir(&escena)); // 192 x 64 = 12.288 px cada barra, exactos. assert_eq!(h.get(&(200, 60, 90)).copied().unwrap_or(0), 12_288, "barra A"); assert_eq!(h.get(&(60, 170, 120)).copied().unwrap_or(0), 12_288, "barra B"); assert_eq!( h.get(&(60, 61, 63)).copied().unwrap_or(0), (ANCHO * ALTO) as usize - 2 * 12_288, "fondo" ); assert_eq!(h.get(&(0, 0, 0)).copied().unwrap_or(0), 0, "no debe quedar negro"); } /// Una capa recortada tiene que recortar: lo que se pinta fuera del clip no /// llega al framebuffer. /// /// Es la orden que más usa el compositor después de `fill` —cada panel, cada /// scroll, cada esquina redondeada abre una— y la que más caro sale si falla, /// porque el síntoma es «se pinta encima de todo» y no un error. #[test] fn la_capa_recorta_de_verdad() { let Some(banco) = Banco::nuevo() else { eprintln!("sin adaptador wgpu: se saltea"); return; }; let fondo = color(10, 20, 30); let tinta = color(250, 240, 230); let mut escena = Escena::new(); let todo = Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO)); escena.fill(Fill::NonZero, Affine::IDENTITY, fondo, None, &todo); escena.push_layer( Fill::NonZero, llimphi_cpu::peniko::Mix::Normal, 1.0, Affine::IDENTITY, &Rect::new(64.0, 64.0, 192.0, 192.0), ); // Se pinta la pantalla ENTERA: si el recorte no funciona, el fondo desaparece. escena.fill(Fill::NonZero, Affine::IDENTITY, tinta, None, &todo); escena.pop_layer(); let h = histograma(&banco.rendir(&escena)); assert_eq!( h.get(&(250, 240, 230)).copied().unwrap_or(0), 128 * 128, "la tinta debe quedar confinada al clip de 128x128" ); assert_eq!( h.get(&(10, 20, 30)).copied().unwrap_or(0), (ANCHO * ALTO) as usize - 128 * 128, "el resto debe seguir siendo fondo" ); } /// Una capa con alfa atenúa lo de adentro sin tocar lo de afuera. /// /// Es la mitad de `push_layer` que el recorte no cubre, y la que usa toda /// animación de aparecer/desaparecer del catálogo. #[test] fn la_capa_con_alfa_atenua() { let Some(banco) = Banco::nuevo() else { eprintln!("sin adaptador wgpu: se saltea"); return; }; let mut escena = Escena::new(); let todo = Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO)); escena.fill(Fill::NonZero, Affine::IDENTITY, color(0, 0, 0), None, &todo); escena.push_layer( Fill::NonZero, llimphi_cpu::peniko::Mix::Normal, 0.5, Affine::IDENTITY, &todo, ); escena.fill( Fill::NonZero, Affine::IDENTITY, color(255, 255, 255), None, &Rect::new(64.0, 64.0, 192.0, 192.0), ); escena.pop_layer(); let pixeles = banco.rendir(&escena); let i = ((128 * ANCHO + 128) * 4) as usize; let gris = pixeles[i]; // Blanco al 50% sobre negro. Se admite ±2 por el redondeo del pipeline u8; // exigir 127 clavado sería atarse a un detalle de implementación de vello. assert!( (126..=129).contains(&gris), "el centro debería quedar a media luz, salió {gris}" ); assert_eq!( (pixeles[i], pixeles[i + 1], pixeles[i + 2]), (gris, gris, gris), "la atenuación debe ser pareja en los tres canales" ); } /// El trazo dibuja contorno y no relleno. #[test] fn el_trazo_deja_hueco_adentro() { let Some(banco) = Banco::nuevo() else { eprintln!("sin adaptador wgpu: se saltea"); return; }; let fondo = color(9, 9, 9); let mut escena = Escena::new(); escena.fill( Fill::NonZero, Affine::IDENTITY, fondo, None, &Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO)), ); escena.stroke( &llimphi_cpu::kurbo::Stroke::new(8.0), Affine::IDENTITY, color(240, 30, 30), None, &Rect::new(64.0, 64.0, 192.0, 192.0), ); let pixeles = banco.rendir(&escena); let en = |x: u32, y: u32| { let i = ((y * ANCHO + x) * 4) as usize; (pixeles[i], pixeles[i + 1], pixeles[i + 2]) }; assert_eq!(en(128, 64), (240, 30, 30), "el borde de arriba debe estar pintado"); assert_eq!(en(128, 128), (9, 9, 9), "el centro debe seguir siendo fondo"); } /// **El camino nuevo de este crate**: una imagen sube al atlas de la GPU y se /// dibuja desde ahí. /// /// Es lo único que no se hereda del pintor CPU —allá la imagen se muestrea de un /// `Pixmap` en RAM— así que es donde vale la pena gastar un test. Los bytes se /// sintetizan acá: no hace falta ningún archivo en disco para ejercitar /// `upload_image` + `ImageSource::opaque_id`. #[test] fn una_imagen_cruza_por_el_atlas() { let Some(banco) = Banco::nuevo() else { eprintln!("sin adaptador wgpu: se saltea"); return; }; const LADO: u32 = 32; let bytes: Vec = (0..LADO * LADO) .flat_map(|_| [17_u8, 200, 90, 255]) .collect(); let imagen = llimphi_cpu::peniko::ImageBrush { image: llimphi_cpu::peniko::ImageData { data: llimphi_cpu::peniko::Blob::from(bytes), format: llimphi_cpu::peniko::ImageFormat::Rgba8, // Alfa separado (no premultiplicado): el caso normal de un PNG, y // el que obliga al conversor a hacer la multiplicación. alpha_type: llimphi_cpu::peniko::ImageAlphaType::Alpha, width: LADO, height: LADO, }, sampler: llimphi_cpu::peniko::ImageSampler::default(), }; let mut escena = Escena::new(); escena.fill( Fill::NonZero, Affine::IDENTITY, color(0, 0, 0), None, &Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO)), ); escena.draw_image(&imagen, Affine::translate((64.0, 64.0))); let h = histograma(&banco.rendir(&escena)); // Qué llegó, no sólo cuánto: un «0» pelado no distingue «la imagen no se // dibujó» de «se dibujó de otro color», y son diagnósticos opuestos. let mut top: Vec<_> = h.iter().collect(); top.sort_by_key(|(_, n)| std::cmp::Reverse(**n)); eprintln!("colores presentes (top 4): {:?}", &top[..top.len().min(4)]); assert_eq!( h.get(&(17, 200, 90)).copied().unwrap_or(0), (LADO * LADO) as usize, "la imagen debe llegar entera y con su color exacto" ); } /// La máscara de luminancia: rasterizada en CPU, consumida por la GPU. /// /// El cruce sólo es posible porque vello_cpu y vello_hybrid cuelgan del mismo /// `vello_common`, así que la `Mask` viaja sin conversión. Si algún día esa /// unificación se rompe, este test es el que avisa. #[test] fn la_mascara_de_luminancia_recorta_por_brillo() { let Some(banco) = Banco::nuevo() else { eprintln!("sin adaptador wgpu: se saltea"); return; }; let mut escena = Escena::new(); let todo = Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO)); escena.fill(Fill::NonZero, Affine::IDENTITY, color(20, 20, 20), None, &todo); escena.push_luminance_mask_layer( Fill::NonZero, 1.0, Affine::IDENTITY, &Rect::new(64.0, 64.0, 192.0, 192.0), ); escena.fill(Fill::NonZero, Affine::IDENTITY, color(250, 250, 250), None, &todo); escena.pop_layer(); let pixeles = banco.rendir(&escena); let en = |x: u32, y: u32| { let i = ((y * ANCHO + x) * 4) as usize; pixeles[i] }; assert!( en(128, 128) > 200, "dentro de la máscara (blanca) debe pasar la tinta, salió {}", en(128, 128) ); assert_eq!( en(16, 16), 20, "fuera de la máscara (negra) no debe pasar nada" ); } /// `append` con transformación: la razón de ser de la grabación. /// /// Se graba un cuadrado en el origen, se lo incorpora trasladado, y se verifica /// que los píxeles aparecieron **donde los mandó la traslación** y no donde se /// dibujaron. Sin esto el compositor no puede reciclar subárboles cacheados. #[test] fn append_traslada_lo_grabado() { let Some(banco) = Banco::nuevo() else { eprintln!("sin adaptador wgpu: se saltea"); return; }; let tinta = color(11, 222, 33); let mut hija = Escena::new(); hija.fill( Fill::NonZero, Affine::IDENTITY, tinta, None, &Rect::new(0.0, 0.0, 64.0, 64.0), ); let mut madre = Escena::new(); madre.fill( Fill::NonZero, Affine::IDENTITY, color(0, 0, 1), None, &Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO)), ); madre.append(&hija, Some(Affine::translate((128.0, 128.0)))); let pixeles = banco.rendir(&madre); let en = |x: u32, y: u32| { let i = ((y * ANCHO + x) * 4) as usize; (pixeles[i], pixeles[i + 1], pixeles[i + 2]) }; assert_eq!(en(160, 160), (11, 222, 33), "debe haber pintado en el destino"); assert_eq!(en(32, 32), (0, 0, 1), "el origen debe haber quedado limpio"); assert_eq!( histograma(&pixeles).get(&(11, 222, 33)).copied().unwrap_or(0), 64 * 64, "el cuadrado no debe cambiar de tamaño al trasladarse" ); } /// **Un relleno a sangre tiene que llegar a las columnas del borde.** /// /// Nació de un defecto medido en metal (Redmi Note 13 Pro, Mali-G57 MC2, a /// 1080x2400): las 8 columnas laterales —`x 0..3` y `x 1076..1079`— salían /// negro puro aunque la vista raíz cubría el ancho entero con su fondo. El eje /// vertical **no** tenía el problema, y el `Pintor` pasa el ancho completo en el /// `RenderSize` sin truncar en ningún lado. /// /// La hipótesis con aritmética a favor era el ancho: 2400 es múltiplo de 16 y /// 1080 no (67,5). Por eso el barrido mezcla a propósito anchos que **son** /// múltiplos de 16 con otros que no: si la hipótesis es buena, sólo los que no /// lo son pierden columnas, y el test queda como la prueba de regresión. #[test] fn el_relleno_a_sangre_cubre_los_bordes_laterales() { let Some(banco) = Banco::nuevo() else { eprintln!("sin adaptador wgpu: se saltea"); return; }; const ALTO_P: u32 = 64; let relleno = color(60, 170, 120); let mut fallos = Vec::new(); for ancho in [256_u32, 1000, 1072, 1080, 1088] { let mut escena = Escena::new(); escena.fill( Fill::NonZero, Affine::IDENTITY, relleno, None, &Rect::new(0.0, 0.0, f64::from(ancho), f64::from(ALTO_P)), ); let px = banco.rendir_en(&escena, ancho, ALTO_P); // El banco limpia a negro OPACO, así que negro = nadie pintó acá. Una // columna cuenta como vacía sólo si NINGUNA de sus filas se pintó. let vacia = |x: u32| { (0..ALTO_P).all(|y| { let i = ((y * ancho + x) * 4) as usize; px[i] == 0 && px[i + 1] == 0 && px[i + 2] == 0 }) }; let izq = (0..ancho).take_while(|&x| vacia(x)).count(); let der = (0..ancho).rev().take_while(|&x| vacia(x)).count(); eprintln!( "ancho {ancho:5} (múltiplo de 16: {:5}) → vacías: {izq} izq · {der} der", ancho % 16 == 0 ); if izq != 0 || der != 0 { fallos.push((ancho, izq, der)); } } assert!( fallos.is_empty(), "un relleno a sangre dejó columnas sin pintar (ancho, izq, der): {fallos:?}" ); } /// **El techo de textura se redondea a potencia de dos.** /// /// No necesita GPU: es la cuenta que decide si `vello_hybrid` sabe direccionar /// su textura de alfas. El caso que importa es 16383 —lo que reporta el Mali del /// Redmi—, cuyo `trailing_zeros()` es 0 y deja el corrimiento del shader en /// nada. Ver `limites_seguros`. #[test] fn el_techo_de_textura_se_redondea_a_potencia_de_dos() { for (entrada, esperado) in [ (16383_u32, 8192_u32), (16384, 16384), (8192, 8192), (8191, 4096), (1, 1), (0, 1), ] { let salida = crate::pintor::potencia_de_dos_hacia_abajo(entrada); assert_eq!(salida, esperado, "techo para {entrada}"); assert!( salida.is_power_of_two(), "{salida} tiene que ser potencia de dos" ); assert!( salida <= entrada.max(1), "el techo no puede subir: {entrada} → {salida}" ); } } /// **El mismo relleno a sangre, pero por el camino que usa el compositor.** /// /// El test de arriba rellena un `Rect`, que en el Pintor cae en `fill_rect`. /// El compositor NUNCA emite eso: `render.rs` arma siempre un `RoundedRect` /// con `node_rrect` —radio 0 incluido—, que se graba como `Forma::Redondeado` /// y termina en `fill_path`. Son dos rasterizados distintos de la misma /// geometría, y sólo el segundo es el que pinta el fondo de una app. #[test] fn el_relleno_a_sangre_redondeado_cubre_los_bordes_laterales() { let Some(banco) = Banco::nuevo() else { eprintln!("sin adaptador wgpu: se saltea"); return; }; const ALTO_P: u32 = 64; let relleno = color(60, 170, 120); let mut fallos = Vec::new(); for ancho in [256_u32, 1000, 1072, 1080, 1088] { let mut escena = Escena::new(); escena.fill( Fill::NonZero, Affine::IDENTITY, relleno, None, &RoundedRect::new(0.0, 0.0, f64::from(ancho), f64::from(ALTO_P), 0.0), ); let px = banco.rendir_en(&escena, ancho, ALTO_P); let vacia = |x: u32| { (0..ALTO_P).all(|y| { let i = ((y * ancho + x) * 4) as usize; px[i] == 0 && px[i + 1] == 0 && px[i + 2] == 0 }) }; let izq = (0..ancho).take_while(|&x| vacia(x)).count(); let der = (0..ancho).rev().take_while(|&x| vacia(x)).count(); eprintln!("ancho {ancho:5} redondeado r=0 → vacías: {izq} izq · {der} der"); if izq != 0 || der != 0 { fallos.push((ancho, izq, der)); } } assert!( fallos.is_empty(), "el relleno redondeado dejó columnas sin pintar (ancho, izq, der): {fallos:?}" ); } /// **El `color_base` tiene que quedar donde la escena no pinta.** /// /// Es la premisa entera del pase `limpiar` del `Pintor`: `vello::Renderer` /// limpia su target con el `base_color` de los `RenderParams`, el de /// `vello_hybrid` —según su doc— entra con `LoadOp::Load`, y por eso el Pintor /// agrega un `LoadOp::Clear` propio. Si esa premisa fuera falsa, el pase sería /// tanto inútil como invisible: con `color_base` igual al fondo que pinta la /// escena —que es el caso normal— los dos se ven exactamente igual. /// /// Nació de una medición en el Redmi que no cerraba: con el clear cambiado a /// magenta, la pantalla no tenía **ni un** píxel magenta. Este test hace la /// misma pregunta donde se puede depurar. #[test] fn el_color_base_pinta_donde_la_escena_no_llega() { let Some(banco) = Banco::nuevo() else { eprintln!("sin adaptador wgpu: se saltea"); return; }; // La escena pinta SÓLO un cuadrado central: las esquinas quedan a cargo del // color base, y son el sitio donde se lee la respuesta. let mut escena = Escena::new(); escena.fill( Fill::NonZero, Affine::IDENTITY, color(60, 170, 120), None, &Rect::new(64.0, 64.0, 192.0, 192.0), ); let base = color(255, 0, 255); let px = banco.rendir_con_base(&escena, ANCHO, ALTO, base); let leer = |x: u32, y: u32| { let i = ((y * ANCHO + x) * 4) as usize; (px[i], px[i + 1], px[i + 2]) }; let centro = leer(128, 128); let esquina = leer(4, 4); eprintln!("centro {centro:?} · esquina {esquina:?} · base esperada (255, 0, 255)"); assert_eq!(centro, (60, 170, 120), "la escena tiene que pintar el centro"); assert_eq!( esquina, (255, 0, 255), "el color_base tiene que sobrevivir donde la escena no pinta; si sale \ (0,0,0) es que vello_hybrid limpia el attachment por su cuenta y el \ pase `limpiar` del Pintor no sirve para nada" ); }