tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Definiciones

⎇ main llimphi-raster/src/lib.rs Rust · 346 líneas · 14.6 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 ·

1//! llimphi-raster — Brocha Matemática.
2//!
3//! Traduce primitivas vectoriales (líneas, curvas de Bézier, texto) a píxeles y
4//! las pinta sobre un [`llimphi_hal::Frame`]. Punto de entrada: [`Renderer`].
5//!
6//! # Dos backends detrás de la misma puerta
7//!
8//! | feature | rasterizador | cómo |
9//! |---------|--------------|------|
10//! | (default) | `vello` | compute shaders, todo en GPU |
11//! | `hybrid` | `vello_hybrid` vía `llimphi-hybrid` | CPU+GPU, **sin compute** |
12//!
13//! `Renderer` tiene **la misma API en los dos** —`new`, `render`,
14//! `render_to_view`— así que `llimphi-ui` no sabe con cuál está hablando. Lo
15//! único que cambia de cara afuera es el tipo de escena, y para eso está el
16//! alias [`Escena`]: `vello::Scene` en un caso, la grabadora de `llimphi-cpu` en
17//! el otro. Es el mismo truco con el que `llimphi-text` conmuta su `Lienzo`
18//! entre tres backends, y a propósito: quien pinta ya está escrito contra un
19//! alias, no contra un tipo concreto.
20//!
21//! **`hybrid` no es una preferencia de gusto.** Es el único camino a píxeles en
22//! GPUs cuyo compilador de shaders no digiere el compute de vello — el Mali-G57
23//! del Redmi de pruebas pierde el device en el primer frame por Vulkan y aborta
24//! por GLES, y con `hybrid` + `Backends::GL` pinta a 119 fps. La matriz completa
25//! está en `02_ruway/llimphi/android/DIAGNOSTICO.md`.
26//!
27//! Las dos features son **excluyentes** por construcción: eligen escenas de
28//! tipos distintos. Encender `hybrid` obliga además a compilar `llimphi-text` y
29//! `llimphi-compositor` en su perfil `cpu`, que es el que pinta en una `Escena`.
30
31use llimphi_hal::{Frame, Hal};
32pub use vello;
33pub use vello::kurbo;
34pub use vello::peniko;
35#[cfg(feature = "hybrid")]
36pub use llimphi_hybrid;
37#[cfg(feature = "hybrid")]
38pub use llimphi_hybrid::vello_hybrid;
39
40/// El tipo de escena que este rasterizador consume.
41///
42/// Existe para que los consumidores —`llimphi-ui`, sobre todo— no nombren
43/// `vello::Scene` a mano y queden atados a un backend. Ver el encabezado del
44/// crate.
45#[cfg(not(feature = "hybrid"))]
46pub type Escena = vello::Scene;
47
48/// El tipo de escena que este rasterizador consume: la **grabadora** de
49/// `llimphi-cpu`, que anota las órdenes en vez de codificarlas para la GPU.
50///
51/// Expone los mismos métodos que `vello::Scene` con las mismas firmas, así que
52/// quien pinta no nota el cambio.
53#[cfg(feature = "hybrid")]
54pub type Escena = llimphi_hybrid::Escena;
55
56/// Deja dicho, una vez por `Renderer`, **con qué se está pintando de verdad**:
57/// el rasterizador (que viaja compilado en el binario) y el backend de wgpu que
58/// terminó eligiéndose (que se decide al arrancar).
59///
60/// Existe por la Regla 8: los dos datos que hacen falta para leer un cuadro
61/// negro son justo los dos que ningún pantallazo muestra, y en un teléfono no se
62/// pueden preguntar de afuera. Sale por `avisar!`, que en Android va a `logcat`.
63/// El backend se lee del **adapter**, no de lo que se pidió: `PRIMARY` con
64/// fallback puede terminar en GL sin que nadie lo haya nombrado.
65fn anunciar(hal: &Hal, rasterizador: &str) {
66    let info = hal.adapter.get_info();
67    llimphi_hal::avisar!(
68        "llimphi-raster: {rasterizador} sobre {:?} · {} ({:?})",
69        info.backend,
70        info.name,
71        info.device_type
72    );
73}
74
75pub mod gpu;
76pub mod gpu_atlas;
77pub mod huella;
78pub use huella::huella_escena;
79pub use gpu::{
80    units_per_chunk, GpuBatch, GpuPipelines, DISC_INSTANCE_STRIDE, LINE_INSTANCE_STRIDE,
81    QUAD_INSTANCE_STRIDE, RECT_INSTANCE_STRIDE, TRI_VERTEX_STRIDE,
82};
83pub use gpu_atlas::{AtlasTextura, FORMATO_ATLAS};
84
85/// Errores del rasterizador.
86#[derive(Debug)]
87pub enum RasterError {
88    Init(String),
89    Render(String),
90}
91
92impl std::fmt::Display for RasterError {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            Self::Init(s) => write!(f, "vello init: {s}"),
96            Self::Render(s) => write!(f, "vello render: {s}"),
97        }
98    }
99}
100
101impl std::error::Error for RasterError {}
102
103/// Rasterizador vectorial. Una instancia por surface (porque vello cachea
104/// resources contra un `surface_format` específico).
105#[cfg(not(feature = "hybrid"))]
106pub struct Renderer {
107    inner: vello::Renderer,
108    /// Ids de blob que **sabemos subidos** al atlas de imágenes de vello.
109    ///
110    /// Existe por un agujero de vello 0.9 que deja las imágenes en blanco sin
111    /// error ni log — ver [`Renderer::revalidar_atlas`]. Es un `Vec` y no un
112    /// `HashSet` a propósito: son unas pocas decenas de ids (las miniaturas
113    /// visibles), y a ese tamaño el barrido lineal gana y no asigna.
114    subidas: Vec<u64>,
115}
116
117#[cfg(not(feature = "hybrid"))]
118impl Renderer {
119    /// Inicializa el rasterizador. Vello acepta cualquier textura compatible
120    /// (Rgba8Unorm / Bgra8Unorm) en `render`, así que no se fija un formato
121    /// en construcción.
122    ///
123    /// **`antialiasing_support`**: pedimos `area` solamente, no `all()`.
124    /// `area` es el único método que `render()` usa (`AaConfig::Area`
125    /// fijo). Pedir `all()` haría a vello compilar también pipelines
126    /// para `msaa8` y `msaa16` que nunca se invocan — en Mali-G57 eso
127    /// triplica el cold-start (medido: 3.7s vs ~1.2s). Si alguna app
128    /// futura necesita MSAA, agregamos un constructor explícito.
129    ///
130    /// **`num_init_threads: None`**: vello paraleliza la compilación
131    /// de shaders en `None` → todos los CPU cores. Mali-G57 viene en
132    /// SoCs octa-core ARM; con 1 thread tardamos 2.0s, con 8 esperamos
133    /// ~400-600ms. La compilación de shaders es 100% CPU (Rust →
134    /// SPIR-V), el GPU no participa, así que multi-thread escala
135    /// casi linealmente hasta saturar el queue del Naga compiler.
136    pub fn new(hal: &Hal) -> Result<Self, RasterError> {
137        anunciar(hal, "vello (compute)");
138        let inner = vello::Renderer::new(
139            &hal.device,
140            vello::RendererOptions {
141                use_cpu: false,
142                antialiasing_support: vello::AaSupport {
143                    area: true,
144                    msaa8: false,
145                    msaa16: false,
146                },
147                num_init_threads: None,
148                pipeline_cache: None,
149            },
150        )
151        .map_err(|e| RasterError::Init(e.to_string()))?;
152        Ok(Self { inner, subidas: Vec::new() })
153    }
154
155    /// Repara, **antes de rasterizar**, un agujero de vello 0.9 que deja las
156    /// imágenes en blanco sin error, sin warning y sin log.
157    ///
158    /// La mecánica, en la fuente de vello:
159    ///
160    /// 1. `vello_encoding/src/resolve.rs:189` — una escena **sin patches**
161    ///    (ni imágenes, ni gradientes, ni texto) toma un atajo y devuelve
162    ///    `Images::default()`, o sea un atlas de **0×0**.
163    /// 2. `vello/src/render.rs:166` — como ese 0×0 no coincide con el atlas
164    ///    persistente (1024×1024), vello lo trata como un cambio de tamaño y
165    ///    **libera la textura**: `recording.free_image(*proxy)`.
166    /// 3. Su `ImageCache`, en cambio, no se entera de nada: sus residentes
167    ///    siguen marcados `dirty = false`, o sea «ya subidos». En la escena
168    ///    siguiente el atlas se re-crea vacío y **no se sube nada**.
169    ///
170    /// Resultado: toda imagen dibujada después queda transparente y se ve lo
171    /// que haya debajo. En `media` lo de debajo es la placa `rgb(16,20,28)` de
172    /// `tooth_thumb` — las miniaturas negras que sergio reportó cinco veces
173    /// entre el 2026-07-29 y el 2026-08-11.
174    ///
175    /// Por qué le pega a `media` y no a las demás apps: hace falta que en el
176    /// **mismo** `Renderer` se alternen escenas con y sin patches. media pinta
177    /// cinco escenas por cuadro (árbol principal, capa `over` —donde vive el
178    /// panel con las miniaturas—, y tres chicas) y alguna es de puros rellenos.
179    /// Una app de una sola escena no lo toca nunca, y por eso ni la sonda de
180    /// miniaturas ni el test de techo del atlas lo habían visto: los dos
181    /// rasterizaban **una** escena por cuadro.
182    ///
183    /// El arreglo es contarle a vello lo que su propio caché ya no sabe:
184    /// llevamos la lista de blobs que dimos por subidos, la **olvidamos** cuando
185    /// pasa una escena sin patches (que es cuando la textura se libera), y
186    /// volvemos a marcar sucia toda imagen que reaparezca. `dirty` es
187    /// exactamente el bit que decide si se re-sube.
188    fn revalidar_atlas(&mut self, scene: &vello::Scene) {
189        let patches = &scene.encoding().resources.patches;
190        if patches.is_empty() {
191            // Este render va a liberar la textura del atlas: lo que creíamos
192            // subido deja de estarlo.
193            self.subidas.clear();
194            return;
195        }
196        for p in patches {
197            let vello_encoding::Patch::Image { image, .. } = p else {
198                continue;
199            };
200            let id = image.data.id();
201            if !self.subidas.contains(&id) {
202                self.subidas.push(id);
203                // El nombre habla de overrides, pero lo único que hace es poner
204                // `dirty = true` en el residente del atlas — que es justo el bit
205                // que perdimos. Sobre una imagen que vello todavía no conoce es
206                // un no-op.
207                self.inner.mark_override_image_dirty(image);
208            }
209        }
210    }
211
212    /// Renderiza `scene` sobre `frame` limpiando con `base_color`. AA fija
213    /// en area-sampling (precisión Δ < 10⁻⁹ rad del SDD).
214    pub fn render(
215        &mut self,
216        hal: &Hal,
217        scene: &vello::Scene,
218        frame: &Frame,
219        base_color: peniko::Color,
220    ) -> Result<(), RasterError> {
221        let (width, height) = frame.size();
222        self.render_to_view(hal, scene, frame.view(), width, height, base_color)
223    }
224
225    /// Como [`render`](Self::render) pero contra una vista de textura
226    /// explícita (mismo formato/tamaño que la intermedia). Lo usa el
227    /// compositor de overlay de `llimphi-ui` para rasterizar la capa de
228    /// overlay sobre fondo transparente en su propia textura. Ojo:
229    /// `render_to_texture` **limpia** el target con `base_color` y escribe
230    /// todos los píxeles — no compone sobre contenido previo.
231    pub fn render_to_view(
232        &mut self,
233        hal: &Hal,
234        scene: &vello::Scene,
235        view: &llimphi_hal::wgpu::TextureView,
236        width: u32,
237        height: u32,
238        base_color: peniko::Color,
239    ) -> Result<(), RasterError> {
240        self.revalidar_atlas(scene);
241        self.inner
242            .render_to_texture(
243                &hal.device,
244                &hal.queue,
245                scene,
246                view,
247                &vello::RenderParams {
248                    base_color,
249                    width,
250                    height,
251                    antialiasing_method: vello::AaConfig::Area,
252                },
253            )
254            .map_err(|e| RasterError::Render(e.to_string()))
255    }
256}
257
258// =============================================================================
259//  Backend `hybrid` — el mismo Renderer, sin compute shaders
260// -----------------------------------------------------------------------------
261//  Gemelo del de arriba: MISMOS tres métodos, mismas firmas salvo el tipo de
262//  escena (que igual se nombra `Escena` de los dos lados). Quien lo usa no se
263//  entera de cuál compiló.
264//
265//  Las diferencias están todas escondidas acá adentro y son tres:
266//
267//    · vello arma su propio encoder y hace submit por dentro de
268//      `render_to_texture`. `llimphi-hybrid` recibe el encoder, así que este
269//      envoltorio lo crea y lo manda — para que la simetría de la API no sea
270//      una mentira sobre quién sincroniza.
271//    · el destino es SIEMPRE `llimphi_hal::INTERMEDIATE_FORMAT`, no el del
272//      swapchain: el rasterizador nunca ve la textura de presentación (ver el
273//      doc de esa constante). Por eso el pintor se construye una vez y no hay
274//      que recompilar pipelines por formato.
275//    · el tamaño se ajusta por fotograma con `redimensionar`, que sólo recrea
276//      la escena y NO el renderer. Ver el porqué en `llimphi_hybrid::Pintor`.
277// =============================================================================
278
279/// Rasterizador vectorial sobre `vello_hybrid` — CPU+GPU, sin compute shaders.
280#[cfg(feature = "hybrid")]
281pub struct Renderer {
282    pintor: llimphi_hybrid::Pintor,
283}
284
285#[cfg(feature = "hybrid")]
286impl Renderer {
287    /// Inicializa el rasterizador.
288    ///
289    /// Arranca en 1x1 porque el tamaño real recién se conoce en el primer
290    /// fotograma, y eso no cuesta nada: redimensionar no recompila pipelines
291    /// (sólo recrea la escena, que es CPU). Lo caro —compilar los shaders del
292    /// driver— pasa **acá**, una sola vez, que es exactamente lo que se quiere
293    /// en una GPU móvil.
294    pub fn new(hal: &Hal) -> Result<Self, RasterError> {
295        anunciar(hal, "vello_hybrid (sin compute)");
296        Ok(Self {
297            pintor: llimphi_hybrid::Pintor::new(
298                &hal.device,
299                // El backend real, no el que se pidió: `Backends::PRIMARY` con
300                // fallback puede terminar en GL sin que nadie lo declare, y de
301                // eso depende la config del atlas de imágenes.
302                hal.adapter.get_info().backend,
303                llimphi_hal::INTERMEDIATE_FORMAT,
304                1,
305                1,
306            ),
307        })
308    }
309
310    /// Renderiza `escena` sobre `frame` limpiando con `base_color`.
311    pub fn render(
312        &mut self,
313        hal: &Hal,
314        escena: &Escena,
315        frame: &Frame,
316        base_color: peniko::Color,
317    ) -> Result<(), RasterError> {
318        let (width, height) = frame.size();
319        self.render_to_view(hal, escena, frame.view(), width, height, base_color)
320    }
321
322    /// Como [`render`](Self::render) pero contra una vista de textura explícita.
323    /// Limpia con `base_color` y compone la escena encima — igual que el camino
324    /// vello, incluido el caso `TRANSPARENT` del overlay.
325    pub fn render_to_view(
326        &mut self,
327        hal: &Hal,
328        escena: &Escena,
329        view: &llimphi_hal::wgpu::TextureView,
330        width: u32,
331        height: u32,
332        base_color: peniko::Color,
333    ) -> Result<(), RasterError> {
334        self.pintor.redimensionar(width, height);
335        let mut encoder =
336            hal.device
337                .create_command_encoder(&llimphi_hal::wgpu::CommandEncoderDescriptor {
338                    label: Some("llimphi-raster-hybrid"),
339                });
340        self.pintor
341            .pintar(escena, &hal.device, &hal.queue, &mut encoder, view, base_color)
342            .map_err(|e| RasterError::Render(format!("{e:?}")))?;
343        hal.queue.submit(core::iter::once(encoder.finish()));
344        Ok(())
345    }
346}