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// =============================================================================
2// pruebas :: que los píxeles salgan, y contarlos como texto
3// -----------------------------------------------------------------------------
4// La Regla 8 del repo dice que renderizar a PNG y MIRARLO es el último recurso,
5// no el primero, porque cuesta tokens. Estas pruebas son la forma cara-cero de
6// la misma evidencia: se rinde de verdad contra la GPU, se lee el framebuffer
7// de vuelta y se cuentan los píxeles POR COLOR. Un histograma es una aserción;
8// una captura es una opinión.
9//
10// Todas necesitan una GPU real. Si no hay adaptador —CI headless sin
11// Vulkan/GL— se saltean con un aviso en vez de fallar: un test rojo por falta
12// de hardware enseña a ignorar los rojos, que es peor que no tener el test.
13// =============================================================================
14
15use std::collections::HashMap;
16
17use llimphi_cpu::kurbo::{Affine, Rect, RoundedRect};
18use llimphi_cpu::peniko::color::{AlphaColor, Srgb};
19use llimphi_cpu::peniko::Fill;
20use llimphi_cpu::Escena;
21
22use crate::Pintor;
23
24const ANCHO: u32 = 256;
25const ALTO: u32 = 256;
26
27fn color(r: u8, g: u8, b: u8) -> AlphaColor<Srgb> {
28 AlphaColor::<Srgb>::from_rgba8(r, g, b, 255)
29}
30
31/// El stack GPU mínimo para rendir a una textura y leerla de vuelta.
32///
33/// Devuelve `None` si la máquina no ofrece adaptador — ver el encabezado.
34struct Banco {
35 device: wgpu::Device,
36 queue: wgpu::Queue,
37 /// El backend elegido: el `Pintor` lo necesita para configurar el atlas —
38 /// ver `ajustes_para` en `pintor.rs`.
39 backend: wgpu::Backend,
40}
41
42impl Banco {
43 fn nuevo() -> Option<Self> {
44 let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
45 // `WGPU_BACKEND=gl` para reproducir el camino de Android desde la
46 // laptop. No es un lujo: el defecto de bordes que persigue
47 // `el_relleno_a_sangre_cubre_los_bordes_laterales` **no aparece** por
48 // Vulkan y el teléfono va por GLES, así que sin esta perilla el test
49 // corre siempre por el backend que no tiene el problema.
50 if let Some(b) = wgpu::Backends::from_env() {
51 desc.backends = b;
52 }
53 let instancia = wgpu::Instance::new(desc);
54 let adapter =
55 pollster::block_on(instancia.request_adapter(&wgpu::RequestAdapterOptions::default()))
56 .ok()?;
57 // Se anuncia el adaptador elegido: un `WGPU_BACKEND` mal escrito se
58 // ignora en silencio, y entonces «probé por GLES» sería mentira.
59 let info = adapter.get_info();
60 eprintln!(
61 "banco sobre {:?} · {} · {}",
62 info.backend, info.name, info.driver_info
63 );
64 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
65 label: Some("llimphi-hybrid-pruebas"),
66 ..Default::default()
67 }))
68 .ok()?;
69 // Sin esto wgpu se traga los errores de validación y una prueba que
70 // falla no dice por qué: el síntoma llega como píxeles que no están.
71 device.on_uncaptured_error(std::sync::Arc::new(|e| eprintln!("WGPU ERROR: {e}")));
72 eprintln!(
73 " límites: max_texture_array_layers={} max_texture_dimension_2d={}",
74 adapter.limits().max_texture_array_layers,
75 adapter.limits().max_texture_dimension_2d
76 );
77 Some(Self {
78 device,
79 queue,
80 backend: info.backend,
81 })
82 }
83
84 /// Rinde la escena y devuelve el framebuffer en RGBA8, fila por fila sin
85 /// relleno (el padding a 256 bytes que exige `copy_texture_to_buffer` se
86 /// recorta acá para que el llamador cuente píxeles y no bytes de alineación).
87 fn rendir(&self, escena: &Escena) -> Vec<u8> {
88 self.rendir_en(escena, ANCHO, ALTO)
89 }
90
91 /// Igual que `rendir`, pero a la medida que se le pida.
92 ///
93 /// Existe porque el tamaño **importa**: 256 es múltiplo de 16 y de 64, así
94 /// que el caso cómodo esconde los dos problemas de borde que hay acá —el
95 /// de vello_hybrid, que se mide en `el_relleno_a_sangre_cubre_los_bordes`,
96 /// y el de la alineación a 256 B que exige `copy_texture_to_buffer`.
97 fn rendir_en(&self, escena: &Escena, ancho: u32, alto: u32) -> Vec<u8> {
98 // Negro OPACO como base: así un píxel negro significa «nadie pintó
99 // acá», que es lo que afirman casi todas las pruebas de este archivo.
100 self.rendir_con_base(escena, ancho, alto, color(0, 0, 0))
101 }
102
103 /// Igual, pero eligiendo el `color_base` que recibe `Pintor::pintar`.
104 ///
105 /// Lo necesita `el_color_base_pinta_donde_la_escena_no_llega`, que tiene que
106 /// poder distinguir el color del clear del color que pinta la escena — con
107 /// los dos iguales, un clear que no hace nada se ve idéntico a uno que sí.
108 fn rendir_con_base(&self, escena: &Escena, ancho: u32, alto: u32, base: AlphaColor<Srgb>) -> Vec<u8> {
109 let formato = wgpu::TextureFormat::Rgba8Unorm;
110 let textura = self.device.create_texture(&wgpu::TextureDescriptor {
111 label: Some("destino"),
112 size: wgpu::Extent3d {
113 width: ancho,
114 height: alto,
115 depth_or_array_layers: 1,
116 },
117 mip_level_count: 1,
118 sample_count: 1,
119 dimension: wgpu::TextureDimension::D2,
120 format: formato,
121 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
122 view_formats: &[],
123 });
124 let vista = textura.create_view(&wgpu::TextureViewDescriptor::default());
125
126 // `copy_texture_to_buffer` exige que `bytes_per_row` sea múltiplo de
127 // 256, y un ancho cualquiera no lo cumple (1080 * 4 = 4320, que no lo
128 // es). Se redondea para arriba y el relleno se recorta al leer, para
129 // que quien cuente píxeles no tenga que saber nada de alineación.
130 let bytes_utiles = ancho * 4;
131 let bytes_por_fila = bytes_utiles.div_ceil(256) * 256;
132
133 let lectura = self.device.create_buffer(&wgpu::BufferDescriptor {
134 label: Some("lectura"),
135 size: u64::from(bytes_por_fila * alto),
136 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
137 mapped_at_creation: false,
138 });
139
140 let mut pintor = Pintor::new(&self.device, self.backend, formato, ancho, alto);
141 let mut encoder = self
142 .device
143 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
144 pintor
145 .pintar(
146 escena,
147 &self.device,
148 &self.queue,
149 &mut encoder,
150 &vista,
151 base,
152 )
153 .expect("vello_hybrid no debe fallar al rendir");
154 encoder.copy_texture_to_buffer(
155 textura.as_image_copy(),
156 wgpu::TexelCopyBufferInfo {
157 buffer: &lectura,
158 layout: wgpu::TexelCopyBufferLayout {
159 offset: 0,
160 bytes_per_row: Some(bytes_por_fila),
161 rows_per_image: Some(alto),
162 },
163 },
164 wgpu::Extent3d {
165 width: ancho,
166 height: alto,
167 depth_or_array_layers: 1,
168 },
169 );
170 self.queue.submit(std::iter::once(encoder.finish()));
171
172 let rebanada = lectura.slice(..);
173 rebanada.map_async(wgpu::MapMode::Read, |_| {});
174 // Sin `submission_index` espera a que se vacíe TODO lo encolado, que es
175 // justo lo que hace falta acá; sin `timeout`, indefinidamente. Un test
176 // que se cuelga se diagnostica; uno que lee un buffer a medio copiar da
177 // un histograma casi correcto, que es mucho peor.
178 self.device
179 .poll(wgpu::PollType::Wait {
180 submission_index: None,
181 timeout: None,
182 })
183 .expect("la GPU debe terminar la copia");
184 // `to_vec` copia y el préstamo muere en el mismo statement, así que
185 // `unmap` no tiene con qué chocar.
186 let datos = rebanada.get_mapped_range().to_vec();
187 lectura.unmap();
188
189 // Recortar el relleno de alineación: el llamador recibe filas contiguas
190 // de `ancho * 4` bytes, como si wgpu no tuviera esa exigencia.
191 if bytes_por_fila == bytes_utiles {
192 return datos;
193 }
194 let mut apretado = Vec::with_capacity((bytes_utiles * alto) as usize);
195 for fila in datos
196 .chunks_exact(bytes_por_fila as usize)
197 .take(alto as usize)
198 {
199 apretado.extend_from_slice(&fila[..bytes_utiles as usize]);
200 }
201 apretado
202 }
203}
204
205/// Cuántos píxeles hay de cada color RGB (se ignora el alfa, que en estas
206/// escenas es siempre opaco).
207fn histograma(pixeles: &[u8]) -> HashMap<(u8, u8, u8), usize> {
208 let mut h = HashMap::new();
209 for p in pixeles.chunks_exact(4) {
210 *h.entry((p[0], p[1], p[2])).or_insert(0) += 1;
211 }
212 h
213}
214
215/// El caso base: tres rectángulos de colores planos sobre un fondo.
216///
217/// Es a propósito la misma escena que el probe que corrió en el Mali a 119 fps,
218/// para que el número de acá y el del teléfono se puedan comparar de frente.
219#[test]
220fn rellenos_planos_llegan_a_los_pixeles() {
221 let Some(banco) = Banco::nuevo() else {
222 eprintln!("sin adaptador wgpu: se saltea");
223 return;
224 };
225
226 let fondo = color(60, 61, 63);
227 let (a, b) = (color(200, 60, 90), color(60, 170, 120));
228
229 let mut escena = Escena::new();
230 let todo = Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO));
231 escena.fill(Fill::NonZero, Affine::IDENTITY, fondo, None, &todo);
232 // Coordenadas enteras: sin bordes fraccionarios no hay antialiasing, y el
233 // histograma da cuentas exactas en vez de una nube de tonos intermedios.
234 escena.fill(
235 Fill::NonZero,
236 Affine::IDENTITY,
237 a,
238 None,
239 &Rect::new(32.0, 32.0, 224.0, 96.0),
240 );
241 escena.fill(
242 Fill::NonZero,
243 Affine::IDENTITY,
244 b,
245 None,
246 &Rect::new(32.0, 128.0, 224.0, 192.0),
247 );
248
249 let h = histograma(&banco.rendir(&escena));
250 // 192 x 64 = 12.288 px cada barra, exactos.
251 assert_eq!(h.get(&(200, 60, 90)).copied().unwrap_or(0), 12_288, "barra A");
252 assert_eq!(h.get(&(60, 170, 120)).copied().unwrap_or(0), 12_288, "barra B");
253 assert_eq!(
254 h.get(&(60, 61, 63)).copied().unwrap_or(0),
255 (ANCHO * ALTO) as usize - 2 * 12_288,
256 "fondo"
257 );
258 assert_eq!(h.get(&(0, 0, 0)).copied().unwrap_or(0), 0, "no debe quedar negro");
259}
260
261/// Una capa recortada tiene que recortar: lo que se pinta fuera del clip no
262/// llega al framebuffer.
263///
264/// Es la orden que más usa el compositor después de `fill` —cada panel, cada
265/// scroll, cada esquina redondeada abre una— y la que más caro sale si falla,
266/// porque el síntoma es «se pinta encima de todo» y no un error.
267#[test]
268fn la_capa_recorta_de_verdad() {
269 let Some(banco) = Banco::nuevo() else {
270 eprintln!("sin adaptador wgpu: se saltea");
271 return;
272 };
273
274 let fondo = color(10, 20, 30);
275 let tinta = color(250, 240, 230);
276
277 let mut escena = Escena::new();
278 let todo = Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO));
279 escena.fill(Fill::NonZero, Affine::IDENTITY, fondo, None, &todo);
280 escena.push_layer(
281 Fill::NonZero,
282 llimphi_cpu::peniko::Mix::Normal,
283 1.0,
284 Affine::IDENTITY,
285 &Rect::new(64.0, 64.0, 192.0, 192.0),
286 );
287 // Se pinta la pantalla ENTERA: si el recorte no funciona, el fondo desaparece.
288 escena.fill(Fill::NonZero, Affine::IDENTITY, tinta, None, &todo);
289 escena.pop_layer();
290
291 let h = histograma(&banco.rendir(&escena));
292 assert_eq!(
293 h.get(&(250, 240, 230)).copied().unwrap_or(0),
294 128 * 128,
295 "la tinta debe quedar confinada al clip de 128x128"
296 );
297 assert_eq!(
298 h.get(&(10, 20, 30)).copied().unwrap_or(0),
299 (ANCHO * ALTO) as usize - 128 * 128,
300 "el resto debe seguir siendo fondo"
301 );
302}
303
304/// Una capa con alfa atenúa lo de adentro sin tocar lo de afuera.
305///
306/// Es la mitad de `push_layer` que el recorte no cubre, y la que usa toda
307/// animación de aparecer/desaparecer del catálogo.
308#[test]
309fn la_capa_con_alfa_atenua() {
310 let Some(banco) = Banco::nuevo() else {
311 eprintln!("sin adaptador wgpu: se saltea");
312 return;
313 };
314
315 let mut escena = Escena::new();
316 let todo = Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO));
317 escena.fill(Fill::NonZero, Affine::IDENTITY, color(0, 0, 0), None, &todo);
318 escena.push_layer(
319 Fill::NonZero,
320 llimphi_cpu::peniko::Mix::Normal,
321 0.5,
322 Affine::IDENTITY,
323 &todo,
324 );
325 escena.fill(
326 Fill::NonZero,
327 Affine::IDENTITY,
328 color(255, 255, 255),
329 None,
330 &Rect::new(64.0, 64.0, 192.0, 192.0),
331 );
332 escena.pop_layer();
333
334 let pixeles = banco.rendir(&escena);
335 let i = ((128 * ANCHO + 128) * 4) as usize;
336 let gris = pixeles[i];
337 // Blanco al 50% sobre negro. Se admite ±2 por el redondeo del pipeline u8;
338 // exigir 127 clavado sería atarse a un detalle de implementación de vello.
339 assert!(
340 (126..=129).contains(&gris),
341 "el centro debería quedar a media luz, salió {gris}"
342 );
343 assert_eq!(
344 (pixeles[i], pixeles[i + 1], pixeles[i + 2]),
345 (gris, gris, gris),
346 "la atenuación debe ser pareja en los tres canales"
347 );
348}
349
350/// El trazo dibuja contorno y no relleno.
351#[test]
352fn el_trazo_deja_hueco_adentro() {
353 let Some(banco) = Banco::nuevo() else {
354 eprintln!("sin adaptador wgpu: se saltea");
355 return;
356 };
357
358 let fondo = color(9, 9, 9);
359 let mut escena = Escena::new();
360 escena.fill(
361 Fill::NonZero,
362 Affine::IDENTITY,
363 fondo,
364 None,
365 &Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO)),
366 );
367 escena.stroke(
368 &llimphi_cpu::kurbo::Stroke::new(8.0),
369 Affine::IDENTITY,
370 color(240, 30, 30),
371 None,
372 &Rect::new(64.0, 64.0, 192.0, 192.0),
373 );
374
375 let pixeles = banco.rendir(&escena);
376 let en = |x: u32, y: u32| {
377 let i = ((y * ANCHO + x) * 4) as usize;
378 (pixeles[i], pixeles[i + 1], pixeles[i + 2])
379 };
380 assert_eq!(en(128, 64), (240, 30, 30), "el borde de arriba debe estar pintado");
381 assert_eq!(en(128, 128), (9, 9, 9), "el centro debe seguir siendo fondo");
382}
383
384/// **El camino nuevo de este crate**: una imagen sube al atlas de la GPU y se
385/// dibuja desde ahí.
386///
387/// Es lo único que no se hereda del pintor CPU —allá la imagen se muestrea de un
388/// `Pixmap` en RAM— así que es donde vale la pena gastar un test. Los bytes se
389/// sintetizan acá: no hace falta ningún archivo en disco para ejercitar
390/// `upload_image` + `ImageSource::opaque_id`.
391#[test]
392fn una_imagen_cruza_por_el_atlas() {
393 let Some(banco) = Banco::nuevo() else {
394 eprintln!("sin adaptador wgpu: se saltea");
395 return;
396 };
397
398 const LADO: u32 = 32;
399 let bytes: Vec<u8> = (0..LADO * LADO)
400 .flat_map(|_| [17_u8, 200, 90, 255])
401 .collect();
402 let imagen = llimphi_cpu::peniko::ImageBrush {
403 image: llimphi_cpu::peniko::ImageData {
404 data: llimphi_cpu::peniko::Blob::from(bytes),
405 format: llimphi_cpu::peniko::ImageFormat::Rgba8,
406 // Alfa separado (no premultiplicado): el caso normal de un PNG, y
407 // el que obliga al conversor a hacer la multiplicación.
408 alpha_type: llimphi_cpu::peniko::ImageAlphaType::Alpha,
409 width: LADO,
410 height: LADO,
411 },
412 sampler: llimphi_cpu::peniko::ImageSampler::default(),
413 };
414
415 let mut escena = Escena::new();
416 escena.fill(
417 Fill::NonZero,
418 Affine::IDENTITY,
419 color(0, 0, 0),
420 None,
421 &Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO)),
422 );
423 escena.draw_image(&imagen, Affine::translate((64.0, 64.0)));
424
425 let h = histograma(&banco.rendir(&escena));
426 // Qué llegó, no sólo cuánto: un «0» pelado no distingue «la imagen no se
427 // dibujó» de «se dibujó de otro color», y son diagnósticos opuestos.
428 let mut top: Vec<_> = h.iter().collect();
429 top.sort_by_key(|(_, n)| std::cmp::Reverse(**n));
430 eprintln!("colores presentes (top 4): {:?}", &top[..top.len().min(4)]);
431 assert_eq!(
432 h.get(&(17, 200, 90)).copied().unwrap_or(0),
433 (LADO * LADO) as usize,
434 "la imagen debe llegar entera y con su color exacto"
435 );
436}
437
438/// La máscara de luminancia: rasterizada en CPU, consumida por la GPU.
439///
440/// El cruce sólo es posible porque vello_cpu y vello_hybrid cuelgan del mismo
441/// `vello_common`, así que la `Mask` viaja sin conversión. Si algún día esa
442/// unificación se rompe, este test es el que avisa.
443#[test]
444fn la_mascara_de_luminancia_recorta_por_brillo() {
445 let Some(banco) = Banco::nuevo() else {
446 eprintln!("sin adaptador wgpu: se saltea");
447 return;
448 };
449
450 let mut escena = Escena::new();
451 let todo = Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO));
452 escena.fill(Fill::NonZero, Affine::IDENTITY, color(20, 20, 20), None, &todo);
453 escena.push_luminance_mask_layer(
454 Fill::NonZero,
455 1.0,
456 Affine::IDENTITY,
457 &Rect::new(64.0, 64.0, 192.0, 192.0),
458 );
459 escena.fill(Fill::NonZero, Affine::IDENTITY, color(250, 250, 250), None, &todo);
460 escena.pop_layer();
461
462 let pixeles = banco.rendir(&escena);
463 let en = |x: u32, y: u32| {
464 let i = ((y * ANCHO + x) * 4) as usize;
465 pixeles[i]
466 };
467 assert!(
468 en(128, 128) > 200,
469 "dentro de la máscara (blanca) debe pasar la tinta, salió {}",
470 en(128, 128)
471 );
472 assert_eq!(
473 en(16, 16),
474 20,
475 "fuera de la máscara (negra) no debe pasar nada"
476 );
477}
478
479/// `append` con transformación: la razón de ser de la grabación.
480///
481/// Se graba un cuadrado en el origen, se lo incorpora trasladado, y se verifica
482/// que los píxeles aparecieron **donde los mandó la traslación** y no donde se
483/// dibujaron. Sin esto el compositor no puede reciclar subárboles cacheados.
484#[test]
485fn append_traslada_lo_grabado() {
486 let Some(banco) = Banco::nuevo() else {
487 eprintln!("sin adaptador wgpu: se saltea");
488 return;
489 };
490
491 let tinta = color(11, 222, 33);
492 let mut hija = Escena::new();
493 hija.fill(
494 Fill::NonZero,
495 Affine::IDENTITY,
496 tinta,
497 None,
498 &Rect::new(0.0, 0.0, 64.0, 64.0),
499 );
500
501 let mut madre = Escena::new();
502 madre.fill(
503 Fill::NonZero,
504 Affine::IDENTITY,
505 color(0, 0, 1),
506 None,
507 &Rect::new(0.0, 0.0, f64::from(ANCHO), f64::from(ALTO)),
508 );
509 madre.append(&hija, Some(Affine::translate((128.0, 128.0))));
510
511 let pixeles = banco.rendir(&madre);
512 let en = |x: u32, y: u32| {
513 let i = ((y * ANCHO + x) * 4) as usize;
514 (pixeles[i], pixeles[i + 1], pixeles[i + 2])
515 };
516 assert_eq!(en(160, 160), (11, 222, 33), "debe haber pintado en el destino");
517 assert_eq!(en(32, 32), (0, 0, 1), "el origen debe haber quedado limpio");
518 assert_eq!(
519 histograma(&pixeles).get(&(11, 222, 33)).copied().unwrap_or(0),
520 64 * 64,
521 "el cuadrado no debe cambiar de tamaño al trasladarse"
522 );
523}
524
525/// **Un relleno a sangre tiene que llegar a las columnas del borde.**
526///
527/// Nació de un defecto medido en metal (Redmi Note 13 Pro, Mali-G57 MC2, a
528/// 1080x2400): las 8 columnas laterales —`x 0..3` y `x 1076..1079`— salían
529/// negro puro aunque la vista raíz cubría el ancho entero con su fondo. El eje
530/// vertical **no** tenía el problema, y el `Pintor` pasa el ancho completo en el
531/// `RenderSize` sin truncar en ningún lado.
532///
533/// La hipótesis con aritmética a favor era el ancho: 2400 es múltiplo de 16 y
534/// 1080 no (67,5). Por eso el barrido mezcla a propósito anchos que **son**
535/// múltiplos de 16 con otros que no: si la hipótesis es buena, sólo los que no
536/// lo son pierden columnas, y el test queda como la prueba de regresión.
537#[test]
538fn el_relleno_a_sangre_cubre_los_bordes_laterales() {
539 let Some(banco) = Banco::nuevo() else {
540 eprintln!("sin adaptador wgpu: se saltea");
541 return;
542 };
543
544 const ALTO_P: u32 = 64;
545 let relleno = color(60, 170, 120);
546 let mut fallos = Vec::new();
547
548 for ancho in [256_u32, 1000, 1072, 1080, 1088] {
549 let mut escena = Escena::new();
550 escena.fill(
551 Fill::NonZero,
552 Affine::IDENTITY,
553 relleno,
554 None,
555 &Rect::new(0.0, 0.0, f64::from(ancho), f64::from(ALTO_P)),
556 );
557 let px = banco.rendir_en(&escena, ancho, ALTO_P);
558
559 // El banco limpia a negro OPACO, así que negro = nadie pintó acá. Una
560 // columna cuenta como vacía sólo si NINGUNA de sus filas se pintó.
561 let vacia = |x: u32| {
562 (0..ALTO_P).all(|y| {
563 let i = ((y * ancho + x) * 4) as usize;
564 px[i] == 0 && px[i + 1] == 0 && px[i + 2] == 0
565 })
566 };
567 let izq = (0..ancho).take_while(|&x| vacia(x)).count();
568 let der = (0..ancho).rev().take_while(|&x| vacia(x)).count();
569 eprintln!(
570 "ancho {ancho:5} (múltiplo de 16: {:5}) → vacías: {izq} izq · {der} der",
571 ancho % 16 == 0
572 );
573 if izq != 0 || der != 0 {
574 fallos.push((ancho, izq, der));
575 }
576 }
577
578 assert!(
579 fallos.is_empty(),
580 "un relleno a sangre dejó columnas sin pintar (ancho, izq, der): {fallos:?}"
581 );
582}
583
584/// **El techo de textura se redondea a potencia de dos.**
585///
586/// No necesita GPU: es la cuenta que decide si `vello_hybrid` sabe direccionar
587/// su textura de alfas. El caso que importa es 16383 —lo que reporta el Mali del
588/// Redmi—, cuyo `trailing_zeros()` es 0 y deja el corrimiento del shader en
589/// nada. Ver `limites_seguros`.
590#[test]
591fn el_techo_de_textura_se_redondea_a_potencia_de_dos() {
592 for (entrada, esperado) in [
593 (16383_u32, 8192_u32),
594 (16384, 16384),
595 (8192, 8192),
596 (8191, 4096),
597 (1, 1),
598 (0, 1),
599 ] {
600 let salida = crate::pintor::potencia_de_dos_hacia_abajo(entrada);
601 assert_eq!(salida, esperado, "techo para {entrada}");
602 assert!(
603 salida.is_power_of_two(),
604 "{salida} tiene que ser potencia de dos"
605 );
606 assert!(
607 salida <= entrada.max(1),
608 "el techo no puede subir: {entrada} → {salida}"
609 );
610 }
611}
612
613/// **El mismo relleno a sangre, pero por el camino que usa el compositor.**
614///
615/// El test de arriba rellena un `Rect`, que en el Pintor cae en `fill_rect`.
616/// El compositor NUNCA emite eso: `render.rs` arma siempre un `RoundedRect`
617/// con `node_rrect` —radio 0 incluido—, que se graba como `Forma::Redondeado`
618/// y termina en `fill_path`. Son dos rasterizados distintos de la misma
619/// geometría, y sólo el segundo es el que pinta el fondo de una app.
620#[test]
621fn el_relleno_a_sangre_redondeado_cubre_los_bordes_laterales() {
622 let Some(banco) = Banco::nuevo() else {
623 eprintln!("sin adaptador wgpu: se saltea");
624 return;
625 };
626
627 const ALTO_P: u32 = 64;
628 let relleno = color(60, 170, 120);
629 let mut fallos = Vec::new();
630
631 for ancho in [256_u32, 1000, 1072, 1080, 1088] {
632 let mut escena = Escena::new();
633 escena.fill(
634 Fill::NonZero,
635 Affine::IDENTITY,
636 relleno,
637 None,
638 &RoundedRect::new(0.0, 0.0, f64::from(ancho), f64::from(ALTO_P), 0.0),
639 );
640 let px = banco.rendir_en(&escena, ancho, ALTO_P);
641
642 let vacia = |x: u32| {
643 (0..ALTO_P).all(|y| {
644 let i = ((y * ancho + x) * 4) as usize;
645 px[i] == 0 && px[i + 1] == 0 && px[i + 2] == 0
646 })
647 };
648 let izq = (0..ancho).take_while(|&x| vacia(x)).count();
649 let der = (0..ancho).rev().take_while(|&x| vacia(x)).count();
650 eprintln!("ancho {ancho:5} redondeado r=0 → vacías: {izq} izq · {der} der");
651 if izq != 0 || der != 0 {
652 fallos.push((ancho, izq, der));
653 }
654 }
655
656 assert!(
657 fallos.is_empty(),
658 "el relleno redondeado dejó columnas sin pintar (ancho, izq, der): {fallos:?}"
659 );
660}
661
662/// **El `color_base` tiene que quedar donde la escena no pinta.**
663///
664/// Es la premisa entera del pase `limpiar` del `Pintor`: `vello::Renderer`
665/// limpia su target con el `base_color` de los `RenderParams`, el de
666/// `vello_hybrid` —según su doc— entra con `LoadOp::Load`, y por eso el Pintor
667/// agrega un `LoadOp::Clear` propio. Si esa premisa fuera falsa, el pase sería
668/// tanto inútil como invisible: con `color_base` igual al fondo que pinta la
669/// escena —que es el caso normal— los dos se ven exactamente igual.
670///
671/// Nació de una medición en el Redmi que no cerraba: con el clear cambiado a
672/// magenta, la pantalla no tenía **ni un** píxel magenta. Este test hace la
673/// misma pregunta donde se puede depurar.
674#[test]
675fn el_color_base_pinta_donde_la_escena_no_llega() {
676 let Some(banco) = Banco::nuevo() else {
677 eprintln!("sin adaptador wgpu: se saltea");
678 return;
679 };
680
681 // La escena pinta SÓLO un cuadrado central: las esquinas quedan a cargo del
682 // color base, y son el sitio donde se lee la respuesta.
683 let mut escena = Escena::new();
684 escena.fill(
685 Fill::NonZero,
686 Affine::IDENTITY,
687 color(60, 170, 120),
688 None,
689 &Rect::new(64.0, 64.0, 192.0, 192.0),
690 );
691
692 let base = color(255, 0, 255);
693 let px = banco.rendir_con_base(&escena, ANCHO, ALTO, base);
694
695 let leer = |x: u32, y: u32| {
696 let i = ((y * ANCHO + x) * 4) as usize;
697 (px[i], px[i + 1], px[i + 2])
698 };
699 let centro = leer(128, 128);
700 let esquina = leer(4, 4);
701 eprintln!("centro {centro:?} · esquina {esquina:?} · base esperada (255, 0, 255)");
702
703 assert_eq!(centro, (60, 170, 120), "la escena tiene que pintar el centro");
704 assert_eq!(
705 esquina,
706 (255, 0, 255),
707 "el color_base tiene que sobrevivir donde la escena no pinta; si sale \
708 (0,0,0) es que vello_hybrid limpia el attachment por su cuenta y el \
709 pase `limpiar` del Pintor no sirve para nada"
710 );
711}