tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1// =============================================================================
2// pintor :: reproducir una `Escena` grabada sobre `vello_hybrid`
3// -----------------------------------------------------------------------------
4// Hermano del `llimphi_cpu::pintor`, y a propósito casi renglón por renglón: la
5// `Escena` guarda órdenes autocontenidas —cada una trae su transform, su pincel
6// y su forma— y la `Scene` de hybrid tiene ESTADO, así que reproducir es, para
7// cada orden, fijar el estado que declara y disparar la primitiva.
8//
9// Igual que allá, no se intenta minimizar los `set_*` comparando con el estado
10// anterior. Sigue siendo prematuro: escribir un `Affine` en un struct no se ve
11// al lado de rasterizar el path que viene atrás.
12//
13// Lo único que NO es igual son las imágenes, y la diferencia es estructural:
14// hybrid las quiere en un atlas de la GPU, y subirlas necesita `device`/`queue`/
15// `encoder`. De ahí las dos pasadas de [`Pintor::pintar`].
16// =============================================================================
17
18use std::collections::HashMap;
19use std::sync::Arc;
20
21use llimphi_cpu::escena::{Escena, Forma, Orden};
22use llimphi_cpu::kurbo::{Affine, Rect};
23use llimphi_cpu::peniko::{Brush, Color, Fill, ImageData};
24use vello_common::paint::{Image, ImageId, ImageSource};
25use vello_common::pixmap::Pixmap;
26
27pub use vello_hybrid::RenderError;
28
29/// El rasterizador: una `Scene` de vello_hybrid con su `Renderer` y su atlas,
30/// lista para recibir escenas una y otra vez.
31///
32/// Se sostiene entre fotogramas a propósito, y acá importa más que en el pintor
33/// CPU: el `Renderer` es dueño del **atlas de texturas**, así que recrearlo
34/// tiraría todas las imágenes subidas y las obligaría a volver a cruzar el bus.
35pub struct Pintor {
36 renderer: vello_hybrid::Renderer,
37 recursos: vello_hybrid::Resources,
38 /// La escena de hybrid, reusada entre frames. `reset()` conserva los búferes
39 /// internos; recrearla por frame sería regalarle trabajo al asignador.
40 gpu: vello_hybrid::Scene,
41 /// Imágenes ya subidas al atlas, por `Blob::id()` de peniko.
42 ///
43 /// La clave es la identidad del blob y no su contenido: peniko ya garantiza
44 /// que dos `Blob` con el mismo id son el mismo buffer, y hashear los bytes
45 /// de cada imagen en cada frame sería exactamente el costo que este caché
46 /// existe para evitar.
47 imagenes: HashMap<u64, ImageId>,
48 ancho: u32,
49 alto: u32,
50}
51
52impl Pintor {
53 /// Un pintor que rinde a un destino de `ancho` x `alto` en `formato`.
54 ///
55 /// `formato` tiene que ser el de la textura contra la que se va a rendir —el
56 /// del swapchain, normalmente—, porque el `Renderer` compila sus pipelines
57 /// contra él en este mismo llamado. En el Mali eso es justamente lo que hay
58 /// que sobrevivir: es acá donde el driver decide si el proceso sigue vivo.
59 pub fn new(
60 device: &wgpu::Device,
61 backend: wgpu::Backend,
62 formato: wgpu::TextureFormat,
63 ancho: u32,
64 alto: u32,
65 ) -> Self {
66 let (ancho, alto) = (ancho.max(1), alto.max(1));
67 Self {
68 renderer: vello_hybrid::Renderer::new_with(
69 device,
70 &vello_hybrid::RenderTargetConfig {
71 format: formato,
72 width: ancho,
73 height: alto,
74 },
75 ajustes_para(backend),
76 ),
77 recursos: vello_hybrid::Resources::new(),
78 gpu: vello_hybrid::Scene::new(dim(ancho), dim(alto)),
79 imagenes: HashMap::new(),
80 ancho,
81 alto,
82 }
83 }
84
85 /// Ancho del destino en píxeles.
86 pub fn ancho(&self) -> u32 {
87 self.ancho
88 }
89
90 /// Alto del destino en píxeles.
91 pub fn alto(&self) -> u32 {
92 self.alto
93 }
94
95 /// Ajusta el pintor a un destino de otro tamaño.
96 ///
97 /// **No recrea el `Renderer`, y eso es lo importante.** El `width`/`height`
98 /// del `RenderTargetConfig` sólo siembra un buffer de configuración al
99 /// construirlo; el tamaño de cada fotograma viaja aparte, en el `RenderSize`
100 /// que recibe `render()`. Así que redimensionar es recrear la `Scene` —CPU
101 /// pura— y nada más.
102 ///
103 /// Recrear el `Renderer` en cambio **recompilaría los pipelines**, que es
104 /// justo la operación que en el Mali del teléfono mata el proceso
105 /// (`cmpbe_v2_compile_multiple_shaders`, ver `../android/DIAGNOSTICO.md`).
106 /// Un rotar la pantalla no puede costar eso. De paso conserva el atlas de
107 /// imágenes, que si no habría que volver a subir entero por el bus.
108 ///
109 /// Sin cambio real no toca nada. La guarda no es higiene: reconfigurar por
110 /// gusto fue el bug de `WinitSurface::resize` que se llevó una noche entera
111 /// (41.335 reconfiguraciones en 13 s).
112 pub fn redimensionar(&mut self, ancho: u32, alto: u32) {
113 let (ancho, alto) = (ancho.max(1), alto.max(1));
114 if (ancho, alto) == (self.ancho, self.alto) {
115 return;
116 }
117 self.ancho = ancho;
118 self.alto = alto;
119 self.gpu = vello_hybrid::Scene::new(dim(ancho), dim(alto));
120 }
121
122 /// Reproduce la escena y la rinde sobre `vista`.
123 ///
124 /// Va en dos pasadas y no es un rodeo: la primera sube al atlas las imágenes
125 /// que aún no están —lo único que necesita `device`/`queue`/`encoder`— y la
126 /// segunda reproduce contra la `Scene`, que es CPU pura. Mezclarlas obligaría
127 /// a llevar los tres prestados por todo el reproductor para un caso que en
128 /// una UI se da un puñado de veces por frame y casi siempre pega en el caché.
129 ///
130 /// `vista` tiene que ser del formato y tamaño con los que se construyó el
131 /// pintor.
132 pub fn pintar(
133 &mut self,
134 escena: &Escena,
135 device: &wgpu::Device,
136 queue: &wgpu::Queue,
137 encoder: &mut wgpu::CommandEncoder,
138 vista: &wgpu::TextureView,
139 color_base: Color,
140 ) -> Result<(), RenderError> {
141 self.subir_imagenes(escena, device, queue, encoder);
142
143 self.gpu.reset();
144 self.pintar_base(color_base);
145 for orden in escena.ordenes() {
146 self.ejecutar(orden);
147 }
148
149 self.renderer.render(
150 &self.gpu,
151 &mut self.recursos,
152 device,
153 queue,
154 encoder,
155 &vello_hybrid::RenderSize {
156 width: self.ancho,
157 height: self.alto,
158 },
159 vista,
160 &vello_hybrid::TextureBindings::new(),
161 )
162 }
163
164 // -------------------------------------------------------------------------
165
166 /// Pinta `color_base` a sangre como primera orden de la escena.
167 ///
168 /// **Acá hubo un error mío que costó medio diagnóstico, y queda escrito para
169 /// que nadie lo rehaga.** La versión anterior hacía esto con un pase de
170 /// `wgpu` propio, sólo por su `LoadOp::Clear`, sobre esta premisa: que
171 /// `vello::Renderer` limpia su target con el `base_color` de los
172 /// `RenderParams` y el de `vello_hybrid` entra con `LoadOp::Load`, o sea
173 /// componiendo sobre lo que ya había.
174 ///
175 /// **La premisa es falsa.** `vello_hybrid` limpia el attachment por su
176 /// cuenta, así que pisaba el clear y el `color_base` **no llegaba nunca a
177 /// la pantalla**: donde la escena no pintaba quedaba negro, no el color
178 /// pedido. Y era invisible en la práctica porque el caso normal es que el
179 /// `color_base` coincida con el fondo que igual pinta la vista raíz — los
180 /// dos se ven idénticos hasta que se los hace distintos a propósito.
181 /// `el_color_base_pinta_donde_la_escena_no_llega` los hace distintos.
182 ///
183 /// El corolario también se cae: la objeción de que «un relleno transparente
184 /// no escribe nada y el contenido viejo sobrevive» no aplica, porque el
185 /// contenido viejo ya no sobrevive — lo borra hybrid. Por eso con alfa cero
186 /// no hay nada que hacer y esto se saltea.
187 fn pintar_base(&mut self, color: Color) {
188 if color.components[3] <= 0.0 {
189 return;
190 }
191 self.gpu.set_transform(Affine::IDENTITY);
192 self.gpu.reset_paint_transform();
193 self.gpu.set_paint(color);
194 self.gpu.fill_rect(&Rect::new(
195 0.0,
196 0.0,
197 f64::from(self.ancho),
198 f64::from(self.alto),
199 ));
200 }
201
202 /// Sube al atlas toda imagen de la escena que todavía no esté.
203 ///
204 /// Recorre tanto `draw_image` como los pinceles-imagen de `fill`/`stroke`:
205 /// son dos caminos distintos hasta la misma textura, y olvidar el segundo
206 /// dejaría el degradado-imagen dibujando transparente sin decir por qué.
207 fn subir_imagenes(
208 &mut self,
209 escena: &Escena,
210 device: &wgpu::Device,
211 queue: &wgpu::Queue,
212 encoder: &mut wgpu::CommandEncoder,
213 ) {
214 for orden in escena.ordenes() {
215 let datos = match orden {
216 Orden::Imagen { imagen, .. } => Some(&imagen.image),
217 Orden::Rellenar { pincel, .. } | Orden::Trazar { pincel, .. } => match pincel {
218 Brush::Image(img) => Some(&img.image),
219 _ => None,
220 },
221 _ => None,
222 };
223 let Some(datos) = datos else { continue };
224 if self.imagenes.contains_key(&datos.data.id()) {
225 continue;
226 }
227 let Some(pixmap) = pixmap_de(datos) else { continue };
228 let id = self
229 .renderer
230 .upload_image(&mut self.recursos, device, queue, encoder, &pixmap);
231 self.imagenes.insert(datos.data.id(), id);
232 }
233 }
234
235 fn ejecutar(&mut self, orden: &Orden) {
236 match orden {
237 Orden::Rellenar {
238 regla,
239 xf,
240 pincel,
241 xf_pincel,
242 forma,
243 } => {
244 self.gpu.set_fill_rule(*regla);
245 self.gpu.set_transform(*xf);
246 fijar_pincel(&mut self.gpu, &self.imagenes, pincel, *xf_pincel);
247 match forma {
248 Forma::Rect(r) => self.gpu.fill_rect(r),
249 otra => self.gpu.fill_path(&otra.a_camino()),
250 }
251 }
252 Orden::Trazar {
253 estilo,
254 xf,
255 pincel,
256 xf_pincel,
257 forma,
258 } => {
259 self.gpu.set_stroke(estilo.clone());
260 self.gpu.set_transform(*xf);
261 fijar_pincel(&mut self.gpu, &self.imagenes, pincel, *xf_pincel);
262 match forma {
263 Forma::Rect(r) => self.gpu.stroke_rect(r),
264 otra => self.gpu.stroke_path(&otra.a_camino()),
265 }
266 }
267 Orden::AbrirCapa {
268 regla,
269 mezcla,
270 alfa,
271 xf,
272 recorte,
273 } => {
274 self.gpu.set_fill_rule(*regla);
275 self.gpu.set_transform(*xf);
276 self.gpu.push_layer(
277 Some(&recorte.a_camino()),
278 Some(*mezcla),
279 Some(*alfa),
280 None,
281 None,
282 );
283 }
284 Orden::AbrirMascaraLuminancia {
285 regla,
286 alfa,
287 xf,
288 recorte,
289 } => self.abrir_mascara_luminancia(*regla, *alfa, *xf, recorte),
290 Orden::CerrarCapa => self.gpu.pop_layer(),
291 Orden::Imagen { imagen, xf } => {
292 // Si la imagen no llegó al atlas —blob mal formado, o más grande
293 // de lo que el atlas admite— se saltea el dibujo en vez de pintar
294 // un rectángulo de basura o abortar el fotograma.
295 let Some(id) = self.imagenes.get(&imagen.image.data.id()).copied() else {
296 return;
297 };
298 let (w, h) = (
299 f64::from(imagen.image.width),
300 f64::from(imagen.image.height),
301 );
302 self.gpu.set_transform(*xf);
303 self.gpu.set_paint(Image {
304 image: ImageSource::opaque_id(id),
305 sampler: imagen.sampler,
306 });
307 self.gpu.reset_paint_transform();
308 self.gpu.set_fill_rule(Fill::NonZero);
309 self.gpu.fill_rect(&Rect::new(0.0, 0.0, w, h));
310 }
311 Orden::Glifos {
312 fuente,
313 tamano,
314 xf,
315 pincel,
316 hint,
317 glifos,
318 } => {
319 // El pincel y el transform se fijan ANTES de abrir el builder:
320 // `glyph_run` presta la escena hasta que se consume.
321 self.gpu.set_transform(*xf);
322 fijar_pincel(&mut self.gpu, &self.imagenes, pincel, None);
323 self.gpu
324 .glyph_run(&mut self.recursos, fuente)
325 .font_size(*tamano)
326 .hint(*hint)
327 .fill_glyphs(glifos.iter().map(|g| glifo::Glyph {
328 id: g.id,
329 x: g.x,
330 y: g.y,
331 }));
332 }
333 Orden::RectDifuso {
334 xf,
335 rect,
336 color,
337 radio,
338 desvio,
339 } => {
340 self.gpu.set_transform(*xf);
341 self.gpu.set_paint(*color);
342 self.gpu.reset_paint_transform();
343 self.gpu
344 .fill_blurred_rounded_rect(rect, *radio as f32, *desvio as f32);
345 }
346 }
347 }
348
349 /// Abre una capa enmascarada por luminancia.
350 ///
351 /// **vello_hybrid no soporta máscaras**: su `push_layer` lleva un
352 /// `unimplemented!()` literal cuando el parámetro `mask` viene con algo
353 /// (`scene.rs:921`, «Mask is unsupported»). Así que hay que dar la vuelta —y
354 /// la vuelta resulta ser exacta, no una aproximación.
355 ///
356 /// El razonamiento: la máscara que este método recibe no es una imagen
357 /// arbitraria, es **una forma**. El pintor CPU la materializa rasterizando
358 /// esa forma en blanco sobre negro y tomando la luminancia; eso da 1 adentro,
359 /// 0 afuera y cobertura parcial en el borde antialiaseado. Que es, dato por
360 /// dato, lo mismo que produce un recorte por esa misma forma. Se implementa
361 /// entonces como capa recortada, sin lienzo extra ni rasterizado en CPU.
362 ///
363 /// La equivalencia depende de que la máscara SIEMPRE nazca de una forma
364 /// blanca — hoy garantizado porque el único constructor es
365 /// `Escena::push_luminance_mask_layer`, que recibe un `impl Shape`. Si algún
366 /// día la `Escena` admite enmascarar con un degradado o una imagen, esto deja
367 /// de valer y hay que volver a la máscara de verdad.
368 fn abrir_mascara_luminancia(&mut self, regla: Fill, alfa: f32, xf: Affine, recorte: &Forma) {
369 self.gpu.set_fill_rule(regla);
370 self.gpu.set_transform(xf);
371 self.gpu
372 .push_layer(Some(&recorte.a_camino()), None, Some(alfa), None, None);
373 }
374}
375
376/// Traduce un pincel de peniko al `PaintType` de vello_common.
377///
378/// Sólido y degradado son el mismo tipo de un lado y del otro. La variante
379/// imagen no: peniko la lleva como bytes crudos y hybrid como un handle al
380/// atlas, así que se resuelve por el caché que llenó `subir_imagenes`. Si no
381/// está —imagen mal formada—, se cae a transparente en vez de abortar el frame.
382fn fijar_pincel(
383 gpu: &mut vello_hybrid::Scene,
384 imagenes: &HashMap<u64, ImageId>,
385 pincel: &Brush,
386 xf_pincel: Option<Affine>,
387) {
388 // La transformación del pincel es la que ancla un degradado a la caja que lo
389 // lleva en vez de al origen del mundo. vello la compone como
390 // `transform * brush_transform`, y hybrid hace lo mismo con su
391 // `paint_transform`, así que el mapeo es directo.
392 match xf_pincel {
393 Some(t) => gpu.set_paint_transform(t),
394 None => gpu.reset_paint_transform(),
395 }
396 match pincel {
397 Brush::Solid(c) => gpu.set_paint(*c),
398 Brush::Gradient(g) => gpu.set_paint(g.clone()),
399 Brush::Image(img) => match imagenes.get(&img.image.data.id()).copied() {
400 Some(id) => gpu.set_paint(Image {
401 image: ImageSource::opaque_id(id),
402 sampler: img.sampler,
403 }),
404 None => gpu.set_paint(llimphi_cpu::peniko::color::AlphaColor::<
405 llimphi_cpu::peniko::color::Srgb,
406 >::TRANSPARENT),
407 },
408 }
409}
410
411/// Los ajustes del renderer para un backend dado.
412///
413/// **Existe por un bug real, no por gusto.** El backend GLES de wgpu decide por
414/// *heurística* si una textura es `D2` o `D2Array`: cuando
415/// `depth_or_array_layers == 1` la trata como `D2` **aunque se haya creado como
416/// array**. El atlas de imágenes de vello_hybrid es un array, así que con una
417/// sola capa el muestreo queda desalineado y **las imágenes no se dibujan** —
418/// sin error de validación, sin warning, sin nada: salen transparentes y el
419/// síntoma es una UI sin iconos.
420///
421/// vello_hybrid conoce el problema y lo esquiva forzando dos capas, pero sólo
422/// bajo `#[cfg(target_arch = "wasm32")]`, porque asume que GLES es sinónimo de
423/// WebGL (su propio TODO se pregunta cómo detectar el backend en vez del
424/// target). En Android GLES es **nativo**, así que caemos del lado no cubierto.
425///
426/// Por eso el backend es un parámetro **obligatorio** de [`Pintor::new`] y no un
427/// `Default`: olvidarlo no rompe el build ni los tests en una máquina con
428/// Vulkan — se cobra callado, y sólo en el teléfono.
429/// **Lo que cuesta, medido, y una optimización PROBADA Y DESCARTADA.** El atlas
430/// default es de 4096x4096 por capa, o sea 67 MB de memoria GPU por capa;
431/// forzar la segunda llevó el `GL mtrack` del demo Android de 219 a 401 MB y los
432/// fps de 119 a 57, en un equipo donde el lowmemorykiller ya había matado
433/// procesos ajenos. Parece obvio bajar `atlas_size` a 2048 —dos capas sumarían
434/// 33 MB, menos que la única capa de antes—, y **no funciona**: probado en el
435/// teléfono, la imagen deja de dibujarse otra vez (0 px) y los fps caen a 8. Sin
436/// entender por qué, no se toca; el tamaño se deja en el default.
437fn ajustes_para(backend: wgpu::Backend) -> vello_hybrid::RenderSettings {
438 let mut ajustes = vello_hybrid::RenderSettings::default();
439 if backend == wgpu::Backend::Gl {
440 ajustes.atlas_config.initial_atlas_count =
441 ajustes.atlas_config.initial_atlas_count.max(2);
442 }
443 ajustes
444}
445
446/// Los límites con los que hay que pedir el `Device` para que vello_hybrid
447/// rasterice bien. **Usala siempre en vez de `Limits::default().using_resolution(...)`.**
448///
449/// `vello_hybrid` dimensiona su textura de alfas —donde vive la cobertura
450/// parcial de todo camino— con `max_texture_dimension_2d` de ancho, y la
451/// direcciona **por máscara y corrimiento**: el shader hace
452/// `tex_x = i & (ancho - 1)` y `tex_y = i >> alphas_tex_width_bits`, con
453/// `alphas_tex_width_bits = ancho.trailing_zeros()`. Las dos cuentas suponen que
454/// el ancho es **potencia de dos**, y nadie lo verifica.
455///
456/// El Mali-G57 del Redmi reporta **16383**. `16383.trailing_zeros()` es **0**,
457/// así que `tex_y = i`: cada texel se busca en una fila muy afuera de la textura
458/// y GLES devuelve ceros. Resultado: alfa 0 en toda tira de cobertura parcial, o
459/// sea **cero antialiasing** y cada forma perdiendo su primer tile de 4 px. Sin
460/// error de validación y sin warning — la escena se ve, sólo que cuantizada a
461/// bloques de 4x4 y con los bordes comidos.
462///
463/// Redondear el techo hacia abajo a la potencia de dos anterior lo arregla
464/// entero, medido en metal: el círculo de la regla pasó de 0 a 1.660 píxeles
465/// antialiaseados y los caminos volvieron a caer en la columna exacta que se
466/// pidió, sin costo de fps (119 antes y después). Ver
467/// `02_ruway/llimphi/android/DIAGNOSTICO.md` §F8.
468///
469/// En una GPU que ya reporta una potencia de dos —o sea, todas las de
470/// escritorio— esto no cambia nada.
471pub fn limites_seguros(adapter: &wgpu::Adapter) -> wgpu::Limits {
472 let mut limites = wgpu::Limits::default().using_resolution(adapter.limits());
473 limites.max_texture_dimension_2d = potencia_de_dos_hacia_abajo(limites.max_texture_dimension_2d);
474 limites
475}
476
477/// La mayor potencia de dos que no pasa a `v`. Con `v == 0` devuelve 1: pedir un
478/// techo de cero texeles no es una opción representable, y saturar hacia arriba
479/// es menos malo que devolver algo que hace `trailing_zeros() == 32`.
480pub(crate) fn potencia_de_dos_hacia_abajo(v: u32) -> u32 {
481 if v == 0 {
482 return 1;
483 }
484 1 << (u32::BITS - 1 - v.leading_zeros())
485}
486
487/// Los pixmaps de vello_common se miden en `u16`. Saturar en vez de truncar:
488/// una pantalla de más de 65.535 px no existe, pero un `as u16` a la ligera
489/// convertiría un ancho absurdo en uno chiquito y el bug saldría como «se
490/// dibuja en una esquina», que es de los caros de diagnosticar.
491fn dim(v: u32) -> u16 {
492 v.clamp(1, u32::from(u16::MAX)) as u16
493}
494
495/// Convierte los bytes de una imagen de peniko en un `Pixmap` premultiplicado,
496/// que es lo que el atlas sabe recibir.
497///
498/// Se apoya en `ImageSource::from_peniko_image_data`, que es el conversor de la
499/// propia vello_common —premultiplica y reordena canales—, en vez de repetirlo
500/// acá. Lo único que agrega esta función es la guarda de tamaño: ese conversor
501/// **paniquea** si la imagen pasa de `u16::MAX` en algún lado, y un blob raro no
502/// debe tumbar el proceso.
503fn pixmap_de(datos: &ImageData) -> Option<Arc<Pixmap>> {
504 if datos.width > u32::from(u16::MAX) || datos.height > u32::from(u16::MAX) {
505 return None;
506 }
507 if datos.width == 0 || datos.height == 0 {
508 return None;
509 }
510 match ImageSource::from_peniko_image_data(datos) {
511 ImageSource::Pixmap(p) => Some(p),
512 ImageSource::OpaqueId { .. } => None,
513 }
514}