//! llimphi-hal — Puente al Silicio. //! //! Aísla el motor del sistema operativo. Pinta en ventana Wayland/X11 //! (vía `mirada` en producción, vía `winit` en dev) o framebuffer directo //! del kernel `wawa` (TODO). Trait `Surface` abstracto + struct `Hal` //! que posee Instance/Adapter/Device/Queue de wgpu. use std::sync::Arc; /// Avisos por `stderr` que no pueden matar a la app. Exporta el macro /// [`avisar!`], que reemplaza a `eprintln!` en todo el runtime. pub mod aviso; pub use raw_window_handle; pub use wgpu; pub use winit; use winit::window::Window; /// Errores al adquirir un frame de la superficie. #[derive(Debug)] pub enum SurfaceError { Lost, Outdated, OutOfMemory, Timeout, /// La ventana está **tapada** (minimizada o detrás de otra) y el sistema no /// entrega textura. Llegó con wgpu 29, que la distingue del resto. /// /// **No es una falla**, y por eso tiene variante propia en vez de caer en /// [`SurfaceError::Other`]: quien lleva el bucle cuenta los acquire fallidos /// y tras unos cuantos reconstruye el stack GPU entero. Una ventana tapada /// unos segundos dispararía esa reconstrucción sin que haya nada roto. Lo /// correcto es saltear el fotograma y volver a pedirlo. Occluded, /// La surface quedó **irrecuperable a nivel de dispositivo**: reconfigurar /// y recrear la `wgpu::Surface` (reusando el mismo device/adapter) fallaron /// ambos. El único remedio es reconstruir todo el stack GPU /// (instance+adapter+device+queue+surface) y con él los recursos colgados /// del device (renderer vello, compositores, texturas). El dueño del bucle /// de render debe atrapar esta variante y rehacer su mundo GPU — ver /// [`Hal::recreate_for_window`] (camino winit) y el runner de layer-shell de /// `pata` (camino raw-surface). Distinta de [`SurfaceError::Lost`], que es /// un hipo transitorio que la propia surface ya recuperó. /// /// Se dispara en el modo de falla de Iris Xe bajo mirada-DRM: al resetear /// el output, la surface Vulkan cae con «Surface does not support the /// adapter's queue family» y recrearla contra el mismo adapter no ayuda. /// Antes esto tumbaba/loopeaba a los clientes (greeter/pata/shuma) o exigía /// forzar el backend GL; la recuperación de device lo resuelve de forma /// general (no atada a esta GPU). DeviceLost, Other(String), } impl std::fmt::Display for SurfaceError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Lost => write!(f, "surface lost"), Self::Outdated => write!(f, "surface outdated"), Self::OutOfMemory => write!(f, "surface out of memory"), Self::Timeout => write!(f, "surface timeout"), Self::Occluded => write!(f, "ventana tapada (no es falla: saltear el cuadro)"), Self::DeviceLost => write!(f, "device lost (full GPU-stack rebuild required)"), Self::Other(s) => write!(f, "surface error: {s}"), } } } impl std::error::Error for SurfaceError {} /// Errores al construir Hal o crear una Surface. #[derive(Debug)] pub enum HalError { NoAdapter, RequestDevice(String), CreateSurface(String), } impl std::fmt::Display for HalError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::NoAdapter => write!(f, "no GPU adapter available"), Self::RequestDevice(s) => write!(f, "request_device failed: {s}"), Self::CreateSurface(s) => write!(f, "create_surface failed: {s}"), } } } impl std::error::Error for HalError {} /// Superficie gráfica donde llimphi pinta. /// /// Vello (rasterizador) emite a una textura intermedia con storage binding /// (la única forma portable: los formatos de swapchain no aceptan writes /// de compute shader en muchos adapters). En `present` se blittea la /// intermedia al swapchain real y se hace el flip. /// /// Implementaciones: /// - [`WinitSurface`]: ventana Wayland/X11/Win32/Cocoa (dev + producción vía mirada). /// - [`RawSurface`]: `wgpu::Surface` creada desde handles raw (wlr-layer-shell). /// - `CanvasSurface` (sólo `wasm32`): un `` del DOM — el camino navegador. /// - `WawaFramebufferSurface` (TODO): framebuffer directo del kernel wawa. pub trait Surface { fn size(&self) -> (u32, u32); fn resize(&mut self, width: u32, height: u32); /// Adquiere la textura intermedia donde el raster pinta este frame. fn acquire(&mut self) -> Result; /// Blittea la intermedia al swapchain y la presenta. fn present(&mut self, frame: Frame, hal: &Hal); } /// Frame en curso. `view()` devuelve la textura intermedia (Rgba8Unorm, /// STORAGE_BINDING) lista para que vello escriba sobre ella. pub struct Frame { surface_texture: wgpu::SurfaceTexture, surface_view: wgpu::TextureView, intermediate_view: wgpu::TextureView, /// Textura secundaria para la capa de overlay (menús/paleta/modal) /// cuando hay contenido `gpu_paint` que la taparía. El overlay se /// rasteriza aquí con fondo transparente y luego se compone con /// alpha SOBRE la intermedia (que ya tiene UI + video). Ver /// [`OverlayCompositor`] y el eventloop de `llimphi-ui`. overlay_view: wgpu::TextureView, width: u32, height: u32, } impl Frame { pub fn view(&self) -> &wgpu::TextureView { &self.intermediate_view } /// Vista de la textura de overlay (mismo tamaño y formato que la /// intermedia). Sólo se usa en el camino de compositing del overlay. pub fn overlay_view(&self) -> &wgpu::TextureView { &self.overlay_view } pub fn size(&self) -> (u32, u32) { (self.width, self.height) } } /// Estado wgpu compartido. Una instancia por proceso. `Device` y `Queue` /// son `Arc` internamente, así que clonar es barato. pub struct Hal { pub instance: wgpu::Instance, pub adapter: wgpu::Adapter, pub device: wgpu::Device, pub queue: wgpu::Queue, } /// Override del backend wgpu. Valores: `gl`/`gles`/`opengl`/`egl` → GL; /// `vulkan`/`vk` → Vulkan; `primary` → PRIMARY; `all` → todos. Sin pedido (o con /// un valor no reconocido) devuelve `None` y el caller usa su default (PRIMARY /// con fallback a todos). /// /// De dónde sale el pedido —la env en todas partes, la propiedad del sistema /// además en Android— y por qué hacen falta las dos: [`backend_pedido`]. /// /// **Para qué**: en Iris Xe bajo mirada-DRM, el backend Vulkan revienta al /// reconfigurar la surface tras un reset de output («Surface does not support the /// adapter's queue family»: falta `VK_EXT_physical_device_drm` y wgpu no puede /// re-matchear el adapter a la surface recreada). Como recrear la surface reusa el /// mismo device/adapter, el error persiste y los clientes layer (greeter/pata/ /// shuma) entran en crash-loop al boot y en cada cambio de VT. Forzar GL /// (`LLIMPHI_WGPU_BACKEND=gl`) esquiva el problema —GL no tiene el concepto de /// queue family— al costo del bug de teardown de GL (SIGSEGV al cerrar, tolerable /// frente a un crash-loop que impide la sesión). Los clientes lo heredan del env /// del compositor. Ver memoria `pata-layer-closed-arje-drm`. fn backend_override() -> Option { let (v, fuente) = backend_pedido()?; match backend_de_texto(&v) { Some(b) => { crate::avisar!("llimphi-hal: backend {b:?} pedido por {fuente}"); Some(b) } None => { crate::avisar!("llimphi-hal: backend «{v}» ({fuente}) no reconocido; uso el default (PRIMARY)"); None } } } /// El nombre escrito → los backends de wgpu. Separado de [`backend_override`] /// para poder certificarlo sin tocar el entorno del proceso: un test que /// escribe env vars le cambia el mundo a los otros tests del mismo binario, /// que corren en hilos del mismo proceso. fn backend_de_texto(v: &str) -> Option { match v.trim().to_ascii_lowercase().as_str() { "gl" | "gles" | "opengl" | "egl" => Some(wgpu::Backends::GL), "vulkan" | "vk" => Some(wgpu::Backends::VULKAN), "primary" => Some(wgpu::Backends::PRIMARY), "all" => Some(wgpu::Backends::all()), _ => None, } } /// De dónde sale la elección de backend, en orden, y **cuál fue** — el segundo /// dato es la mitad que importa cuando el aviso se lee en un `logcat` ajeno. /// /// El entorno primero: es el camino de Linux y el que usan `mirada-gl.sh` y los /// arneses. Después, y **sólo en Android**, la propiedad del sistema. /// /// **Por qué hace falta la segunda.** Una app de Android lanzada por el launcher /// no hereda el entorno de nadie: `LLIMPHI_WGPU_BACKEND` existe desde siempre y /// en un teléfono **no hay forma de ponerla**, así que probar GLES exigía /// recompilar. Con la propiedad se hace desde afuera y sin root: /// /// ```sh /// adb shell setprop debug.llimphi.backend gl /// adb shell am force-stop # el valor se lee UNA vez, al arrancar /// ``` /// /// El prefijo `debug.` no es cosmético: es el espacio de nombres que bionic deja /// escribir al usuario `shell` sin root, y el mismo que usa el propio Android /// para sus interruptores de gráficos (`debug.hwui.renderer`). Una propiedad sin /// ese prefijo requiere permisos que un teléfono de banco de pruebas no da. /// /// ⚠️ Esto elige el **backend de wgpu** (Vulkan / GL), que es una decisión de /// tiempo de corrida. NO elige el **rasterizador** (`vello` / `vello_hybrid`): /// ése es de tiempo de compilación —son escenas de tipos distintos, ver el /// encabezado de `llimphi-raster`— y viaja en el APK, no en una propiedad. fn backend_pedido() -> Option<(String, &'static str)> { if let Ok(v) = std::env::var("LLIMPHI_WGPU_BACKEND") { return Some((v, "LLIMPHI_WGPU_BACKEND")); } #[cfg(target_os = "android")] if let Some(v) = propiedad_del_sistema("debug.llimphi.backend") { return Some((v, "debug.llimphi.backend")); } None } /// Lee una propiedad del sistema de Android. `None` si no está puesta o si está /// vacía — que para bionic son lo mismo: `__system_property_get` devuelve 0 en /// los dos casos. /// /// Se hace por `libc` y no lanzando `getprop`: esto corre en el arranque del /// runtime, antes del primer cuadro, y un `fork`+`exec` ahí es caro y puede /// fallar por políticas de la app. #[cfg(target_os = "android")] fn propiedad_del_sistema(nombre: &str) -> Option { // `PROP_VALUE_MAX` de bionic, con el NUL adentro. bionic promete no // escribir más que eso, así que el buffer fijo es correcto y no un límite // nuestro. const MAX: usize = 92; let nombre = std::ffi::CString::new(nombre).ok()?; let mut buf = [0u8; MAX]; // SAFETY: `nombre` vive toda la llamada y es un C string válido; `buf` // tiene los PROP_VALUE_MAX bytes que la función promete no pasar. let n = unsafe { libc::__system_property_get(nombre.as_ptr(), buf.as_mut_ptr() as *mut libc::c_char) }; if n <= 0 { return None; } let v = std::str::from_utf8(buf.get(..n as usize)?).ok()?.trim(); (!v.is_empty()).then(|| v.to_string()) } /// Un `RawDisplayHandle` presentado como `HasDisplayHandle`, para poder /// entregárselo a `wgpu::InstanceDescriptor`. /// /// Hace falta en el camino de layer-shell (`pata`, la barra, el greeter), donde /// no hay una `winit::Window` que ya implemente el trait: lo único que tenemos /// es el handle crudo que el caller nos pasa. #[derive(Debug, Clone, Copy)] struct DisplayCrudo(raw_window_handle::RawDisplayHandle); // SAFETY: el contrato de `new_for_raw_surface` ya exige que los handles apunten // a objetos vivos durante toda la vida de la surface — es la misma promesa que // sostiene a `create_surface_unsafe`, y este envoltorio no la extiende, sólo la // transporta. `RawDisplayHandle` es un puntero opaco: `Send`/`Sync` valen porque // wgpu no lo desreferencia fuera del hilo del compositor. unsafe impl Send for DisplayCrudo {} unsafe impl Sync for DisplayCrudo {} impl raw_window_handle::HasDisplayHandle for DisplayCrudo { fn display_handle( &self, ) -> Result, raw_window_handle::HandleError> { // SAFETY: ver arriba — el handle sobrevive a este préstamo por contrato. Ok(unsafe { raw_window_handle::DisplayHandle::borrow_raw(self.0) }) } } /// Construye una `wgpu::Instance` con los backends pedidos y, si se tiene, el /// display del sistema de ventanas. /// /// **El display no es un adorno.** wgpu 29 lo sumó al `InstanceDescriptor` y su /// propia documentación es explícita: en GLES es *necesario para presentar, /// especialmente en Wayland*; en Vulkan/Metal/DX12 se ignora. Y GL no es un /// camino hipotético acá — es justo el fallback al que caen `Hal::new` y /// `new_for_raw_surface` cuando no hay adaptador PRIMARY. Antes de wgpu 29 no /// existía la opción y el backend GL se las arreglaba; pasarlo cuando lo /// tenemos es lo que evita descubrir en una VM vieja que la ventana no presenta. /// /// Existe además porque en wgpu 29 `InstanceDescriptor` **perdió `Default`** (el /// display no tiene un valor razonable por omisión), así que los cuatro sitios /// que construían una instancia con `..Default::default()` necesitaban todos la /// misma reescritura. /// El display que lleva adentro un `SurfaceTargetUnsafe`, si es de los que /// traen handles crudos. /// /// Las otras variantes del enum (las de puntero de plataforma) no exponen un /// display separado; ahí no hay nada que enhebrar y se devuelve `None`, que es /// exactamente el comportamiento de antes de wgpu 29. fn display_de(target: &wgpu::SurfaceTargetUnsafe) -> Option { match target { wgpu::SurfaceTargetUnsafe::RawHandle { raw_display_handle, .. } => raw_display_handle.map(DisplayCrudo), _ => None, } } /// Instancia **sin** display: los caminos donde no hay ventana todavía (el /// arranque diferido de escritorio) o donde no aplica (canvas del navegador). fn instancia(backends: wgpu::Backends) -> wgpu::Instance { let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); desc.backends = backends; wgpu::Instance::new(desc) } /// Instancia **con** display. Ver el porqué en [`instancia`]. /// /// Genérica en vez de tomar un `Box` porque wgpu **no reexporta** el /// trait `WgpuHasDisplayHandle` que pide ese campo: no hay forma de nombrarlo /// desde afuera. Sí hay una impl general para todo `HasDisplayHandle + Debug + /// Send + Sync + 'static`, así que se piden esos límites y la coerción al objeto /// de trait la resuelve el compilador al asignar el campo. fn instancia_con(backends: wgpu::Backends, display: D) -> wgpu::Instance where D: raw_window_handle::HasDisplayHandle + core::fmt::Debug + Send + Sync + 'static, { let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); desc.backends = backends; desc.display = Some(Box::new(display)); wgpu::Instance::new(desc) } /// Pista de memoria explícita por la env `LLIMPHI_WGPU_MEM`. `low`/`min`/`ram` /// → [`MemoryHints::MemoryUsage`] (el asignador de wgpu minimiza la huella: /// bloques de suballocación chicos, menos reserva especulativa). `perf` → /// [`MemoryHints::Performance`] (pools grandes, prioriza velocidad). `None` si /// la env no está o no se reconoce → el caller **autodetecta** (ver /// [`resolver_memory_hint`]). El manager de `pacha` la fija por contexto para /// equipos chicos; sin ella, la app decide sola. fn memory_hint_env() -> Option { match std::env::var("LLIMPHI_WGPU_MEM") .map(|v| v.trim().to_ascii_lowercase()) .as_deref() { Ok("low") | Ok("min") | Ok("ram") | Ok("memoria") => Some(wgpu::MemoryHints::MemoryUsage), Ok("perf") | Ok("performance") | Ok("velocidad") => Some(wgpu::MemoryHints::Performance), Ok(other) if !other.is_empty() => { crate::avisar!("llimphi-hal: LLIMPHI_WGPU_MEM=«{other}» no reconocido; autodetecto"); None } _ => None, } } /// Resuelve la pista de memoria del device: la env manda; sin ella, /// **autoadapta** a la máquina. Un adapter de tipo `Cpu` (raster por software, /// llvmpipe / VM sin GPU) no tiene VRAM y compone contra la RAM del sistema, así /// que ahí `MemoryUsage` (mínima huella) es la elección correcta por defecto. Con /// una GPU real se mantiene `Performance` salvo que el env/pacha pida lo /// contrario. Es la contraparte a nivel de render de `pacha_core::Rendimiento`. /// Los límites con los que se pide el `Device`, en un solo sitio. /// /// `Limits::default()` cubre los 5 storage buffers/stage que vello necesita; /// `downlevel_defaults()` sólo expone 4 y rompe el raster. Si el adapter no lo /// aguanta, `using_resolution` recorta lo recortable (texturas y buffers /// grandes) preservando los conteos mínimos. /// /// Encima de eso, **el techo de textura se redondea hacia abajo a una potencia /// de dos**. No es cosmético: en el perfil hybrid, `vello_hybrid` dimensiona su /// textura de alfas con `max_texture_dimension_2d` y la direcciona por máscara y /// corrimiento (`i & (ancho-1)`, `i >> ancho.trailing_zeros()`), suponiendo sin /// verificar que es potencia de dos. El Mali-G57 del Redmi reporta 16383, cuyo /// `trailing_zeros()` es 0 — y ahí se cae TODA la cobertura parcial: cero /// antialiasing y cada forma perdiendo su primer tile, sin un solo error de /// validación. La versión documentada y medida de esto es /// `llimphi_hybrid::limites_seguros`; acá se repite la cuenta en vez de /// depender del crate para no arrastrar `vello_hybrid` a cada build de /// escritorio. Ver `02_ruway/llimphi/android/DIAGNOSTICO.md` §F8. /// /// En una GPU que ya reporta potencia de dos —todas las de escritorio— es un /// no-op. fn limites_para(adapter: &wgpu::Adapter) -> wgpu::Limits { let mut limites = wgpu::Limits::default().using_resolution(adapter.limits()); let techo = limites.max_texture_dimension_2d; if techo > 0 && !techo.is_power_of_two() { limites.max_texture_dimension_2d = 1 << (u32::BITS - 1 - techo.leading_zeros()); } limites } fn resolver_memory_hint(adapter: &wgpu::Adapter) -> wgpu::MemoryHints { if let Some(h) = memory_hint_env() { return h; } match adapter.get_info().device_type { wgpu::DeviceType::Cpu => wgpu::MemoryHints::MemoryUsage, _ => wgpu::MemoryHints::Performance, } } /// Preferencia de potencia por la env `LLIMPHI_WGPU_POWER`. `low`/`ahorro` → /// [`PowerPreference::LowPower`] (elige la iGPU integrada en equipos híbridos: /// menos consumo, menos RAM de driver, y de paso esquiva el problema de dmabuf /// cruzando dispositivos en Optimus). Sin la env → `HighPerformance`, el default /// de siempre. Una barra o un WM 2D nunca necesitan la dGPU discreta. fn power_preference_override() -> wgpu::PowerPreference { match std::env::var("LLIMPHI_WGPU_POWER") .map(|v| v.trim().to_ascii_lowercase()) .as_deref() { Ok("low") | Ok("ahorro") | Ok("integrada") => wgpu::PowerPreference::LowPower, Ok("high") | Ok("alto") | Ok("discreta") => wgpu::PowerPreference::HighPerformance, Ok(other) if !other.is_empty() => { crate::avisar!("llimphi-hal: LLIMPHI_WGPU_POWER=«{other}» no reconocido; uso HighPerformance"); wgpu::PowerPreference::HighPerformance } _ => wgpu::PowerPreference::HighPerformance, } } /// Precisión de color que se le pide al **swapchain**. /// /// ## El techo honesto, antes de que nadie prometa HDR /// /// El motor rasteriza **siempre** contra una textura intermedia /// [`INTERMEDIATE_FORMAT`] = `Rgba8Unorm`, porque `vello` lo exige /// (`Renderer::render_to_texture` pide justo ese formato con `STORAGE_BINDING`). /// O sea: **el contenido vello es 8 bits por canal, elijas el swapchain que /// elijas**. Pedir `Rgb10` con una UI normal sólo *upsamplea* 8 bits a 10 — no /// aparece rango que la escena no tenga. Eso no es HDR, es HDR falso, y el /// motivo por el que este enum NO tiene variantes `HdrPq`/`HdrHlg`. /// /// Dos cosas sí son reales y por eso esto existe: /// /// 1. **El camino GPU-directo** (`llimphi_raster::gpu::GpuBatch`) rasteriza a /// un target parametrizable con color de vértice `Float32x4`, así que un /// canvas denso que pinte por ahí **sí** carga más de 8 bits /// (`llimphi-raster/tests/gpu_hdr_precision`). Para que esa precisión llegue /// a la pantalla hace falta que el swapchain no la tire, y eso es lo que /// elige este enum. /// 2. **El banding de los degradés**: 10 bits en el swapchain suaviza las /// escaleras de un gradiente de fondo grande aunque la fuente sea de 8, que /// es un efecto chico pero visible y gratis. /// /// ## Lo que wgpu 29 NO deja pedir /// /// El espacio de color. `wgpu::SurfaceConfiguration` no tiene campo de /// colorspace y `wgpu::SurfaceCapabilities` no enumera espacios (verificado en /// `wgpu-types-29.0.3/src/surface.rs:136-153`: sólo `formats`, `present_modes`, /// `alpha_modes`, `usages`). Vulkan tiene `VK_EXT_swapchain_colorspace`, pero /// wgpu no lo expone. Sin ese campo **no se puede señalar PQ ni HLG**, así que /// un display HDR de verdad seguirá interpretando lo presentado como sRGB. Eso /// es un bloqueo de upstream, no una tarea pendiente de este crate. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] #[non_exhaustive] pub enum ColorTarget { /// 8 bits por canal, sin sRGB de hardware. El default de siempre: el blit /// desde la intermedia lineal preserva los valores tal cual. #[default] Srgb8, /// 10 bits por canal (`Rgb10a2Unorm`). Cae a [`ColorTarget::Srgb8`] si la /// surface no lo ofrece. Rgb10, /// Punto flotante de 16 bits (`Rgba16Float`). Es el único que preserva /// valores > 1.0 del camino GPU-directo. Raro fuera de Vulkan con /// colorspace extendido; cae a `Rgb10` y después a `Srgb8`. Float16, } impl ColorTarget { /// Los formatos que sirven a este objetivo, del preferido al tolerable. fn candidatos(self) -> &'static [wgpu::TextureFormat] { use wgpu::TextureFormat as F; match self { // Bgra8 primero: es el que casi todo compositor prefiere, y así se // evita una conversión de canales en el present. ColorTarget::Srgb8 => &[F::Bgra8Unorm, F::Rgba8Unorm], ColorTarget::Rgb10 => &[F::Rgb10a2Unorm], ColorTarget::Float16 => &[F::Rgba16Float], } } /// La cadena de degradación: lo que se intenta si el objetivo no está. fn cadena(self) -> &'static [ColorTarget] { match self { ColorTarget::Srgb8 => &[ColorTarget::Srgb8], ColorTarget::Rgb10 => &[ColorTarget::Rgb10, ColorTarget::Srgb8], ColorTarget::Float16 => { &[ColorTarget::Float16, ColorTarget::Rgb10, ColorTarget::Srgb8] } } } /// El nombre con el que se pide por `LLIMPHI_COLOR`. Es el que va en el /// informe: si alguien lee «DEGRADADO a Bgra8Unorm» quiere saber qué /// escribió él, no cómo se llama la variante en Rust. pub fn nombre(self) -> &'static str { match self { ColorTarget::Srgb8 => "srgb8", ColorTarget::Rgb10 => "rgb10", ColorTarget::Float16 => "float16", } } } /// Objetivo de color pedido por entorno: `LLIMPHI_COLOR = srgb8 | rgb10 | /// float16`. Mismo idiom que [`memory_hint_env`] y `LLIMPHI_PRESENT_MODE`: /// sirve para un A/B en la laptop con display real sin recompilar. Sin la env, /// [`ColorTarget::Srgb8`] — cero regresión contra el comportamiento histórico. fn color_target_env() -> ColorTarget { objetivo_pedido().unwrap_or_default() } /// Lo mismo que [`color_target_env`] pero distinguiendo «no pidió nada» de /// «pidió 8 bits». Los dos casos eligen el mismo formato, y por eso el /// selector no necesita la diferencia; el **informe** sí: se le habla sólo a /// quien preguntó, para que una app que no toca la env siga arrancando muda. fn objetivo_pedido() -> Option { match std::env::var("LLIMPHI_COLOR") .map(|v| v.trim().to_ascii_lowercase()) .as_deref() { Ok("srgb8") | Ok("srgb") | Ok("8") => Some(ColorTarget::Srgb8), Ok("rgb10") | Ok("10") | Ok("10bit") => Some(ColorTarget::Rgb10), Ok("float16") | Ok("f16") | Ok("hdr") => Some(ColorTarget::Float16), Ok(other) if !other.is_empty() => { crate::avisar!("llimphi-hal: LLIMPHI_COLOR=«{other}» no reconocido; uso srgb8"); Some(ColorTarget::Srgb8) } _ => None, } } /// Elige el formato del swapchain entre los que ofrece la surface, para el /// `objetivo` pedido. **Función pura** — por eso se puede certificar por texto /// sin GPU (`tests/formato.rs`). /// /// Degrada por la cadena de [`ColorTarget::cadena`] y, si no encuentra nada /// conocido, devuelve `None` para que el caller aplique su propio último /// recurso (el primer formato que liste la surface, que es el preferido del /// backend). /// /// Nunca elige un formato `*Srgb`: la intermedia es lineal y el blit debe /// pasar los valores tal cual; con un target sRGB el hardware aplicaría la /// curva por segunda vez y todo saldría lavado. pub fn elegir_formato( disponibles: &[wgpu::TextureFormat], objetivo: ColorTarget, ) -> Option { objetivo .cadena() .iter() .flat_map(|t| t.candidatos()) .find(|f| disponibles.contains(f)) .copied() } /// Qué se pidió y qué se concedió, en una línea. **Función pura** — se /// certifica por texto sin GPU (`tests/formato.rs`), igual que /// [`elegir_formato`]. /// /// Existe porque la degradación era **muda**: pedir `LLIMPHI_COLOR=rgb10` en /// una surface que no ofrece 10 bits daba una ventana de 8 bits idéntica a la /// de siempre, sin ninguna señal, y la única forma de saber cuál se había /// elegido era leer `WinitSurface::format()` con un depurador. Un A/B de /// precisión en el que las dos ramas se ven iguales y ninguna dice nada no es /// un A/B: es adivinar. pub fn informe_formato( objetivo: ColorTarget, elegido: Option, disponibles: &[wgpu::TextureFormat], ) -> String { let pedido = objetivo.nombre(); let Some(elegido) = elegido else { return format!("LLIMPHI_COLOR={pedido}: la surface no ofrece ningún formato"); }; if objetivo.candidatos().contains(&elegido) { format!("LLIMPHI_COLOR={pedido} concedido: {elegido:?}") } else { format!( "LLIMPHI_COLOR={pedido} DEGRADADO a {elegido:?}: la surface no ofrece \ ninguno de {:?}; ofrece {disponibles:?}", objetivo.candidatos() ) } } /// El formato de swapchain para esta surface: [`elegir_formato`] con el /// objetivo del entorno, y el primer formato de la surface como último recurso. /// /// `donde` nombra la superficie (una app puede abrir varias: la ventana, las /// layer surfaces de sus popovers) para que el informe diga de cuál habla. fn formato_de_swapchain( caps: &wgpu::SurfaceCapabilities, donde: &str, ) -> Option { let objetivo = color_target_env(); let elegido = elegir_formato(&caps.formats, objetivo).or_else(|| caps.formats.first().copied()); if objetivo_pedido().is_some() { crate::avisar!( "llimphi-hal[{donde}]: {}", informe_formato(objetivo, elegido, &caps.formats) ); } elegido } /// Gancho de verificación: fuerza un [`SurfaceError::DeviceLost`] sintético cada /// N cuadros para poder ejercitar la recuperación de device SIN esperar un reset /// de output real (que en Iris Xe sólo pasa bajo mirada-DRM, no bajo KDE/kwin). /// /// Activar con `LLIMPHI_SIMULATE_DEVICE_LOSS=N` (N≥1): cada N `acquire()` /// exitosos, el N-ésimo devuelve `DeviceLost` en vez de un `Frame`. El dueño del /// bucle debe reconstruir todo el stack GPU; si tras eso el render sigue vivo y /// sin fugas, la recuperación quedó certificada como texto (no screenshot). Sin /// la env, es un no-op de coste cero (un `load` atómico Relaxed). El contador es /// global al proceso: con varias surfaces reparte los trips entre ellas, lo cual /// es justo lo que queremos (probar que CUALQUIER surface dispara el rebuild). fn simulate_device_loss_trip() -> bool { use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::OnceLock; static PERIOD: OnceLock> = OnceLock::new(); let period = *PERIOD.get_or_init(|| { std::env::var("LLIMPHI_SIMULATE_DEVICE_LOSS") .ok() .and_then(|v| v.trim().parse::().ok()) .filter(|n| *n >= 1) }); let Some(period) = period else { return false }; static COUNT: AtomicU64 = AtomicU64::new(0); // fetch_add devuelve el valor previo; el trip cae en el múltiplo de period. (COUNT.fetch_add(1, Ordering::Relaxed) + 1) % period == 0 } impl Hal { /// Construye Hal pidiendo un adapter compatible con una surface dada /// (recomendado: pasar `Some(&surface)` para garantizar que el adapter /// elegido sabe presentar a esa surface). pub async fn new( compatible_surface: Option<&wgpu::Surface<'static>>, ) -> Result { Self::new_inner(compatible_surface, false).await } /// Construye un `Hal` **headless** (sin surface) prefiriendo el adapter de /// **software** (llvmpipe/lavapipe, `deviceType == CPU`) cuando /// `prefer_software`. Es el modo de los tests golden de `llimphi-test`: el /// rasterizador por software es DETERMINISTA e idéntico host-a-host y en CI, /// así el golden no queda atado a la GPU de quien lo generó. Si la máquina no /// expone un fallback adapter (no hay lavapipe instalado), cae al adapter /// normal — en ese caso el golden se certifica con tolerancia perceptual, no /// bit-exacta. Nunca pasa `compatible_surface`: renderiza a textura, no /// presenta a ventana. /// /// # Correrlo contra la GPU de verdad /// /// Con `LLIMPHI_TEST_GPU=1` se ignora `prefer_software` y se usa el adapter /// normal. Existe porque **toda** la certificación headless de llimphi /// —cachés, troceado, scissor, precisión float, el texto por atlas, los tres /// caminos a píxeles— corre contra un rasterizador de software, y eso deja /// una pregunta sin contestar: ¿el hardware hace lo mismo? /// /// No es una duda abstracta. El 2026-08-05 se midió el ahorro del `scissor` /// en las dos: llvmpipe decía 97,9 %, la Iris Xe dice 57 %. Ahí la /// diferencia era de *rendimiento*, que se esperaba; pero nada garantiza que /// una diferencia de *resultado* no esté esperando en algún borde. /// /// El default sigue siendo el software, y debe seguir: es lo que hace que un /// golden valga lo mismo en cualquier máquina y en CI. Esto es para preguntar /// a mano, no para cambiar la referencia. pub async fn new_headless(prefer_software: bool) -> Result { let forzar_gpu = std::env::var("LLIMPHI_TEST_GPU").ok().as_deref() == Some("1"); if prefer_software && !forzar_gpu { if let Ok(hal) = Self::new_inner(None, true).await { return Ok(hal); } // Sin fallback adapter disponible: seguimos con el adapter normal; la // tolerancia perceptual del arnés absorbe la diferencia de vendor. } Self::new_inner(None, false).await } /// Igual que [`Hal::new`], pero **con el display del sistema de ventanas**. /// /// ## Por qué hace falta, y por qué no alcanza con que `instancia_con` exista /// /// Medido el 2026-09-18 en una VM sin Vulkan (mesa iris/llvmpipe, `-Dvulkan-drivers=` vacío): /// `shuma-pregunta` y `boveda` **no abren ventana**, y el panic no nombra la causa — /// `index out of bounds: the len is 0` en `caps.formats[0]`, o sea que la surface no tiene NI /// UN formato—. La cadena entera, con `RUST_LOG=wgpu_hal=debug`: /// /// ```text /// No (or unknown) windowing system ((None, Some(..))) present. Using surfaceless platform /// Trying native-render → No config found! · Trying presentation → No config found! /// ``` /// /// Ese `None` es el `raw_display_handle` que [`Hal::new`] **no pasa a propósito**, porque su /// camino se pensó como «todavía no hay ventana». Sin display, wgpu abre el display EGL por la /// plataforma *surfaceless*, que por definición no publica configs con `WINDOW_BIT`: sin config /// de ventana la surface no es presentable, y sin eso no hay formatos. /// /// **En una máquina con Vulkan no se nota**, porque `Backends::PRIMARY` no necesita el display. /// Sólo se cae al backend GL cuando no hay Vulkan/Metal/DX12 — que es el caso de cualquier VM /// sin ICD y de una distro que compila mesa sin drivers Vulkan. Ahí, hasta hoy, **ninguna app /// llimphi de escritorio podía pintar**. /// /// El llamador de escritorio (`llimphi-ui`) sí tiene la ventana cuando construye el `Hal`: la /// creó una línea antes para hacerle la surface. Lo único que faltaba era enhebrarla. pub async fn new_con_display( compatible_surface: Option<&wgpu::Surface<'static>>, display: D, ) -> Result where D: raw_window_handle::HasDisplayHandle + core::fmt::Debug + Clone + Send + Sync + 'static, { Self::new_generico(compatible_surface, false, move |b| { instancia_con(b, display.clone()) }) .await } async fn new_inner( compatible_surface: Option<&wgpu::Surface<'static>>, force_fallback_adapter: bool, ) -> Result { Self::new_generico(compatible_surface, force_fallback_adapter, instancia).await } /// El cuerpo compartido por los dos caminos. Lo único que los distingue es **cómo se arma la /// instancia**, y por eso eso es lo que se parametriza: duplicar el resto —la elección de /// backend, el fallback, los límites, el handler de errores— es cómo se termina con dos /// caminos que divergen sin que nadie se entere. async fn new_generico( compatible_surface: Option<&wgpu::Surface<'static>>, force_fallback_adapter: bool, armar: impl Fn(wgpu::Backends) -> wgpu::Instance, ) -> Result { let opts = wgpu::RequestAdapterOptions { power_preference: power_preference_override(), force_fallback_adapter, compatible_surface, }; // Preferimos backends PRIMARY (Vulkan/Metal/DX12). El backend GL de // wgpu sobre Mesa/Wayland tiene un bug de teardown: al soltar la // instancia, `eglTerminate` marshalea sobre una conexión Wayland ya // muerta (`wl_proxy_marshal`) y revienta con SIGSEGV. Con // `Backends::all()` (el default), wgpu puede elegir GL aun habiendo // Vulkan, y la app crashea al cerrar/teardown. Forzamos PRIMARY; si la // máquina no tiene Vulkan/Metal/DX12 (VM vieja, etc.) caemos a todos // los backends —incluido GL— para no dejarla sin gráficos. En el // camino de escritorio `compatible_surface` es `None` (la surface se // crea después contra esta misma instancia), así que cambiar de // instancia aquí es seguro. // Override por env (`LLIMPHI_WGPU_BACKEND`) o PRIMARY por default. let forced = backend_override(); // La instancia la arma el llamador (`armar`): `new_inner` sin display —el camino que // de verdad no tiene ventana todavía— y `new_con_display` con ella. ⚠ Antes ESTE camino // era el único y siempre iba sin display, y en una máquina sin Vulkan eso significa que // la ventana nunca presenta: ver `new_con_display`. let primary = armar(forced.unwrap_or(wgpu::Backends::PRIMARY)); let (instance, adapter) = match primary.request_adapter(&opts).await { Ok(a) => (primary, a), // Con override explícito NO caemos a otros backends: el operador eligió. Err(_) if forced.is_some() => return Err(HalError::NoAdapter), Err(_) => { let all = armar(wgpu::Backends::all()); let a = all .request_adapter(&opts) .await .map_err(|_| HalError::NoAdapter)?; (all, a) } }; let limits = limites_para(&adapter); let (device, queue) = adapter .request_device(&wgpu::DeviceDescriptor { label: Some("llimphi-hal-device"), required_features: wgpu::Features::empty(), required_limits: limits, memory_hints: resolver_memory_hint(&adapter), experimental_features: wgpu::ExperimentalFeatures::default(), trace: wgpu::Trace::Off, }) .await .map_err(|e| HalError::RequestDevice(e.to_string()))?; instalar_handler_errores(&device); Ok(Self { instance, adapter, device, queue, }) } /// Construye el `Hal` **y** una [`RawSurface`] a la vez, eligiendo el adaptador /// **compatible con esa surface** — el dispositivo que el compositor sabe /// presentar. Es el camino correcto para el backend layer-shell de `pata`. /// /// El problema que resuelve: en sistemas multi-GPU (Optimus), pedir el /// adaptador sin pista de surface (`new(None)` con `HighPerformance`) puede /// elegir la dGPU mientras el compositor compone en la iGPU → los dmabuf /// cruzan dispositivos y `get_capabilities` devuelve 0 formatos (la surface /// "no expone formatos"). Pasar `compatible_surface` ata el adaptador al /// dispositivo del compositor. Como la surface hace falta ANTES de pedir el /// adaptador, y `new` crea la instancia internamente, este constructor une los /// dos pasos. /// /// `make_target` reconstruye el `SurfaceTargetUnsafe` cada vez que se llama /// (los `RawHandle` son `Copy`): `create_surface_unsafe` consume el target y /// puede que probemos dos instancias (PRIMARY y, si no hay adaptador, todos /// los backends — el GL de Mesa/Wayland revienta en teardown, por eso PRIMARY /// primero, igual que [`Hal::new`]). /// /// # Safety /// Los handles que produce `make_target` deben apuntar a objetos Wayland/… /// vivos durante toda la vida de la `RawSurface` devuelta. pub async unsafe fn new_for_raw_surface( make_target: impl Fn() -> wgpu::SurfaceTargetUnsafe, width: u32, height: u32, ) -> Result<(Self, RawSurface), HalError> { // PRIMARY (Vulkan/Metal/DX12) primero; si no hay adaptador compatible, a // todos los backends recreando instancia y surface. // Override por env (`LLIMPHI_WGPU_BACKEND`) o PRIMARY por default. let forced = backend_override(); // El display del compositor sale del propio target: es el mismo handle // que `create_surface_unsafe` va a usar. Enhebrarlo importa acá más que // en ningún otro camino — éste es el de layer-shell sobre Wayland, y es // el que cae a GL cuando no hay Vulkan. let display = display_de(&make_target()); let primary = match display { Some(d) => instancia_con(forced.unwrap_or(wgpu::Backends::PRIMARY), d), None => instancia(forced.unwrap_or(wgpu::Backends::PRIMARY)), }; let prim_surface = unsafe { primary.create_surface_unsafe(make_target()) } .map_err(|e| HalError::CreateSurface(e.to_string()))?; let prim_adapter = primary .request_adapter(&wgpu::RequestAdapterOptions { power_preference: power_preference_override(), force_fallback_adapter: false, compatible_surface: Some(&prim_surface), }) .await; let (instance, adapter, wgpu_surface) = match prim_adapter { Ok(a) => (primary, a, prim_surface), // Con override explícito NO caemos a otros backends: el operador eligió. Err(_) if forced.is_some() => return Err(HalError::NoAdapter), Err(_) => { let all = match display { Some(d) => instancia_con(wgpu::Backends::all(), d), None => instancia(wgpu::Backends::all()), }; let surface = unsafe { all.create_surface_unsafe(make_target()) } .map_err(|e| HalError::CreateSurface(e.to_string()))?; let a = all .request_adapter(&wgpu::RequestAdapterOptions { power_preference: power_preference_override(), force_fallback_adapter: false, compatible_surface: Some(&surface), }) .await .map_err(|_| HalError::NoAdapter)?; (all, a, surface) } }; let limits = limites_para(&adapter); let (device, queue) = adapter .request_device(&wgpu::DeviceDescriptor { label: Some("llimphi-hal-device"), required_features: wgpu::Features::empty(), required_limits: limits, memory_hints: resolver_memory_hint(&adapter), experimental_features: wgpu::ExperimentalFeatures::default(), trace: wgpu::Trace::Off, }) .await .map_err(|e| HalError::RequestDevice(e.to_string()))?; instalar_handler_errores(&device); let hal = Self { instance, adapter, device, queue, }; // Extraemos los raw handles del target para que la `RawSurface` pueda // recrearse ante una pérdida (los `RawHandle` son `Copy`). let (raw_display, raw_window) = match make_target() { wgpu::SurfaceTargetUnsafe::RawHandle { raw_display_handle, raw_window_handle, } => ( // wgpu 29 volvió opcional el display del target. `RawSurface` // lo guarda sin envolver porque lo necesita SIEMPRE para // recrearse; si el caller no lo trajo, no hay con qué recrear y // es mejor decirlo acá que fallar a mitad de una recuperación. raw_display_handle.ok_or_else(|| { HalError::CreateSurface( "new_for_raw_surface requiere un display handle en el target".into(), ) })?, raw_window_handle, ), _ => { return Err(HalError::CreateSurface( "new_for_raw_surface requiere SurfaceTargetUnsafe::RawHandle".into(), )) } }; let surface = RawSurface::from_surface(&hal, wgpu_surface, raw_display, raw_window, width, height)?; Ok((hal, surface)) } /// Reconstruye **todo el stack GPU** (instance+adapter+device+queue) y una /// [`WinitSurface`] nueva sobre la MISMA ventana, eligiendo el adapter /// compatible con la surface recreada. Es la recuperación real ante /// [`SurfaceError::DeviceLost`] en el camino winit (apps `llimphi_ui::run`: /// greeter/shuma/nada/cosmos…): reconfigurar o recrear sólo la surface no /// alcanza cuando el adapter dejó de matchear (Iris Xe «queue family»); hay /// que rehacer el device entero. Tras esto el caller debe reconstruir lo que /// cuelga del device (renderer vello, compositores, texturas de la app) /// contra el `Hal` devuelto. /// /// Es **surface-first** (crea la surface antes de pedir el adapter con /// `compatible_surface`), lo correcto para garantizar el match adapter↔surface /// tras la pérdida. Bloquea internamente (`pollster`) porque la recuperación /// ocurre dentro de un handler de redraw síncrono. Respeta el override de /// backend (`LLIMPHI_WGPU_BACKEND`) igual que [`Hal::new`]. /// /// **No existe en wasm**, justamente por ese `pollster`: en el navegador no /// hay hilos y el `Condvar` de `block_on` paniquea. La recuperación de /// device en web tendría que ser asíncrona, como el arranque /// ([`Hal::for_canvas`]); hoy no está, y que la función no exista convierte /// el intento en un error de compilación en vez de un `unreachable` en /// producción. #[cfg(not(target_arch = "wasm32"))] pub fn recreate_for_window( window: Arc, ) -> Result<(Self, WinitSurface), HalError> { let forced = backend_override(); // La ventana YA implementa `HasDisplayHandle`, así que acá enhebrar el // display no cuesta nada: se clona el `Arc`. Importa porque este camino // también cae a `Backends::all()` —o sea GL— si el adapter PRIMARY no // vuelve, y es la recuperación tras perder el device: presentar mal acá // significa una ventana negra permanente. let make_instance = |backends: wgpu::Backends| { instancia_con(backends, window.clone()) }; let request_adapter = |inst: &wgpu::Instance, surf: &wgpu::Surface<'static>| { pollster::block_on(inst.request_adapter(&wgpu::RequestAdapterOptions { power_preference: power_preference_override(), force_fallback_adapter: false, compatible_surface: Some(surf), })) }; let primary = make_instance(forced.unwrap_or(wgpu::Backends::PRIMARY)); let prim_surface = primary .create_surface(window.clone()) .map_err(|e| HalError::CreateSurface(e.to_string()))?; let (instance, adapter, surface) = match request_adapter(&primary, &prim_surface) { Ok(a) => (primary, a, prim_surface), // Con override explícito NO caemos a otros backends: el operador eligió. Err(_) if forced.is_some() => return Err(HalError::NoAdapter), Err(_) => { let all = make_instance(wgpu::Backends::all()); let surface = all .create_surface(window.clone()) .map_err(|e| HalError::CreateSurface(e.to_string()))?; let a = request_adapter(&all, &surface).map_err(|_| HalError::NoAdapter)?; (all, a, surface) } }; let limits = limites_para(&adapter); let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { label: Some("llimphi-hal-device"), required_features: wgpu::Features::empty(), required_limits: limits, memory_hints: resolver_memory_hint(&adapter), experimental_features: wgpu::ExperimentalFeatures::default(), trace: wgpu::Trace::Off, })) .map_err(|e| HalError::RequestDevice(e.to_string()))?; instalar_handler_errores(&device); let hal = Self { instance, adapter, device, queue, }; let surface = WinitSurface::from_surface(&hal, window, surface)?; Ok((hal, surface)) } } /// Surface basada en `winit::window::Window`. Mantiene una textura /// intermedia `Rgba8Unorm` con storage binding (donde pinta vello) y /// un `TextureBlitter` que la copia al swapchain al presentar. pub struct WinitSurface { window: Arc, /// `None` sólo entre [`WinitSurface::soltar_vk_surface`] y la creación de /// la siguiente: en Android hay que DESTRUIR la VkSurface antes de crear /// otra sobre la misma `ANativeWindow`. surface: Option>, config: wgpu::SurfaceConfiguration, device: wgpu::Device, intermediate: wgpu::Texture, intermediate_view: wgpu::TextureView, /// Textura de la capa de overlay (ver [`Frame::overlay_view`]). overlay: wgpu::Texture, overlay_view: wgpu::TextureView, blitter: wgpu::util::TextureBlitter, /// La instancia con que se creó la surface: guardada para poder RECREARLA /// desde la ventana ante un hipo del compositor/GPU (ver /// [`WinitSurface::recreate`]) antes de escalar a un rebuild de device. instance: wgpu::Instance, /// El `present` descubrió que el device está perdido. Lo levanta /// [`Surface::present`] y lo cobra el siguiente [`Surface::acquire`], /// devolviendo [`SurfaceError::DeviceLost`] para que el runtime rehaga el /// stack por el camino que ya existe. Ver el porqué en `present`. device_perdido: bool, } /// El formato de la textura contra la que rasteriza **siempre** el motor. /// /// Es público porque el rasterizador lo necesita y no puede deducirlo: un /// `TextureView` de wgpu no dice su formato. Y es un solo formato, no el del /// swapchain, porque [`Frame::view`] y [`Frame::overlay_view`] devuelven las /// texturas **intermedias**, no la de presentación — el blitter de /// `llimphi-hal` es quien traduce al formato que pida la pantalla. Eso le /// ahorra al rasterizador tener que compilar pipelines por formato, que en /// GPUs móviles es la operación cara (y en el Mali del teléfono, la peligrosa). pub const INTERMEDIATE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; impl WinitSurface { /// Constructor "feliz": crea la `wgpu::Surface` internamente. /// Conveniente en desktop donde la secuencia normal es /// `Hal::new(None)` → `WinitSurface::new(hal, window)`. **En Android /// usar [`WinitSurface::from_surface`]** — allí la surface debe /// existir antes del `request_adapter(compatible_surface=Some(...))`, /// y crearla dos veces sobre la misma `ANativeWindow` falla con /// `ERROR_NATIVE_WINDOW_IN_USE_KHR`. pub fn new(hal: &Hal, window: Arc) -> Result { let surface = hal .instance .create_surface(window.clone()) .map_err(|e| HalError::CreateSurface(e.to_string()))?; Self::from_surface(hal, window, surface) } /// Constructor reutilizable: arma el `WinitSurface` envolviendo una /// `wgpu::Surface` ya creada por el caller. Necesario en Android /// porque el orden allí es: /// /// 1. `instance.create_surface(window)` /// 2. `instance.request_adapter(compatible_surface=Some(&surface))` /// 3. `adapter.request_device(...)` /// 4. `WinitSurface::from_surface(hal, window, surface)` /// /// — no se puede dropear la surface entre 2 y 4 ni recrearla, porque /// Android reserva la `ANativeWindow` por VkSurface y rechaza un /// segundo `vkCreateAndroidSurfaceKHR` sobre la misma ventana. pub fn from_surface( hal: &Hal, window: Arc, surface: wgpu::Surface<'static>, ) -> Result { let size = window.inner_size(); let caps = surface.get_capabilities(&hal.adapter); // Preferimos Bgra8Unorm o Rgba8Unorm (no sRGB) para que el blit // desde la intermedia lineal preserve los valores tal cual. Más // precisión sólo si la pide `LLIMPHI_COLOR` — ver [`ColorTarget`]. let format = formato_de_swapchain(&caps, "ventana").unwrap_or(caps.formats[0]); let config = wgpu::SurfaceConfiguration { // El swapchain solo necesita render-attachment: vello no escribe // directo, escribe a la intermedia y luego se blittea. usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format, width: size.width.max(1), height: size.height.max(1), present_mode: choose_present_mode(&caps), desired_maximum_frame_latency: 2, alpha_mode: caps.alpha_modes[0], view_formats: vec![], }; configure_checked(&hal.device, &surface, &config).map_err(HalError::CreateSurface)?; let (intermediate, intermediate_view) = create_intermediate(&hal.device, config.width, config.height); let (overlay, overlay_view) = create_intermediate(&hal.device, config.width, config.height); let blitter = wgpu::util::TextureBlitter::new(&hal.device, format); Ok(Self { window, surface: Some(surface), config, device: hal.device.clone(), intermediate, intermediate_view, overlay, overlay_view, blitter, instance: hal.instance.clone(), device_perdido: false, }) } pub fn format(&self) -> wgpu::TextureFormat { self.config.format } /// La `wgpu::Surface` viva. Sólo falta entre [`soltar_vk_surface`] y la /// creación de la siguiente, ventana en la que nadie debe pedir frames. /// /// [`soltar_vk_surface`]: WinitSurface::soltar_vk_surface #[inline] fn vk(&self) -> &wgpu::Surface<'static> { self.surface .as_ref() .expect("WinitSurface usada después de soltar su VkSurface") } /// **Destruye la `VkSurface` ya mismo**, sin tocar el resto. /// /// Existe por la regla de Android: **una sola `VkSurface` por /// `ANativeWindow`**. Rehacer el stack GPU tras un `DeviceLost` crea la /// surface nueva contra la MISMA ventana, así que si la vieja sigue viva /// `vkCreateAndroidSurfaceKHR` falla y `wgpu-hal` lo trata como fatal /// (`create_android_surface(...).expect("AndroidSurface failed")`, /// `vulkan/instance.rs:525`) → abort. Medido en metal el 2026-07-28: el /// rebuild moría justo ahí, porque `rebuild_gpu_after_device_loss` sólo /// reemplazaba `state.surface` **después** de haber creado la nueva. /// /// Es responsabilidad del caller no pedir frames entre esta llamada y la /// instalación de la surface nueva. pub fn soltar_vk_surface(&mut self) { self.surface = None; } /// Recrea la `wgpu::Surface` desde la MISMA ventana winit y la reconfigura. /// Paso intermedio de recuperación: barato frente a rehacer el device. /// Lo llama [`WinitSurface::acquire`] cuando `get_current_texture` devuelve /// `Outdated`/`Lost` y reconfigurar no bastó. Si tras esto la surface sigue /// rota (el adapter dejó de matchear — Iris Xe «queue family»), `acquire` /// escala a [`SurfaceError::DeviceLost`] y el dueño del bucle reconstruye el /// `Hal` entero con [`Hal::recreate_for_window`]. fn recreate(&mut self) -> Result<(), String> { // La vieja MUERE antes de crear la nueva: en Android crear una segunda // VkSurface sobre la misma ANativeWindow falla // (`ERROR_NATIVE_WINDOW_IN_USE_KHR`), y en el resto de las plataformas // soltarla primero no cuesta nada. self.surface = None; let surface = self .instance .create_surface(self.window.clone()) .map_err(|e| format!("create_surface: {e}"))?; configure_checked(&self.device, &surface, &self.config)?; self.surface = Some(surface); Ok(()) } } /// Surface sobre una `wgpu::Surface` creada desde **handles raw** (sin /// `winit::Window`): la usa el backend `wlr-layer-shell` de `pata` para pintar /// en una *layer surface* de Wayland (barras/paneles al nivel de eww/waybar). /// Misma mecánica que [`WinitSurface`] —intermedia `Rgba8Unorm` + blit al /// swapchain— pero el tamaño se pasa explícito porque no hay ventana que /// consultar. La `wgpu::Surface` la crea el caller (típicamente con /// `instance.create_surface_unsafe` desde los punteros `wl_display`/`wl_surface`). pub struct RawSurface { surface: wgpu::Surface<'static>, config: wgpu::SurfaceConfiguration, device: wgpu::Device, intermediate: wgpu::Texture, intermediate_view: wgpu::TextureView, overlay: wgpu::Texture, overlay_view: wgpu::TextureView, blitter: wgpu::util::TextureBlitter, /// La instancia + los raw handles del `wl_surface`/`wl_display`, guardados para /// **recrear** la `wgpu::Surface` cuando queda irrecuperable (ver /// [`RawSurface::recreate`]). Los `RawHandle` son `Copy` y apuntan a objetos /// que el caller mantiene vivos toda la vida de la `RawSurface`. instance: wgpu::Instance, raw_display: raw_window_handle::RawDisplayHandle, raw_window: raw_window_handle::RawWindowHandle, } impl RawSurface { /// Envuelve una `wgpu::Surface` ya creada, con el tamaño físico inicial. Los /// `raw_display`/`raw_window` son los handles con que se creó la surface: se /// guardan para poder RECREARLA si el compositor la invalida (Iris Xe), ya que /// reconfigurar la misma surface no siempre recupera. pub fn from_surface( hal: &Hal, surface: wgpu::Surface<'static>, raw_display: raw_window_handle::RawDisplayHandle, raw_window: raw_window_handle::RawWindowHandle, width: u32, height: u32, ) -> Result { let caps = surface.get_capabilities(&hal.adapter); let info = hal.adapter.get_info(); // Si la superficie no expone formatos, el compositor no la soporta por // este backend (Vulkan/GL WSI): error claro en vez de un panic por // indexar `formats[0]` sobre una lista vacía. let format = match formato_de_swapchain(&caps, "layer") { Some(f) => f, None => { return Err(HalError::CreateSurface(format!( "la superficie no expone formatos (adapter {:?}/{:?}): el compositor no la soporta por {:?} WSI", info.backend, info.device_type, info.backend ))) } }; // Para una layer surface (wlr-layer-shell) la transparencia es // crítica: la usamos para popovers/menús que pintan un panel chico y // dejan el resto transparente para ver el escritorio. La heurística // ingenua `caps.alpha_modes.first()` cae a veces en `Opaque` (el // compositor descarta alpha) — el clear TRANSPARENT se compone como // negro literal y el menú inicio sale como un cuadrón negro. // // Preferencia: PreMultiplied > PostMultiplied > Inherit > Auto > // Opaque. Los dos primeros componen alpha como esperamos; los dos // siguientes dejan que el compositor decida (típicamente respeta el // alpha del buffer ARGB); Opaque es el último recurso. let alpha_mode = { use wgpu::CompositeAlphaMode as Mode; let want = [ Mode::PreMultiplied, Mode::PostMultiplied, Mode::Inherit, Mode::Auto, ]; want.iter() .copied() .find(|m| caps.alpha_modes.contains(m)) .or_else(|| caps.alpha_modes.first().copied()) .unwrap_or(Mode::Auto) }; let config = wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format, width: width.max(1), height: height.max(1), present_mode: choose_present_mode(&caps), desired_maximum_frame_latency: 2, alpha_mode, view_formats: vec![], }; configure_checked(&hal.device, &surface, &config).map_err(HalError::CreateSurface)?; let (intermediate, intermediate_view) = create_intermediate(&hal.device, config.width, config.height); let (overlay, overlay_view) = create_intermediate(&hal.device, config.width, config.height); let blitter = wgpu::util::TextureBlitter::new(&hal.device, format); Ok(Self { surface, config, device: hal.device.clone(), intermediate, intermediate_view, overlay, overlay_view, blitter, instance: hal.instance.clone(), raw_display, raw_window, }) } pub fn format(&self) -> wgpu::TextureFormat { self.config.format } /// Recrea la `wgpu::Surface` desde el raw handle del `wl_surface` y la /// reconfigura. Es la recuperación REAL cuando la surface quedó irrecuperable /// —p. ej. `configure` falla con «Surface does not support the adapter's queue /// family» tras un reset del compositor en Iris Xe—: reconfigurar la MISMA /// surface no basta, hay que crear una nueva. Devuelve el error si falla (el /// caller salta el frame y reintenta). Sin esto, la barra quedaba en un bucle /// de «surface perdida» hasta que se caía la conexión (Broken pipe). fn recreate(&mut self) -> Result<(), String> { let target = wgpu::SurfaceTargetUnsafe::RawHandle { // wgpu 29 volvió opcional el display del target. raw_display_handle: Some(self.raw_display), raw_window_handle: self.raw_window, }; // SAFETY: los handles apuntan a objetos Wayland que el caller mantiene // vivos toda la vida de la `RawSurface` (mismo contrato que `from_surface`). let surface = unsafe { self.instance.create_surface_unsafe(target) } .map_err(|e| format!("create_surface_unsafe: {e}"))?; configure_checked(&self.device, &surface, &self.config)?; self.surface = surface; Ok(()) } } impl Surface for RawSurface { fn size(&self) -> (u32, u32) { (self.config.width, self.config.height) } fn resize(&mut self, width: u32, height: u32) { let (w, h) = (width.max(1), height.max(1)); // Sin cambio de tamaño NO reconfiguramos. El backend layer-shell de `pata` // llama a `resize` en cada cuadro (no tiene eventos de resize como winit); // reconfigurar el swapchain por cuadro lo reconstruye una y otra vez, y en // Vulkan WSI eso **destruye el `wl_buffer` recién presentado antes de que el // compositor lo componga** — wlroots lo tolera, smithay (mirada) no, y la // superficie queda en negro (el compositor ve `buffer=None`). if self.config.width == w && self.config.height == h { return; } self.config.width = w; self.config.height = h; if let Err(e) = configure_checked(&self.device, &self.surface, &self.config) { // Igual que en `acquire`: si reconfigurar no recupera, recreamos la // surface desde el raw handle antes de rendirnos. match self.recreate() { Ok(()) => crate::avisar!("llimphi-hal: surface RECREADA en resize (reconfigure falló: {e})"), Err(e2) => { crate::avisar!("llimphi-hal: configure en resize falló ({e}); recrear también falló ({e2})") } } } let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height); self.intermediate = tex; self.intermediate_view = view; let (otex, oview) = create_intermediate(&self.device, self.config.width, self.config.height); self.overlay = otex; self.overlay_view = oview; } fn acquire(&mut self) -> Result { // Gancho de verificación: DeviceLost sintético cada N cuadros // (`LLIMPHI_SIMULATE_DEVICE_LOSS`). No-op sin la env. if simulate_device_loss_trip() { return Err(SurfaceError::DeviceLost); } // wgpu 29 dejó de devolver `Result` acá: ahora es un enum de estado. La // traducción no es literal en un punto y conviene tenerlo claro — // `Suboptimal` **trae textura válida** y en wgpu 27 llegaba como `Ok` // con un `suboptimal: bool` al costado. Tratarlo como error sería tirar // un fotograma perfectamente bueno en cada cambio de tamaño. use wgpu::CurrentSurfaceTexture as Estado; let texture = match self.surface.get_current_texture() { Estado::Success(t) | Estado::Suboptimal(t) => t, // El backend layer-shell no tiene un evento de resize que reconfigure // el swapchain; si quedó obsoleto/perdido, lo reconstruimos aquí mismo // y reintentamos una vez. Sin esto el panel quedaría en negro para // siempre tras el primer `Outdated`. e @ (Estado::Outdated | Estado::Lost) => { // Reconfigurar CAPTURANDO: si la surface está realmente perdida, // `configure` emite un error de validación que, sin captura, // mataría el proceso (era el crash de pata). Aquí lo devolvemos // como `DeviceLost` y el caller reconstruye el stack GPU. if let Err(msg) = configure_checked(&self.device, &self.surface, &self.config) { // Reconfigurar no recuperó (surface realmente perdida): RECREAR // desde el raw handle contra el MISMO device. Si eso también // falla, el device quedó irrecuperable (el adapter dejó de // matchear — Iris Xe «queue family») → DeviceLost: el runner // de pata rehace `Hal` + todas las surfaces/renderers. match self.recreate() { Ok(()) => crate::avisar!( "llimphi-hal: surface RECREADA tras {e:?} (reconfigure falló: {msg})" ), Err(msg2) => { crate::avisar!( "llimphi-hal: reconfigurar tras {e:?} falló ({msg}); recrear también falló ({msg2}) → DeviceLost" ); return Err(SurfaceError::DeviceLost); } } } // Sigue rota tras reconfigurar+recrear → device irrecuperable. match self.surface.get_current_texture() { Estado::Success(t) | Estado::Suboptimal(t) => t, _ => return Err(SurfaceError::DeviceLost), } } Estado::Timeout => return Err(SurfaceError::Timeout), // Ventana tapada (minimizada o detrás de otra). **No es una falla**, // y confundirla con una sale caro: el runtime cuenta acquires // fallidos y a los 8 rehace el stack GPU entero, así que una ventana // tapada ocho cuadros dispararía una reconstrucción por nada. Estado::Occluded => return Err(SurfaceError::Occluded), otro => return Err(SurfaceError::Other(format!("{otro:?}"))), }; let surface_view = texture .texture .create_view(&wgpu::TextureViewDescriptor::default()); Ok(Frame { surface_texture: texture, surface_view, intermediate_view: self.intermediate_view.clone(), overlay_view: self.overlay_view.clone(), width: self.config.width, height: self.config.height, }) } fn present(&mut self, frame: Frame, hal: &Hal) { let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("llimphi-blit-raw"), }); self.blitter.copy( &hal.device, &mut encoder, &frame.intermediate_view, &frame.surface_view, ); hal.queue.submit(std::iter::once(encoder.finish())); frame.surface_texture.present(); } } // --------------------------------------------------------------------------- // El HAL del navegador: un `` del DOM // --------------------------------------------------------------------------- /// Surface sobre un `` del DOM. Es la tercera implementación de /// [`Surface`], hermana de [`WinitSurface`] y [`RawSurface`], y la única que /// existe en `wasm32`: el resto del stack (raster, layout, text, compositor) ya /// compila para wasm sin tocar nada. /// /// Misma mecánica que las otras dos —intermedia `Rgba8Unorm` con storage /// binding donde pinta vello + blit al swapchain— pero: /// /// - La `wgpu::Surface` se crea con [`wgpu::SurfaceTarget::Canvas`], no desde /// una ventana ni desde handles raw. /// - **No se puede bloquear el hilo.** El main thread del navegador no admite /// `pollster::block_on`; por eso el camino de construcción es /// [`Hal::for_canvas`], `async`, y `configure` no usa error-scopes (que /// resuelven de forma asíncrona). Ver [`configure_web`]. /// - El tamaño de la surface es el **backing store** del canvas /// (`canvas.width`/`canvas.height`, en píxeles físicos), que NO es su tamaño /// CSS. Confundirlos es el bug clásico del canvas borroso en pantallas HiDPI: /// usar [`CanvasSurface::resize_to_css`] para sincronizarlos por /// `devicePixelRatio`. /// /// ## WebGPU sí, WebGL2 todavía no /// /// El renderer full-GPU de vello escribe la intermedia por **compute shader**, /// y eso exige `STORAGE_BINDING` — que WebGPU da y **WebGL2 no tiene**. Si el /// navegador cae al backend GL, [`CanvasSurface::from_surface`] devuelve un /// error explícito en vez de dejar que reviente como error de validación /// críptico. El camino WebGL2 es `vello_hybrid` (feature `hybrid` de /// `llimphi-raster`, CPU+GPU sin compute), que necesita su propia surface /// porque no pinta a una storage texture. Pendiente. #[cfg(target_arch = "wasm32")] pub struct CanvasSurface { canvas: web_sys::HtmlCanvasElement, surface: wgpu::Surface<'static>, config: wgpu::SurfaceConfiguration, device: wgpu::Device, #[allow(dead_code)] intermediate: wgpu::Texture, intermediate_view: wgpu::TextureView, #[allow(dead_code)] overlay: wgpu::Texture, overlay_view: wgpu::TextureView, blitter: wgpu::util::TextureBlitter, } #[cfg(target_arch = "wasm32")] impl Hal { /// Construye el stack GPU entero atado a un ``: instancia, /// surface, adapter compatible, device y [`CanvasSurface`]. **Es el /// equivalente web de [`Hal::recreate_for_window`]**, y por el mismo /// motivo hace surface-first: el adapter se pide con /// `compatible_surface: Some(&surface)` para que el navegador entregue uno /// que sepa presentar a ESE canvas. /// /// `async` sin alternativa: en el main thread del navegador no se puede /// bloquear. El caller típico es /// `wasm_bindgen_futures::spawn_local(async move { Hal::for_canvas(c).await })`. pub async fn for_canvas( canvas: web_sys::HtmlCanvasElement, ) -> Result<(Self, CanvasSurface), HalError> { // En web no aplica la preferencia PRIMARY-o-nada del escritorio (el bug // de teardown de EGL/Mesa no existe acá): `all()` deja que el navegador // ofrezca WebGPU y, si no hay, WebGL2 — el chequeo de storage textures // en `CanvasSurface::from_surface` es el que decide si sirve. // Sin display: en el navegador no hay uno que pasar — el display handle // del `InstanceDescriptor` es para el sistema de ventanas nativo. let instance = instancia(wgpu::Backends::all()); let surface = instance .create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone())) .map_err(|e| HalError::CreateSurface(e.to_string()))?; let adapter = instance .request_adapter(&wgpu::RequestAdapterOptions { power_preference: power_preference_override(), force_fallback_adapter: false, compatible_surface: Some(&surface), }) .await .map_err(|_| HalError::NoAdapter)?; let limits = limites_para(&adapter); let (device, queue) = adapter .request_device(&wgpu::DeviceDescriptor { label: Some("llimphi-hal-device-canvas"), required_features: wgpu::Features::empty(), required_limits: limits, memory_hints: resolver_memory_hint(&adapter), experimental_features: wgpu::ExperimentalFeatures::default(), trace: wgpu::Trace::Off, }) .await .map_err(|e| HalError::RequestDevice(e.to_string()))?; instalar_handler_errores(&device); let hal = Self { instance, adapter, device, queue, }; let surface = CanvasSurface::from_surface(&hal, canvas, surface)?; Ok((hal, surface)) } } #[cfg(target_arch = "wasm32")] impl CanvasSurface { /// Envuelve una `wgpu::Surface` ya creada sobre el canvas. La ruta normal /// es [`Hal::for_canvas`], que hace las dos cosas en el orden correcto; /// este constructor existe para quien ya tiene un `Hal` armado. pub fn from_surface( hal: &Hal, canvas: web_sys::HtmlCanvasElement, surface: wgpu::Surface<'static>, ) -> Result { // Guarda de WebGL2: sin storage textures la intermedia de vello no se // puede crear y el error real aparecería mucho después, como validación // ilegible en medio del primer frame. let feats = hal.adapter.get_texture_format_features(INTERMEDIATE_FORMAT); if !feats .allowed_usages .contains(wgpu::TextureUsages::STORAGE_BINDING) { let info = hal.adapter.get_info(); return Err(HalError::CreateSurface(format!( "el adapter del navegador ({:?}) no admite STORAGE_BINDING en {INTERMEDIATE_FORMAT:?}: \ es el camino WebGL2, y el renderer full-GPU de vello necesita compute. \ Hace falta un navegador con WebGPU, o el renderer `vello_hybrid` \ (feature `hybrid` de llimphi-raster), todavía sin surface propia.", info.backend ))); } let caps = surface.get_capabilities(&hal.adapter); let format = match formato_de_swapchain(&caps, "canvas") { Some(f) => f, None => { return Err(HalError::CreateSurface( "el canvas no expone formatos de swapchain".to_string(), )) } }; let config = wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format, // El backing store del canvas, en píxeles FÍSICOS. Ver // `resize_to_css` para el puente desde el tamaño CSS. width: canvas.width().max(1), height: canvas.height().max(1), present_mode: choose_present_mode(&caps), desired_maximum_frame_latency: 2, // En un canvas embebido en una página, `Opaque` es lo que // corresponde salvo que la app quiera dejar ver el fondo del // documento; el navegador lista primero lo que soporta. alpha_mode: caps.alpha_modes[0], view_formats: vec![], }; configure_web(&hal.device, &surface, &config); let (intermediate, intermediate_view) = create_intermediate(&hal.device, config.width, config.height); let (overlay, overlay_view) = create_intermediate(&hal.device, config.width, config.height); let blitter = wgpu::util::TextureBlitter::new(&hal.device, format); Ok(Self { canvas, surface, config, device: hal.device.clone(), intermediate, intermediate_view, overlay, overlay_view, blitter, }) } pub fn format(&self) -> wgpu::TextureFormat { self.config.format } pub fn canvas(&self) -> &web_sys::HtmlCanvasElement { &self.canvas } /// Sincroniza el backing store del canvas con su tamaño **CSS** por /// `devicePixelRatio` y reconfigura el swapchain. Es lo que hay que llamar /// ante un `resize` de la ventana o un cambio de zoom/monitor. /// /// Sin esto el canvas se ve **borroso** en pantallas HiDPI: el navegador /// escala un backing store de 800×600 a 1600×1200 CSS px. Devuelve el /// tamaño físico resultante para que el caller alimente el layout (que /// trabaja en px físicos, igual que en escritorio). pub fn resize_to_css(&mut self) -> (u32, u32) { let dpr = web_sys::window().map(|w| w.device_pixel_ratio()).unwrap_or(1.0); let css_w = self.canvas.client_width().max(1) as f64; let css_h = self.canvas.client_height().max(1) as f64; let w = (css_w * dpr).round() as u32; let h = (css_h * dpr).round() as u32; self.resize(w, h); (self.config.width, self.config.height) } } #[cfg(target_arch = "wasm32")] impl Surface for CanvasSurface { fn size(&self) -> (u32, u32) { (self.config.width, self.config.height) } fn resize(&mut self, width: u32, height: u32) { let (w, h) = (width.max(1), height.max(1)); if self.config.width == w && self.config.height == h { return; } self.config.width = w; self.config.height = h; // El backing store del canvas manda: si no se actualiza, el navegador // escala el resultado y la surface queda desincronizada del DOM. self.canvas.set_width(w); self.canvas.set_height(h); configure_web(&self.device, &self.surface, &self.config); let (tex, view) = create_intermediate(&self.device, w, h); self.intermediate = tex; self.intermediate_view = view; let (otex, oview) = create_intermediate(&self.device, w, h); self.overlay = otex; self.overlay_view = oview; } fn acquire(&mut self) -> Result { // Mismo enum que el camino nativo (wgpu 29 dejó de devolver `Result`): // `Suboptimal` **trae textura válida** y tirarla sería perder un cuadro // bueno en cada cambio de tamaño — que en el navegador es cada arrastre // del borde de la ventana. use wgpu::CurrentSurfaceTexture as Estado; let texture = match self.surface.get_current_texture() { Estado::Success(t) | Estado::Suboptimal(t) => t, // En el navegador no hay un `wl_surface` que recrear: la // recuperación es reconfigurar y reintentar una vez. Un // `Outdated` acá suele ser el canvas redimensionado entre frames. Estado::Outdated | Estado::Lost => { configure_web(&self.device, &self.surface, &self.config); match self.surface.get_current_texture() { Estado::Success(t) | Estado::Suboptimal(t) => t, _ => return Err(SurfaceError::DeviceLost), } } Estado::Timeout => return Err(SurfaceError::Timeout), // Pestaña en segundo plano / canvas fuera de vista. No es falla: el // runtime cuenta acquires fallidos y a los 8 rehace el stack GPU, // así que confundirla con una reconstruye todo por nada. Estado::Occluded => return Err(SurfaceError::Occluded), otro => return Err(SurfaceError::Other(format!("{otro:?}"))), }; let surface_view = texture .texture .create_view(&wgpu::TextureViewDescriptor::default()); Ok(Frame { surface_texture: texture, surface_view, intermediate_view: self.intermediate_view.clone(), overlay_view: self.overlay_view.clone(), width: self.config.width, height: self.config.height, }) } fn present(&mut self, frame: Frame, hal: &Hal) { let mut encoder = hal .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("llimphi-blit-canvas"), }); self.blitter.copy( &hal.device, &mut encoder, &frame.intermediate_view, &frame.surface_view, ); hal.queue.submit(std::iter::once(encoder.finish())); frame.surface_texture.present(); } } /// `configure` para el navegador: **sin** error-scope. Lo usan tanto /// [`CanvasSurface`] como —vía [`configure_checked`]— [`WinitSurface`] y /// [`RawSurface`] cuando el target es wasm. /// /// [`configure_checked`] captura los errores de validación con /// `push_error_scope`/`pop_error_scope`, y `pop_error_scope` devuelve un /// futuro que en escritorio se resuelve con `pollster::block_on`. En el main /// thread del navegador bloquear está prohibido —`block_on` espera sobre un /// `Condvar` y en wasm32 eso **paniquea**, que en la página se ve como /// `Uncaught RuntimeError: unreachable`— y esperar el scope de forma asíncrona /// convertiría en `async` toda la cadena `resize`/`acquire`, que es sincrónica /// por contrato del trait [`Surface`]. /// /// A cambio, un fallo de configuración acá no se detecta en el acto: aparece /// en el siguiente `get_current_texture`, que [`CanvasSurface::acquire`] /// maneja reconfigurando y, si no recupera, devolviendo `DeviceLost`. Los /// errores no capturados igual salen por el handler de /// [`instalar_handler_errores`], que loguea en vez de matar el proceso. #[cfg(target_arch = "wasm32")] fn configure_web( device: &wgpu::Device, surface: &wgpu::Surface<'static>, config: &wgpu::SurfaceConfiguration, ) { surface.configure(device, config); } /// Elige el modo de presentación del swapchain. /// /// Default: **Mailbox** si el driver lo expone, sino **Fifo**. La razón es /// el cuelgue observado en las apps Llimphi (investigación 2026-05-30): con /// `Fifo`/`AutoVsync`, `surface.get_current_texture()` **bloquea** esperando /// el frame-callback del compositor Wayland — si el compositor no suelta un /// buffer, el hilo del UI queda dormido (CPU baja, deadlock aparente). /// `Mailbox` no bloquea (triple-buffer, descarta frames viejos), así que el /// loop nunca se queda esperando al compositor. `Fifo` está garantizado por /// spec como fallback. /// /// Override por entorno para A/B sin recompilar (útil en la laptop con /// display real): `LLIMPHI_PRESENT_MODE = fifo | mailbox | immediate | /// fifo_relaxed`. Si el modo pedido no está soportado, se ignora y se aplica /// el default. fn choose_present_mode(caps: &wgpu::SurfaceCapabilities) -> wgpu::PresentMode { use wgpu::PresentMode::{Fifo, FifoRelaxed, Immediate, Mailbox}; if let Ok(v) = std::env::var("LLIMPHI_PRESENT_MODE") { let want = match v.trim().to_ascii_lowercase().as_str() { "fifo" | "vsync" => Some(Fifo), "fifo_relaxed" | "fiforelaxed" => Some(FifoRelaxed), "mailbox" => Some(Mailbox), "immediate" | "novsync" => Some(Immediate), _ => None, }; if let Some(m) = want { if caps.present_modes.contains(&m) { return m; } } } if caps.present_modes.contains(&Mailbox) { Mailbox } else { Fifo } } /// Instala en el device un handler de errores wgpu **no capturados** que LOGUEA /// en vez de dejar que el default de wgpu («Handling wgpu errors as fatal by /// default») paniquee el proceso entero. Un glitch de device —p. ej. la pérdida /// transitoria en un flap de VT bajo arje— NO debe tumbar la app (greeter, pata…): /// la recuperación de surface (`Outdated`/`Lost`) rearma en el próximo frame. Los /// errores de validación puntuales se siguen capturando con error-scopes aparte. /// /// **En wasm el aviso va a la consola del navegador.** Con `eprintln!` a secas /// el mensaje se escribe en un stderr que en una página no existe: un error de /// validación durante el blit desaparecía sin rastro y el síntoma llegaba como /// «el canvas no pinta», sin una sola línea que explicara por qué. /// Se prendió el callback de device perdido de ALGÚN device de este proceso. /// /// Global a propósito: todos los devices de una app llimphi salen del mismo /// `Hal`, y perder uno es una condición del proceso, no de una surface. Lo lee /// [`Surface::present`] —un `load` relajado por frame, costo cero— para **no /// llamar a `wgpu::SurfaceTexture::present` sobre un device muerto**, que /// paniquea de forma fatal e inatajable (ni error-scope ni handler lo salvan). static DEVICE_PERDIDO_GLOBAL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); fn instalar_handler_errores(device: &wgpu::Device) { // **Callback de device perdido.** Sin esto, perder el device se entera uno // por el DAÑO —`Parent device is lost` colgando de un `present`, o texturas // que nacen inválidas— y nunca por la CAUSA: wgpu tiene el motivo y el // mensaje del backend, y si nadie los pide se tiran. Costó una noche de // Android 2026-07-27: el device se perdía en el primer frame y el log sólo // mostraba los síntomas río abajo. device.set_device_lost_callback(|razon, msg| { DEVICE_PERDIDO_GLOBAL.store(true, std::sync::atomic::Ordering::Relaxed); let aviso = format!("llimphi-hal: DEVICE PERDIDO ({razon:?}): {msg}"); #[cfg(target_arch = "wasm32")] web_sys::console::error_1(&aviso.as_str().into()); #[cfg(not(target_arch = "wasm32"))] crate::avisar!("{aviso}"); }); device.on_uncaptured_error(std::sync::Arc::new(|e| { #[cfg(target_arch = "wasm32")] web_sys::console::error_1( &format!("llimphi-hal: error wgpu no capturado (no fatal, ignorado): {e}").into(), ); #[cfg(not(target_arch = "wasm32"))] crate::avisar!("llimphi-hal: error wgpu no capturado (no fatal, ignorado): {e}"); })); } /// Configura el swapchain **capturando** los errores de validación de wgpu, en /// vez de dejar que el handler por defecto («wgpu errors as fatal») paniquee el /// proceso entero. Devuelve `Err(mensaje)` si la configuración falló — casi /// siempre porque la surface se perdió (`SURFACE_LOST`, un hipo transitorio del /// compositor/GPU): el caller decide (saltar el frame, marcar perdida) en vez de /// morir. Robustez: una app no debe caerse por un glitch del surface (lo que /// tumbaba a `pata` con «Surface does not support the adapter's queue family»). /// /// Los errores de validación de wgpu son CPU-side: el scope los captura durante /// la llamada, así que `pop_error_scope` resuelve sin bloquear contra la GPU. /// /// **En wasm delega en [`configure_web`] y no captura nada.** «Sin bloquear /// contra la GPU» no es lo mismo que «sin bloquear»: `pop_error_scope` sigue /// devolviendo un futuro, y `pollster::block_on` lo espera sobre un `Condvar`. /// En `wasm32-unknown-unknown` no hay hilos y ese `Condvar::wait` **paniquea** /// (`std/sys/sync/condvar/no_threads.rs`), lo que en el navegador se ve como un /// `Uncaught RuntimeError: unreachable`. Lo encontró correr el bucle web de /// verdad en Chrome headless — ningún `cargo check` lo podía ver. fn configure_checked( device: &wgpu::Device, surface: &wgpu::Surface<'static>, config: &wgpu::SurfaceConfiguration, ) -> Result<(), String> { #[cfg(target_arch = "wasm32")] { configure_web(device, surface, config); return Ok(()); } #[cfg(not(target_arch = "wasm32"))] { // wgpu 29 mudó el `pop` al guard que devuelve `push_error_scope`, en // vez de tenerlo en el device. Es la misma captura de siempre. let alcance = device.push_error_scope(wgpu::ErrorFilter::Validation); surface.configure(device, config); match pollster::block_on(alcance.pop()) { Some(e) => Err(e.to_string()), None => Ok(()), } } } /// Igual que [`create_intermediate`] pero **capturando** los errores de /// validación de wgpu en vez de dejar que el `create_view` de la línea siguiente /// se coma un error derivado. /// /// El modo de falla que motivó esto (Android/Mali, 2026-07-27): si el /// `create_texture` falla, wgpu devuelve una textura **inválida** en vez de /// abortar, y el `create_view` inmediato emite `Texture with /// 'llimphi-intermediate' label is invalid`. Ese error derivado **tapa el /// original** — nunca se supo por qué falló la creación. Y según qué handler /// tenga instalado el device, el mismo bug da dos síntomas opuestos: con el /// default de wgpu («errors as fatal») la app **se cierra sola**; con /// [`instalar_handler_errores`] el error se loguea, la textura inválida **se /// instala igual**, y la app queda **viva pintando en negro para siempre**. /// /// Capturando acá, el caller decide: conservar las texturas viejas (que /// funcionaban) y seguir, en vez de quedarse con una rota. fn create_intermediate_checked( device: &wgpu::Device, width: u32, height: u32, ) -> Result<(wgpu::Texture, wgpu::TextureView), String> { // Web: `pop_error_scope` devuelve un futuro y `block_on` lo espera sobre un // `Condvar` que en wasm paniquea — misma razón por la que `configure_checked` // delega en `configure_web`. Ahí seguimos sin capturar. #[cfg(target_arch = "wasm32")] { return Ok(create_intermediate(device, width, height)); } #[cfg(not(target_arch = "wasm32"))] { let alcance = device.push_error_scope(wgpu::ErrorFilter::Validation); let par = create_intermediate(device, width, height); match pollster::block_on(alcance.pop()) { Some(e) => Err(e.to_string()), None => Ok(par), } } } fn create_intermediate( device: &wgpu::Device, width: u32, height: u32, ) -> (wgpu::Texture, wgpu::TextureView) { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("llimphi-intermediate"), size: wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: INTERMEDIATE_FORMAT, // STORAGE_BINDING: vello escribe via compute shader. // TEXTURE_BINDING: el blitter la lee como sampler source. // RENDER_ATTACHMENT: render passes con clear-only (sin vello) // también escriben aquí — desktop drivers lo tolerían sin este // flag, Adreno con validación estricta rechaza el frame. usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); (texture, view) } /// Compositor de la capa de overlay: alpha-blittea una textura source (el /// overlay rasterizado por vello sobre fondo transparente) SOBRE una textura /// target (la intermedia, que ya tiene la UI principal + el video pintado por /// `gpu_paint`). Resuelve el z-order: sin esto, el blit de `gpu_paint` (video) /// queda encima de la capa vello del overlay y los menús se ven por debajo del /// video. /// /// Es un pase de pantalla completa (triángulo) que samplea el source y lo /// emite con alpha-over. El factor de blend asume alpha **premultiplicado** /// (lo que produce vello); si en pantalla los menús se ven con halos oscuros o /// transparencia rara, exportar `LLIMPHI_OVERLAY_BLEND=straight` para usar /// alpha recto sin recompilar. pub struct OverlayCompositor { pipeline: wgpu::RenderPipeline, sampler: wgpu::Sampler, bind_layout: wgpu::BindGroupLayout, /// Uniform con el rect destino. Persistente: se reescribe por composite en /// vez de crear un buffer por cuadro. uniforms: wgpu::Buffer, } impl OverlayCompositor { pub fn new(device: &wgpu::Device) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("llimphi-overlay-composite"), source: wgpu::ShaderSource::Wgsl(OVERLAY_COMPOSITE_WGSL.into()), }); let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("llimphi-overlay-bgl"), entries: &[ wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::VERTEX, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, }, ], }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("llimphi-overlay-pl"), bind_group_layouts: &[Some(&bind_layout)], immediate_size: 0, }); // Alpha-over. `src_factor` distingue premultiplicado (One) de recto // (SrcAlpha); el resto es siempre OneMinusSrcAlpha. let straight = std::env::var("LLIMPHI_OVERLAY_BLEND") .map(|v| v.trim().eq_ignore_ascii_case("straight")) .unwrap_or(false); let color_src = if straight { wgpu::BlendFactor::SrcAlpha } else { wgpu::BlendFactor::One }; let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("llimphi-overlay-pipe"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default(), }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs"), targets: &[Some(wgpu::ColorTargetState { format: INTERMEDIATE_FORMAT, blend: Some(wgpu::BlendState { color: wgpu::BlendComponent { src_factor: color_src, dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, operation: wgpu::BlendOperation::Add, }, alpha: wgpu::BlendComponent { src_factor: wgpu::BlendFactor::One, dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, operation: wgpu::BlendOperation::Add, }, }), write_mask: wgpu::ColorWrites::ALL, })], compilation_options: Default::default(), }), primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview_mask: None, cache: None, }); let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("llimphi-overlay-sampler"), ..Default::default() }); // 4 floats: rect destino en NDC (x0, y0, x1, y1). Ver el WGSL. let uniforms = device.create_buffer(&wgpu::BufferDescriptor { label: Some("llimphi-overlay-uniforms"), size: 16, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); OverlayCompositor { pipeline, sampler, bind_layout, uniforms, } } /// Compone `source` (overlay con fondo transparente) sobre `target` (la /// intermedia), preservando el contenido previo del target (LoadOp::Load) /// y mezclando con alpha. Graba un render pass en `encoder`. /// /// `source` cubre el target entero. Para pegar una textura **más chica** en /// su lugar del frame —una capa over rasterizada sólo en su bounding box— /// usar [`Self::composite_rect`]. pub fn composite( &self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, source: &wgpu::TextureView, ) { // Rect destino = NDC completo: el quad cubre todo, igual que antes. self.composite_ndc(device, queue, encoder, target, source, [-1.0, 1.0, 1.0, -1.0]); } /// Compone `source` sobre `target` **restringido a `dst`** (x, y, w, h en px /// del frame, con `viewport = (ancho, alto)`), estirando la textura fuente /// para llenarlo. Es el camino que hace rentable rasterizar la capa over en /// una textura del tamaño de lo que dibuja: sin esto habría que rasterizar /// —y componer— el frame entero para pintar una barra de 5 px de alto. pub fn composite_rect( &self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, source: &wgpu::TextureView, dst: [f32; 4], viewport: (u32, u32), ) { let (vw, vh) = (viewport.0.max(1) as f32, viewport.1.max(1) as f32); // px → NDC. La Y se invierte: en pantalla crece hacia abajo, en NDC // hacia arriba. let ndc = [ dst[0] / vw * 2.0 - 1.0, 1.0 - dst[1] / vh * 2.0, (dst[0] + dst[2]) / vw * 2.0 - 1.0, 1.0 - (dst[1] + dst[3]) / vh * 2.0, ]; self.composite_ndc(device, queue, encoder, target, source, ndc); } fn composite_ndc( &self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, source: &wgpu::TextureView, ndc: [f32; 4], ) { let mut bytes = [0u8; 16]; for (i, v) in ndc.iter().enumerate() { bytes[i * 4..(i + 1) * 4].copy_from_slice(&v.to_ne_bytes()); } // El uniform se escribe por la queue; el pass de abajo lo lee en el // mismo submit. queue.write_buffer(&self.uniforms, 0, &bytes); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("llimphi-overlay-bg"), layout: &self.bind_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(source), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler), }, wgpu::BindGroupEntry { binding: 2, resource: self.uniforms.as_entire_binding(), }, ], }); let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("llimphi-overlay-composite-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: target, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, &bind_group, &[]); pass.draw(0..6, 0..1); } } /// Pase que samplea la textura de overlay y la emite para alpha-over. Un quad /// (dos triángulos) sobre el rect destino que viene por uniform en NDC — /// `[-1, 1, 1, -1]` es el viewport entero, el caso clásico. La UV recorre la /// textura fuente completa, así que una scratch del tamaño del bounding box de /// la capa over aterriza justo donde va. const OVERLAY_COMPOSITE_WGSL: &str = r#" struct VsOut { @builtin(position) pos: vec4, @location(0) uv: vec2, }; // x0, y0 = esquina superior izquierda; x1, y1 = inferior derecha. En NDC. struct Uniforms { dst: vec4 }; @group(0) @binding(2) var u: Uniforms; @vertex fn vs(@builtin(vertex_index) vi: u32) -> VsOut { var uvs = array, 6>( vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 1.0), ); let q = uvs[vi]; var out: VsOut; out.pos = vec4( mix(u.dst.x, u.dst.z, q.x), mix(u.dst.y, u.dst.w, q.y), 0.0, 1.0, ); out.uv = q; return out; } @group(0) @binding(0) var src_tex: texture_2d; @group(0) @binding(1) var src_samp: sampler; @fragment fn fs(in: VsOut) -> @location(0) vec4 { return textureSample(src_tex, src_samp, in.uv); } "#; /// Gaussian backdrop blur sobre la intermediate (la textura donde vello pinta /// la UI). El compositor empuja dos render passes separables (horizontal + /// vertical) restringidas por scissor al rect del nodo `.backdrop_blur(sigma)`, /// usando una textura scratch interna del mismo tamaño que la intermediate. /// /// **Pipeline**: vs = triángulo grande full-screen (clip-space), fs = suma /// ponderada de N samples a lo largo de `direction`, pesos Gauss `exp(-i²/2σ²)`. /// El bind group lleva la textura source + sampler bilinear + UBO con /// `(direction, pixel_size, sigma, radius)`. El scissor recorta el output al /// rect del nodo; el resto del target queda intacto (LoadOp::Load). /// /// **Coste**: una pasada por dirección por nodo blur, ~`2*radius+1` taps por /// pixel del rect. Para `sigma=8` (radius=24), ~49 taps/pixel — barato si el /// rect es pequeño (chrome), pesado si es full-screen. v1: sin cap dinámico, /// se asume que el caller no abusa. /// /// **Limitaciones v1**: /// - Un scratch full-screen alocado por compositor; resize sigue al `Surface`. /// - `radius` cap en 32 — sigmas > ~10 se ven menos suaves (clip de cola). /// - Bordes del rect: clamp-to-edge (sampler) → los pixeles fuera del rect /// que se muestrean en la cola del Gauss salen como espejo del borde. En /// un viewport razonable la diferencia es invisible; documentado. pub struct BlurCompositor { pipeline: wgpu::RenderPipeline, sampler: wgpu::Sampler, bind_layout: wgpu::BindGroupLayout, scratch: Option, } struct BlurScratch { _texture: wgpu::Texture, view: wgpu::TextureView, width: u32, height: u32, } /// Layout en GPU del UBO del blur. Debe coincidir con el `BlurParams` del WGSL. /// Padding explícito al final para llegar a múltiplo de 16 bytes (alignment /// estándar de uniformes en wgpu). #[repr(C)] #[derive(Clone, Copy)] struct BlurUniforms { direction: [f32; 2], pixel_size: [f32; 2], sigma: f32, radius: f32, _pad: [f32; 2], } const BLUR_UBO_SIZE: u64 = std::mem::size_of::() as u64; const BLUR_MAX_RADIUS: f32 = 32.0; impl BlurCompositor { pub fn new(device: &wgpu::Device) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("llimphi-blur-shader"), source: wgpu::ShaderSource::Wgsl(BLUR_WGSL.into()), }); let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("llimphi-blur-bgl"), entries: &[ wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, }, ], }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("llimphi-blur-pl"), bind_group_layouts: &[Some(&bind_layout)], immediate_size: 0, }); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("llimphi-blur-pipe"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default(), }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs"), targets: &[Some(wgpu::ColorTargetState { format: INTERMEDIATE_FORMAT, // El blur OVERWRITE el rect; no necesita alpha-over. El // resultado del Gauss es opaco si los pixeles muestreados // lo son (la intermediate tiene UI + background opaco). blend: None, write_mask: wgpu::ColorWrites::ALL, })], compilation_options: Default::default(), }), primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview_mask: None, cache: None, }); let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("llimphi-blur-sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, address_mode_w: wgpu::AddressMode::ClampToEdge, mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, mipmap_filter: wgpu::MipmapFilterMode::Nearest, ..Default::default() }); BlurCompositor { pipeline, sampler, bind_layout, scratch: None, } } /// Aplica un blur Gaussiano sobre `target` en el rect dado (coords pixel /// del viewport). Si el rect cae fuera del viewport, no hace nada. Usa /// un scratch interno del mismo tamaño que el viewport — se aloca lazy y /// se reusa entre frames; se recrea si el viewport cambió. /// /// `sigma` controla el ancho del kernel. ~`σ=4` da "frosted glass" suave, /// `σ=16` un blur fuerte. El radius efectivo se cap a [`BLUR_MAX_RADIUS`]. pub fn blur( &mut self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, viewport: (u32, u32), rect: (f32, f32, f32, f32), sigma: f32, ) { let (vw, vh) = viewport; if vw == 0 || vh == 0 || sigma <= 0.0 { return; } let (rx, ry, rw, rh) = rect; // Clamp scissor al viewport (un rect fuera del viewport pifia el // RenderPass). let x0 = rx.max(0.0) as u32; let y0 = ry.max(0.0) as u32; let x1 = (rx + rw).min(vw as f32).max(0.0) as u32; let y1 = (ry + rh).min(vh as f32).max(0.0) as u32; if x1 <= x0 || y1 <= y0 { return; } let scissor = (x0, y0, x1 - x0, y1 - y0); // Scratch del tamaño del viewport. Si cambió, recrear. let need_new = match &self.scratch { Some(s) => s.width != vw || s.height != vh, None => true, }; if need_new { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("llimphi-blur-scratch"), size: wgpu::Extent3d { width: vw, height: vh, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: INTERMEDIATE_FORMAT, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); self.scratch = Some(BlurScratch { _texture: texture, view, width: vw, height: vh, }); } let scratch_view = &self.scratch.as_ref().expect("scratch creado arriba").view; let radius = (sigma * 3.0).ceil().min(BLUR_MAX_RADIUS); let pixel_size = [1.0 / vw as f32, 1.0 / vh as f32]; let ubo_h_data = BlurUniforms { direction: [1.0, 0.0], pixel_size, sigma, radius, _pad: [0.0, 0.0], }; let ubo_v_data = BlurUniforms { direction: [0.0, 1.0], pixel_size, sigma, radius, _pad: [0.0, 0.0], }; // UBOs por llamada (ver nota en `ColorFilterCompositor::apply`): varios // blurs en el mismo submit con sigmas distintos no deben aliasar un UBO // compartido (ganaría el último). Buffers frescos por llamada (32 bytes). let ubo_h = device.create_buffer(&wgpu::BufferDescriptor { label: Some("llimphi-blur-ubo-h"), size: BLUR_UBO_SIZE, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let ubo_v = device.create_buffer(&wgpu::BufferDescriptor { label: Some("llimphi-blur-ubo-v"), size: BLUR_UBO_SIZE, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); queue.write_buffer(&ubo_h, 0, bytemuck_cast(&ubo_h_data)); queue.write_buffer(&ubo_v, 0, bytemuck_cast(&ubo_v_data)); // Pass 1: target → scratch (horizontal). let bg_h = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("llimphi-blur-bg-h"), layout: &self.bind_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(target), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler), }, wgpu::BindGroupEntry { binding: 2, resource: ubo_h.as_entire_binding(), }, ], }); { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("llimphi-blur-pass-h"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: scratch_view, resolve_target: None, depth_slice: None, ops: wgpu::Operations { // No nos importa qué hay fuera del scissor: el segundo // pase sólo lee dentro del scissor también. load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, &bg_h, &[]); pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3); pass.draw(0..3, 0..1); } // Pass 2: scratch → target (vertical), preservando lo fuera del scissor. let bg_v = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("llimphi-blur-bg-v"), layout: &self.bind_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(scratch_view), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler), }, wgpu::BindGroupEntry { binding: 2, resource: ubo_v.as_entire_binding(), }, ], }); { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("llimphi-blur-pass-v"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: target, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, &bg_v, &[]); pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3); pass.draw(0..3, 0..1); } } } /// Aplica una **matriz de color 4×5** (CSS `filter: brightness/contrast/ /// grayscale/sepia/saturate/invert/hue-rotate/opacity`) sobre un rect de la /// intermediate. Espejo de [`BlurCompositor`] pero con un fragment shader que /// multiplica cada píxel por la matriz: `out = M·rgba + bias`, clampeado a /// `[0,1]`. Dos pases (target→scratch aplicando la matriz, scratch→target /// copia identidad) por la misma razón que el blur: un render pass no puede /// leer y escribir la misma textura. Fase 7.1233. pub struct ColorFilterCompositor { pipeline: wgpu::RenderPipeline, sampler: wgpu::Sampler, bind_layout: wgpu::BindGroupLayout, scratch: Option, } /// UBO de la matriz de color. 5 `vec4` (filas R/G/B/A + bias) = 80 bytes, /// múltiplo de 16. Debe coincidir con `ColorParams` del WGSL. #[repr(C)] #[derive(Clone, Copy)] struct ColorUniforms { r: [f32; 4], g: [f32; 4], b: [f32; 4], a: [f32; 4], bias: [f32; 4], } const COLOR_UBO_SIZE: u64 = std::mem::size_of::() as u64; /// La matriz identidad (copia sin cambios), usada en el segundo pase. const COLOR_IDENTITY: ColorUniforms = ColorUniforms { r: [1.0, 0.0, 0.0, 0.0], g: [0.0, 1.0, 0.0, 0.0], b: [0.0, 0.0, 1.0, 0.0], a: [0.0, 0.0, 0.0, 1.0], bias: [0.0, 0.0, 0.0, 0.0], }; impl ColorFilterCompositor { pub fn new(device: &wgpu::Device) -> Self { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("llimphi-color-filter-shader"), source: wgpu::ShaderSource::Wgsl(COLOR_WGSL.into()), }); let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("llimphi-color-filter-bgl"), entries: &[ wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, }, ], }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("llimphi-color-filter-pl"), bind_group_layouts: &[Some(&bind_layout)], immediate_size: 0, }); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("llimphi-color-filter-pipe"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default(), }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs"), targets: &[Some(wgpu::ColorTargetState { format: INTERMEDIATE_FORMAT, // OVERWRITE el rect, igual que el blur — el resultado de la // matriz reemplaza el píxel. blend: None, write_mask: wgpu::ColorWrites::ALL, })], compilation_options: Default::default(), }), primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview_mask: None, cache: None, }); let sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("llimphi-color-filter-sampler"), address_mode_u: wgpu::AddressMode::ClampToEdge, address_mode_v: wgpu::AddressMode::ClampToEdge, address_mode_w: wgpu::AddressMode::ClampToEdge, mag_filter: wgpu::FilterMode::Nearest, min_filter: wgpu::FilterMode::Nearest, mipmap_filter: wgpu::MipmapFilterMode::Nearest, ..Default::default() }); ColorFilterCompositor { pipeline, sampler, bind_layout, scratch: None, } } /// Aplica la matriz de color `matrix` (4×5 row-major: por fila /// `[c0, c1, c2, c3, bias]`, salida R/G/B/A) sobre `target` en el rect dado /// (coords pixel del viewport). Fuera del viewport no hace nada. Usa un /// scratch del tamaño del viewport (lazy, reusado entre frames). pub fn apply( &mut self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, viewport: (u32, u32), rect: (f32, f32, f32, f32), matrix: [f32; 20], ) { let (vw, vh) = viewport; if vw == 0 || vh == 0 { return; } let (rx, ry, rw, rh) = rect; let x0 = rx.max(0.0) as u32; let y0 = ry.max(0.0) as u32; let x1 = (rx + rw).min(vw as f32).max(0.0) as u32; let y1 = (ry + rh).min(vh as f32).max(0.0) as u32; if x1 <= x0 || y1 <= y0 { return; } let scissor = (x0, y0, x1 - x0, y1 - y0); let need_new = match &self.scratch { Some(s) => s.width != vw || s.height != vh, None => true, }; if need_new { let texture = device.create_texture(&wgpu::TextureDescriptor { label: Some("llimphi-color-filter-scratch"), size: wgpu::Extent3d { width: vw, height: vh, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: INTERMEDIATE_FORMAT, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); self.scratch = Some(BlurScratch { _texture: texture, view, width: vw, height: vh, }); } let scratch_view = &self.scratch.as_ref().expect("scratch creado arriba").view; // El [f32;20] viene por filas de 5 (`[c0,c1,c2,c3,bias]`); lo partimos // en 4 vec4 de coeficientes + un vec4 de bias para el UBO. let apply = ColorUniforms { r: [matrix[0], matrix[1], matrix[2], matrix[3]], g: [matrix[5], matrix[6], matrix[7], matrix[8]], b: [matrix[10], matrix[11], matrix[12], matrix[13]], a: [matrix[15], matrix[16], matrix[17], matrix[18]], bias: [matrix[4], matrix[9], matrix[14], matrix[19]], }; // UBOs **por llamada**: varias `apply` en el mismo encoder/submit // comparten cola; `write_buffer` se aplica una vez antes de los command // buffers (gana el último valor escrito), así que un UBO compartido haría // que todas las pasadas leyeran la última matriz. Buffers frescos por // llamada evitan ese alias (80 bytes c/u, despreciable). let ubo_apply = device.create_buffer(&wgpu::BufferDescriptor { label: Some("llimphi-color-filter-ubo-apply"), size: COLOR_UBO_SIZE, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); let ubo_copy = device.create_buffer(&wgpu::BufferDescriptor { label: Some("llimphi-color-filter-ubo-copy"), size: COLOR_UBO_SIZE, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); queue.write_buffer(&ubo_apply, 0, bytemuck_cast(&apply)); queue.write_buffer(&ubo_copy, 0, bytemuck_cast(&COLOR_IDENTITY)); // Pass 1: target → scratch (aplica la matriz). let bg_apply = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("llimphi-color-filter-bg-apply"), layout: &self.bind_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(target), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler), }, wgpu::BindGroupEntry { binding: 2, resource: ubo_apply.as_entire_binding(), }, ], }); { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("llimphi-color-filter-pass-apply"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: scratch_view, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, &bg_apply, &[]); pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3); pass.draw(0..3, 0..1); } // Pass 2: scratch → target (copia identidad), preservando lo de afuera. let bg_copy = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("llimphi-color-filter-bg-copy"), layout: &self.bind_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(scratch_view), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler), }, wgpu::BindGroupEntry { binding: 2, resource: ubo_copy.as_entire_binding(), }, ], }); { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("llimphi-color-filter-pass-copy"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: target, resolve_target: None, depth_slice: None, ops: wgpu::Operations { load: wgpu::LoadOp::Load, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, &bg_copy, &[]); pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3); pass.draw(0..3, 0..1); } } } /// "bytemuck" minimal sin dep: convierte `&T` a `&[u8]`. Sólo para POD repr(C) /// — usado para escribir los UBOs del blur con `queue.write_buffer`. fn bytemuck_cast(v: &T) -> &[u8] { unsafe { std::slice::from_raw_parts( v as *const T as *const u8, std::mem::size_of::(), ) } } /// Separable Gaussian, una dirección por pase. El vs es el mismo triángulo /// grande del overlay; el fs samplea `2*radius+1` taps a lo largo de /// `direction*pixel_size`. Pesos `exp(-i²/2σ²)` normalizados por la suma — /// independiente del radius por si quedó cortada la cola. const BLUR_WGSL: &str = r#" struct VsOut { @builtin(position) pos: vec4, @location(0) uv: vec2, }; @vertex fn vs(@builtin(vertex_index) vi: u32) -> VsOut { var corners = array, 3>( vec2(-1.0, -1.0), vec2( 3.0, -1.0), vec2(-1.0, 3.0), ); let xy = corners[vi]; var out: VsOut; out.pos = vec4(xy, 0.0, 1.0); out.uv = vec2((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5); return out; } struct BlurParams { direction: vec2, pixel_size: vec2, sigma: f32, radius: f32, _pad: vec2, }; @group(0) @binding(0) var src_tex: texture_2d; @group(0) @binding(1) var src_samp: sampler; @group(0) @binding(2) var params: BlurParams; @fragment fn fs(in: VsOut) -> @location(0) vec4 { let dir = params.direction * params.pixel_size; let r = i32(params.radius); let two_sigma_sq = 2.0 * params.sigma * params.sigma; var acc = vec4(0.0); var weight_sum = 0.0; for (var i = -r; i <= r; i = i + 1) { let fi = f32(i); let w = exp(-(fi * fi) / two_sigma_sq); acc = acc + textureSample(src_tex, src_samp, in.uv + dir * fi) * w; weight_sum = weight_sum + w; } return acc / weight_sum; } "#; /// Matriz de color 4×5: `out = M·rgba + bias`, clampeado a `[0,1]`. El vs es el /// mismo triángulo grande; el fs hace 4 `dot` (una fila por canal) más el bias. const COLOR_WGSL: &str = r#" struct VsOut { @builtin(position) pos: vec4, @location(0) uv: vec2, }; @vertex fn vs(@builtin(vertex_index) vi: u32) -> VsOut { var corners = array, 3>( vec2(-1.0, -1.0), vec2( 3.0, -1.0), vec2(-1.0, 3.0), ); let xy = corners[vi]; var out: VsOut; out.pos = vec4(xy, 0.0, 1.0); out.uv = vec2((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5); return out; } struct ColorParams { r: vec4, g: vec4, b: vec4, a: vec4, bias: vec4, }; @group(0) @binding(0) var src_tex: texture_2d; @group(0) @binding(1) var src_samp: sampler; @group(0) @binding(2) var params: ColorParams; @fragment fn fs(in: VsOut) -> @location(0) vec4 { let c = textureSample(src_tex, src_samp, in.uv); var o: vec4; o.r = dot(params.r, c) + params.bias.r; o.g = dot(params.g, c) + params.bias.g; o.b = dot(params.b, c) + params.bias.b; o.a = dot(params.a, c) + params.bias.a; return clamp(o, vec4(0.0), vec4(1.0)); } "#; impl Surface for WinitSurface { fn size(&self) -> (u32, u32) { (self.config.width, self.config.height) } fn resize(&mut self, width: u32, height: u32) { let (w, h) = (width.max(1), height.max(1)); // **Sin cambio de tamaño NO se toca nada.** `RawSurface` y // `CanvasSurface` ya tenían este guard; `WinitSurface` —la que usan // TODAS las apps de escritorio y Android— era la única sin él, y eso // dejaba abierta la puerta a un `resize(w, h)` con las medidas que ya // tenía puestas: reconfigurar el swapchain y recrear las dos texturas // intermedias por gusto. // // Encontrado en metal el 2026-07-27 (Redmi Note 13 Pro, Mali-G57): el // camino de recuperación de `handle_redraw` llama exactamente a // `surface.resize(surface.size())` cuando `acquire` falla, así que sin // guard eso es un bucle cerrado —falla → resize → redraw → falla—. // Medido en logcat: **41.335 reconfiguraciones de swapchain en 13 s**, // ~10 MB de textura por vuelta, hasta que la creación empieza a // devolver texturas inválidas y la app se queda en negro (o muere, si // su device no tiene handler no-fatal). El guard corta el bucle en el // punto más bajo, valga quien valga el caller. if self.config.width == w && self.config.height == h { return; } // Las texturas PRIMERO, y recién si las dos salieron bien se toca el // resto. Así un fallo no deja el estado a medio aplicar: la surface // sigue configurada al tamaño viejo, que es el de las texturas que // quedan puestas, y el frame siguiente es coherente aunque salga con // las medidas anteriores. Instalar una textura **inválida** —lo que // hacía antes— es la pantalla negra perpetua. let (nueva, overlay_nueva) = match ( create_intermediate_checked(&self.device, w, h), create_intermediate_checked(&self.device, w, h), ) { (Ok(a), Ok(b)) => (a, b), (a, b) => { let e = a.err().or_else(|| b.err()).unwrap_or_default(); crate::avisar!( "llimphi-hal: no se pudo crear la textura intermedia {w}x{h} ({e}); \ me quedo en {}x{}", self.config.width, self.config.height ); return; } }; self.config.width = w; self.config.height = h; if let Err(e) = configure_checked(&self.device, self.vk(), &self.config) { crate::avisar!("llimphi-hal: configure en resize falló (surface perdida?): {e}"); } (self.intermediate, self.intermediate_view) = nueva; (self.overlay, self.overlay_view) = overlay_nueva; } fn acquire(&mut self) -> Result { // El `present` anterior descubrió que el device está perdido: no tiene // sentido pedir un frame más contra un device muerto. Se cobra acá para // entrar por el camino de recuperación que ya existe. if self.device_perdido { return Err(SurfaceError::DeviceLost); } // Gancho de verificación: fuerza un DeviceLost sintético cada N cuadros // (env `LLIMPHI_SIMULATE_DEVICE_LOSS`) para ejercitar el rebuild sin un // reset de output real. No-op sin la env. if simulate_device_loss_trip() { return Err(SurfaceError::DeviceLost); } // Ver la nota sobre `Suboptimal` en el `acquire` de `RawSurface`. use wgpu::CurrentSurfaceTexture as Estado; let texture = match self.vk().get_current_texture() { Estado::Success(t) | Estado::Suboptimal(t) => t, // El camino winit tampoco tenía recuperación: un `Outdated`/`Lost` // (hipo del compositor, cambio de VT) se propagaba y la app saltaba // el frame para siempre en el peor caso. Reconfiguramos; si no basta, // recreamos la surface desde la ventana; si ESO falla, el device // quedó irrecuperable → DeviceLost (rebuild del stack GPU completo). e @ (Estado::Outdated | Estado::Lost) => { if let Err(msg) = configure_checked(&self.device, self.vk(), &self.config) { match self.recreate() { Ok(()) => crate::avisar!( "llimphi-hal: WinitSurface RECREADA tras {e:?} (reconfigure falló: {msg})" ), Err(msg2) => { crate::avisar!( "llimphi-hal: reconfigurar tras {e:?} falló ({msg}); recrear también falló ({msg2}) → DeviceLost" ); return Err(SurfaceError::DeviceLost); } } } match self.vk().get_current_texture() { Estado::Success(t) | Estado::Suboptimal(t) => t, // Sigue rota tras reconfigurar+recrear → device irrecuperable. Estado::Lost | Estado::Outdated => return Err(SurfaceError::DeviceLost), Estado::Timeout => return Err(SurfaceError::Timeout), Estado::Occluded => return Err(SurfaceError::Occluded), otro => return Err(SurfaceError::Other(format!("{otro:?}"))), } } Estado::Timeout => return Err(SurfaceError::Timeout), // Ventana tapada: no es falla. Ver [`SurfaceError::Occluded`]. Estado::Occluded => return Err(SurfaceError::Occluded), otro => return Err(SurfaceError::Other(format!("{otro:?}"))), }; let surface_view = texture .texture .create_view(&wgpu::TextureViewDescriptor::default()); // `TextureView` envuelve un Arc — clonar es atomic-incref, no // recrea la vista. La intermedia sólo cambia en `resize`. Ok(Frame { surface_texture: texture, surface_view, intermediate_view: self.intermediate_view.clone(), overlay_view: self.overlay_view.clone(), width: self.config.width, height: self.config.height, }) } /// Blitea la intermedia al swapchain y presenta. /// /// **El `present` va CAPTURADO**, como `configure` y `acquire`. Era el único /// de los tres sin red: `wgpu::SurfaceTexture::present` trata sus errores /// como fatales pase lo que pase —ni el handler no-fatal del device lo /// salva—, así que un device perdido acá **paniquea**, y el unwind arrastra /// destructores de wgpu-hal que paniquean a su vez /// (`Trying to destroy a SurfaceAcquireSemaphores that is still in use by a /// SurfaceTexture` → `panic in a destructor during cleanup` → **abort**). /// /// Medido en Android el 2026-07-28: el device se pierde en el primer frame /// y la app **se mata ahí**, antes de que el runtime pueda contar fallos ni /// llamar a `rebuild_gpu_after_device_loss` (0 fallos y 0 rebuilds /// registrados en el log — no llegaba a ejecutarse nada). Capturando, la /// pérdida se convierte en una bandera que el próximo `acquire` cobra como /// [`SurfaceError::DeviceLost`], que es el camino de recuperación **que ya /// existía** y nunca se estaba alcanzando. fn present(&mut self, frame: Frame, hal: &Hal) { let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("llimphi-blit"), }); self.blitter.copy( &hal.device, &mut encoder, &frame.intermediate_view, &frame.surface_view, ); hal.queue.submit(std::iter::once(encoder.finish())); // **No presentar sobre un device muerto.** Un `load` relajado, nada de // `pop_error_scope` por frame (eso poll‑ea la GPU y sería un peaje en la // ruta caliente de todas las apps). Si el device ya se perdió, soltamos // el `SurfaceTexture` sin presentar y dejamos la marca: el próximo // `acquire` devuelve `DeviceLost` y el runtime rehace el stack. if DEVICE_PERDIDO_GLOBAL.load(std::sync::atomic::Ordering::Relaxed) { self.device_perdido = true; crate::avisar!( "llimphi-hal: device perdido — no presento este frame; \ DeviceLost en el próximo acquire" ); // **El `SurfaceTexture` se filtra a propósito.** Con el device // perdido, wgpu 27 paniquea de forma fatal por los DOS caminos: // `present()` (wgpu_core.rs:3858) y su `Drop` (wgpu_core.rs:3867), // y ninguno es atajable —no pasan por error-scope ni por el handler // no-fatal—. Medido en Android: saltear el present movía el panic al // destructor, con la misma cascada (`SurfaceAcquireSemaphores still // in use` → `panic in a destructor` → **abort**). Olvidarla evita // los dos. El costo es un handle huérfano en un stack GPU que // estamos por rehacer entero; el beneficio es que el proceso vive. std::mem::forget(frame.surface_texture); return; } frame.surface_texture.present(); } } #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; /// Un `Hal` headless, o `None` si esta máquina no tiene con qué (CI sin GPU /// ni lavapipe). Sin esto el test fallaría por falta de hardware, que no es /// lo que queremos certificar. fn hal_o_skip() -> Option { pollster::block_on(Hal::new_headless(false)).ok() } /// Una textura intermedia de tamaño imposible **devuelve `Err`**; no /// paniquea ni entrega una textura inválida. /// /// Es la regresión de Android/Mali del 2026-07-27: `create_texture` fallaba, /// wgpu devolvía una textura **inválida**, y el `create_view` inmediato /// emitía `Texture with 'llimphi-intermediate' label is invalid` — un error /// derivado que tapaba el original. Según el handler del device, el mismo /// bug mataba la app (default fatal de wgpu) o la dejaba viva pintando en /// negro para siempre (handler no-fatal). Con la creación capturada, el /// caller se entera y conserva las texturas que funcionaban. #[test] fn textura_intermedia_imposible_da_error_y_no_paniquea() { let Some(hal) = hal_o_skip() else { crate::avisar!("sin GPU headless en esta máquina: test omitido"); return; }; let limite = hal.device.limits().max_texture_dimension_2d; let r = create_intermediate_checked(&hal.device, limite + 1, 64); assert!( r.is_err(), "pasarse del límite de textura 2D ({limite}) tiene que dar Err, no una textura inválida" ); // El mensaje es el de wgpu, no uno derivado del `create_view`: es el que // dice de verdad qué pasó. let msg = r.unwrap_err(); assert!( !msg.contains("create_view"), "el error tiene que ser el de la creación, no el derivado de la vista: {msg}" ); } /// Y el camino bueno sigue funcionando: un tamaño sano devuelve `Ok` con la /// textura del tamaño pedido. Sin esto, un `create_intermediate_checked` que /// devolviera `Err` SIEMPRE pasaría el test de arriba. #[test] fn textura_intermedia_de_tamano_sano_sale_ok() { let Some(hal) = hal_o_skip() else { crate::avisar!("sin GPU headless en esta máquina: test omitido"); return; }; let (tex, _view) = create_intermediate_checked(&hal.device, 320, 240).expect("320x240 tiene que salir"); assert_eq!((tex.width(), tex.height()), (320, 240)); } /// Los nombres que el operador escribe —en la env o en la propiedad de /// Android— dan el backend que dicen, sin importar mayúsculas ni espacios. /// /// Vale la pena tenerlo escrito porque el valor llega de AFUERA por dos /// caminos distintos y uno de ellos (`adb shell setprop`) es imposible de /// probar acá: si el parser se rompe, en el teléfono no se ve un error sino /// un backend que "no hizo caso", que es el síntoma más caro de diagnosticar. #[test] fn los_nombres_de_backend_se_leen_como_dicen() { assert_eq!(backend_de_texto("gl"), Some(wgpu::Backends::GL)); assert_eq!(backend_de_texto(" GLES\n"), Some(wgpu::Backends::GL)); assert_eq!(backend_de_texto("Vulkan"), Some(wgpu::Backends::VULKAN)); assert_eq!(backend_de_texto("vk"), Some(wgpu::Backends::VULKAN)); assert_eq!(backend_de_texto("primary"), Some(wgpu::Backends::PRIMARY)); assert_eq!(backend_de_texto("all"), Some(wgpu::Backends::all())); // Lo no reconocido NO cae a un backend cualquiera: devuelve `None` y el // caller se queda con su default. assert_eq!(backend_de_texto("metal-por-favor"), None); assert_eq!(backend_de_texto(""), None); } }