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//! 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}