tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
refresh: stack al día (vello 0.7 / wgpu 27 / parley 0.6) + motor 3D voxel
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}