tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
feat: llimphi standalone — framework UI soberano extraído del monorepo
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 Some(a) => (primary, a),
149 None => {
150 let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
151 let a = all.request_adapter(&opts).await.ok_or(HalError::NoAdapter)?;
152 (all, a)
153 }
154 };
155 // `Limits::default()` cubre los 5 storage buffers/stage que vello
156 // necesita. `downlevel_defaults()` solo expone 4 y rompe el raster.
157 // Si el adapter no lo aguanta, `using_resolution` recorta lo recortable
158 // (texturas/buffers grandes) preservando los conteos mínimos.
159 let limits = wgpu::Limits::default().using_resolution(adapter.limits());
160 let (device, queue) = adapter
161 .request_device(
162 &wgpu::DeviceDescriptor {
163 label: Some("llimphi-hal-device"),
164 required_features: wgpu::Features::empty(),
165 required_limits: limits,
166 memory_hints: wgpu::MemoryHints::Performance,
167 },
168 None,
169 )
170 .await
171 .map_err(|e| HalError::RequestDevice(e.to_string()))?;
172 Ok(Self {
173 instance,
174 adapter,
175 device,
176 queue,
177 })
178 }
179
180 /// Construye el `Hal` **y** una [`RawSurface`] a la vez, eligiendo el adaptador
181 /// **compatible con esa surface** — el dispositivo que el compositor sabe
182 /// presentar. Es el camino correcto para el backend layer-shell de `pata`.
183 ///
184 /// El problema que resuelve: en sistemas multi-GPU (Optimus), pedir el
185 /// adaptador sin pista de surface (`new(None)` con `HighPerformance`) puede
186 /// elegir la dGPU mientras el compositor compone en la iGPU → los dmabuf
187 /// cruzan dispositivos y `get_capabilities` devuelve 0 formatos (la surface
188 /// "no expone formatos"). Pasar `compatible_surface` ata el adaptador al
189 /// dispositivo del compositor. Como la surface hace falta ANTES de pedir el
190 /// adaptador, y `new` crea la instancia internamente, este constructor une los
191 /// dos pasos.
192 ///
193 /// `make_target` reconstruye el `SurfaceTargetUnsafe` cada vez que se llama
194 /// (los `RawHandle` son `Copy`): `create_surface_unsafe` consume el target y
195 /// puede que probemos dos instancias (PRIMARY y, si no hay adaptador, todos
196 /// los backends — el GL de Mesa/Wayland revienta en teardown, por eso PRIMARY
197 /// primero, igual que [`Hal::new`]).
198 ///
199 /// # Safety
200 /// Los handles que produce `make_target` deben apuntar a objetos Wayland/…
201 /// vivos durante toda la vida de la `RawSurface` devuelta.
202 pub async unsafe fn new_for_raw_surface(
203 make_target: impl Fn() -> wgpu::SurfaceTargetUnsafe,
204 width: u32,
205 height: u32,
206 ) -> Result<(Self, RawSurface), HalError> {
207 // PRIMARY (Vulkan/Metal/DX12) primero; si no hay adaptador compatible, a
208 // todos los backends recreando instancia y surface.
209 let primary = wgpu::Instance::new(&wgpu::InstanceDescriptor {
210 backends: wgpu::Backends::PRIMARY,
211 ..Default::default()
212 });
213 let prim_surface = unsafe { primary.create_surface_unsafe(make_target()) }
214 .map_err(|e| HalError::CreateSurface(e.to_string()))?;
215 let prim_adapter = primary
216 .request_adapter(&wgpu::RequestAdapterOptions {
217 power_preference: wgpu::PowerPreference::HighPerformance,
218 force_fallback_adapter: false,
219 compatible_surface: Some(&prim_surface),
220 })
221 .await;
222 let (instance, adapter, wgpu_surface) = match prim_adapter {
223 Some(a) => (primary, a, prim_surface),
224 None => {
225 let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
226 let surface = unsafe { all.create_surface_unsafe(make_target()) }
227 .map_err(|e| HalError::CreateSurface(e.to_string()))?;
228 let a = all
229 .request_adapter(&wgpu::RequestAdapterOptions {
230 power_preference: wgpu::PowerPreference::HighPerformance,
231 force_fallback_adapter: false,
232 compatible_surface: Some(&surface),
233 })
234 .await
235 .ok_or(HalError::NoAdapter)?;
236 (all, a, surface)
237 }
238 };
239 let limits = wgpu::Limits::default().using_resolution(adapter.limits());
240 let (device, queue) = adapter
241 .request_device(
242 &wgpu::DeviceDescriptor {
243 label: Some("llimphi-hal-device"),
244 required_features: wgpu::Features::empty(),
245 required_limits: limits,
246 memory_hints: wgpu::MemoryHints::Performance,
247 },
248 None,
249 )
250 .await
251 .map_err(|e| HalError::RequestDevice(e.to_string()))?;
252 let hal = Self {
253 instance,
254 adapter,
255 device,
256 queue,
257 };
258 let surface = RawSurface::from_surface(&hal, wgpu_surface, width, height)?;
259 Ok((hal, surface))
260 }
261}
262
263/// Surface basada en `winit::window::Window`. Mantiene una textura
264/// intermedia `Rgba8Unorm` con storage binding (donde pinta vello) y
265/// un `TextureBlitter` que la copia al swapchain al presentar.
266pub struct WinitSurface {
267 _window: Arc<Window>,
268 surface: wgpu::Surface<'static>,
269 config: wgpu::SurfaceConfiguration,
270 device: wgpu::Device,
271 intermediate: wgpu::Texture,
272 intermediate_view: wgpu::TextureView,
273 /// Textura de la capa de overlay (ver [`Frame::overlay_view`]).
274 overlay: wgpu::Texture,
275 overlay_view: wgpu::TextureView,
276 blitter: wgpu::util::TextureBlitter,
277}
278
279const INTERMEDIATE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
280
281impl WinitSurface {
282 /// Constructor "feliz": crea la `wgpu::Surface` internamente.
283 /// Conveniente en desktop donde la secuencia normal es
284 /// `Hal::new(None)` → `WinitSurface::new(hal, window)`. **En Android
285 /// usar [`WinitSurface::from_surface`]** — allí la surface debe
286 /// existir antes del `request_adapter(compatible_surface=Some(...))`,
287 /// y crearla dos veces sobre la misma `ANativeWindow` falla con
288 /// `ERROR_NATIVE_WINDOW_IN_USE_KHR`.
289 pub fn new(hal: &Hal, window: Arc<Window>) -> Result<Self, HalError> {
290 let surface = hal
291 .instance
292 .create_surface(window.clone())
293 .map_err(|e| HalError::CreateSurface(e.to_string()))?;
294 Self::from_surface(hal, window, surface)
295 }
296
297 /// Constructor reutilizable: arma el `WinitSurface` envolviendo una
298 /// `wgpu::Surface` ya creada por el caller. Necesario en Android
299 /// porque el orden allí es:
300 ///
301 /// 1. `instance.create_surface(window)`
302 /// 2. `instance.request_adapter(compatible_surface=Some(&surface))`
303 /// 3. `adapter.request_device(...)`
304 /// 4. `WinitSurface::from_surface(hal, window, surface)`
305 ///
306 /// — no se puede dropear la surface entre 2 y 4 ni recrearla, porque
307 /// Android reserva la `ANativeWindow` por VkSurface y rechaza un
308 /// segundo `vkCreateAndroidSurfaceKHR` sobre la misma ventana.
309 pub fn from_surface(
310 hal: &Hal,
311 window: Arc<Window>,
312 surface: wgpu::Surface<'static>,
313 ) -> Result<Self, HalError> {
314 let size = window.inner_size();
315 let caps = surface.get_capabilities(&hal.adapter);
316 // Preferimos Bgra8Unorm o Rgba8Unorm (no sRGB) para que el blit
317 // desde la intermedia lineal preserve los valores tal cual.
318 let format = caps
319 .formats
320 .iter()
321 .copied()
322 .find(|f| matches!(f, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm))
323 .unwrap_or(caps.formats[0]);
324 let config = wgpu::SurfaceConfiguration {
325 // El swapchain solo necesita render-attachment: vello no escribe
326 // directo, escribe a la intermedia y luego se blittea.
327 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
328 format,
329 width: size.width.max(1),
330 height: size.height.max(1),
331 present_mode: choose_present_mode(&caps),
332 desired_maximum_frame_latency: 2,
333 alpha_mode: caps.alpha_modes[0],
334 view_formats: vec![],
335 };
336 surface.configure(&hal.device, &config);
337 let (intermediate, intermediate_view) =
338 create_intermediate(&hal.device, config.width, config.height);
339 let (overlay, overlay_view) =
340 create_intermediate(&hal.device, config.width, config.height);
341 let blitter = wgpu::util::TextureBlitter::new(&hal.device, format);
342 Ok(Self {
343 _window: window,
344 surface,
345 config,
346 device: hal.device.clone(),
347 intermediate,
348 intermediate_view,
349 overlay,
350 overlay_view,
351 blitter,
352 })
353 }
354
355 pub fn format(&self) -> wgpu::TextureFormat {
356 self.config.format
357 }
358}
359
360/// Surface sobre una `wgpu::Surface` creada desde **handles raw** (sin
361/// `winit::Window`): la usa el backend `wlr-layer-shell` de `pata` para pintar
362/// en una *layer surface* de Wayland (barras/paneles al nivel de eww/waybar).
363/// Misma mecánica que [`WinitSurface`] —intermedia `Rgba8Unorm` + blit al
364/// swapchain— pero el tamaño se pasa explícito porque no hay ventana que
365/// consultar. La `wgpu::Surface` la crea el caller (típicamente con
366/// `instance.create_surface_unsafe` desde los punteros `wl_display`/`wl_surface`).
367pub struct RawSurface {
368 surface: wgpu::Surface<'static>,
369 config: wgpu::SurfaceConfiguration,
370 device: wgpu::Device,
371 intermediate: wgpu::Texture,
372 intermediate_view: wgpu::TextureView,
373 overlay: wgpu::Texture,
374 overlay_view: wgpu::TextureView,
375 blitter: wgpu::util::TextureBlitter,
376}
377
378impl RawSurface {
379 /// Envuelve una `wgpu::Surface` ya creada, con el tamaño físico inicial.
380 pub fn from_surface(
381 hal: &Hal,
382 surface: wgpu::Surface<'static>,
383 width: u32,
384 height: u32,
385 ) -> Result<Self, HalError> {
386 let caps = surface.get_capabilities(&hal.adapter);
387 let info = hal.adapter.get_info();
388 // Si la superficie no expone formatos, el compositor no la soporta por
389 // este backend (Vulkan/GL WSI): error claro en vez de un panic por
390 // indexar `formats[0]` sobre una lista vacía.
391 let format = match caps
392 .formats
393 .iter()
394 .copied()
395 .find(|f| matches!(f, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm))
396 .or_else(|| caps.formats.first().copied())
397 {
398 Some(f) => f,
399 None => {
400 return Err(HalError::CreateSurface(format!(
401 "la superficie no expone formatos (adapter {:?}/{:?}): el compositor no la soporta por {:?} WSI",
402 info.backend, info.device_type, info.backend
403 )))
404 }
405 };
406 let alpha_mode = caps
407 .alpha_modes
408 .first()
409 .copied()
410 .unwrap_or(wgpu::CompositeAlphaMode::Auto);
411 let config = wgpu::SurfaceConfiguration {
412 usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
413 format,
414 width: width.max(1),
415 height: height.max(1),
416 present_mode: choose_present_mode(&caps),
417 desired_maximum_frame_latency: 2,
418 alpha_mode,
419 view_formats: vec![],
420 };
421 surface.configure(&hal.device, &config);
422 let (intermediate, intermediate_view) =
423 create_intermediate(&hal.device, config.width, config.height);
424 let (overlay, overlay_view) =
425 create_intermediate(&hal.device, config.width, config.height);
426 let blitter = wgpu::util::TextureBlitter::new(&hal.device, format);
427 Ok(Self {
428 surface,
429 config,
430 device: hal.device.clone(),
431 intermediate,
432 intermediate_view,
433 overlay,
434 overlay_view,
435 blitter,
436 })
437 }
438
439 pub fn format(&self) -> wgpu::TextureFormat {
440 self.config.format
441 }
442}
443
444impl Surface for RawSurface {
445 fn size(&self) -> (u32, u32) {
446 (self.config.width, self.config.height)
447 }
448
449 fn resize(&mut self, width: u32, height: u32) {
450 let (w, h) = (width.max(1), height.max(1));
451 // Sin cambio de tamaño NO reconfiguramos. El backend layer-shell de `pata`
452 // llama a `resize` en cada cuadro (no tiene eventos de resize como winit);
453 // reconfigurar el swapchain por cuadro lo reconstruye una y otra vez, y en
454 // Vulkan WSI eso **destruye el `wl_buffer` recién presentado antes de que el
455 // compositor lo componga** — wlroots lo tolera, smithay (mirada) no, y la
456 // superficie queda en negro (el compositor ve `buffer=None`).
457 if self.config.width == w && self.config.height == h {
458 return;
459 }
460 self.config.width = w;
461 self.config.height = h;
462 self.surface.configure(&self.device, &self.config);
463 let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height);
464 self.intermediate = tex;
465 self.intermediate_view = view;
466 let (otex, oview) =
467 create_intermediate(&self.device, self.config.width, self.config.height);
468 self.overlay = otex;
469 self.overlay_view = oview;
470 }
471
472 fn acquire(&mut self) -> Result<Frame, SurfaceError> {
473 let texture = match self.surface.get_current_texture() {
474 Ok(t) => t,
475 // El backend layer-shell no tiene un evento de resize que reconfigure
476 // el swapchain; si quedó obsoleto/perdido, lo reconstruimos aquí mismo
477 // y reintentamos una vez. Sin esto el panel quedaría en negro para
478 // siempre tras el primer `Outdated`.
479 Err(e @ (wgpu::SurfaceError::Outdated | wgpu::SurfaceError::Lost)) => {
480 self.surface.configure(&self.device, &self.config);
481 self.surface.get_current_texture().map_err(|_| match e {
482 wgpu::SurfaceError::Lost => SurfaceError::Lost,
483 _ => SurfaceError::Outdated,
484 })?
485 }
486 Err(wgpu::SurfaceError::OutOfMemory) => return Err(SurfaceError::OutOfMemory),
487 Err(wgpu::SurfaceError::Timeout) => return Err(SurfaceError::Timeout),
488 Err(other) => return Err(SurfaceError::Other(format!("{other:?}"))),
489 };
490 let surface_view = texture
491 .texture
492 .create_view(&wgpu::TextureViewDescriptor::default());
493 Ok(Frame {
494 surface_texture: texture,
495 surface_view,
496 intermediate_view: self.intermediate_view.clone(),
497 overlay_view: self.overlay_view.clone(),
498 width: self.config.width,
499 height: self.config.height,
500 })
501 }
502
503 fn present(&mut self, frame: Frame, hal: &Hal) {
504 let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
505 label: Some("llimphi-blit-raw"),
506 });
507 self.blitter.copy(
508 &hal.device,
509 &mut encoder,
510 &frame.intermediate_view,
511 &frame.surface_view,
512 );
513 hal.queue.submit(std::iter::once(encoder.finish()));
514 frame.surface_texture.present();
515 }
516}
517
518/// Elige el modo de presentación del swapchain.
519///
520/// Default: **Mailbox** si el driver lo expone, sino **Fifo**. La razón es
521/// el cuelgue observado en las apps Llimphi (investigación 2026-05-30): con
522/// `Fifo`/`AutoVsync`, `surface.get_current_texture()` **bloquea** esperando
523/// el frame-callback del compositor Wayland — si el compositor no suelta un
524/// buffer, el hilo del UI queda dormido (CPU baja, deadlock aparente).
525/// `Mailbox` no bloquea (triple-buffer, descarta frames viejos), así que el
526/// loop nunca se queda esperando al compositor. `Fifo` está garantizado por
527/// spec como fallback.
528///
529/// Override por entorno para A/B sin recompilar (útil en la laptop con
530/// display real): `LLIMPHI_PRESENT_MODE = fifo | mailbox | immediate |
531/// fifo_relaxed`. Si el modo pedido no está soportado, se ignora y se aplica
532/// el default.
533fn choose_present_mode(caps: &wgpu::SurfaceCapabilities) -> wgpu::PresentMode {
534 use wgpu::PresentMode::{Fifo, FifoRelaxed, Immediate, Mailbox};
535 if let Ok(v) = std::env::var("LLIMPHI_PRESENT_MODE") {
536 let want = match v.trim().to_ascii_lowercase().as_str() {
537 "fifo" | "vsync" => Some(Fifo),
538 "fifo_relaxed" | "fiforelaxed" => Some(FifoRelaxed),
539 "mailbox" => Some(Mailbox),
540 "immediate" | "novsync" => Some(Immediate),
541 _ => None,
542 };
543 if let Some(m) = want {
544 if caps.present_modes.contains(&m) {
545 return m;
546 }
547 }
548 }
549 if caps.present_modes.contains(&Mailbox) {
550 Mailbox
551 } else {
552 Fifo
553 }
554}
555
556fn create_intermediate(
557 device: &wgpu::Device,
558 width: u32,
559 height: u32,
560) -> (wgpu::Texture, wgpu::TextureView) {
561 let texture = device.create_texture(&wgpu::TextureDescriptor {
562 label: Some("llimphi-intermediate"),
563 size: wgpu::Extent3d {
564 width,
565 height,
566 depth_or_array_layers: 1,
567 },
568 mip_level_count: 1,
569 sample_count: 1,
570 dimension: wgpu::TextureDimension::D2,
571 format: INTERMEDIATE_FORMAT,
572 // STORAGE_BINDING: vello escribe via compute shader.
573 // TEXTURE_BINDING: el blitter la lee como sampler source.
574 // RENDER_ATTACHMENT: render passes con clear-only (sin vello)
575 // también escriben acá — desktop drivers lo tolerían sin este
576 // flag, Adreno con validación estricta rechaza el frame.
577 usage: wgpu::TextureUsages::STORAGE_BINDING
578 | wgpu::TextureUsages::TEXTURE_BINDING
579 | wgpu::TextureUsages::RENDER_ATTACHMENT,
580 view_formats: &[],
581 });
582 let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
583 (texture, view)
584}
585
586/// Compositor de la capa de overlay: alpha-blittea una textura source (el
587/// overlay rasterizado por vello sobre fondo transparente) SOBRE una textura
588/// target (la intermedia, que ya tiene la UI principal + el video pintado por
589/// `gpu_paint`). Resuelve el z-order: sin esto, el blit de `gpu_paint` (video)
590/// queda encima de la capa vello del overlay y los menús se ven por debajo del
591/// video.
592///
593/// Es un pase de pantalla completa (triángulo) que samplea el source y lo
594/// emite con alpha-over. El factor de blend asume alpha **premultiplicado**
595/// (lo que produce vello); si en pantalla los menús se ven con halos oscuros o
596/// transparencia rara, exportar `LLIMPHI_OVERLAY_BLEND=straight` para usar
597/// alpha recto sin recompilar.
598pub struct OverlayCompositor {
599 pipeline: wgpu::RenderPipeline,
600 sampler: wgpu::Sampler,
601 bind_layout: wgpu::BindGroupLayout,
602}
603
604impl OverlayCompositor {
605 pub fn new(device: &wgpu::Device) -> Self {
606 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
607 label: Some("llimphi-overlay-composite"),
608 source: wgpu::ShaderSource::Wgsl(OVERLAY_COMPOSITE_WGSL.into()),
609 });
610 let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
611 label: Some("llimphi-overlay-bgl"),
612 entries: &[
613 wgpu::BindGroupLayoutEntry {
614 binding: 0,
615 visibility: wgpu::ShaderStages::FRAGMENT,
616 ty: wgpu::BindingType::Texture {
617 sample_type: wgpu::TextureSampleType::Float { filterable: true },
618 view_dimension: wgpu::TextureViewDimension::D2,
619 multisampled: false,
620 },
621 count: None,
622 },
623 wgpu::BindGroupLayoutEntry {
624 binding: 1,
625 visibility: wgpu::ShaderStages::FRAGMENT,
626 ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
627 count: None,
628 },
629 ],
630 });
631 let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
632 label: Some("llimphi-overlay-pl"),
633 bind_group_layouts: &[&bind_layout],
634 push_constant_ranges: &[],
635 });
636 // Alpha-over. `src_factor` distingue premultiplicado (One) de recto
637 // (SrcAlpha); el resto es siempre OneMinusSrcAlpha.
638 let straight = std::env::var("LLIMPHI_OVERLAY_BLEND")
639 .map(|v| v.trim().eq_ignore_ascii_case("straight"))
640 .unwrap_or(false);
641 let color_src = if straight {
642 wgpu::BlendFactor::SrcAlpha
643 } else {
644 wgpu::BlendFactor::One
645 };
646 let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
647 label: Some("llimphi-overlay-pipe"),
648 layout: Some(&pipeline_layout),
649 vertex: wgpu::VertexState {
650 module: &shader,
651 entry_point: Some("vs"),
652 buffers: &[],
653 compilation_options: Default::default(),
654 },
655 fragment: Some(wgpu::FragmentState {
656 module: &shader,
657 entry_point: Some("fs"),
658 targets: &[Some(wgpu::ColorTargetState {
659 format: INTERMEDIATE_FORMAT,
660 blend: Some(wgpu::BlendState {
661 color: wgpu::BlendComponent {
662 src_factor: color_src,
663 dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
664 operation: wgpu::BlendOperation::Add,
665 },
666 alpha: wgpu::BlendComponent {
667 src_factor: wgpu::BlendFactor::One,
668 dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
669 operation: wgpu::BlendOperation::Add,
670 },
671 }),
672 write_mask: wgpu::ColorWrites::ALL,
673 })],
674 compilation_options: Default::default(),
675 }),
676 primitive: wgpu::PrimitiveState::default(),
677 depth_stencil: None,
678 multisample: wgpu::MultisampleState::default(),
679 multiview: None,
680 cache: None,
681 });
682 let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
683 label: Some("llimphi-overlay-sampler"),
684 ..Default::default()
685 });
686 OverlayCompositor {
687 pipeline,
688 sampler,
689 bind_layout,
690 }
691 }
692
693 /// Compone `source` (overlay con fondo transparente) sobre `target` (la
694 /// intermedia), preservando el contenido previo del target (LoadOp::Load)
695 /// y mezclando con alpha. Graba un render pass en `encoder`.
696 pub fn composite(
697 &self,
698 device: &wgpu::Device,
699 encoder: &mut wgpu::CommandEncoder,
700 target: &wgpu::TextureView,
701 source: &wgpu::TextureView,
702 ) {
703 let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
704 label: Some("llimphi-overlay-bg"),
705 layout: &self.bind_layout,
706 entries: &[
707 wgpu::BindGroupEntry {
708 binding: 0,
709 resource: wgpu::BindingResource::TextureView(source),
710 },
711 wgpu::BindGroupEntry {
712 binding: 1,
713 resource: wgpu::BindingResource::Sampler(&self.sampler),
714 },
715 ],
716 });
717 let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
718 label: Some("llimphi-overlay-composite-pass"),
719 color_attachments: &[Some(wgpu::RenderPassColorAttachment {
720 view: target,
721 resolve_target: None,
722 ops: wgpu::Operations {
723 load: wgpu::LoadOp::Load,
724 store: wgpu::StoreOp::Store,
725 },
726 })],
727 depth_stencil_attachment: None,
728 timestamp_writes: None,
729 occlusion_query_set: None,
730 });
731 pass.set_pipeline(&self.pipeline);
732 pass.set_bind_group(0, &bind_group, &[]);
733 pass.draw(0..3, 0..1);
734 }
735}
736
737/// Pase de pantalla completa que samplea la textura de overlay y la emite
738/// para alpha-over. Triángulo grande que cubre el viewport; UV mapea clip
739/// → texel 1:1 (Y invertida, igual que un blit estándar).
740const OVERLAY_COMPOSITE_WGSL: &str = r#"
741struct VsOut {
742 @builtin(position) pos: vec4<f32>,
743 @location(0) uv: vec2<f32>,
744};
745
746@vertex
747fn vs(@builtin(vertex_index) vi: u32) -> VsOut {
748 var corners = array<vec2<f32>, 3>(
749 vec2<f32>(-1.0, -1.0),
750 vec2<f32>( 3.0, -1.0),
751 vec2<f32>(-1.0, 3.0),
752 );
753 let xy = corners[vi];
754 var out: VsOut;
755 out.pos = vec4<f32>(xy, 0.0, 1.0);
756 out.uv = vec2<f32>((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5);
757 return out;
758}
759
760@group(0) @binding(0) var src_tex: texture_2d<f32>;
761@group(0) @binding(1) var src_samp: sampler;
762
763@fragment
764fn fs(in: VsOut) -> @location(0) vec4<f32> {
765 return textureSample(src_tex, src_samp, in.uv);
766}
767"#;
768
769impl Surface for WinitSurface {
770 fn size(&self) -> (u32, u32) {
771 (self.config.width, self.config.height)
772 }
773
774 fn resize(&mut self, width: u32, height: u32) {
775 self.config.width = width.max(1);
776 self.config.height = height.max(1);
777 self.surface.configure(&self.device, &self.config);
778 let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height);
779 self.intermediate = tex;
780 self.intermediate_view = view;
781 let (otex, oview) =
782 create_intermediate(&self.device, self.config.width, self.config.height);
783 self.overlay = otex;
784 self.overlay_view = oview;
785 }
786
787 fn acquire(&mut self) -> Result<Frame, SurfaceError> {
788 let texture = self.surface.get_current_texture().map_err(|e| match e {
789 wgpu::SurfaceError::Lost => SurfaceError::Lost,
790 wgpu::SurfaceError::Outdated => SurfaceError::Outdated,
791 wgpu::SurfaceError::OutOfMemory => SurfaceError::OutOfMemory,
792 wgpu::SurfaceError::Timeout => SurfaceError::Timeout,
793 other => SurfaceError::Other(format!("{other:?}")),
794 })?;
795 let surface_view = texture
796 .texture
797 .create_view(&wgpu::TextureViewDescriptor::default());
798 // `TextureView` envuelve un Arc — clonar es atomic-incref, no
799 // recrea la vista. La intermedia sólo cambia en `resize`.
800 Ok(Frame {
801 surface_texture: texture,
802 surface_view,
803 intermediate_view: self.intermediate_view.clone(),
804 overlay_view: self.overlay_view.clone(),
805 width: self.config.width,
806 height: self.config.height,
807 })
808 }
809
810 fn present(&mut self, frame: Frame, hal: &Hal) {
811 let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
812 label: Some("llimphi-blit"),
813 });
814 self.blitter.copy(
815 &hal.device,
816 &mut encoder,
817 &frame.intermediate_view,
818 &frame.surface_view,
819 );
820 hal.queue.submit(std::iter::once(encoder.finish()));
821 frame.surface_texture.present();
822 }
823}