tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos
1//! ¿Cuántas imágenes distintas aguanta una escena antes de que vello **deje de
2//! dibujar** algunas — sin error, sin warning?
3//!
4//! **El porqué** (2026-08-10). sergio reportó dos veces que las miniaturas de
5//! `media` «aparecen y después las reemplaza un negro». La primera vuelta se
6//! arregló la evicción de la caché de miniaturas de la app; el síntoma siguió
7//! igual con el binario nuevo desplegado — o sea que la miniatura **está** en
8//! la caché y el negro lo pone otra capa. Leyendo vello aparece un camino que
9//! calla:
10//!
11//! ```text
12//! // vello_encoding-0.9.0/src/resolve.rs:524
13//! // If the atlas is already maximum size, there's nothing we can do. Set
14//! // the xy field to None so this image isn't rendered and then carry on--
15//! // other images might still fit.
16//! ```
17//!
18//! Es decir: las imágenes viven en un atlas cuadrado que arranca en 1024 px y
19//! duplica hasta 8192; cuando ni evictando ni creciendo entra una más, **esa
20//! imagen no se dibuja** y queda lo que haya debajo. En `media` lo que hay
21//! debajo es la placa `rgba(16,20,28)` del `tooth_thumb` — el negro del
22//! reporte.
23//!
24//! Este test no adivina: pone N imágenes distintas en una escena, la rastriza y
25//! **cuenta cuántas llegaron al píxel**. Es la Regla 8 en su forma barata — la
26//! evidencia es un número en texto, no un PNG — y la 8.ter: el síntoma iba por
27//! su segundo reporte, así que lo que faltaba era el instrumento.
28//!
29//! Ojo con lo que este test **no** dice: mide el techo, no si `media` lo toca.
30//! Para eso hay que contar las imágenes que la app mete en una escena.
31
32use llimphi_hal::{wgpu, Hal};
33use llimphi_raster::peniko::{
34 color::palette, Blob, Color, ImageAlphaType, ImageBrush, ImageData, ImageFormat,
35};
36use llimphi_raster::{kurbo::Affine, Escena, Renderer};
37
38/// Lado de la textura destino. Cada imagen se dibuja en una celda de
39/// [`CELDA`] px, así que entran `(LADO/CELDA)²` imágenes por escena.
40const LADO: u32 = 512;
41const CELDA: u32 = 8;
42const POR_FILA: u32 = LADO / CELDA;
43
44/// Tamaño de la imagen **fuente**. Es lo que ocupa en el atlas — el tamaño con
45/// el que se dibuja no importa. 256×144 es exactamente lo que `media` extrae
46/// para una miniatura (`thumbs::THUMB_W = 256`, 16:9).
47const IMG_W: u32 = 256;
48const IMG_H: u32 = 144;
49
50const FMT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
51
52/// Imagen de color plano, con un `Blob` **nuevo** cada vez. El id del blob es
53/// un contador global monótono (`linebender_resource_handle`), así que dos
54/// llamadas nunca comparten entrada de atlas aunque los píxeles sean iguales —
55/// que es justo lo que pasa en la app cuando una miniatura se re-decodifica.
56fn imagen_plana(r: u8, g: u8, b: u8) -> ImageBrush {
57 let mut px = Vec::with_capacity((IMG_W * IMG_H * 4) as usize);
58 for _ in 0..IMG_W * IMG_H {
59 px.extend_from_slice(&[r, g, b, 255]);
60 }
61 ImageBrush {
62 image: ImageData {
63 data: Blob::from(px),
64 format: ImageFormat::Rgba8,
65 alpha_type: ImageAlphaType::Alpha,
66 width: IMG_W,
67 height: IMG_H,
68 },
69 sampler: Default::default(),
70 }
71}
72
73/// Color de la imagen `i`, elegido para que ninguna celda pueda confundirse con
74/// el fondo (canal rojo siempre ≥ 32) ni con su vecina.
75fn color_de(i: u32) -> (u8, u8, u8) {
76 (
77 32 + (i % 200) as u8,
78 ((i / 7) % 256) as u8,
79 ((i / 53) % 256) as u8,
80 )
81}
82
83fn leer(hal: &Hal, tex: &wgpu::Texture) -> Vec<u8> {
84 let unpadded = LADO as usize * 4;
85 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
86 let padded = unpadded.div_ceil(align) * align;
87 let buf = hal.device.create_buffer(&wgpu::BufferDescriptor {
88 label: Some("atlas-readback"),
89 size: (padded * LADO as usize) as u64,
90 usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
91 mapped_at_creation: false,
92 });
93 let mut enc = hal
94 .device
95 .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
96 enc.copy_texture_to_buffer(
97 wgpu::TexelCopyTextureInfo {
98 texture: tex,
99 mip_level: 0,
100 origin: wgpu::Origin3d::ZERO,
101 aspect: wgpu::TextureAspect::All,
102 },
103 wgpu::TexelCopyBufferInfo {
104 buffer: &buf,
105 layout: wgpu::TexelCopyBufferLayout {
106 offset: 0,
107 bytes_per_row: Some(padded as u32),
108 rows_per_image: Some(LADO),
109 },
110 },
111 wgpu::Extent3d { width: LADO, height: LADO, depth_or_array_layers: 1 },
112 );
113 hal.queue.submit(std::iter::once(enc.finish()));
114 let slice = buf.slice(..);
115 let (tx, rx) = std::sync::mpsc::channel();
116 slice.map_async(wgpu::MapMode::Read, move |r| {
117 let _ = tx.send(r);
118 });
119 let _ = hal.device.poll(wgpu::PollType::wait_indefinitely());
120 rx.recv().unwrap().unwrap();
121 let data = slice.get_mapped_range();
122 let mut out = Vec::with_capacity((LADO * LADO * 4) as usize);
123 for row in 0..LADO as usize {
124 let s = row * padded;
125 out.extend_from_slice(&data[s..s + unpadded]);
126 }
127 drop(data);
128 buf.unmap();
129 out
130}
131
132/// Dibuja `n` imágenes distintas y devuelve **cuántas se ven** en el resultado.
133///
134/// Se muestrea el centro de cada celda: si la imagen se dibujó, ese píxel es su
135/// color; si vello la descartó, quedó el fondo. Devuelve `(vistas, n)`.
136fn cuantas_llegan(hal: &Hal, renderer: &mut Renderer, n: u32) -> u32 {
137 cuantas_llegan_desde(hal, renderer, n, 0)
138}
139
140/// Como [`cuantas_llegan`], pero corriendo el rango de colores/ids en `desde`:
141/// dos llamadas con `desde` distinto no comparten **ninguna** imagen, que es lo
142/// que necesita el test de acumulación cuadro a cuadro.
143fn cuantas_llegan_desde(hal: &Hal, renderer: &mut Renderer, n: u32, desde: u32) -> u32 {
144 let tex = hal.device.create_texture(&wgpu::TextureDescriptor {
145 label: Some("atlas-target"),
146 size: wgpu::Extent3d { width: LADO, height: LADO, depth_or_array_layers: 1 },
147 mip_level_count: 1,
148 sample_count: 1,
149 dimension: wgpu::TextureDimension::D2,
150 format: FMT,
151 usage: wgpu::TextureUsages::RENDER_ATTACHMENT
152 | wgpu::TextureUsages::COPY_SRC
153 | wgpu::TextureUsages::STORAGE_BINDING,
154 view_formats: &[],
155 });
156 let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
157
158 let mut escena = Escena::new();
159 let mut esperados = Vec::with_capacity(n as usize);
160 for i in 0..n {
161 let (r, g, b) = color_de(desde + i);
162 esperados.push((r, g, b));
163 let cx = (i % POR_FILA) * CELDA;
164 let cy = (i / POR_FILA) * CELDA;
165 let img = imagen_plana(r, g, b);
166 let fit = Affine::translate((cx as f64, cy as f64))
167 * Affine::scale_non_uniform(CELDA as f64 / IMG_W as f64, CELDA as f64 / IMG_H as f64);
168 escena.draw_image(&img, fit);
169 }
170
171 renderer
172 .render_to_view(hal, &escena, &view, LADO, LADO, palette::css::BLACK)
173 .expect("render");
174 let buf = leer(hal, &tex);
175
176 let mut vistas = 0;
177 for (i, (r, g, b)) in esperados.iter().enumerate() {
178 let x = (i as u32 % POR_FILA) * CELDA + CELDA / 2;
179 let y = (i as u32 / POR_FILA) * CELDA + CELDA / 2;
180 let o = ((y * LADO + x) * 4) as usize;
181 // Tolerancia amplia: da igual el filtrado, lo que se pregunta es «¿hay
182 // algo o quedó el fondo?».
183 let cerca = (buf[o] as i32 - *r as i32).abs() < 24
184 && (buf[o + 1] as i32 - *g as i32).abs() < 24
185 && (buf[o + 2] as i32 - *b as i32).abs() < 24;
186 if cerca {
187 vistas += 1;
188 }
189 }
190 vistas
191}
192
193/// El techo del atlas de imágenes de vello, en número de miniaturas de `media`.
194///
195/// No afirma un valor exacto (depende del empaquetado de `guillotiere` y del
196/// `max_texture_dimension_2d` del dispositivo): afirma la **forma** del fallo —
197/// que existe un N a partir del cual algunas imágenes dejan de dibujarse y
198/// nadie avisa. Imprime la tabla para que el número quede en el registro.
199#[test]
200fn el_atlas_de_imagenes_tiene_techo_y_lo_cruza_en_silencio() {
201 let hal = pollster::block_on(Hal::new_headless(true)).expect("hal");
202 let mut renderer = Renderer::new(&hal).expect("renderer");
203
204 let mut primer_fallo = None;
205 for n in [16u32, 64, 256, 1024, 2048, 4096] {
206 if n > POR_FILA * POR_FILA {
207 break;
208 }
209 let vistas = cuantas_llegan(&hal, &mut renderer, n);
210 println!(
211 "atlas-imagenes · pedidas {n:>4} de {IMG_W}x{IMG_H} → dibujadas {vistas:>4} \
212 ({} perdidas)",
213 n - vistas
214 );
215 if vistas < n && primer_fallo.is_none() {
216 primer_fallo = Some((n, vistas));
217 }
218 }
219
220 match primer_fallo {
221 Some((n, vistas)) => {
222 println!(
223 "atlas-imagenes · TECHO entre {} y {n} imágenes vivas en una escena \
224 (a {n} se dibujaron {vistas})",
225 n / 4
226 );
227 }
228 None => println!(
229 "atlas-imagenes · sin techo hasta {} imágenes: el atlas no es el culpable a esta escala",
230 POR_FILA * POR_FILA
231 ),
232 }
233}
234
235/// **La pregunta que importa para `media`**: pocas imágenes visibles, pero
236/// **nuevas** cuadro a cuadro. Es lo que pasa al scrollear la grilla de
237/// miniaturas o arrastrar la línea de tiempo: cada `Blob` recién decodificado
238/// entra al atlas con id nuevo, y el atlas sólo evicta cuando una asignación
239/// **falla** — y una sola vez por pasada (`evicted_in_resolve != 0 → false`).
240///
241/// Si la evicción no alcanza a limpiar lo viejo, las imágenes **de este cuadro**
242/// dejan de dibujarse. Ese sería el negro del reporte: no se pierde la
243/// miniatura, se pierde el dibujo.
244#[test]
245fn imagenes_nuevas_cuadro_a_cuadro_no_pueden_apagar_las_visibles() {
246 let hal = pollster::block_on(Hal::new_headless(true)).expect("hal");
247 let mut renderer = Renderer::new(&hal).expect("renderer");
248
249 // 40 celdas visibles — el orden de magnitud de una grilla de miniaturas
250 // virtualizada. 120 cuadros ⇒ 4.800 imágenes distintas a lo largo del
251 // recorrido, muy por encima del techo de ~1.800 de una escena sola.
252 const VISIBLES: u32 = 40;
253 const CUADROS: u32 = 120;
254 let mut peor = (0u32, VISIBLES);
255 let mut cuadros_con_perdida = 0;
256 for cuadro in 0..CUADROS {
257 // `desde` corre el rango de ids, así que ninguna imagen se repite entre
258 // cuadros: todas son blobs nuevos, como los que decodifica la app.
259 let vistas = cuantas_llegan_desde(&hal, &mut renderer, VISIBLES, cuadro * VISIBLES);
260 if vistas < VISIBLES {
261 cuadros_con_perdida += 1;
262 if vistas < peor.1 {
263 peor = (cuadro, vistas);
264 }
265 }
266 }
267 println!(
268 "atlas-imagenes · {CUADROS} cuadros × {VISIBLES} imágenes nuevas → \
269 {cuadros_con_perdida} cuadros con pérdida (peor: cuadro {} con {}/{VISIBLES})",
270 peor.0, peor.1
271 );
272 assert_eq!(
273 cuadros_con_perdida, 0,
274 "el atlas dejó de dibujar imágenes visibles al acumular blobs nuevos: \
275 peor cuadro {} con {}/{VISIBLES}",
276 peor.0, peor.1
277 );
278}
279
280/// Control por **presencia**: con pocas imágenes se dibujan todas. Si esto
281/// fallara, el test de arriba estaría contando mal (celda equivocada, color
282/// equivocado, filtrado) y su «techo» no querría decir nada.
283#[test]
284fn con_pocas_imagenes_se_dibujan_todas() {
285 let hal = pollster::block_on(Hal::new_headless(true)).expect("hal");
286 let mut renderer = Renderer::new(&hal).expect("renderer");
287 let vistas = cuantas_llegan(&hal, &mut renderer, 16);
288 assert_eq!(vistas, 16, "16 imágenes chicas tienen que entrar sí o sí");
289}
290
291/// El fondo **no** se confunde con una imagen: sin dibujar nada, cero celdas
292/// dan positivo. Es la mitad por ausencia del control de arriba.
293#[test]
294fn sin_imagenes_no_hay_falsos_positivos() {
295 let hal = pollster::block_on(Hal::new_headless(true)).expect("hal");
296 let mut renderer = Renderer::new(&hal).expect("renderer");
297 let vistas = cuantas_llegan(&hal, &mut renderer, 0);
298 assert_eq!(vistas, 0);
299 // Y con el fondo del color de una celda tampoco: el muestreo mira el color
300 // propio de cada imagen, que el fondo negro nunca iguala.
301 let _ = Color::from_rgba8(0, 0, 0, 255);
302}
303
304/// **La forma real de un cuadro de `media`**: no una escena por cuadro, sino
305/// **cinco** sobre el mismo `Renderer` — el árbol principal, la capa `over`
306/// (donde vive el panel con las miniaturas), y las chicas — y sólo UNA lleva
307/// imágenes.
308///
309/// Por qué importa y por qué el test de acumulación de arriba no lo cubre: el
310/// atlas de vello lleva una **generación por resolve**, y `render_to_view` es
311/// un resolve. Con cinco resolves por cuadro y `EVICT_AFTER_GENERATIONS = 2`,
312/// una imagen usada en el resolve del panel queda «vieja» cuatro resolves antes
313/// de que le toque el turno siguiente. El test anterior rasterizaba **una sola**
314/// escena por cuadro, así que jamás ejercitó esa distancia entre usos — y por
315/// eso dio 0 pérdidas mientras la app perdía todas.
316///
317/// Las imágenes se construyen **una vez** y se clonan, como hace la app (la
318/// caché de `thumbs` devuelve clones del mismo `Image`, o sea el mismo id de
319/// blob).
320#[test]
321fn escenas_sin_imagenes_intercaladas_no_pueden_apagar_las_del_panel() {
322 let hal = pollster::block_on(Hal::new_headless(true)).expect("hal");
323
324 // 24 miniaturas: lo que `media` mete en el panel de la playlist (medido en
325 // vivo con `LLIMPHI_DEBUG_CAPAS=1`: «174n img 24/24»).
326 const VISIBLES: u32 = 24;
327 const CUADROS: u32 = 12;
328
329 println!("mudas cuadros con pérdida peor caso");
330 let mut roto_desde = None;
331 for mudas in 0..=4u32 {
332 // Renderer nuevo por fila: el atlas es estado del `Renderer` y arrastrar
333 // el de la fila anterior mezclaría los casos.
334 let mut renderer = Renderer::new(&hal).expect("renderer");
335 let imagenes: Vec<ImageBrush> = (0..VISIBLES)
336 .map(|i| {
337 let (r, g, b) = color_de(i);
338 imagen_plana(r, g, b)
339 })
340 .collect();
341 let (mut perdidos, mut peor) = (0u32, VISIBLES);
342 for _ in 0..CUADROS {
343 for _ in 0..mudas {
344 rasterizar_muda(&hal, &mut renderer);
345 }
346 let vistas = cuantas_llegan_con(&hal, &mut renderer, &imagenes);
347 if vistas < VISIBLES {
348 perdidos += 1;
349 peor = peor.min(vistas);
350 }
351 }
352 println!("{mudas:>5} {perdidos:>19} {peor:>9}");
353 if perdidos > 0 && roto_desde.is_none() {
354 roto_desde = Some(mudas);
355 }
356 }
357 match roto_desde {
358 Some(n) => println!(
359 "→ con {n} escena(s) sin imágenes intercaladas por cuadro, las miniaturas \
360 ya dibujadas dejan de dibujarse"
361 ),
362 None => println!("→ intercalar escenas mudas no apaga nada"),
363 }
364 assert_eq!(
365 roto_desde, None,
366 "intercalar escenas sin imágenes apagó miniaturas ya visibles — \
367 es el negro de las miniaturas de media (media pinta 5 escenas por cuadro \
368 y sólo una lleva imágenes)"
369 );
370}
371
372/// Una escena **sin patches**: puros rellenos, ni imagen ni gradiente ni texto.
373/// Es la forma de los árboles chicos que `media` pinta junto al panel, y la que
374/// dispara el agujero de vello (ver `Renderer::revalidar_atlas`). Se rasteriza y
375/// se tira: lo que importa es que pasó por el mismo `Renderer`.
376fn rasterizar_muda(hal: &Hal, renderer: &mut Renderer) {
377 let tex = hal.device.create_texture(&wgpu::TextureDescriptor {
378 label: Some("muda"),
379 size: wgpu::Extent3d { width: 64, height: 64, depth_or_array_layers: 1 },
380 mip_level_count: 1,
381 sample_count: 1,
382 dimension: wgpu::TextureDimension::D2,
383 format: FMT,
384 usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::STORAGE_BINDING,
385 view_formats: &[],
386 });
387 let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
388 let mut escena = Escena::new();
389 escena.fill(
390 llimphi_raster::peniko::Fill::NonZero,
391 Affine::IDENTITY,
392 Color::from_rgba8(30, 36, 46, 255),
393 None,
394 &llimphi_raster::kurbo::Rect::new(0.0, 0.0, 64.0, 64.0),
395 );
396 renderer
397 .render_to_view(hal, &escena, &view, 64, 64, palette::css::BLACK)
398 .expect("render muda");
399}
400
401/// Como [`cuantas_llegan`] pero con imágenes **dadas** (las mismas cuadro a
402/// cuadro, clonadas), que es como las pasa la app.
403fn cuantas_llegan_con(hal: &Hal, renderer: &mut Renderer, imagenes: &[ImageBrush]) -> u32 {
404 let tex = hal.device.create_texture(&wgpu::TextureDescriptor {
405 label: Some("atlas-target"),
406 size: wgpu::Extent3d { width: LADO, height: LADO, depth_or_array_layers: 1 },
407 mip_level_count: 1,
408 sample_count: 1,
409 dimension: wgpu::TextureDimension::D2,
410 format: FMT,
411 usage: wgpu::TextureUsages::RENDER_ATTACHMENT
412 | wgpu::TextureUsages::COPY_SRC
413 | wgpu::TextureUsages::STORAGE_BINDING,
414 view_formats: &[],
415 });
416 let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
417 let mut escena = Escena::new();
418 for (i, img) in imagenes.iter().enumerate() {
419 let cx = (i as u32 % POR_FILA) * CELDA;
420 let cy = (i as u32 / POR_FILA) * CELDA;
421 let fit = Affine::translate((cx as f64, cy as f64))
422 * Affine::scale_non_uniform(CELDA as f64 / IMG_W as f64, CELDA as f64 / IMG_H as f64);
423 escena.draw_image(img, fit);
424 }
425 renderer
426 .render_to_view(hal, &escena, &view, LADO, LADO, palette::css::BLACK)
427 .expect("render");
428 let buf = leer(hal, &tex);
429 let mut vistas = 0;
430 for i in 0..imagenes.len() {
431 let (r, g, b) = color_de(i as u32);
432 let x = (i as u32 % POR_FILA) * CELDA + CELDA / 2;
433 let y = (i as u32 / POR_FILA) * CELDA + CELDA / 2;
434 let o = ((y * LADO + x) * 4) as usize;
435 if (buf[o] as i32 - r as i32).abs() < 24
436 && (buf[o + 1] as i32 - g as i32).abs() < 24
437 && (buf[o + 2] as i32 - b as i32).abs() < 24
438 {
439 vistas += 1;
440 }
441 }
442 vistas
443}