// ============================================================================= // pintor :: reproducir una `Escena` grabada sobre `vello_hybrid` // ----------------------------------------------------------------------------- // Hermano del `llimphi_cpu::pintor`, y a propósito casi renglón por renglón: la // `Escena` guarda órdenes autocontenidas —cada una trae su transform, su pincel // y su forma— y la `Scene` de hybrid tiene ESTADO, así que reproducir es, para // cada orden, fijar el estado que declara y disparar la primitiva. // // Igual que allá, no se intenta minimizar los `set_*` comparando con el estado // anterior. Sigue siendo prematuro: escribir un `Affine` en un struct no se ve // al lado de rasterizar el path que viene atrás. // // Lo único que NO es igual son las imágenes, y la diferencia es estructural: // hybrid las quiere en un atlas de la GPU, y subirlas necesita `device`/`queue`/ // `encoder`. De ahí las dos pasadas de [`Pintor::pintar`]. // ============================================================================= use std::collections::HashMap; use std::sync::Arc; use llimphi_cpu::escena::{Escena, Forma, Orden}; use llimphi_cpu::kurbo::{Affine, Rect}; use llimphi_cpu::peniko::{Brush, Color, Fill, ImageData}; use vello_common::paint::{Image, ImageId, ImageSource}; use vello_common::pixmap::Pixmap; pub use vello_hybrid::RenderError; /// El rasterizador: una `Scene` de vello_hybrid con su `Renderer` y su atlas, /// lista para recibir escenas una y otra vez. /// /// Se sostiene entre fotogramas a propósito, y acá importa más que en el pintor /// CPU: el `Renderer` es dueño del **atlas de texturas**, así que recrearlo /// tiraría todas las imágenes subidas y las obligaría a volver a cruzar el bus. pub struct Pintor { renderer: vello_hybrid::Renderer, recursos: vello_hybrid::Resources, /// La escena de hybrid, reusada entre frames. `reset()` conserva los búferes /// internos; recrearla por frame sería regalarle trabajo al asignador. gpu: vello_hybrid::Scene, /// Imágenes ya subidas al atlas, por `Blob::id()` de peniko. /// /// La clave es la identidad del blob y no su contenido: peniko ya garantiza /// que dos `Blob` con el mismo id son el mismo buffer, y hashear los bytes /// de cada imagen en cada frame sería exactamente el costo que este caché /// existe para evitar. imagenes: HashMap, ancho: u32, alto: u32, } impl Pintor { /// Un pintor que rinde a un destino de `ancho` x `alto` en `formato`. /// /// `formato` tiene que ser el de la textura contra la que se va a rendir —el /// del swapchain, normalmente—, porque el `Renderer` compila sus pipelines /// contra él en este mismo llamado. En el Mali eso es justamente lo que hay /// que sobrevivir: es acá donde el driver decide si el proceso sigue vivo. pub fn new( device: &wgpu::Device, backend: wgpu::Backend, formato: wgpu::TextureFormat, ancho: u32, alto: u32, ) -> Self { let (ancho, alto) = (ancho.max(1), alto.max(1)); Self { renderer: vello_hybrid::Renderer::new_with( device, &vello_hybrid::RenderTargetConfig { format: formato, width: ancho, height: alto, }, ajustes_para(backend), ), recursos: vello_hybrid::Resources::new(), gpu: vello_hybrid::Scene::new(dim(ancho), dim(alto)), imagenes: HashMap::new(), ancho, alto, } } /// Ancho del destino en píxeles. pub fn ancho(&self) -> u32 { self.ancho } /// Alto del destino en píxeles. pub fn alto(&self) -> u32 { self.alto } /// Ajusta el pintor a un destino de otro tamaño. /// /// **No recrea el `Renderer`, y eso es lo importante.** El `width`/`height` /// del `RenderTargetConfig` sólo siembra un buffer de configuración al /// construirlo; el tamaño de cada fotograma viaja aparte, en el `RenderSize` /// que recibe `render()`. Así que redimensionar es recrear la `Scene` —CPU /// pura— y nada más. /// /// Recrear el `Renderer` en cambio **recompilaría los pipelines**, que es /// justo la operación que en el Mali del teléfono mata el proceso /// (`cmpbe_v2_compile_multiple_shaders`, ver `../android/DIAGNOSTICO.md`). /// Un rotar la pantalla no puede costar eso. De paso conserva el atlas de /// imágenes, que si no habría que volver a subir entero por el bus. /// /// Sin cambio real no toca nada. La guarda no es higiene: reconfigurar por /// gusto fue el bug de `WinitSurface::resize` que se llevó una noche entera /// (41.335 reconfiguraciones en 13 s). pub fn redimensionar(&mut self, ancho: u32, alto: u32) { let (ancho, alto) = (ancho.max(1), alto.max(1)); if (ancho, alto) == (self.ancho, self.alto) { return; } self.ancho = ancho; self.alto = alto; self.gpu = vello_hybrid::Scene::new(dim(ancho), dim(alto)); } /// Reproduce la escena y la rinde sobre `vista`. /// /// Va en dos pasadas y no es un rodeo: la primera sube al atlas las imágenes /// que aún no están —lo único que necesita `device`/`queue`/`encoder`— y la /// segunda reproduce contra la `Scene`, que es CPU pura. Mezclarlas obligaría /// a llevar los tres prestados por todo el reproductor para un caso que en /// una UI se da un puñado de veces por frame y casi siempre pega en el caché. /// /// `vista` tiene que ser del formato y tamaño con los que se construyó el /// pintor. pub fn pintar( &mut self, escena: &Escena, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, vista: &wgpu::TextureView, color_base: Color, ) -> Result<(), RenderError> { self.subir_imagenes(escena, device, queue, encoder); self.gpu.reset(); self.pintar_base(color_base); for orden in escena.ordenes() { self.ejecutar(orden); } self.renderer.render( &self.gpu, &mut self.recursos, device, queue, encoder, &vello_hybrid::RenderSize { width: self.ancho, height: self.alto, }, vista, &vello_hybrid::TextureBindings::new(), ) } // ------------------------------------------------------------------------- /// Pinta `color_base` a sangre como primera orden de la escena. /// /// **Acá hubo un error mío que costó medio diagnóstico, y queda escrito para /// que nadie lo rehaga.** La versión anterior hacía esto con un pase de /// `wgpu` propio, sólo por su `LoadOp::Clear`, sobre esta premisa: que /// `vello::Renderer` limpia su target con el `base_color` de los /// `RenderParams` y el de `vello_hybrid` entra con `LoadOp::Load`, o sea /// componiendo sobre lo que ya había. /// /// **La premisa es falsa.** `vello_hybrid` limpia el attachment por su /// cuenta, así que pisaba el clear y el `color_base` **no llegaba nunca a /// la pantalla**: donde la escena no pintaba quedaba negro, no el color /// pedido. Y era invisible en la práctica porque el caso normal es que el /// `color_base` coincida con el fondo que igual pinta la vista raíz — los /// dos se ven idénticos hasta que se los hace distintos a propósito. /// `el_color_base_pinta_donde_la_escena_no_llega` los hace distintos. /// /// El corolario también se cae: la objeción de que «un relleno transparente /// no escribe nada y el contenido viejo sobrevive» no aplica, porque el /// contenido viejo ya no sobrevive — lo borra hybrid. Por eso con alfa cero /// no hay nada que hacer y esto se saltea. fn pintar_base(&mut self, color: Color) { if color.components[3] <= 0.0 { return; } self.gpu.set_transform(Affine::IDENTITY); self.gpu.reset_paint_transform(); self.gpu.set_paint(color); self.gpu.fill_rect(&Rect::new( 0.0, 0.0, f64::from(self.ancho), f64::from(self.alto), )); } /// Sube al atlas toda imagen de la escena que todavía no esté. /// /// Recorre tanto `draw_image` como los pinceles-imagen de `fill`/`stroke`: /// son dos caminos distintos hasta la misma textura, y olvidar el segundo /// dejaría el degradado-imagen dibujando transparente sin decir por qué. fn subir_imagenes( &mut self, escena: &Escena, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, ) { for orden in escena.ordenes() { let datos = match orden { Orden::Imagen { imagen, .. } => Some(&imagen.image), Orden::Rellenar { pincel, .. } | Orden::Trazar { pincel, .. } => match pincel { Brush::Image(img) => Some(&img.image), _ => None, }, _ => None, }; let Some(datos) = datos else { continue }; if self.imagenes.contains_key(&datos.data.id()) { continue; } let Some(pixmap) = pixmap_de(datos) else { continue }; let id = self .renderer .upload_image(&mut self.recursos, device, queue, encoder, &pixmap); self.imagenes.insert(datos.data.id(), id); } } fn ejecutar(&mut self, orden: &Orden) { match orden { Orden::Rellenar { regla, xf, pincel, xf_pincel, forma, } => { self.gpu.set_fill_rule(*regla); self.gpu.set_transform(*xf); fijar_pincel(&mut self.gpu, &self.imagenes, pincel, *xf_pincel); match forma { Forma::Rect(r) => self.gpu.fill_rect(r), otra => self.gpu.fill_path(&otra.a_camino()), } } Orden::Trazar { estilo, xf, pincel, xf_pincel, forma, } => { self.gpu.set_stroke(estilo.clone()); self.gpu.set_transform(*xf); fijar_pincel(&mut self.gpu, &self.imagenes, pincel, *xf_pincel); match forma { Forma::Rect(r) => self.gpu.stroke_rect(r), otra => self.gpu.stroke_path(&otra.a_camino()), } } Orden::AbrirCapa { regla, mezcla, alfa, xf, recorte, } => { self.gpu.set_fill_rule(*regla); self.gpu.set_transform(*xf); self.gpu.push_layer( Some(&recorte.a_camino()), Some(*mezcla), Some(*alfa), None, None, ); } Orden::AbrirMascaraLuminancia { regla, alfa, xf, recorte, } => self.abrir_mascara_luminancia(*regla, *alfa, *xf, recorte), Orden::CerrarCapa => self.gpu.pop_layer(), Orden::Imagen { imagen, xf } => { // Si la imagen no llegó al atlas —blob mal formado, o más grande // de lo que el atlas admite— se saltea el dibujo en vez de pintar // un rectángulo de basura o abortar el fotograma. let Some(id) = self.imagenes.get(&imagen.image.data.id()).copied() else { return; }; let (w, h) = ( f64::from(imagen.image.width), f64::from(imagen.image.height), ); self.gpu.set_transform(*xf); self.gpu.set_paint(Image { image: ImageSource::opaque_id(id), sampler: imagen.sampler, }); self.gpu.reset_paint_transform(); self.gpu.set_fill_rule(Fill::NonZero); self.gpu.fill_rect(&Rect::new(0.0, 0.0, w, h)); } Orden::Glifos { fuente, tamano, xf, pincel, hint, glifos, } => { // El pincel y el transform se fijan ANTES de abrir el builder: // `glyph_run` presta la escena hasta que se consume. self.gpu.set_transform(*xf); fijar_pincel(&mut self.gpu, &self.imagenes, pincel, None); self.gpu .glyph_run(&mut self.recursos, fuente) .font_size(*tamano) .hint(*hint) .fill_glyphs(glifos.iter().map(|g| glifo::Glyph { id: g.id, x: g.x, y: g.y, })); } Orden::RectDifuso { xf, rect, color, radio, desvio, } => { self.gpu.set_transform(*xf); self.gpu.set_paint(*color); self.gpu.reset_paint_transform(); self.gpu .fill_blurred_rounded_rect(rect, *radio as f32, *desvio as f32); } } } /// Abre una capa enmascarada por luminancia. /// /// **vello_hybrid no soporta máscaras**: su `push_layer` lleva un /// `unimplemented!()` literal cuando el parámetro `mask` viene con algo /// (`scene.rs:921`, «Mask is unsupported»). Así que hay que dar la vuelta —y /// la vuelta resulta ser exacta, no una aproximación. /// /// El razonamiento: la máscara que este método recibe no es una imagen /// arbitraria, es **una forma**. El pintor CPU la materializa rasterizando /// esa forma en blanco sobre negro y tomando la luminancia; eso da 1 adentro, /// 0 afuera y cobertura parcial en el borde antialiaseado. Que es, dato por /// dato, lo mismo que produce un recorte por esa misma forma. Se implementa /// entonces como capa recortada, sin lienzo extra ni rasterizado en CPU. /// /// La equivalencia depende de que la máscara SIEMPRE nazca de una forma /// blanca — hoy garantizado porque el único constructor es /// `Escena::push_luminance_mask_layer`, que recibe un `impl Shape`. Si algún /// día la `Escena` admite enmascarar con un degradado o una imagen, esto deja /// de valer y hay que volver a la máscara de verdad. fn abrir_mascara_luminancia(&mut self, regla: Fill, alfa: f32, xf: Affine, recorte: &Forma) { self.gpu.set_fill_rule(regla); self.gpu.set_transform(xf); self.gpu .push_layer(Some(&recorte.a_camino()), None, Some(alfa), None, None); } } /// Traduce un pincel de peniko al `PaintType` de vello_common. /// /// Sólido y degradado son el mismo tipo de un lado y del otro. La variante /// imagen no: peniko la lleva como bytes crudos y hybrid como un handle al /// atlas, así que se resuelve por el caché que llenó `subir_imagenes`. Si no /// está —imagen mal formada—, se cae a transparente en vez de abortar el frame. fn fijar_pincel( gpu: &mut vello_hybrid::Scene, imagenes: &HashMap, pincel: &Brush, xf_pincel: Option, ) { // La transformación del pincel es la que ancla un degradado a la caja que lo // lleva en vez de al origen del mundo. vello la compone como // `transform * brush_transform`, y hybrid hace lo mismo con su // `paint_transform`, así que el mapeo es directo. match xf_pincel { Some(t) => gpu.set_paint_transform(t), None => gpu.reset_paint_transform(), } match pincel { Brush::Solid(c) => gpu.set_paint(*c), Brush::Gradient(g) => gpu.set_paint(g.clone()), Brush::Image(img) => match imagenes.get(&img.image.data.id()).copied() { Some(id) => gpu.set_paint(Image { image: ImageSource::opaque_id(id), sampler: img.sampler, }), None => gpu.set_paint(llimphi_cpu::peniko::color::AlphaColor::< llimphi_cpu::peniko::color::Srgb, >::TRANSPARENT), }, } } /// Los ajustes del renderer para un backend dado. /// /// **Existe por un bug real, no por gusto.** El backend GLES de wgpu decide por /// *heurística* si una textura es `D2` o `D2Array`: cuando /// `depth_or_array_layers == 1` la trata como `D2` **aunque se haya creado como /// array**. El atlas de imágenes de vello_hybrid es un array, así que con una /// sola capa el muestreo queda desalineado y **las imágenes no se dibujan** — /// sin error de validación, sin warning, sin nada: salen transparentes y el /// síntoma es una UI sin iconos. /// /// vello_hybrid conoce el problema y lo esquiva forzando dos capas, pero sólo /// bajo `#[cfg(target_arch = "wasm32")]`, porque asume que GLES es sinónimo de /// WebGL (su propio TODO se pregunta cómo detectar el backend en vez del /// target). En Android GLES es **nativo**, así que caemos del lado no cubierto. /// /// Por eso el backend es un parámetro **obligatorio** de [`Pintor::new`] y no un /// `Default`: olvidarlo no rompe el build ni los tests en una máquina con /// Vulkan — se cobra callado, y sólo en el teléfono. /// **Lo que cuesta, medido, y una optimización PROBADA Y DESCARTADA.** El atlas /// default es de 4096x4096 por capa, o sea 67 MB de memoria GPU por capa; /// forzar la segunda llevó el `GL mtrack` del demo Android de 219 a 401 MB y los /// fps de 119 a 57, en un equipo donde el lowmemorykiller ya había matado /// procesos ajenos. Parece obvio bajar `atlas_size` a 2048 —dos capas sumarían /// 33 MB, menos que la única capa de antes—, y **no funciona**: probado en el /// teléfono, la imagen deja de dibujarse otra vez (0 px) y los fps caen a 8. Sin /// entender por qué, no se toca; el tamaño se deja en el default. fn ajustes_para(backend: wgpu::Backend) -> vello_hybrid::RenderSettings { let mut ajustes = vello_hybrid::RenderSettings::default(); if backend == wgpu::Backend::Gl { ajustes.atlas_config.initial_atlas_count = ajustes.atlas_config.initial_atlas_count.max(2); } ajustes } /// Los límites con los que hay que pedir el `Device` para que vello_hybrid /// rasterice bien. **Usala siempre en vez de `Limits::default().using_resolution(...)`.** /// /// `vello_hybrid` dimensiona su textura de alfas —donde vive la cobertura /// parcial de todo camino— con `max_texture_dimension_2d` de ancho, y la /// direcciona **por máscara y corrimiento**: el shader hace /// `tex_x = i & (ancho - 1)` y `tex_y = i >> alphas_tex_width_bits`, con /// `alphas_tex_width_bits = ancho.trailing_zeros()`. Las dos cuentas suponen que /// el ancho es **potencia de dos**, y nadie lo verifica. /// /// El Mali-G57 del Redmi reporta **16383**. `16383.trailing_zeros()` es **0**, /// así que `tex_y = i`: cada texel se busca en una fila muy afuera de la textura /// y GLES devuelve ceros. Resultado: alfa 0 en toda tira de cobertura parcial, o /// sea **cero antialiasing** y cada forma perdiendo su primer tile de 4 px. Sin /// error de validación y sin warning — la escena se ve, sólo que cuantizada a /// bloques de 4x4 y con los bordes comidos. /// /// Redondear el techo hacia abajo a la potencia de dos anterior lo arregla /// entero, medido en metal: el círculo de la regla pasó de 0 a 1.660 píxeles /// antialiaseados y los caminos volvieron a caer en la columna exacta que se /// pidió, sin costo de fps (119 antes y después). Ver /// `02_ruway/llimphi/android/DIAGNOSTICO.md` §F8. /// /// En una GPU que ya reporta una potencia de dos —o sea, todas las de /// escritorio— esto no cambia nada. pub fn limites_seguros(adapter: &wgpu::Adapter) -> wgpu::Limits { let mut limites = wgpu::Limits::default().using_resolution(adapter.limits()); limites.max_texture_dimension_2d = potencia_de_dos_hacia_abajo(limites.max_texture_dimension_2d); limites } /// La mayor potencia de dos que no pasa a `v`. Con `v == 0` devuelve 1: pedir un /// techo de cero texeles no es una opción representable, y saturar hacia arriba /// es menos malo que devolver algo que hace `trailing_zeros() == 32`. pub(crate) fn potencia_de_dos_hacia_abajo(v: u32) -> u32 { if v == 0 { return 1; } 1 << (u32::BITS - 1 - v.leading_zeros()) } /// Los pixmaps de vello_common se miden en `u16`. Saturar en vez de truncar: /// una pantalla de más de 65.535 px no existe, pero un `as u16` a la ligera /// convertiría un ancho absurdo en uno chiquito y el bug saldría como «se /// dibuja en una esquina», que es de los caros de diagnosticar. fn dim(v: u32) -> u16 { v.clamp(1, u32::from(u16::MAX)) as u16 } /// Convierte los bytes de una imagen de peniko en un `Pixmap` premultiplicado, /// que es lo que el atlas sabe recibir. /// /// Se apoya en `ImageSource::from_peniko_image_data`, que es el conversor de la /// propia vello_common —premultiplica y reordena canales—, en vez de repetirlo /// acá. Lo único que agrega esta función es la guarda de tamaño: ese conversor /// **paniquea** si la imagen pasa de `u16::MAX` en algún lado, y un blob raro no /// debe tumbar el proceso. fn pixmap_de(datos: &ImageData) -> Option> { if datos.width > u32::from(u16::MAX) || datos.height > u32::from(u16::MAX) { return None; } if datos.width == 0 || datos.height == 0 { return None; } match ImageSource::from_peniko_image_data(datos) { ImageSource::Pixmap(p) => Some(p), ImageSource::OpaqueId { .. } => None, } }