diff --git a/Cargo.lock b/Cargo.lock index dc10f91..ff30ad2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -262,6 +262,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "downcast-rs" version = "1.2.1" @@ -363,6 +372,18 @@ dependencies = [ "r-efi", ] +[[package]] +name = "glow" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263218b0ba2b0715c3370fb04674f5f8aa331594b521c94ff0a8e4a8472d1386" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -460,6 +481,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -472,6 +503,16 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -722,6 +763,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "serde" version = "1.0.229" @@ -811,6 +858,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.16.2" @@ -1052,8 +1108,11 @@ dependencies = [ "anyhow", "chrono", "clap", + "glow", "image", + "khronos-egl", "libc", + "libloading", "niri-ipc", "rustix", "serde", @@ -1064,12 +1123,20 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "wayland-backend", "wayland-client", + "wayland-egl", "wayland-protocols", "wayland-protocols-wlr", "wl-clipboard-rs", ] +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasm-bindgen" version = "0.2.129" @@ -1124,6 +1191,7 @@ dependencies = [ "cc", "downcast-rs", "rustix", + "scoped-tls", "smallvec", "wayland-sys", ] @@ -1162,6 +1230,16 @@ dependencies = [ "xcursor", ] +[[package]] +name = "wayland-egl" +version = "0.32.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b97bdb7c49e5bd9b7562f38ff84f0dad47079fdc9e926f691a787f6dbc05451" +dependencies = [ + "wayland-backend", + "wayland-sys", +] + [[package]] name = "wayland-protocols" version = "0.32.13" @@ -1230,9 +1308,21 @@ version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ + "dlib", + "log", "pkg-config", ] +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 2ae05b5..13c69fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,8 +9,11 @@ license = "MIT" anyhow = "1.0.104" chrono = "0.4.45" clap = { version = "4.6.7", features = ["derive"] } +glow = "0.18.0" image = { version = "0.25.10", default-features = false, features = ["png"] } +khronos-egl = { version = "6.0.0", features = ["dynamic"] } libc = "0.2.189" +libloading = "0.8" niri-ipc = "=26.4.0" rustix = { version = "1.1.5", features = ["event", "fs", "pipe", "process", "stdio"] } serde = { version = "1.0.229", features = ["derive"] } @@ -20,7 +23,9 @@ smithay-client-toolkit = "0.21.1" toml = "1.1.6" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +wayland-backend = { version = "0.3.17", features = ["client_system"] } wayland-client = "0.31.15" +wayland-egl = "0.32.11" wayland-protocols = { version = "0.32.13", features = ["client", "staging"] } wayland-protocols-wlr = { version = "0.3.12", features = ["client"] } wl-clipboard-rs = "0.9.4" diff --git a/nix/package.ulu.nix b/nix/package.ulu.nix index ef5754b..bd847f5 100644 --- a/nix/package.ulu.nix +++ b/nix/package.ulu.nix @@ -34,8 +34,11 @@ doCheck = false; # Clicking a preview opens Satty. --suffix keeps a user's own Satty first. + # Zoom loads libEGL.so.1 (libglvnd); Mesa's drivers come from the system. postInstall = '' - wrapProgram $out/bin/valw --suffix PATH : ${lib.makeBinPath [ pkgs.satty ]} + wrapProgram $out/bin/valw \ + --suffix PATH : ${lib.makeBinPath [ pkgs.satty ]} \ + --suffix LD_LIBRARY_PATH : ${lib.makeLibraryPath [ pkgs.libglvnd ]} ''; meta = { diff --git a/src/doctor.rs b/src/doctor.rs index 52dbb08..bc5001f 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -142,6 +142,20 @@ pub fn check_save_dir(dir: &Path) -> Check { } } +/// EGL with an OpenGL ES 3 config; only zoom needs it. +pub fn check_egl(result: Result) -> Check { + match result { + Ok(vendor) => check(Level::Ok, format!("EGL ({vendor}) with OpenGL ES 3 (zoom)")), + Err(e) => check( + Level::Warn, + format!( + "no OpenGL ES 3 through EGL: {} (zoom won't work)", + one_line(&e) + ), + ), + } +} + /// Runs every check and prints one line each. Fails if any check failed. pub fn run() -> Result<()> { let mut checks = Vec::new(); @@ -158,6 +172,7 @@ pub fn run() -> Result<()> { ), )); } + checks.push(check_egl(crate::zoom::gl::probe(&wl.conn))); } Err(e) => checks.push(check(Level::Fail, format!("wayland: {}", one_line(&e)))), } @@ -182,6 +197,16 @@ pub fn run() -> Result<()> { mod tests { use super::*; + #[test] + fn egl_check_levels() { + let ok = check_egl(Ok("Mesa Project".into())); + assert_eq!(ok.level, Level::Ok); + assert!(ok.text.contains("Mesa Project"), "{}", ok.text); + let missing = check_egl(Err(anyhow::anyhow!("could not load libEGL.so.1"))); + assert_eq!(missing.level, Level::Warn, "only zoom needs it"); + assert!(missing.text.contains("zoom"), "{}", missing.text); + } + fn globals(list: &[(&str, u32)]) -> Vec<(String, u32)> { list.iter().map(|&(n, v)| (n.to_string(), v)).collect() } diff --git a/src/zoom/gl.rs b/src/zoom/gl.rs new file mode 100644 index 0000000..6224ead --- /dev/null +++ b/src/zoom/gl.rs @@ -0,0 +1,258 @@ +//! OpenGL ES 3 through EGL on a Wayland surface: just enough to show the +//! frozen frame zoomed in. + +use anyhow::{Context, Result, anyhow}; +use glow::HasContext; +use khronos_egl as egl; +use wayland_client::{Connection, Proxy, protocol::wl_surface::WlSurface}; + +use super::view::View; +use crate::frame::Bgrx; + +type Egl = egl::DynamicInstance; + +/// `EGL_PLATFORM_WAYLAND_KHR`. +const PLATFORM_WAYLAND: egl::Enum = 0x31D8; + +const VERTEX: &str = "#version 300 es +// One triangle that covers the screen. +void main() { + vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2)); + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0); +} +"; + +const FRAGMENT: &str = "#version 300 es +precision highp float; +uniform sampler2D frame; +uniform float scale; +// The frame pixel at the screen's top-left. +uniform vec2 origin; +// Centre and radius in screen px; radius 0 means off. +uniform vec3 flashlight; +out vec4 color; +void main() { + ivec2 size = textureSize(frame, 0); + // gl_FragCoord starts at the bottom-left; the frame at the top-left. + vec2 screen = vec2(gl_FragCoord.x, float(size.y) - gl_FragCoord.y); + ivec2 texel = clamp(ivec2(floor(origin + screen / scale)), ivec2(0), size - 1); + // The texture holds the frame's B, G, R, X bytes. + vec3 rgb = texelFetch(frame, texel, 0).bgr; + if (flashlight.z > 0.0) { + float d = distance(screen, flashlight.xy); + rgb *= mix(1.0, 0.25, smoothstep(flashlight.z - 1.5, flashlight.z + 1.5, d)); + } + color = vec4(rgb, 1.0); +} +"; + +/// Loads libEGL and opens the compositor's EGL display. +fn open(conn: &Connection) -> Result<(Egl, egl::Display)> { + let lib = + unsafe { libloading::Library::new("libEGL.so.1") }.context("could not load libEGL.so.1")?; + let egl = unsafe { Egl::load_required_from(lib) } + .map_err(|e| anyhow!("libEGL.so.1 has no EGL 1.5: {e}"))?; + let display = unsafe { + egl.get_platform_display( + PLATFORM_WAYLAND, + conn.backend().display_ptr().cast(), + &[egl::ATTRIB_NONE], + ) + } + .context("no EGL display for the Wayland connection")?; + egl.initialize(display) + .context("could not initialise EGL")?; + Ok((egl, display)) +} + +fn config(egl: &Egl, display: egl::Display) -> Result { + #[rustfmt::skip] + let attributes = [ + egl::SURFACE_TYPE, egl::WINDOW_BIT, + egl::RENDERABLE_TYPE, egl::OPENGL_ES3_BIT, + egl::RED_SIZE, 8, egl::GREEN_SIZE, 8, egl::BLUE_SIZE, 8, + egl::NONE, + ]; + egl.choose_first_config(display, &attributes) + .context("eglChooseConfig failed")? + .context("no EGL config for OpenGL ES 3 windows") +} + +/// The EGL vendor, if a window config for OpenGL ES 3 exists. +pub fn probe(conn: &Connection) -> Result { + let (egl, display) = open(conn)?; + let result = config(&egl, display).map(|_| { + egl.query_string(Some(display), egl::VENDOR) + .map(|v| v.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "unknown vendor".into()) + }); + let _ = egl.terminate(display); + result +} + +/// Draws the frozen frame on one surface. +pub struct Renderer { + gl: glow::Context, + scale: glow::UniformLocation, + origin: glow::UniformLocation, + flashlight: glow::UniformLocation, + egl: Egl, + display: egl::Display, + context: egl::Context, + surface: egl::Surface, + // Destroyed after the EGL surface that draws into it. + _window: wayland_egl::WlEglSurface, +} + +impl Renderer { + /// A GL surface of `size` buffer px on `surface`, showing `frame`. + pub fn new( + conn: &Connection, + surface: &WlSurface, + size: (u32, u32), + frame: &Bgrx, + ) -> Result { + let (egl, display) = open(conn)?; + egl.bind_api(egl::OPENGL_ES_API) + .context("no OpenGL ES in EGL")?; + let config = config(&egl, display)?; + let context = egl + .create_context( + display, + config, + None, + &[egl::CONTEXT_MAJOR_VERSION, 3, egl::NONE], + ) + .context("could not create an OpenGL ES 3 context")?; + let window = wayland_egl::WlEglSurface::new(surface.id(), size.0 as i32, size.1 as i32) + .context("could not create a wl_egl_window")?; + let egl_surface = unsafe { + egl.create_window_surface(display, config, window.ptr() as egl::NativeWindowType, None) + } + .context("could not create the EGL window surface")?; + egl.make_current(display, Some(egl_surface), Some(egl_surface), Some(context)) + .context("eglMakeCurrent failed")?; + // valw paces drawing with frame callbacks; swaps must not block. + egl.swap_interval(display, 0) + .context("eglSwapInterval failed")?; + + let gl = unsafe { + glow::Context::from_loader_function(|name| { + egl.get_proc_address(name) + .map_or(std::ptr::null(), |f| f as *const _) + }) + }; + let (scale, origin, flashlight) = unsafe { setup(&gl, size, frame)? }; + Ok(Renderer { + gl, + scale, + origin, + flashlight, + egl, + display, + context, + surface: egl_surface, + _window: window, + }) + } + + /// Draws `view` and swaps (which commits the surface). + pub fn draw(&self, view: &View, flashlight: Option<(f64, f64, f64)>) -> Result<()> { + let (x, y, r) = flashlight.unwrap_or_default(); + unsafe { + self.gl.uniform_1_f32(Some(&self.scale), view.scale as f32); + self.gl.uniform_2_f32( + Some(&self.origin), + view.origin.0 as f32, + view.origin.1 as f32, + ); + self.gl + .uniform_3_f32(Some(&self.flashlight), x as f32, y as f32, r as f32); + self.gl.draw_arrays(glow::TRIANGLES, 0, 3); + } + self.egl + .swap_buffers(self.display, self.surface) + .context("eglSwapBuffers failed") + } +} + +impl Drop for Renderer { + fn drop(&mut self) { + let _ = self.egl.make_current(self.display, None, None, None); + let _ = self.egl.destroy_surface(self.display, self.surface); + let _ = self.egl.destroy_context(self.display, self.context); + let _ = self.egl.terminate(self.display); + } +} + +/// Compiles the program, uploads the frame and returns the uniforms. +unsafe fn setup( + gl: &glow::Context, + size: (u32, u32), + frame: &Bgrx, +) -> Result<( + glow::UniformLocation, + glow::UniformLocation, + glow::UniformLocation, +)> { + unsafe { + tracing::info!("zoom renderer: {}", gl.get_parameter_string(glow::RENDERER)); + let program = gl.create_program().map_err(|e| anyhow!(e))?; + for (kind, source) in [ + (glow::VERTEX_SHADER, VERTEX), + (glow::FRAGMENT_SHADER, FRAGMENT), + ] { + let shader = gl.create_shader(kind).map_err(|e| anyhow!(e))?; + gl.shader_source(shader, source); + gl.compile_shader(shader); + if !gl.get_shader_compile_status(shader) { + anyhow::bail!("shader: {}", gl.get_shader_info_log(shader)); + } + gl.attach_shader(program, shader); + } + gl.link_program(program); + if !gl.get_program_link_status(program) { + anyhow::bail!("program: {}", gl.get_program_info_log(program)); + } + gl.use_program(Some(program)); + // GLES 3 needs a bound vertex array even without attributes. + let vao = gl.create_vertex_array().map_err(|e| anyhow!(e))?; + gl.bind_vertex_array(Some(vao)); + + let texture = gl.create_texture().map_err(|e| anyhow!(e))?; + gl.bind_texture(glow::TEXTURE_2D, Some(texture)); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_MIN_FILTER, + glow::NEAREST as i32, + ); + gl.tex_parameter_i32( + glow::TEXTURE_2D, + glow::TEXTURE_MAG_FILTER, + glow::NEAREST as i32, + ); + gl.tex_image_2d( + glow::TEXTURE_2D, + 0, + glow::RGBA8 as i32, + frame.width() as i32, + frame.height() as i32, + 0, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice(Some(frame.as_raw())), + ); + gl.viewport(0, 0, size.0 as i32, size.1 as i32); + + let uniform = |name: &str| { + gl.get_uniform_location(program, name) + .with_context(|| format!("no uniform {name}")) + }; + gl.uniform_1_i32(uniform("frame").ok().as_ref(), 0); + Ok(( + uniform("scale")?, + uniform("origin")?, + uniform("flashlight")?, + )) + } +} diff --git a/src/zoom/mod.rs b/src/zoom/mod.rs index 843e50d..429c905 100644 --- a/src/zoom/mod.rs +++ b/src/zoom/mod.rs @@ -1,3 +1,4 @@ //! Zoom mode: the frozen focused output, zoomed and panned on the GPU. +pub mod gl; pub mod view;