Skip to main content

llimphi_hal/
lib.rs

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    Other(String),
24}
25
26impl std::fmt::Display for SurfaceError {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            Self::Lost => write!(f, "surface lost"),
30            Self::Outdated => write!(f, "surface outdated"),
31            Self::OutOfMemory => write!(f, "surface out of memory"),
32            Self::Timeout => write!(f, "surface timeout"),
33            Self::Other(s) => write!(f, "surface error: {s}"),
34        }
35    }
36}
37
38impl std::error::Error for SurfaceError {}
39
40/// Errores al construir Hal o crear una Surface.
41#[derive(Debug)]
42pub enum HalError {
43    NoAdapter,
44    RequestDevice(String),
45    CreateSurface(String),
46}
47
48impl std::fmt::Display for HalError {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            Self::NoAdapter => write!(f, "no GPU adapter available"),
52            Self::RequestDevice(s) => write!(f, "request_device failed: {s}"),
53            Self::CreateSurface(s) => write!(f, "create_surface failed: {s}"),
54        }
55    }
56}
57
58impl std::error::Error for HalError {}
59
60/// Superficie gráfica donde llimphi pinta.
61///
62/// Vello (rasterizador) emite a una textura intermedia con storage binding
63/// (la única forma portable: los formatos de swapchain no aceptan writes
64/// de compute shader en muchos adapters). En `present` se blittea la
65/// intermedia al swapchain real y se hace el flip.
66///
67/// Implementaciones:
68/// - [`WinitSurface`]: ventana Wayland/X11 (dev + producción vía mirada).
69/// - `WawaFramebufferSurface` (TODO): framebuffer directo del kernel wawa.
70pub trait Surface {
71    fn size(&self) -> (u32, u32);
72    fn resize(&mut self, width: u32, height: u32);
73    /// Adquiere la textura intermedia donde el raster pinta este frame.
74    fn acquire(&mut self) -> Result<Frame, SurfaceError>;
75    /// Blittea la intermedia al swapchain y la presenta.
76    fn present(&mut self, frame: Frame, hal: &Hal);
77}
78
79/// Frame en curso. `view()` devuelve la textura intermedia (Rgba8Unorm,
80/// STORAGE_BINDING) lista para que vello escriba sobre ella.
81pub struct Frame {
82    surface_texture: wgpu::SurfaceTexture,
83    surface_view: wgpu::TextureView,
84    intermediate_view: wgpu::TextureView,
85    /// Textura secundaria para la capa de overlay (menús/paleta/modal)
86    /// cuando hay contenido `gpu_paint` que la taparía. El overlay se
87    /// rasteriza acá con fondo transparente y luego se compone con
88    /// alpha SOBRE la intermedia (que ya tiene UI + video). Ver
89    /// [`OverlayCompositor`] y el eventloop de `llimphi-ui`.
90    overlay_view: wgpu::TextureView,
91    width: u32,
92    height: u32,
93}
94
95impl Frame {
96    pub fn view(&self) -> &wgpu::TextureView {
97        &self.intermediate_view
98    }
99
100    /// Vista de la textura de overlay (mismo tamaño y formato que la
101    /// intermedia). Sólo se usa en el camino de compositing del overlay.
102    pub fn overlay_view(&self) -> &wgpu::TextureView {
103        &self.overlay_view
104    }
105
106    pub fn size(&self) -> (u32, u32) {
107        (self.width, self.height)
108    }
109}
110
111/// Estado wgpu compartido. Una instancia por proceso. `Device` y `Queue`
112/// son `Arc` internamente, así que clonar es barato.
113pub struct Hal {
114    pub instance: wgpu::Instance,
115    pub adapter: wgpu::Adapter,
116    pub device: wgpu::Device,
117    pub queue: wgpu::Queue,
118}
119
120impl Hal {
121    /// Construye Hal pidiendo un adapter compatible con una surface dada
122    /// (recomendado: pasar `Some(&surface)` para garantizar que el adapter
123    /// elegido sabe presentar a esa surface).
124    pub async fn new(
125        compatible_surface: Option<&wgpu::Surface<'static>>,
126    ) -> Result<Self, HalError> {
127        let opts = wgpu::RequestAdapterOptions {
128            power_preference: wgpu::PowerPreference::HighPerformance,
129            force_fallback_adapter: false,
130            compatible_surface,
131        };
132        // Preferimos backends PRIMARY (Vulkan/Metal/DX12). El backend GL de
133        // wgpu sobre Mesa/Wayland tiene un bug de teardown: al soltar la
134        // instancia, `eglTerminate` marshalea sobre una conexión Wayland ya
135        // muerta (`wl_proxy_marshal`) y revienta con SIGSEGV. Con
136        // `Backends::all()` (el default), wgpu puede elegir GL aun habiendo
137        // Vulkan, y la app crashea al cerrar/teardown. Forzamos PRIMARY; si la
138        // máquina no tiene Vulkan/Metal/DX12 (VM vieja, etc.) caemos a todos
139        // los backends —incluido GL— para no dejarla sin gráficos. En el
140        // camino de escritorio `compatible_surface` es `None` (la surface se
141        // crea después contra esta misma instancia), así que cambiar de
142        // instancia aquí es seguro.
143        let primary = wgpu::Instance::new(&wgpu::InstanceDescriptor {
144            backends: wgpu::Backends::PRIMARY,
145            ..Default::default()
146        });
147        let (instance, adapter) = match primary.request_adapter(&opts).await {
148            Ok(a) => (primary, a),
149            Err(_) => {
150                let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
151                let a = all
152                    .request_adapter(&opts)
153                    .await
154                    .map_err(|_| HalError::NoAdapter)?;
155                (all, a)
156            }
157        };
158        // `Limits::default()` cubre los 5 storage buffers/stage que vello
159        // necesita. `downlevel_defaults()` solo expone 4 y rompe el raster.
160        // Si el adapter no lo aguanta, `using_resolution` recorta lo recortable
161        // (texturas/buffers grandes) preservando los conteos mínimos.
162        let limits = wgpu::Limits::default().using_resolution(adapter.limits());
163        let (device, queue) = adapter
164            .request_device(&wgpu::DeviceDescriptor {
165                label: Some("llimphi-hal-device"),
166                required_features: wgpu::Features::empty(),
167                required_limits: limits,
168                memory_hints: wgpu::MemoryHints::Performance,
169                experimental_features: wgpu::ExperimentalFeatures::default(),
170                trace: wgpu::Trace::Off,
171            })
172            .await
173            .map_err(|e| HalError::RequestDevice(e.to_string()))?;
174        Ok(Self {
175            instance,
176            adapter,
177            device,
178            queue,
179        })
180    }
181
182    /// Construye el `Hal` **y** una [`RawSurface`] a la vez, eligiendo el adaptador
183    /// **compatible con esa surface** — el dispositivo que el compositor sabe
184    /// presentar. Es el camino correcto para el backend layer-shell de `pata`.
185    ///
186    /// El problema que resuelve: en sistemas multi-GPU (Optimus), pedir el
187    /// adaptador sin pista de surface (`new(None)` con `HighPerformance`) puede
188    /// elegir la dGPU mientras el compositor compone en la iGPU → los dmabuf
189    /// cruzan dispositivos y `get_capabilities` devuelve 0 formatos (la surface
190    /// "no expone formatos"). Pasar `compatible_surface` ata el adaptador al
191    /// dispositivo del compositor. Como la surface hace falta ANTES de pedir el
192    /// adaptador, y `new` crea la instancia internamente, este constructor une los
193    /// dos pasos.
194    ///
195    /// `make_target` reconstruye el `SurfaceTargetUnsafe` cada vez que se llama
196    /// (los `RawHandle` son `Copy`): `create_surface_unsafe` consume el target y
197    /// puede que probemos dos instancias (PRIMARY y, si no hay adaptador, todos
198    /// los backends — el GL de Mesa/Wayland revienta en teardown, por eso PRIMARY
199    /// primero, igual que [`Hal::new`]).
200    ///
201    /// # Safety
202    /// Los handles que produce `make_target` deben apuntar a objetos Wayland/…
203    /// vivos durante toda la vida de la `RawSurface` devuelta.
204    pub async unsafe fn new_for_raw_surface(
205        make_target: impl Fn() -> wgpu::SurfaceTargetUnsafe,
206        width: u32,
207        height: u32,
208    ) -> Result<(Self, RawSurface), HalError> {
209        // PRIMARY (Vulkan/Metal/DX12) primero; si no hay adaptador compatible, a
210        // todos los backends recreando instancia y surface.
211        let primary = wgpu::Instance::new(&wgpu::InstanceDescriptor {
212            backends: wgpu::Backends::PRIMARY,
213            ..Default::default()
214        });
215        let prim_surface = unsafe { primary.create_surface_unsafe(make_target()) }
216            .map_err(|e| HalError::CreateSurface(e.to_string()))?;
217        let prim_adapter = primary
218            .request_adapter(&wgpu::RequestAdapterOptions {
219                power_preference: wgpu::PowerPreference::HighPerformance,
220                force_fallback_adapter: false,
221                compatible_surface: Some(&prim_surface),
222            })
223            .await;
224        let (instance, adapter, wgpu_surface) = match prim_adapter {
225            Ok(a) => (primary, a, prim_surface),
226            Err(_) => {
227                let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
228                let surface = unsafe { all.create_surface_unsafe(make_target()) }
229                    .map_err(|e| HalError::CreateSurface(e.to_string()))?;
230                let a = all
231                    .request_adapter(&wgpu::RequestAdapterOptions {
232                        power_preference: wgpu::PowerPreference::HighPerformance,
233                        force_fallback_adapter: false,
234                        compatible_surface: Some(&surface),
235                    })
236                    .await
237                    .map_err(|_| HalError::NoAdapter)?;
238                (all, a, surface)
239            }
240        };
241        let limits = wgpu::Limits::default().using_resolution(adapter.limits());
242        let (device, queue) = adapter
243            .request_device(&wgpu::DeviceDescriptor {
244                label: Some("llimphi-hal-device"),
245                required_features: wgpu::Features::empty(),
246                required_limits: limits,
247                memory_hints: wgpu::MemoryHints::Performance,
248                experimental_features: wgpu::ExperimentalFeatures::default(),
249                trace: wgpu::Trace::Off,
250            })
251            .await
252            .map_err(|e| HalError::RequestDevice(e.to_string()))?;
253        let hal = Self {
254            instance,
255            adapter,
256            device,
257            queue,
258        };
259        let surface = RawSurface::from_surface(&hal, wgpu_surface, width, height)?;
260        Ok((hal, surface))
261    }
262}
263
264/// Surface basada en `winit::window::Window`. Mantiene una textura
265/// intermedia `Rgba8Unorm` con storage binding (donde pinta vello) y
266/// un `TextureBlitter` que la copia al swapchain al presentar.
267pub struct WinitSurface {
268    _window: Arc<Window>,
269    surface: wgpu::Surface<'static>,
270    config: wgpu::SurfaceConfiguration,
271    device: wgpu::Device,
272    intermediate: wgpu::Texture,
273    intermediate_view: wgpu::TextureView,
274    /// Textura de la capa de overlay (ver [`Frame::overlay_view`]).
275    overlay: wgpu::Texture,
276    overlay_view: wgpu::TextureView,
277    blitter: wgpu::util::TextureBlitter,
278}
279
280const INTERMEDIATE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
281
282impl WinitSurface {
283    /// Constructor "feliz": crea la `wgpu::Surface` internamente.
284    /// Conveniente en desktop donde la secuencia normal es
285    /// `Hal::new(None)` → `WinitSurface::new(hal, window)`. **En Android
286    /// usar [`WinitSurface::from_surface`]** — allí la surface debe
287    /// existir antes del `request_adapter(compatible_surface=Some(...))`,
288    /// y crearla dos veces sobre la misma `ANativeWindow` falla con
289    /// `ERROR_NATIVE_WINDOW_IN_USE_KHR`.
290    pub fn new(hal: &Hal, window: Arc<Window>) -> Result<Self, HalError> {
291        let surface = hal
292            .instance
293            .create_surface(window.clone())
294            .map_err(|e| HalError::CreateSurface(e.to_string()))?;
295        Self::from_surface(hal, window, surface)
296    }
297
298    /// Constructor reutilizable: arma el `WinitSurface` envolviendo una
299    /// `wgpu::Surface` ya creada por el caller. Necesario en Android
300    /// porque el orden allí es:
301    ///
302    /// 1. `instance.create_surface(window)`
303    /// 2. `instance.request_adapter(compatible_surface=Some(&surface))`
304    /// 3. `adapter.request_device(...)`
305    /// 4. `WinitSurface::from_surface(hal, window, surface)`
306    ///
307    /// — no se puede dropear la surface entre 2 y 4 ni recrearla, porque
308    /// Android reserva la `ANativeWindow` por VkSurface y rechaza un
309    /// segundo `vkCreateAndroidSurfaceKHR` sobre la misma ventana.
310    pub fn from_surface(
311        hal: &Hal,
312        window: Arc<Window>,
313        surface: wgpu::Surface<'static>,
314    ) -> Result<Self, HalError> {
315        let size = window.inner_size();
316        let caps = surface.get_capabilities(&hal.adapter);
317        // Preferimos Bgra8Unorm o Rgba8Unorm (no sRGB) para que el blit
318        // desde la intermedia lineal preserve los valores tal cual.
319        let format = caps
320            .formats
321            .iter()
322            .copied()
323            .find(|f| matches!(f, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm))
324            .unwrap_or(caps.formats[0]);
325        let config = wgpu::SurfaceConfiguration {
326            // El swapchain solo necesita render-attachment: vello no escribe
327            // directo, escribe a la intermedia y luego se blittea.
328            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
329            format,
330            width: size.width.max(1),
331            height: size.height.max(1),
332            present_mode: choose_present_mode(&caps),
333            desired_maximum_frame_latency: 2,
334            alpha_mode: caps.alpha_modes[0],
335            view_formats: vec![],
336        };
337        surface.configure(&hal.device, &config);
338        let (intermediate, intermediate_view) =
339            create_intermediate(&hal.device, config.width, config.height);
340        let (overlay, overlay_view) =
341            create_intermediate(&hal.device, config.width, config.height);
342        let blitter = wgpu::util::TextureBlitter::new(&hal.device, format);
343        Ok(Self {
344            _window: window,
345            surface,
346            config,
347            device: hal.device.clone(),
348            intermediate,
349            intermediate_view,
350            overlay,
351            overlay_view,
352            blitter,
353        })
354    }
355
356    pub fn format(&self) -> wgpu::TextureFormat {
357        self.config.format
358    }
359}
360
361/// Surface sobre una `wgpu::Surface` creada desde **handles raw** (sin
362/// `winit::Window`): la usa el backend `wlr-layer-shell` de `pata` para pintar
363/// en una *layer surface* de Wayland (barras/paneles al nivel de eww/waybar).
364/// Misma mecánica que [`WinitSurface`] —intermedia `Rgba8Unorm` + blit al
365/// swapchain— pero el tamaño se pasa explícito porque no hay ventana que
366/// consultar. La `wgpu::Surface` la crea el caller (típicamente con
367/// `instance.create_surface_unsafe` desde los punteros `wl_display`/`wl_surface`).
368pub struct RawSurface {
369    surface: wgpu::Surface<'static>,
370    config: wgpu::SurfaceConfiguration,
371    device: wgpu::Device,
372    intermediate: wgpu::Texture,
373    intermediate_view: wgpu::TextureView,
374    overlay: wgpu::Texture,
375    overlay_view: wgpu::TextureView,
376    blitter: wgpu::util::TextureBlitter,
377}
378
379impl RawSurface {
380    /// Envuelve una `wgpu::Surface` ya creada, con el tamaño físico inicial.
381    pub fn from_surface(
382        hal: &Hal,
383        surface: wgpu::Surface<'static>,
384        width: u32,
385        height: u32,
386    ) -> Result<Self, HalError> {
387        let caps = surface.get_capabilities(&hal.adapter);
388        let info = hal.adapter.get_info();
389        // Si la superficie no expone formatos, el compositor no la soporta por
390        // este backend (Vulkan/GL WSI): error claro en vez de un panic por
391        // indexar `formats[0]` sobre una lista vacía.
392        let format = match caps
393            .formats
394            .iter()
395            .copied()
396            .find(|f| matches!(f, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm))
397            .or_else(|| caps.formats.first().copied())
398        {
399            Some(f) => f,
400            None => {
401                return Err(HalError::CreateSurface(format!(
402                    "la superficie no expone formatos (adapter {:?}/{:?}): el compositor no la soporta por {:?} WSI",
403                    info.backend, info.device_type, info.backend
404                )))
405            }
406        };
407        // Para una layer surface (wlr-layer-shell) la transparencia es
408        // crítica: la usamos para popovers/menús que pintan un panel chico y
409        // dejan el resto transparente para ver el escritorio. La heurística
410        // ingenua `caps.alpha_modes.first()` cae a veces en `Opaque` (el
411        // compositor descarta alpha) — el clear TRANSPARENT se compone como
412        // negro literal y el menú inicio sale como un cuadrón negro.
413        //
414        // Preferencia: PreMultiplied > PostMultiplied > Inherit > Auto >
415        // Opaque. Los dos primeros componen alpha como esperamos; los dos
416        // siguientes dejan que el compositor decida (típicamente respeta el
417        // alpha del buffer ARGB); Opaque es el último recurso.
418        let alpha_mode = {
419            use wgpu::CompositeAlphaMode as Mode;
420            let want = [
421                Mode::PreMultiplied,
422                Mode::PostMultiplied,
423                Mode::Inherit,
424                Mode::Auto,
425            ];
426            want.iter()
427                .copied()
428                .find(|m| caps.alpha_modes.contains(m))
429                .or_else(|| caps.alpha_modes.first().copied())
430                .unwrap_or(Mode::Auto)
431        };
432        let config = wgpu::SurfaceConfiguration {
433            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
434            format,
435            width: width.max(1),
436            height: height.max(1),
437            present_mode: choose_present_mode(&caps),
438            desired_maximum_frame_latency: 2,
439            alpha_mode,
440            view_formats: vec![],
441        };
442        surface.configure(&hal.device, &config);
443        let (intermediate, intermediate_view) =
444            create_intermediate(&hal.device, config.width, config.height);
445        let (overlay, overlay_view) =
446            create_intermediate(&hal.device, config.width, config.height);
447        let blitter = wgpu::util::TextureBlitter::new(&hal.device, format);
448        Ok(Self {
449            surface,
450            config,
451            device: hal.device.clone(),
452            intermediate,
453            intermediate_view,
454            overlay,
455            overlay_view,
456            blitter,
457        })
458    }
459
460    pub fn format(&self) -> wgpu::TextureFormat {
461        self.config.format
462    }
463}
464
465impl Surface for RawSurface {
466    fn size(&self) -> (u32, u32) {
467        (self.config.width, self.config.height)
468    }
469
470    fn resize(&mut self, width: u32, height: u32) {
471        let (w, h) = (width.max(1), height.max(1));
472        // Sin cambio de tamaño NO reconfiguramos. El backend layer-shell de `pata`
473        // llama a `resize` en cada cuadro (no tiene eventos de resize como winit);
474        // reconfigurar el swapchain por cuadro lo reconstruye una y otra vez, y en
475        // Vulkan WSI eso **destruye el `wl_buffer` recién presentado antes de que el
476        // compositor lo componga** — wlroots lo tolera, smithay (mirada) no, y la
477        // superficie queda en negro (el compositor ve `buffer=None`).
478        if self.config.width == w && self.config.height == h {
479            return;
480        }
481        self.config.width = w;
482        self.config.height = h;
483        self.surface.configure(&self.device, &self.config);
484        let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height);
485        self.intermediate = tex;
486        self.intermediate_view = view;
487        let (otex, oview) =
488            create_intermediate(&self.device, self.config.width, self.config.height);
489        self.overlay = otex;
490        self.overlay_view = oview;
491    }
492
493    fn acquire(&mut self) -> Result<Frame, SurfaceError> {
494        let texture = match self.surface.get_current_texture() {
495            Ok(t) => t,
496            // El backend layer-shell no tiene un evento de resize que reconfigure
497            // el swapchain; si quedó obsoleto/perdido, lo reconstruimos aquí mismo
498            // y reintentamos una vez. Sin esto el panel quedaría en negro para
499            // siempre tras el primer `Outdated`.
500            Err(e @ (wgpu::SurfaceError::Outdated | wgpu::SurfaceError::Lost)) => {
501                self.surface.configure(&self.device, &self.config);
502                self.surface.get_current_texture().map_err(|_| match e {
503                    wgpu::SurfaceError::Lost => SurfaceError::Lost,
504                    _ => SurfaceError::Outdated,
505                })?
506            }
507            Err(wgpu::SurfaceError::OutOfMemory) => return Err(SurfaceError::OutOfMemory),
508            Err(wgpu::SurfaceError::Timeout) => return Err(SurfaceError::Timeout),
509            Err(other) => return Err(SurfaceError::Other(format!("{other:?}"))),
510        };
511        let surface_view = texture
512            .texture
513            .create_view(&wgpu::TextureViewDescriptor::default());
514        Ok(Frame {
515            surface_texture: texture,
516            surface_view,
517            intermediate_view: self.intermediate_view.clone(),
518            overlay_view: self.overlay_view.clone(),
519            width: self.config.width,
520            height: self.config.height,
521        })
522    }
523
524    fn present(&mut self, frame: Frame, hal: &Hal) {
525        let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
526            label: Some("llimphi-blit-raw"),
527        });
528        self.blitter.copy(
529            &hal.device,
530            &mut encoder,
531            &frame.intermediate_view,
532            &frame.surface_view,
533        );
534        hal.queue.submit(std::iter::once(encoder.finish()));
535        frame.surface_texture.present();
536    }
537}
538
539/// Elige el modo de presentación del swapchain.
540///
541/// Default: **Mailbox** si el driver lo expone, sino **Fifo**. La razón es
542/// el cuelgue observado en las apps Llimphi (investigación 2026-05-30): con
543/// `Fifo`/`AutoVsync`, `surface.get_current_texture()` **bloquea** esperando
544/// el frame-callback del compositor Wayland — si el compositor no suelta un
545/// buffer, el hilo del UI queda dormido (CPU baja, deadlock aparente).
546/// `Mailbox` no bloquea (triple-buffer, descarta frames viejos), así que el
547/// loop nunca se queda esperando al compositor. `Fifo` está garantizado por
548/// spec como fallback.
549///
550/// Override por entorno para A/B sin recompilar (útil en la laptop con
551/// display real): `LLIMPHI_PRESENT_MODE = fifo | mailbox | immediate |
552/// fifo_relaxed`. Si el modo pedido no está soportado, se ignora y se aplica
553/// el default.
554fn choose_present_mode(caps: &wgpu::SurfaceCapabilities) -> wgpu::PresentMode {
555    use wgpu::PresentMode::{Fifo, FifoRelaxed, Immediate, Mailbox};
556    if let Ok(v) = std::env::var("LLIMPHI_PRESENT_MODE") {
557        let want = match v.trim().to_ascii_lowercase().as_str() {
558            "fifo" | "vsync" => Some(Fifo),
559            "fifo_relaxed" | "fiforelaxed" => Some(FifoRelaxed),
560            "mailbox" => Some(Mailbox),
561            "immediate" | "novsync" => Some(Immediate),
562            _ => None,
563        };
564        if let Some(m) = want {
565            if caps.present_modes.contains(&m) {
566                return m;
567            }
568        }
569    }
570    if caps.present_modes.contains(&Mailbox) {
571        Mailbox
572    } else {
573        Fifo
574    }
575}
576
577fn create_intermediate(
578    device: &wgpu::Device,
579    width: u32,
580    height: u32,
581) -> (wgpu::Texture, wgpu::TextureView) {
582    let texture = device.create_texture(&wgpu::TextureDescriptor {
583        label: Some("llimphi-intermediate"),
584        size: wgpu::Extent3d {
585            width,
586            height,
587            depth_or_array_layers: 1,
588        },
589        mip_level_count: 1,
590        sample_count: 1,
591        dimension: wgpu::TextureDimension::D2,
592        format: INTERMEDIATE_FORMAT,
593        // STORAGE_BINDING: vello escribe via compute shader.
594        // TEXTURE_BINDING: el blitter la lee como sampler source.
595        // RENDER_ATTACHMENT: render passes con clear-only (sin vello)
596        //   también escriben acá — desktop drivers lo tolerían sin este
597        //   flag, Adreno con validación estricta rechaza el frame.
598        usage: wgpu::TextureUsages::STORAGE_BINDING
599            | wgpu::TextureUsages::TEXTURE_BINDING
600            | wgpu::TextureUsages::RENDER_ATTACHMENT,
601        view_formats: &[],
602    });
603    let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
604    (texture, view)
605}
606
607/// Compositor de la capa de overlay: alpha-blittea una textura source (el
608/// overlay rasterizado por vello sobre fondo transparente) SOBRE una textura
609/// target (la intermedia, que ya tiene la UI principal + el video pintado por
610/// `gpu_paint`). Resuelve el z-order: sin esto, el blit de `gpu_paint` (video)
611/// queda encima de la capa vello del overlay y los menús se ven por debajo del
612/// video.
613///
614/// Es un pase de pantalla completa (triángulo) que samplea el source y lo
615/// emite con alpha-over. El factor de blend asume alpha **premultiplicado**
616/// (lo que produce vello); si en pantalla los menús se ven con halos oscuros o
617/// transparencia rara, exportar `LLIMPHI_OVERLAY_BLEND=straight` para usar
618/// alpha recto sin recompilar.
619pub struct OverlayCompositor {
620    pipeline: wgpu::RenderPipeline,
621    sampler: wgpu::Sampler,
622    bind_layout: wgpu::BindGroupLayout,
623}
624
625impl OverlayCompositor {
626    pub fn new(device: &wgpu::Device) -> Self {
627        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
628            label: Some("llimphi-overlay-composite"),
629            source: wgpu::ShaderSource::Wgsl(OVERLAY_COMPOSITE_WGSL.into()),
630        });
631        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
632            label: Some("llimphi-overlay-bgl"),
633            entries: &[
634                wgpu::BindGroupLayoutEntry {
635                    binding: 0,
636                    visibility: wgpu::ShaderStages::FRAGMENT,
637                    ty: wgpu::BindingType::Texture {
638                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
639                        view_dimension: wgpu::TextureViewDimension::D2,
640                        multisampled: false,
641                    },
642                    count: None,
643                },
644                wgpu::BindGroupLayoutEntry {
645                    binding: 1,
646                    visibility: wgpu::ShaderStages::FRAGMENT,
647                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
648                    count: None,
649                },
650            ],
651        });
652        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
653            label: Some("llimphi-overlay-pl"),
654            bind_group_layouts: &[&bind_layout],
655            push_constant_ranges: &[],
656        });
657        // Alpha-over. `src_factor` distingue premultiplicado (One) de recto
658        // (SrcAlpha); el resto es siempre OneMinusSrcAlpha.
659        let straight = std::env::var("LLIMPHI_OVERLAY_BLEND")
660            .map(|v| v.trim().eq_ignore_ascii_case("straight"))
661            .unwrap_or(false);
662        let color_src = if straight {
663            wgpu::BlendFactor::SrcAlpha
664        } else {
665            wgpu::BlendFactor::One
666        };
667        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
668            label: Some("llimphi-overlay-pipe"),
669            layout: Some(&pipeline_layout),
670            vertex: wgpu::VertexState {
671                module: &shader,
672                entry_point: Some("vs"),
673                buffers: &[],
674                compilation_options: Default::default(),
675            },
676            fragment: Some(wgpu::FragmentState {
677                module: &shader,
678                entry_point: Some("fs"),
679                targets: &[Some(wgpu::ColorTargetState {
680                    format: INTERMEDIATE_FORMAT,
681                    blend: Some(wgpu::BlendState {
682                        color: wgpu::BlendComponent {
683                            src_factor: color_src,
684                            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
685                            operation: wgpu::BlendOperation::Add,
686                        },
687                        alpha: wgpu::BlendComponent {
688                            src_factor: wgpu::BlendFactor::One,
689                            dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
690                            operation: wgpu::BlendOperation::Add,
691                        },
692                    }),
693                    write_mask: wgpu::ColorWrites::ALL,
694                })],
695                compilation_options: Default::default(),
696            }),
697            primitive: wgpu::PrimitiveState::default(),
698            depth_stencil: None,
699            multisample: wgpu::MultisampleState::default(),
700            multiview: None,
701            cache: None,
702        });
703        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
704            label: Some("llimphi-overlay-sampler"),
705            ..Default::default()
706        });
707        OverlayCompositor {
708            pipeline,
709            sampler,
710            bind_layout,
711        }
712    }
713
714    /// Compone `source` (overlay con fondo transparente) sobre `target` (la
715    /// intermedia), preservando el contenido previo del target (LoadOp::Load)
716    /// y mezclando con alpha. Graba un render pass en `encoder`.
717    pub fn composite(
718        &self,
719        device: &wgpu::Device,
720        encoder: &mut wgpu::CommandEncoder,
721        target: &wgpu::TextureView,
722        source: &wgpu::TextureView,
723    ) {
724        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
725            label: Some("llimphi-overlay-bg"),
726            layout: &self.bind_layout,
727            entries: &[
728                wgpu::BindGroupEntry {
729                    binding: 0,
730                    resource: wgpu::BindingResource::TextureView(source),
731                },
732                wgpu::BindGroupEntry {
733                    binding: 1,
734                    resource: wgpu::BindingResource::Sampler(&self.sampler),
735                },
736            ],
737        });
738        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
739            label: Some("llimphi-overlay-composite-pass"),
740            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
741                view: target,
742                resolve_target: None,
743                depth_slice: None,
744                ops: wgpu::Operations {
745                    load: wgpu::LoadOp::Load,
746                    store: wgpu::StoreOp::Store,
747                },
748            })],
749            depth_stencil_attachment: None,
750            timestamp_writes: None,
751            occlusion_query_set: None,
752        });
753        pass.set_pipeline(&self.pipeline);
754        pass.set_bind_group(0, &bind_group, &[]);
755        pass.draw(0..3, 0..1);
756    }
757}
758
759/// Pase de pantalla completa que samplea la textura de overlay y la emite
760/// para alpha-over. Triángulo grande que cubre el viewport; UV mapea clip
761/// → texel 1:1 (Y invertida, igual que un blit estándar).
762const OVERLAY_COMPOSITE_WGSL: &str = r#"
763struct VsOut {
764    @builtin(position) pos: vec4<f32>,
765    @location(0) uv: vec2<f32>,
766};
767
768@vertex
769fn vs(@builtin(vertex_index) vi: u32) -> VsOut {
770    var corners = array<vec2<f32>, 3>(
771        vec2<f32>(-1.0, -1.0),
772        vec2<f32>( 3.0, -1.0),
773        vec2<f32>(-1.0,  3.0),
774    );
775    let xy = corners[vi];
776    var out: VsOut;
777    out.pos = vec4<f32>(xy, 0.0, 1.0);
778    out.uv = vec2<f32>((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5);
779    return out;
780}
781
782@group(0) @binding(0) var src_tex: texture_2d<f32>;
783@group(0) @binding(1) var src_samp: sampler;
784
785@fragment
786fn fs(in: VsOut) -> @location(0) vec4<f32> {
787    return textureSample(src_tex, src_samp, in.uv);
788}
789"#;
790
791/// Gaussian backdrop blur sobre la intermediate (la textura donde vello pinta
792/// la UI). El compositor empuja dos render passes separables (horizontal +
793/// vertical) restringidas por scissor al rect del nodo `.backdrop_blur(sigma)`,
794/// usando una textura scratch interna del mismo tamaño que la intermediate.
795///
796/// **Pipeline**: vs = triángulo grande full-screen (clip-space), fs = suma
797/// ponderada de N samples a lo largo de `direction`, pesos Gauss `exp(-i²/2σ²)`.
798/// El bind group lleva la textura source + sampler bilinear + UBO con
799/// `(direction, pixel_size, sigma, radius)`. El scissor recorta el output al
800/// rect del nodo; el resto del target queda intacto (LoadOp::Load).
801///
802/// **Coste**: una pasada por dirección por nodo blur, ~`2*radius+1` taps por
803/// pixel del rect. Para `sigma=8` (radius=24), ~49 taps/pixel — barato si el
804/// rect es pequeño (chrome), pesado si es full-screen. v1: sin cap dinámico,
805/// se asume que el caller no abusa.
806///
807/// **Limitaciones v1**:
808/// - Un scratch full-screen alocado por compositor; resize sigue al `Surface`.
809/// - `radius` cap en 32 — sigmas > ~10 se ven menos suaves (clip de cola).
810/// - Bordes del rect: clamp-to-edge (sampler) → los pixeles fuera del rect
811///   que se muestrean en la cola del Gauss salen como espejo del borde. En
812///   un viewport razonable la diferencia es invisible; documentado.
813pub struct BlurCompositor {
814    pipeline: wgpu::RenderPipeline,
815    sampler: wgpu::Sampler,
816    bind_layout: wgpu::BindGroupLayout,
817    scratch: Option<BlurScratch>,
818}
819
820struct BlurScratch {
821    _texture: wgpu::Texture,
822    view: wgpu::TextureView,
823    width: u32,
824    height: u32,
825}
826
827/// Layout en GPU del UBO del blur. Debe coincidir con el `BlurParams` del WGSL.
828/// Padding explícito al final para llegar a múltiplo de 16 bytes (alignment
829/// estándar de uniformes en wgpu).
830#[repr(C)]
831#[derive(Clone, Copy)]
832struct BlurUniforms {
833    direction: [f32; 2],
834    pixel_size: [f32; 2],
835    sigma: f32,
836    radius: f32,
837    _pad: [f32; 2],
838}
839
840const BLUR_UBO_SIZE: u64 = std::mem::size_of::<BlurUniforms>() as u64;
841const BLUR_MAX_RADIUS: f32 = 32.0;
842
843impl BlurCompositor {
844    pub fn new(device: &wgpu::Device) -> Self {
845        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
846            label: Some("llimphi-blur-shader"),
847            source: wgpu::ShaderSource::Wgsl(BLUR_WGSL.into()),
848        });
849        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
850            label: Some("llimphi-blur-bgl"),
851            entries: &[
852                wgpu::BindGroupLayoutEntry {
853                    binding: 0,
854                    visibility: wgpu::ShaderStages::FRAGMENT,
855                    ty: wgpu::BindingType::Texture {
856                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
857                        view_dimension: wgpu::TextureViewDimension::D2,
858                        multisampled: false,
859                    },
860                    count: None,
861                },
862                wgpu::BindGroupLayoutEntry {
863                    binding: 1,
864                    visibility: wgpu::ShaderStages::FRAGMENT,
865                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
866                    count: None,
867                },
868                wgpu::BindGroupLayoutEntry {
869                    binding: 2,
870                    visibility: wgpu::ShaderStages::FRAGMENT,
871                    ty: wgpu::BindingType::Buffer {
872                        ty: wgpu::BufferBindingType::Uniform,
873                        has_dynamic_offset: false,
874                        min_binding_size: None,
875                    },
876                    count: None,
877                },
878            ],
879        });
880        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
881            label: Some("llimphi-blur-pl"),
882            bind_group_layouts: &[&bind_layout],
883            push_constant_ranges: &[],
884        });
885        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
886            label: Some("llimphi-blur-pipe"),
887            layout: Some(&pipeline_layout),
888            vertex: wgpu::VertexState {
889                module: &shader,
890                entry_point: Some("vs"),
891                buffers: &[],
892                compilation_options: Default::default(),
893            },
894            fragment: Some(wgpu::FragmentState {
895                module: &shader,
896                entry_point: Some("fs"),
897                targets: &[Some(wgpu::ColorTargetState {
898                    format: INTERMEDIATE_FORMAT,
899                    // El blur OVERWRITE el rect; no necesita alpha-over. El
900                    // resultado del Gauss es opaco si los pixeles muestreados
901                    // lo son (la intermediate tiene UI + background opaco).
902                    blend: None,
903                    write_mask: wgpu::ColorWrites::ALL,
904                })],
905                compilation_options: Default::default(),
906            }),
907            primitive: wgpu::PrimitiveState::default(),
908            depth_stencil: None,
909            multisample: wgpu::MultisampleState::default(),
910            multiview: None,
911            cache: None,
912        });
913        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
914            label: Some("llimphi-blur-sampler"),
915            address_mode_u: wgpu::AddressMode::ClampToEdge,
916            address_mode_v: wgpu::AddressMode::ClampToEdge,
917            address_mode_w: wgpu::AddressMode::ClampToEdge,
918            mag_filter: wgpu::FilterMode::Linear,
919            min_filter: wgpu::FilterMode::Linear,
920            mipmap_filter: wgpu::FilterMode::Nearest,
921            ..Default::default()
922        });
923        BlurCompositor {
924            pipeline,
925            sampler,
926            bind_layout,
927            scratch: None,
928        }
929    }
930
931    /// Aplica un blur Gaussiano sobre `target` en el rect dado (coords pixel
932    /// del viewport). Si el rect cae fuera del viewport, no hace nada. Usa
933    /// un scratch interno del mismo tamaño que el viewport — se aloca lazy y
934    /// se reusa entre frames; se recrea si el viewport cambió.
935    ///
936    /// `sigma` controla el ancho del kernel. ~`σ=4` da "frosted glass" suave,
937    /// `σ=16` un blur fuerte. El radius efectivo se cap a [`BLUR_MAX_RADIUS`].
938    pub fn blur(
939        &mut self,
940        device: &wgpu::Device,
941        queue: &wgpu::Queue,
942        encoder: &mut wgpu::CommandEncoder,
943        target: &wgpu::TextureView,
944        viewport: (u32, u32),
945        rect: (f32, f32, f32, f32),
946        sigma: f32,
947    ) {
948        let (vw, vh) = viewport;
949        if vw == 0 || vh == 0 || sigma <= 0.0 {
950            return;
951        }
952        let (rx, ry, rw, rh) = rect;
953        // Clamp scissor al viewport (un rect fuera del viewport pifia el
954        // RenderPass).
955        let x0 = rx.max(0.0) as u32;
956        let y0 = ry.max(0.0) as u32;
957        let x1 = (rx + rw).min(vw as f32).max(0.0) as u32;
958        let y1 = (ry + rh).min(vh as f32).max(0.0) as u32;
959        if x1 <= x0 || y1 <= y0 {
960            return;
961        }
962        let scissor = (x0, y0, x1 - x0, y1 - y0);
963
964        // Scratch del tamaño del viewport. Si cambió, recrear.
965        let need_new = match &self.scratch {
966            Some(s) => s.width != vw || s.height != vh,
967            None => true,
968        };
969        if need_new {
970            let texture = device.create_texture(&wgpu::TextureDescriptor {
971                label: Some("llimphi-blur-scratch"),
972                size: wgpu::Extent3d {
973                    width: vw,
974                    height: vh,
975                    depth_or_array_layers: 1,
976                },
977                mip_level_count: 1,
978                sample_count: 1,
979                dimension: wgpu::TextureDimension::D2,
980                format: INTERMEDIATE_FORMAT,
981                usage: wgpu::TextureUsages::TEXTURE_BINDING
982                    | wgpu::TextureUsages::RENDER_ATTACHMENT,
983                view_formats: &[],
984            });
985            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
986            self.scratch = Some(BlurScratch {
987                _texture: texture,
988                view,
989                width: vw,
990                height: vh,
991            });
992        }
993        let scratch_view = &self.scratch.as_ref().expect("scratch creado arriba").view;
994
995        let radius = (sigma * 3.0).ceil().min(BLUR_MAX_RADIUS);
996        let pixel_size = [1.0 / vw as f32, 1.0 / vh as f32];
997        let ubo_h_data = BlurUniforms {
998            direction: [1.0, 0.0],
999            pixel_size,
1000            sigma,
1001            radius,
1002            _pad: [0.0, 0.0],
1003        };
1004        let ubo_v_data = BlurUniforms {
1005            direction: [0.0, 1.0],
1006            pixel_size,
1007            sigma,
1008            radius,
1009            _pad: [0.0, 0.0],
1010        };
1011        // UBOs por llamada (ver nota en `ColorFilterCompositor::apply`): varios
1012        // blurs en el mismo submit con sigmas distintos no deben aliasar un UBO
1013        // compartido (ganaría el último). Buffers frescos por llamada (32 bytes).
1014        let ubo_h = device.create_buffer(&wgpu::BufferDescriptor {
1015            label: Some("llimphi-blur-ubo-h"),
1016            size: BLUR_UBO_SIZE,
1017            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1018            mapped_at_creation: false,
1019        });
1020        let ubo_v = device.create_buffer(&wgpu::BufferDescriptor {
1021            label: Some("llimphi-blur-ubo-v"),
1022            size: BLUR_UBO_SIZE,
1023            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1024            mapped_at_creation: false,
1025        });
1026        queue.write_buffer(&ubo_h, 0, bytemuck_cast(&ubo_h_data));
1027        queue.write_buffer(&ubo_v, 0, bytemuck_cast(&ubo_v_data));
1028
1029        // Pass 1: target → scratch (horizontal).
1030        let bg_h = device.create_bind_group(&wgpu::BindGroupDescriptor {
1031            label: Some("llimphi-blur-bg-h"),
1032            layout: &self.bind_layout,
1033            entries: &[
1034                wgpu::BindGroupEntry {
1035                    binding: 0,
1036                    resource: wgpu::BindingResource::TextureView(target),
1037                },
1038                wgpu::BindGroupEntry {
1039                    binding: 1,
1040                    resource: wgpu::BindingResource::Sampler(&self.sampler),
1041                },
1042                wgpu::BindGroupEntry {
1043                    binding: 2,
1044                    resource: ubo_h.as_entire_binding(),
1045                },
1046            ],
1047        });
1048        {
1049            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1050                label: Some("llimphi-blur-pass-h"),
1051                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1052                    view: scratch_view,
1053                    resolve_target: None,
1054                    depth_slice: None,
1055                    ops: wgpu::Operations {
1056                        // No nos importa qué hay fuera del scissor: el segundo
1057                        // pase sólo lee dentro del scissor también.
1058                        load: wgpu::LoadOp::Load,
1059                        store: wgpu::StoreOp::Store,
1060                    },
1061                })],
1062                depth_stencil_attachment: None,
1063                timestamp_writes: None,
1064                occlusion_query_set: None,
1065            });
1066            pass.set_pipeline(&self.pipeline);
1067            pass.set_bind_group(0, &bg_h, &[]);
1068            pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
1069            pass.draw(0..3, 0..1);
1070        }
1071
1072        // Pass 2: scratch → target (vertical), preservando lo fuera del scissor.
1073        let bg_v = device.create_bind_group(&wgpu::BindGroupDescriptor {
1074            label: Some("llimphi-blur-bg-v"),
1075            layout: &self.bind_layout,
1076            entries: &[
1077                wgpu::BindGroupEntry {
1078                    binding: 0,
1079                    resource: wgpu::BindingResource::TextureView(scratch_view),
1080                },
1081                wgpu::BindGroupEntry {
1082                    binding: 1,
1083                    resource: wgpu::BindingResource::Sampler(&self.sampler),
1084                },
1085                wgpu::BindGroupEntry {
1086                    binding: 2,
1087                    resource: ubo_v.as_entire_binding(),
1088                },
1089            ],
1090        });
1091        {
1092            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1093                label: Some("llimphi-blur-pass-v"),
1094                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1095                    view: target,
1096                    resolve_target: None,
1097                    depth_slice: None,
1098                    ops: wgpu::Operations {
1099                        load: wgpu::LoadOp::Load,
1100                        store: wgpu::StoreOp::Store,
1101                    },
1102                })],
1103                depth_stencil_attachment: None,
1104                timestamp_writes: None,
1105                occlusion_query_set: None,
1106            });
1107            pass.set_pipeline(&self.pipeline);
1108            pass.set_bind_group(0, &bg_v, &[]);
1109            pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
1110            pass.draw(0..3, 0..1);
1111        }
1112    }
1113}
1114
1115/// Aplica una **matriz de color 4×5** (CSS `filter: brightness/contrast/
1116/// grayscale/sepia/saturate/invert/hue-rotate/opacity`) sobre un rect de la
1117/// intermediate. Espejo de [`BlurCompositor`] pero con un fragment shader que
1118/// multiplica cada píxel por la matriz: `out = M·rgba + bias`, clampeado a
1119/// `[0,1]`. Dos pases (target→scratch aplicando la matriz, scratch→target
1120/// copia identidad) por la misma razón que el blur: un render pass no puede
1121/// leer y escribir la misma textura. Fase 7.1233.
1122pub struct ColorFilterCompositor {
1123    pipeline: wgpu::RenderPipeline,
1124    sampler: wgpu::Sampler,
1125    bind_layout: wgpu::BindGroupLayout,
1126    scratch: Option<BlurScratch>,
1127}
1128
1129/// UBO de la matriz de color. 5 `vec4` (filas R/G/B/A + bias) = 80 bytes,
1130/// múltiplo de 16. Debe coincidir con `ColorParams` del WGSL.
1131#[repr(C)]
1132#[derive(Clone, Copy)]
1133struct ColorUniforms {
1134    r: [f32; 4],
1135    g: [f32; 4],
1136    b: [f32; 4],
1137    a: [f32; 4],
1138    bias: [f32; 4],
1139}
1140
1141const COLOR_UBO_SIZE: u64 = std::mem::size_of::<ColorUniforms>() as u64;
1142
1143/// La matriz identidad (copia sin cambios), usada en el segundo pase.
1144const COLOR_IDENTITY: ColorUniforms = ColorUniforms {
1145    r: [1.0, 0.0, 0.0, 0.0],
1146    g: [0.0, 1.0, 0.0, 0.0],
1147    b: [0.0, 0.0, 1.0, 0.0],
1148    a: [0.0, 0.0, 0.0, 1.0],
1149    bias: [0.0, 0.0, 0.0, 0.0],
1150};
1151
1152impl ColorFilterCompositor {
1153    pub fn new(device: &wgpu::Device) -> Self {
1154        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
1155            label: Some("llimphi-color-filter-shader"),
1156            source: wgpu::ShaderSource::Wgsl(COLOR_WGSL.into()),
1157        });
1158        let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
1159            label: Some("llimphi-color-filter-bgl"),
1160            entries: &[
1161                wgpu::BindGroupLayoutEntry {
1162                    binding: 0,
1163                    visibility: wgpu::ShaderStages::FRAGMENT,
1164                    ty: wgpu::BindingType::Texture {
1165                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
1166                        view_dimension: wgpu::TextureViewDimension::D2,
1167                        multisampled: false,
1168                    },
1169                    count: None,
1170                },
1171                wgpu::BindGroupLayoutEntry {
1172                    binding: 1,
1173                    visibility: wgpu::ShaderStages::FRAGMENT,
1174                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
1175                    count: None,
1176                },
1177                wgpu::BindGroupLayoutEntry {
1178                    binding: 2,
1179                    visibility: wgpu::ShaderStages::FRAGMENT,
1180                    ty: wgpu::BindingType::Buffer {
1181                        ty: wgpu::BufferBindingType::Uniform,
1182                        has_dynamic_offset: false,
1183                        min_binding_size: None,
1184                    },
1185                    count: None,
1186                },
1187            ],
1188        });
1189        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1190            label: Some("llimphi-color-filter-pl"),
1191            bind_group_layouts: &[&bind_layout],
1192            push_constant_ranges: &[],
1193        });
1194        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
1195            label: Some("llimphi-color-filter-pipe"),
1196            layout: Some(&pipeline_layout),
1197            vertex: wgpu::VertexState {
1198                module: &shader,
1199                entry_point: Some("vs"),
1200                buffers: &[],
1201                compilation_options: Default::default(),
1202            },
1203            fragment: Some(wgpu::FragmentState {
1204                module: &shader,
1205                entry_point: Some("fs"),
1206                targets: &[Some(wgpu::ColorTargetState {
1207                    format: INTERMEDIATE_FORMAT,
1208                    // OVERWRITE el rect, igual que el blur — el resultado de la
1209                    // matriz reemplaza el píxel.
1210                    blend: None,
1211                    write_mask: wgpu::ColorWrites::ALL,
1212                })],
1213                compilation_options: Default::default(),
1214            }),
1215            primitive: wgpu::PrimitiveState::default(),
1216            depth_stencil: None,
1217            multisample: wgpu::MultisampleState::default(),
1218            multiview: None,
1219            cache: None,
1220        });
1221        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
1222            label: Some("llimphi-color-filter-sampler"),
1223            address_mode_u: wgpu::AddressMode::ClampToEdge,
1224            address_mode_v: wgpu::AddressMode::ClampToEdge,
1225            address_mode_w: wgpu::AddressMode::ClampToEdge,
1226            mag_filter: wgpu::FilterMode::Nearest,
1227            min_filter: wgpu::FilterMode::Nearest,
1228            mipmap_filter: wgpu::FilterMode::Nearest,
1229            ..Default::default()
1230        });
1231        ColorFilterCompositor {
1232            pipeline,
1233            sampler,
1234            bind_layout,
1235            scratch: None,
1236        }
1237    }
1238
1239    /// Aplica la matriz de color `matrix` (4×5 row-major: por fila
1240    /// `[c0, c1, c2, c3, bias]`, salida R/G/B/A) sobre `target` en el rect dado
1241    /// (coords pixel del viewport). Fuera del viewport no hace nada. Usa un
1242    /// scratch del tamaño del viewport (lazy, reusado entre frames).
1243    pub fn apply(
1244        &mut self,
1245        device: &wgpu::Device,
1246        queue: &wgpu::Queue,
1247        encoder: &mut wgpu::CommandEncoder,
1248        target: &wgpu::TextureView,
1249        viewport: (u32, u32),
1250        rect: (f32, f32, f32, f32),
1251        matrix: [f32; 20],
1252    ) {
1253        let (vw, vh) = viewport;
1254        if vw == 0 || vh == 0 {
1255            return;
1256        }
1257        let (rx, ry, rw, rh) = rect;
1258        let x0 = rx.max(0.0) as u32;
1259        let y0 = ry.max(0.0) as u32;
1260        let x1 = (rx + rw).min(vw as f32).max(0.0) as u32;
1261        let y1 = (ry + rh).min(vh as f32).max(0.0) as u32;
1262        if x1 <= x0 || y1 <= y0 {
1263            return;
1264        }
1265        let scissor = (x0, y0, x1 - x0, y1 - y0);
1266
1267        let need_new = match &self.scratch {
1268            Some(s) => s.width != vw || s.height != vh,
1269            None => true,
1270        };
1271        if need_new {
1272            let texture = device.create_texture(&wgpu::TextureDescriptor {
1273                label: Some("llimphi-color-filter-scratch"),
1274                size: wgpu::Extent3d {
1275                    width: vw,
1276                    height: vh,
1277                    depth_or_array_layers: 1,
1278                },
1279                mip_level_count: 1,
1280                sample_count: 1,
1281                dimension: wgpu::TextureDimension::D2,
1282                format: INTERMEDIATE_FORMAT,
1283                usage: wgpu::TextureUsages::TEXTURE_BINDING
1284                    | wgpu::TextureUsages::RENDER_ATTACHMENT,
1285                view_formats: &[],
1286            });
1287            let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
1288            self.scratch = Some(BlurScratch {
1289                _texture: texture,
1290                view,
1291                width: vw,
1292                height: vh,
1293            });
1294        }
1295        let scratch_view = &self.scratch.as_ref().expect("scratch creado arriba").view;
1296
1297        // El [f32;20] viene por filas de 5 (`[c0,c1,c2,c3,bias]`); lo partimos
1298        // en 4 vec4 de coeficientes + un vec4 de bias para el UBO.
1299        let apply = ColorUniforms {
1300            r: [matrix[0], matrix[1], matrix[2], matrix[3]],
1301            g: [matrix[5], matrix[6], matrix[7], matrix[8]],
1302            b: [matrix[10], matrix[11], matrix[12], matrix[13]],
1303            a: [matrix[15], matrix[16], matrix[17], matrix[18]],
1304            bias: [matrix[4], matrix[9], matrix[14], matrix[19]],
1305        };
1306        // UBOs **por llamada**: varias `apply` en el mismo encoder/submit
1307        // comparten cola; `write_buffer` se aplica una vez antes de los command
1308        // buffers (gana el último valor escrito), así que un UBO compartido haría
1309        // que todas las pasadas leyeran la última matriz. Buffers frescos por
1310        // llamada evitan ese alias (80 bytes c/u, despreciable).
1311        let ubo_apply = device.create_buffer(&wgpu::BufferDescriptor {
1312            label: Some("llimphi-color-filter-ubo-apply"),
1313            size: COLOR_UBO_SIZE,
1314            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1315            mapped_at_creation: false,
1316        });
1317        let ubo_copy = device.create_buffer(&wgpu::BufferDescriptor {
1318            label: Some("llimphi-color-filter-ubo-copy"),
1319            size: COLOR_UBO_SIZE,
1320            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1321            mapped_at_creation: false,
1322        });
1323        queue.write_buffer(&ubo_apply, 0, bytemuck_cast(&apply));
1324        queue.write_buffer(&ubo_copy, 0, bytemuck_cast(&COLOR_IDENTITY));
1325
1326        // Pass 1: target → scratch (aplica la matriz).
1327        let bg_apply = device.create_bind_group(&wgpu::BindGroupDescriptor {
1328            label: Some("llimphi-color-filter-bg-apply"),
1329            layout: &self.bind_layout,
1330            entries: &[
1331                wgpu::BindGroupEntry {
1332                    binding: 0,
1333                    resource: wgpu::BindingResource::TextureView(target),
1334                },
1335                wgpu::BindGroupEntry {
1336                    binding: 1,
1337                    resource: wgpu::BindingResource::Sampler(&self.sampler),
1338                },
1339                wgpu::BindGroupEntry {
1340                    binding: 2,
1341                    resource: ubo_apply.as_entire_binding(),
1342                },
1343            ],
1344        });
1345        {
1346            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1347                label: Some("llimphi-color-filter-pass-apply"),
1348                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1349                    view: scratch_view,
1350                    resolve_target: None,
1351                    depth_slice: None,
1352                    ops: wgpu::Operations {
1353                        load: wgpu::LoadOp::Load,
1354                        store: wgpu::StoreOp::Store,
1355                    },
1356                })],
1357                depth_stencil_attachment: None,
1358                timestamp_writes: None,
1359                occlusion_query_set: None,
1360            });
1361            pass.set_pipeline(&self.pipeline);
1362            pass.set_bind_group(0, &bg_apply, &[]);
1363            pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
1364            pass.draw(0..3, 0..1);
1365        }
1366
1367        // Pass 2: scratch → target (copia identidad), preservando lo de afuera.
1368        let bg_copy = device.create_bind_group(&wgpu::BindGroupDescriptor {
1369            label: Some("llimphi-color-filter-bg-copy"),
1370            layout: &self.bind_layout,
1371            entries: &[
1372                wgpu::BindGroupEntry {
1373                    binding: 0,
1374                    resource: wgpu::BindingResource::TextureView(scratch_view),
1375                },
1376                wgpu::BindGroupEntry {
1377                    binding: 1,
1378                    resource: wgpu::BindingResource::Sampler(&self.sampler),
1379                },
1380                wgpu::BindGroupEntry {
1381                    binding: 2,
1382                    resource: ubo_copy.as_entire_binding(),
1383                },
1384            ],
1385        });
1386        {
1387            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1388                label: Some("llimphi-color-filter-pass-copy"),
1389                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1390                    view: target,
1391                    resolve_target: None,
1392                    depth_slice: None,
1393                    ops: wgpu::Operations {
1394                        load: wgpu::LoadOp::Load,
1395                        store: wgpu::StoreOp::Store,
1396                    },
1397                })],
1398                depth_stencil_attachment: None,
1399                timestamp_writes: None,
1400                occlusion_query_set: None,
1401            });
1402            pass.set_pipeline(&self.pipeline);
1403            pass.set_bind_group(0, &bg_copy, &[]);
1404            pass.set_scissor_rect(scissor.0, scissor.1, scissor.2, scissor.3);
1405            pass.draw(0..3, 0..1);
1406        }
1407    }
1408}
1409
1410/// "bytemuck" minimal sin dep: convierte `&T` a `&[u8]`. Sólo para POD repr(C)
1411/// — usado para escribir los UBOs del blur con `queue.write_buffer`.
1412fn bytemuck_cast<T: Copy>(v: &T) -> &[u8] {
1413    unsafe {
1414        std::slice::from_raw_parts(
1415            v as *const T as *const u8,
1416            std::mem::size_of::<T>(),
1417        )
1418    }
1419}
1420
1421/// Separable Gaussian, una dirección por pase. El vs es el mismo triángulo
1422/// grande del overlay; el fs samplea `2*radius+1` taps a lo largo de
1423/// `direction*pixel_size`. Pesos `exp(-i²/2σ²)` normalizados por la suma —
1424/// independiente del radius por si quedó cortada la cola.
1425const BLUR_WGSL: &str = r#"
1426struct VsOut {
1427    @builtin(position) pos: vec4<f32>,
1428    @location(0) uv: vec2<f32>,
1429};
1430
1431@vertex
1432fn vs(@builtin(vertex_index) vi: u32) -> VsOut {
1433    var corners = array<vec2<f32>, 3>(
1434        vec2<f32>(-1.0, -1.0),
1435        vec2<f32>( 3.0, -1.0),
1436        vec2<f32>(-1.0,  3.0),
1437    );
1438    let xy = corners[vi];
1439    var out: VsOut;
1440    out.pos = vec4<f32>(xy, 0.0, 1.0);
1441    out.uv = vec2<f32>((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5);
1442    return out;
1443}
1444
1445struct BlurParams {
1446    direction: vec2<f32>,
1447    pixel_size: vec2<f32>,
1448    sigma: f32,
1449    radius: f32,
1450    _pad: vec2<f32>,
1451};
1452
1453@group(0) @binding(0) var src_tex: texture_2d<f32>;
1454@group(0) @binding(1) var src_samp: sampler;
1455@group(0) @binding(2) var<uniform> params: BlurParams;
1456
1457@fragment
1458fn fs(in: VsOut) -> @location(0) vec4<f32> {
1459    let dir = params.direction * params.pixel_size;
1460    let r = i32(params.radius);
1461    let two_sigma_sq = 2.0 * params.sigma * params.sigma;
1462    var acc = vec4<f32>(0.0);
1463    var weight_sum = 0.0;
1464    for (var i = -r; i <= r; i = i + 1) {
1465        let fi = f32(i);
1466        let w = exp(-(fi * fi) / two_sigma_sq);
1467        acc = acc + textureSample(src_tex, src_samp, in.uv + dir * fi) * w;
1468        weight_sum = weight_sum + w;
1469    }
1470    return acc / weight_sum;
1471}
1472"#;
1473
1474/// Matriz de color 4×5: `out = M·rgba + bias`, clampeado a `[0,1]`. El vs es el
1475/// mismo triángulo grande; el fs hace 4 `dot` (una fila por canal) más el bias.
1476const COLOR_WGSL: &str = r#"
1477struct VsOut {
1478    @builtin(position) pos: vec4<f32>,
1479    @location(0) uv: vec2<f32>,
1480};
1481
1482@vertex
1483fn vs(@builtin(vertex_index) vi: u32) -> VsOut {
1484    var corners = array<vec2<f32>, 3>(
1485        vec2<f32>(-1.0, -1.0),
1486        vec2<f32>( 3.0, -1.0),
1487        vec2<f32>(-1.0,  3.0),
1488    );
1489    let xy = corners[vi];
1490    var out: VsOut;
1491    out.pos = vec4<f32>(xy, 0.0, 1.0);
1492    out.uv = vec2<f32>((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5);
1493    return out;
1494}
1495
1496struct ColorParams {
1497    r: vec4<f32>,
1498    g: vec4<f32>,
1499    b: vec4<f32>,
1500    a: vec4<f32>,
1501    bias: vec4<f32>,
1502};
1503
1504@group(0) @binding(0) var src_tex: texture_2d<f32>;
1505@group(0) @binding(1) var src_samp: sampler;
1506@group(0) @binding(2) var<uniform> params: ColorParams;
1507
1508@fragment
1509fn fs(in: VsOut) -> @location(0) vec4<f32> {
1510    let c = textureSample(src_tex, src_samp, in.uv);
1511    var o: vec4<f32>;
1512    o.r = dot(params.r, c) + params.bias.r;
1513    o.g = dot(params.g, c) + params.bias.g;
1514    o.b = dot(params.b, c) + params.bias.b;
1515    o.a = dot(params.a, c) + params.bias.a;
1516    return clamp(o, vec4<f32>(0.0), vec4<f32>(1.0));
1517}
1518"#;
1519
1520impl Surface for WinitSurface {
1521    fn size(&self) -> (u32, u32) {
1522        (self.config.width, self.config.height)
1523    }
1524
1525    fn resize(&mut self, width: u32, height: u32) {
1526        self.config.width = width.max(1);
1527        self.config.height = height.max(1);
1528        self.surface.configure(&self.device, &self.config);
1529        let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height);
1530        self.intermediate = tex;
1531        self.intermediate_view = view;
1532        let (otex, oview) =
1533            create_intermediate(&self.device, self.config.width, self.config.height);
1534        self.overlay = otex;
1535        self.overlay_view = oview;
1536    }
1537
1538    fn acquire(&mut self) -> Result<Frame, SurfaceError> {
1539        let texture = self.surface.get_current_texture().map_err(|e| match e {
1540            wgpu::SurfaceError::Lost => SurfaceError::Lost,
1541            wgpu::SurfaceError::Outdated => SurfaceError::Outdated,
1542            wgpu::SurfaceError::OutOfMemory => SurfaceError::OutOfMemory,
1543            wgpu::SurfaceError::Timeout => SurfaceError::Timeout,
1544            other => SurfaceError::Other(format!("{other:?}")),
1545        })?;
1546        let surface_view = texture
1547            .texture
1548            .create_view(&wgpu::TextureViewDescriptor::default());
1549        // `TextureView` envuelve un Arc — clonar es atomic-incref, no
1550        // recrea la vista. La intermedia sólo cambia en `resize`.
1551        Ok(Frame {
1552            surface_texture: texture,
1553            surface_view,
1554            intermediate_view: self.intermediate_view.clone(),
1555            overlay_view: self.overlay_view.clone(),
1556            width: self.config.width,
1557            height: self.config.height,
1558        })
1559    }
1560
1561    fn present(&mut self, frame: Frame, hal: &Hal) {
1562        let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1563            label: Some("llimphi-blit"),
1564        });
1565        self.blitter.copy(
1566            &hal.device,
1567            &mut encoder,
1568            &frame.intermediate_view,
1569            &frame.surface_view,
1570        );
1571        hal.queue.submit(std::iter::once(encoder.finish()));
1572        frame.surface_texture.present();
1573    }
1574}