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//! 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}