tawasuyu

tawasuyu/llimphi

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

Rust 238TOML 102Markdown 83Shell 24178 Sutichaykuna

⎇ main llimphi-ui/examples/stress_idle.rs Rust · 197 siq'ikuna · 7.0 KB Ñawpa willay Pi imata llamkarqan ch'uya

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

1//! stress_idle — **medir antes de tocar** (Iniciativa #1 de `PLAN-MOTOR-PROFUNDO.md`,
2//! tarea 1). El caso que justificaría las damage regions, aislado:
3//!
4//!   - un **fondo estático grande** que ocupa toda la ventana y está envuelto en
5//!     `.repaint_boundary(1)`, así el `paint` (walk + encode en CPU) ya está
6//!     resuelto por el Bloque 23 y no ensucia la medición;
7//!   - un **nodo chico que se mueve** cada frame — el spinner/cursor/badge de una
8//!     app quieta. Es lo único que cambia de píxeles.
9//!
10//! Lo que queda arriba de la mesa, entonces, es exactamente lo que el damage
11//! atacaría: **`raster` + `present` de la ventana entera para repintar un
12//! cuadradito**. El ejemplo imprime al arrancar qué fracción del área se mueve
13//! de verdad — el techo teórico del ahorro.
14//!
15//! ```bash
16//! LLIMPHI_PERF=1 cargo run -p llimphi-ui --example stress_idle --release
17//! # el nodo animado más chico o más grande (por lado, en px):
18//! LLIMPHI_PERF=1 cargo run -p llimphi-ui --example stress_idle --release -- --sprite 16
19//! ```
20//!
21//! **El número que importa es en metal.** Acá (llvmpipe / raster por software)
22//! `raster` mide el costo de una CPU rasterizando, no el de una GPU: sirve para
23//! ver que el ejemplo hace lo que dice, no para decidir. La corrida que decide
24//! está en el runbook `[METAL]` de `PLAN-MOTOR-PROFUNDO.md`.
25//!
26//! Y el aviso honesto, para que nadie lea de más: aunque el damage se
27//! implementara, **`vello` re-rasteriza la escena entera** (no expone dirty
28//! rect), así que el ahorro sólo aparecería en canvases GPU-directos densos. Si
29//! esta medición en metal dice que `raster + present` es una fracción chica del
30//! frame, la iniciativa se cierra como "bloqueada por upstream" en vez de
31//! forzarse — que es lo que el plan mismo pide.
32
33use llimphi_ui::llimphi_layout::taffy::prelude::*;
34use llimphi_ui::llimphi_raster::peniko::Color;
35use llimphi_ui::{App, Handle, View};
36use std::time::Duration;
37
38#[derive(Clone)]
39enum Msg {
40    Tick,
41}
42
43struct Idle;
44
45struct Model {
46    frame: u64,
47    /// Lado del nodo animado, en px.
48    sprite: f32,
49    quit_after: u64,
50    renders: std::cell::Cell<u64>,
51}
52
53fn arg_u64(name: &str, default: u64) -> u64 {
54    let mut args = std::env::args();
55    while let Some(a) = args.next() {
56        if a == name {
57            if let Some(v) = args.next() {
58                if let Ok(n) = v.parse::<u64>() {
59                    return n;
60                }
61            }
62        }
63    }
64    default
65}
66
67/// El fondo: una grilla de celdas de color fijo que llena la ventana y **no
68/// cambia nunca**. Va dentro de un `repaint_boundary` para que su encode se
69/// cachee tras el primer frame — si no, el `paint` se comería la medición y el
70/// ejemplo estaría midiendo el Bloque 23 en vez del raster.
71fn fondo_estatico() -> View<Msg> {
72    const FILAS: usize = 24;
73    const COLS: usize = 40;
74    let mut filas = Vec::with_capacity(FILAS);
75    for r in 0..FILAS {
76        let mut celdas = Vec::with_capacity(COLS);
77        for c in 0..COLS {
78            // Degradé diagonal reproducible: nada de aleatorio, para que dos
79            // corridas sean comparables byte a byte.
80            let t = ((r * COLS + c) % 255) as u8;
81            celdas.push(
82                View::new(Style {
83                    flex_grow: 1.0,
84                    size: Size {
85                        width: percent(1.0_f32 / COLS as f32),
86                        height: percent(1.0_f32),
87                    },
88                    ..Default::default()
89                })
90                .fill(Color::from_rgba8(20 + t / 8, 24 + t / 6, 40 + t / 4, 255)),
91            );
92        }
93        filas.push(
94            View::new(Style {
95                flex_direction: FlexDirection::Row,
96                flex_grow: 1.0,
97                size: Size {
98                    width: percent(1.0_f32),
99                    height: percent(1.0_f32 / FILAS as f32),
100                },
101                ..Default::default()
102            })
103            .children(celdas),
104        );
105    }
106    View::new(Style {
107        flex_direction: FlexDirection::Column,
108        size: Size {
109            width: percent(1.0_f32),
110            height: percent(1.0_f32),
111        },
112        ..Default::default()
113    })
114    .children(filas)
115    .repaint_boundary(1)
116}
117
118impl App for Idle {
119    type Model = Model;
120    type Msg = Msg;
121
122    fn title() -> &'static str {
123        "llimphi · stress_idle"
124    }
125
126    fn init(handle: &Handle<Self::Msg>) -> Self::Model {
127        let sprite = arg_u64("--sprite", 48).max(1) as f32;
128        let quit_after = arg_u64("--frames", 240);
129        handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
130        // El techo del damage, dicho antes de medir nada: contra una ventana de
131        // 1280×720 (el tamaño con el que arranca el runtime), qué porción se
132        // mueve. Es una cota superior — el rect sucio real incluye el sitio
133        // viejo y el nuevo del sprite.
134        let area = (sprite * sprite) / (1280.0 * 720.0) * 100.0;
135        eprintln!(
136            "[stress_idle] sprite={sprite}px — se mueve {area:.3} % del área \
137             (a 1280×720). Todo lo demás es fondo estático cacheado. \
138             LLIMPHI_PERF=1 para el desglose; mirá `raster` y `present`."
139        );
140        Model {
141            frame: 0,
142            sprite,
143            quit_after,
144            renders: std::cell::Cell::new(0),
145        }
146    }
147
148    fn update(mut model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model {
149        match msg {
150            Msg::Tick => {
151                model.frame = model.frame.wrapping_add(1);
152                if model.quit_after != 0 && model.renders.get() >= model.quit_after {
153                    handle.quit();
154                }
155            }
156        }
157        model
158    }
159
160    fn view(model: &Self::Model) -> View<Self::Msg> {
161        model.renders.set(model.renders.get() + 1);
162        // El único píxel que cambia: un cuadrado que recorre la ventana en
163        // diagonal. `absolute` para que no reflowee el fondo (si moviera el
164        // layout, la medición dejaría de ser "sólo raster").
165        let t = (model.frame % 200) as f32 / 200.0;
166        let sprite = View::new(Style {
167            position: Position::Absolute,
168            inset: Rect {
169                left: percent(0.05_f32 + t * 0.85),
170                top: percent(0.05_f32 + t * 0.85),
171                right: auto(),
172                bottom: auto(),
173            },
174            size: Size {
175                width: length(model.sprite),
176                height: length(model.sprite),
177            },
178            ..Default::default()
179        })
180        .fill(Color::from_rgba8(240, 180, 60, 255))
181        .radius((model.sprite * 0.25) as f64);
182
183        View::new(Style {
184            size: Size {
185                width: percent(1.0_f32),
186                height: percent(1.0_f32),
187            },
188            ..Default::default()
189        })
190        .fill(Color::from_rgba8(16, 18, 24, 255))
191        .children(vec![fondo_estatico(), sprite])
192    }
193}
194
195fn main() {
196    llimphi_ui::run::<Idle>();
197}