tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1//! llimphi-hal — Puente al Silicio.
2//!
3//! Aísla el motor del sistema operativo. Pinta en ventana Wayland/X11
4//! (vía `mirada` en producción, vía `winit` en dev) o framebuffer directo
5//! del kernel `wawa` (TODO). Trait `Surface` abstracto + struct `Hal`
6//! que posee Instance/Adapter/Device/Queue de wgpu.
7
8use std::sync::Arc;
9
10/// Avisos por `stderr` que no pueden matar a la app. Exporta el macro
11/// [`avisar!`], que reemplaza a `eprintln!` en todo el runtime.
12pub mod aviso;
13
14pub use raw_window_handle;
15pub use wgpu;
16pub use winit;
17
18use winit::window::Window;
19
20/// Errores al adquirir un frame de la superficie.
21#[derive(Debug)]
22pub enum SurfaceError {
23 Lost,
24 Outdated,
25 OutOfMemory,
26 Timeout,
27 /// La ventana está **tapada** (minimizada o detrás de otra) y el sistema no
28 /// entrega textura. Llegó con wgpu 29, que la distingue del resto.
29 ///
30 /// **No es una falla**, y por eso tiene variante propia en vez de caer en
31 /// [`SurfaceError::Other`]: quien lleva el bucle cuenta los acquire fallidos
32 /// y tras unos cuantos reconstruye el stack GPU entero. Una ventana tapada
33 /// unos segundos dispararía esa reconstrucción sin que haya nada roto. Lo
34 /// correcto es saltear el fotograma y volver a pedirlo.
35 Occluded,
36 /// La surface quedó **irrecuperable a nivel de dispositivo**: reconfigurar
37 /// y recrear la `wgpu::Surface` (reusando el mismo device/adapter) fallaron
38 /// ambos. El único remedio es reconstruir todo el stack GPU
39 /// (instance+adapter+device+queue+surface) y con él los recursos colgados
40 /// del device (renderer vello, compositores, texturas). El dueño del bucle
41 /// de render debe atrapar esta variante y rehacer su mundo GPU — ver
42 /// [`Hal::recreate_for_window`] (camino winit) y el runner de layer-shell de
43 /// `pata` (camino raw-surface). Distinta de [`SurfaceError::Lost`], que es
44 /// un hipo transitorio que la propia surface ya recuperó.
45 ///
46 /// Se dispara en el modo de falla de Iris Xe bajo mirada-DRM: al resetear
47 /// el output, la surface Vulkan cae con «Surface does not support the
48 /// adapter's queue family» y recrearla contra el mismo adapter no ayuda.
49 /// Antes esto tumbaba/loopeaba a los clientes (greeter/pata/shuma) o exigía
50 /// forzar el backend GL; la recuperación de device lo resuelve de forma
51 /// general (no atada a esta GPU).
52 DeviceLost,
53 Other(String),
54}
55
56impl std::fmt::Display for SurfaceError {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 Self::Lost => write!(f, "surface lost"),
60 Self::Outdated => write!(f, "surface outdated"),
61 Self::OutOfMemory => write!(f, "surface out of memory"),
62 Self::Timeout => write!(f, "surface timeout"),
63 Self::Occluded => write!(f, "ventana tapada (no es falla: saltear el cuadro)"),
64 Self::DeviceLost => write!(f, "device lost (full GPU-stack rebuild required)"),
65 Self::Other(s) => write!(f, "surface error: {s}"),
66 }
67 }
68}
69
70impl std::error::Error for SurfaceError {}
71
72/// Errores al construir Hal o crear una Surface.
73#[derive(Debug)]
74pub enum HalError {
75 NoAdapter,
76 RequestDevice(String),
77 CreateSurface(String),
78}
79
80impl std::fmt::Display for HalError {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 match self {
83 Self::NoAdapter => write!(f, "no GPU adapter available"),
84 Self::RequestDevice(s) => write!(f, "request_device failed: {s}"),
85 Self::CreateSurface(s) => write!(f, "create_surface failed: {s}"),
86 }
87 }
88}
89
90impl std::error::Error for HalError {}
91
92/// Superficie gráfica donde llimphi pinta.
93///
94/// Vello (rasterizador) emite a una textura intermedia con storage binding
95/// (la única forma portable: los formatos de swapchain no aceptan writes
96/// de compute shader en muchos adapters). En `present` se blittea la
97/// intermedia al swapchain real y se hace el flip.
98///
99/// Implementaciones:
100/// - [`WinitSurface`]: ventana Wayland/X11/Win32/Cocoa (dev + producción vía mirada).
101/// - [`RawSurface`]: `wgpu::Surface` creada desde handles raw (wlr-layer-shell).
102/// - `CanvasSurface` (sólo `wasm32`): un `<canvas>` del DOM — el camino navegador.
103/// - `WawaFramebufferSurface` (TODO): framebuffer directo del kernel wawa.
104pub trait Surface {
105 fn size(&self) -> (u32, u32);
106 fn resize(&mut self, width: u32, height: u32);
107 /// Adquiere la textura intermedia donde el raster pinta este frame.
108 fn acquire(&mut self) -> Result<Frame, SurfaceError>;
109 /// Blittea la intermedia al swapchain y la presenta.
110 fn present(&mut self, frame: Frame, hal: &Hal);
111}
112
113/// Frame en curso. `view()` devuelve la textura intermedia (Rgba8Unorm,
114/// STORAGE_BINDING) lista para que vello escriba sobre ella.
115pub struct Frame {
116 surface_texture: wgpu::SurfaceTexture,
117 surface_view: wgpu::TextureView,
118 intermediate_view: wgpu::TextureView,
119 /// Textura secundaria para la capa de overlay (menús/paleta/modal)
120 /// cuando hay contenido `gpu_paint` que la taparía. El overlay se
121 /// rasteriza aquí con fondo transparente y luego se compone con
122 /// alpha SOBRE la intermedia (que ya tiene UI + video). Ver
123 /// [`OverlayCompositor`] y el eventloop de `llimphi-ui`.
124 overlay_view: wgpu::TextureView,
125 width: u32,
126 height: u32,
127}
128
129impl Frame {
130 pub fn view(&self) -> &wgpu::TextureView {
131 &self.intermediate_view
132 }
133
134 /// Vista de la textura de overlay (mismo tamaño y formato que la
135 /// intermedia). Sólo se usa en el camino de compositing del overlay.
136 pub fn overlay_view(&self) -> &wgpu::TextureView {
137 &self.overlay_view
138 }
139
140 pub fn size(&self) -> (u32, u32) {
141 (self.width, self.height)
142 }
143}
144
145/// Estado wgpu compartido. Una instancia por proceso. `Device` y `Queue`
146/// son `Arc` internamente, así que clonar es barato.
147pub struct Hal {
148 pub instance: wgpu::Instance,
149 pub adapter: wgpu::Adapter,
150 pub device: wgpu::Device,
151 pub queue: wgpu::Queue,
152}
153
154/// Override del backend wgpu. Valores: `gl`/`gles`/`opengl`/`egl` → GL;
155/// `vulkan`/`vk` → Vulkan; `primary` → PRIMARY; `all` → todos. Sin pedido (o con
156/// un valor no reconocido) devuelve `None` y el caller usa su default (PRIMARY
157/// con fallback a todos).
158///
159/// De dónde sale el pedido —la env en todas partes, la propiedad del sistema
160/// además en Android— y por qué hacen falta las dos: [`backend_pedido`].
161///
162/// **Para qué**: en Iris Xe bajo mirada-DRM, el backend Vulkan revienta al
163/// reconfigurar la surface tras un reset de output («Surface does not support the
164/// adapter's queue family»: falta `VK_EXT_physical_device_drm` y wgpu no puede
165/// re-matchear el adapter a la surface recreada). Como recrear la surface reusa el
166/// mismo device/adapter, el error persiste y los clientes layer (greeter/pata/
167/// shuma) entran en crash-loop al boot y en cada cambio de VT. Forzar GL
168/// (`LLIMPHI_WGPU_BACKEND=gl`) esquiva el problema —GL no tiene el concepto de
169/// queue family— al costo del bug de teardown de GL (SIGSEGV al cerrar, tolerable
170/// frente a un crash-loop que impide la sesión). Los clientes lo heredan del env
171/// del compositor. Ver memoria `pata-layer-closed-arje-drm`.
172fn backend_override() -> Option<wgpu::Backends> {
173 let (v, fuente) = backend_pedido()?;
174 match backend_de_texto(&v) {
175 Some(b) => {
176 crate::avisar!("llimphi-hal: backend {b:?} pedido por {fuente}");
177 Some(b)
178 }
179 None => {
180 crate::avisar!("llimphi-hal: backend «{v}» ({fuente}) no reconocido; uso el default (PRIMARY)");
181 None
182 }
183 }
184}
185
186/// El nombre escrito → los backends de wgpu. Separado de [`backend_override`]
187/// para poder certificarlo sin tocar el entorno del proceso: un test que
188/// escribe env vars le cambia el mundo a los otros tests del mismo binario,
189/// que corren en hilos del mismo proceso.
190fn backend_de_texto(v: &str) -> Option<wgpu::Backends> {
191 match v.trim().to_ascii_lowercase().as_str() {
192 "gl" | "gles" | "opengl" | "egl" => Some(wgpu::Backends::GL),
193 "vulkan" | "vk" => Some(wgpu::Backends::VULKAN),
194 "primary" => Some(wgpu::Backends::PRIMARY),
195 "all" => Some(wgpu::Backends::all()),
196 _ => None,
197 }
198}
199
200/// De dónde sale la elección de backend, en orden, y **cuál fue** — el segundo
201/// dato es la mitad que importa cuando el aviso se lee en un `logcat` ajeno.
202///
203/// El entorno primero: es el camino de Linux y el que usan `mirada-gl.sh` y los
204/// arneses. Después, y **sólo en Android**, la propiedad del sistema.
205///
206/// **Por qué hace falta la segunda.** Una app de Android lanzada por el launcher
207/// no hereda el entorno de nadie: `LLIMPHI_WGPU_BACKEND` existe desde siempre y
208/// en un teléfono **no hay forma de ponerla**, así que probar GLES exigía
209/// recompilar. Con la propiedad se hace desde afuera y sin root:
210///
211/// ```sh
212/// adb shell setprop debug.llimphi.backend gl
213/// adb shell am force-stop <paquete> # el valor se lee UNA vez, al arrancar
214/// ```
215///
216/// El prefijo `debug.` no es cosmético: es el espacio de nombres que bionic deja
217/// escribir al usuario `shell` sin root, y el mismo que usa el propio Android
218/// para sus interruptores de gráficos (`debug.hwui.renderer`). Una propiedad sin
219/// ese prefijo requiere permisos que un teléfono de banco de pruebas no da.
220///
221/// ⚠️ Esto elige el **backend de wgpu** (Vulkan / GL), que es una decisión de
222/// tiempo de corrida. NO elige el **rasterizador** (`vello` / `vello_hybrid`):
223/// ése es de tiempo de compilación —son escenas de tipos distintos, ver el
224/// encabezado de `llimphi-raster`— y viaja en el APK, no en una propiedad.
225fn backend_pedido() -> Option<(String, &'static str)> {
226 if let Ok(v) = std::env::var("LLIMPHI_WGPU_BACKEND") {
227 return Some((v, "LLIMPHI_WGPU_BACKEND"));
228 }
229 #[cfg(target_os = "android")]
230 if let Some(v) = propiedad_del_sistema("debug.llimphi.backend") {
231 return Some((v, "debug.llimphi.backend"));
232 }
233 None
234}
235
236/// Lee una propiedad del sistema de Android. `None` si no está puesta o si está
237/// vacía — que para bionic son lo mismo: `__system_property_get` devuelve 0 en
238/// los dos casos.
239///
240/// Se hace por `libc` y no lanzando `getprop`: esto corre en el arranque del
241/// runtime, antes del primer cuadro, y un `fork`+`exec` ahí es caro y puede
242/// fallar por políticas de la app.
243#[cfg(target_os = "android")]
244fn propiedad_del_sistema(nombre: &str) -> Option<String> {
245 // `PROP_VALUE_MAX` de bionic, con el NUL adentro. bionic promete no
246 // escribir más que eso, así que el buffer fijo es correcto y no un límite
247 // nuestro.
248 const MAX: usize = 92;
249 let nombre = std::ffi::CString::new(nombre).ok()?;
250 let mut buf = [0u8; MAX];
251 // SAFETY: `nombre` vive toda la llamada y es un C string válido; `buf`
252 // tiene los PROP_VALUE_MAX bytes que la función promete no pasar.
253 let n = unsafe {
254 libc::__system_property_get(nombre.as_ptr(), buf.as_mut_ptr() as *mut libc::c_char)
255 };
256 if n <= 0 {
257 return None;
258 }
259 let v = std::str::from_utf8(buf.get(..n as usize)?).ok()?.trim();
260 (!v.is_empty()).then(|| v.to_string())
261}
262
263/// Un `RawDisplayHandle` presentado como `HasDisplayHandle`, para poder
264/// entregárselo a `wgpu::InstanceDescriptor`.
265///
266/// Hace falta en el camino de layer-shell (`pata`, la barra, el greeter), donde
267/// no hay una `winit::Window` que ya implemente el trait: lo único que tenemos
268/// es el handle crudo que el caller nos pasa.
269#[derive(Debug, Clone, Copy)]
270struct DisplayCrudo(raw_window_handle::RawDisplayHandle);
271
272// SAFETY: el contrato de `new_for_raw_surface` ya exige que los handles apunten
273// a objetos vivos durante toda la vida de la surface — es la misma promesa que
274// sostiene a `create_surface_unsafe`, y este envoltorio no la extiende, sólo la
275// transporta. `RawDisplayHandle` es un puntero opaco: `Send`/`Sync` valen porque
276// wgpu no lo desreferencia fuera del hilo del compositor.
277unsafe impl Send for DisplayCrudo {}
278unsafe impl Sync for DisplayCrudo {}
279
280impl raw_window_handle::HasDisplayHandle for DisplayCrudo {
281 fn display_handle(
282 &self,
283 ) -> Result<raw_window_handle::DisplayHandle<'_>, raw_window_handle::HandleError> {
284 // SAFETY: ver arriba — el handle sobrevive a este préstamo por contrato.
285 Ok(unsafe { raw_window_handle::DisplayHandle::borrow_raw(self.0) })
286 }
287}
288
289/// Construye una `wgpu::Instance` con los backends pedidos y, si se tiene, el
290/// display del sistema de ventanas.
291///
292/// **El display no es un adorno.** wgpu 29 lo sumó al `InstanceDescriptor` y su
293/// propia documentación es explícita: en GLES es *necesario para presentar,
294/// especialmente en Wayland*; en Vulkan/Metal/DX12 se ignora. Y GL no es un
295/// camino hipotético acá — es justo el fallback al que caen `Hal::new` y
296/// `new_for_raw_surface` cuando no hay adaptador PRIMARY. Antes de wgpu 29 no
297/// existía la opción y el backend GL se las arreglaba; pasarlo cuando lo
298/// tenemos es lo que evita descubrir en una VM vieja que la ventana no presenta.
299///
300/// Existe además porque en wgpu 29 `InstanceDescriptor` **perdió `Default`** (el
301/// display no tiene un valor razonable por omisión), así que los cuatro sitios
302/// que construían una instancia con `..Default::default()` necesitaban todos la
303/// misma reescritura.
304/// El display que lleva adentro un `SurfaceTargetUnsafe`, si es de los que
305/// traen handles crudos.
306///
307/// Las otras variantes del enum (las de puntero de plataforma) no exponen un
308/// display separado; ahí no hay nada que enhebrar y se devuelve `None`, que es
309/// exactamente el comportamiento de antes de wgpu 29.
310fn display_de(target: &wgpu::SurfaceTargetUnsafe) -> Option<DisplayCrudo> {
311 match target {
312 wgpu::SurfaceTargetUnsafe::RawHandle {
313 raw_display_handle, ..
314 } => raw_display_handle.map(DisplayCrudo),
315 _ => None,
316 }
317}
318
319/// Instancia **sin** display: los caminos donde no hay ventana todavía (el
320/// arranque diferido de escritorio) o donde no aplica (canvas del navegador).
321fn instancia(backends: wgpu::Backends) -> wgpu::Instance {
322 let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
323 desc.backends = backends;
324 wgpu::Instance::new(desc)
325}
326
327/// Instancia **con** display. Ver el porqué en [`instancia`].
328///
329/// Genérica en vez de tomar un `Box<dyn _>` porque wgpu **no reexporta** el
330/// trait `WgpuHasDisplayHandle` que pide ese campo: no hay forma de nombrarlo
331/// desde afuera. Sí hay una impl general para todo `HasDisplayHandle + Debug +
332/// Send + Sync + 'static`, así que se piden esos límites y la coerción al objeto
333/// de trait la resuelve el compilador al asignar el campo.
334fn instancia_con<D>(backends: wgpu::Backends, display: D) -> wgpu::Instance
335where
336 D: raw_window_handle::HasDisplayHandle + core::fmt::Debug + Send + Sync + 'static,
337{
338 let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
339 desc.backends = backends;
340 desc.display = Some(Box::new(display));
341 wgpu::Instance::new(desc)
342}
343
344/// Pista de memoria explícita por la env `LLIMPHI_WGPU_MEM`. `low`/`min`/`ram`
345/// → [`MemoryHints::MemoryUsage`] (el asignador de wgpu minimiza la huella:
346/// bloques de suballocación chicos, menos reserva especulativa). `perf` →
347/// [`MemoryHints::Performance`] (pools grandes, prioriza velocidad). `None` si
348/// la env no está o no se reconoce → el caller **autodetecta** (ver
349/// [`resolver_memory_hint`]). El manager de `pacha` la fija por contexto para
350/// equipos chicos; sin ella, la app decide sola.
351fn memory_hint_env() -> Option<wgpu::MemoryHints> {
352 match std::env::var("LLIMPHI_WGPU_MEM")
353 .map(|v| v.trim().to_ascii_lowercase())
354 .as_deref()
355 {
356 Ok("low") | Ok("min") | Ok("ram") | Ok("memoria") => Some(wgpu::MemoryHints::MemoryUsage),
357 Ok("perf") | Ok("performance") | Ok("velocidad") => Some(wgpu::MemoryHints::Performance),
358 Ok(other) if !other.is_empty() => {
359 crate::avisar!("llimphi-hal: LLIMPHI_WGPU_MEM=«{other}» no reconocido; autodetecto");
360 None
361 }
362 _ => None,
363 }
364}
365
366/// Resuelve la pista de memoria del device: la env manda; sin ella,
367/// **autoadapta** a la máquina. Un adapter de tipo `Cpu` (raster por software,
368/// llvmpipe / VM sin GPU) no tiene VRAM y compone contra la RAM del sistema, así
369/// que ahí `MemoryUsage` (mínima huella) es la elección correcta por defecto. Con
370/// una GPU real se mantiene `Performance` salvo que el env/pacha pida lo
371/// contrario. Es la contraparte a nivel de render de `pacha_core::Rendimiento`.
372/// Los límites con los que se pide el `Device`, en un solo sitio.
373///
374/// `Limits::default()` cubre los 5 storage buffers/stage que vello necesita;
375/// `downlevel_defaults()` sólo expone 4 y rompe el raster. Si el adapter no lo
376/// aguanta, `using_resolution` recorta lo recortable (texturas y buffers
377/// grandes) preservando los conteos mínimos.
378///
379/// Encima de eso, **el techo de textura se redondea hacia abajo a una potencia
380/// de dos**. No es cosmético: en el perfil hybrid, `vello_hybrid` dimensiona su
381/// textura de alfas con `max_texture_dimension_2d` y la direcciona por máscara y
382/// corrimiento (`i & (ancho-1)`, `i >> ancho.trailing_zeros()`), suponiendo sin
383/// verificar que es potencia de dos. El Mali-G57 del Redmi reporta 16383, cuyo
384/// `trailing_zeros()` es 0 — y ahí se cae TODA la cobertura parcial: cero
385/// antialiasing y cada forma perdiendo su primer tile, sin un solo error de
386/// validación. La versión documentada y medida de esto es
387/// `llimphi_hybrid::limites_seguros`; acá se repite la cuenta en vez de
388/// depender del crate para no arrastrar `vello_hybrid` a cada build de
389/// escritorio. Ver `02_ruway/llimphi/android/DIAGNOSTICO.md` §F8.
390///
391/// En una GPU que ya reporta potencia de dos —todas las de escritorio— es un
392/// no-op.
393fn limites_para(adapter: &wgpu::Adapter) -> wgpu::Limits {
394 let mut limites = wgpu::Limits::default().using_resolution(adapter.limits());
395 let techo = limites.max_texture_dimension_2d;
396 if techo > 0 && !techo.is_power_of_two() {
397 limites.max_texture_dimension_2d = 1 << (u32::BITS - 1 - techo.leading_zeros());
398 }
399 limites
400}
401
402fn resolver_memory_hint(adapter: &wgpu::Adapter) -> wgpu::MemoryHints {
403 if let Some(h) = memory_hint_env() {
404 return h;
405 }
406 match adapter.get_info().device_type {
407 wgpu::DeviceType::Cpu => wgpu::MemoryHints::MemoryUsage,
408 _ => wgpu::MemoryHints::Performance,
409 }
410}
411
412/// Preferencia de potencia por la env `LLIMPHI_WGPU_POWER`. `low`/`ahorro` →
413/// [`PowerPreference::LowPower`] (elige la iGPU integrada en equipos híbridos:
414/// menos consumo, menos RAM de driver, y de paso esquiva el problema de dmabuf
415/// cruzando dispositivos en Optimus). Sin la env → `HighPerformance`, el default
416/// de siempre. Una barra o un WM 2D nunca necesitan la dGPU discreta.
417fn power_preference_override() -> wgpu::PowerPreference {
418 match std::env::var("LLIMPHI_WGPU_POWER")
419 .map(|v| v.trim().to_ascii_lowercase())
420 .as_deref()
421 {
422 Ok("low") | Ok("ahorro") | Ok("integrada") => wgpu::PowerPreference::LowPower,
423 Ok("high") | Ok("alto") | Ok("discreta") => wgpu::PowerPreference::HighPerformance,
424 Ok(other) if !other.is_empty() => {
425 crate::avisar!("llimphi-hal: LLIMPHI_WGPU_POWER=«{other}» no reconocido; uso HighPerformance");
426 wgpu::PowerPreference::HighPerformance
427 }
428 _ => wgpu::PowerPreference::HighPerformance,
429 }
430}
431
432/// Precisión de color que se le pide al **swapchain**.
433///
434/// ## El techo honesto, antes de que nadie prometa HDR
435///
436/// El motor rasteriza **siempre** contra una textura intermedia
437/// [`INTERMEDIATE_FORMAT`] = `Rgba8Unorm`, porque `vello` lo exige
438/// (`Renderer::render_to_texture` pide justo ese formato con `STORAGE_BINDING`).
439/// O sea: **el contenido vello es 8 bits por canal, elijas el swapchain que
440/// elijas**. Pedir `Rgb10` con una UI normal sólo *upsamplea* 8 bits a 10 — no
441/// aparece rango que la escena no tenga. Eso no es HDR, es HDR falso, y el
442/// motivo por el que este enum NO tiene variantes `HdrPq`/`HdrHlg`.
443///
444/// Dos cosas sí son reales y por eso esto existe:
445///
446/// 1. **El camino GPU-directo** (`llimphi_raster::gpu::GpuBatch`) rasteriza a
447/// un target parametrizable con color de vértice `Float32x4`, así que un
448/// canvas denso que pinte por ahí **sí** carga más de 8 bits
449/// (`llimphi-raster/tests/gpu_hdr_precision`). Para que esa precisión llegue
450/// a la pantalla hace falta que el swapchain no la tire, y eso es lo que
451/// elige este enum.
452/// 2. **El banding de los degradés**: 10 bits en el swapchain suaviza las
453/// escaleras de un gradiente de fondo grande aunque la fuente sea de 8, que
454/// es un efecto chico pero visible y gratis.
455///
456/// ## Lo que wgpu 29 NO deja pedir
457///
458/// El espacio de color. `wgpu::SurfaceConfiguration` no tiene campo de
459/// colorspace y `wgpu::SurfaceCapabilities` no enumera espacios (verificado en
460/// `wgpu-types-29.0.3/src/surface.rs:136-153`: sólo `formats`, `present_modes`,
461/// `alpha_modes`, `usages`). Vulkan tiene `VK_EXT_swapchain_colorspace`, pero
462/// wgpu no lo expone. Sin ese campo **no se puede señalar PQ ni HLG**, así que
463/// un display HDR de verdad seguirá interpretando lo presentado como sRGB. Eso
464/// es un bloqueo de upstream, no una tarea pendiente de este crate.
465#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
466#[non_exhaustive]
467pub enum ColorTarget {
468 /// 8 bits por canal, sin sRGB de hardware. El default de siempre: el blit
469 /// desde la intermedia lineal preserva los valores tal cual.
470 #[default]
471 Srgb8,
472 /// 10 bits por canal (`Rgb10a2Unorm`). Cae a [`ColorTarget::Srgb8`] si la
473 /// surface no lo ofrece.
474 Rgb10,
475 /// Punto flotante de 16 bits (`Rgba16Float`). Es el único que preserva
476 /// valores > 1.0 del camino GPU-directo. Raro fuera de Vulkan con
477 /// colorspace extendido; cae a `Rgb10` y después a `Srgb8`.
478 Float16,
479}
480
481impl ColorTarget {
482 /// Los formatos que sirven a este objetivo, del preferido al tolerable.
483 fn candidatos(self) -> &'static [wgpu::TextureFormat] {
484 use wgpu::TextureFormat as F;
485 match self {
486 // Bgra8 primero: es el que casi todo compositor prefiere, y así se
487 // evita una conversión de canales en el present.
488 ColorTarget::Srgb8 => &[F::Bgra8Unorm, F::Rgba8Unorm],
489 ColorTarget::Rgb10 => &[F::Rgb10a2Unorm],
490 ColorTarget::Float16 => &[F::Rgba16Float],
491 }
492 }
493
494 /// La cadena de degradación: lo que se intenta si el objetivo no está.
495 fn cadena(self) -> &'static [ColorTarget] {
496 match self {
497 ColorTarget::Srgb8 => &[ColorTarget::Srgb8],
498 ColorTarget::Rgb10 => &[ColorTarget::Rgb10, ColorTarget::Srgb8],
499 ColorTarget::Float16 => {
500 &[ColorTarget::Float16, ColorTarget::Rgb10, ColorTarget::Srgb8]
501 }
502 }
503 }
504
505 /// El nombre con el que se pide por `LLIMPHI_COLOR`. Es el que va en el
506 /// informe: si alguien lee «DEGRADADO a Bgra8Unorm» quiere saber qué
507 /// escribió él, no cómo se llama la variante en Rust.
508 pub fn nombre(self) -> &'static str {
509 match self {
510 ColorTarget::Srgb8 => "srgb8",
511 ColorTarget::Rgb10 => "rgb10",
512 ColorTarget::Float16 => "float16",
513 }
514 }
515}
516
517/// Objetivo de color pedido por entorno: `LLIMPHI_COLOR = srgb8 | rgb10 |
518/// float16`. Mismo idiom que [`memory_hint_env`] y `LLIMPHI_PRESENT_MODE`:
519/// sirve para un A/B en la laptop con display real sin recompilar. Sin la env,
520/// [`ColorTarget::Srgb8`] — cero regresión contra el comportamiento histórico.
521fn color_target_env() -> ColorTarget {
522 objetivo_pedido().unwrap_or_default()
523}
524
525/// Lo mismo que [`color_target_env`] pero distinguiendo «no pidió nada» de
526/// «pidió 8 bits». Los dos casos eligen el mismo formato, y por eso el
527/// selector no necesita la diferencia; el **informe** sí: se le habla sólo a
528/// quien preguntó, para que una app que no toca la env siga arrancando muda.
529fn objetivo_pedido() -> Option<ColorTarget> {
530 match std::env::var("LLIMPHI_COLOR")
531 .map(|v| v.trim().to_ascii_lowercase())
532 .as_deref()
533 {
534 Ok("srgb8") | Ok("srgb") | Ok("8") => Some(ColorTarget::Srgb8),
535 Ok("rgb10") | Ok("10") | Ok("10bit") => Some(ColorTarget::Rgb10),
536 Ok("float16") | Ok("f16") | Ok("hdr") => Some(ColorTarget::Float16),
537 Ok(other) if !other.is_empty() => {
538 crate::avisar!("llimphi-hal: LLIMPHI_COLOR=«{other}» no reconocido; uso srgb8");
539 Some(ColorTarget::Srgb8)
540 }
541 _ => None,
542 }
543}
544
545/// Elige el formato del swapchain entre los que ofrece la surface, para el
546/// `objetivo` pedido. **Función pura** — por eso se puede certificar por texto
547/// sin GPU (`tests/formato.rs`).
548///
549/// Degrada por la cadena de [`ColorTarget::cadena`] y, si no encuentra nada
550/// conocido, devuelve `None` para que el caller aplique su propio último
551/// recurso (el primer formato que liste la surface, que es el preferido del
552/// backend).
553///
554/// Nunca elige un formato `*Srgb`: la intermedia es lineal y el blit debe
555/// pasar los valores tal cual; con un target sRGB el hardware aplicaría la
556/// curva por segunda vez y todo saldría lavado.
557pub fn elegir_formato(
558 disponibles: &[wgpu::TextureFormat],
559 objetivo: ColorTarget,
560) -> Option<wgpu::TextureFormat> {
561 objetivo
562 .cadena()
563 .iter()
564 .flat_map(|t| t.candidatos())
565 .find(|f| disponibles.contains(f))
566 .copied()
567}
568
569/// Qué se pidió y qué se concedió, en una línea. **Función pura** — se
570/// certifica por texto sin GPU (`tests/formato.rs`), igual que
571/// [`elegir_formato`].
572///
573/// Existe porque la degradación era **muda**: pedir `LLIMPHI_COLOR=rgb10` en
574/// una surface que no ofrece 10 bits daba una ventana de 8 bits idéntica a la
575/// de siempre, sin ninguna señal, y la única forma de saber cuál se había
576/// elegido era leer `WinitSurface::format()` con un depurador. Un A/B de
577/// precisión en el que las dos ramas se ven iguales y ninguna dice nada no es
578/// un A/B: es adivinar.
579pub fn informe_formato(
580 objetivo: ColorTarget,
581 elegido: Option<wgpu::TextureFormat>,
582 disponibles: &[wgpu::TextureFormat],
583) -> String {
584 let pedido = objetivo.nombre();
585 let Some(elegido) = elegido else {
586 return format!("LLIMPHI_COLOR={pedido}: la surface no ofrece ningún formato");
587 };
588 if objetivo.candidatos().contains(&elegido) {
589 format!("LLIMPHI_COLOR={pedido} concedido: {elegido:?}")
590 } else {
591 format!(
592 "LLIMPHI_COLOR={pedido} DEGRADADO a {elegido:?}: la surface no ofrece \
593 ninguno de {:?}; ofrece {disponibles:?}",
594 objetivo.candidatos()
595 )
596 }
597}
598
599/// El formato de swapchain para esta surface: [`elegir_formato`] con el
600/// objetivo del entorno, y el primer formato de la surface como último recurso.
601///
602/// `donde` nombra la superficie (una app puede abrir varias: la ventana, las
603/// layer surfaces de sus popovers) para que el informe diga de cuál habla.
604fn formato_de_swapchain(
605 caps: &wgpu::SurfaceCapabilities,
606 donde: &str,
607) -> Option<wgpu::TextureFormat> {
608 let objetivo = color_target_env();
609 let elegido =
610 elegir_formato(&caps.formats, objetivo).or_else(|| caps.formats.first().copied());
611 if objetivo_pedido().is_some() {
612 crate::avisar!(
613 "llimphi-hal[{donde}]: {}",
614 informe_formato(objetivo, elegido, &caps.formats)
615 );
616 }
617 elegido
618}
619
620/// Gancho de verificación: fuerza un [`SurfaceError::DeviceLost`] sintético cada
621/// N cuadros para poder ejercitar la recuperación de device SIN esperar un reset
622/// de output real (que en Iris Xe sólo pasa bajo mirada-DRM, no bajo KDE/kwin).
623///
624/// Activar con `LLIMPHI_SIMULATE_DEVICE_LOSS=N` (N≥1): cada N `acquire()`
625/// exitosos, el N-ésimo devuelve `DeviceLost` en vez de un `Frame`. El dueño del
626/// bucle debe reconstruir todo el stack GPU; si tras eso el render sigue vivo y
627/// sin fugas, la recuperación quedó certificada como texto (no screenshot). Sin
628/// la env, es un no-op de coste cero (un `load` atómico Relaxed). El contador es
629/// global al proceso: con varias surfaces reparte los trips entre ellas, lo cual
630/// es justo lo que queremos (probar que CUALQUIER surface dispara el rebuild).
631fn simulate_device_loss_trip() -> bool {
632 use std::sync::atomic::{AtomicU64, Ordering};
633 use std::sync::OnceLock;
634 static PERIOD: OnceLock<Option<u64>> = OnceLock::new();
635 let period = *PERIOD.get_or_init(|| {
636 std::env::var("LLIMPHI_SIMULATE_DEVICE_LOSS")
637 .ok()
638 .and_then(|v| v.trim().parse::<u64>().ok())
639 .filter(|n| *n >= 1)
640 });
641 let Some(period) = period else { return false };
642 static COUNT: AtomicU64 = AtomicU64::new(0);
643 // fetch_add devuelve el valor previo; el trip cae en el múltiplo de period.
644 (COUNT.fetch_add(1, Ordering::Relaxed) + 1) % period == 0
645}
646
647impl Hal {
648 /// Construye Hal pidiendo un adapter compatible con una surface dada
649 /// (recomendado: pasar `Some(&surface)` para garantizar que el adapter
650 /// elegido sabe presentar a esa surface).
651 pub async fn new(
652 compatible_surface: Option<&wgpu::Surface<'static>>,
653 ) -> Result<Self, HalError> {
654 Self::new_inner(compatible_surface, false).await
655 }
656
657 /// Construye un `Hal` **headless** (sin surface) prefiriendo el adapter de
658 /// **software** (llvmpipe/lavapipe, `deviceType == CPU`) cuando
659 /// `prefer_software`. Es el modo de los tests golden de `llimphi-test`: el
660 /// rasterizador por software es DETERMINISTA e idéntico host-a-host y en CI,
661 /// así el golden no queda atado a la GPU de quien lo generó. Si la máquina no
662 /// expone un fallback adapter (no hay lavapipe instalado), cae al adapter
663 /// normal — en ese caso el golden se certifica con tolerancia perceptual, no
664 /// bit-exacta. Nunca pasa `compatible_surface`: renderiza a textura, no
665 /// presenta a ventana.
666 ///
667 /// # Correrlo contra la GPU de verdad
668 ///
669 /// Con `LLIMPHI_TEST_GPU=1` se ignora `prefer_software` y se usa el adapter
670 /// normal. Existe porque **toda** la certificación headless de llimphi
671 /// —cachés, troceado, scissor, precisión float, el texto por atlas, los tres
672 /// caminos a píxeles— corre contra un rasterizador de software, y eso deja
673 /// una pregunta sin contestar: ¿el hardware hace lo mismo?
674 ///
675 /// No es una duda abstracta. El 2026-08-05 se midió el ahorro del `scissor`
676 /// en las dos: llvmpipe decía 97,9 %, la Iris Xe dice 57 %. Ahí la
677 /// diferencia era de *rendimiento*, que se esperaba; pero nada garantiza que
678 /// una diferencia de *resultado* no esté esperando en algún borde.
679 ///
680 /// El default sigue siendo el software, y debe seguir: es lo que hace que un
681 /// golden valga lo mismo en cualquier máquina y en CI. Esto es para preguntar
682 /// a mano, no para cambiar la referencia.
683 pub async fn new_headless(prefer_software: bool) -> Result<Self, HalError> {
684 let forzar_gpu = std::env::var("LLIMPHI_TEST_GPU").ok().as_deref() == Some("1");
685 if prefer_software && !forzar_gpu {
686 if let Ok(hal) = Self::new_inner(None, true).await {
687 return Ok(hal);
688 }
689 // Sin fallback adapter disponible: seguimos con el adapter normal; la
690 // tolerancia perceptual del arnés absorbe la diferencia de vendor.
691 }
692 Self::new_inner(None, false).await
693 }
694
695 /// Igual que [`Hal::new`], pero **con el display del sistema de ventanas**.
696 ///
697 /// ## Por qué hace falta, y por qué no alcanza con que `instancia_con` exista
698 ///
699 /// Medido el 2026-09-18 en una VM sin Vulkan (mesa iris/llvmpipe, `-Dvulkan-drivers=` vacío):
700 /// `shuma-pregunta` y `boveda` **no abren ventana**, y el panic no nombra la causa —
701 /// `index out of bounds: the len is 0` en `caps.formats[0]`, o sea que la surface no tiene NI
702 /// UN formato—. La cadena entera, con `RUST_LOG=wgpu_hal=debug`:
703 ///
704 /// ```text
705 /// No (or unknown) windowing system ((None, Some(..))) present. Using surfaceless platform
706 /// Trying native-render → No config found! · Trying presentation → No config found!
707 /// ```
708 ///
709 /// Ese `None` es el `raw_display_handle` que [`Hal::new`] **no pasa a propósito**, porque su
710 /// camino se pensó como «todavía no hay ventana». Sin display, wgpu abre el display EGL por la
711 /// plataforma *surfaceless*, que por definición no publica configs con `WINDOW_BIT`: sin config
712 /// de ventana la surface no es presentable, y sin eso no hay formatos.
713 ///
714 /// **En una máquina con Vulkan no se nota**, porque `Backends::PRIMARY` no necesita el display.
715 /// Sólo se cae al backend GL cuando no hay Vulkan/Metal/DX12 — que es el caso de cualquier VM
716 /// sin ICD y de una distro que compila mesa sin drivers Vulkan. Ahí, hasta hoy, **ninguna app
717 /// llimphi de escritorio podía pintar**.
718 ///
719 /// El llamador de escritorio (`llimphi-ui`) sí tiene la ventana cuando construye el `Hal`: la
720 /// creó una línea antes para hacerle la surface. Lo único que faltaba era enhebrarla.
721 pub async fn new_con_display<D>(
722 compatible_surface: Option<&wgpu::Surface<'static>>,
723 display: D,
724 ) -> Result<Self, HalError>
725 where
726 D: raw_window_handle::HasDisplayHandle
727 + core::fmt::Debug
728 + Clone
729 + Send
730 + Sync
731 + 'static,
732 {
733 Self::new_generico(compatible_surface, false, move |b| {
734 instancia_con(b, display.clone())
735 })
736 .await
737 }
738
739 async fn new_inner(
740 compatible_surface: Option<&wgpu::Surface<'static>>,
741 force_fallback_adapter: bool,
742 ) -> Result<Self, HalError> {
743 Self::new_generico(compatible_surface, force_fallback_adapter, instancia).await
744 }
745
746 /// El cuerpo compartido por los dos caminos. Lo único que los distingue es **cómo se arma la
747 /// instancia**, y por eso eso es lo que se parametriza: duplicar el resto —la elección de
748 /// backend, el fallback, los límites, el handler de errores— es cómo se termina con dos
749 /// caminos que divergen sin que nadie se entere.
750 async fn new_generico(
751 compatible_surface: Option<&wgpu::Surface<'static>>,
752 force_fallback_adapter: bool,
753 armar: impl Fn(wgpu::Backends) -> wgpu::Instance,
754 ) -> Result<Self, HalError> {
755 let opts = wgpu::RequestAdapterOptions {
756 power_preference: power_preference_override(),
757 force_fallback_adapter,
758 compatible_surface,
759 };
760 // Preferimos backends PRIMARY (Vulkan/Metal/DX12). El backend GL de
761 // wgpu sobre Mesa/Wayland tiene un bug de teardown: al soltar la
762 // instancia, `eglTerminate` marshalea sobre una conexión Wayland ya
763 // muerta (`wl_proxy_marshal`) y revienta con SIGSEGV. Con
764 // `Backends::all()` (el default), wgpu puede elegir GL aun habiendo
765 // Vulkan, y la app crashea al cerrar/teardown. Forzamos PRIMARY; si la
766 // máquina no tiene Vulkan/Metal/DX12 (VM vieja, etc.) caemos a todos
767 // los backends —incluido GL— para no dejarla sin gráficos. En el
768 // camino de escritorio `compatible_surface` es `None` (la surface se
769 // crea después contra esta misma instancia), así que cambiar de
770 // instancia aquí es seguro.
771 // Override por env (`LLIMPHI_WGPU_BACKEND`) o PRIMARY por default.
772 let forced = backend_override();
773 // La instancia la arma el llamador (`armar`): `new_inner` sin display —el camino que
774 // de verdad no tiene ventana todavía— y `new_con_display` con ella. ⚠ Antes ESTE camino
775 // era el único y siempre iba sin display, y en una máquina sin Vulkan eso significa que
776 // la ventana nunca presenta: ver `new_con_display`.
777 let primary = armar(forced.unwrap_or(wgpu::Backends::PRIMARY));
778 let (instance, adapter) = match primary.request_adapter(&opts).await {
779 Ok(a) => (primary, a),
780 // Con override explícito NO caemos a otros backends: el operador eligió.
781 Err(_) if forced.is_some() => return Err(HalError::NoAdapter),
782 Err(_) => {
783 let all = armar(wgpu::Backends::all());
784 let a = all
785 .request_adapter(&opts)
786 .await
787 .map_err(|_| HalError::NoAdapter)?;
788 (all, a)
789 }
790 };
791 let limits = limites_para(&adapter);
792 let (device, queue) = adapter
793 .request_device(&wgpu::DeviceDescriptor {
794 label: Some("llimphi-hal-device"),
795 required_features: wgpu::Features::empty(),
796 required_limits: limits,
797 memory_hints: resolver_memory_hint(&adapter),
798 experimental_features: wgpu::ExperimentalFeatures::default(),
799 trace: wgpu::Trace::Off,
800 })
801 .await
802 .map_err(|e| HalError::RequestDevice(e.to_string()))?;
803 instalar_handler_errores(&device);
804 Ok(Self {
805 instance,
806 adapter,
807 device,
808 queue,
809 })
810 }
811
812 /// Construye el `Hal` **y** una [`RawSurface`] a la vez, eligiendo el adaptador
813 /// **compatible con esa surface** — el dispositivo que el compositor sabe
814 /// presentar. Es el camino correcto para el backend layer-shell de `pata`.
815 ///
816 /// El problema que resuelve: en sistemas multi-GPU (Optimus), pedir el
817 /// adaptador sin pista de surface (`new(None)` con `HighPerformance`) puede
818 /// elegir la dGPU mientras el compositor compone en la iGPU → los dmabuf
819 /// cruzan dispositivos y `get_capabilities` devuelve 0 formatos (la surface
820 /// "no expone formatos"). Pasar `compatible_surface` ata el adaptador al
821 /// dispositivo del compositor. Como la surface hace falta ANTES de pedir el
822 /// adaptador, y `new` crea la instancia internamente, este constructor une los
823 /// dos pasos.
824 ///
825 /// `make_target` reconstruye el `SurfaceTargetUnsafe` cada vez que se llama
826 /// (los `RawHandle` son `Copy`): `create_surface_unsafe` consume el target y
827 /// puede que probemos dos instancias (PRIMARY y, si no hay adaptador, todos
828 /// los backends — el GL de Mesa/Wayland revienta en teardown, por eso PRIMARY
829 /// primero, igual que [`Hal::new`]).
830 ///
831 /// # Safety
832 /// Los handles que produce `make_target` deben apuntar a objetos Wayland/…
833 /// vivos durante toda la vida de la `RawSurface` devuelta.
834 pub async unsafe fn new_for_raw_surface(
835 make_target: impl Fn() -> wgpu::SurfaceTargetUnsafe,
836 width: u32,
837 height: u32,
838 ) -> Result<(Self, RawSurface), HalError> {
839 // PRIMARY (Vulkan/Metal/DX12) primero; si no hay adaptador compatible, a
840 // todos los backends recreando instancia y surface.
841 // Override por env (`LLIMPHI_WGPU_BACKEND`) o PRIMARY por default.
842 let forced = backend_override();
843 // El display del compositor sale del propio target: es el mismo handle
844 // que `create_surface_unsafe` va a usar. Enhebrarlo importa acá más que
845 // en ningún otro camino — éste es el de layer-shell sobre Wayland, y es
846 // el que cae a GL cuando no hay Vulkan.
847 let display = display_de(&make_target());
848 let primary = match display {
849 Some(d) => instancia_con(forced.unwrap_or(wgpu::Backends::PRIMARY), d),
850 None => instancia(forced.unwrap_or(wgpu::Backends::PRIMARY)),
851 };
852 let prim_surface = unsafe { primary.create_surface_unsafe(make_target()) }
853 .map_err(|e| HalError::CreateSurface(e.to_string()))?;
854 let prim_adapter = primary
855 .request_adapter(&wgpu::RequestAdapterOptions {
856 power_preference: power_preference_override(),
857 force_fallback_adapter: false,
858 compatible_surface: Some(&prim_surface),
859 })
860 .await;
861 let (instance, adapter, wgpu_surface) = match prim_adapter {
862 Ok(a) => (primary, a, prim_surface),
863 // Con override explícito NO caemos a otros backends: el operador eligió.
864 Err(_) if forced.is_some() => return Err(HalError::NoAdapter),
865 Err(_) => {
866 let all = match display {
867 Some(d) => instancia_con(wgpu::Backends::all(), d),
868 None => instancia(wgpu::Backends::all()),
869 };
870 let surface = unsafe { all.create_surface_unsafe(make_target()) }
871 .map_err(|e| HalError::CreateSurface(e.to_string()))?;
872 let a = all
873 .request_adapter(&wgpu::RequestAdapterOptions {
874 power_preference: power_preference_override(),
875 force_fallback_adapter: false,
876 compatible_surface: Some(&surface),
877 })
878 .await
879 .map_err(|_| HalError::NoAdapter)?;
880 (all, a, surface)
881 }
882 };
883 let limits = limites_para(&adapter);
884 let (device, queue) = adapter
885 .request_device(&wgpu::DeviceDescriptor {
886 label: Some("llimphi-hal-device"),
887 required_features: wgpu::Features::empty(),
888 required_limits: limits,
889 memory_hints: resolver_memory_hint(&adapter),
890 experimental_features: wgpu::ExperimentalFeatures::default(),
891 trace: wgpu::Trace::Off,
892 })
893 .await
894 .map_err(|e| HalError::RequestDevice(e.to_string()))?;
895 instalar_handler_errores(&device);
896 let hal = Self {
897 instance,
898 adapter,
899 device,
900 queue,
901 };
902 // Extraemos los raw handles del target para que la `RawSurface` pueda
903 // recrearse ante una pérdida (los `RawHandle` son `Copy`).
904 let (raw_display, raw_window) = match make_target() {
905 wgpu::SurfaceTargetUnsafe::RawHandle {
906 raw_display_handle,
907 raw_window_handle,
908 } => (
909 // wgpu 29 volvió opcional el display del target. `RawSurface`
910 // lo guarda sin envolver porque lo necesita SIEMPRE para
911 // recrearse; si el caller no lo trajo, no hay con qué recrear y
912 // es mejor decirlo acá que fallar a mitad de una recuperación.
913 raw_display_handle.ok_or_else(|| {
914 HalError::CreateSurface(
915 "new_for_raw_surface requiere un display handle en el target".into(),
916 )
917 })?,
918 raw_window_handle,
919 ),
920 _ => {
921 return Err(HalError::CreateSurface(
922 "new_for_raw_surface requiere SurfaceTargetUnsafe::RawHandle".into(),
923 ))
924 }
925 };
926 let surface =
927 RawSurface::from_surface(&hal, wgpu_surface, raw_display, raw_window, width, height)?;
928 Ok((hal, surface))
929 }
930
931 /// Reconstruye **todo el stack GPU** (instance+adapter+device+queue) y una
932 /// [`WinitSurface`] nueva sobre la MISMA ventana, eligiendo el adapter
933 /// compatible con la surface recreada. Es la recuperación real ante
934 /// [`SurfaceError::DeviceLost`] en el camino winit (apps `llimphi_ui::run`:
935 /// greeter/shuma/nada/cosmos…): reconfigurar o recrear sólo la surface no
936 /// alcanza cuando el adapter dejó de matchear (Iris Xe «queue family»); hay
937 /// que rehacer el device entero. Tras esto el caller debe reconstruir lo que
938 /// cuelga del device (renderer vello, compositores, texturas de la app)
939 /// contra el `Hal` devuelto.
940 ///
941 /// Es **surface-first** (crea la surface antes de pedir el adapter con
942 /// `compatible_surface`), lo correcto para garantizar el match adapter↔surface
943 /// tras la pérdida. Bloquea internamente (`pollster`) porque la recuperación
944 /// ocurre dentro de un handler de redraw síncrono. Respeta el override de
945 /// backend (`LLIMPHI_WGPU_BACKEND`) igual que [`Hal::new`].
946 ///
947 /// **No existe en wasm**, justamente por ese `pollster`: en el navegador no
948 /// hay hilos y el `Condvar` de `block_on` paniquea. La recuperación de
949 /// device en web tendría que ser asíncrona, como el arranque
950 /// ([`Hal::for_canvas`]); hoy no está, y que la función no exista convierte
951 /// el intento en un error de compilación en vez de un `unreachable` en
952 /// producción.
953 #[cfg(not(target_arch = "wasm32"))]
954 pub fn recreate_for_window(
955 window: Arc<Window>,
956 ) -> Result<(Self, WinitSurface), HalError> {
957 let forced = backend_override();
958 // La ventana YA implementa `HasDisplayHandle`, así que acá enhebrar el
959 // display no cuesta nada: se clona el `Arc`. Importa porque este camino
960 // también cae a `Backends::all()` —o sea GL— si el adapter PRIMARY no
961 // vuelve, y es la recuperación tras perder el device: presentar mal acá
962 // significa una ventana negra permanente.
963 let make_instance = |backends: wgpu::Backends| {
964 instancia_con(backends, window.clone())
965 };
966 let request_adapter = |inst: &wgpu::Instance, surf: &wgpu::Surface<'static>| {
967 pollster::block_on(inst.request_adapter(&wgpu::RequestAdapterOptions {
968 power_preference: power_preference_override(),
969 force_fallback_adapter: false,
970 compatible_surface: Some(surf),
971 }))
972 };
973 let primary = make_instance(forced.unwrap_or(wgpu::Backends::PRIMARY));
974 let prim_surface = primary
975 .create_surface(window.clone())
976 .map_err(|e| HalError::CreateSurface(e.to_string()))?;
977 let (instance, adapter, surface) = match request_adapter(&primary, &prim_surface) {
978 Ok(a) => (primary, a, prim_surface),
979 // Con override explícito NO caemos a otros backends: el operador eligió.
980 Err(_) if forced.is_some() => return Err(HalError::NoAdapter),
981 Err(_) => {
982 let all = make_instance(wgpu::Backends::all());
983 let surface = all
984 .create_surface(window.clone())
985 .map_err(|e| HalError::CreateSurface(e.to_string()))?;
986 let a = request_adapter(&all, &surface).map_err(|_| HalError::NoAdapter)?;
987 (all, a, surface)
988 }
989 };
990 let limits = limites_para(&adapter);
991 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
992 label: Some("llimphi-hal-device"),
993 required_features: wgpu::Features::empty(),
994 required_limits: limits,
995 memory_hints: resolver_memory_hint(&adapter),
996 experimental_features: wgpu::ExperimentalFeatures::default(),
997 trace: wgpu::Trace::Off,
998 }))
999 .map_err(|e| HalError::RequestDevice(e.to_string()))?;
1000 instalar_handler_errores(&device);
1001 let hal = Self {
1002 instance,
1003 adapter,
1004 device,
1005 queue,
1006 };
1007 let surface = WinitSurface::from_surface(&hal, window, surface)?;
1008 Ok((hal, surface))
1009 }
1010}
1011
1012/// Surface basada en `winit::window::Window`. Mantiene una textura
1013/// intermedia `Rgba8Unorm` con storage binding (donde pinta vello) y
1014/// un `TextureBlitter` que la copia al swapchain al presentar.
1015pub struct WinitSurface {
1016 window: Arc<Window>,
1017 /// `None` sólo entre [`WinitSurface::soltar_vk_surface`] y la creación de
1018 /// la siguiente: en Android hay que DESTRUIR la VkSurface antes de crear
1019 /// otra sobre la misma `ANativeWindow`.
1020 surface: Option<wgpu::Surface<'static>>,
1021 config: wgpu::SurfaceConfiguration,
1022 device: wgpu::Device,
1023 intermediate: wgpu::Texture,
1024 intermediate_view: wgpu::TextureView,
1025 /// Textura de la capa de overlay (ver [`Frame::overlay_view`]).
1026 overlay: wgpu::Texture,
1027 overlay_view: wgpu::TextureView,
1028 blitter: wgpu::util::TextureBlitter,
1029 /// La instancia con que se creó la surface: guardada para poder RECREARLA
1030 /// desde la ventana ante un hipo del compositor/GPU (ver
1031 /// [`WinitSurface::recreate`]) antes de escalar a un rebuild de device.
1032 instance: wgpu::Instance,
1033 /// El `present` descubrió que el device está perdido. Lo levanta
1034 /// [`Surface::present`] y lo cobra el siguiente [`Surface::acquire`],
1035 /// devolviendo [`SurfaceError::DeviceLost`] para que el runtime rehaga el
1036 /// stack por el camino que ya existe. Ver el porqué en `present`.
1037 device_perdido: bool,
1038}
1039
1040/// El formato de la textura contra la que rasteriza **siempre** el motor.
1041///
1042/// Es público porque el rasterizador lo necesita y no puede deducirlo: un
1043/// `TextureView` de wgpu no dice su formato. Y es un solo formato, no el del
1044/// swapchain, porque [`Frame::view`] y [`Frame::overlay_view`] devuelven las
1045/// texturas **intermedias**, no la de presentación — el blitter de
1046/// `llimphi-hal` es quien traduce al formato que pida la pantalla. Eso le
1047/// ahorra al rasterizador tener que compilar pipelines por formato, que en
1048/// GPUs móviles es la operación cara (y en el Mali del teléfono, la peligrosa).
1049pub const INTERMEDIATE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
1050
1051impl WinitSurface {
1052 /// Constructor "feliz": crea la `wgpu::Surface` internamente.
1053 /// Conveniente en desktop donde la secuencia normal es
1054 /// `Hal::new(None)` → `WinitSurface::new(hal, window)`. **En Android
1055 /// usar [`WinitSurface::from_surface`]** — allí la surface debe
1056 /// existir antes del `request_adapter(compatible_surface=Some(...))`,
1057 /// y crearla dos veces sobre la misma `ANativeWindow` falla con
1058 /// `ERROR_NATIVE_WINDOW_IN_USE_KHR`.
1059 pub fn new(hal: &Hal, window: Arc<Window>) -> Result<Self, HalError> {
1060 let surface = hal
1061 .instance
1062 .create_surface(window.clone())
1063 .map_err(|e| HalError::CreateSurface(e.to_string()))?;
1064 Self::from_surface(hal, window, surface)
1065 }
1066
1067 /// Constructor reutilizable: arma el `WinitSurface` envolviendo una
1068 /// `wgpu::Surface` ya creada por el caller. Necesario en Android
1069 /// porque el orden allí es:
1070 ///
1071 /// 1. `instance.create_surface(window)`
1072 /// 2. `instance.request_adapter(compatible_surface=Some(&surface))`
1073 /// 3. `adapter.request_device(...)`
1074 /// 4. `WinitSurface::from_surface(hal, window, surface)`
1075 ///
1076 /// — no se puede dropear la surface entre 2 y 4 ni recrearla, porque
1077 /// Android reserva la `ANativeWindow` por VkSurface y rechaza un
1078 /// segundo `vkCreateAndroidSurfaceKHR` sobre la misma ventana.
1079 pub fn from_surface(
1080 hal: &Hal,
1081 window: Arc<Window>,
1082 surface: wgpu::Surface<'static>,
1083 ) -> Result<Self, HalError> {
1084 let size = window.inner_size();
1085 let caps = surface.get_capabilities(&hal.adapter);
1086 // Preferimos Bgra8Unorm o Rgba8Unorm (no sRGB) para que el blit
1087 // desde la intermedia lineal preserve los valores tal cual. Más
1088 // precisión sólo si la pide `LLIMPHI_COLOR` — ver [`ColorTarget`].
1089 let format = formato_de_swapchain(&caps, "ventana").unwrap_or(caps.formats[0]);
1090 let config = wgpu::SurfaceConfiguration {
1091 // El swapchain solo necesita render-attachment: vello no escribe
1092 // directo, escribe a la intermedia y luego se blittea.
1093 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1094 format,
1095 width: size.width.max(1),
1096 height: size.height.max(1),
1097 present_mode: choose_present_mode(&caps),
1098 desired_maximum_frame_latency: 2,
1099 alpha_mode: caps.alpha_modes[0],
1100 view_formats: vec![],
1101 };
1102 configure_checked(&hal.device, &surface, &config).map_err(HalError::CreateSurface)?;
1103 let (intermediate, intermediate_view) =
1104 create_intermediate(&hal.device, config.width, config.height);
1105 let (overlay, overlay_view) =
1106 create_intermediate(&hal.device, config.width, config.height);
1107 let blitter = wgpu::util::TextureBlitter::new(&hal.device, format);
1108 Ok(Self {
1109 window,
1110 surface: Some(surface),
1111 config,
1112 device: hal.device.clone(),
1113 intermediate,
1114 intermediate_view,
1115 overlay,
1116 overlay_view,
1117 blitter,
1118 instance: hal.instance.clone(),
1119 device_perdido: false,
1120 })
1121 }
1122
1123 pub fn format(&self) -> wgpu::TextureFormat {
1124 self.config.format
1125 }
1126
1127 /// La `wgpu::Surface` viva. Sólo falta entre [`soltar_vk_surface`] y la
1128 /// creación de la siguiente, ventana en la que nadie debe pedir frames.
1129 ///
1130 /// [`soltar_vk_surface`]: WinitSurface::soltar_vk_surface
1131 #[inline]
1132 fn vk(&self) -> &wgpu::Surface<'static> {
1133 self.surface
1134 .as_ref()
1135 .expect("WinitSurface usada después de soltar su VkSurface")
1136 }
1137
1138 /// **Destruye la `VkSurface` ya mismo**, sin tocar el resto.
1139 ///
1140 /// Existe por la regla de Android: **una sola `VkSurface` por
1141 /// `ANativeWindow`**. Rehacer el stack GPU tras un `DeviceLost` crea la
1142 /// surface nueva contra la MISMA ventana, así que si la vieja sigue viva
1143 /// `vkCreateAndroidSurfaceKHR` falla y `wgpu-hal` lo trata como fatal
1144 /// (`create_android_surface(...).expect("AndroidSurface failed")`,
1145 /// `vulkan/instance.rs:525`) → abort. Medido en metal el 2026-07-28: el
1146 /// rebuild moría justo ahí, porque `rebuild_gpu_after_device_loss` sólo
1147 /// reemplazaba `state.surface` **después** de haber creado la nueva.
1148 ///
1149 /// Es responsabilidad del caller no pedir frames entre esta llamada y la
1150 /// instalación de la surface nueva.
1151 pub fn soltar_vk_surface(&mut self) {
1152 self.surface = None;
1153 }
1154
1155 /// Recrea la `wgpu::Surface` desde la MISMA ventana winit y la reconfigura.
1156 /// Paso intermedio de recuperación: barato frente a rehacer el device.
1157 /// Lo llama [`WinitSurface::acquire`] cuando `get_current_texture` devuelve
1158 /// `Outdated`/`Lost` y reconfigurar no bastó. Si tras esto la surface sigue
1159 /// rota (el adapter dejó de matchear — Iris Xe «queue family»), `acquire`
1160 /// escala a [`SurfaceError::DeviceLost`] y el dueño del bucle reconstruye el
1161 /// `Hal` entero con [`Hal::recreate_for_window`].
1162 fn recreate(&mut self) -> Result<(), String> {
1163 // La vieja MUERE antes de crear la nueva: en Android crear una segunda
1164 // VkSurface sobre la misma ANativeWindow falla
1165 // (`ERROR_NATIVE_WINDOW_IN_USE_KHR`), y en el resto de las plataformas
1166 // soltarla primero no cuesta nada.
1167 self.surface = None;
1168 let surface = self
1169 .instance
1170 .create_surface(self.window.clone())
1171 .map_err(|e| format!("create_surface: {e}"))?;
1172 configure_checked(&self.device, &surface, &self.config)?;
1173 self.surface = Some(surface);
1174 Ok(())
1175 }
1176}
1177
1178/// Surface sobre una `wgpu::Surface` creada desde **handles raw** (sin
1179/// `winit::Window`): la usa el backend `wlr-layer-shell` de `pata` para pintar
1180/// en una *layer surface* de Wayland (barras/paneles al nivel de eww/waybar).
1181/// Misma mecánica que [`WinitSurface`] —intermedia `Rgba8Unorm` + blit al
1182/// swapchain— pero el tamaño se pasa explícito porque no hay ventana que
1183/// consultar. La `wgpu::Surface` la crea el caller (típicamente con
1184/// `instance.create_surface_unsafe` desde los punteros `wl_display`/`wl_surface`).
1185pub struct RawSurface {
1186 surface: wgpu::Surface<'static>,
1187 config: wgpu::SurfaceConfiguration,
1188 device: wgpu::Device,
1189 intermediate: wgpu::Texture,
1190 intermediate_view: wgpu::TextureView,
1191 overlay: wgpu::Texture,
1192 overlay_view: wgpu::TextureView,
1193 blitter: wgpu::util::TextureBlitter,
1194 /// La instancia + los raw handles del `wl_surface`/`wl_display`, guardados para
1195 /// **recrear** la `wgpu::Surface` cuando queda irrecuperable (ver
1196 /// [`RawSurface::recreate`]). Los `RawHandle` son `Copy` y apuntan a objetos
1197 /// que el caller mantiene vivos toda la vida de la `RawSurface`.
1198 instance: wgpu::Instance,
1199 raw_display: raw_window_handle::RawDisplayHandle,
1200 raw_window: raw_window_handle::RawWindowHandle,
1201}
1202
1203impl RawSurface {
1204 /// Envuelve una `wgpu::Surface` ya creada, con el tamaño físico inicial. Los
1205 /// `raw_display`/`raw_window` son los handles con que se creó la surface: se
1206 /// guardan para poder RECREARLA si el compositor la invalida (Iris Xe), ya que
1207 /// reconfigurar la misma surface no siempre recupera.
1208 pub fn from_surface(
1209 hal: &Hal,
1210 surface: wgpu::Surface<'static>,
1211 raw_display: raw_window_handle::RawDisplayHandle,
1212 raw_window: raw_window_handle::RawWindowHandle,
1213 width: u32,
1214 height: u32,
1215 ) -> Result<Self, HalError> {
1216 let caps = surface.get_capabilities(&hal.adapter);
1217 let info = hal.adapter.get_info();
1218 // Si la superficie no expone formatos, el compositor no la soporta por
1219 // este backend (Vulkan/GL WSI): error claro en vez de un panic por
1220 // indexar `formats[0]` sobre una lista vacía.
1221 let format = match formato_de_swapchain(&caps, "layer") {
1222 Some(f) => f,
1223 None => {
1224 return Err(HalError::CreateSurface(format!(
1225 "la superficie no expone formatos (adapter {:?}/{:?}): el compositor no la soporta por {:?} WSI",
1226 info.backend, info.device_type, info.backend
1227 )))
1228 }
1229 };
1230 // Para una layer surface (wlr-layer-shell) la transparencia es
1231 // crítica: la usamos para popovers/menús que pintan un panel chico y
1232 // dejan el resto transparente para ver el escritorio. La heurística
1233 // ingenua `caps.alpha_modes.first()` cae a veces en `Opaque` (el
1234 // compositor descarta alpha) — el clear TRANSPARENT se compone como
1235 // negro literal y el menú inicio sale como un cuadrón negro.
1236 //
1237 // Preferencia: PreMultiplied > PostMultiplied > Inherit > Auto >
1238 // Opaque. Los dos primeros componen alpha como esperamos; los dos
1239 // siguientes dejan que el compositor decida (típicamente respeta el
1240 // alpha del buffer ARGB); Opaque es el último recurso.
1241 let alpha_mode = {
1242 use wgpu::CompositeAlphaMode as Mode;
1243 let want = [
1244 Mode::PreMultiplied,
1245 Mode::PostMultiplied,
1246 Mode::Inherit,
1247 Mode::Auto,
1248 ];
1249 want.iter()
1250 .copied()
1251 .find(|m| caps.alpha_modes.contains(m))
1252 .or_else(|| caps.alpha_modes.first().copied())
1253 .unwrap_or(Mode::Auto)
1254 };
1255 let config = wgpu::SurfaceConfiguration {
1256 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1257 format,
1258 width: width.max(1),
1259 height: height.max(1),
1260 present_mode: choose_present_mode(&caps),
1261 desired_maximum_frame_latency: 2,
1262 alpha_mode,
1263 view_formats: vec![],
1264 };
1265 configure_checked(&hal.device, &surface, &config).map_err(HalError::CreateSurface)?;
1266 let (intermediate, intermediate_view) =
1267 create_intermediate(&hal.device, config.width, config.height);
1268 let (overlay, overlay_view) =
1269 create_intermediate(&hal.device, config.width, config.height);
1270 let blitter = wgpu::util::TextureBlitter::new(&hal.device, format);
1271 Ok(Self {
1272 surface,
1273 config,
1274 device: hal.device.clone(),
1275 intermediate,
1276 intermediate_view,
1277 overlay,
1278 overlay_view,
1279 blitter,
1280 instance: hal.instance.clone(),
1281 raw_display,
1282 raw_window,
1283 })
1284 }
1285
1286 pub fn format(&self) -> wgpu::TextureFormat {
1287 self.config.format
1288 }
1289
1290 /// Recrea la `wgpu::Surface` desde el raw handle del `wl_surface` y la
1291 /// reconfigura. Es la recuperación REAL cuando la surface quedó irrecuperable
1292 /// —p. ej. `configure` falla con «Surface does not support the adapter's queue
1293 /// family» tras un reset del compositor en Iris Xe—: reconfigurar la MISMA
1294 /// surface no basta, hay que crear una nueva. Devuelve el error si falla (el
1295 /// caller salta el frame y reintenta). Sin esto, la barra quedaba en un bucle
1296 /// de «surface perdida» hasta que se caía la conexión (Broken pipe).
1297 fn recreate(&mut self) -> Result<(), String> {
1298 let target = wgpu::SurfaceTargetUnsafe::RawHandle {
1299 // wgpu 29 volvió opcional el display del target.
1300 raw_display_handle: Some(self.raw_display),
1301 raw_window_handle: self.raw_window,
1302 };
1303 // SAFETY: los handles apuntan a objetos Wayland que el caller mantiene
1304 // vivos toda la vida de la `RawSurface` (mismo contrato que `from_surface`).
1305 let surface = unsafe { self.instance.create_surface_unsafe(target) }
1306 .map_err(|e| format!("create_surface_unsafe: {e}"))?;
1307 configure_checked(&self.device, &surface, &self.config)?;
1308 self.surface = surface;
1309 Ok(())
1310 }
1311}
1312
1313impl Surface for RawSurface {
1314 fn size(&self) -> (u32, u32) {
1315 (self.config.width, self.config.height)
1316 }
1317
1318 fn resize(&mut self, width: u32, height: u32) {
1319 let (w, h) = (width.max(1), height.max(1));
1320 // Sin cambio de tamaño NO reconfiguramos. El backend layer-shell de `pata`
1321 // llama a `resize` en cada cuadro (no tiene eventos de resize como winit);
1322 // reconfigurar el swapchain por cuadro lo reconstruye una y otra vez, y en
1323 // Vulkan WSI eso **destruye el `wl_buffer` recién presentado antes de que el
1324 // compositor lo componga** — wlroots lo tolera, smithay (mirada) no, y la
1325 // superficie queda en negro (el compositor ve `buffer=None`).
1326 if self.config.width == w && self.config.height == h {
1327 return;
1328 }
1329 self.config.width = w;
1330 self.config.height = h;
1331 if let Err(e) = configure_checked(&self.device, &self.surface, &self.config) {
1332 // Igual que en `acquire`: si reconfigurar no recupera, recreamos la
1333 // surface desde el raw handle antes de rendirnos.
1334 match self.recreate() {
1335 Ok(()) => crate::avisar!("llimphi-hal: surface RECREADA en resize (reconfigure falló: {e})"),
1336 Err(e2) => {
1337 crate::avisar!("llimphi-hal: configure en resize falló ({e}); recrear también falló ({e2})")
1338 }
1339 }
1340 }
1341 let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height);
1342 self.intermediate = tex;
1343 self.intermediate_view = view;
1344 let (otex, oview) =
1345 create_intermediate(&self.device, self.config.width, self.config.height);
1346 self.overlay = otex;
1347 self.overlay_view = oview;
1348 }
1349
1350 fn acquire(&mut self) -> Result<Frame, SurfaceError> {
1351 // Gancho de verificación: DeviceLost sintético cada N cuadros
1352 // (`LLIMPHI_SIMULATE_DEVICE_LOSS`). No-op sin la env.
1353 if simulate_device_loss_trip() {
1354 return Err(SurfaceError::DeviceLost);
1355 }
1356 // wgpu 29 dejó de devolver `Result` acá: ahora es un enum de estado. La
1357 // traducción no es literal en un punto y conviene tenerlo claro —
1358 // `Suboptimal` **trae textura válida** y en wgpu 27 llegaba como `Ok`
1359 // con un `suboptimal: bool` al costado. Tratarlo como error sería tirar
1360 // un fotograma perfectamente bueno en cada cambio de tamaño.
1361 use wgpu::CurrentSurfaceTexture as Estado;
1362 let texture = match self.surface.get_current_texture() {
1363 Estado::Success(t) | Estado::Suboptimal(t) => t,
1364 // El backend layer-shell no tiene un evento de resize que reconfigure
1365 // el swapchain; si quedó obsoleto/perdido, lo reconstruimos aquí mismo
1366 // y reintentamos una vez. Sin esto el panel quedaría en negro para
1367 // siempre tras el primer `Outdated`.
1368 e @ (Estado::Outdated | Estado::Lost) => {
1369 // Reconfigurar CAPTURANDO: si la surface está realmente perdida,
1370 // `configure` emite un error de validación que, sin captura,
1371 // mataría el proceso (era el crash de pata). Aquí lo devolvemos
1372 // como `DeviceLost` y el caller reconstruye el stack GPU.
1373 if let Err(msg) = configure_checked(&self.device, &self.surface, &self.config) {
1374 // Reconfigurar no recuperó (surface realmente perdida): RECREAR
1375 // desde el raw handle contra el MISMO device. Si eso también
1376 // falla, el device quedó irrecuperable (el adapter dejó de
1377 // matchear — Iris Xe «queue family») → DeviceLost: el runner
1378 // de pata rehace `Hal` + todas las surfaces/renderers.
1379 match self.recreate() {
1380 Ok(()) => crate::avisar!(
1381 "llimphi-hal: surface RECREADA tras {e:?} (reconfigure falló: {msg})"
1382 ),
1383 Err(msg2) => {
1384 crate::avisar!(
1385 "llimphi-hal: reconfigurar tras {e:?} falló ({msg}); recrear también falló ({msg2}) → DeviceLost"
1386 );
1387 return Err(SurfaceError::DeviceLost);
1388 }
1389 }
1390 }
1391 // Sigue rota tras reconfigurar+recrear → device irrecuperable.
1392 match self.surface.get_current_texture() {
1393 Estado::Success(t) | Estado::Suboptimal(t) => t,
1394 _ => return Err(SurfaceError::DeviceLost),
1395 }
1396 }
1397 Estado::Timeout => return Err(SurfaceError::Timeout),
1398 // Ventana tapada (minimizada o detrás de otra). **No es una falla**,
1399 // y confundirla con una sale caro: el runtime cuenta acquires
1400 // fallidos y a los 8 rehace el stack GPU entero, así que una ventana
1401 // tapada ocho cuadros dispararía una reconstrucción por nada.
1402 Estado::Occluded => return Err(SurfaceError::Occluded),
1403 otro => return Err(SurfaceError::Other(format!("{otro:?}"))),
1404 };
1405 let surface_view = texture
1406 .texture
1407 .create_view(&wgpu::TextureViewDescriptor::default());
1408 Ok(Frame {
1409 surface_texture: texture,
1410 surface_view,
1411 intermediate_view: self.intermediate_view.clone(),
1412 overlay_view: self.overlay_view.clone(),
1413 width: self.config.width,
1414 height: self.config.height,
1415 })
1416 }
1417
1418 fn present(&mut self, frame: Frame, hal: &Hal) {
1419 let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1420 label: Some("llimphi-blit-raw"),
1421 });
1422 self.blitter.copy(
1423 &hal.device,
1424 &mut encoder,
1425 &frame.intermediate_view,
1426 &frame.surface_view,
1427 );
1428 hal.queue.submit(std::iter::once(encoder.finish()));
1429 frame.surface_texture.present();
1430 }
1431}
1432
1433// ---------------------------------------------------------------------------
1434// El HAL del navegador: un `<canvas>` del DOM
1435// ---------------------------------------------------------------------------
1436
1437/// Surface sobre un `<canvas>` del DOM. Es la tercera implementación de
1438/// [`Surface`], hermana de [`WinitSurface`] y [`RawSurface`], y la única que
1439/// existe en `wasm32`: el resto del stack (raster, layout, text, compositor) ya
1440/// compila para wasm sin tocar nada.
1441///
1442/// Misma mecánica que las otras dos —intermedia `Rgba8Unorm` con storage
1443/// binding donde pinta vello + blit al swapchain— pero:
1444///
1445/// - La `wgpu::Surface` se crea con [`wgpu::SurfaceTarget::Canvas`], no desde
1446/// una ventana ni desde handles raw.
1447/// - **No se puede bloquear el hilo.** El main thread del navegador no admite
1448/// `pollster::block_on`; por eso el camino de construcción es
1449/// [`Hal::for_canvas`], `async`, y `configure` no usa error-scopes (que
1450/// resuelven de forma asíncrona). Ver [`configure_web`].
1451/// - El tamaño de la surface es el **backing store** del canvas
1452/// (`canvas.width`/`canvas.height`, en píxeles físicos), que NO es su tamaño
1453/// CSS. Confundirlos es el bug clásico del canvas borroso en pantallas HiDPI:
1454/// usar [`CanvasSurface::resize_to_css`] para sincronizarlos por
1455/// `devicePixelRatio`.
1456///
1457/// ## WebGPU sí, WebGL2 todavía no
1458///
1459/// El renderer full-GPU de vello escribe la intermedia por **compute shader**,
1460/// y eso exige `STORAGE_BINDING` — que WebGPU da y **WebGL2 no tiene**. Si el
1461/// navegador cae al backend GL, [`CanvasSurface::from_surface`] devuelve un
1462/// error explícito en vez de dejar que reviente como error de validación
1463/// críptico. El camino WebGL2 es `vello_hybrid` (feature `hybrid` de
1464/// `llimphi-raster`, CPU+GPU sin compute), que necesita su propia surface
1465/// porque no pinta a una storage texture. Pendiente.
1466#[cfg(target_arch = "wasm32")]
1467pub struct CanvasSurface {
1468 canvas: web_sys::HtmlCanvasElement,
1469 surface: wgpu::Surface<'static>,
1470 config: wgpu::SurfaceConfiguration,
1471 device: wgpu::Device,
1472 #[allow(dead_code)]
1473 intermediate: wgpu::Texture,
1474 intermediate_view: wgpu::TextureView,
1475 #[allow(dead_code)]
1476 overlay: wgpu::Texture,
1477 overlay_view: wgpu::TextureView,
1478 blitter: wgpu::util::TextureBlitter,
1479}
1480
1481#[cfg(target_arch = "wasm32")]
1482impl Hal {
1483 /// Construye el stack GPU entero atado a un `<canvas>`: instancia,
1484 /// surface, adapter compatible, device y [`CanvasSurface`]. **Es el
1485 /// equivalente web de [`Hal::recreate_for_window`]**, y por el mismo
1486 /// motivo hace surface-first: el adapter se pide con
1487 /// `compatible_surface: Some(&surface)` para que el navegador entregue uno
1488 /// que sepa presentar a ESE canvas.
1489 ///
1490 /// `async` sin alternativa: en el main thread del navegador no se puede
1491 /// bloquear. El caller típico es
1492 /// `wasm_bindgen_futures::spawn_local(async move { Hal::for_canvas(c).await })`.
1493 pub async fn for_canvas(
1494 canvas: web_sys::HtmlCanvasElement,
1495 ) -> Result<(Self, CanvasSurface), HalError> {
1496 // En web no aplica la preferencia PRIMARY-o-nada del escritorio (el bug
1497 // de teardown de EGL/Mesa no existe acá): `all()` deja que el navegador
1498 // ofrezca WebGPU y, si no hay, WebGL2 — el chequeo de storage textures
1499 // en `CanvasSurface::from_surface` es el que decide si sirve.
1500 // Sin display: en el navegador no hay uno que pasar — el display handle
1501 // del `InstanceDescriptor` es para el sistema de ventanas nativo.
1502 let instance = instancia(wgpu::Backends::all());
1503 let surface = instance
1504 .create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone()))
1505 .map_err(|e| HalError::CreateSurface(e.to_string()))?;
1506 let adapter = instance
1507 .request_adapter(&wgpu::RequestAdapterOptions {
1508 power_preference: power_preference_override(),
1509 force_fallback_adapter: false,
1510 compatible_surface: Some(&surface),
1511 })
1512 .await
1513 .map_err(|_| HalError::NoAdapter)?;
1514 let limits = limites_para(&adapter);
1515 let (device, queue) = adapter
1516 .request_device(&wgpu::DeviceDescriptor {
1517 label: Some("llimphi-hal-device-canvas"),
1518 required_features: wgpu::Features::empty(),
1519 required_limits: limits,
1520 memory_hints: resolver_memory_hint(&adapter),
1521 experimental_features: wgpu::ExperimentalFeatures::default(),
1522 trace: wgpu::Trace::Off,
1523 })
1524 .await
1525 .map_err(|e| HalError::RequestDevice(e.to_string()))?;
1526 instalar_handler_errores(&device);
1527 let hal = Self {
1528 instance,
1529 adapter,
1530 device,
1531 queue,
1532 };
1533 let surface = CanvasSurface::from_surface(&hal, canvas, surface)?;
1534 Ok((hal, surface))
1535 }
1536}
1537
1538#[cfg(target_arch = "wasm32")]
1539impl CanvasSurface {
1540 /// Envuelve una `wgpu::Surface` ya creada sobre el canvas. La ruta normal
1541 /// es [`Hal::for_canvas`], que hace las dos cosas en el orden correcto;
1542 /// este constructor existe para quien ya tiene un `Hal` armado.
1543 pub fn from_surface(
1544 hal: &Hal,
1545 canvas: web_sys::HtmlCanvasElement,
1546 surface: wgpu::Surface<'static>,
1547 ) -> Result<Self, HalError> {
1548 // Guarda de WebGL2: sin storage textures la intermedia de vello no se
1549 // puede crear y el error real aparecería mucho después, como validación
1550 // ilegible en medio del primer frame.
1551 let feats = hal.adapter.get_texture_format_features(INTERMEDIATE_FORMAT);
1552 if !feats
1553 .allowed_usages
1554 .contains(wgpu::TextureUsages::STORAGE_BINDING)
1555 {
1556 let info = hal.adapter.get_info();
1557 return Err(HalError::CreateSurface(format!(
1558 "el adapter del navegador ({:?}) no admite STORAGE_BINDING en {INTERMEDIATE_FORMAT:?}: \
1559 es el camino WebGL2, y el renderer full-GPU de vello necesita compute. \
1560 Hace falta un navegador con WebGPU, o el renderer `vello_hybrid` \
1561 (feature `hybrid` de llimphi-raster), todavía sin surface propia.",
1562 info.backend
1563 )));
1564 }
1565 let caps = surface.get_capabilities(&hal.adapter);
1566 let format = match formato_de_swapchain(&caps, "canvas") {
1567 Some(f) => f,
1568 None => {
1569 return Err(HalError::CreateSurface(
1570 "el canvas no expone formatos de swapchain".to_string(),
1571 ))
1572 }
1573 };
1574 let config = wgpu::SurfaceConfiguration {
1575 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
1576 format,
1577 // El backing store del canvas, en píxeles FÍSICOS. Ver
1578 // `resize_to_css` para el puente desde el tamaño CSS.
1579 width: canvas.width().max(1),
1580 height: canvas.height().max(1),
1581 present_mode: choose_present_mode(&caps),
1582 desired_maximum_frame_latency: 2,
1583 // En un canvas embebido en una página, `Opaque` es lo que
1584 // corresponde salvo que la app quiera dejar ver el fondo del
1585 // documento; el navegador lista primero lo que soporta.
1586 alpha_mode: caps.alpha_modes[0],
1587 view_formats: vec![],
1588 };
1589 configure_web(&hal.device, &surface, &config);
1590 let (intermediate, intermediate_view) =
1591 create_intermediate(&hal.device, config.width, config.height);
1592 let (overlay, overlay_view) = create_intermediate(&hal.device, config.width, config.height);
1593 let blitter = wgpu::util::TextureBlitter::new(&hal.device, format);
1594 Ok(Self {
1595 canvas,
1596 surface,
1597 config,
1598 device: hal.device.clone(),
1599 intermediate,
1600 intermediate_view,
1601 overlay,
1602 overlay_view,
1603 blitter,
1604 })
1605 }
1606
1607 pub fn format(&self) -> wgpu::TextureFormat {
1608 self.config.format
1609 }
1610
1611 pub fn canvas(&self) -> &web_sys::HtmlCanvasElement {
1612 &self.canvas
1613 }
1614
1615 /// Sincroniza el backing store del canvas con su tamaño **CSS** por
1616 /// `devicePixelRatio` y reconfigura el swapchain. Es lo que hay que llamar
1617 /// ante un `resize` de la ventana o un cambio de zoom/monitor.
1618 ///
1619 /// Sin esto el canvas se ve **borroso** en pantallas HiDPI: el navegador
1620 /// escala un backing store de 800×600 a 1600×1200 CSS px. Devuelve el
1621 /// tamaño físico resultante para que el caller alimente el layout (que
1622 /// trabaja en px físicos, igual que en escritorio).
1623 pub fn resize_to_css(&mut self) -> (u32, u32) {
1624 let dpr = web_sys::window().map(|w| w.device_pixel_ratio()).unwrap_or(1.0);
1625 let css_w = self.canvas.client_width().max(1) as f64;
1626 let css_h = self.canvas.client_height().max(1) as f64;
1627 let w = (css_w * dpr).round() as u32;
1628 let h = (css_h * dpr).round() as u32;
1629 self.resize(w, h);
1630 (self.config.width, self.config.height)
1631 }
1632}
1633
1634#[cfg(target_arch = "wasm32")]
1635impl Surface for CanvasSurface {
1636 fn size(&self) -> (u32, u32) {
1637 (self.config.width, self.config.height)
1638 }
1639
1640 fn resize(&mut self, width: u32, height: u32) {
1641 let (w, h) = (width.max(1), height.max(1));
1642 if self.config.width == w && self.config.height == h {
1643 return;
1644 }
1645 self.config.width = w;
1646 self.config.height = h;
1647 // El backing store del canvas manda: si no se actualiza, el navegador
1648 // escala el resultado y la surface queda desincronizada del DOM.
1649 self.canvas.set_width(w);
1650 self.canvas.set_height(h);
1651 configure_web(&self.device, &self.surface, &self.config);
1652 let (tex, view) = create_intermediate(&self.device, w, h);
1653 self.intermediate = tex;
1654 self.intermediate_view = view;
1655 let (otex, oview) = create_intermediate(&self.device, w, h);
1656 self.overlay = otex;
1657 self.overlay_view = oview;
1658 }
1659
1660 fn acquire(&mut self) -> Result<Frame, SurfaceError> {
1661 // Mismo enum que el camino nativo (wgpu 29 dejó de devolver `Result`):
1662 // `Suboptimal` **trae textura válida** y tirarla sería perder un cuadro
1663 // bueno en cada cambio de tamaño — que en el navegador es cada arrastre
1664 // del borde de la ventana.
1665 use wgpu::CurrentSurfaceTexture as Estado;
1666 let texture = match self.surface.get_current_texture() {
1667 Estado::Success(t) | Estado::Suboptimal(t) => t,
1668 // En el navegador no hay un `wl_surface` que recrear: la
1669 // recuperación es reconfigurar y reintentar una vez. Un
1670 // `Outdated` acá suele ser el canvas redimensionado entre frames.
1671 Estado::Outdated | Estado::Lost => {
1672 configure_web(&self.device, &self.surface, &self.config);
1673 match self.surface.get_current_texture() {
1674 Estado::Success(t) | Estado::Suboptimal(t) => t,
1675 _ => return Err(SurfaceError::DeviceLost),
1676 }
1677 }
1678 Estado::Timeout => return Err(SurfaceError::Timeout),
1679 // Pestaña en segundo plano / canvas fuera de vista. No es falla: el
1680 // runtime cuenta acquires fallidos y a los 8 rehace el stack GPU,
1681 // así que confundirla con una reconstruye todo por nada.
1682 Estado::Occluded => return Err(SurfaceError::Occluded),
1683 otro => return Err(SurfaceError::Other(format!("{otro:?}"))),
1684 };
1685 let surface_view = texture
1686 .texture
1687 .create_view(&wgpu::TextureViewDescriptor::default());
1688 Ok(Frame {
1689 surface_texture: texture,
1690 surface_view,
1691 intermediate_view: self.intermediate_view.clone(),
1692 overlay_view: self.overlay_view.clone(),
1693 width: self.config.width,
1694 height: self.config.height,
1695 })
1696 }
1697
1698 fn present(&mut self, frame: Frame, hal: &Hal) {
1699 let mut encoder = hal
1700 .device
1701 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
1702 label: Some("llimphi-blit-canvas"),
1703 });
1704 self.blitter.copy(
1705 &hal.device,
1706 &mut encoder,
1707 &frame.intermediate_view,
1708 &frame.surface_view,
1709 );
1710 hal.queue.submit(std::iter::once(encoder.finish()));
1711 frame.surface_texture.present();
1712 }
1713}
1714
1715/// `configure` para el navegador: **sin** error-scope. Lo usan tanto
1716/// [`CanvasSurface`] como —vía [`configure_checked`]— [`WinitSurface`] y
1717/// [`RawSurface`] cuando el target es wasm.
1718///
1719/// [`configure_checked`] captura los errores de validación con
1720/// `push_error_scope`/`pop_error_scope`, y `pop_error_scope` devuelve un
1721/// futuro que en escritorio se resuelve con `pollster::block_on`. En el main
1722/// thread del navegador bloquear está prohibido —`block_on` espera sobre un
1723/// `Condvar` y en wasm32 eso **paniquea**, que en la página se ve como
1724/// `Uncaught RuntimeError: unreachable`— y esperar el scope de forma asíncrona
1725/// convertiría en `async` toda la cadena `resize`/`acquire`, que es sincrónica
1726/// por contrato del trait [`Surface`].
1727///
1728/// A cambio, un fallo de configuración acá no se detecta en el acto: aparece
1729/// en el siguiente `get_current_texture`, que [`CanvasSurface::acquire`]
1730/// maneja reconfigurando y, si no recupera, devolviendo `DeviceLost`. Los
1731/// errores no capturados igual salen por el handler de
1732/// [`instalar_handler_errores`], que loguea en vez de matar el proceso.
1733#[cfg(target_arch = "wasm32")]
1734fn configure_web(
1735 device: &wgpu::Device,
1736 surface: &wgpu::Surface<'static>,
1737 config: &wgpu::SurfaceConfiguration,
1738) {
1739 surface.configure(device, config);
1740}
1741
1742/// Elige el modo de presentación del swapchain.
1743///
1744/// Default: **Mailbox** si el driver lo expone, sino **Fifo**. La razón es
1745/// el cuelgue observado en las apps Llimphi (investigación 2026-05-30): con
1746/// `Fifo`/`AutoVsync`, `surface.get_current_texture()` **bloquea** esperando
1747/// el frame-callback del compositor Wayland — si el compositor no suelta un
1748/// buffer, el hilo del UI queda dormido (CPU baja, deadlock aparente).
1749/// `Mailbox` no bloquea (triple-buffer, descarta frames viejos), así que el
1750/// loop nunca se queda esperando al compositor. `Fifo` está garantizado por
1751/// spec como fallback.
1752///
1753/// Override por entorno para A/B sin recompilar (útil en la laptop con
1754/// display real): `LLIMPHI_PRESENT_MODE = fifo | mailbox | immediate |
1755/// fifo_relaxed`. Si el modo pedido no está soportado, se ignora y se aplica
1756/// el default.
1757fn choose_present_mode(caps: &wgpu::SurfaceCapabilities) -> wgpu::PresentMode {
1758 use wgpu::PresentMode::{Fifo, FifoRelaxed, Immediate, Mailbox};
1759 if let Ok(v) = std::env::var("LLIMPHI_PRESENT_MODE") {
1760 let want = match v.trim().to_ascii_lowercase().as_str() {
1761 "fifo" | "vsync" => Some(Fifo),
1762 "fifo_relaxed" | "fiforelaxed" => Some(FifoRelaxed),
1763 "mailbox" => Some(Mailbox),
1764 "immediate" | "novsync" => Some(Immediate),
1765 _ => None,
1766 };
1767 if let Some(m) = want {
1768 if caps.present_modes.contains(&m) {
1769 return m;
1770 }
1771 }
1772 }
1773 if caps.present_modes.contains(&Mailbox) {
1774 Mailbox
1775 } else {
1776 Fifo
1777 }
1778}
1779
1780/// Instala en el device un handler de errores wgpu **no capturados** que LOGUEA
1781/// en vez de dejar que el default de wgpu («Handling wgpu errors as fatal by
1782/// default») paniquee el proceso entero. Un glitch de device —p. ej. la pérdida
1783/// transitoria en un flap de VT bajo arje— NO debe tumbar la app (greeter, pata…):
1784/// la recuperación de surface (`Outdated`/`Lost`) rearma en el próximo frame. Los
1785/// errores de validación puntuales se siguen capturando con error-scopes aparte.
1786///
1787/// **En wasm el aviso va a la consola del navegador.** Con `eprintln!` a secas
1788/// el mensaje se escribe en un stderr que en una página no existe: un error de
1789/// validación durante el blit desaparecía sin rastro y el síntoma llegaba como
1790/// «el canvas no pinta», sin una sola línea que explicara por qué.
1791/// Se prendió el callback de device perdido de ALGÚN device de este proceso.
1792///
1793/// Global a propósito: todos los devices de una app llimphi salen del mismo
1794/// `Hal`, y perder uno es una condición del proceso, no de una surface. Lo lee
1795/// [`Surface::present`] —un `load` relajado por frame, costo cero— para **no
1796/// llamar a `wgpu::SurfaceTexture::present` sobre un device muerto**, que
1797/// paniquea de forma fatal e inatajable (ni error-scope ni handler lo salvan).
1798static DEVICE_PERDIDO_GLOBAL: std::sync::atomic::AtomicBool =
1799 std::sync::atomic::AtomicBool::new(false);
1800
1801fn instalar_handler_errores(device: &wgpu::Device) {
1802 // **Callback de device perdido.** Sin esto, perder el device se entera uno
1803 // por el DAÑO —`Parent device is lost` colgando de un `present`, o texturas
1804 // que nacen inválidas— y nunca por la CAUSA: wgpu tiene el motivo y el
1805 // mensaje del backend, y si nadie los pide se tiran. Costó una noche de
1806 // Android 2026-07-27: el device se perdía en el primer frame y el log sólo
1807 // mostraba los síntomas río abajo.
1808 device.set_device_lost_callback(|razon, msg| {
1809 DEVICE_PERDIDO_GLOBAL.store(true, std::sync::atomic::Ordering::Relaxed);
1810 let aviso = format!("llimphi-hal: DEVICE PERDIDO ({razon:?}): {msg}");
1811 #[cfg(target_arch = "wasm32")]
1812 web_sys::console::error_1(&aviso.as_str().into());
1813 #[cfg(not(target_arch = "wasm32"))]
1814 crate::avisar!("{aviso}");
1815 });
1816 device.on_uncaptured_error(std::sync::Arc::new(|e| {
1817 #[cfg(target_arch = "wasm32")]
1818 web_sys::console::error_1(
1819 &format!("llimphi-hal: error wgpu no capturado (no fatal, ignorado): {e}").into(),
1820 );
1821 #[cfg(not(target_arch = "wasm32"))]
1822 crate::avisar!("llimphi-hal: error wgpu no capturado (no fatal, ignorado): {e}");
1823 }));
1824}
1825
1826/// Configura el swapchain **capturando** los errores de validación de wgpu, en
1827/// vez de dejar que el handler por defecto («wgpu errors as fatal») paniquee el
1828/// proceso entero. Devuelve `Err(mensaje)` si la configuración falló — casi
1829/// siempre porque la surface se perdió (`SURFACE_LOST`, un hipo transitorio del
1830/// compositor/GPU): el caller decide (saltar el frame, marcar perdida) en vez de
1831/// morir. Robustez: una app no debe caerse por un glitch del surface (lo que
1832/// tumbaba a `pata` con «Surface does not support the adapter's queue family»).
1833///
1834/// Los errores de validación de wgpu son CPU-side: el scope los captura durante
1835/// la llamada, así que `pop_error_scope` resuelve sin bloquear contra la GPU.
1836///
1837/// **En wasm delega en [`configure_web`] y no captura nada.** «Sin bloquear
1838/// contra la GPU» no es lo mismo que «sin bloquear»: `pop_error_scope` sigue
1839/// devolviendo un futuro, y `pollster::block_on` lo espera sobre un `Condvar`.
1840/// En `wasm32-unknown-unknown` no hay hilos y ese `Condvar::wait` **paniquea**
1841/// (`std/sys/sync/condvar/no_threads.rs`), lo que en el navegador se ve como un
1842/// `Uncaught RuntimeError: unreachable`. Lo encontró correr el bucle web de
1843/// verdad en Chrome headless — ningún `cargo check` lo podía ver.
1844fn configure_checked(
1845 device: &wgpu::Device,
1846 surface: &wgpu::Surface<'static>,
1847 config: &wgpu::SurfaceConfiguration,
1848) -> Result<(), String> {
1849 #[cfg(target_arch = "wasm32")]
1850 {
1851 configure_web(device, surface, config);
1852 return Ok(());
1853 }
1854 #[cfg(not(target_arch = "wasm32"))]
1855 {
1856 // wgpu 29 mudó el `pop` al guard que devuelve `push_error_scope`, en
1857 // vez de tenerlo en el device. Es la misma captura de siempre.
1858 let alcance = device.push_error_scope(wgpu::ErrorFilter::Validation);
1859 surface.configure(device, config);
1860 match pollster::block_on(alcance.pop()) {
1861 Some(e) => Err(e.to_string()),
1862 None => Ok(()),
1863 }
1864 }
1865}
1866
1867/// Igual que [`create_intermediate`] pero **capturando** los errores de
1868/// validación de wgpu en vez de dejar que el `create_view` de la línea siguiente
1869/// se coma un error derivado.
1870///
1871/// El modo de falla que motivó esto (Android/Mali, 2026-07-27): si el
1872/// `create_texture` falla, wgpu devuelve una textura **inválida** en vez de
1873/// abortar, y el `create_view` inmediato emite `Texture with
1874/// 'llimphi-intermediate' label is invalid`. Ese error derivado **tapa el
1875/// original** — nunca se supo por qué falló la creación. Y según qué handler
1876/// tenga instalado el device, el mismo bug da dos síntomas opuestos: con el
1877/// default de wgpu («errors as fatal») la app **se cierra sola**; con
1878/// [`instalar_handler_errores`] el error se loguea, la textura inválida **se
1879/// instala igual**, y la app queda **viva pintando en negro para siempre**.
1880///
1881/// Capturando acá, el caller decide: conservar las texturas viejas (que
1882/// funcionaban) y seguir, en vez de quedarse con una rota.
1883fn create_intermediate_checked(
1884 device: &wgpu::Device,
1885 width: u32,
1886 height: u32,
1887) -> Result<(wgpu::Texture, wgpu::TextureView), String> {
1888 // Web: `pop_error_scope` devuelve un futuro y `block_on` lo espera sobre un
1889 // `Condvar` que en wasm paniquea — misma razón por la que `configure_checked`
1890 // delega en `configure_web`. Ahí seguimos sin capturar.
1891 #[cfg(target_arch = "wasm32")]
1892 {
1893 return Ok(create_intermediate(device, width, height));
1894 }
1895 #[cfg(not(target_arch = "wasm32"))]
1896 {
1897 let alcance = device.push_error_scope(wgpu::ErrorFilter::Validation);
1898 let par = create_intermediate(device, width, height);
1899 match pollster::block_on(alcance.pop()) {
1900 Some(e) => Err(e.to_string()),
1901 None => Ok(par),
1902 }
1903 }
1904}
1905
1906fn create_intermediate(
1907 device: &wgpu::Device,
1908 width: u32,
1909 height: u32,
1910) -> (wgpu::Texture, wgpu::TextureView) {
1911 let texture = device.create_texture(&wgpu::TextureDescriptor {
1912 label: Some("llimphi-intermediate"),
1913 size: wgpu::Extent3d {
1914 width,
1915 height,
1916 depth_or_array_layers: 1,
1917 },
1918 mip_level_count: 1,
1919 sample_count: 1,
1920 dimension: wgpu::TextureDimension::D2,
1921 format: INTERMEDIATE_FORMAT,
1922 // STORAGE_BINDING: vello escribe via compute shader.
1923 // TEXTURE_BINDING: el blitter la lee como sampler source.
1924 // RENDER_ATTACHMENT: render passes con clear-only (sin vello)
1925 // también escriben aquí — desktop drivers lo tolerían sin este
1926 // flag, Adreno con validación estricta rechaza el frame.
1927 usage: wgpu::TextureUsages::STORAGE_BINDING
1928 | wgpu::TextureUsages::TEXTURE_BINDING
1929 | wgpu::TextureUsages::RENDER_ATTACHMENT,
1930 view_formats: &[],
1931 });
1932 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1933 (texture, view)
1934}
1935
1936/// Compositor de la capa de overlay: alpha-blittea una textura source (el
1937/// overlay rasterizado por vello sobre fondo transparente) SOBRE una textura
1938/// target (la intermedia, que ya tiene la UI principal + el video pintado por
1939/// `gpu_paint`). Resuelve el z-order: sin esto, el blit de `gpu_paint` (video)
1940/// queda encima de la capa vello del overlay y los menús se ven por debajo del
1941/// video.
1942///
1943/// Es un pase de pantalla completa (triángulo) que samplea el source y lo
1944/// emite con alpha-over. El factor de blend asume alpha **premultiplicado**
1945/// (lo que produce vello); si en pantalla los menús se ven con halos oscuros o
1946/// transparencia rara, exportar `LLIMPHI_OVERLAY_BLEND=straight` para usar
1947/// alpha recto sin recompilar.
1948pub struct OverlayCompositor {
1949 pipeline: wgpu::RenderPipeline,
1950 sampler: wgpu::Sampler,
1951 bind_layout: wgpu::BindGroupLayout,
1952 /// Uniform con el rect destino. Persistente: se reescribe por composite en
1953 /// vez de crear un buffer por cuadro.
1954 uniforms: wgpu::Buffer,
1955}
1956
1957impl OverlayCompositor {
1958 pub fn new(device: &wgpu::Device) -> Self {
1959 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1960 label: Some("llimphi-overlay-composite"),
1961 source: wgpu::ShaderSource::Wgsl(OVERLAY_COMPOSITE_WGSL.into()),
1962 });
1963 let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1964 label: Some("llimphi-overlay-bgl"),
1965 entries: &[
1966 wgpu::BindGroupLayoutEntry {
1967 binding: 0,
1968 visibility: wgpu::ShaderStages::FRAGMENT,
1969 ty: wgpu::BindingType::Texture {
1970 sample_type: wgpu::TextureSampleType::Float { filterable: true },
1971 view_dimension: wgpu::TextureViewDimension::D2,
1972 multisampled: false,
1973 },
1974 count: None,
1975 },
1976 wgpu::BindGroupLayoutEntry {
1977 binding: 1,
1978 visibility: wgpu::ShaderStages::FRAGMENT,
1979 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
1980 count: None,
1981 },
1982 wgpu::BindGroupLayoutEntry {
1983 binding: 2,
1984 visibility: wgpu::ShaderStages::VERTEX,
1985 ty: wgpu::BindingType::Buffer {
1986 ty: wgpu::BufferBindingType::Uniform,
1987 has_dynamic_offset: false,
1988 min_binding_size: None,
1989 },
1990 count: None,
1991 },
1992 ],
1993 });
1994 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1995 label: Some("llimphi-overlay-pl"),
1996 bind_group_layouts: &[Some(&bind_layout)],
1997 immediate_size: 0,
1998 });
1999 // Alpha-over. `src_factor` distingue premultiplicado (One) de recto
2000 // (SrcAlpha); el resto es siempre OneMinusSrcAlpha.
2001 let straight = std::env::var("LLIMPHI_OVERLAY_BLEND")
2002 .map(|v| v.trim().eq_ignore_ascii_case("straight"))
2003 .unwrap_or(false);
2004 let color_src = if straight {
2005 wgpu::BlendFactor::SrcAlpha
2006 } else {
2007 wgpu::BlendFactor::One
2008 };
2009 let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2010 label: Some("llimphi-overlay-pipe"),
2011 layout: Some(&pipeline_layout),
2012 vertex: wgpu::VertexState {
2013 module: &shader,
2014 entry_point: Some("vs"),
2015 buffers: &[],
2016 compilation_options: Default::default(),
2017 },
2018 fragment: Some(wgpu::FragmentState {
2019 module: &shader,
2020 entry_point: Some("fs"),
2021 targets: &[Some(wgpu::ColorTargetState {
2022 format: INTERMEDIATE_FORMAT,
2023 blend: Some(wgpu::BlendState {
2024 color: wgpu::BlendComponent {
2025 src_factor: color_src,
2026 dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
2027 operation: wgpu::BlendOperation::Add,
2028 },
2029 alpha: wgpu::BlendComponent {
2030 src_factor: wgpu::BlendFactor::One,
2031 dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
2032 operation: wgpu::BlendOperation::Add,
2033 },
2034 }),
2035 write_mask: wgpu::ColorWrites::ALL,
2036 })],
2037 compilation_options: Default::default(),
2038 }),
2039 primitive: wgpu::PrimitiveState::default(),
2040 depth_stencil: None,
2041 multisample: wgpu::MultisampleState::default(),
2042 multiview_mask: None,
2043 cache: None,
2044 });
2045 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
2046 label: Some("llimphi-overlay-sampler"),
2047 ..Default::default()
2048 });
2049 // 4 floats: rect destino en NDC (x0, y0, x1, y1). Ver el WGSL.
2050 let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
2051 label: Some("llimphi-overlay-uniforms"),
2052 size: 16,
2053 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2054 mapped_at_creation: false,
2055 });
2056 OverlayCompositor {
2057 pipeline,
2058 sampler,
2059 bind_layout,
2060 uniforms,
2061 }
2062 }
2063
2064 /// Compone `source` (overlay con fondo transparente) sobre `target` (la
2065 /// intermedia), preservando el contenido previo del target (LoadOp::Load)
2066 /// y mezclando con alpha. Graba un render pass en `encoder`.
2067 ///
2068 /// `source` cubre el target entero. Para pegar una textura **más chica** en
2069 /// su lugar del frame —una capa over rasterizada sólo en su bounding box—
2070 /// usar [`Self::composite_rect`].
2071 pub fn composite(
2072 &self,
2073 device: &wgpu::Device,
2074 queue: &wgpu::Queue,
2075 encoder: &mut wgpu::CommandEncoder,
2076 target: &wgpu::TextureView,
2077 source: &wgpu::TextureView,
2078 ) {
2079 // Rect destino = NDC completo: el quad cubre todo, igual que antes.
2080 self.composite_ndc(device, queue, encoder, target, source, [-1.0, 1.0, 1.0, -1.0]);
2081 }
2082
2083 /// Compone `source` sobre `target` **restringido a `dst`** (x, y, w, h en px
2084 /// del frame, con `viewport = (ancho, alto)`), estirando la textura fuente
2085 /// para llenarlo. Es el camino que hace rentable rasterizar la capa over en
2086 /// una textura del tamaño de lo que dibuja: sin esto habría que rasterizar
2087 /// —y componer— el frame entero para pintar una barra de 5 px de alto.
2088 pub fn composite_rect(
2089 &self,
2090 device: &wgpu::Device,
2091 queue: &wgpu::Queue,
2092 encoder: &mut wgpu::CommandEncoder,
2093 target: &wgpu::TextureView,
2094 source: &wgpu::TextureView,
2095 dst: [f32; 4],
2096 viewport: (u32, u32),
2097 ) {
2098 let (vw, vh) = (viewport.0.max(1) as f32, viewport.1.max(1) as f32);
2099 // px → NDC. La Y se invierte: en pantalla crece hacia abajo, en NDC
2100 // hacia arriba.
2101 let ndc = [
2102 dst[0] / vw * 2.0 - 1.0,
2103 1.0 - dst[1] / vh * 2.0,
2104 (dst[0] + dst[2]) / vw * 2.0 - 1.0,
2105 1.0 - (dst[1] + dst[3]) / vh * 2.0,
2106 ];
2107 self.composite_ndc(device, queue, encoder, target, source, ndc);
2108 }
2109
2110 fn composite_ndc(
2111 &self,
2112 device: &wgpu::Device,
2113 queue: &wgpu::Queue,
2114 encoder: &mut wgpu::CommandEncoder,
2115 target: &wgpu::TextureView,
2116 source: &wgpu::TextureView,
2117 ndc: [f32; 4],
2118 ) {
2119 let mut bytes = [0u8; 16];
2120 for (i, v) in ndc.iter().enumerate() {
2121 bytes[i * 4..(i + 1) * 4].copy_from_slice(&v.to_ne_bytes());
2122 }
2123 // El uniform se escribe por la queue; el pass de abajo lo lee en el
2124 // mismo submit.
2125 queue.write_buffer(&self.uniforms, 0, &bytes);
2126 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
2127 label: Some("llimphi-overlay-bg"),
2128 layout: &self.bind_layout,
2129 entries: &[
2130 wgpu::BindGroupEntry {
2131 binding: 0,
2132 resource: wgpu::BindingResource::TextureView(source),
2133 },
2134 wgpu::BindGroupEntry {
2135 binding: 1,
2136 resource: wgpu::BindingResource::Sampler(&self.sampler),
2137 },
2138 wgpu::BindGroupEntry {
2139 binding: 2,
2140 resource: self.uniforms.as_entire_binding(),
2141 },
2142 ],
2143 });
2144 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2145 label: Some("llimphi-overlay-composite-pass"),
2146 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2147 view: target,
2148 resolve_target: None,
2149 depth_slice: None,
2150 ops: wgpu::Operations {
2151 load: wgpu::LoadOp::Load,
2152 store: wgpu::StoreOp::Store,
2153 },
2154 })],
2155 depth_stencil_attachment: None,
2156 timestamp_writes: None,
2157 occlusion_query_set: None,
2158 multiview_mask: None,
2159 });
2160 pass.set_pipeline(&self.pipeline);
2161 pass.set_bind_group(0, &bind_group, &[]);
2162 pass.draw(0..6, 0..1);
2163 }
2164}
2165
2166/// Pase que samplea la textura de overlay y la emite para alpha-over. Un quad
2167/// (dos triángulos) sobre el rect destino que viene por uniform en NDC —
2168/// `[-1, 1, 1, -1]` es el viewport entero, el caso clásico. La UV recorre la
2169/// textura fuente completa, así que una scratch del tamaño del bounding box de
2170/// la capa over aterriza justo donde va.
2171const OVERLAY_COMPOSITE_WGSL: &str = r#"
2172struct VsOut {
2173 @builtin(position) pos: vec4<f32>,
2174 @location(0) uv: vec2<f32>,
2175};
2176
2177// x0, y0 = esquina superior izquierda; x1, y1 = inferior derecha. En NDC.
2178struct Uniforms { dst: vec4<f32> };
2179@group(0) @binding(2) var<uniform> u: Uniforms;
2180
2181@vertex
2182fn vs(@builtin(vertex_index) vi: u32) -> VsOut {
2183 var uvs = array<vec2<f32>, 6>(
2184 vec2<f32>(0.0, 0.0),
2185 vec2<f32>(1.0, 0.0),
2186 vec2<f32>(1.0, 1.0),
2187 vec2<f32>(0.0, 0.0),
2188 vec2<f32>(1.0, 1.0),
2189 vec2<f32>(0.0, 1.0),
2190 );
2191 let q = uvs[vi];
2192 var out: VsOut;
2193 out.pos = vec4<f32>(
2194 mix(u.dst.x, u.dst.z, q.x),
2195 mix(u.dst.y, u.dst.w, q.y),
2196 0.0,
2197 1.0,
2198 );
2199 out.uv = q;
2200 return out;
2201}
2202
2203@group(0) @binding(0) var src_tex: texture_2d<f32>;
2204@group(0) @binding(1) var src_samp: sampler;
2205
2206@fragment
2207fn fs(in: VsOut) -> @location(0) vec4<f32> {
2208 return textureSample(src_tex, src_samp, in.uv);
2209}
2210"#;
2211
2212/// Gaussian backdrop blur sobre la intermediate (la textura donde vello pinta
2213/// la UI). El compositor empuja dos render passes separables (horizontal +
2214/// vertical) restringidas por scissor al rect del nodo `.backdrop_blur(sigma)`,
2215/// usando una textura scratch interna del mismo tamaño que la intermediate.
2216///
2217/// **Pipeline**: vs = triángulo grande full-screen (clip-space), fs = suma
2218/// ponderada de N samples a lo largo de `direction`, pesos Gauss `exp(-i²/2σ²)`.
2219/// El bind group lleva la textura source + sampler bilinear + UBO con
2220/// `(direction, pixel_size, sigma, radius)`. El scissor recorta el output al
2221/// rect del nodo; el resto del target queda intacto (LoadOp::Load).
2222///
2223/// **Coste**: una pasada por dirección por nodo blur, ~`2*radius+1` taps por
2224/// pixel del rect. Para `sigma=8` (radius=24), ~49 taps/pixel — barato si el
2225/// rect es pequeño (chrome), pesado si es full-screen. v1: sin cap dinámico,
2226/// se asume que el caller no abusa.
2227///
2228/// **Limitaciones v1**:
2229/// - Un scratch full-screen alocado por compositor; resize sigue al `Surface`.
2230/// - `radius` cap en 32 — sigmas > ~10 se ven menos suaves (clip de cola).
2231/// - Bordes del rect: clamp-to-edge (sampler) → los pixeles fuera del rect
2232/// que se muestrean en la cola del Gauss salen como espejo del borde. En
2233/// un viewport razonable la diferencia es invisible; documentado.
2234pub struct BlurCompositor {
2235 pipeline: wgpu::RenderPipeline,
2236 sampler: wgpu::Sampler,
2237 bind_layout: wgpu::BindGroupLayout,
2238 scratch: Option<BlurScratch>,
2239}
2240
2241struct BlurScratch {
2242 _texture: wgpu::Texture,
2243 view: wgpu::TextureView,
2244 width: u32,
2245 height: u32,
2246}
2247
2248/// Layout en GPU del UBO del blur. Debe coincidir con el `BlurParams` del WGSL.
2249/// Padding explícito al final para llegar a múltiplo de 16 bytes (alignment
2250/// estándar de uniformes en wgpu).
2251#[repr(C)]
2252#[derive(Clone, Copy)]
2253struct BlurUniforms {
2254 direction: [f32; 2],
2255 pixel_size: [f32; 2],
2256 sigma: f32,
2257 radius: f32,
2258 _pad: [f32; 2],
2259}
2260
2261const BLUR_UBO_SIZE: u64 = std::mem::size_of::<BlurUniforms>() as u64;
2262const BLUR_MAX_RADIUS: f32 = 32.0;
2263
2264impl BlurCompositor {
2265 pub fn new(device: &wgpu::Device) -> Self {
2266 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2267 label: Some("llimphi-blur-shader"),
2268 source: wgpu::ShaderSource::Wgsl(BLUR_WGSL.into()),
2269 });
2270 let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2271 label: Some("llimphi-blur-bgl"),
2272 entries: &[
2273 wgpu::BindGroupLayoutEntry {
2274 binding: 0,
2275 visibility: wgpu::ShaderStages::FRAGMENT,
2276 ty: wgpu::BindingType::Texture {
2277 sample_type: wgpu::TextureSampleType::Float { filterable: true },
2278 view_dimension: wgpu::TextureViewDimension::D2,
2279 multisampled: false,
2280 },
2281 count: None,
2282 },
2283 wgpu::BindGroupLayoutEntry {
2284 binding: 1,
2285 visibility: wgpu::ShaderStages::FRAGMENT,
2286 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
2287 count: None,
2288 },
2289 wgpu::BindGroupLayoutEntry {
2290 binding: 2,
2291 visibility: wgpu::ShaderStages::FRAGMENT,
2292 ty: wgpu::BindingType::Buffer {
2293 ty: wgpu::BufferBindingType::Uniform,
2294 has_dynamic_offset: false,
2295 min_binding_size: None,
2296 },
2297 count: None,
2298 },
2299 ],
2300 });
2301 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2302 label: Some("llimphi-blur-pl"),
2303 bind_group_layouts: &[Some(&bind_layout)],
2304 immediate_size: 0,
2305 });
2306 let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2307 label: Some("llimphi-blur-pipe"),
2308 layout: Some(&pipeline_layout),
2309 vertex: wgpu::VertexState {
2310 module: &shader,
2311 entry_point: Some("vs"),
2312 buffers: &[],
2313 compilation_options: Default::default(),
2314 },
2315 fragment: Some(wgpu::FragmentState {
2316 module: &shader,
2317 entry_point: Some("fs"),
2318 targets: &[Some(wgpu::ColorTargetState {
2319 format: INTERMEDIATE_FORMAT,
2320 // El blur OVERWRITE el rect; no necesita alpha-over. El
2321 // resultado del Gauss es opaco si los pixeles muestreados
2322 // lo son (la intermediate tiene UI + background opaco).
2323 blend: None,
2324 write_mask: wgpu::ColorWrites::ALL,
2325 })],
2326 compilation_options: Default::default(),
2327 }),
2328 primitive: wgpu::PrimitiveState::default(),
2329 depth_stencil: None,
2330 multisample: wgpu::MultisampleState::default(),
2331 multiview_mask: None,
2332 cache: None,
2333 });
2334 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
2335 label: Some("llimphi-blur-sampler"),
2336 address_mode_u: wgpu::AddressMode::ClampToEdge,
2337 address_mode_v: wgpu::AddressMode::ClampToEdge,
2338 address_mode_w: wgpu::AddressMode::ClampToEdge,
2339 mag_filter: wgpu::FilterMode::Linear,
2340 min_filter: wgpu::FilterMode::Linear,
2341 mipmap_filter: wgpu::MipmapFilterMode::Nearest,
2342 ..Default::default()
2343 });
2344 BlurCompositor {
2345 pipeline,
2346 sampler,
2347 bind_layout,
2348 scratch: None,
2349 }
2350 }
2351
2352 /// Aplica un blur Gaussiano sobre `target` en el rect dado (coords pixel
2353 /// del viewport). Si el rect cae fuera del viewport, no hace nada. Usa
2354 /// un scratch interno del mismo tamaño que el viewport — se aloca lazy y
2355 /// se reusa entre frames; se recrea si el viewport cambió.
2356 ///
2357 /// `sigma` controla el ancho del kernel. ~`σ=4` da "frosted glass" suave,
2358 /// `σ=16` un blur fuerte. El radius efectivo se cap a [`BLUR_MAX_RADIUS`].
2359 pub fn blur(
2360 &mut self,
2361 device: &wgpu::Device,
2362 queue: &wgpu::Queue,
2363 encoder: &mut wgpu::CommandEncoder,
2364 target: &wgpu::TextureView,
2365 viewport: (u32, u32),
2366 rect: (f32, f32, f32, f32),
2367 sigma: f32,
2368 ) {
2369 let (vw, vh) = viewport;
2370 if vw == 0 || vh == 0 || sigma <= 0.0 {
2371 return;
2372 }
2373 let (rx, ry, rw, rh) = rect;
2374 // Clamp scissor al viewport (un rect fuera del viewport pifia el
2375 // RenderPass).
2376 let x0 = rx.max(0.0) as u32;
2377 let y0 = ry.max(0.0) as u32;
2378 let x1 = (rx + rw).min(vw as f32).max(0.0) as u32;
2379 let y1 = (ry + rh).min(vh as f32).max(0.0) as u32;
2380 if x1 <= x0 || y1 <= y0 {
2381 return;
2382 }
2383 let scissor = (x0, y0, x1 - x0, y1 - y0);
2384
2385 // Scratch del tamaño del viewport. Si cambió, recrear.
2386 let need_new = match &self.scratch {
2387 Some(s) => s.width != vw || s.height != vh,
2388 None => true,
2389 };
2390 if need_new {
2391 let texture = device.create_texture(&wgpu::TextureDescriptor {
2392 label: Some("llimphi-blur-scratch"),
2393 size: wgpu::Extent3d {
2394 width: vw,
2395 height: vh,
2396 depth_or_array_layers: 1,
2397 },
2398 mip_level_count: 1,
2399 sample_count: 1,
2400 dimension: wgpu::TextureDimension::D2,
2401 format: INTERMEDIATE_FORMAT,
2402 usage: wgpu::TextureUsages::TEXTURE_BINDING
2403 | wgpu::TextureUsages::RENDER_ATTACHMENT,
2404 view_formats: &[],
2405 });
2406 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
2407 self.scratch = Some(BlurScratch {
2408 _texture: texture,
2409 view,
2410 width: vw,
2411 height: vh,
2412 });
2413 }
2414 let scratch_view = &self.scratch.as_ref().expect("scratch creado arriba").view;
2415
2416 let radius = (sigma * 3.0).ceil().min(BLUR_MAX_RADIUS);
2417 let pixel_size = [1.0 / vw as f32, 1.0 / vh as f32];
2418 let ubo_h_data = BlurUniforms {
2419 direction: [1.0, 0.0],
2420 pixel_size,
2421 sigma,
2422 radius,
2423 _pad: [0.0, 0.0],
2424 };
2425 let ubo_v_data = BlurUniforms {
2426 direction: [0.0, 1.0],
2427 pixel_size,
2428 sigma,
2429 radius,
2430 _pad: [0.0, 0.0],
2431 };
2432 // UBOs por llamada (ver nota en `ColorFilterCompositor::apply`): varios
2433 // blurs en el mismo submit con sigmas distintos no deben aliasar un UBO
2434 // compartido (ganaría el último). Buffers frescos por llamada (32 bytes).
2435 let ubo_h = device.create_buffer(&wgpu::BufferDescriptor {
2436 label: Some("llimphi-blur-ubo-h"),
2437 size: BLUR_UBO_SIZE,
2438 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2439 mapped_at_creation: false,
2440 });
2441 let ubo_v = device.create_buffer(&wgpu::BufferDescriptor {
2442 label: Some("llimphi-blur-ubo-v"),
2443 size: BLUR_UBO_SIZE,
2444 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2445 mapped_at_creation: false,
2446 });
2447 queue.write_buffer(&ubo_h, 0, bytemuck_cast(&ubo_h_data));
2448 queue.write_buffer(&ubo_v, 0, bytemuck_cast(&ubo_v_data));
2449
2450 // Pass 1: target → scratch (horizontal).
2451 let bg_h = device.create_bind_group(&wgpu::BindGroupDescriptor {
2452 label: Some("llimphi-blur-bg-h"),
2453 layout: &self.bind_layout,
2454 entries: &[
2455 wgpu::BindGroupEntry {
2456 binding: 0,
2457 resource: wgpu::BindingResource::TextureView(target),
2458 },
2459 wgpu::BindGroupEntry {
2460 binding: 1,
2461 resource: wgpu::BindingResource::Sampler(&self.sampler),
2462 },
2463 wgpu::BindGroupEntry {
2464 binding: 2,
2465 resource: ubo_h.as_entire_binding(),
2466 },
2467 ],
2468 });
2469 {
2470 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2471 label: Some("llimphi-blur-pass-h"),
2472 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2473 view: scratch_view,
2474 resolve_target: None,
2475 depth_slice: None,
2476 ops: wgpu::Operations {
2477 // No nos importa qué hay fuera del scissor: el segundo
2478 // pase sólo lee dentro del scissor también.
2479 load: wgpu::LoadOp::Load,
2480 store: wgpu::StoreOp::Store,
2481 },
2482 })],
2483 depth_stencil_attachment: None,
2484 timestamp_writes: None,
2485 occlusion_query_set: None,
2486 multiview_mask: None,
2487 });
2488 pass.set_pipeline(&self.pipeline);
2489 pass.set_bind_group(0, &bg_h, &[]);
2490 pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
2491 pass.draw(0..3, 0..1);
2492 }
2493
2494 // Pass 2: scratch → target (vertical), preservando lo fuera del scissor.
2495 let bg_v = device.create_bind_group(&wgpu::BindGroupDescriptor {
2496 label: Some("llimphi-blur-bg-v"),
2497 layout: &self.bind_layout,
2498 entries: &[
2499 wgpu::BindGroupEntry {
2500 binding: 0,
2501 resource: wgpu::BindingResource::TextureView(scratch_view),
2502 },
2503 wgpu::BindGroupEntry {
2504 binding: 1,
2505 resource: wgpu::BindingResource::Sampler(&self.sampler),
2506 },
2507 wgpu::BindGroupEntry {
2508 binding: 2,
2509 resource: ubo_v.as_entire_binding(),
2510 },
2511 ],
2512 });
2513 {
2514 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2515 label: Some("llimphi-blur-pass-v"),
2516 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2517 view: target,
2518 resolve_target: None,
2519 depth_slice: None,
2520 ops: wgpu::Operations {
2521 load: wgpu::LoadOp::Load,
2522 store: wgpu::StoreOp::Store,
2523 },
2524 })],
2525 depth_stencil_attachment: None,
2526 timestamp_writes: None,
2527 occlusion_query_set: None,
2528 multiview_mask: None,
2529 });
2530 pass.set_pipeline(&self.pipeline);
2531 pass.set_bind_group(0, &bg_v, &[]);
2532 pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
2533 pass.draw(0..3, 0..1);
2534 }
2535 }
2536}
2537
2538/// Aplica una **matriz de color 4×5** (CSS `filter: brightness/contrast/
2539/// grayscale/sepia/saturate/invert/hue-rotate/opacity`) sobre un rect de la
2540/// intermediate. Espejo de [`BlurCompositor`] pero con un fragment shader que
2541/// multiplica cada píxel por la matriz: `out = M·rgba + bias`, clampeado a
2542/// `[0,1]`. Dos pases (target→scratch aplicando la matriz, scratch→target
2543/// copia identidad) por la misma razón que el blur: un render pass no puede
2544/// leer y escribir la misma textura. Fase 7.1233.
2545pub struct ColorFilterCompositor {
2546 pipeline: wgpu::RenderPipeline,
2547 sampler: wgpu::Sampler,
2548 bind_layout: wgpu::BindGroupLayout,
2549 scratch: Option<BlurScratch>,
2550}
2551
2552/// UBO de la matriz de color. 5 `vec4` (filas R/G/B/A + bias) = 80 bytes,
2553/// múltiplo de 16. Debe coincidir con `ColorParams` del WGSL.
2554#[repr(C)]
2555#[derive(Clone, Copy)]
2556struct ColorUniforms {
2557 r: [f32; 4],
2558 g: [f32; 4],
2559 b: [f32; 4],
2560 a: [f32; 4],
2561 bias: [f32; 4],
2562}
2563
2564const COLOR_UBO_SIZE: u64 = std::mem::size_of::<ColorUniforms>() as u64;
2565
2566/// La matriz identidad (copia sin cambios), usada en el segundo pase.
2567const COLOR_IDENTITY: ColorUniforms = ColorUniforms {
2568 r: [1.0, 0.0, 0.0, 0.0],
2569 g: [0.0, 1.0, 0.0, 0.0],
2570 b: [0.0, 0.0, 1.0, 0.0],
2571 a: [0.0, 0.0, 0.0, 1.0],
2572 bias: [0.0, 0.0, 0.0, 0.0],
2573};
2574
2575impl ColorFilterCompositor {
2576 pub fn new(device: &wgpu::Device) -> Self {
2577 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
2578 label: Some("llimphi-color-filter-shader"),
2579 source: wgpu::ShaderSource::Wgsl(COLOR_WGSL.into()),
2580 });
2581 let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
2582 label: Some("llimphi-color-filter-bgl"),
2583 entries: &[
2584 wgpu::BindGroupLayoutEntry {
2585 binding: 0,
2586 visibility: wgpu::ShaderStages::FRAGMENT,
2587 ty: wgpu::BindingType::Texture {
2588 sample_type: wgpu::TextureSampleType::Float { filterable: true },
2589 view_dimension: wgpu::TextureViewDimension::D2,
2590 multisampled: false,
2591 },
2592 count: None,
2593 },
2594 wgpu::BindGroupLayoutEntry {
2595 binding: 1,
2596 visibility: wgpu::ShaderStages::FRAGMENT,
2597 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
2598 count: None,
2599 },
2600 wgpu::BindGroupLayoutEntry {
2601 binding: 2,
2602 visibility: wgpu::ShaderStages::FRAGMENT,
2603 ty: wgpu::BindingType::Buffer {
2604 ty: wgpu::BufferBindingType::Uniform,
2605 has_dynamic_offset: false,
2606 min_binding_size: None,
2607 },
2608 count: None,
2609 },
2610 ],
2611 });
2612 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
2613 label: Some("llimphi-color-filter-pl"),
2614 bind_group_layouts: &[Some(&bind_layout)],
2615 immediate_size: 0,
2616 });
2617 let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
2618 label: Some("llimphi-color-filter-pipe"),
2619 layout: Some(&pipeline_layout),
2620 vertex: wgpu::VertexState {
2621 module: &shader,
2622 entry_point: Some("vs"),
2623 buffers: &[],
2624 compilation_options: Default::default(),
2625 },
2626 fragment: Some(wgpu::FragmentState {
2627 module: &shader,
2628 entry_point: Some("fs"),
2629 targets: &[Some(wgpu::ColorTargetState {
2630 format: INTERMEDIATE_FORMAT,
2631 // OVERWRITE el rect, igual que el blur — el resultado de la
2632 // matriz reemplaza el píxel.
2633 blend: None,
2634 write_mask: wgpu::ColorWrites::ALL,
2635 })],
2636 compilation_options: Default::default(),
2637 }),
2638 primitive: wgpu::PrimitiveState::default(),
2639 depth_stencil: None,
2640 multisample: wgpu::MultisampleState::default(),
2641 multiview_mask: None,
2642 cache: None,
2643 });
2644 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
2645 label: Some("llimphi-color-filter-sampler"),
2646 address_mode_u: wgpu::AddressMode::ClampToEdge,
2647 address_mode_v: wgpu::AddressMode::ClampToEdge,
2648 address_mode_w: wgpu::AddressMode::ClampToEdge,
2649 mag_filter: wgpu::FilterMode::Nearest,
2650 min_filter: wgpu::FilterMode::Nearest,
2651 mipmap_filter: wgpu::MipmapFilterMode::Nearest,
2652 ..Default::default()
2653 });
2654 ColorFilterCompositor {
2655 pipeline,
2656 sampler,
2657 bind_layout,
2658 scratch: None,
2659 }
2660 }
2661
2662 /// Aplica la matriz de color `matrix` (4×5 row-major: por fila
2663 /// `[c0, c1, c2, c3, bias]`, salida R/G/B/A) sobre `target` en el rect dado
2664 /// (coords pixel del viewport). Fuera del viewport no hace nada. Usa un
2665 /// scratch del tamaño del viewport (lazy, reusado entre frames).
2666 pub fn apply(
2667 &mut self,
2668 device: &wgpu::Device,
2669 queue: &wgpu::Queue,
2670 encoder: &mut wgpu::CommandEncoder,
2671 target: &wgpu::TextureView,
2672 viewport: (u32, u32),
2673 rect: (f32, f32, f32, f32),
2674 matrix: [f32; 20],
2675 ) {
2676 let (vw, vh) = viewport;
2677 if vw == 0 || vh == 0 {
2678 return;
2679 }
2680 let (rx, ry, rw, rh) = rect;
2681 let x0 = rx.max(0.0) as u32;
2682 let y0 = ry.max(0.0) as u32;
2683 let x1 = (rx + rw).min(vw as f32).max(0.0) as u32;
2684 let y1 = (ry + rh).min(vh as f32).max(0.0) as u32;
2685 if x1 <= x0 || y1 <= y0 {
2686 return;
2687 }
2688 let scissor = (x0, y0, x1 - x0, y1 - y0);
2689
2690 let need_new = match &self.scratch {
2691 Some(s) => s.width != vw || s.height != vh,
2692 None => true,
2693 };
2694 if need_new {
2695 let texture = device.create_texture(&wgpu::TextureDescriptor {
2696 label: Some("llimphi-color-filter-scratch"),
2697 size: wgpu::Extent3d {
2698 width: vw,
2699 height: vh,
2700 depth_or_array_layers: 1,
2701 },
2702 mip_level_count: 1,
2703 sample_count: 1,
2704 dimension: wgpu::TextureDimension::D2,
2705 format: INTERMEDIATE_FORMAT,
2706 usage: wgpu::TextureUsages::TEXTURE_BINDING
2707 | wgpu::TextureUsages::RENDER_ATTACHMENT,
2708 view_formats: &[],
2709 });
2710 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
2711 self.scratch = Some(BlurScratch {
2712 _texture: texture,
2713 view,
2714 width: vw,
2715 height: vh,
2716 });
2717 }
2718 let scratch_view = &self.scratch.as_ref().expect("scratch creado arriba").view;
2719
2720 // El [f32;20] viene por filas de 5 (`[c0,c1,c2,c3,bias]`); lo partimos
2721 // en 4 vec4 de coeficientes + un vec4 de bias para el UBO.
2722 let apply = ColorUniforms {
2723 r: [matrix[0], matrix[1], matrix[2], matrix[3]],
2724 g: [matrix[5], matrix[6], matrix[7], matrix[8]],
2725 b: [matrix[10], matrix[11], matrix[12], matrix[13]],
2726 a: [matrix[15], matrix[16], matrix[17], matrix[18]],
2727 bias: [matrix[4], matrix[9], matrix[14], matrix[19]],
2728 };
2729 // UBOs **por llamada**: varias `apply` en el mismo encoder/submit
2730 // comparten cola; `write_buffer` se aplica una vez antes de los command
2731 // buffers (gana el último valor escrito), así que un UBO compartido haría
2732 // que todas las pasadas leyeran la última matriz. Buffers frescos por
2733 // llamada evitan ese alias (80 bytes c/u, despreciable).
2734 let ubo_apply = device.create_buffer(&wgpu::BufferDescriptor {
2735 label: Some("llimphi-color-filter-ubo-apply"),
2736 size: COLOR_UBO_SIZE,
2737 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2738 mapped_at_creation: false,
2739 });
2740 let ubo_copy = device.create_buffer(&wgpu::BufferDescriptor {
2741 label: Some("llimphi-color-filter-ubo-copy"),
2742 size: COLOR_UBO_SIZE,
2743 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
2744 mapped_at_creation: false,
2745 });
2746 queue.write_buffer(&ubo_apply, 0, bytemuck_cast(&apply));
2747 queue.write_buffer(&ubo_copy, 0, bytemuck_cast(&COLOR_IDENTITY));
2748
2749 // Pass 1: target → scratch (aplica la matriz).
2750 let bg_apply = device.create_bind_group(&wgpu::BindGroupDescriptor {
2751 label: Some("llimphi-color-filter-bg-apply"),
2752 layout: &self.bind_layout,
2753 entries: &[
2754 wgpu::BindGroupEntry {
2755 binding: 0,
2756 resource: wgpu::BindingResource::TextureView(target),
2757 },
2758 wgpu::BindGroupEntry {
2759 binding: 1,
2760 resource: wgpu::BindingResource::Sampler(&self.sampler),
2761 },
2762 wgpu::BindGroupEntry {
2763 binding: 2,
2764 resource: ubo_apply.as_entire_binding(),
2765 },
2766 ],
2767 });
2768 {
2769 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2770 label: Some("llimphi-color-filter-pass-apply"),
2771 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2772 view: scratch_view,
2773 resolve_target: None,
2774 depth_slice: None,
2775 ops: wgpu::Operations {
2776 load: wgpu::LoadOp::Load,
2777 store: wgpu::StoreOp::Store,
2778 },
2779 })],
2780 depth_stencil_attachment: None,
2781 timestamp_writes: None,
2782 occlusion_query_set: None,
2783 multiview_mask: None,
2784 });
2785 pass.set_pipeline(&self.pipeline);
2786 pass.set_bind_group(0, &bg_apply, &[]);
2787 pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
2788 pass.draw(0..3, 0..1);
2789 }
2790
2791 // Pass 2: scratch → target (copia identidad), preservando lo de afuera.
2792 let bg_copy = device.create_bind_group(&wgpu::BindGroupDescriptor {
2793 label: Some("llimphi-color-filter-bg-copy"),
2794 layout: &self.bind_layout,
2795 entries: &[
2796 wgpu::BindGroupEntry {
2797 binding: 0,
2798 resource: wgpu::BindingResource::TextureView(scratch_view),
2799 },
2800 wgpu::BindGroupEntry {
2801 binding: 1,
2802 resource: wgpu::BindingResource::Sampler(&self.sampler),
2803 },
2804 wgpu::BindGroupEntry {
2805 binding: 2,
2806 resource: ubo_copy.as_entire_binding(),
2807 },
2808 ],
2809 });
2810 {
2811 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2812 label: Some("llimphi-color-filter-pass-copy"),
2813 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2814 view: target,
2815 resolve_target: None,
2816 depth_slice: None,
2817 ops: wgpu::Operations {
2818 load: wgpu::LoadOp::Load,
2819 store: wgpu::StoreOp::Store,
2820 },
2821 })],
2822 depth_stencil_attachment: None,
2823 timestamp_writes: None,
2824 occlusion_query_set: None,
2825 multiview_mask: None,
2826 });
2827 pass.set_pipeline(&self.pipeline);
2828 pass.set_bind_group(0, &bg_copy, &[]);
2829 pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
2830 pass.draw(0..3, 0..1);
2831 }
2832 }
2833}
2834
2835/// "bytemuck" minimal sin dep: convierte `&T` a `&[u8]`. Sólo para POD repr(C)
2836/// — usado para escribir los UBOs del blur con `queue.write_buffer`.
2837fn bytemuck_cast<T: Copy>(v: &T) -> &[u8] {
2838 unsafe {
2839 std::slice::from_raw_parts(
2840 v as *const T as *const u8,
2841 std::mem::size_of::<T>(),
2842 )
2843 }
2844}
2845
2846/// Separable Gaussian, una dirección por pase. El vs es el mismo triángulo
2847/// grande del overlay; el fs samplea `2*radius+1` taps a lo largo de
2848/// `direction*pixel_size`. Pesos `exp(-i²/2σ²)` normalizados por la suma —
2849/// independiente del radius por si quedó cortada la cola.
2850const BLUR_WGSL: &str = r#"
2851struct VsOut {
2852 @builtin(position) pos: vec4<f32>,
2853 @location(0) uv: vec2<f32>,
2854};
2855
2856@vertex
2857fn vs(@builtin(vertex_index) vi: u32) -> VsOut {
2858 var corners = array<vec2<f32>, 3>(
2859 vec2<f32>(-1.0, -1.0),
2860 vec2<f32>( 3.0, -1.0),
2861 vec2<f32>(-1.0, 3.0),
2862 );
2863 let xy = corners[vi];
2864 var out: VsOut;
2865 out.pos = vec4<f32>(xy, 0.0, 1.0);
2866 out.uv = vec2<f32>((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5);
2867 return out;
2868}
2869
2870struct BlurParams {
2871 direction: vec2<f32>,
2872 pixel_size: vec2<f32>,
2873 sigma: f32,
2874 radius: f32,
2875 _pad: vec2<f32>,
2876};
2877
2878@group(0) @binding(0) var src_tex: texture_2d<f32>;
2879@group(0) @binding(1) var src_samp: sampler;
2880@group(0) @binding(2) var<uniform> params: BlurParams;
2881
2882@fragment
2883fn fs(in: VsOut) -> @location(0) vec4<f32> {
2884 let dir = params.direction * params.pixel_size;
2885 let r = i32(params.radius);
2886 let two_sigma_sq = 2.0 * params.sigma * params.sigma;
2887 var acc = vec4<f32>(0.0);
2888 var weight_sum = 0.0;
2889 for (var i = -r; i <= r; i = i + 1) {
2890 let fi = f32(i);
2891 let w = exp(-(fi * fi) / two_sigma_sq);
2892 acc = acc + textureSample(src_tex, src_samp, in.uv + dir * fi) * w;
2893 weight_sum = weight_sum + w;
2894 }
2895 return acc / weight_sum;
2896}
2897"#;
2898
2899/// Matriz de color 4×5: `out = M·rgba + bias`, clampeado a `[0,1]`. El vs es el
2900/// mismo triángulo grande; el fs hace 4 `dot` (una fila por canal) más el bias.
2901const COLOR_WGSL: &str = r#"
2902struct VsOut {
2903 @builtin(position) pos: vec4<f32>,
2904 @location(0) uv: vec2<f32>,
2905};
2906
2907@vertex
2908fn vs(@builtin(vertex_index) vi: u32) -> VsOut {
2909 var corners = array<vec2<f32>, 3>(
2910 vec2<f32>(-1.0, -1.0),
2911 vec2<f32>( 3.0, -1.0),
2912 vec2<f32>(-1.0, 3.0),
2913 );
2914 let xy = corners[vi];
2915 var out: VsOut;
2916 out.pos = vec4<f32>(xy, 0.0, 1.0);
2917 out.uv = vec2<f32>((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5);
2918 return out;
2919}
2920
2921struct ColorParams {
2922 r: vec4<f32>,
2923 g: vec4<f32>,
2924 b: vec4<f32>,
2925 a: vec4<f32>,
2926 bias: vec4<f32>,
2927};
2928
2929@group(0) @binding(0) var src_tex: texture_2d<f32>;
2930@group(0) @binding(1) var src_samp: sampler;
2931@group(0) @binding(2) var<uniform> params: ColorParams;
2932
2933@fragment
2934fn fs(in: VsOut) -> @location(0) vec4<f32> {
2935 let c = textureSample(src_tex, src_samp, in.uv);
2936 var o: vec4<f32>;
2937 o.r = dot(params.r, c) + params.bias.r;
2938 o.g = dot(params.g, c) + params.bias.g;
2939 o.b = dot(params.b, c) + params.bias.b;
2940 o.a = dot(params.a, c) + params.bias.a;
2941 return clamp(o, vec4<f32>(0.0), vec4<f32>(1.0));
2942}
2943"#;
2944
2945impl Surface for WinitSurface {
2946 fn size(&self) -> (u32, u32) {
2947 (self.config.width, self.config.height)
2948 }
2949
2950 fn resize(&mut self, width: u32, height: u32) {
2951 let (w, h) = (width.max(1), height.max(1));
2952 // **Sin cambio de tamaño NO se toca nada.** `RawSurface` y
2953 // `CanvasSurface` ya tenían este guard; `WinitSurface` —la que usan
2954 // TODAS las apps de escritorio y Android— era la única sin él, y eso
2955 // dejaba abierta la puerta a un `resize(w, h)` con las medidas que ya
2956 // tenía puestas: reconfigurar el swapchain y recrear las dos texturas
2957 // intermedias por gusto.
2958 //
2959 // Encontrado en metal el 2026-07-27 (Redmi Note 13 Pro, Mali-G57): el
2960 // camino de recuperación de `handle_redraw` llama exactamente a
2961 // `surface.resize(surface.size())` cuando `acquire` falla, así que sin
2962 // guard eso es un bucle cerrado —falla → resize → redraw → falla—.
2963 // Medido en logcat: **41.335 reconfiguraciones de swapchain en 13 s**,
2964 // ~10 MB de textura por vuelta, hasta que la creación empieza a
2965 // devolver texturas inválidas y la app se queda en negro (o muere, si
2966 // su device no tiene handler no-fatal). El guard corta el bucle en el
2967 // punto más bajo, valga quien valga el caller.
2968 if self.config.width == w && self.config.height == h {
2969 return;
2970 }
2971 // Las texturas PRIMERO, y recién si las dos salieron bien se toca el
2972 // resto. Así un fallo no deja el estado a medio aplicar: la surface
2973 // sigue configurada al tamaño viejo, que es el de las texturas que
2974 // quedan puestas, y el frame siguiente es coherente aunque salga con
2975 // las medidas anteriores. Instalar una textura **inválida** —lo que
2976 // hacía antes— es la pantalla negra perpetua.
2977 let (nueva, overlay_nueva) = match (
2978 create_intermediate_checked(&self.device, w, h),
2979 create_intermediate_checked(&self.device, w, h),
2980 ) {
2981 (Ok(a), Ok(b)) => (a, b),
2982 (a, b) => {
2983 let e = a.err().or_else(|| b.err()).unwrap_or_default();
2984 crate::avisar!(
2985 "llimphi-hal: no se pudo crear la textura intermedia {w}x{h} ({e}); \
2986 me quedo en {}x{}",
2987 self.config.width, self.config.height
2988 );
2989 return;
2990 }
2991 };
2992 self.config.width = w;
2993 self.config.height = h;
2994 if let Err(e) = configure_checked(&self.device, self.vk(), &self.config) {
2995 crate::avisar!("llimphi-hal: configure en resize falló (surface perdida?): {e}");
2996 }
2997 (self.intermediate, self.intermediate_view) = nueva;
2998 (self.overlay, self.overlay_view) = overlay_nueva;
2999 }
3000
3001 fn acquire(&mut self) -> Result<Frame, SurfaceError> {
3002 // El `present` anterior descubrió que el device está perdido: no tiene
3003 // sentido pedir un frame más contra un device muerto. Se cobra acá para
3004 // entrar por el camino de recuperación que ya existe.
3005 if self.device_perdido {
3006 return Err(SurfaceError::DeviceLost);
3007 }
3008 // Gancho de verificación: fuerza un DeviceLost sintético cada N cuadros
3009 // (env `LLIMPHI_SIMULATE_DEVICE_LOSS`) para ejercitar el rebuild sin un
3010 // reset de output real. No-op sin la env.
3011 if simulate_device_loss_trip() {
3012 return Err(SurfaceError::DeviceLost);
3013 }
3014 // Ver la nota sobre `Suboptimal` en el `acquire` de `RawSurface`.
3015 use wgpu::CurrentSurfaceTexture as Estado;
3016 let texture = match self.vk().get_current_texture() {
3017 Estado::Success(t) | Estado::Suboptimal(t) => t,
3018 // El camino winit tampoco tenía recuperación: un `Outdated`/`Lost`
3019 // (hipo del compositor, cambio de VT) se propagaba y la app saltaba
3020 // el frame para siempre en el peor caso. Reconfiguramos; si no basta,
3021 // recreamos la surface desde la ventana; si ESO falla, el device
3022 // quedó irrecuperable → DeviceLost (rebuild del stack GPU completo).
3023 e @ (Estado::Outdated | Estado::Lost) => {
3024 if let Err(msg) = configure_checked(&self.device, self.vk(), &self.config) {
3025 match self.recreate() {
3026 Ok(()) => crate::avisar!(
3027 "llimphi-hal: WinitSurface RECREADA tras {e:?} (reconfigure falló: {msg})"
3028 ),
3029 Err(msg2) => {
3030 crate::avisar!(
3031 "llimphi-hal: reconfigurar tras {e:?} falló ({msg}); recrear también falló ({msg2}) → DeviceLost"
3032 );
3033 return Err(SurfaceError::DeviceLost);
3034 }
3035 }
3036 }
3037 match self.vk().get_current_texture() {
3038 Estado::Success(t) | Estado::Suboptimal(t) => t,
3039 // Sigue rota tras reconfigurar+recrear → device irrecuperable.
3040 Estado::Lost | Estado::Outdated => return Err(SurfaceError::DeviceLost),
3041 Estado::Timeout => return Err(SurfaceError::Timeout),
3042 Estado::Occluded => return Err(SurfaceError::Occluded),
3043 otro => return Err(SurfaceError::Other(format!("{otro:?}"))),
3044 }
3045 }
3046 Estado::Timeout => return Err(SurfaceError::Timeout),
3047 // Ventana tapada: no es falla. Ver [`SurfaceError::Occluded`].
3048 Estado::Occluded => return Err(SurfaceError::Occluded),
3049 otro => return Err(SurfaceError::Other(format!("{otro:?}"))),
3050 };
3051 let surface_view = texture
3052 .texture
3053 .create_view(&wgpu::TextureViewDescriptor::default());
3054 // `TextureView` envuelve un Arc — clonar es atomic-incref, no
3055 // recrea la vista. La intermedia sólo cambia en `resize`.
3056 Ok(Frame {
3057 surface_texture: texture,
3058 surface_view,
3059 intermediate_view: self.intermediate_view.clone(),
3060 overlay_view: self.overlay_view.clone(),
3061 width: self.config.width,
3062 height: self.config.height,
3063 })
3064 }
3065
3066 /// Blitea la intermedia al swapchain y presenta.
3067 ///
3068 /// **El `present` va CAPTURADO**, como `configure` y `acquire`. Era el único
3069 /// de los tres sin red: `wgpu::SurfaceTexture::present` trata sus errores
3070 /// como fatales pase lo que pase —ni el handler no-fatal del device lo
3071 /// salva—, así que un device perdido acá **paniquea**, y el unwind arrastra
3072 /// destructores de wgpu-hal que paniquean a su vez
3073 /// (`Trying to destroy a SurfaceAcquireSemaphores that is still in use by a
3074 /// SurfaceTexture` → `panic in a destructor during cleanup` → **abort**).
3075 ///
3076 /// Medido en Android el 2026-07-28: el device se pierde en el primer frame
3077 /// y la app **se mata ahí**, antes de que el runtime pueda contar fallos ni
3078 /// llamar a `rebuild_gpu_after_device_loss` (0 fallos y 0 rebuilds
3079 /// registrados en el log — no llegaba a ejecutarse nada). Capturando, la
3080 /// pérdida se convierte en una bandera que el próximo `acquire` cobra como
3081 /// [`SurfaceError::DeviceLost`], que es el camino de recuperación **que ya
3082 /// existía** y nunca se estaba alcanzando.
3083 fn present(&mut self, frame: Frame, hal: &Hal) {
3084 let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
3085 label: Some("llimphi-blit"),
3086 });
3087 self.blitter.copy(
3088 &hal.device,
3089 &mut encoder,
3090 &frame.intermediate_view,
3091 &frame.surface_view,
3092 );
3093 hal.queue.submit(std::iter::once(encoder.finish()));
3094 // **No presentar sobre un device muerto.** Un `load` relajado, nada de
3095 // `pop_error_scope` por frame (eso poll‑ea la GPU y sería un peaje en la
3096 // ruta caliente de todas las apps). Si el device ya se perdió, soltamos
3097 // el `SurfaceTexture` sin presentar y dejamos la marca: el próximo
3098 // `acquire` devuelve `DeviceLost` y el runtime rehace el stack.
3099 if DEVICE_PERDIDO_GLOBAL.load(std::sync::atomic::Ordering::Relaxed) {
3100 self.device_perdido = true;
3101 crate::avisar!(
3102 "llimphi-hal: device perdido — no presento este frame; \
3103 DeviceLost en el próximo acquire"
3104 );
3105 // **El `SurfaceTexture` se filtra a propósito.** Con el device
3106 // perdido, wgpu 27 paniquea de forma fatal por los DOS caminos:
3107 // `present()` (wgpu_core.rs:3858) y su `Drop` (wgpu_core.rs:3867),
3108 // y ninguno es atajable —no pasan por error-scope ni por el handler
3109 // no-fatal—. Medido en Android: saltear el present movía el panic al
3110 // destructor, con la misma cascada (`SurfaceAcquireSemaphores still
3111 // in use` → `panic in a destructor` → **abort**). Olvidarla evita
3112 // los dos. El costo es un handle huérfano en un stack GPU que
3113 // estamos por rehacer entero; el beneficio es que el proceso vive.
3114 std::mem::forget(frame.surface_texture);
3115 return;
3116 }
3117 frame.surface_texture.present();
3118 }
3119}
3120
3121#[cfg(all(test, not(target_arch = "wasm32")))]
3122mod tests {
3123 use super::*;
3124
3125 /// Un `Hal` headless, o `None` si esta máquina no tiene con qué (CI sin GPU
3126 /// ni lavapipe). Sin esto el test fallaría por falta de hardware, que no es
3127 /// lo que queremos certificar.
3128 fn hal_o_skip() -> Option<Hal> {
3129 pollster::block_on(Hal::new_headless(false)).ok()
3130 }
3131
3132 /// Una textura intermedia de tamaño imposible **devuelve `Err`**; no
3133 /// paniquea ni entrega una textura inválida.
3134 ///
3135 /// Es la regresión de Android/Mali del 2026-07-27: `create_texture` fallaba,
3136 /// wgpu devolvía una textura **inválida**, y el `create_view` inmediato
3137 /// emitía `Texture with 'llimphi-intermediate' label is invalid` — un error
3138 /// derivado que tapaba el original. Según el handler del device, el mismo
3139 /// bug mataba la app (default fatal de wgpu) o la dejaba viva pintando en
3140 /// negro para siempre (handler no-fatal). Con la creación capturada, el
3141 /// caller se entera y conserva las texturas que funcionaban.
3142 #[test]
3143 fn textura_intermedia_imposible_da_error_y_no_paniquea() {
3144 let Some(hal) = hal_o_skip() else {
3145 crate::avisar!("sin GPU headless en esta máquina: test omitido");
3146 return;
3147 };
3148 let limite = hal.device.limits().max_texture_dimension_2d;
3149 let r = create_intermediate_checked(&hal.device, limite + 1, 64);
3150 assert!(
3151 r.is_err(),
3152 "pasarse del límite de textura 2D ({limite}) tiene que dar Err, no una textura inválida"
3153 );
3154 // El mensaje es el de wgpu, no uno derivado del `create_view`: es el que
3155 // dice de verdad qué pasó.
3156 let msg = r.unwrap_err();
3157 assert!(
3158 !msg.contains("create_view"),
3159 "el error tiene que ser el de la creación, no el derivado de la vista: {msg}"
3160 );
3161 }
3162
3163 /// Y el camino bueno sigue funcionando: un tamaño sano devuelve `Ok` con la
3164 /// textura del tamaño pedido. Sin esto, un `create_intermediate_checked` que
3165 /// devolviera `Err` SIEMPRE pasaría el test de arriba.
3166 #[test]
3167 fn textura_intermedia_de_tamano_sano_sale_ok() {
3168 let Some(hal) = hal_o_skip() else {
3169 crate::avisar!("sin GPU headless en esta máquina: test omitido");
3170 return;
3171 };
3172 let (tex, _view) =
3173 create_intermediate_checked(&hal.device, 320, 240).expect("320x240 tiene que salir");
3174 assert_eq!((tex.width(), tex.height()), (320, 240));
3175 }
3176
3177 /// Los nombres que el operador escribe —en la env o en la propiedad de
3178 /// Android— dan el backend que dicen, sin importar mayúsculas ni espacios.
3179 ///
3180 /// Vale la pena tenerlo escrito porque el valor llega de AFUERA por dos
3181 /// caminos distintos y uno de ellos (`adb shell setprop`) es imposible de
3182 /// probar acá: si el parser se rompe, en el teléfono no se ve un error sino
3183 /// un backend que "no hizo caso", que es el síntoma más caro de diagnosticar.
3184 #[test]
3185 fn los_nombres_de_backend_se_leen_como_dicen() {
3186 assert_eq!(backend_de_texto("gl"), Some(wgpu::Backends::GL));
3187 assert_eq!(backend_de_texto(" GLES\n"), Some(wgpu::Backends::GL));
3188 assert_eq!(backend_de_texto("Vulkan"), Some(wgpu::Backends::VULKAN));
3189 assert_eq!(backend_de_texto("vk"), Some(wgpu::Backends::VULKAN));
3190 assert_eq!(backend_de_texto("primary"), Some(wgpu::Backends::PRIMARY));
3191 assert_eq!(backend_de_texto("all"), Some(wgpu::Backends::all()));
3192 // Lo no reconocido NO cae a un backend cualquiera: devuelve `None` y el
3193 // caller se queda con su default.
3194 assert_eq!(backend_de_texto("metal-por-favor"), None);
3195 assert_eq!(backend_de_texto(""), None);
3196 }
3197}