tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-compositor/src/sugerencia.rs Rust · 410 líneas · 16.0 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros d251eb32b · Sergio ·

1//! Dónde poner un `repaint_boundary`, medido en vez de adivinado.
2//!
3//! ## El agujero que esto tapa
4//!
5//! El Bloque 23 del `PLAN-NUCLEO-RETENIDO` construyó el caché de rasterización
6//! por subárbol, lo certificó píxel a píxel, y quedó anotado un follow-up:
7//! «falta cablear `repaint_boundary` en chrome estático real». Seis meses
8//! después no lo usa **ninguna app** — sólo los `examples/stress_*` del propio
9//! motor, que se lo ponen a un grid que ellos mismos fabricaron quieto.
10//!
11//! La razón no es desidia: **nadie sabe dónde**. Marcar un subárbol que en
12//! realidad cambia todos los frames no es neutro —se paga el hash entero cada
13//! cuadro y no se ahorra nada—, y desde afuera no hay forma de distinguir un
14//! panel quieto de uno que se reconstruye igual a sí mismo. Adivinar en la ruta
15//! caliente es exactamente lo que el Bloque 21 prohíbe: medir antes de tocar.
16//!
17//! Esto es el instrumento que faltaba. Observa el árbol cuadro a cuadro y dice
18//! qué subárboles **no cambiaron**, cuántos nodos tienen y cómo reconocerlos.
19//!
20//! ## Por qué una sugerencia de acá es una predicción, no una opinión
21//!
22//! El detector reusa **las mismas tres funciones** que usa el caché de verdad:
23//! `opens_layer` (posición limpia), `boundary_cacheable` (paint-puro y sin el
24//! puntero adentro) y `boundary_hash` (el hash con rects relativos al origen,
25//! que es el que decide hit o miss). No hay una segunda implementación que
26//! pueda divergir de la primera — que es como el bench del GPU-directo terminó
27//! publicando 23.000 fps sobre un pase que no dibujaba.
28//!
29//! Lo que **no** dice: si conviene. Una sugerencia es «esto habría sido un
30//! cache-hit», y el hash se paga todos los cuadros. Por eso hay un mínimo de
31//! nodos: bajo cierto tamaño, hashear cuesta más que volver a pintar.
32
33use alloc::string::String;
34use alloc::vec::Vec;
35use hashbrown::HashMap;
36
37use crate::llimphi_layout::ComputedLayout;
38use crate::render::{boundary_cacheable, boundary_hash, opens_layer};
39use crate::Mounted;
40
41/// Un subárbol que viene quieto y sería cache-hit si llevara
42/// [`crate::View::repaint_boundary`].
43#[derive(Debug, Clone)]
44pub struct SugerenciaBoundary {
45    /// Índice del nodo raíz del subárbol en el `Mounted` de este cuadro.
46    pub idx: usize,
47    /// Cuántos nodos cuelgan de él (contándolo). Es la magnitud del ahorro: es
48    /// lo que `paint` recorre y codifica cada cuadro sin necesidad.
49    pub nodos: usize,
50    /// Su rect en pantalla, para ubicarlo.
51    pub rect: (f32, f32, f32, f32),
52    /// El primer texto que aparece adentro. Un índice de nodo no le dice nada a
53    /// nadie; «Archivos» sí.
54    pub texto: Option<String>,
55    /// Cuántos cuadros seguidos viene idéntico.
56    pub frames_quieto: u32,
57}
58
59/// Detector de subárboles quietos. Vive entre cuadros.
60///
61/// **Es diagnóstico, no producción**: hashea cada candidato por cuadro, que es
62/// O(nodos × profundidad). Se enciende a mano y se apaga.
63pub struct DetectorBoundaries {
64    previo: HashMap<(usize, usize), (u64, u32)>,
65    actual: HashMap<(usize, usize), (u64, u32)>,
66    /// Tamaño mínimo del subárbol para considerarlo. Bajo esto, el hash que se
67    /// pagaría cada cuadro no lo compensa el pintado que se ahorra.
68    pub min_nodos: usize,
69    /// Cuántos cuadros seguidos tiene que venir idéntico antes de sugerirlo.
70    /// Un solo cuadro quieto no dice nada: un botón entre dos parpadeos del
71    /// cursor también lo está.
72    pub min_frames: u32,
73}
74
75impl Default for DetectorBoundaries {
76    fn default() -> Self {
77        Self {
78            previo: HashMap::new(),
79            actual: HashMap::new(),
80            min_nodos: 16,
81            min_frames: 30,
82        }
83    }
84}
85
86impl DetectorBoundaries {
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    /// Con umbrales propios. `min_nodos` = tamaño mínimo del subárbol;
92    /// `min_frames` = cuántos cuadros seguidos tiene que venir idéntico.
93    pub fn con(min_nodos: usize, min_frames: u32) -> Self {
94        Self { min_nodos, min_frames, ..Self::default() }
95    }
96
97    /// Mira un cuadro y devuelve los subárboles que ya llevan `min_frames`
98    /// quietos.
99    ///
100    /// Devuelve sólo los **máximos**: si un panel entero viene quieto, no se
101    /// listan también sus doce hijos. Y no sugiere nada dentro de un
102    /// `repaint_boundary` ya declarado — ahí el trabajo está hecho.
103    pub fn observar<Msg>(
104        &mut self,
105        mounted: &Mounted<Msg>,
106        computed: &ComputedLayout,
107        hover_idx: Option<usize>,
108        drop_hover_idx: Option<usize>,
109    ) -> Vec<SugerenciaBoundary> {
110        self.actual.clear();
111        let mut salida = Vec::new();
112        // Capas abiertas por un ancestro (clip/alpha/transform): adentro de una,
113        // un `scene.append` trasladado no reproduce lo que hay, así que el caché
114        // ni lo intenta y sugerirlo sería mentir.
115        let mut capas: Vec<usize> = Vec::new();
116        // Subárboles ya cubiertos: o son un boundary declarado, o ya se
117        // sugirieron y sus hijos no agregan nada.
118        let mut cubierto: Vec<usize> = Vec::new();
119
120        for idx in 0..mounted.nodes.len() {
121            while capas.last().is_some_and(|&e| idx >= e) {
122                capas.pop();
123            }
124            while cubierto.last().is_some_and(|&e| idx >= e) {
125                cubierto.pop();
126            }
127            let nodo = &mounted.nodes[idx];
128            let end = nodo.subtree_end;
129
130            if nodo.repaint_boundary.is_some() {
131                cubierto.push(end);
132            } else if cubierto.is_empty()
133                && capas.is_empty()
134                && end - idx >= self.min_nodos
135                && boundary_cacheable(mounted, idx, end, hover_idx, drop_hover_idx)
136            {
137                let rect = computed
138                    .get(nodo.id)
139                    .map(|r| (r.x, r.y, r.w, r.h))
140                    .unwrap_or((0.0, 0.0, 0.0, 0.0));
141                let hash = boundary_hash(mounted, computed, idx, end, (rect.0, rect.1));
142                // La identidad entre cuadros es `(idx, end)`. Si el árbol cambia
143                // de forma, el par deja de casar y el contador arranca de cero:
144                // se pierden sugerencias, no se inventan. Para un diagnóstico
145                // ése es el lado correcto del error.
146                let quieto = match self.previo.get(&(idx, end)) {
147                    Some((h, q)) if *h == hash => q + 1,
148                    _ => 1,
149                };
150                self.actual.insert((idx, end), (hash, quieto));
151                if quieto >= self.min_frames {
152                    salida.push(SugerenciaBoundary {
153                        idx,
154                        nodos: end - idx,
155                        rect,
156                        texto: mounted.nodes[idx..end]
157                            .iter()
158                            .find_map(|n| n.text.as_ref().map(|t| t.content.clone())),
159                        frames_quieto: quieto,
160                    });
161                    cubierto.push(end);
162                }
163            }
164
165            // El propio nodo puede abrir su capa: se registra DESPUÉS de
166            // evaluarlo, porque esa capa es interna a su subárbol.
167            if opens_layer(nodo) {
168                capas.push(end);
169            }
170        }
171
172        core::mem::swap(&mut self.previo, &mut self.actual);
173        salida
174    }
175
176    /// Olvida lo observado (cambió el viewport, el tema, la pantalla…).
177    pub fn clear(&mut self) {
178        self.previo.clear();
179        self.actual.clear();
180    }
181}
182
183/// Una línea por sugerencia, lista para imprimir.
184pub fn describir(s: &SugerenciaBoundary) -> String {
185    let etiqueta = match &s.texto {
186        Some(t) => {
187            let t: String = t.chars().take(28).collect();
188            alloc::format!(" «{t}»")
189        }
190        None => String::new(),
191    };
192    alloc::format!(
193        "  nodo {} · {} nodos · ({:.0},{:.0} {:.0}×{:.0}) · {} cuadros quieto{}",
194        s.idx,
195        s.nodos,
196        s.rect.0,
197        s.rect.1,
198        s.rect.2,
199        s.rect.3,
200        s.frames_quieto,
201        etiqueta
202    )
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::llimphi_layout::taffy::prelude::*;
209    use crate::llimphi_layout::LayoutTree;
210    use crate::peniko::Color;
211    use crate::{mount, paint_cached, RepaintCache, View};
212
213    const VP: (f32, f32) = (400.0, 300.0);
214    const CLAVE_PANEL: u64 = 0xC0FFEE;
215
216    fn montar(v: View<()>) -> (crate::Mounted<()>, ComputedLayout) {
217        let mut lt = LayoutTree::new();
218        let m = mount(&mut lt, v);
219        let c = lt.compute(m.root, VP).expect("layout");
220        (m, c)
221    }
222
223    /// Un panel de `n` filas de texto. Es la forma de un sidebar real: muchos
224    /// nodos, todos paint-puros.
225    fn panel(titulo: &str, n: usize) -> View<()> {
226        let mut hijos = alloc::vec![View::<()>::new(Style::default()).text(
227            titulo,
228            14.0,
229            Color::WHITE
230        )];
231        for i in 0..n {
232            hijos.push(
233                View::<()>::new(Style {
234                    size: Size { width: length(180.0), height: length(20.0) },
235                    ..Default::default()
236                })
237                .fill(Color::from_rgba8(40, 40, 50, 255))
238                .children(alloc::vec![View::<()>::new(Style::default()).text(
239                    alloc::format!("fila {i}"),
240                    12.0,
241                    Color::WHITE
242                )]),
243            );
244        }
245        View::<()>::new(Style {
246            flex_direction: FlexDirection::Column,
247            size: Size { width: length(200.0), height: percent(1.0_f32) },
248            ..Default::default()
249        })
250        .fill(Color::from_rgba8(24, 24, 32, 255))
251        .children(hijos)
252    }
253
254    /// Chrome quieto al costado + un contador que cambia todos los cuadros. Es
255    /// exactamente el caso que el Bloque 23 quiere y que ninguna app cableó.
256    fn pantalla(marca: u32) -> View<()> {
257        View::<()>::new(Style {
258            size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
259            ..Default::default()
260        })
261        .children(alloc::vec![
262            panel("Archivos", 12),
263            View::<()>::new(Style::default()).text(
264                alloc::format!("contador: {marca}"),
265                13.0,
266                Color::WHITE
267            ),
268        ])
269    }
270
271    fn correr(det: &mut DetectorBoundaries, cuadros: u32) -> Vec<SugerenciaBoundary> {
272        let mut ultima = Vec::new();
273        for f in 0..cuadros {
274            let (m, c) = montar(pantalla(f));
275            ultima = det.observar(&m, &c, None, None);
276        }
277        ultima
278    }
279
280    #[test]
281    fn señala_el_panel_quieto_y_no_lo_que_cambia() {
282        let mut det = DetectorBoundaries::con(16, 5);
283        let sug = correr(&mut det, 6);
284        assert_eq!(sug.len(), 1, "tendría que salir un solo subárbol: {sug:?}");
285        let s = &sug[0];
286        assert!(s.nodos >= 25, "el panel entero, no una fila: {} nodos", s.nodos);
287        assert_eq!(s.texto.as_deref(), Some("Archivos"), "y reconocible por su texto");
288        assert!(s.frames_quieto >= 5);
289        // El rect es el del panel, no el de la pantalla.
290        assert!((s.rect.2 - 200.0).abs() < 1.0, "rect {:?}", s.rect);
291    }
292
293    #[test]
294    fn un_cuadro_quieto_no_alcanza() {
295        // Cualquier cosa está quieta entre dos cuadros. Sugerir con esa
296        // evidencia sería mandar a cablear un boundary a un panel que cambia
297        // cada medio segundo, y el hash se paga en TODOS los cuadros.
298        let mut det = DetectorBoundaries::con(16, 30);
299        assert!(correr(&mut det, 29).is_empty(), "29 cuadros no llegan a 30");
300        assert!(!correr(&mut det, 1).is_empty(), "el 30 sí");
301    }
302
303    #[test]
304    fn lo_que_cambia_no_se_sugiere_nunca() {
305        // El control: si el panel también cambia, no hay nada que cachear y el
306        // detector tiene que callarse por muchos cuadros que pasen.
307        let mut det = DetectorBoundaries::con(16, 3);
308        let mut sug = Vec::new();
309        for f in 0..20 {
310            let vivo = View::<()>::new(Style {
311                size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
312                ..Default::default()
313            })
314            .children(alloc::vec![panel(&alloc::format!("Archivos {f}"), 12)]);
315            let (m, c) = montar(vivo);
316            sug = det.observar(&m, &c, None, None);
317        }
318        assert!(sug.is_empty(), "sugirió cachear algo que cambia: {sug:?}");
319    }
320
321    #[test]
322    fn no_sugiere_lo_que_ya_tiene_boundary() {
323        // Ni el nodo marcado ni sus hijos: ahí el trabajo está hecho y repetirlo
324        // en la salida sería ruido que hace ignorar el instrumento.
325        let mut det = DetectorBoundaries::con(16, 3);
326        let mut sug = Vec::new();
327        for f in 0..10 {
328            let v = View::<()>::new(Style {
329                size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
330                ..Default::default()
331            })
332            .children(alloc::vec![
333                panel("Archivos", 12).repaint_boundary(1),
334                View::<()>::new(Style::default()).text(
335                    alloc::format!("{f}"),
336                    13.0,
337                    Color::WHITE
338                ),
339            ]);
340            let (m, c) = montar(v);
341            sug = det.observar(&m, &c, None, None);
342        }
343        assert!(sug.is_empty(), "sugirió un boundary donde ya hay uno: {sug:?}");
344    }
345
346    #[test]
347    fn con_el_puntero_adentro_se_calla() {
348        // Misma regla que el caché: un subárbol con el hover adentro no se
349        // sirve cacheado, así que sugerirlo sería prometer un ahorro que no va
350        // a pasar mientras el usuario tenga el puntero ahí.
351        let mut det = DetectorBoundaries::con(16, 3);
352        let mut sug = Vec::new();
353        for f in 0..10 {
354            let (m, c) = montar(pantalla(f));
355            // Un nodo bien adentro del panel.
356            sug = det.observar(&m, &c, Some(6), None);
357        }
358        assert!(sug.is_empty(), "sugirió con el puntero adentro: {sug:?}");
359    }
360
361    /// **El test que le da sentido a todos los demás.** Una sugerencia no es una
362    /// opinión sobre el árbol: es la predicción de que, puesto ahí el boundary,
363    /// el caché va a **acertar**. Acá se toma la sugerencia, se cablea, y se
364    /// cuentan los hits. Que además lo pintado no cambie lo verifica por
365    /// píxeles `llimphi-test/tests/boundary_cache.rs`, que es donde vive el
366    /// rasterizador.
367    #[test]
368    fn cablear_la_sugerencia_da_los_cache_hits_que_prometio() {
369        let mut det = DetectorBoundaries::con(16, 3);
370        let sug = correr(&mut det, 4);
371        let s = sug.first().expect("tenía que sugerir el panel");
372
373        // Se cablea donde dijo: el panel es el hijo 0 de la pantalla.
374        let con_boundary = |marca: u32| {
375            View::<()>::new(Style {
376                size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
377                ..Default::default()
378            })
379            .children(alloc::vec![
380                panel("Archivos", 12).repaint_boundary(CLAVE_PANEL),
381                View::<()>::new(Style::default()).text(
382                    alloc::format!("contador: {marca}"),
383                    13.0,
384                    Color::WHITE
385                ),
386            ])
387        };
388        let mut ts = crate::llimphi_text::Typesetter::new();
389        let mut cache = RepaintCache::new();
390        for marca in 0..4u32 {
391            let (m, c) = montar(con_boundary(marca));
392            let mut lienzo = crate::Lienzo::new();
393            paint_cached(&mut lienzo, &m, &c, &mut ts, None, None, &mut cache);
394        }
395        assert_eq!(
396            (cache.hits, cache.misses),
397            (3, 1),
398            "la sugerencia prometía cache-hits y el caché no acertó"
399        );
400
401        // Y que el nodo sugerido sea efectivamente el que quedó marcado: si el
402        // índice apuntara a otro lado, los hits de arriba serían casualidad.
403        let (m, _) = montar(con_boundary(0));
404        assert_eq!(
405            m.nodes[s.idx].repaint_boundary,
406            Some(CLAVE_PANEL),
407            "el idx de la sugerencia no es el nodo que lleva el boundary"
408        );
409    }
410}