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