A glass design after Apple's Liquid Glass (appearance.surface.design = glass, appearance.glass.*): glass.wgsl draws the shadow and a thin tint under the content and a lens-like lit edge, specular rim and faint fringe over it

This commit is contained in:
server2
2026-10-20 16:24:19 +02:00
parent 90130add06
commit c24671e981
4 changed files with 199 additions and 5 deletions
+3 -3
View File
@@ -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
+9 -1
View File
@@ -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"
+139
View File
@@ -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<f32>, 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<f32>(radius, s * 1.15)), radius_max);
let r1 = min(length(vec2<f32>(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<f32>, half: vec2<f32>, radius: f32) -> f32 {
let r = min(radius, min(half.x, half.y));
let q = abs(p) - half + vec2<f32>(r);
return length(max(q, vec2<f32>(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<f32>, half: vec2<f32>, radius: f32) -> vec2<f32> {
let r = min(radius, min(half.x, half.y));
let q = abs(p) - half + vec2<f32>(r);
let side = select(vec2<f32>(-1.0), vec2<f32>(1.0), p >= vec2<f32>(0.0));
if q.x > 0.0 && q.y > 0.0 {
return normalize(q) * side;
}
return select(vec2<f32>(0.0, side.y), vec2<f32>(side.x, 0.0), q.x > q.y);
}
// premultiplied colours, one over the other
fn over(top: vec4<f32>, under: vec4<f32>) -> vec4<f32> {
return top + under * (1.0 - top.a);
}
// amane blends a plain colour over the window
fn plain(color: vec4<f32>) -> vec4<f32> {
if color.a <= 0.0 {
return vec4<f32>(0.0);
}
return vec4<f32>(color.rgb / color.a, color.a);
}
@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
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<f32>(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<f32>(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<f32>(1.0)), vec2<f32>(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<f32>(tint.rgb * tint.a, tint.a), vec4<f32>(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<f32>(-0.7071, -0.7071));
// a faint warm fringe toward the light and a cool one away from it
let hue = mix(vec3<f32>(0.75, 0.85, 1.0), vec3<f32>(1.0, 0.9, 0.78), facing * 0.5 + 0.5);
let tone = mix(vec3<f32>(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<f32>(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<f32>(tone * line, line), glass);
return plain(over(glass * inside, under));
}
+48 -1
View File
@@ -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<PathBuf> = LazyLock::new(|| shader_file("surface.wgsl", include_str!("surface.wgsl")));
static GLASS: LazyLock<PathBuf> = 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 {