diff --git a/README.md b/README.md index cc45cab..50ed2b0 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ The config window edits the same file: Settings at the foot of the control centr ### Glass -`appearance.surface.design` picks what the island, the pills, the tray menu, the control centre and its popups are made of: `solid` (the wallpaper's gradient with an accent glow), `frosted` (a thin tint, a hairline white rim, a sheen along the top, a soft shadow) or `liquid` (clear glass, denser toward the bottom right, a light-catching edge, the accent's glow). Each design's opacity, rim and light are settings of their own (`appearance.solid|frosted|liquid.*`); the look itself is `src/surface.rs`, drawn by one shader (`src/surface.wgsl`: the shadow, the fill and the sheen or lit edge in one pass; the rim stays a border). On see-through glass a face fades with `.opacity()` instead of a veil, a GPU layer only while it fades. +`appearance.surface.design` picks what the island, the pills, the tray menu, the control centre and its popups are made of: `solid` (the wallpaper's gradient with an accent glow), `frosted` (a thin tint, a hairline white rim, a sheen along the top, a soft shadow), `liquid` (clear glass, denser toward the bottom right, a light-catching edge, the accent's glow) or `glass` (after Apple's Liquid Glass: a thin tint and an edge like the rim of a lens, brightest where the light falls in at the top left, a glint where it leaves at the bottom right, a thin specular line and a faint warm and cool fringe). Each design's opacity, rim and light are settings of their own (`appearance.solid|frosted|liquid|glass.*`); the look itself is `src/surface.rs`, drawn by one shader (`src/surface.wgsl`: the shadow, the fill and the sheen or lit edge in one pass; the rim stays a border). `glass` has its own, `src/glass.wgsl`, drawn twice: the shadow and tint under the content, the lit edge over it, so a photo on a card lies behind the glass. Unlike Apple's, it doesn't bend the desktop at the rim: a shell's shader never sees what's behind its window, Hyprland's blur is all it has. On see-through glass a face fades with `.opacity()` instead of a veil, a GPU layer only while it fades. Screenshots of each design, from when they were made: `docs/design/` (`base-solid-card.png`, `base-glass-0.5-card.png` for solid below 1, `frosted-0.62-card.png`, `liquid-0.4-card.png`, and a board for each glass design: the card three times side by side, with the pills below). @@ -142,7 +142,7 @@ Amane draws rectangles, borders, letters and pictures as cheap quads, and gradie `tools/perf.sh PID [SECONDS]` (on the desktop) prints a process's CPU (and its children's), GPU per engine (DRM fdinfo) and memory. -Amane can only blur what the shell draws, so the frosting comes from Hyprland: a layer rule for the shell's windows (the namespace match is a full match). `ignore_alpha` leaves pixels at or below that opacity unblurred, which keeps the clear margin around the pills sharp; keep it below the design's thinnest part, or that part shows the desktop unblurred (liquid at its default 0.4 is 0.22 in its top left corner). +Amane can only blur what the shell draws, so the frosting comes from Hyprland: a layer rule for the shell's windows (the namespace match is a full match). `ignore_alpha` leaves pixels at or below that opacity unblurred, which keeps the clear margin around the pills sharp; keep it below the design's thinnest part, or that part shows the desktop unblurred (liquid at its default 0.4 is 0.22 in its top left corner; glass is its `opacity` all over, 0.32 by default). ```lua hl.layer_rule({ @@ -185,7 +185,7 @@ src/png.rs a stdlib png writer, for tray icons sent as pixels src/system.rs what the control centre shows: iwd, wired cards, openvpn, power, the external GPU src/glyph.rs every drawn icon, as a Canvas or as shapes for a shared one src/theme.rs the shell's colours: the matugen theme file, the cross-fade; watches config.toml -src/surface.rs what the island, pills, menus and popups are made of: the solid, frosted and liquid designs as values for surface.wgsl +src/surface.rs what the island, pills, menus and popups are made of: the solid, frosted and liquid designs as values for surface.wgsl, glass for glass.wgsl src/*.wgsl shaders: surface (every surface), ring (the candles), curve (the equalizer's line) src/config.rs every setting, declared once (typed accessors + the window's list), config.toml read and written src/hypr.rs Hyprland's options for the config window: hyprctl descriptions/getoption, hl.config, amane.lua diff --git a/src/config.rs b/src/config.rs index 31b3d9f..b6a72ce 100644 --- a/src/config.rs +++ b/src/config.rs @@ -232,7 +232,7 @@ settings! { hover_ms: u64 = 220, 0..=2000; "How long hover highlights take to fade in and out" } surface { - design: String ["solid", "frosted", "liquid"] = String::from("solid"); "What the island, the pills, the menus, the control centre and its popups are made of: solid (the wallpaper's gradient with an accent glow), frosted (a thin tint, a hairline rim and a sheen) or liquid (clear glass with a light-catching edge). See-through ones need Hyprland's blur behind the shell: README, Glass" + design: String ["solid", "frosted", "liquid", "glass"] = String::from("solid"); "What the island, the pills, the menus, the control centre and its popups are made of: solid (the wallpaper's gradient with an accent glow), frosted (a thin tint, a hairline rim and a sheen), liquid (clear glass with a light-catching edge) or glass (after Apple's Liquid Glass: a thin tint with a lens-like rim). See-through ones need Hyprland's blur behind the shell: README, Glass" } solid { opacity: f32 = 1.0, 0.05..=1.0; "How opaque the solid design is; below 1 the desktop shows through" @@ -252,6 +252,14 @@ settings! { light: f32 = 0.235, 0.0..=1.0; "Opacity of the light caught at the top left edge" glow: f32 = 0.22, 0.0..=1.0; "Strength of the accent glow around each surface" } + glass { + opacity: f32 = 0.32, 0.05..=1.0; "How strongly the glass is tinted in its middle. Keep it above the ignore_alpha of Hyprland's layer rule, or the desktop behind isn't blurred (README, Glass)" + edge: f32 = 16.0, 1..=60; "Width of the band along the rim that catches the light like the edge of a lens, in pixels" + light: f32 = 0.55, 0.0..=1.0; "Strength of the light caught where it falls in (top left) and where it leaves (bottom right)" + rim: f32 = 0.5, 0.0..=1.0; "Strength of the thin bright line on the rim" + dispersion: f32 = 0.3, 0.0..=1.0; "Strength of the warm and cool fringe along the rim, as glass splits light" + shadow: f32 = 0.18, 0.0..=1.0; "Strength of the soft shadow under each surface" + } fallback { accent: Color = Color::rgb(247, 168, 200); "Accent, used until matugen writes ~/.config/amane/theme from the wallpaper" on_accent: Color = Color::rgb(40, 18, 32); "Text and icons on the accent colour, without a theme file" diff --git a/src/glass.wgsl b/src/glass.wgsl new file mode 100644 index 0000000..fd00d33 --- /dev/null +++ b/src/glass.wgsl @@ -0,0 +1,139 @@ +// glass after Apple's Liquid Glass, as far as a surface that can't see what's behind it +// gets: a thin, lightly tinted middle over Hyprland's blur, and an edge drawn like the rim +// of a lens: a band that brightens toward the rim where the light falls in (top left) and +// where it leaves again (bottom right), a thin specular line on the rim itself, and a faint +// warm and cool fringe. surface.rs fills the values: +// 0: shape width, height, corner radius, margin (room around the shape for the shadow) +// 1: shadow colour, its alpha already scaled by the shadow's strength +// 2: shadow deviation, its x and y offset, lift (0 to 1, hovered or raised) +// 3: width of the edge band in pixels, light, rim and fringe strengths (0 to 1) +// 4-6: the tint, top left to bottom right, plain colours with the middle's opacity as alpha +// 7.x: the layer: 1 the shadow and the tint, under the content; 2 the lit edge and the rim, +// over it (a photo on the card then lies behind the glass); 0 both + +fn erf7(x: f32) -> f32 { + let y = clamp(x * 1.1283791671, -100.0, 100.0); + let yy = y * y; + let z = y + (0.24295 + (0.03395 + 0.0104 * yy) * yy) * (y * yy); + + return z / sqrt(1.0 + z * z); +} + +// vello's blurred rounded rectangle at p, from its centre, as in surface.wgsl +fn shadow(p: vec2, w: f32, h: f32, radius: f32, deviation: f32) -> f32 { + let s = max(deviation, 1e-5); + let inv = 1.0 / s; + let min_edge = min(w, h); + let radius_max = 0.5 * min_edge; + let r0 = min(length(vec2(radius, s * 1.15)), radius_max); + let r1 = min(length(vec2(radius, s * 2.0)), radius_max); + let exponent = 2.0 * r1 / r0; + + let delta = 1.25 * s * (exp(-pow(0.5 * inv * w, 2.0)) - exp(-pow(0.5 * inv * h, 2.0))); + let width = w + min(delta, 0.0); + let height = h - max(delta, 0.0); + + let scale = 0.5 * erf7(inv * 0.5 * (max(width, height) - 0.5 * radius)); + + let y0 = abs(p.y) - (height * 0.5 - r1); + let x0 = abs(p.x) - (width * 0.5 - r1); + let d_pos = pow(pow(max(x0, 0.0), exponent) + pow(max(y0, 0.0), exponent), 1.0 / exponent); + let d = d_pos + min(max(x0, y0), 0.0) - r1; + + return scale * (erf7(inv * (min_edge + d)) - erf7(inv * d)); +} + +// signed distance to a rounded rectangle centred on 0, negative inside +fn rounded(p: vec2, half: vec2, radius: f32) -> f32 { + let r = min(radius, min(half.x, half.y)); + let q = abs(p) - half + vec2(r); + + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r; +} + +// the way out of the shape at p: across a side, or out from a corner's centre +fn outward(p: vec2, half: vec2, radius: f32) -> vec2 { + let r = min(radius, min(half.x, half.y)); + let q = abs(p) - half + vec2(r); + let side = select(vec2(-1.0), vec2(1.0), p >= vec2(0.0)); + + if q.x > 0.0 && q.y > 0.0 { + return normalize(q) * side; + } + + return select(vec2(0.0, side.y), vec2(side.x, 0.0), q.x > q.y); +} + +// premultiplied colours, one over the other +fn over(top: vec4, under: vec4) -> vec4 { + return top + under * (1.0 - top.a); +} + +// amane blends a plain colour over the window +fn plain(color: vec4) -> vec4 { + if color.a <= 0.0 { + return vec4(0.0); + } + + return vec4(color.rgb / color.a, color.a); +} + +@fragment +fn main(@location(0) uv: vec2) -> @location(0) vec4 { + let shape = values[0]; + let extent = shape.xy; + let centre = extent / 2.0; + let lift = values[2].w; + let edge = max(values[3].x, 1.0); + let light = values[3].y * (1.0 + 0.35 * lift); + let rim = values[3].z * (1.0 + 0.35 * lift); + let fringe = values[3].w; + + let layer = u32(values[7].x); + + let p = uv * size - vec2(shape.w); + let pixel = max(fwidth(p.x), 1e-4); + let from_centre = p - centre; + + let shade = select(shadow(from_centre - values[2].yz, extent.x, extent.y, shape.z, values[2].x) * values[1].a, 0.0, layer == 2u); + let under = vec4(values[1].rgb * shade, shade); + + let d = rounded(from_centre, centre, shape.z); + let inside = clamp(0.5 - d / pixel, 0.0, 1.0); + + if inside <= 0.0 { + return plain(under); + } + + // the tint, a gradient over the shape from its top left to its bottom right + let t = clamp(dot(p / max(extent, vec2(1.0)), vec2(0.5)), 0.0, 1.0); + let tint = select(mix(values[5], values[6], (t - 0.5) * 2.0), mix(values[4], values[5], t * 2.0), t < 0.5); + + var glass = select(vec4(tint.rgb * tint.a, tint.a), vec4(0.0), layer == 2u); + + if layer == 1u { + return plain(over(glass * inside, under)); + } + + // the lens: 1 on the rim, 0 deeper in than `edge`, steepest at the rim like a bevel + let band = pow(1.0 - smoothstep(0.0, edge, max(-d, 0.0)), 2.0); + + // which way this part of the rim faces: toward the light (top left) is 1, away is -1 + let facing = dot(outward(from_centre, centre, shape.z), vec2(-0.7071, -0.7071)); + + // a faint warm fringe toward the light and a cool one away from it + let hue = mix(vec3(0.75, 0.85, 1.0), vec3(1.0, 0.9, 0.78), facing * 0.5 + 0.5); + let tone = mix(vec3(1.0), hue, fringe); + + // brightest where the light comes in, a weaker glint where it goes out, a little all round + let caught = clamp(light * band * (0.9 * max(facing, 0.0) + 0.45 * max(-facing, 0.0) + 0.12), 0.0, 1.0); + + glass = over(vec4(tone * caught, caught), glass); + + // the specular line on the rim itself, about 1.5 px wide + let line = clamp(clamp((d + 1.5) / pixel, 0.0, 1.0) * rim * (0.35 + 0.65 * abs(facing)), 0.0, 1.0); + + glass = over(vec4(tone * line, line), glass); + + return plain(over(glass * inside, under)); +} diff --git a/src/surface.rs b/src/surface.rs index 2605b6b..864f67d 100644 --- a/src/surface.rs +++ b/src/surface.rs @@ -3,7 +3,7 @@ use std::sync::LazyLock; use amane::{Blend, Color, Rectangle, Stack, children}; -use crate::config::appearance::{frosted, liquid, solid, surface}; +use crate::config::appearance::{frosted, glass, liquid, solid, surface}; use crate::island::faded; use crate::theme; @@ -22,6 +22,10 @@ use crate::theme; * the rim stays a border, which amane draws as a quad */ pub fn shell(width: f32, height: f32, radius: f32, colors: &[Color; 3], lift: f32, content: Rectangle) -> Rectangle { + if surface::design() == "glass" { + return glass_shell(width, height, radius, colors, lift, content); + } + let theme = theme::now(); let look = match surface::design().as_str() { @@ -89,6 +93,49 @@ pub fn clear() -> bool { } static SHADER: LazyLock = LazyLock::new(|| shader_file("surface.wgsl", include_str!("surface.wgsl"))); +static GLASS: LazyLock = LazyLock::new(|| shader_file("glass.wgsl", include_str!("glass.wgsl"))); + +/* + * after Apple's Liquid Glass: glass.wgsl draws the shadow and the thin tint in the + * wallpaper's colours under the content, and the lens-like edge with its rim over it, so a + * photo on a card lies behind the glass; there's no border. the frosting behind is + * Hyprland's blur; the desktop isn't bent at the rim the way Apple's glass bends it, since + * a shell's shader can't see what's behind its window + */ +fn glass_shell(width: f32, height: f32, radius: f32, colors: &[Color; 3], lift: f32, content: Rectangle) -> Rectangle { + let (blur, drop): (f32, f32) = (22.0, 6.0); + let margin = (1.25 * blur + drop).ceil(); + + let mut shadow = plain(Color::BLACK); + + shadow[3] = (glass::shadow() + 0.1 * lift).clamp(0.0, 1.0); + + let tint = |color: Color| { + let mut tint = plain(color); + + tint[3] = glass::opacity().clamp(0.0, 1.0); + + tint + }; + + // the under layer covers the shadow's room around the shape too, the edge only the shape + let layer = |which: f32, margin: f32| { + let values = vec![ + [width, height, radius, margin], + shadow, + [blur / 2.0, 0.0, drop, lift], + [glass::edge(), glass::light(), glass::rim(), glass::dispersion()], + tint(colors[0]), + tint(colors[1]), + tint(colors[2]), + [which, 0.0, 0.0, 0.0], + ]; + + Rectangle::new().width(width + 2.0 * margin).height(height + 2.0 * margin).translate(-margin, -margin).shader(GLASS.as_path()).shader_values(values) + }; + + Rectangle::new().width(width).height(height).child(Stack::new(children![layer(1.0, margin), content, layer(2.0, 0.0)]).width(width).height(height)) +} // a shader's source is part of the shell; amane wants a file, so it goes beside cava's config pub fn shader_file(name: &str, source: &str) -> PathBuf {