tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ main llimphi-raster/tests/gpu_atlas_imagenes.rs Rust · 443 siq'ikuna · 18.1 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 128 miembros, los 76 widgets y los 11 módulos b3f12e136 · Sergio ·

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}