tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1// redraw.rs — Ciclo completo de repintado: layout → paint → GPU → present.
2// Esta función es la ruta caliente: mount + compute + animaciones + vello +
3// pasadas GPU + present + retención de frame.
4
5use super::super::*;
6use super::helpers::{
7 build_selectable_layout, resolve_layout_builders, selectable_node_in,
8};
9use super::push_a11y_tree;
10
11/// Ejecuta la pasada completa de redraw para la ventana primaria.
12/// Se llama desde `handle_primary_window_event` cuando el evento es
13/// `WindowEvent::RedrawRequested`. Recibe `state` y `handle` separados para
14/// facilitar el borrow-checker (no necesita `&mut Runtime<A>` completo).
15pub(super) fn handle_redraw<A: App>(
16 state: &mut RuntimeState<A>,
17 handle: &Handle<A::Msg>,
18) {
19 // **Retención de frame entero**. Si:
20 // (a) hay scene retenida del frame anterior (`retained`),
21 // (b) `last_render` SIGUE siendo `Some` — la invariante del
22 // runtime es que cualquier handler que muta visualmente
23 // pone `last_render = None`, así que `Some` ⇒ nadie tocó
24 // nada que afecte la pintura,
25 // (c) el frame retenido NO estaba animando ni ripplando
26 // (si lo estaba, el ticker NECESITA avanzarlo),
27 // (d) no hay overlay, drag, ni long-press en curso (camino
28 // conservador: esos casos suelen estar acoplados a
29 // cambios visuales que no atraviesan `last_render`),
30 // (e) el viewport sigue del mismo tamaño,
31 // entonces `state.scene` ya tiene EXACTAMENTE lo que hay que
32 // mostrar. Saltamos mount + layout + paint y solo hacemos un
33 // render+present de la scene retenida. Cubre redraws espurios
34 // (expose del compositor, refocus, el último frame de una anim
35 // ya asentada). Si algo falla en el acquire, caemos al camino
36 // completo (no es un error, sólo un viewport efímero).
37 let cache_hit = state.last_render.is_some()
38 && state.drag.is_none()
39 && state.pending_long_press.is_none()
40 && state.retained.as_ref().is_some_and(|r| {
41 !r.animating
42 && !r.rippling
43 && !r.has_overlay
44 && !r.has_gpu_paint
45 && (r.w, r.h) == state.surface.size()
46 });
47 if cache_hit {
48 // Este camino re-rasteriza la escena vello sobre la intermedia,
49 // limpiando a negro: después de pasar por acá, la intermedia **ya no
50 // tiene** lo que hayan puesto los painters GPU. Olvidamos la huella
51 // para que el próximo cuadro se pinte entero y no se saltee apoyándose
52 // en una intermedia que perdió el video. (Hoy `!has_gpu_paint` impide
53 // llegar acá con contenido GPU vivo; esto es el cinturón por si esa
54 // guarda se afloja.)
55 state.escena_huella = None;
56 super::perf::contar_retenido();
57 match state.surface.acquire() {
58 Ok(frame) => {
59 if state
60 .renderer
61 .render(&state.hal, &state.scene, &frame, palette::css::BLACK)
62 .is_ok()
63 {
64 super::timing_primer_present();
65 super::aplicar_fullscreen_inicial::<A>(&state.window);
66 super::notificar_present(&state.window);
67 state.surface.present(frame, &state.hal);
68 // Este camino no emite muestra, pero sí sella el fin: si no
69 // lo hiciera, el `entre` del próximo cuadro muestreado se
70 // comería todo el tiempo de los retenidos y diría que la
71 // app estuvo quieta cuando estuvo presentando.
72 super::perf::sellar_fin(super::perf::enabled());
73 // Modo vsync: el frame retenido también tiene que pedir el
74 // siguiente, o el enganche al compositor se corta acá
75 // (una app en modo continuo que caiga en la retención
76 // dejaría de repintar hasta el próximo evento).
77 if super::modo_vsync_activo()
78 && A::wants_vsync_frames(state.model.as_ref().expect("model"))
79 {
80 state.window.request_redraw();
81 }
82 return;
83 }
84 // render falló → cae al camino completo
85 }
86 Err(llimphi_hal::SurfaceError::DeviceLost) => {
87 // Device irrecuperable: marca y salí. El dispatcher rehace el
88 // stack GPU tras el redraw (`rebuild_gpu_after_device_loss`).
89 state.needs_device_rebuild = true;
90 return;
91 }
92 Err(_) => { /* surface efímera → camino completo */ }
93 }
94 }
95 // Título dinámico (App::window_title): si cambió respecto del
96 // último aplicado, se lo pasamos a winit. Barato: una
97 // comparación de String por frame, set_title sólo en el cambio.
98 if let Some(t) = A::window_title(state.model.as_ref().expect("model")) {
99 if state.last_title.as_deref() != Some(t.as_str()) {
100 state.window.set_title(&t);
101 state.last_title = Some(t);
102 }
103 }
104 // Posiciona la ventana de candidatos del IME junto al caret
105 // (sólo con IME activo y si la app reporta el área).
106 if A::ime_allowed() {
107 if let Some((x, y, w, h)) =
108 A::ime_cursor_area(state.model.as_ref().expect("model"))
109 {
110 state.window.set_ime_cursor_area(
111 llimphi_hal::winit::dpi::PhysicalPosition::new(x as f64, y as f64),
112 llimphi_hal::winit::dpi::PhysicalSize::new(
113 w.max(1.0) as u32,
114 h.max(1.0) as u32,
115 ),
116 );
117 }
118 }
119 // Perfilador (`LLIMPHI_PERF`): se consulta ACÁ y no después del acquire
120 // porque el acquire es justamente lo que faltaba medir — en `Fifo` bloquea
121 // esperando al compositor, y hasta hoy caía fuera de toda etapa.
122 let pf = super::perf::enabled();
123 let entre_us = super::perf::hueco(pf);
124 let m_acquire_0 = super::perf::mark(pf);
125 let frame = match state.surface.acquire() {
126 Ok(f) => {
127 state.acquire_fallidos = 0;
128 f
129 }
130 Err(llimphi_hal::SurfaceError::DeviceLost) => {
131 // Device irrecuperable: marca y salí; el dispatcher rehace el stack
132 // GPU tras el redraw (`rebuild_gpu_after_device_loss`).
133 state.needs_device_rebuild = true;
134 return;
135 }
136 // **Ventana tapada: no es un fallo.** Llegó con wgpu 29, que la
137 // distingue del resto de los estados. Se saltea el cuadro y NO se toca
138 // el contador: si contara, una ventana minimizada ocho cuadros —o sea
139 // una fracción de segundo— dispararía la reconstrucción del stack GPU
140 // entero sin que haya nada roto. Tampoco se pide otro redraw: no hay a
141 // quién mostrárselo, y pedirlo sería girar en vacío contra el
142 // compositor, que es justo el gasto que se viene cazando en pata.
143 Err(llimphi_hal::SurfaceError::Occluded) => {
144 // Se cuenta, y no es contabilidad de más: sin este número el tramo
145 // `ocio` del período es ilegible — un hueco largo se lee igual si el
146 // compositor dejó de pedir cuadros (bug) que si la ventana no estaba
147 // a la vista (correcto). Ver `perf::OCLUIDOS`.
148 super::perf::contar_ocluido();
149 return;
150 }
151 // Un fallo efímero (viewport en tránsito, hipo del compositor): saltamos
152 // el frame y pedimos otro. Antes esto llamaba `surface.resize(size())`
153 // —resize a las medidas que YA tenía— con la idea de reconfigurar el
154 // swapchain. Dos cosas estaban mal: `acquire` ya reconfigura y recrea
155 // por dentro ante `Outdated`/`Lost`, así que era redundante; y como el
156 // reintento es inmediato, un fallo persistente se volvía un bucle
157 // cerrado que reconstruía el swapchain miles de veces por segundo hasta
158 // dejar la app en negro (visto en Android/Mali el 2026-07-27, 41.335
159 // reconfiguraciones en 13 s). Ahora se cuenta, se dice por qué, y si no
160 // afloja se escala a rebuild del stack.
161 Err(e) => {
162 state.acquire_fallidos += 1;
163 if state.acquire_fallidos <= 3 {
164 super::aviso_error(&format!(
165 "llimphi-ui: acquire falló ({e:?}), salteo el frame \
166 ({}/{ACQUIRE_FALLOS_MAX})",
167 state.acquire_fallidos
168 ));
169 }
170 if state.acquire_fallidos >= ACQUIRE_FALLOS_MAX {
171 super::aviso_error(&format!(
172 "llimphi-ui: {ACQUIRE_FALLOS_MAX} acquire fallidos seguidos \
173 (último: {e:?}) → rehago el stack GPU"
174 ));
175 state.acquire_fallidos = 0;
176 state.needs_device_rebuild = true;
177 return;
178 }
179 state.window.request_redraw();
180 return;
181 }
182 };
183 let acquire_us = super::perf::us(m_acquire_0, super::perf::mark(pf));
184 let (w, h) = frame.size();
185 // Bloque 21 — perfilador por frame (`LLIMPHI_PERF`). Cero costo apagado.
186 if pf {
187 // Huella del binario: sin esto una tanda de bytes no se puede fechar.
188 super::perf::set_tam_view(core::mem::size_of::<View<A::Msg>>());
189 }
190 let m_start = super::perf::mark(pf);
191 // Contador de asignaciones de heap: se muestrea en los MISMOS puntos que los
192 // µs, así el desglose de allocs es comparable etapa por etapa. Cero si nadie
193 // instaló `crate::alloc::ContadorAlloc` (ver feature `perf-alloc`).
194 let a_start = super::perf::alloc_mark(pf);
195 // LayoutBuilder: resuelve los constructores diferidos en dos
196 // pasadas (coste cero si no hay ninguno). Necesita el typesetter
197 // para medir, así que va antes de tomar `model_ref` para el overlay.
198 let mut view = resolve_layout_builders::<A>(
199 state.model.as_ref().expect("model"),
200 (w as f32, h as f32),
201 &mut state.typesetter,
202 );
203 // Animaciones implícitas de **tamaño** (`View::animated_size`):
204 // reconcila el `View` tree y parcha `style.size` ANTES del
205 // mount/layout. Así siblings/hijos reflowean suave (la
206 // animación se ve en el layout cascade, no sólo en el rect del
207 // nodo aislado). Coste cero sin nodos `animated_size`.
208 let frame_now = crate::Instant::now();
209 let size_animating = llimphi_compositor::reconcile_size_anim(
210 &mut view,
211 &mut state.size_anim_registry,
212 frame_now,
213 );
214 let model_ref = state.model.as_ref().expect("model");
215 let overlay_view = super::vigia::aplicar_view_overlay::<A>(model_ref);
216 // Bloque 24 — layout retenido. `mount_incremental` reusa los `NodeId` de
217 // taffy del frame anterior cuando la FORMA del árbol no cambió (sólo re-estila
218 // /re-mide lo que cambió), dejando limpio el resto para que taffy saltee su
219 // re-solve. Si la forma cambió, cae a `clear()`+rebuild histórico → cero
220 // regresión. Correctitud probada por el arnés de fuzz (retenido==fresco).
221 // Bloque 25 — extrae los builders de los nodos `memo` (dejándolos como
222 // marcadores) ANTES del mount. Sin nodos memo, `memo_pending` queda vacío y
223 // todo lo de abajo es no-op. Debe correr tras el size-anim (que muta el view).
224 let mut memo_pending = Vec::new();
225 llimphi_compositor::take_memo_builders(&mut view, &mut memo_pending);
226 // Censo de props raras, UNA vez por proceso (ver `perf::censo_pendiente`).
227 // Va antes de la marca de `view` a propósito: así el recorrido cae en el
228 // frame frío, que ya es el atípico del lote, y no ensucia el régimen.
229 if pf && super::perf::censo_pendiente() {
230 let c = llimphi_compositor::censo_rare(&view);
231 super::perf::set_censo(c.nodos, c.con_rare);
232 }
233 let m_view = super::perf::mark(pf);
234 let a_view = super::perf::alloc_mark(pf);
235 // Reciclado de buffers (Bloque 21): el `Mounted` del frame anterior ya no se
236 // usa —se sobrescribe entero en `last_render` al cierre de este frame— así
237 // que en vez de dejarlo morir para pedir otro `Vec` del mismo porte, se lo
238 // pasamos al mount para que lo `clear()`ee y escriba encima. A 200k nodos
239 // eso es un bloque de ~432 MB por frame que deja de ir y venir del
240 // allocator. Seguro acá: pasado el `cache_hit` de arriba, nadie vuelve a
241 // leer `last_render` hasta que se reasigna al final.
242 //
243 // Las DOS fuentes, y la segunda es la que importa: `mount_pool` (lo que
244 // rescató `invalidar_render` cuando un `update` tiró la caché) y, si no hay,
245 // `last_render` (redraw sin update de por medio). Son excluyentes por
246 // construcción. Mirar sólo `last_render` deja el reciclado muerto en
247 // cualquier app que actualice su modelo entre frames — o sea, todas.
248 //
249 // El overlay NO se recicla: vive dentro de `last_render.overlay` y es chico
250 // (un menú, un modal) — no paga la complejidad.
251 //
252 // El `ComputedLayout` viaja en el MISMO envase (`last_render`/pool), así que
253 // se rescata en la misma operación: separarlo obligaría a un segundo `take`
254 // sobre un `last_render` que este `or_else` ya consumió, y el mapa de rects
255 // se perdería justo en el camino donde más caro es (el frame con update).
256 let mut reciclado_layout = state.layout_pool.take();
257 let reciclado = state.mount_pool.take().or_else(|| {
258 state.last_render.take().map(|r| {
259 reciclado_layout = Some(r.computed);
260 r.mounted
261 })
262 });
263 let hubo_reciclado = reciclado.is_some();
264 let hubo_reciclado_layout = reciclado_layout.is_some();
265 let (mut mounted, retained, layout_reused): (Mounted<A::Msg>, _, bool) =
266 llimphi_compositor::mount_incremental_reciclando(
267 &mut state.layout,
268 view,
269 // Por valor: el mount escribe encima de los búferes del retenido en
270 // vez de devolver tres `Vec` nuevos. Se reinstala tres líneas abajo.
271 state.retained_layout.take(),
272 reciclado,
273 );
274 state.retained_layout = Some(retained);
275 let m_mount = super::perf::mark(pf);
276 let a_mount = super::perf::alloc_mark(pf);
277 let mut computed = {
278 let ts = &mut state.typesetter;
279 let tmap = &mounted.text_measures;
280 let imap = &mounted.intrinsic_measures;
281 state
282 .layout
283 .compute_with_measure_reciclando(
284 mounted.root,
285 (w as f32, h as f32),
286 |nid, known, avail| {
287 match tmap.get(&nid) {
288 Some(tm) => measure_text_node(ts, tm, known, avail),
289 // Contenido REEMPLAZADO (imagen): se mide por su tamaño
290 // natural, que no es lo mismo en min-content que en
291 // max-content. Ver `measure_intrinsic_node`.
292 None => match imap.get(&nid) {
293 Some(nat) => measure_intrinsic_node(nat, known, avail),
294 None => llimphi_layout::taffy::Size::ZERO,
295 },
296 }
297 },
298 reciclado_layout,
299 )
300 .expect("layout")
301 };
302 // Bloque 25 — splice de los subárboles memoizados en el Mounted/computed ya
303 // calculados: reusa el cacheado (sin reconstruir el View) o lo reconstruye si
304 // deps/tamaño cambiaron. No-op sin memos.
305 if !memo_pending.is_empty() {
306 let ts = &mut state.typesetter;
307 llimphi_compositor::splice_memos(
308 &mut mounted,
309 &mut computed,
310 &memo_pending,
311 &mut state.memo_registry,
312 |tm, k, a| measure_text_node(ts, tm, k, a),
313 );
314 }
315 let m_layout = super::perf::mark(pf);
316 let a_layout = super::perf::alloc_mark(pf);
317 // Animaciones implícitas (`View::animated`): reconcilia el árbol
318 // con el estado retenido DESPUÉS del layout y ANTES del paint —
319 // interpola fill/radius de los nodos con `anim`. Si alguna sigue
320 // viva pedimos otro frame al final (ticker autodetenido).
321 let now = frame_now;
322 let anim_active = state.anim_registry.reconcile(&mut mounted, now);
323 // Scene-split de fantasmas: el contenido viejo de un switch con
324 // `exit_under` se hornea como `underlay` del nodo — el paint lo apendea
325 // DEBAJO del contenido nuevo, en su posición z (no encima de todo).
326 state.anim_registry.prepare_underlays(&mut mounted, now);
327 // Heroes (`View::hero`): si la misma key cambió de rect entre
328 // frames, escribe en `transform` la afín que "vuela" del rect
329 // anterior al actual. Independiente del anim_registry — sólo
330 // toca `transform`, que el paint ya respeta. Coste cero sin
331 // nodos hero.
332 let hero_active = state.hero_registry.reconcile(&mut mounted, &computed, now);
333 // `size_animating` viene del reconcile previo al mount; lo
334 // ORrijimos al `animating` global para que se pida el
335 // próximo frame y el `retained.animating == true` invalide
336 // la cache de retención (la siguiente pasada reconstruye con
337 // el size interpolado).
338 let animating = anim_active || hero_active || size_animating;
339 // Mount + layout del overlay en un árbol aparte. Lo
340 // computamos con el mismo tamaño de viewport para que
341 // un scrim a percent(1.0) cubra toda la pantalla.
342 let overlay_built = if let Some(v) = overlay_view {
343 state.overlay_layout.clear();
344 let omounted: Mounted<A::Msg> = mount(&mut state.overlay_layout, v);
345 let ocomputed = {
346 let ts = &mut state.typesetter;
347 let tmap = &omounted.text_measures;
348 let imap = &omounted.intrinsic_measures;
349 state
350 .overlay_layout
351 .compute_with_measure(omounted.root, (w as f32, h as f32), |nid, known, avail| {
352 match tmap.get(&nid) {
353 Some(tm) => measure_text_node(ts, tm, known, avail),
354 None => match imap.get(&nid) {
355 Some(nat) => measure_intrinsic_node(nat, known, avail),
356 None => llimphi_layout::taffy::Size::ZERO,
357 },
358 }
359 })
360 .expect("layout overlay")
361 };
362 let ohover = hit_test_hover(
363 &omounted,
364 &ocomputed,
365 state.cursor.x as f32,
366 state.cursor.y as f32,
367 );
368 Some(OverlayCache {
369 mounted: omounted,
370 computed: ocomputed,
371 hover_idx: ohover,
372 })
373 } else {
374 None
375 };
376 // Hover en el main solo si NO hay overlay — durante un
377 // menú abierto, el fondo no debe reaccionar al ratón.
378 let hover_idx = if overlay_built.is_some() {
379 None
380 } else {
381 hit_test_hover(
382 &mounted,
383 &computed,
384 state.cursor.x as f32,
385 state.cursor.y as f32,
386 )
387 };
388 // Drop hover sólo si hay drag activo con payload (un
389 // drag bloquea el overlay; rara combinación pero la
390 // resolvemos a favor del drag).
391 let drop_hover_idx = state
392 .drag
393 .as_ref()
394 .and_then(|d| d.payload.map(|_| ()))
395 .and_then(|_| {
396 hit_test_drop(
397 &mounted,
398 &computed,
399 state.cursor.x as f32,
400 state.cursor.y as f32,
401 )
402 });
403 // Z-order del overlay sobre contenido `gpu_paint`: si el
404 // árbol principal tiene painters gpu (p. ej. el video de
405 // media) Y hay un overlay activo, el overlay NO va en la
406 // escena principal (quedaría debajo del blit gpu). Se
407 // rasteriza aparte sobre fondo transparente y se compone con
408 // alpha DESPUÉS del pase gpu. Sin gpu o sin overlay, el camino
409 // de siempre (overlay en la escena principal) — coste cero.
410 let composite_overlay =
411 overlay_built.is_some() && has_gpu_painter(&mounted);
412
413 state.scene.reset();
414 // Bloque 23 — pintura con caché de RepaintBoundary. Sin nodos boundary en el
415 // árbol, `paint_cached` delega en `paint` (coste ~0); con ellos, reusa la
416 // sub-escena rasterizada de los subárboles estáticos.
417 paint_cached(
418 &mut state.scene,
419 &mounted,
420 &computed,
421 &mut state.typesetter,
422 hover_idx,
423 drop_hover_idx,
424 &mut state.repaint_cache,
425 );
426 // Diagnóstico (`LLIMPHI_BOUNDARY_HINT`): qué subárboles vienen quietos y se
427 // están repintando de gusto. Apagado no existe — el `Option` es `None` y
428 // esto es una comparación. Va acá, con el mismo `hover_idx`/`drop_hover_idx`
429 // que acaba de usar el caché, para que la sugerencia sea una predicción de
430 // lo que el caché haría y no una opinión sobre el árbol.
431 if let Some(det) = state.detector_boundaries.as_mut() {
432 let sug = det.observar(&mounted, &computed, hover_idx, drop_hover_idx);
433 super::perf::reportar_boundaries(&sug);
434 }
435 // Animación de salida (fade-out). 1) Capturá la subescena de
436 // cada nodo `exit` presente (snapshot para cuando desaparezca).
437 // 2) Reproduce los fantasmas de los que ya se fueron, con
438 // opacidad decreciente — por encima del contenido, debajo del
439 // overlay. Coste cero si ningún nodo usa `animated_exit`.
440 for (idx, end, key) in state.anim_registry.live_exit_nodes(&mounted, now) {
441 let a = mounted.nodes[idx].anim.expect("nodo exit lleva anim");
442 // Centro del rect del nodo: origen de la afín de salida del fantasma.
443 let centro = computed
444 .get(mounted.nodes[idx].id)
445 .map(|r| (r.x + r.w * 0.5, r.y + r.h * 0.5))
446 .unwrap_or((0.0, 0.0));
447 // El underlay (fantasma ANTERIOR) no debe entrar a la captura del
448 // contenido vivo — si no, el próximo fantasma arrastraría al previo.
449 let saved_underlay = mounted.nodes[idx].underlay.take();
450 let mut sub = Escena::new();
451 paint_range(
452 &mut sub,
453 &mounted,
454 &computed,
455 &mut state.typesetter,
456 None,
457 None,
458 idx,
459 end,
460 vello::kurbo::Affine::IDENTITY,
461 );
462 mounted.nodes[idx].underlay = saved_underlay;
463 state.anim_registry.store_live_exit_en(key, sub, centro, &a, now);
464 }
465 state
466 .anim_registry
467 .replay_ghosts(&mut state.scene, now, w as f32, h as f32);
468 // Resaltado de la selección de texto activa (sobre el
469 // contenido, bajo el overlay). Reconstruye el layout del nodo
470 // seleccionado y pinta los rects de `parley::Selection` con un
471 // tinte translúcido (deja leer el texto debajo).
472 if let Some(tsel) = state.selection {
473 if let Some((spec, (rx, ry, rw, _rh))) =
474 selectable_node_in(&mounted, &computed, tsel.key)
475 {
476 let layout = build_selectable_layout(&mut state.typesetter, &spec, rw);
477 use vello::kurbo::{Affine, Rect};
478 use vello::peniko::{Color, Fill};
479 let hl = Color::from_rgba8(86, 148, 246, 80);
480 let scene = &mut state.scene;
481 tsel.sel.geometry_with(&layout, |bb, _line| {
482 let r = Rect::new(
483 rx as f64 + bb.x0,
484 ry as f64 + bb.y0,
485 rx as f64 + bb.x1,
486 ry as f64 + bb.y1,
487 );
488 scene.fill(Fill::NonZero, Affine::IDENTITY, hl, None, &r);
489 });
490 }
491 }
492 // Ripples/InkWell: las salpicaduras vivas se pintan sobre el
493 // contenido (translúcidas, recortadas al nodo) y debajo del
494 // overlay. Si alguna sigue viva, pide otro frame al final.
495 let rippling =
496 state
497 .ripple_registry
498 .paint(&mut state.scene, &mounted, &computed, now);
499 // **El fantasma del arrastre**, encima de todo lo del árbol principal y
500 // debajo del overlay: si hay un menú abierto mientras arrastrás, el menú
501 // manda. Se rearma por cuadro porque va pegado al cursor — son tres nodos,
502 // y sólo mientras dura el gesto. Ver `crate::fantasma`.
503 if let Some(carga) = state.drag.as_ref().and_then(|d| d.carga.clone()) {
504 let (vw, vh) = state.surface.size();
505 let caja = crate::fantasma::caja(
506 (state.cursor.x as f32, state.cursor.y as f32),
507 crate::fantasma::ancho_contenido(&carga),
508 (vw as f32, vh as f32),
509 );
510 let vista = crate::fantasma::vista::<A::Msg>(&carga, caja);
511 let mut layout = LayoutTree::new();
512 let fmounted: Mounted<A::Msg> = mount(&mut layout, vista);
513 let ts = &mut state.typesetter;
514 let fcomputed = {
515 let tmap = &fmounted.text_measures;
516 layout
517 .compute_with_measure(fmounted.root, (vw as f32, vh as f32), |nid, known, avail| {
518 match tmap.get(&nid) {
519 Some(tm) => measure_text_node(ts, tm, known, avail),
520 None => llimphi_layout::taffy::Size::ZERO,
521 }
522 })
523 .expect("layout del fantasma")
524 };
525 paint(&mut state.scene, &fmounted, &fcomputed, &mut state.typesetter, None, None);
526 }
527 if !composite_overlay {
528 if let Some(ov) = overlay_built.as_ref() {
529 paint(
530 &mut state.scene,
531 &ov.mounted,
532 &ov.computed,
533 &mut state.typesetter,
534 ov.hover_idx,
535 None,
536 );
537 }
538 }
539 let m_paint = super::perf::mark(pf);
540 let a_paint = super::perf::alloc_mark(pf);
541 let (vw, vh) = frame.size();
542
543 // ─── ¿Este cuadro va a pintar exactamente lo mismo que el anterior? ───
544 //
545 // Una app en modo vsync recibe cuadros a 60 Hz, pero su contenido rara vez
546 // cambia tan seguido: un video de 24 fps redibuja lo mismo en 3 de cada 5.
547 // Si nada cambió, se saltea el cuadro **entero** —vello, painters GPU,
548 // composite— y se presenta la intermedia tal como quedó, que ya tiene el
549 // resultado correcto (es una textura persistente de la surface, no se
550 // recicla por cuadro). El present sí ocurre: el ritmo con el compositor no
551 // se toca.
552 //
553 // Se salta sólo con TODAS estas ciertas, y cada una tapa una forma de
554 // servir un cuadro viejo:
555 //
556 // · la app dice que sus `gpu_painter` pintan lo mismo (`gpu_content_changed`);
557 // · la escena vello da la misma huella que la del cuadro anterior;
558 // · el viewport no cambió — un resize **recrea** la intermedia y la deja en
559 // blanco, así que presentar sin pintar mostraría un cuadro vacío;
560 // · no hay overlay (menú/modal): en el camino composite vive en otra
561 // textura y se compone con alpha sobre la intermedia, y componerlo de
562 // nuevo sobre lo ya compuesto lo **apilaría**;
563 // · no hay post-pasadas de `backdrop_blur` ni `filter`, por lo mismo:
564 // desenfocar lo ya desenfocado compone dos veces;
565 // · no hay arrastre ni long-press en curso (conservador, igual que la
566 // retención de cuadro entero de más arriba).
567 let hay_post_pasadas = mounted
568 .nodes
569 .iter()
570 .any(|n| n.backdrop_blur.is_some() || !n.filter.is_empty());
571 /// **Apagado por defecto, y no por prudencia genérica.** Este salteo se
572 /// mandó encendido el 2026-08-10 y rompió el reproductor: dejaba
573 /// `has_gpu_paint` en `false`, la retención de cuadro entero se creía
574 /// habilitada y re-rasterizaba la escena limpiando a negro sin volver a
575 /// blittear el video — **cuadros negros intercalados**, que el operador
576 /// comió durante horas creyendo que era una prueba en curso.
577 ///
578 /// La causa está arreglada (ver `has_gpu_paint: any_gpu || saltear`), pero
579 /// el arreglo **no se pudo verificar en metal**: la ventana abre en un
580 /// escritorio que el agente no puede hacer visible, y el compositor no
581 /// rinde una ventana tapada. Y lo que compra, medido, es ~12 % de CPU en el
582 /// mejor caso — el perfilador mostró que el 96–100 % del tiempo de media es
583 /// espera de vsync y GPU, no CPU. Eso no paga el riesgo de un reproductor
584 /// roto: se enciende con `LLIMPHI_SKIP=1`, se mira, y recién entonces se
585 /// discute volverlo default.
586 fn skip_habilitado() -> bool {
587 use std::sync::OnceLock;
588 static D: OnceLock<bool> = OnceLock::new();
589 *D.get_or_init(|| std::env::var("LLIMPHI_SKIP").is_ok())
590 }
591 let huella_escena = llimphi_raster::huella_escena(&state.scene);
592 let saltear = skip_habilitado()
593 && huella_escena.is_some()
594 && state.escena_huella == huella_escena.map(|h| (h, vw, vh))
595 && !A::gpu_content_changed(state.model.as_ref().expect("model"))
596 && overlay_built.is_none()
597 && !hay_post_pasadas
598 && state.drag.is_none()
599 && state.pending_long_press.is_none();
600 state.escena_huella = huella_escena.map(|h| (h, vw, vh));
601
602 if !saltear {
603 if let Err(e) = state.renderer.render(
604 &state.hal,
605 &state.scene,
606 &frame,
607 palette::css::BLACK,
608 ) {
609 super::aviso_error(&format!("render error: {e}"));
610 // No se pudo pintar: lo que hay en la intermedia ya no se
611 // corresponde con la huella que acabamos de guardar. Se olvida,
612 // para no saltear el cuadro que viene apoyándose en ella.
613 state.escena_huella = None;
614 }
615 }
616 let m_raster = super::perf::mark(pf);
617 // Capa de overlay aparte (camino composite): vello la
618 // rasteriza con fondo transparente en `frame.overlay_view()`.
619 // Se renderiza ANTES del pase gpu para que el blit del
620 // compositor (en `gpu_encoder`) la lea ya escrita.
621 if composite_overlay {
622 if let Some(ov) = overlay_built.as_ref() {
623 state.scene.reset();
624 paint(
625 &mut state.scene,
626 &ov.mounted,
627 &ov.computed,
628 &mut state.typesetter,
629 ov.hover_idx,
630 None,
631 );
632 if let Err(e) = state.renderer.render_to_view(
633 &state.hal,
634 &state.scene,
635 frame.overlay_view(),
636 vw,
637 vh,
638 palette::css::TRANSPARENT,
639 ) {
640 super::aviso_error(&format!("render overlay error: {e}"));
641 }
642 }
643 }
644 // Pasada GPU directo (Fase 1 del SDD §"GPU directo wgpu"):
645 // si algún View del main o del overlay registró un
646 // `gpu_painter`, ejecutamos todos sus callbacks contra un
647 // único `CommandEncoder`, encima de lo que vello acaba de
648 // pintar sobre la intermediate. Submitimos antes del
649 // present para que el blit al swapchain incluya las
650 // primitivas GPU. Si nadie usó el hook, no se crea ni
651 // submitea nada — coste cero.
652 let mut gpu_encoder = state.hal.device.create_command_encoder(
653 &llimphi_hal::wgpu::CommandEncoderDescriptor {
654 label: Some("llimphi-ui-gpu-paint"),
655 },
656 );
657 // Desglose del tramo `gpu` (sólo con el perfilador encendido; quedan en 0
658 // si la capa over no se activa en este frame).
659 let mut over_paint_us = 0.0_f64;
660 let mut over_tex_us = 0.0_f64;
661 let mut over_raster_us = 0.0_f64;
662 // ¿Este cuadro reusó la capa over ya dibujada en vez de rasterizarla?
663 let mut over_cache_hit = false;
664 let viewport = frame.size();
665 // Backdrop blur (Bloque 11): post-pasada Gauss separable sobre
666 // la intermediate, restringida al rect de cada nodo
667 // `.backdrop_blur(sigma)`. Sucede TRAS la rasterización vello
668 // y ANTES de los `gpu_painter`/composite — los painters GPU
669 // que se solapen con el blur ven el rect ya borroneado y se
670 // dibujan encima nítidos. Coste cero sin nodos blur (loop
671 // vacío + bandera `blurred` queda false).
672 let backdrop_blurs =
673 llimphi_compositor::collect_backdrop_blurs(&mounted, &computed);
674 let blurred = !backdrop_blurs.is_empty();
675 for b in &backdrop_blurs {
676 state.blur_compositor.blur(
677 &state.hal.device,
678 &state.hal.queue,
679 &mut gpu_encoder,
680 frame.view(),
681 viewport,
682 b.rect,
683 b.sigma,
684 );
685 }
686 // `filter: …` sobre el propio subárbol (Fase 7.1232+): misma post-pasada
687 // que el backdrop, pero leyendo `MountedNode::filter` y restringida al rect
688 // del nodo. Se aplica DESPUÉS del backdrop, sobre los píxeles ya
689 // rasterizados (el contenido del nodo). Hoy sólo `Blur`; el resto de las
690 // variantes se suman por fase.
691 let filter_passes = llimphi_compositor::collect_filters(&mounted, &computed);
692 let filtered = !filter_passes.is_empty();
693 for p in &filter_passes {
694 match &p.op {
695 llimphi_compositor::FilterOp::Blur(sigma) => {
696 state.blur_compositor.blur(
697 &state.hal.device,
698 &state.hal.queue,
699 &mut gpu_encoder,
700 frame.view(),
701 viewport,
702 p.rect,
703 *sigma,
704 );
705 }
706 llimphi_compositor::FilterOp::ColorMatrix(m) => {
707 state.color_filter_compositor.apply(
708 &state.hal.device,
709 &state.hal.queue,
710 &mut gpu_encoder,
711 frame.view(),
712 viewport,
713 p.rect,
714 *m,
715 );
716 }
717 // drop-shadow se pinta en vello (no es post-pasada); collect_filters
718 // no la emite, pero el match debe ser exhaustivo. Fase 7.1234.
719 llimphi_compositor::FilterOp::DropShadow(_) => {}
720 }
721 }
722 // Con `saltear` no se corre ningún painter GPU: la intermedia ya tiene lo
723 // que pintaron el cuadro pasado, y volver a correrlos sería redibujar el
724 // mismo video encima de sí mismo. (`blurred`/`filtered` son false por
725 // construcción acá: `saltear` exige que no haya post-pasadas.)
726 let mut any_gpu = !saltear
727 && (blurred
728 | filtered
729 | paint_gpu(
730 &mounted,
731 &computed,
732 &state.hal.device,
733 &state.hal.queue,
734 &mut gpu_encoder,
735 frame.view(),
736 viewport,
737 ));
738 if let Some(ov) = overlay_built.as_ref() {
739 // En el camino composite, los painters gpu del overlay van
740 // sobre SU textura; si no, sobre la intermedia.
741 let target = if composite_overlay {
742 frame.overlay_view()
743 } else {
744 frame.view()
745 };
746 any_gpu |= paint_gpu(
747 &ov.mounted,
748 &ov.computed,
749 &state.hal.device,
750 &state.hal.queue,
751 &mut gpu_encoder,
752 target,
753 viewport,
754 );
755 }
756 // Capa vello "over" (Primitivo B): nodos con `paint_over` registran
757 // primitivas vello que deben quedar ENCIMA del pase GPU directo
758 // (sprites/texto AA sobre celdas instanciadas — dominium, motor
759 // voxel). Camino opt-in y coste cero si nadie la usa (loop barato +
760 // bandera false): el orden total queda [vello base] → [gpu_paint] →
761 // [vello over] → [overlay/menús].
762 //
763 // Mecánica de z-order correcta: rasterizamos la escena over en una
764 // textura scratch transparente (vello, con su propio submit — no
765 // toca la intermedia) y luego GRABAMOS el composite alpha de esa
766 // scratch sobre la intermedia DENTRO de `gpu_encoder`, después de
767 // los pases GPU directos. Como `gpu_encoder` se submitea al final
768 // (línea de abajo), el composite corre en la GPU DESPUÉS de las
769 // primitivas GPU → el over-layer queda encima de ellas. Va ANTES
770 // del composite de menús, así los menús siguen por encima del over.
771 let over_active = has_over_painter(&mounted)
772 || overlay_built
773 .as_ref()
774 .map(|ov| has_over_painter(&ov.mounted))
775 .unwrap_or(false);
776 /// ¿Rasterizar la capa over en el frame entero (comportamiento previo al
777 /// recorte por bounding box)? Sólo para medir. Cacheado: un `env::var` por
778 /// cuadro no es gratis.
779 fn over_fullframe() -> bool {
780 use std::sync::OnceLock;
781 static D: OnceLock<bool> = OnceLock::new();
782 *D.get_or_init(|| std::env::var("LLIMPHI_OVER_FULLFRAME").is_ok())
783 }
784
785 /// ¿Saltear la pasada over por completo? Sólo para medir el **techo** de lo
786 /// que se ganaría cacheándola (la UI over desaparece: no es un modo usable).
787 fn over_off() -> bool {
788 use std::sync::OnceLock;
789 static D: OnceLock<bool> = OnceLock::new();
790 *D.get_or_init(|| std::env::var("LLIMPHI_OVER_OFF").is_ok())
791 }
792
793 /// Válvula del caché de la capa over: `LLIMPHI_OVER_NOCACHE=1` rasteriza
794 /// siempre, aunque la escena no haya cambiado. Está para el A/B **alternado
795 /// en el mismo bucle** (la única medición que le saca señal a esta máquina)
796 /// y para descartar el caché de un plumazo si alguna vez se sospecha que
797 /// congela algo en pantalla.
798 fn over_nocache() -> bool {
799 use std::sync::OnceLock;
800 static D: OnceLock<bool> = OnceLock::new();
801 *D.get_or_init(|| std::env::var("LLIMPHI_OVER_NOCACHE").is_ok())
802 }
803
804 /// Traza del recorte y del caché. Cacheada como las otras: se consultaba con
805 /// un `env::var` **por cuadro**, que asigna una `String` cada vez.
806 fn over_debug() -> bool {
807 use std::sync::OnceLock;
808 static D: OnceLock<bool> = OnceLock::new();
809 *D.get_or_init(|| std::env::var("LLIMPHI_DEBUG_OVER").is_ok())
810 }
811
812 if over_active && !over_off() && !saltear {
813 let m_over_paint = super::perf::mark(pf);
814 // Bounding box de TODO lo que la pasada over va a dibujar (árbol
815 // principal + overlay). La capa se rasteriza y se compone sólo ahí:
816 // pintar una barra de progreso de 1840×5 no puede costar un frame
817 // entero de vello. Ver `over_bbox` y `02_ruway/media/RENDIMIENTO.md`.
818 let caja_over = {
819 let a = llimphi_compositor::over_bbox(&mounted, &computed);
820 let b = overlay_built
821 .as_ref()
822 .and_then(|ov| llimphi_compositor::over_bbox(&ov.mounted, &ov.computed));
823 match (a, b) {
824 (Some(a), Some(b)) => Some((
825 a.0.min(b.0),
826 a.1.min(b.1),
827 a.2.max(b.2),
828 a.3.max(b.3),
829 )),
830 (x, y) => x.or(y),
831 }
832 };
833 // Acotada al frame y **snapeada a una grilla**: el bbox de un OSC late
834 // por sub-píxeles (el texto del reloj cambia de ancho, una barra
835 // avanza), y sin snap la scratch se reasignaría casi cada cuadro —
836 // justo lo que se vino a evitar. Con grilla, en régimen es constante.
837 // Si queda vacía (la capa cae entera fuera de la ventana) no hay nada
838 // que componer.
839 const GRILLA: f32 = 32.0;
840 // Válvula para medir: `LLIMPHI_OVER_FULLFRAME=1` vuelve al
841 // comportamiento viejo (rasterizar la capa over en el frame entero).
842 // Está para poder correr el A/B **alternando en el mismo bucle** — entre
843 // dos corridas separadas, el ruido de la máquina supera al efecto.
844 let caja_over = if over_fullframe() { None } else { caja_over };
845 let recorte = caja_over.and_then(|(x0, y0, x1, y1)| {
846 let x0 = (x0 / GRILLA).floor() * GRILLA;
847 let y0 = (y0 / GRILLA).floor() * GRILLA;
848 let x1 = (x1 / GRILLA).ceil() * GRILLA;
849 let y1 = (y1 / GRILLA).ceil() * GRILLA;
850 let x0 = x0.max(0.0);
851 let y0 = y0.max(0.0);
852 let x1 = x1.min(vw as f32);
853 let y1 = y1.min(vh as f32);
854 (x1 > x0 && y1 > y0).then_some((x0, y0, x1 - x0, y1 - y0))
855 });
856 let (ox, oy) = recorte.map(|r| (r.0, r.1)).unwrap_or((0.0, 0.0));
857 if over_debug() {
858 if let Some((x, y, w, h)) = recorte {
859 llimphi_hal::avisar!(
860 "[over] recorte {w}x{h} en ({x},{y}) — {:.1}% del frame {vw}x{vh}",
861 (w * h) / (vw as f32 * vh as f32) * 100.0
862 );
863 }
864 }
865
866 // Escena vello aparte (no pisamos `state.scene`, que el caller
867 // retiene/reusa). Fondo transparente: sólo lo pintado por los
868 // `over_painter` lleva alpha. Se pinta en coordenadas LOCALES al
869 // recorte — por eso las variantes `_at`.
870 let mut over_scene = Escena::new();
871 let mut any_over = llimphi_compositor::paint_over_at(
872 &mut over_scene,
873 &mounted,
874 &computed,
875 &mut state.typesetter,
876 (ox, oy),
877 );
878 // Subárboles de Views marcados `View::over` (UI encima de contenido
879 // gpu_paint — p. ej. el OSC del reproductor): se pintan aquí, con la
880 // maquinaria completa (fills/texto/hover), en la misma escena over.
881 any_over |= llimphi_compositor::paint_over_layers_at(
882 &mut over_scene,
883 &mounted,
884 &computed,
885 &mut state.typesetter,
886 hover_idx,
887 drop_hover_idx,
888 (ox, oy),
889 );
890 if let Some(ov) = overlay_built.as_ref() {
891 any_over |= llimphi_compositor::paint_over_at(
892 &mut over_scene,
893 &ov.mounted,
894 &ov.computed,
895 &mut state.typesetter,
896 (ox, oy),
897 );
898 any_over |= llimphi_compositor::paint_over_layers_at(
899 &mut over_scene,
900 &ov.mounted,
901 &ov.computed,
902 &mut state.typesetter,
903 ov.hover_idx,
904 None,
905 (ox, oy),
906 );
907 }
908 let m_over_0 = super::perf::mark(pf);
909 over_paint_us = super::perf::us(m_over_paint, m_over_0);
910 if any_over {
911 let (rx, ry, rw, rh) = recorte.unwrap_or((0.0, 0.0, vw as f32, vh as f32));
912 let (tw, th) = (rw.max(1.0) as u32, rh.max(1.0) as u32);
913 // Scratch transparente del tamaño del RECORTE (mismo formato que la
914 // intermedia: Rgba8Unorm). Vello escribe via compute
915 // (STORAGE_BINDING) y el composite la lee como sampler
916 // (TEXTURE_BINDING). Persistente: se reasigna sólo cuando cambia el
917 // tamaño, y el recorte viene snapeado a una grilla justamente para
918 // que no cambie por un píxel.
919 // ¿Va a pintar lo mismo que la vez pasada? La huella sale del
920 // **encoding de vello** —lo que la GPU va a leer—, no de los campos
921 // de los nodos: si es la misma, los píxeles son los mismos por
922 // construcción. Ver `llimphi_raster::huella`. `None` (perfil
923 // hybrid) = no se sabe, y entonces se rasteriza como siempre.
924 let huella = (!over_nocache())
925 .then(|| llimphi_raster::huella_escena(&over_scene))
926 .flatten();
927 let reusa = matches!(state.over_scratch, Some((_, _, w, h)) if w == tw && h == th);
928 if !reusa {
929 let tex = state.hal.device.create_texture(
930 &llimphi_hal::wgpu::TextureDescriptor {
931 label: Some("llimphi-ui-over-scratch"),
932 size: llimphi_hal::wgpu::Extent3d {
933 width: tw,
934 height: th,
935 depth_or_array_layers: 1,
936 },
937 mip_level_count: 1,
938 sample_count: 1,
939 dimension: llimphi_hal::wgpu::TextureDimension::D2,
940 format: llimphi_hal::wgpu::TextureFormat::Rgba8Unorm,
941 usage: llimphi_hal::wgpu::TextureUsages::STORAGE_BINDING
942 | llimphi_hal::wgpu::TextureUsages::TEXTURE_BINDING
943 | llimphi_hal::wgpu::TextureUsages::RENDER_ATTACHMENT,
944 view_formats: &[],
945 },
946 );
947 let view = tex.create_view(&llimphi_hal::wgpu::TextureViewDescriptor::default());
948 state.over_scratch = Some((tex, view, tw, th));
949 // Textura nueva = lienzo en blanco: lo que hubiera dibujado ya
950 // no está. Sin esto, un cambio de tamaño dejaría la huella
951 // vieja apuntando a una scratch vacía y la capa se vería negra.
952 state.over_huella = None;
953 }
954 // Acierto: la scratch ya tiene EXACTAMENTE esta escena dibujada.
955 // Se saltea la rasterización (el tramo caro) y se compone lo que ya
956 // está. Sólo puede acertar si además la scratch es la misma (`reusa`).
957 let acierto = reusa && huella.is_some() && state.over_huella == huella;
958 if over_debug() {
959 // Qué tramo del encoding cambió respecto del cuadro anterior:
960 // sin esto, un fallo de caché sólo dice «cambió algo».
961 let tramos = llimphi_raster::huella::huella_por_tramo(&over_scene);
962 let cambio: Vec<&str> = tramos
963 .iter()
964 .zip(state.over_tramos.iter())
965 .filter(|((_, a), b)| a != *b)
966 .map(|((n, _), _)| *n)
967 .collect();
968 state.over_tramos = tramos.map(|(_, v)| v);
969 // Las coordenadas crudas, para poder decir QUÉ se mueve: un
970 // `path_data` que cambia sin más no señala a nadie.
971 let coords: Vec<f32> = llimphi_raster::huella::coords_de_paths(&over_scene);
972 let dif: Vec<String> = coords
973 .iter()
974 .zip(state.over_coords.iter())
975 .enumerate()
976 .filter(|(_, (a, b))| *a != *b)
977 .map(|(i, (a, b))| format!("[{i}] {b}→{a}"))
978 .take(6)
979 .collect();
980 state.over_coords = coords;
981 llimphi_hal::avisar!(
982 "[over] {} scratch {tw}x{th} reusa={reusa} cambió: {} | {}",
983 if acierto { "ACIERTO" } else { "falla " },
984 if cambio.is_empty() { "nada".into() } else { cambio.join(",") },
985 dif.join(" ")
986 );
987 }
988 let over_view = &state.over_scratch.as_ref().expect("scratch recién puesta").1;
989 let m_over_1 = super::perf::mark(pf);
990 // Vello rasteriza la escena over a la scratch (su propio
991 // submit; limpia con TRANSPARENT). Independiente de la
992 // intermedia. `tw`×`th`, no el frame entero: la escena ya viene
993 // pintada en coordenadas locales al recorte.
994 if acierto {
995 over_cache_hit = true;
996 } else if let Err(e) = state.renderer.render_to_view(
997 &state.hal,
998 &over_scene,
999 over_view,
1000 tw,
1001 th,
1002 palette::css::TRANSPARENT,
1003 ) {
1004 super::aviso_error(&format!("render over-layer error: {e}"));
1005 // Rasterización fallida: la scratch quedó en un estado que no
1006 // podemos afirmar. Se olvida la huella para no servirla como
1007 // buena el cuadro que viene.
1008 state.over_huella = None;
1009 } else {
1010 state.over_huella = huella;
1011 }
1012 let m_over_2 = super::perf::mark(pf);
1013 over_tex_us = super::perf::us(m_over_0, m_over_1);
1014 over_raster_us = super::perf::us(m_over_1, m_over_2);
1015 // Composite alpha de la scratch sobre la intermedia, en su lugar del
1016 // frame, grabado en `gpu_encoder` DESPUÉS de los pases GPU directos.
1017 state.overlay_compositor.composite_rect(
1018 &state.hal.device,
1019 &state.hal.queue,
1020 &mut gpu_encoder,
1021 frame.view(),
1022 over_view,
1023 [rx, ry, rw, rh],
1024 (vw, vh),
1025 );
1026 any_gpu = true;
1027 }
1028 }
1029 // Composición alpha del overlay SOBRE la intermedia (que ya
1030 // tiene UI + video). Último pase del encoder → corre después
1031 // del blit del video. Garantiza menús por encima del video.
1032 if composite_overlay {
1033 state.overlay_compositor.composite(
1034 &state.hal.device,
1035 &state.hal.queue,
1036 &mut gpu_encoder,
1037 frame.view(),
1038 frame.overlay_view(),
1039 );
1040 any_gpu = true;
1041 }
1042 let m_submit_0 = super::perf::mark(pf);
1043 if any_gpu {
1044 state
1045 .hal
1046 .queue
1047 .submit(std::iter::once(gpu_encoder.finish()));
1048 }
1049 let m_gpu = super::perf::mark(pf);
1050 let submit_us = super::perf::us(m_submit_0, m_gpu);
1051 super::timing_primer_present();
1052 super::aplicar_fullscreen_inicial::<A>(&state.window);
1053 super::notificar_present(&state.window);
1054 state.surface.present(frame, &state.hal);
1055 let m_present = super::perf::mark(pf);
1056 // Se vacía ANTES de sellar el fin: lo que se cronometre de acá en adelante
1057 // pertenece al hueco del cuadro SIGUIENTE, no al que se está por reportar.
1058 let otros_us = super::perf::tomar_otros(pf);
1059 super::perf::sellar_fin(pf);
1060 let a_end = super::perf::alloc_mark(pf);
1061 // Bloque 21 — emití la muestra del frame (camino completo). `mounted`
1062 // sigue vivo (se mueve recién a `last_render`, abajo). El camino
1063 // `cache_hit` no llega aquí: no se muestrea a propósito.
1064 if pf {
1065 super::perf::submit(super::perf::FrameSample {
1066 view_us: super::perf::us(m_start, m_view),
1067 mount_us: super::perf::us(m_view, m_mount),
1068 layout_us: super::perf::us(m_mount, m_layout),
1069 paint_us: super::perf::us(m_layout, m_paint),
1070 raster_us: super::perf::us(m_paint, m_raster),
1071 gpu_us: super::perf::us(m_raster, m_gpu),
1072 over_paint_us,
1073 over_tex_us,
1074 over_raster_us,
1075 over_cache_hit,
1076 frame_salteado: saltear,
1077 submit_us,
1078 present_us: super::perf::us(m_gpu, m_present),
1079 acquire_us,
1080 entre_us,
1081 otros_us,
1082 total_us: super::perf::us(m_start, m_present),
1083 n_nodes: mounted.nodes.len(),
1084 boundary_hits: state.repaint_cache.hits,
1085 boundary_misses: state.repaint_cache.misses,
1086 layout_reused,
1087 mount_recycled: hubo_reciclado,
1088 layout_recycled: hubo_reciclado_layout,
1089 allocs: super::perf::allocs(a_start, a_end),
1090 allocs_view: super::perf::allocs(a_start, a_view),
1091 allocs_mount: super::perf::allocs(a_view, a_mount),
1092 allocs_layout: super::perf::allocs(a_mount, a_layout),
1093 allocs_paint: super::perf::allocs(a_layout, a_paint),
1094 alloc_bytes: super::perf::alloc_bytes(a_start, a_end),
1095 alloc_bytes_view: super::perf::alloc_bytes(a_start, a_view),
1096 alloc_bytes_mount: super::perf::alloc_bytes(a_view, a_mount),
1097 alloc_bytes_layout: super::perf::alloc_bytes(a_mount, a_layout),
1098 alloc_bytes_paint: super::perf::alloc_bytes(a_layout, a_paint),
1099 });
1100 }
1101 // Ticker de animaciones implícitas: si quedó alguna en curso,
1102 // pedí el próximo frame. Cuando todas se asientan, `animating`
1103 // queda false y el loop de redraws se detiene solo (sin render
1104 // ocioso, sin spawn_periodic por animación).
1105 //
1106 // El mismo mecanismo sirve el **modo vsync** (`App::wants_vsync_frames`):
1107 // pedir el cuadro siguiente acá, después de `notificar_present`, es lo que
1108 // engancha el bucle al frame-callback del compositor en vez de a un
1109 // temporizador propio. Ver la doc del trait para el porqué.
1110 let vsync_frames = super::modo_vsync_activo()
1111 && A::wants_vsync_frames(state.model.as_ref().expect("model"));
1112 if animating || rippling || vsync_frames {
1113 state.window.request_redraw();
1114 }
1115 state.retained = Some(RetainedScene {
1116 w,
1117 h,
1118 animating,
1119 rippling,
1120 has_overlay: overlay_built.is_some(),
1121 // `|| saltear` — y esto **no** es un detalle. Lo que este campo tiene
1122 // que decir es «la intermedia tiene contenido que NO sale de la escena
1123 // vello», porque de eso depende la retención de cuadro entero de más
1124 // arriba: si cree que no lo hay, re-rasteriza la escena limpiando a
1125 // NEGRO y nunca vuelve a blittear el video.
1126 //
1127 // Un cuadro salteado no corre los painters GPU, así que `any_gpu` queda
1128 // en `false` — pero la intermedia SÍ tiene el video del cuadro anterior.
1129 // Sin este `|| saltear`, el cuadro siguiente entraba por la retención y
1130 // salía negro con sólo la UI encima: **el parpadeo de cuadros negros
1131 // intercalados que reportó el operador el 2026-08-10**. Se salteó
1132 // *porque* hay contenido GPU vivo; decir lo contrario es exactamente la
1133 // mentira que rompe el cuadro siguiente.
1134 has_gpu_paint: any_gpu || saltear,
1135 });
1136 state.last_render = Some(RenderCache {
1137 mounted,
1138 computed,
1139 drop_hover_idx,
1140 overlay: overlay_built,
1141 });
1142 // AccessKit: tras un paint exitoso, empujamos el árbol al
1143 // adapter. `update_if_active` se salta el closure si no hay
1144 // tecnología asistiva escuchando — coste cero en ese caso.
1145 push_a11y_tree::<A>(state);
1146
1147 // Rueda **diferida**: llegaron ruedas mientras `last_render` estaba
1148 // invalidado (un update recién había corrido). Ahora la caché de hit-test
1149 // está fresca — se procesa el delta acumulado contra ella. `process_wheel`
1150 // puede volver a invalidar `last_render` y pedir otro redraw (el ciclo se
1151 // autolimita: sin ruedas nuevas, `pending_wheel` queda en None).
1152 if let Some(wd) = state.pending_wheel.take() {
1153 crate::Runtime::<A>::process_wheel(state, handle, wd);
1154 }
1155}