tawasuyu

tawasuyu/llimphi

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

Rust 229TOML 101Markdown 82Shell 24052 Definiciones

⎇ main llimphi-compositor/src/render/cache.rs Rust · 374 líneas · 14.3 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual d8edd9a9c · Sergio ·

1use super::*;
2
3// ─────────────────────────────────────────────────────────────────────────
4// RepaintBoundary — caché de rasterización de subárboles (Bloque 23 de
5// PLAN-NUCLEO-RETENIDO.md). Ver `View::repaint_boundary`.
6//
7// Diseño *fail-safe*: `paint_range` queda INTACTO (cero regresión para todo el
8// que no use boundaries). Toda la lógica vive en `paint_cached`, un wrapper que
9// pinta los tramos no-boundary con el mismo `paint_range` de siempre e intercala
10// el append (cacheado o fresco) de cada boundary. Ante cualquier duda sobre si
11// un subárbol es cacheable, se pinta fresco → nunca produce un frame incorrecto.
12// ─────────────────────────────────────────────────────────────────────────
13
14/// Caché de rasterización de los [`View::repaint_boundary`]. Vive en el runtime
15/// entre frames. Cada entrada guarda la sub-escena vello del subárbol en coords
16/// **locales** al origen del boundary + un hash de su contenido; mientras el
17/// hash no cambie se reusa (`scene.append` trasladado al origen actual), así
18/// sobrevive a un scroll/translación del subárbol sin re-pintar.
19#[derive(Default)]
20pub struct RepaintCache {
21    entries: HashMap<u64, CachedBoundary>,
22    seen: hashbrown::HashSet<u64>,
23    /// Aciertos de caché acumulados (telemetría / tests).
24    pub hits: u64,
25    /// Fallos de caché acumulados (telemetría / tests).
26    pub misses: u64,
27}
28
29pub(crate) struct CachedBoundary {
30    scene: Lienzo,
31    hash: u64,
32}
33
34impl RepaintCache {
35    pub fn new() -> Self {
36        Self::default()
37    }
38    /// Entradas vivas.
39    pub fn len(&self) -> usize {
40        self.entries.len()
41    }
42    pub fn is_empty(&self) -> bool {
43        self.entries.is_empty()
44    }
45    /// Vacía el caché (p. ej. al cambiar el viewport o el tema).
46    pub fn clear(&mut self) {
47        self.entries.clear();
48        self.seen.clear();
49    }
50    fn begin_frame(&mut self) {
51        self.seen.clear();
52    }
53    /// Descarta las entradas cuyo boundary no apareció este frame.
54    fn gc(&mut self) {
55        let seen = &self.seen;
56        self.entries.retain(|k, _| seen.contains(k));
57    }
58}
59
60/// `true` si algún nodo del árbol declara `repaint_boundary` (el runtime lo usa
61/// para decidir si vale la pena `paint_cached` o alcanza con `paint`).
62pub fn has_repaint_boundary<Msg>(mounted: &Mounted<Msg>) -> bool {
63    mounted.nodes.iter().any(|n| n.repaint_boundary.is_some())
64}
65
66/// ¿El nodo abre una capa vello / transform / scrollport que un `scene.append`
67/// trasladado NO podría reproducir si fuera ancestro de un boundary? Si es
68/// ancestro de un boundary, ese boundary NO está en posición "limpia".
69pub(crate) fn opens_layer<Msg>(n: &MountedNode<Msg>) -> bool {
70    n.clip
71        || n.alpha.is_some()
72        || n.blend.is_some()
73        || n.transform.is_some()
74        || n.transform_rel.is_some()
75        || n.mask_image.is_some()
76        || n.sticky.is_some()
77        || n.over_layer
78        || n.clip_inset.is_some()
79        || n.clip_ellipse.is_some()
80        || n.clip_polygon.is_some()
81        || n.clip_path_svg.is_some()
82        || n.clip_ref_inset.is_some()
83}
84
85/// Recolecta los `repaint_boundary` en posición **limpia** (sin capa ancestral
86/// abierta), en pre-orden. Un boundary bajo un transform/clip/alpha NO se
87/// recolecta → cae a un tramo normal y se pinta fresco.
88pub(crate) fn collect_clean_boundaries<Msg>(mounted: &Mounted<Msg>) -> Vec<(usize, usize, u64)> {
89    let mut out = Vec::new();
90    let mut layer_ends: Vec<usize> = Vec::new();
91    for idx in 0..mounted.nodes.len() {
92        while layer_ends.last().is_some_and(|&e| idx >= e) {
93            layer_ends.pop();
94        }
95        let node = &mounted.nodes[idx];
96        if let Some(key) = node.repaint_boundary {
97            if layer_ends.is_empty() {
98                out.push((idx, node.subtree_end, key));
99            }
100        }
101        // El propio boundary puede abrir su capa (clip/alpha propios): se registra
102        // DESPUÉS de recolectarlo (su capa es interna a su sub-escena).
103        if opens_layer(node) {
104            layer_ends.push(node.subtree_end);
105        }
106    }
107    out
108}
109
110/// ¿El subárbol `[idx, end)` es cacheable? Paint-puro (sin closures de pintado
111/// ni contenido dinámico) y sin el nodo hovereado/drop adentro. Conservador:
112/// ante la duda `false` (se pinta fresco).
113///
114/// **Gradiente e imagen entraron en 2026-08-05.** Quedaban fuera «porque no se
115/// hashean de forma barata», y eso dejaba al caché sin el caso que lo motivaba:
116/// un sidebar real tiene un degradé de fondo o un ícono, y **un solo nodo con
117/// imagen en el subárbol lo descalificaba entero**. La imagen se hashea por el
118/// **id del `Blob`** —un contador global de peniko, O(1)— y no por sus píxeles;
119/// dos blobs distintos con los mismos bytes dan ids distintos y eso es un miss
120/// de más, nunca un hit indebido. El gradiente usa el `bit_hash` **de peniko**
121/// para los stops, que es la definición upstream de «mismo gradiente».
122///
123/// La **máscara** sigue afuera, y a propósito: su `MaskPlacement` es un nido de
124/// enums (size/pos/repeat/mode/clip-inset/origin-inset) cuya superficie de hash
125/// es más grande que lo que compra —una `mask-image` en chrome estático es rara—
126/// y cada campo que se olvidara sería un hit indebido, o sea un cuadro mal.
127pub(crate) fn boundary_cacheable<Msg>(
128    mounted: &Mounted<Msg>,
129    idx: usize,
130    end: usize,
131    hover_idx: Option<usize>,
132    drop_hover_idx: Option<usize>,
133) -> bool {
134    if hover_idx.is_some_and(|h| h >= idx && h < end) {
135        return false;
136    }
137    if drop_hover_idx.is_some_and(|d| d >= idx && d < end) {
138        return false;
139    }
140    mounted.nodes[idx..end].iter().all(|n| {
141        n.painter.is_none()
142            && n.gpu_painter.is_none()
143            && n.over_painter.is_none()
144            && n.mask_image.is_none()
145            && n.anim.is_none()
146            && n.ripple.is_none()
147            && n.hero.is_none()
148    })
149}
150
151pub(crate) fn hash_color(c: &Color, h: &mut impl Hasher) {
152    for comp in c.components {
153        comp.to_bits().hash(h);
154    }
155}
156
157/// Hash de un gradiente. Los stops van por el `bit_hash` **de peniko**: es la
158/// definición upstream de «mismo gradiente», así que no hay una segunda idea
159/// nuestra que pueda quedar corta cuando peniko sume un campo.
160pub(crate) fn hash_gradient(g: &crate::peniko::Gradient, h: &mut impl Hasher) {
161    use crate::peniko::color::cache_key::BitHash;
162    use crate::peniko::GradientKind;
163    core::mem::discriminant(&g.kind).hash(h);
164    let mut punto = |x: f64, y: f64| {
165        x.to_bits().hash(h);
166        y.to_bits().hash(h);
167    };
168    match &g.kind {
169        GradientKind::Linear(l) => {
170            punto(l.start.x, l.start.y);
171            punto(l.end.x, l.end.y);
172        }
173        GradientKind::Radial(r) => {
174            punto(r.start_center.x, r.start_center.y);
175            punto(r.end_center.x, r.end_center.y);
176            punto(r.start_radius as f64, r.end_radius as f64);
177        }
178        GradientKind::Sweep(sw) => {
179            punto(sw.center.x, sw.center.y);
180            punto(sw.start_angle as f64, sw.end_angle as f64);
181        }
182    }
183    (g.extend as u8).hash(h);
184    core::mem::discriminant(&g.interpolation_cs).hash(h);
185    core::mem::discriminant(&g.hue_direction).hash(h);
186    core::mem::discriminant(&g.interpolation_alpha_space).hash(h);
187    g.stops.bit_hash(h);
188}
189
190/// Hash de una imagen: **el id del `Blob`**, no sus píxeles.
191///
192/// El id es un contador global de peniko que se asigna al construir el blob, así
193/// que es O(1) y sobrevive a los clones —que es como una app pasa la misma
194/// imagen cuadro a cuadro—. El error posible es en la dirección segura: dos
195/// blobs distintos con los mismos bytes dan ids distintos y eso es un **miss de
196/// más**, un repintado gratis, nunca un hit indebido.
197pub(crate) fn hash_image(img: &Image, h: &mut impl Hasher) {
198    img.image.data.id().hash(h);
199    (img.image.format as u8).hash(h);
200    (img.image.alpha_type as u8).hash(h);
201    img.image.width.hash(h);
202    img.image.height.hash(h);
203    // El sampler cambia lo pintado sin cambiar un byte del blob.
204    (img.sampler.x_extend as u8).hash(h);
205    (img.sampler.y_extend as u8).hash(h);
206    (img.sampler.quality as u8).hash(h);
207    img.sampler.alpha.to_bits().hash(h);
208}
209
210/// Hash del contenido pintable del subárbol `[idx, end)`, con los rects
211/// **relativos al origen** — así una traslación pura del boundary (scroll) deja
212/// el hash igual y es cache-hit. Cubre las variantes que `boundary_cacheable`
213/// deja pasar (fill/text/borde/sombra/radio/alpha/clip/transform propios).
214pub(crate) fn boundary_hash<Msg>(
215    mounted: &Mounted<Msg>,
216    computed: &ComputedLayout,
217    idx: usize,
218    end: usize,
219    origin: (f32, f32),
220) -> u64 {
221    let mut h = hasher_memo();
222    (end - idx).hash(&mut h);
223    for i in idx..end {
224        let n = &mounted.nodes[i];
225        match computed.get(n.id) {
226            Some(r) => {
227                (r.x - origin.0).to_bits().hash(&mut h);
228                (r.y - origin.1).to_bits().hash(&mut h);
229                r.w.to_bits().hash(&mut h);
230                r.h.to_bits().hash(&mut h);
231            }
232            None => u32::MAX.hash(&mut h),
233        }
234        n.fill.is_some().hash(&mut h);
235        if let Some(c) = &n.fill {
236            hash_color(c, &mut h);
237        }
238        n.radius.to_bits().hash(&mut h);
239        if let Some(cr) = &n.corner_radii {
240            for v in [cr.top_left, cr.top_right, cr.bottom_right, cr.bottom_left] {
241                v.to_bits().hash(&mut h);
242            }
243        }
244        if let Some(b) = &n.border {
245            b.width.to_bits().hash(&mut h);
246            hash_color(&b.color, &mut h);
247        }
248        if let Some(s) = &n.shadow {
249            hash_color(&s.color, &mut h);
250            s.blur.to_bits().hash(&mut h);
251            s.spread.to_bits().hash(&mut h);
252            s.dx.to_bits().hash(&mut h);
253            s.dy.to_bits().hash(&mut h);
254        }
255        if let Some(a) = n.alpha {
256            a.to_bits().hash(&mut h);
257        }
258        n.clip.hash(&mut h);
259        if let Some(t) = n.transform {
260            for c in t.as_coeffs() {
261                c.to_bits().hash(&mut h);
262            }
263        }
264        if let Some((x, y)) = n.transform_rel {
265            x.to_bits().hash(&mut h);
266            y.to_bits().hash(&mut h);
267        }
268        if let Some(g) = &n.fill_gradient {
269            hash_gradient(g, &mut h);
270            n.fill_gradient_superficie.hash(&mut h);
271        }
272        if let Some(img) = &n.image {
273            hash_image(img, &mut h);
274            // El encaje decide la transformación con la que se pinta: dos
275            // `Contain`/`Cover` sobre la misma imagen dan cuadros distintos.
276            n.image_fit.map(|f| f as u8).hash(&mut h);
277        }
278        if let Some(t) = &n.text {
279            t.content.hash(&mut h);
280            t.size_px.to_bits().hash(&mut h);
281            hash_color(&t.color, &mut h);
282            t.weight.to_bits().hash(&mut h);
283            t.line_height.to_bits().hash(&mut h);
284            t.italic.hash(&mut h);
285            (t.alignment as u8).hash(&mut h);
286        }
287    }
288    h.finish()
289}
290
291/// Como [`paint`], pero **cachea la rasterización** de los subárboles marcados
292/// con [`View::repaint_boundary`] que estén en posición limpia y sean
293/// paint-puros. El resto del árbol (y los boundaries no cacheables) se pinta
294/// idéntico a `paint`. Si el árbol no tiene boundaries, delega en `paint` —
295/// coste ~0.
296pub fn paint_cached<Msg>(
297    scene: &mut Lienzo,
298    mounted: &Mounted<Msg>,
299    computed: &ComputedLayout,
300    typesetter: &mut llimphi_text::Typesetter,
301    hover_idx: Option<usize>,
302    drop_hover_idx: Option<usize>,
303    cache: &mut RepaintCache,
304) {
305    cache.begin_frame();
306    let boundaries = collect_clean_boundaries(mounted);
307    if boundaries.is_empty() {
308        cache.gc();
309        paint(scene, mounted, computed, typesetter, hover_idx, drop_hover_idx);
310        return;
311    }
312    let n = mounted.nodes.len();
313    let mut cursor = 0usize;
314    for (bidx, bend, key) in boundaries {
315        // Subsumido por un boundary externo ya procesado.
316        if bidx < cursor {
317            continue;
318        }
319        // Tramo previo al boundary: pintura normal.
320        if cursor < bidx {
321            paint_range(
322                scene, mounted, computed, typesetter, hover_idx, drop_hover_idx, cursor, bidx,
323                Affine::IDENTITY,
324            );
325        }
326        let origin = computed
327            .get(mounted.nodes[bidx].id)
328            .map(|r| (r.x, r.y))
329            .unwrap_or((0.0, 0.0));
330        if boundary_cacheable(mounted, bidx, bend, hover_idx, drop_hover_idx) {
331            let hash = boundary_hash(mounted, computed, bidx, bend, origin);
332            let hit = cache
333                .entries
334                .get(&key)
335                .is_some_and(|c| c.hash == hash);
336            if hit {
337                cache.hits += 1;
338            } else {
339                cache.misses += 1;
340                // Rasterizá el subárbol a una sub-escena en coords LOCALES.
341                let mut sub = crate::Lienzo::new();
342                paint_range(
343                    &mut sub, mounted, computed, typesetter, hover_idx, drop_hover_idx, bidx, bend,
344                    Affine::translate((-origin.0 as f64, -origin.1 as f64)),
345                );
346                cache.entries.insert(key, CachedBoundary { scene: sub, hash });
347            }
348            cache.seen.insert(key);
349            // Append trasladado al origen actual (sobrevive al scroll).
350            if let Some(c) = cache.entries.get(&key) {
351                scene.append(
352                    &c.scene,
353                    Some(Affine::translate((origin.0 as f64, origin.1 as f64))),
354                );
355            }
356        } else {
357            // No cacheable → fresco inline (idéntico a `paint`).
358            paint_range(
359                scene, mounted, computed, typesetter, hover_idx, drop_hover_idx, bidx, bend,
360                Affine::IDENTITY,
361            );
362        }
363        cursor = bend;
364    }
365    // Cola tras el último boundary.
366    if cursor < n {
367        paint_range(
368            scene, mounted, computed, typesetter, hover_idx, drop_hover_idx, cursor, n,
369            Affine::IDENTITY,
370        );
371    }
372    cache.gc();
373}
374