//! Dónde poner un `repaint_boundary`, medido en vez de adivinado. //! //! ## El agujero que esto tapa //! //! El Bloque 23 del `PLAN-NUCLEO-RETENIDO` construyó el caché de rasterización //! por subárbol, lo certificó píxel a píxel, y quedó anotado un follow-up: //! «falta cablear `repaint_boundary` en chrome estático real». Seis meses //! después no lo usa **ninguna app** — sólo los `examples/stress_*` del propio //! motor, que se lo ponen a un grid que ellos mismos fabricaron quieto. //! //! La razón no es desidia: **nadie sabe dónde**. Marcar un subárbol que en //! realidad cambia todos los frames no es neutro —se paga el hash entero cada //! cuadro y no se ahorra nada—, y desde afuera no hay forma de distinguir un //! panel quieto de uno que se reconstruye igual a sí mismo. Adivinar en la ruta //! caliente es exactamente lo que el Bloque 21 prohíbe: medir antes de tocar. //! //! Esto es el instrumento que faltaba. Observa el árbol cuadro a cuadro y dice //! qué subárboles **no cambiaron**, cuántos nodos tienen y cómo reconocerlos. //! //! ## Por qué una sugerencia de acá es una predicción, no una opinión //! //! El detector reusa **las mismas tres funciones** que usa el caché de verdad: //! `opens_layer` (posición limpia), `boundary_cacheable` (paint-puro y sin el //! puntero adentro) y `boundary_hash` (el hash con rects relativos al origen, //! que es el que decide hit o miss). No hay una segunda implementación que //! pueda divergir de la primera — que es como el bench del GPU-directo terminó //! publicando 23.000 fps sobre un pase que no dibujaba. //! //! Lo que **no** dice: si conviene. Una sugerencia es «esto habría sido un //! cache-hit», y el hash se paga todos los cuadros. Por eso hay un mínimo de //! nodos: bajo cierto tamaño, hashear cuesta más que volver a pintar. use alloc::string::String; use alloc::vec::Vec; use hashbrown::HashMap; use crate::llimphi_layout::ComputedLayout; use crate::render::{boundary_cacheable, boundary_hash, opens_layer}; use crate::Mounted; /// Un subárbol que viene quieto y sería cache-hit si llevara /// [`crate::View::repaint_boundary`]. #[derive(Debug, Clone)] pub struct SugerenciaBoundary { /// Índice del nodo raíz del subárbol en el `Mounted` de este cuadro. pub idx: usize, /// Cuántos nodos cuelgan de él (contándolo). Es la magnitud del ahorro: es /// lo que `paint` recorre y codifica cada cuadro sin necesidad. pub nodos: usize, /// Su rect en pantalla, para ubicarlo. pub rect: (f32, f32, f32, f32), /// El primer texto que aparece adentro. Un índice de nodo no le dice nada a /// nadie; «Archivos» sí. pub texto: Option, /// Cuántos cuadros seguidos viene idéntico. pub frames_quieto: u32, } /// Detector de subárboles quietos. Vive entre cuadros. /// /// **Es diagnóstico, no producción**: hashea cada candidato por cuadro, que es /// O(nodos × profundidad). Se enciende a mano y se apaga. pub struct DetectorBoundaries { previo: HashMap<(usize, usize), (u64, u32)>, actual: HashMap<(usize, usize), (u64, u32)>, /// Tamaño mínimo del subárbol para considerarlo. Bajo esto, el hash que se /// pagaría cada cuadro no lo compensa el pintado que se ahorra. pub min_nodos: usize, /// Cuántos cuadros seguidos tiene que venir idéntico antes de sugerirlo. /// Un solo cuadro quieto no dice nada: un botón entre dos parpadeos del /// cursor también lo está. pub min_frames: u32, } impl Default for DetectorBoundaries { fn default() -> Self { Self { previo: HashMap::new(), actual: HashMap::new(), min_nodos: 16, min_frames: 30, } } } impl DetectorBoundaries { pub fn new() -> Self { Self::default() } /// Con umbrales propios. `min_nodos` = tamaño mínimo del subárbol; /// `min_frames` = cuántos cuadros seguidos tiene que venir idéntico. pub fn con(min_nodos: usize, min_frames: u32) -> Self { Self { min_nodos, min_frames, ..Self::default() } } /// Mira un cuadro y devuelve los subárboles que ya llevan `min_frames` /// quietos. /// /// Devuelve sólo los **máximos**: si un panel entero viene quieto, no se /// listan también sus doce hijos. Y no sugiere nada dentro de un /// `repaint_boundary` ya declarado — ahí el trabajo está hecho. pub fn observar( &mut self, mounted: &Mounted, computed: &ComputedLayout, hover_idx: Option, drop_hover_idx: Option, ) -> Vec { self.actual.clear(); let mut salida = Vec::new(); // Capas abiertas por un ancestro (clip/alpha/transform): adentro de una, // un `scene.append` trasladado no reproduce lo que hay, así que el caché // ni lo intenta y sugerirlo sería mentir. let mut capas: Vec = Vec::new(); // Subárboles ya cubiertos: o son un boundary declarado, o ya se // sugirieron y sus hijos no agregan nada. let mut cubierto: Vec = Vec::new(); for idx in 0..mounted.nodes.len() { while capas.last().is_some_and(|&e| idx >= e) { capas.pop(); } while cubierto.last().is_some_and(|&e| idx >= e) { cubierto.pop(); } let nodo = &mounted.nodes[idx]; let end = nodo.subtree_end; if nodo.repaint_boundary.is_some() { cubierto.push(end); } else if cubierto.is_empty() && capas.is_empty() && end - idx >= self.min_nodos && boundary_cacheable(mounted, idx, end, hover_idx, drop_hover_idx) { let rect = computed .get(nodo.id) .map(|r| (r.x, r.y, r.w, r.h)) .unwrap_or((0.0, 0.0, 0.0, 0.0)); let hash = boundary_hash(mounted, computed, idx, end, (rect.0, rect.1)); // La identidad entre cuadros es `(idx, end)`. Si el árbol cambia // de forma, el par deja de casar y el contador arranca de cero: // se pierden sugerencias, no se inventan. Para un diagnóstico // ése es el lado correcto del error. let quieto = match self.previo.get(&(idx, end)) { Some((h, q)) if *h == hash => q + 1, _ => 1, }; self.actual.insert((idx, end), (hash, quieto)); if quieto >= self.min_frames { salida.push(SugerenciaBoundary { idx, nodos: end - idx, rect, texto: mounted.nodes[idx..end] .iter() .find_map(|n| n.text.as_ref().map(|t| t.content.clone())), frames_quieto: quieto, }); cubierto.push(end); } } // El propio nodo puede abrir su capa: se registra DESPUÉS de // evaluarlo, porque esa capa es interna a su subárbol. if opens_layer(nodo) { capas.push(end); } } core::mem::swap(&mut self.previo, &mut self.actual); salida } /// Olvida lo observado (cambió el viewport, el tema, la pantalla…). pub fn clear(&mut self) { self.previo.clear(); self.actual.clear(); } } /// Una línea por sugerencia, lista para imprimir. pub fn describir(s: &SugerenciaBoundary) -> String { let etiqueta = match &s.texto { Some(t) => { let t: String = t.chars().take(28).collect(); alloc::format!(" «{t}»") } None => String::new(), }; alloc::format!( " nodo {} · {} nodos · ({:.0},{:.0} {:.0}×{:.0}) · {} cuadros quieto{}", s.idx, s.nodos, s.rect.0, s.rect.1, s.rect.2, s.rect.3, s.frames_quieto, etiqueta ) } #[cfg(test)] mod tests { use super::*; use crate::llimphi_layout::taffy::prelude::*; use crate::llimphi_layout::LayoutTree; use crate::peniko::Color; use crate::{mount, paint_cached, RepaintCache, View}; const VP: (f32, f32) = (400.0, 300.0); const CLAVE_PANEL: u64 = 0xC0FFEE; fn montar(v: View<()>) -> (crate::Mounted<()>, ComputedLayout) { let mut lt = LayoutTree::new(); let m = mount(&mut lt, v); let c = lt.compute(m.root, VP).expect("layout"); (m, c) } /// Un panel de `n` filas de texto. Es la forma de un sidebar real: muchos /// nodos, todos paint-puros. fn panel(titulo: &str, n: usize) -> View<()> { let mut hijos = alloc::vec![View::<()>::new(Style::default()).text( titulo, 14.0, Color::WHITE )]; for i in 0..n { hijos.push( View::<()>::new(Style { size: Size { width: length(180.0), height: length(20.0) }, ..Default::default() }) .fill(Color::from_rgba8(40, 40, 50, 255)) .children(alloc::vec![View::<()>::new(Style::default()).text( alloc::format!("fila {i}"), 12.0, Color::WHITE )]), ); } View::<()>::new(Style { flex_direction: FlexDirection::Column, size: Size { width: length(200.0), height: percent(1.0_f32) }, ..Default::default() }) .fill(Color::from_rgba8(24, 24, 32, 255)) .children(hijos) } /// Chrome quieto al costado + un contador que cambia todos los cuadros. Es /// exactamente el caso que el Bloque 23 quiere y que ninguna app cableó. fn pantalla(marca: u32) -> View<()> { View::<()>::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, ..Default::default() }) .children(alloc::vec![ panel("Archivos", 12), View::<()>::new(Style::default()).text( alloc::format!("contador: {marca}"), 13.0, Color::WHITE ), ]) } fn correr(det: &mut DetectorBoundaries, cuadros: u32) -> Vec { let mut ultima = Vec::new(); for f in 0..cuadros { let (m, c) = montar(pantalla(f)); ultima = det.observar(&m, &c, None, None); } ultima } #[test] fn señala_el_panel_quieto_y_no_lo_que_cambia() { let mut det = DetectorBoundaries::con(16, 5); let sug = correr(&mut det, 6); assert_eq!(sug.len(), 1, "tendría que salir un solo subárbol: {sug:?}"); let s = &sug[0]; assert!(s.nodos >= 25, "el panel entero, no una fila: {} nodos", s.nodos); assert_eq!(s.texto.as_deref(), Some("Archivos"), "y reconocible por su texto"); assert!(s.frames_quieto >= 5); // El rect es el del panel, no el de la pantalla. assert!((s.rect.2 - 200.0).abs() < 1.0, "rect {:?}", s.rect); } #[test] fn un_cuadro_quieto_no_alcanza() { // Cualquier cosa está quieta entre dos cuadros. Sugerir con esa // evidencia sería mandar a cablear un boundary a un panel que cambia // cada medio segundo, y el hash se paga en TODOS los cuadros. let mut det = DetectorBoundaries::con(16, 30); assert!(correr(&mut det, 29).is_empty(), "29 cuadros no llegan a 30"); assert!(!correr(&mut det, 1).is_empty(), "el 30 sí"); } #[test] fn lo_que_cambia_no_se_sugiere_nunca() { // El control: si el panel también cambia, no hay nada que cachear y el // detector tiene que callarse por muchos cuadros que pasen. let mut det = DetectorBoundaries::con(16, 3); let mut sug = Vec::new(); for f in 0..20 { let vivo = View::<()>::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, ..Default::default() }) .children(alloc::vec![panel(&alloc::format!("Archivos {f}"), 12)]); let (m, c) = montar(vivo); sug = det.observar(&m, &c, None, None); } assert!(sug.is_empty(), "sugirió cachear algo que cambia: {sug:?}"); } #[test] fn no_sugiere_lo_que_ya_tiene_boundary() { // Ni el nodo marcado ni sus hijos: ahí el trabajo está hecho y repetirlo // en la salida sería ruido que hace ignorar el instrumento. let mut det = DetectorBoundaries::con(16, 3); let mut sug = Vec::new(); for f in 0..10 { let v = View::<()>::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, ..Default::default() }) .children(alloc::vec![ panel("Archivos", 12).repaint_boundary(1), View::<()>::new(Style::default()).text( alloc::format!("{f}"), 13.0, Color::WHITE ), ]); let (m, c) = montar(v); sug = det.observar(&m, &c, None, None); } assert!(sug.is_empty(), "sugirió un boundary donde ya hay uno: {sug:?}"); } #[test] fn con_el_puntero_adentro_se_calla() { // Misma regla que el caché: un subárbol con el hover adentro no se // sirve cacheado, así que sugerirlo sería prometer un ahorro que no va // a pasar mientras el usuario tenga el puntero ahí. let mut det = DetectorBoundaries::con(16, 3); let mut sug = Vec::new(); for f in 0..10 { let (m, c) = montar(pantalla(f)); // Un nodo bien adentro del panel. sug = det.observar(&m, &c, Some(6), None); } assert!(sug.is_empty(), "sugirió con el puntero adentro: {sug:?}"); } /// **El test que le da sentido a todos los demás.** Una sugerencia no es una /// opinión sobre el árbol: es la predicción de que, puesto ahí el boundary, /// el caché va a **acertar**. Acá se toma la sugerencia, se cablea, y se /// cuentan los hits. Que además lo pintado no cambie lo verifica por /// píxeles `llimphi-test/tests/boundary_cache.rs`, que es donde vive el /// rasterizador. #[test] fn cablear_la_sugerencia_da_los_cache_hits_que_prometio() { let mut det = DetectorBoundaries::con(16, 3); let sug = correr(&mut det, 4); let s = sug.first().expect("tenía que sugerir el panel"); // Se cablea donde dijo: el panel es el hijo 0 de la pantalla. let con_boundary = |marca: u32| { View::<()>::new(Style { size: Size { width: percent(1.0_f32), height: percent(1.0_f32) }, ..Default::default() }) .children(alloc::vec![ panel("Archivos", 12).repaint_boundary(CLAVE_PANEL), View::<()>::new(Style::default()).text( alloc::format!("contador: {marca}"), 13.0, Color::WHITE ), ]) }; let mut ts = crate::llimphi_text::Typesetter::new(); let mut cache = RepaintCache::new(); for marca in 0..4u32 { let (m, c) = montar(con_boundary(marca)); let mut lienzo = crate::Lienzo::new(); paint_cached(&mut lienzo, &m, &c, &mut ts, None, None, &mut cache); } assert_eq!( (cache.hits, cache.misses), (3, 1), "la sugerencia prometía cache-hits y el caché no acertó" ); // Y que el nodo sugerido sea efectivamente el que quedó marcado: si el // índice apuntara a otro lado, los hits de arriba serían casualidad. let (m, _) = montar(con_boundary(0)); assert_eq!( m.nodes[s.idx].repaint_boundary, Some(CLAVE_PANEL), "el idx de la sugerencia no es el nodo que lleva el boundary" ); } }