feat: llimphi standalone — framework UI soberano extraído del monorepo

Motor gráfico Llimphi como workspace independiente: bucle Elm
(input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley.
Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets
+ módulos, sin dependencias al resto del monorepo. cargo check --workspace
pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 04:23:42 +00:00
commit e65e9cc623
286 changed files with 46136 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "llimphi-hal"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
[dependencies]
wgpu = { workspace = true }
raw-window-handle = { workspace = true }
winit = { workspace = true }
pollster = { workspace = true }
[[example]]
name = "clear_screen"
path = "examples/clear_screen.rs"
+10
View File
@@ -0,0 +1,10 @@
# llimphi-hal
> Abstracción de superficie de [llimphi](../README.md). Multi-plataforma.
Trait `Surface` que abstrae window/framebuffer/canvas. Implementaciones: `winit` (Linux/macOS/Windows desktop), `android` (NDK), `wawa` (framebuffer del kernel). El resto del stack llimphi habla `Surface`; mover Wayland → Wawa es cambiar el HAL, no el árbol gráfico.
## Deps
- `winit`, `raw-window-handle`
- `serde`, `wgpu` (re-export para que widgets puedan paint_with)
+10
View File
@@ -0,0 +1,10 @@
# llimphi-hal
> Surface abstraction of [llimphi](../README.md). Multi-platform.
`Surface` trait that abstracts window/framebuffer/canvas. Implementations: `winit` (Linux/macOS/Windows desktop), `android` (NDK), `wawa` (kernel framebuffer). The rest of the llimphi stack talks to `Surface`; moving Wayland → Wawa is swapping the HAL, not the scene tree.
## Deps
- `winit`, `raw-window-handle`
- `serde`, `wgpu` (re-export so widgets can paint_with)
+135
View File
@@ -0,0 +1,135 @@
//! Fase 1 de Llimphi: ventana gris plomo a la frecuencia máxima del display.
//!
//! Corre con: `cargo run -p llimphi-hal --example clear_screen --release`.
//!
//! Imprime fps por stderr cada segundo. En un panel de 144 Hz con AutoVsync
//! debe estabilizarse cerca de 144; en uno de 60 Hz, cerca de 60.
use std::sync::Arc;
use std::time::Instant;
use llimphi_hal::winit::application::ApplicationHandler;
use llimphi_hal::winit::dpi::LogicalSize;
use llimphi_hal::winit::event::WindowEvent;
use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
use llimphi_hal::{wgpu, Hal, Surface, WinitSurface};
const LEAD_GRAY: wgpu::Color = wgpu::Color {
r: 0.235,
g: 0.239,
b: 0.247,
a: 1.0,
};
struct State {
window: Arc<Window>,
hal: Hal,
surface: WinitSurface,
}
struct App {
state: Option<State>,
frames: u64,
last_report: Instant,
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.state.is_some() {
return;
}
let window = event_loop
.create_window(
WindowAttributes::default()
.with_title("llimphi · clear_screen")
.with_inner_size(LogicalSize::new(960u32, 540u32)),
)
.expect("create window");
let window = Arc::new(window);
let hal = pollster::block_on(Hal::new(None)).expect("hal");
let surface = WinitSurface::new(&hal, window.clone()).expect("surface");
window.request_redraw();
self.state = Some(State {
window,
hal,
surface,
});
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
let Some(state) = self.state.as_mut() else {
return;
};
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => {
state.surface.resize(size.width, size.height);
state.window.request_redraw();
}
WindowEvent::RedrawRequested => {
let frame = match state.surface.acquire() {
Ok(f) => f,
Err(_) => {
let (w, h) = state.surface.size();
state.surface.resize(w, h);
state.window.request_redraw();
return;
}
};
let mut encoder =
state
.hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("clear_screen-encoder"),
});
{
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("clear_screen-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: frame.view(),
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(LEAD_GRAY),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
}
state.hal.queue.submit(std::iter::once(encoder.finish()));
state.surface.present(frame, &state.hal);
self.frames += 1;
let elapsed = self.last_report.elapsed();
if elapsed.as_secs() >= 1 {
let fps = self.frames as f64 / elapsed.as_secs_f64();
eprintln!("llimphi · clear_screen — {fps:.1} fps");
self.frames = 0;
self.last_report = Instant::now();
}
state.window.request_redraw();
}
_ => {}
}
}
}
fn main() {
let event_loop = EventLoop::new().expect("event loop");
event_loop.set_control_flow(ControlFlow::Poll);
let mut app = App {
state: None,
frames: 0,
last_report: Instant::now(),
};
event_loop.run_app(&mut app).expect("run app");
}
+823
View File
@@ -0,0 +1,823 @@
//! llimphi-hal — Puente al Silicio.
//!
//! Aísla el motor del sistema operativo. Pinta en ventana Wayland/X11
//! (vía `mirada` en producción, vía `winit` en dev) o framebuffer directo
//! del kernel `wawa` (TODO). Trait `Surface` abstracto + struct `Hal`
//! que posee Instance/Adapter/Device/Queue de wgpu.
use std::sync::Arc;
pub use raw_window_handle;
pub use wgpu;
pub use winit;
use winit::window::Window;
/// Errores al adquirir un frame de la superficie.
#[derive(Debug)]
pub enum SurfaceError {
Lost,
Outdated,
OutOfMemory,
Timeout,
Other(String),
}
impl std::fmt::Display for SurfaceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Lost => write!(f, "surface lost"),
Self::Outdated => write!(f, "surface outdated"),
Self::OutOfMemory => write!(f, "surface out of memory"),
Self::Timeout => write!(f, "surface timeout"),
Self::Other(s) => write!(f, "surface error: {s}"),
}
}
}
impl std::error::Error for SurfaceError {}
/// Errores al construir Hal o crear una Surface.
#[derive(Debug)]
pub enum HalError {
NoAdapter,
RequestDevice(String),
CreateSurface(String),
}
impl std::fmt::Display for HalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoAdapter => write!(f, "no GPU adapter available"),
Self::RequestDevice(s) => write!(f, "request_device failed: {s}"),
Self::CreateSurface(s) => write!(f, "create_surface failed: {s}"),
}
}
}
impl std::error::Error for HalError {}
/// Superficie gráfica donde llimphi pinta.
///
/// Vello (rasterizador) emite a una textura intermedia con storage binding
/// (la única forma portable: los formatos de swapchain no aceptan writes
/// de compute shader en muchos adapters). En `present` se blittea la
/// intermedia al swapchain real y se hace el flip.
///
/// Implementaciones:
/// - [`WinitSurface`]: ventana Wayland/X11 (dev + producción vía mirada).
/// - `WawaFramebufferSurface` (TODO): framebuffer directo del kernel wawa.
pub trait Surface {
fn size(&self) -> (u32, u32);
fn resize(&mut self, width: u32, height: u32);
/// Adquiere la textura intermedia donde el raster pinta este frame.
fn acquire(&mut self) -> Result<Frame, SurfaceError>;
/// Blittea la intermedia al swapchain y la presenta.
fn present(&mut self, frame: Frame, hal: &Hal);
}
/// Frame en curso. `view()` devuelve la textura intermedia (Rgba8Unorm,
/// STORAGE_BINDING) lista para que vello escriba sobre ella.
pub struct Frame {
surface_texture: wgpu::SurfaceTexture,
surface_view: wgpu::TextureView,
intermediate_view: wgpu::TextureView,
/// Textura secundaria para la capa de overlay (menús/paleta/modal)
/// cuando hay contenido `gpu_paint` que la taparía. El overlay se
/// rasteriza acá con fondo transparente y luego se compone con
/// alpha SOBRE la intermedia (que ya tiene UI + video). Ver
/// [`OverlayCompositor`] y el eventloop de `llimphi-ui`.
overlay_view: wgpu::TextureView,
width: u32,
height: u32,
}
impl Frame {
pub fn view(&self) -> &wgpu::TextureView {
&self.intermediate_view
}
/// Vista de la textura de overlay (mismo tamaño y formato que la
/// intermedia). Sólo se usa en el camino de compositing del overlay.
pub fn overlay_view(&self) -> &wgpu::TextureView {
&self.overlay_view
}
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
}
/// Estado wgpu compartido. Una instancia por proceso. `Device` y `Queue`
/// son `Arc` internamente, así que clonar es barato.
pub struct Hal {
pub instance: wgpu::Instance,
pub adapter: wgpu::Adapter,
pub device: wgpu::Device,
pub queue: wgpu::Queue,
}
impl Hal {
/// Construye Hal pidiendo un adapter compatible con una surface dada
/// (recomendado: pasar `Some(&surface)` para garantizar que el adapter
/// elegido sabe presentar a esa surface).
pub async fn new(
compatible_surface: Option<&wgpu::Surface<'static>>,
) -> Result<Self, HalError> {
let opts = wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface,
};
// Preferimos backends PRIMARY (Vulkan/Metal/DX12). El backend GL de
// wgpu sobre Mesa/Wayland tiene un bug de teardown: al soltar la
// instancia, `eglTerminate` marshalea sobre una conexión Wayland ya
// muerta (`wl_proxy_marshal`) y revienta con SIGSEGV. Con
// `Backends::all()` (el default), wgpu puede elegir GL aun habiendo
// Vulkan, y la app crashea al cerrar/teardown. Forzamos PRIMARY; si la
// máquina no tiene Vulkan/Metal/DX12 (VM vieja, etc.) caemos a todos
// los backends —incluido GL— para no dejarla sin gráficos. En el
// camino de escritorio `compatible_surface` es `None` (la surface se
// crea después contra esta misma instancia), así que cambiar de
// instancia aquí es seguro.
let primary = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
..Default::default()
});
let (instance, adapter) = match primary.request_adapter(&opts).await {
Some(a) => (primary, a),
None => {
let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
let a = all.request_adapter(&opts).await.ok_or(HalError::NoAdapter)?;
(all, a)
}
};
// `Limits::default()` cubre los 5 storage buffers/stage que vello
// necesita. `downlevel_defaults()` solo expone 4 y rompe el raster.
// Si el adapter no lo aguanta, `using_resolution` recorta lo recortable
// (texturas/buffers grandes) preservando los conteos mínimos.
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
label: Some("llimphi-hal-device"),
required_features: wgpu::Features::empty(),
required_limits: limits,
memory_hints: wgpu::MemoryHints::Performance,
},
None,
)
.await
.map_err(|e| HalError::RequestDevice(e.to_string()))?;
Ok(Self {
instance,
adapter,
device,
queue,
})
}
/// Construye el `Hal` **y** una [`RawSurface`] a la vez, eligiendo el adaptador
/// **compatible con esa surface** — el dispositivo que el compositor sabe
/// presentar. Es el camino correcto para el backend layer-shell de `pata`.
///
/// El problema que resuelve: en sistemas multi-GPU (Optimus), pedir el
/// adaptador sin pista de surface (`new(None)` con `HighPerformance`) puede
/// elegir la dGPU mientras el compositor compone en la iGPU → los dmabuf
/// cruzan dispositivos y `get_capabilities` devuelve 0 formatos (la surface
/// "no expone formatos"). Pasar `compatible_surface` ata el adaptador al
/// dispositivo del compositor. Como la surface hace falta ANTES de pedir el
/// adaptador, y `new` crea la instancia internamente, este constructor une los
/// dos pasos.
///
/// `make_target` reconstruye el `SurfaceTargetUnsafe` cada vez que se llama
/// (los `RawHandle` son `Copy`): `create_surface_unsafe` consume el target y
/// puede que probemos dos instancias (PRIMARY y, si no hay adaptador, todos
/// los backends — el GL de Mesa/Wayland revienta en teardown, por eso PRIMARY
/// primero, igual que [`Hal::new`]).
///
/// # Safety
/// Los handles que produce `make_target` deben apuntar a objetos Wayland/…
/// vivos durante toda la vida de la `RawSurface` devuelta.
pub async unsafe fn new_for_raw_surface(
make_target: impl Fn() -> wgpu::SurfaceTargetUnsafe,
width: u32,
height: u32,
) -> Result<(Self, RawSurface), HalError> {
// PRIMARY (Vulkan/Metal/DX12) primero; si no hay adaptador compatible, a
// todos los backends recreando instancia y surface.
let primary = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
..Default::default()
});
let prim_surface = unsafe { primary.create_surface_unsafe(make_target()) }
.map_err(|e| HalError::CreateSurface(e.to_string()))?;
let prim_adapter = primary
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&prim_surface),
})
.await;
let (instance, adapter, wgpu_surface) = match prim_adapter {
Some(a) => (primary, a, prim_surface),
None => {
let all = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
let surface = unsafe { all.create_surface_unsafe(make_target()) }
.map_err(|e| HalError::CreateSurface(e.to_string()))?;
let a = all
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&surface),
})
.await
.ok_or(HalError::NoAdapter)?;
(all, a, surface)
}
};
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
let (device, queue) = adapter
.request_device(
&wgpu::DeviceDescriptor {
label: Some("llimphi-hal-device"),
required_features: wgpu::Features::empty(),
required_limits: limits,
memory_hints: wgpu::MemoryHints::Performance,
},
None,
)
.await
.map_err(|e| HalError::RequestDevice(e.to_string()))?;
let hal = Self {
instance,
adapter,
device,
queue,
};
let surface = RawSurface::from_surface(&hal, wgpu_surface, width, height)?;
Ok((hal, surface))
}
}
/// Surface basada en `winit::window::Window`. Mantiene una textura
/// intermedia `Rgba8Unorm` con storage binding (donde pinta vello) y
/// un `TextureBlitter` que la copia al swapchain al presentar.
pub struct WinitSurface {
_window: Arc<Window>,
surface: wgpu::Surface<'static>,
config: wgpu::SurfaceConfiguration,
device: wgpu::Device,
intermediate: wgpu::Texture,
intermediate_view: wgpu::TextureView,
/// Textura de la capa de overlay (ver [`Frame::overlay_view`]).
overlay: wgpu::Texture,
overlay_view: wgpu::TextureView,
blitter: wgpu::util::TextureBlitter,
}
const INTERMEDIATE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
impl WinitSurface {
/// Constructor "feliz": crea la `wgpu::Surface` internamente.
/// Conveniente en desktop donde la secuencia normal es
/// `Hal::new(None)` → `WinitSurface::new(hal, window)`. **En Android
/// usar [`WinitSurface::from_surface`]** — allí la surface debe
/// existir antes del `request_adapter(compatible_surface=Some(...))`,
/// y crearla dos veces sobre la misma `ANativeWindow` falla con
/// `ERROR_NATIVE_WINDOW_IN_USE_KHR`.
pub fn new(hal: &Hal, window: Arc<Window>) -> Result<Self, HalError> {
let surface = hal
.instance
.create_surface(window.clone())
.map_err(|e| HalError::CreateSurface(e.to_string()))?;
Self::from_surface(hal, window, surface)
}
/// Constructor reutilizable: arma el `WinitSurface` envolviendo una
/// `wgpu::Surface` ya creada por el caller. Necesario en Android
/// porque el orden allí es:
///
/// 1. `instance.create_surface(window)`
/// 2. `instance.request_adapter(compatible_surface=Some(&surface))`
/// 3. `adapter.request_device(...)`
/// 4. `WinitSurface::from_surface(hal, window, surface)`
///
/// — no se puede dropear la surface entre 2 y 4 ni recrearla, porque
/// Android reserva la `ANativeWindow` por VkSurface y rechaza un
/// segundo `vkCreateAndroidSurfaceKHR` sobre la misma ventana.
pub fn from_surface(
hal: &Hal,
window: Arc<Window>,
surface: wgpu::Surface<'static>,
) -> Result<Self, HalError> {
let size = window.inner_size();
let caps = surface.get_capabilities(&hal.adapter);
// Preferimos Bgra8Unorm o Rgba8Unorm (no sRGB) para que el blit
// desde la intermedia lineal preserve los valores tal cual.
let format = caps
.formats
.iter()
.copied()
.find(|f| matches!(f, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm))
.unwrap_or(caps.formats[0]);
let config = wgpu::SurfaceConfiguration {
// El swapchain solo necesita render-attachment: vello no escribe
// directo, escribe a la intermedia y luego se blittea.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width: size.width.max(1),
height: size.height.max(1),
present_mode: choose_present_mode(&caps),
desired_maximum_frame_latency: 2,
alpha_mode: caps.alpha_modes[0],
view_formats: vec![],
};
surface.configure(&hal.device, &config);
let (intermediate, intermediate_view) =
create_intermediate(&hal.device, config.width, config.height);
let (overlay, overlay_view) =
create_intermediate(&hal.device, config.width, config.height);
let blitter = wgpu::util::TextureBlitter::new(&hal.device, format);
Ok(Self {
_window: window,
surface,
config,
device: hal.device.clone(),
intermediate,
intermediate_view,
overlay,
overlay_view,
blitter,
})
}
pub fn format(&self) -> wgpu::TextureFormat {
self.config.format
}
}
/// Surface sobre una `wgpu::Surface` creada desde **handles raw** (sin
/// `winit::Window`): la usa el backend `wlr-layer-shell` de `pata` para pintar
/// en una *layer surface* de Wayland (barras/paneles al nivel de eww/waybar).
/// Misma mecánica que [`WinitSurface`] —intermedia `Rgba8Unorm` + blit al
/// swapchain— pero el tamaño se pasa explícito porque no hay ventana que
/// consultar. La `wgpu::Surface` la crea el caller (típicamente con
/// `instance.create_surface_unsafe` desde los punteros `wl_display`/`wl_surface`).
pub struct RawSurface {
surface: wgpu::Surface<'static>,
config: wgpu::SurfaceConfiguration,
device: wgpu::Device,
intermediate: wgpu::Texture,
intermediate_view: wgpu::TextureView,
overlay: wgpu::Texture,
overlay_view: wgpu::TextureView,
blitter: wgpu::util::TextureBlitter,
}
impl RawSurface {
/// Envuelve una `wgpu::Surface` ya creada, con el tamaño físico inicial.
pub fn from_surface(
hal: &Hal,
surface: wgpu::Surface<'static>,
width: u32,
height: u32,
) -> Result<Self, HalError> {
let caps = surface.get_capabilities(&hal.adapter);
let info = hal.adapter.get_info();
// Si la superficie no expone formatos, el compositor no la soporta por
// este backend (Vulkan/GL WSI): error claro en vez de un panic por
// indexar `formats[0]` sobre una lista vacía.
let format = match caps
.formats
.iter()
.copied()
.find(|f| matches!(f, wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm))
.or_else(|| caps.formats.first().copied())
{
Some(f) => f,
None => {
return Err(HalError::CreateSurface(format!(
"la superficie no expone formatos (adapter {:?}/{:?}): el compositor no la soporta por {:?} WSI",
info.backend, info.device_type, info.backend
)))
}
};
let alpha_mode = caps
.alpha_modes
.first()
.copied()
.unwrap_or(wgpu::CompositeAlphaMode::Auto);
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format,
width: width.max(1),
height: height.max(1),
present_mode: choose_present_mode(&caps),
desired_maximum_frame_latency: 2,
alpha_mode,
view_formats: vec![],
};
surface.configure(&hal.device, &config);
let (intermediate, intermediate_view) =
create_intermediate(&hal.device, config.width, config.height);
let (overlay, overlay_view) =
create_intermediate(&hal.device, config.width, config.height);
let blitter = wgpu::util::TextureBlitter::new(&hal.device, format);
Ok(Self {
surface,
config,
device: hal.device.clone(),
intermediate,
intermediate_view,
overlay,
overlay_view,
blitter,
})
}
pub fn format(&self) -> wgpu::TextureFormat {
self.config.format
}
}
impl Surface for RawSurface {
fn size(&self) -> (u32, u32) {
(self.config.width, self.config.height)
}
fn resize(&mut self, width: u32, height: u32) {
let (w, h) = (width.max(1), height.max(1));
// Sin cambio de tamaño NO reconfiguramos. El backend layer-shell de `pata`
// llama a `resize` en cada cuadro (no tiene eventos de resize como winit);
// reconfigurar el swapchain por cuadro lo reconstruye una y otra vez, y en
// Vulkan WSI eso **destruye el `wl_buffer` recién presentado antes de que el
// compositor lo componga** — wlroots lo tolera, smithay (mirada) no, y la
// superficie queda en negro (el compositor ve `buffer=None`).
if self.config.width == w && self.config.height == h {
return;
}
self.config.width = w;
self.config.height = h;
self.surface.configure(&self.device, &self.config);
let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height);
self.intermediate = tex;
self.intermediate_view = view;
let (otex, oview) =
create_intermediate(&self.device, self.config.width, self.config.height);
self.overlay = otex;
self.overlay_view = oview;
}
fn acquire(&mut self) -> Result<Frame, SurfaceError> {
let texture = match self.surface.get_current_texture() {
Ok(t) => t,
// El backend layer-shell no tiene un evento de resize que reconfigure
// el swapchain; si quedó obsoleto/perdido, lo reconstruimos aquí mismo
// y reintentamos una vez. Sin esto el panel quedaría en negro para
// siempre tras el primer `Outdated`.
Err(e @ (wgpu::SurfaceError::Outdated | wgpu::SurfaceError::Lost)) => {
self.surface.configure(&self.device, &self.config);
self.surface.get_current_texture().map_err(|_| match e {
wgpu::SurfaceError::Lost => SurfaceError::Lost,
_ => SurfaceError::Outdated,
})?
}
Err(wgpu::SurfaceError::OutOfMemory) => return Err(SurfaceError::OutOfMemory),
Err(wgpu::SurfaceError::Timeout) => return Err(SurfaceError::Timeout),
Err(other) => return Err(SurfaceError::Other(format!("{other:?}"))),
};
let surface_view = texture
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
Ok(Frame {
surface_texture: texture,
surface_view,
intermediate_view: self.intermediate_view.clone(),
overlay_view: self.overlay_view.clone(),
width: self.config.width,
height: self.config.height,
})
}
fn present(&mut self, frame: Frame, hal: &Hal) {
let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("llimphi-blit-raw"),
});
self.blitter.copy(
&hal.device,
&mut encoder,
&frame.intermediate_view,
&frame.surface_view,
);
hal.queue.submit(std::iter::once(encoder.finish()));
frame.surface_texture.present();
}
}
/// Elige el modo de presentación del swapchain.
///
/// Default: **Mailbox** si el driver lo expone, sino **Fifo**. La razón es
/// el cuelgue observado en las apps Llimphi (investigación 2026-05-30): con
/// `Fifo`/`AutoVsync`, `surface.get_current_texture()` **bloquea** esperando
/// el frame-callback del compositor Wayland — si el compositor no suelta un
/// buffer, el hilo del UI queda dormido (CPU baja, deadlock aparente).
/// `Mailbox` no bloquea (triple-buffer, descarta frames viejos), así que el
/// loop nunca se queda esperando al compositor. `Fifo` está garantizado por
/// spec como fallback.
///
/// Override por entorno para A/B sin recompilar (útil en la laptop con
/// display real): `LLIMPHI_PRESENT_MODE = fifo | mailbox | immediate |
/// fifo_relaxed`. Si el modo pedido no está soportado, se ignora y se aplica
/// el default.
fn choose_present_mode(caps: &wgpu::SurfaceCapabilities) -> wgpu::PresentMode {
use wgpu::PresentMode::{Fifo, FifoRelaxed, Immediate, Mailbox};
if let Ok(v) = std::env::var("LLIMPHI_PRESENT_MODE") {
let want = match v.trim().to_ascii_lowercase().as_str() {
"fifo" | "vsync" => Some(Fifo),
"fifo_relaxed" | "fiforelaxed" => Some(FifoRelaxed),
"mailbox" => Some(Mailbox),
"immediate" | "novsync" => Some(Immediate),
_ => None,
};
if let Some(m) = want {
if caps.present_modes.contains(&m) {
return m;
}
}
}
if caps.present_modes.contains(&Mailbox) {
Mailbox
} else {
Fifo
}
}
fn create_intermediate(
device: &wgpu::Device,
width: u32,
height: u32,
) -> (wgpu::Texture, wgpu::TextureView) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("llimphi-intermediate"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE_FORMAT,
// STORAGE_BINDING: vello escribe via compute shader.
// TEXTURE_BINDING: el blitter la lee como sampler source.
// RENDER_ATTACHMENT: render passes con clear-only (sin vello)
// también escriben acá — desktop drivers lo tolerían sin este
// flag, Adreno con validación estricta rechaza el frame.
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
(texture, view)
}
/// Compositor de la capa de overlay: alpha-blittea una textura source (el
/// overlay rasterizado por vello sobre fondo transparente) SOBRE una textura
/// target (la intermedia, que ya tiene la UI principal + el video pintado por
/// `gpu_paint`). Resuelve el z-order: sin esto, el blit de `gpu_paint` (video)
/// queda encima de la capa vello del overlay y los menús se ven por debajo del
/// video.
///
/// Es un pase de pantalla completa (triángulo) que samplea el source y lo
/// emite con alpha-over. El factor de blend asume alpha **premultiplicado**
/// (lo que produce vello); si en pantalla los menús se ven con halos oscuros o
/// transparencia rara, exportar `LLIMPHI_OVERLAY_BLEND=straight` para usar
/// alpha recto sin recompilar.
pub struct OverlayCompositor {
pipeline: wgpu::RenderPipeline,
sampler: wgpu::Sampler,
bind_layout: wgpu::BindGroupLayout,
}
impl OverlayCompositor {
pub fn new(device: &wgpu::Device) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("llimphi-overlay-composite"),
source: wgpu::ShaderSource::Wgsl(OVERLAY_COMPOSITE_WGSL.into()),
});
let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("llimphi-overlay-bgl"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("llimphi-overlay-pl"),
bind_group_layouts: &[&bind_layout],
push_constant_ranges: &[],
});
// Alpha-over. `src_factor` distingue premultiplicado (One) de recto
// (SrcAlpha); el resto es siempre OneMinusSrcAlpha.
let straight = std::env::var("LLIMPHI_OVERLAY_BLEND")
.map(|v| v.trim().eq_ignore_ascii_case("straight"))
.unwrap_or(false);
let color_src = if straight {
wgpu::BlendFactor::SrcAlpha
} else {
wgpu::BlendFactor::One
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("llimphi-overlay-pipe"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs"),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs"),
targets: &[Some(wgpu::ColorTargetState {
format: INTERMEDIATE_FORMAT,
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: color_src,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
multiview: None,
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("llimphi-overlay-sampler"),
..Default::default()
});
OverlayCompositor {
pipeline,
sampler,
bind_layout,
}
}
/// Compone `source` (overlay con fondo transparente) sobre `target` (la
/// intermedia), preservando el contenido previo del target (LoadOp::Load)
/// y mezclando con alpha. Graba un render pass en `encoder`.
pub fn composite(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
target: &wgpu::TextureView,
source: &wgpu::TextureView,
) {
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("llimphi-overlay-bg"),
layout: &self.bind_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("llimphi-overlay-composite-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.draw(0..3, 0..1);
}
}
/// Pase de pantalla completa que samplea la textura de overlay y la emite
/// para alpha-over. Triángulo grande que cubre el viewport; UV mapea clip
/// → texel 1:1 (Y invertida, igual que un blit estándar).
const OVERLAY_COMPOSITE_WGSL: &str = r#"
struct VsOut {
@builtin(position) pos: vec4<f32>,
@location(0) uv: vec2<f32>,
};
@vertex
fn vs(@builtin(vertex_index) vi: u32) -> VsOut {
var corners = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 3.0, -1.0),
vec2<f32>(-1.0, 3.0),
);
let xy = corners[vi];
var out: VsOut;
out.pos = vec4<f32>(xy, 0.0, 1.0);
out.uv = vec2<f32>((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5);
return out;
}
@group(0) @binding(0) var src_tex: texture_2d<f32>;
@group(0) @binding(1) var src_samp: sampler;
@fragment
fn fs(in: VsOut) -> @location(0) vec4<f32> {
return textureSample(src_tex, src_samp, in.uv);
}
"#;
impl Surface for WinitSurface {
fn size(&self) -> (u32, u32) {
(self.config.width, self.config.height)
}
fn resize(&mut self, width: u32, height: u32) {
self.config.width = width.max(1);
self.config.height = height.max(1);
self.surface.configure(&self.device, &self.config);
let (tex, view) = create_intermediate(&self.device, self.config.width, self.config.height);
self.intermediate = tex;
self.intermediate_view = view;
let (otex, oview) =
create_intermediate(&self.device, self.config.width, self.config.height);
self.overlay = otex;
self.overlay_view = oview;
}
fn acquire(&mut self) -> Result<Frame, SurfaceError> {
let texture = self.surface.get_current_texture().map_err(|e| match e {
wgpu::SurfaceError::Lost => SurfaceError::Lost,
wgpu::SurfaceError::Outdated => SurfaceError::Outdated,
wgpu::SurfaceError::OutOfMemory => SurfaceError::OutOfMemory,
wgpu::SurfaceError::Timeout => SurfaceError::Timeout,
other => SurfaceError::Other(format!("{other:?}")),
})?;
let surface_view = texture
.texture
.create_view(&wgpu::TextureViewDescriptor::default());
// `TextureView` envuelve un Arc — clonar es atomic-incref, no
// recrea la vista. La intermedia sólo cambia en `resize`.
Ok(Frame {
surface_texture: texture,
surface_view,
intermediate_view: self.intermediate_view.clone(),
overlay_view: self.overlay_view.clone(),
width: self.config.width,
height: self.config.height,
})
}
fn present(&mut self, frame: Frame, hal: &Hal) {
let mut encoder = hal.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("llimphi-blit"),
});
self.blitter.copy(
&hal.device,
&mut encoder,
&frame.intermediate_view,
&frame.surface_view,
);
hal.queue.submit(std::iter::once(encoder.finish()));
frame.surface_texture.present();
}
}