feat!: replace CPU rendering with GPU via wgpu/WGSL
Drop tiny-skia and tempfile. All rendering now runs on the GPU using a WGSL fragment shader with rounded-rect SDF, gaussian glow falloff, and cursor avoidance. Add color temperature support with presets (warm, cool, studio, daylight, golden) and custom Kelvin values. Suppress MangoHUD via environment variable.
This commit is contained in:
6 files changed
+1419
-502
No files matched your search
Generated
+911
-240
File diff suppressed because it is too large.
Load diff
+5
-2
@@ -8,8 +8,11 @@ rust-version = "1.85"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0"
|
||||
bytemuck = { version = "1", features = ["derive"] }
|
||||
nix = { version = "0.29", features = ["signal", "process", "poll"] }
|
||||
tempfile = "3"
|
||||
tiny-skia = "0.12.0"
|
||||
pollster = "0.4"
|
||||
raw-window-handle = "0.6"
|
||||
wayland-backend = { version = "0.3", features = ["client_system"] }
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols-wlr = { version = "0.3", features = ["client"] }
|
||||
wgpu = "29"
|
||||
+74
-25
@@ -4,71 +4,120 @@ mod wayland;
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tiny_skia::Pixmap;
|
||||
use anyhow::Result;
|
||||
|
||||
use lock::ToggleResult;
|
||||
use render::GpuRenderer;
|
||||
use wayland::{OverlayEvent, WaylandBackend};
|
||||
|
||||
const DISPLAY_DURATION: Duration = Duration::from_secs(5);
|
||||
const DISPLAY_DURATION: Duration = Duration::from_secs(10);
|
||||
const FRAME_INTERVAL: Duration = Duration::from_millis(16);
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// Suppress MangoHUD — it hooks into Vulkan and we don't want an overlay on our overlay.
|
||||
std::env::set_var("DISABLE_MANGOHUD", "1");
|
||||
|
||||
match lock::try_toggle()? {
|
||||
ToggleResult::SignaledExisting => return Ok(()),
|
||||
ToggleResult::RunNew => {}
|
||||
}
|
||||
|
||||
let color = parse_color_arg();
|
||||
|
||||
let _lock = lock::create_lock()?;
|
||||
|
||||
let mut wl = WaylandBackend::new()?;
|
||||
let (w, h) = wl.screen_size();
|
||||
let mut pixmap = Pixmap::new(w, h).context("create pixmap")?;
|
||||
let mut argb_buf = vec![0u8; (w as usize) * (h as usize) * 4];
|
||||
|
||||
let mut renderer = GpuRenderer::new(
|
||||
wl.raw_display_handle()?,
|
||||
wl.raw_window_handle()?,
|
||||
w,
|
||||
h,
|
||||
color,
|
||||
)?;
|
||||
|
||||
let mut cursor_x = w as f64 / 2.0;
|
||||
let mut cursor_y = h as f64 / 2.0;
|
||||
|
||||
draw(&mut wl, &mut pixmap, &mut argb_buf, cursor_x, cursor_y)?;
|
||||
renderer.render(cursor_x, cursor_y)?;
|
||||
|
||||
let start = Instant::now();
|
||||
let mut last_draw = Instant::now();
|
||||
let mut dirty = false;
|
||||
|
||||
loop {
|
||||
let elapsed = start.elapsed();
|
||||
if elapsed >= DISPLAY_DURATION || lock::should_exit() {
|
||||
break;
|
||||
}
|
||||
|
||||
if dirty && last_draw.elapsed() >= FRAME_INTERVAL {
|
||||
renderer.render(cursor_x, cursor_y)?;
|
||||
last_draw = Instant::now();
|
||||
dirty = false;
|
||||
}
|
||||
|
||||
let remaining = DISPLAY_DURATION - elapsed;
|
||||
let event = match wl.next_event(remaining)? {
|
||||
let poll_timeout = if dirty {
|
||||
FRAME_INTERVAL
|
||||
.saturating_sub(last_draw.elapsed())
|
||||
.min(remaining)
|
||||
} else {
|
||||
remaining
|
||||
};
|
||||
|
||||
let event = match wl.next_event(poll_timeout)? {
|
||||
Some(ev) => ev,
|
||||
None => continue, // poll timeout — loop back to check elapsed time
|
||||
None => continue,
|
||||
};
|
||||
match event {
|
||||
OverlayEvent::Close => break,
|
||||
OverlayEvent::CursorMoved(x, y) => {
|
||||
cursor_x = x;
|
||||
cursor_y = y;
|
||||
draw(&mut wl, &mut pixmap, &mut argb_buf, cursor_x, cursor_y)?;
|
||||
}
|
||||
OverlayEvent::Configure(new_w, new_h) => {
|
||||
if new_w != w || new_h != h {
|
||||
break;
|
||||
}
|
||||
dirty = true;
|
||||
}
|
||||
OverlayEvent::Configure(_, _) => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn draw(
|
||||
wl: &mut WaylandBackend,
|
||||
pixmap: &mut Pixmap,
|
||||
argb_buf: &mut [u8],
|
||||
cursor_x: f64,
|
||||
cursor_y: f64,
|
||||
) -> Result<()> {
|
||||
render::render_ring_light(pixmap, cursor_x, cursor_y);
|
||||
render::pixmap_to_argb8888(pixmap, argb_buf);
|
||||
let (w, h) = wl.screen_size();
|
||||
wl.present(argb_buf, w, h)
|
||||
/// Parse color temperature from CLI args.
|
||||
///
|
||||
/// Usage:
|
||||
/// ringa → pure white (default)
|
||||
/// ringa warm → 3200K (warm tungsten)
|
||||
/// ringa cool → 6500K (cool daylight)
|
||||
/// ringa studio → 5000K (neutral studio)
|
||||
/// ringa daylight → 5600K (daylight balanced)
|
||||
/// ringa golden → 2700K (golden hour)
|
||||
/// ringa 4200 → custom Kelvin value
|
||||
fn parse_color_arg() -> [f32; 3] {
|
||||
let arg = match std::env::args().nth(1) {
|
||||
Some(a) => a,
|
||||
None => return [1.0, 1.0, 1.0], // pure white
|
||||
};
|
||||
|
||||
let kelvin = match arg.to_lowercase().as_str() {
|
||||
"warm" => 3200,
|
||||
"golden" => 2700,
|
||||
"studio" => 5000,
|
||||
"daylight" => 5600,
|
||||
"cool" => 6500,
|
||||
"white" => return [1.0, 1.0, 1.0],
|
||||
other => match other.parse::<u32>() {
|
||||
Ok(k) if (1000..=40000).contains(&k) => k,
|
||||
_ => {
|
||||
eprintln!(
|
||||
"usage: ringa [white|warm|golden|studio|daylight|cool|<kelvin>]"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
render::kelvin_to_rgb(kelvin)
|
||||
}
|
||||
+266
-162
@@ -1,183 +1,287 @@
|
||||
//! Ring light glow rendering.
|
||||
//!
|
||||
//! The ring is a thick stroke centered on a single rounded rectangle SDF.
|
||||
//! This guarantees uniform width everywhere, including at corners.
|
||||
//! Glow bleeds outward and inward from the stroke edges.
|
||||
//! GPU renderer using wgpu + WGSL fragment shader.
|
||||
|
||||
use tiny_skia::Pixmap;
|
||||
use anyhow::{Context, Result};
|
||||
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
// The rounded rect centerline is inset this far from the screen edge.
|
||||
// The visible ring extends RING_HALF_WIDTH on either side of this line.
|
||||
const CENTERLINE_INSET: f32 = 80.0;
|
||||
// Half the stroke width. Total ring thickness = 2 * RING_HALF_WIDTH.
|
||||
const RING_HALF_WIDTH: f32 = 30.0;
|
||||
// Corner radius of the centerline rounded rect.
|
||||
const CORNER_RADIUS: f32 = 30.0;
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Uniforms {
|
||||
screen_size: [f32; 2],
|
||||
cursor_pos: [f32; 2],
|
||||
outer_inset: f32,
|
||||
outer_corner_radius: f32,
|
||||
ring_width: f32,
|
||||
inner_corner_radius: f32,
|
||||
glow_spread: f32,
|
||||
cursor_avoid_radius: f32,
|
||||
cursor_avoid_falloff: f32,
|
||||
_pad: f32,
|
||||
color: [f32; 3],
|
||||
_pad2: f32,
|
||||
}
|
||||
|
||||
// Soft glow spread outward and inward from the ring edges.
|
||||
const GLOW_SPREAD: f32 = 50.0;
|
||||
const RING_ALPHA: f32 = 1.00;
|
||||
pub struct GpuRenderer {
|
||||
surface: wgpu::Surface<'static>,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
uniform_buffer: wgpu::Buffer,
|
||||
bind_group: wgpu::BindGroup,
|
||||
uniforms: Uniforms,
|
||||
}
|
||||
|
||||
// Cursor avoidance.
|
||||
const CURSOR_AVOID_RADIUS: f32 = 120.0;
|
||||
const CURSOR_AVOID_FALLOFF: f32 = 60.0;
|
||||
impl GpuRenderer {
|
||||
pub fn new(
|
||||
display_handle: RawDisplayHandle,
|
||||
window_handle: RawWindowHandle,
|
||||
width: u32,
|
||||
height: u32,
|
||||
color: [f32; 3],
|
||||
) -> Result<Self> {
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::VULKAN,
|
||||
flags: wgpu::InstanceFlags::empty(),
|
||||
backend_options: Default::default(),
|
||||
memory_budget_thresholds: Default::default(),
|
||||
display: None,
|
||||
});
|
||||
|
||||
/// Render the ring light glow into `pixmap`, avoiding the cursor at `(cx, cy)`.
|
||||
pub fn render_ring_light(pixmap: &mut Pixmap, cx: f64, cy: f64) {
|
||||
let w = pixmap.width() as f32;
|
||||
let h = pixmap.height() as f32;
|
||||
let cx = cx as f32;
|
||||
let cy = cy as f32;
|
||||
let wi = pixmap.width() as usize;
|
||||
let hi = pixmap.height() as usize;
|
||||
|
||||
let data = pixmap.data_mut();
|
||||
data.fill(0);
|
||||
|
||||
let screen_cx = w / 2.0;
|
||||
let screen_cy = h / 2.0;
|
||||
|
||||
// Single rounded rect centerline.
|
||||
let hw = w / 2.0 - CENTERLINE_INSET;
|
||||
let hh = h / 2.0 - CENTERLINE_INSET;
|
||||
|
||||
// The affected band: from (CENTERLINE_INSET - RING_HALF_WIDTH - GLOW_SPREAD)
|
||||
// to (CENTERLINE_INSET + RING_HALF_WIDTH + GLOW_SPREAD) from each screen edge.
|
||||
let band_start = (CENTERLINE_INSET - RING_HALF_WIDTH - GLOW_SPREAD)
|
||||
.max(0.0)
|
||||
.floor() as usize;
|
||||
let band_end = (CENTERLINE_INSET + RING_HALF_WIDTH + GLOW_SPREAD).ceil() as usize + 1;
|
||||
|
||||
// Top band
|
||||
for py in band_start..band_end.min(hi) {
|
||||
for px in 0..wi {
|
||||
let a = pixel_alpha(px as f32, py as f32, screen_cx, screen_cy, hw, hh, cx, cy);
|
||||
write_pixel(data, py * wi + px, a);
|
||||
let surface = unsafe {
|
||||
instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle {
|
||||
raw_display_handle: Some(display_handle),
|
||||
raw_window_handle: window_handle,
|
||||
})
|
||||
}
|
||||
.context("create wgpu surface")?;
|
||||
|
||||
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
|
||||
compatible_surface: Some(&surface),
|
||||
power_preference: wgpu::PowerPreference::LowPower,
|
||||
..Default::default()
|
||||
}))
|
||||
.context("no compatible GPU adapter")?;
|
||||
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(
|
||||
&wgpu::DeviceDescriptor {
|
||||
label: Some("ringa"),
|
||||
..Default::default()
|
||||
},
|
||||
))
|
||||
.context("request GPU device")?;
|
||||
|
||||
let caps = surface.get_capabilities(&adapter);
|
||||
let format = caps
|
||||
.formats
|
||||
.iter()
|
||||
.find(|f| f.is_srgb())
|
||||
.copied()
|
||||
.or_else(|| caps.formats.first().copied())
|
||||
.context("no supported surface format")?;
|
||||
|
||||
let alpha_mode = if caps
|
||||
.alpha_modes
|
||||
.contains(&wgpu::CompositeAlphaMode::PreMultiplied)
|
||||
{
|
||||
wgpu::CompositeAlphaMode::PreMultiplied
|
||||
} else {
|
||||
caps.alpha_modes
|
||||
.first()
|
||||
.copied()
|
||||
.context("no supported alpha mode")?
|
||||
};
|
||||
|
||||
surface.configure(
|
||||
&device,
|
||||
&wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
present_mode: wgpu::PresentMode::Mailbox,
|
||||
desired_maximum_frame_latency: 1,
|
||||
alpha_mode,
|
||||
view_formats: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("ring shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
|
||||
});
|
||||
|
||||
let uniforms = Uniforms {
|
||||
screen_size: [width as f32, height as f32],
|
||||
cursor_pos: [width as f32 / 2.0, height as f32 / 2.0],
|
||||
outer_inset: 65.0,
|
||||
outer_corner_radius: 36.0,
|
||||
ring_width: 50.0,
|
||||
inner_corner_radius: 100.0,
|
||||
glow_spread: 50.0,
|
||||
cursor_avoid_radius: 300.0,
|
||||
cursor_avoid_falloff: 1500.0,
|
||||
_pad: 0.0,
|
||||
color,
|
||||
_pad2: 0.0,
|
||||
};
|
||||
|
||||
let uniform_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("uniforms"),
|
||||
contents: bytemuck::bytes_of(&uniforms),
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
|
||||
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("uniforms layout"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("uniforms bind group"),
|
||||
layout: &bind_group_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform_buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("ring pipeline layout"),
|
||||
bind_group_layouts: &[Some(&bind_group_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("ring pipeline"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[],
|
||||
compilation_options: Default::default(),
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_main"),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
compilation_options: Default::default(),
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
surface,
|
||||
device,
|
||||
queue,
|
||||
pipeline,
|
||||
uniform_buffer,
|
||||
bind_group,
|
||||
uniforms,
|
||||
})
|
||||
}
|
||||
|
||||
// Bottom band
|
||||
let bottom_start = hi.saturating_sub(band_end);
|
||||
let bottom_end = hi.saturating_sub(band_start);
|
||||
for py in bottom_start..bottom_end.min(hi) {
|
||||
if py < band_end {
|
||||
continue;
|
||||
}
|
||||
for px in 0..wi {
|
||||
let a = pixel_alpha(px as f32, py as f32, screen_cx, screen_cy, hw, hh, cx, cy);
|
||||
write_pixel(data, py * wi + px, a);
|
||||
}
|
||||
}
|
||||
pub fn render(&mut self, cx: f64, cy: f64) -> Result<()> {
|
||||
self.uniforms.cursor_pos = [cx as f32, cy as f32];
|
||||
self.queue
|
||||
.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(&self.uniforms));
|
||||
|
||||
// Left band (between top and bottom)
|
||||
let vert_start = band_end.min(hi);
|
||||
let vert_end = hi.saturating_sub(band_end);
|
||||
for py in vert_start..vert_end {
|
||||
for px in band_start..band_end.min(wi) {
|
||||
let a = pixel_alpha(px as f32, py as f32, screen_cx, screen_cy, hw, hh, cx, cy);
|
||||
write_pixel(data, py * wi + px, a);
|
||||
}
|
||||
}
|
||||
|
||||
// Right band (between top and bottom)
|
||||
let right_start = wi.saturating_sub(band_end);
|
||||
let right_end = wi.saturating_sub(band_start);
|
||||
for py in vert_start..vert_end {
|
||||
for px in right_start..right_end.min(wi) {
|
||||
if px < band_end {
|
||||
continue;
|
||||
let frame = match self.surface.get_current_texture() {
|
||||
wgpu::CurrentSurfaceTexture::Success(tex)
|
||||
| wgpu::CurrentSurfaceTexture::Suboptimal(tex) => tex,
|
||||
wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => {
|
||||
return Ok(())
|
||||
}
|
||||
let a = pixel_alpha(px as f32, py as f32, screen_cx, screen_cy, hw, hh, cx, cy);
|
||||
write_pixel(data, py * wi + px, a);
|
||||
wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => {
|
||||
anyhow::bail!("surface lost")
|
||||
}
|
||||
_ => anyhow::bail!("unexpected surface texture state"),
|
||||
};
|
||||
|
||||
let view = frame
|
||||
.texture
|
||||
.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
let mut encoder = self
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("ring encoder"),
|
||||
});
|
||||
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("ring pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
depth_slice: None,
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.bind_group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
self.queue.submit(std::iter::once(encoder.finish()));
|
||||
frame.present();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a tiny-skia `Pixmap` (premultiplied RGBA) to Wayland's ARGB8888
|
||||
/// format (premultiplied BGRA in memory on little-endian).
|
||||
pub fn pixmap_to_argb8888(pixmap: &Pixmap, out: &mut [u8]) {
|
||||
let src = pixmap.data();
|
||||
assert_eq!(out.len(), src.len());
|
||||
for (dst, src) in out.chunks_exact_mut(4).zip(src.chunks_exact(4)) {
|
||||
dst[0] = src[2]; // B
|
||||
dst[1] = src[1]; // G
|
||||
dst[2] = src[0]; // R
|
||||
dst[3] = src[3]; // A
|
||||
}
|
||||
}
|
||||
/// Convert color temperature in Kelvin to linear RGB.
|
||||
/// Based on Tanner Helland's algorithm.
|
||||
pub fn kelvin_to_rgb(kelvin: u32) -> [f32; 3] {
|
||||
let temp = (kelvin as f64 / 100.0).clamp(10.0, 400.0);
|
||||
|
||||
// ---- Private helpers --------------------------------------------------------
|
||||
|
||||
/// Compute glow alpha for a single pixel using a single rounded rect SDF.
|
||||
///
|
||||
/// The ring is all pixels where |sdf| <= RING_HALF_WIDTH (a thick stroke).
|
||||
/// Glow fades from the ring edges outward/inward over GLOW_SPREAD pixels.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pixel_alpha(
|
||||
px: f32,
|
||||
py: f32,
|
||||
scx: f32,
|
||||
scy: f32,
|
||||
hw: f32,
|
||||
hh: f32,
|
||||
cursor_x: f32,
|
||||
cursor_y: f32,
|
||||
) -> u8 {
|
||||
// Signed distance to the rounded rect centerline.
|
||||
let sd = rounded_rect_sdf(px, py, scx, scy, hw, hh, CORNER_RADIUS);
|
||||
let abs_sd = sd.abs();
|
||||
|
||||
let glow = if abs_sd <= RING_HALF_WIDTH {
|
||||
// Inside the ring stroke — full brightness.
|
||||
RING_ALPHA
|
||||
let r = if temp <= 66.0 {
|
||||
1.0
|
||||
} else {
|
||||
// Distance from the nearest ring edge.
|
||||
let dist = abs_sd - RING_HALF_WIDTH;
|
||||
if dist >= GLOW_SPREAD {
|
||||
return 0;
|
||||
}
|
||||
let t = dist / GLOW_SPREAD;
|
||||
smoothstep(1.0 - t) * RING_ALPHA
|
||||
let r = 329.698727446 * (temp - 60.0).powf(-0.1332047592);
|
||||
(r / 255.0).clamp(0.0, 1.0)
|
||||
};
|
||||
|
||||
// Cursor avoidance.
|
||||
let dx = px - cursor_x;
|
||||
let dy = py - cursor_y;
|
||||
let cursor_dist = (dx * dx + dy * dy).sqrt();
|
||||
let cursor_factor =
|
||||
smoothstep(((cursor_dist - CURSOR_AVOID_RADIUS) / CURSOR_AVOID_FALLOFF).clamp(0.0, 1.0));
|
||||
let g = if temp <= 66.0 {
|
||||
let g = 99.4708025861 * temp.ln() - 161.1195681661;
|
||||
(g / 255.0).clamp(0.0, 1.0)
|
||||
} else {
|
||||
let g = 288.1221695283 * (temp - 60.0).powf(-0.0755148492);
|
||||
(g / 255.0).clamp(0.0, 1.0)
|
||||
};
|
||||
|
||||
let alpha = glow * cursor_factor;
|
||||
(alpha * 255.0) as u8
|
||||
}
|
||||
let b = if temp >= 66.0 {
|
||||
1.0
|
||||
} else if temp <= 19.0 {
|
||||
0.0
|
||||
} else {
|
||||
let b = 138.5177312231 * (temp - 10.0).ln() - 305.0447927307;
|
||||
(b / 255.0).clamp(0.0, 1.0)
|
||||
};
|
||||
|
||||
/// Signed distance field for a rounded rectangle.
|
||||
///
|
||||
/// Returns negative inside, positive outside, zero on the boundary.
|
||||
fn rounded_rect_sdf(px: f32, py: f32, cx: f32, cy: f32, hw: f32, hh: f32, r: f32) -> f32 {
|
||||
let dx = (px - cx).abs() - (hw - r);
|
||||
let dy = (py - cy).abs() - (hh - r);
|
||||
let outside = dx.max(0.0);
|
||||
let outside_y = dy.max(0.0);
|
||||
let outside_len = (outside * outside + outside_y * outside_y).sqrt();
|
||||
let inside = dx.max(dy).min(0.0);
|
||||
outside_len + inside - r
|
||||
}
|
||||
|
||||
/// Write a premultiplied white pixel with the given alpha.
|
||||
fn write_pixel(data: &mut [u8], idx: usize, alpha: u8) {
|
||||
if alpha == 0 {
|
||||
return;
|
||||
}
|
||||
let offset = idx * 4;
|
||||
data[offset] = alpha;
|
||||
data[offset + 1] = alpha;
|
||||
data[offset + 2] = alpha;
|
||||
data[offset + 3] = alpha;
|
||||
}
|
||||
|
||||
/// Hermite smoothstep: 3t^2 - 2t^3
|
||||
fn smoothstep(t: f32) -> f32 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
[r as f32, g as f32, b as f32]
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
struct Uniforms {
|
||||
screen_size: vec2f,
|
||||
cursor_pos: vec2f,
|
||||
// Outer rounded rect: inset from screen edge
|
||||
outer_inset: f32,
|
||||
outer_corner_radius: f32,
|
||||
// Inner rounded rect: inset further by ring_width
|
||||
ring_width: f32,
|
||||
inner_corner_radius: f32,
|
||||
// Glow
|
||||
glow_spread: f32,
|
||||
// Cursor avoidance
|
||||
cursor_avoid_radius: f32,
|
||||
cursor_avoid_falloff: f32,
|
||||
// Color temperature (linear RGB)
|
||||
_pad: f32,
|
||||
color: vec3f,
|
||||
_pad2: f32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
|
||||
let x = f32(i32(i) / 2) * 4.0 - 1.0;
|
||||
let y = f32(i32(i) % 2) * 4.0 - 1.0;
|
||||
return vec4f(x, y, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// Rounded rectangle SDF (Inigo Quilez).
|
||||
fn rounded_rect_sdf(p: vec2f, h: vec2f, r: f32) -> f32 {
|
||||
let q = abs(p) - h + vec2f(r);
|
||||
return length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - r;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(@builtin(position) pos: vec4f) -> @location(0) vec4f {
|
||||
let center = u.screen_size / 2.0;
|
||||
let p = pos.xy - center;
|
||||
|
||||
// Outer rounded rect
|
||||
let outer_half = center - vec2f(u.outer_inset);
|
||||
let sd_outer = rounded_rect_sdf(p, outer_half, u.outer_corner_radius);
|
||||
|
||||
// Inner rounded rect
|
||||
let inner_half = outer_half - vec2f(u.ring_width);
|
||||
let sd_inner = rounded_rect_sdf(p, inner_half, u.inner_corner_radius);
|
||||
|
||||
// Ring: inside outer AND outside inner = solid at full brightness
|
||||
var alpha: f32;
|
||||
if sd_outer <= 0.0 && sd_inner >= 0.0 {
|
||||
alpha = 1.0;
|
||||
} else if sd_outer > 0.0 {
|
||||
// Gaussian glow fading outward
|
||||
let t = sd_outer / u.glow_spread;
|
||||
alpha = exp(-3.0 * t * t);
|
||||
} else {
|
||||
// Gaussian glow fading inward
|
||||
let t = -sd_inner / u.glow_spread;
|
||||
alpha = exp(-3.0 * t * t);
|
||||
}
|
||||
|
||||
// Cursor avoidance
|
||||
let cursor_dist = distance(pos.xy, u.cursor_pos);
|
||||
let avoid_start = u.cursor_avoid_radius;
|
||||
let avoid_end = u.cursor_avoid_radius + u.cursor_avoid_falloff;
|
||||
if cursor_dist < avoid_end {
|
||||
let t_avoid = clamp((cursor_dist - avoid_start) / (avoid_end - avoid_start), 0.0, 1.0);
|
||||
alpha *= t_avoid * t_avoid * (3.0 - 2.0 * t_avoid);
|
||||
}
|
||||
|
||||
// Premultiplied color output
|
||||
let rgb = u.color * alpha;
|
||||
return vec4f(rgb, alpha);
|
||||
}
|
||||
+88
-73
@@ -1,12 +1,10 @@
|
||||
//! Wayland backend: layer-shell overlay with keyboard and pointer tracking.
|
||||
//!
|
||||
//! Creates a full-screen layer-shell surface for the ring light overlay.
|
||||
//! Tracks pointer position for cursor avoidance and captures keyboard
|
||||
//! for Escape to exit.
|
||||
//! Creates a full-screen layer-shell surface. Exposes raw Wayland handles
|
||||
//! for wgpu surface creation. No CPU pixel buffers — GPU renders directly.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Write;
|
||||
use std::os::fd::AsFd;
|
||||
use std::ptr::NonNull;
|
||||
use std::time::Duration;
|
||||
|
||||
use wayland_client::{
|
||||
@@ -15,32 +13,28 @@ use wayland_client::{
|
||||
wl_buffer, wl_compositor, wl_keyboard, wl_pointer, wl_region, wl_registry, wl_seat,
|
||||
wl_shm, wl_shm_pool, wl_surface,
|
||||
},
|
||||
Connection, Dispatch, EventQueue, QueueHandle, WEnum,
|
||||
Connection, Dispatch, EventQueue, Proxy, QueueHandle, WEnum,
|
||||
};
|
||||
use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use raw_window_handle::{
|
||||
RawDisplayHandle, RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle,
|
||||
};
|
||||
|
||||
// Linux evdev key code for Escape.
|
||||
const KEY_ESCAPE: u32 = 1;
|
||||
|
||||
/// Events emitted by the Wayland backend to the main loop.
|
||||
#[allow(dead_code)]
|
||||
pub enum OverlayEvent {
|
||||
/// Escape pressed or surface closed by compositor.
|
||||
Close,
|
||||
/// Pointer moved to (x, y) in surface-local coordinates.
|
||||
CursorMoved(f64, f64),
|
||||
/// Surface (re-)configured with new dimensions.
|
||||
Configure(u32, u32),
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// WaylandBackend — public API.
|
||||
// ===========================================================================
|
||||
|
||||
pub struct WaylandBackend {
|
||||
state: WaylandState,
|
||||
event_queue: EventQueue<WaylandState>,
|
||||
#[allow(dead_code)]
|
||||
queue_handle: QueueHandle<WaylandState>,
|
||||
}
|
||||
|
||||
@@ -73,9 +67,6 @@ impl WaylandBackend {
|
||||
if state.compositor.is_none() {
|
||||
anyhow::bail!("compositor does not advertise wl_compositor");
|
||||
}
|
||||
if state.shm.is_none() {
|
||||
anyhow::bail!("compositor does not advertise wl_shm");
|
||||
}
|
||||
if state.layer_shell.is_none() {
|
||||
anyhow::bail!(
|
||||
"compositor does not support wlr-layer-shell-unstable-v1. \
|
||||
@@ -105,10 +96,23 @@ impl WaylandBackend {
|
||||
(self.state.screen_w, self.state.screen_h)
|
||||
}
|
||||
|
||||
/// Wait up to `timeout` for the next event. Returns `None` on timeout
|
||||
/// or if the surface has been torn down.
|
||||
/// Raw Wayland display handle for wgpu surface creation.
|
||||
pub fn raw_display_handle(&self) -> Result<RawDisplayHandle> {
|
||||
let ptr = self.state.conn.backend().display_ptr() as *mut std::ffi::c_void;
|
||||
let nn = NonNull::new(ptr).context("wl_display pointer is null")?;
|
||||
Ok(RawDisplayHandle::Wayland(WaylandDisplayHandle::new(nn)))
|
||||
}
|
||||
|
||||
/// Raw Wayland surface handle for wgpu surface creation.
|
||||
pub fn raw_window_handle(&self) -> Result<RawWindowHandle> {
|
||||
let surface = self.state.surface.as_ref().context("no wl_surface")?;
|
||||
let ptr = surface.id().as_ptr() as *mut std::ffi::c_void;
|
||||
let nn = NonNull::new(ptr).context("wl_surface pointer is null")?;
|
||||
Ok(RawWindowHandle::Wayland(WaylandWindowHandle::new(nn)))
|
||||
}
|
||||
|
||||
/// Wait up to `timeout` for the next event. Returns `None` on timeout.
|
||||
pub fn next_event(&mut self, timeout: Duration) -> Result<Option<OverlayEvent>> {
|
||||
// First drain any already-buffered events.
|
||||
if let Some(ev) = self.state.pending_events.pop_front() {
|
||||
return Ok(Some(ev));
|
||||
}
|
||||
@@ -116,62 +120,71 @@ impl WaylandBackend {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Flush outgoing requests, then poll the fd with a timeout.
|
||||
self.event_queue
|
||||
.flush()
|
||||
.context("flush before poll")?;
|
||||
|
||||
let read_guard = self.event_queue.prepare_read().unwrap();
|
||||
let fd = read_guard.connection_fd();
|
||||
let mut pollfd = [nix::poll::PollFd::new(fd, nix::poll::PollFlags::POLLIN)];
|
||||
let millis = timeout.as_millis().min(u16::MAX as u128) as u16;
|
||||
let n = nix::poll::poll(&mut pollfd, millis).context("poll")?;
|
||||
|
||||
if n > 0 {
|
||||
// Data available — read and dispatch.
|
||||
let _ = read_guard.read();
|
||||
} else {
|
||||
// Timeout — drop the read guard (cancels the read).
|
||||
drop(read_guard);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Try to dispatch any already-buffered events first.
|
||||
self.event_queue
|
||||
.dispatch_pending(&mut self.state)
|
||||
.context("dispatch_pending")?;
|
||||
.context("dispatch_pending (pre-poll)")?;
|
||||
if let Some(ev) = self.state.pending_events.pop_front() {
|
||||
// Re-run coalescing below with this event re-queued.
|
||||
self.state.pending_events.push_front(ev);
|
||||
} else {
|
||||
// No pending events — try to read from the fd with a timeout.
|
||||
match self.event_queue.prepare_read() {
|
||||
Some(read_guard) => {
|
||||
let fd = read_guard.connection_fd();
|
||||
let mut pollfd =
|
||||
[nix::poll::PollFd::new(fd, nix::poll::PollFlags::POLLIN)];
|
||||
let millis = timeout.as_millis().min(u16::MAX as u128) as u16;
|
||||
let n = nix::poll::poll(&mut pollfd, millis).context("poll")?;
|
||||
|
||||
Ok(self.state.pending_events.pop_front())
|
||||
}
|
||||
if n > 0 {
|
||||
let _ = read_guard.read();
|
||||
} else {
|
||||
drop(read_guard);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
/// Copy an ARGB8888 pixel buffer to the overlay surface via wl_shm.
|
||||
pub fn present(&mut self, pixels: &[u8], width: u32, height: u32) -> Result<()> {
|
||||
let shm = self.state.shm.as_ref().context("wl_shm not available")?;
|
||||
let surface = self
|
||||
.state
|
||||
.surface
|
||||
.as_ref()
|
||||
.context("wl_surface not available")?;
|
||||
self.event_queue
|
||||
.dispatch_pending(&mut self.state)
|
||||
.context("dispatch_pending (post-read)")?;
|
||||
}
|
||||
None => {
|
||||
// Another queue has events buffered — dispatch ours.
|
||||
self.event_queue
|
||||
.dispatch_pending(&mut self.state)
|
||||
.context("dispatch_pending (no-read)")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let stride = width * 4;
|
||||
let mut file = tempfile::tempfile().context("create shm tempfile")?;
|
||||
file.write_all(pixels).context("write pixel buffer")?;
|
||||
|
||||
let pool = shm.create_pool(file.as_fd(), pixels.len() as i32, &self.queue_handle, ());
|
||||
let buffer = pool.create_buffer(
|
||||
0,
|
||||
width as i32,
|
||||
height as i32,
|
||||
stride as i32,
|
||||
wl_shm::Format::Argb8888,
|
||||
&self.queue_handle,
|
||||
(),
|
||||
);
|
||||
|
||||
surface.attach(Some(&buffer), 0, 0);
|
||||
surface.damage_buffer(0, 0, width as i32, height as i32);
|
||||
surface.commit();
|
||||
pool.destroy();
|
||||
Ok(())
|
||||
// Coalesce CursorMoved events.
|
||||
let mut last_cursor: Option<(f64, f64)> = None;
|
||||
let mut first_non_cursor: Option<OverlayEvent> = None;
|
||||
while let Some(ev) = self.state.pending_events.pop_front() {
|
||||
match ev {
|
||||
OverlayEvent::CursorMoved(x, y) => last_cursor = Some((x, y)),
|
||||
other => {
|
||||
first_non_cursor = Some(other);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ev) = first_non_cursor {
|
||||
if let Some((x, y)) = last_cursor {
|
||||
self.state
|
||||
.pending_events
|
||||
.push_front(OverlayEvent::CursorMoved(x, y));
|
||||
}
|
||||
return Ok(Some(ev));
|
||||
}
|
||||
if let Some((x, y)) = last_cursor {
|
||||
return Ok(Some(OverlayEvent::CursorMoved(x, y)));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +208,7 @@ struct WaylandState {
|
||||
conn: Connection,
|
||||
|
||||
compositor: Option<wl_compositor::WlCompositor>,
|
||||
#[allow(dead_code)]
|
||||
shm: Option<wl_shm::WlShm>,
|
||||
seat: Option<wl_seat::WlSeat>,
|
||||
layer_shell: Option<zwlr_layer_shell_v1::ZwlrLayerShellV1>,
|
||||
@@ -221,6 +235,11 @@ impl WaylandState {
|
||||
.context("zwlr_layer_shell_v1 not available")?;
|
||||
|
||||
let surface = compositor.create_surface(qh, ());
|
||||
|
||||
let input_region = compositor.create_region(qh, ());
|
||||
input_region.add(0, 0, i32::MAX, i32::MAX);
|
||||
surface.set_input_region(Some(&input_region));
|
||||
|
||||
let layer_surface = layer_shell.get_layer_surface(
|
||||
&surface,
|
||||
None,
|
||||
@@ -347,12 +366,8 @@ impl Dispatch<wl_pointer::WlPointer, ()> for WaylandState {
|
||||
surface_x,
|
||||
surface_y,
|
||||
..
|
||||
} => {
|
||||
state
|
||||
.pending_events
|
||||
.push_back(OverlayEvent::CursorMoved(surface_x, surface_y));
|
||||
}
|
||||
wl_pointer::Event::Motion {
|
||||
| wl_pointer::Event::Motion {
|
||||
surface_x,
|
||||
surface_y,
|
||||
..
|
||||
|
||||
Reference in new issue
Block a user