Performance: surfaces, the candle ring and the equalizer curve as shaders, the pill's bars as quads; tools/perf.sh

A playing pill cost the iGPU about 2.9 ms and the CPU 3.7 ms a frame, 60 frames a
second: two vello passes over the whole island window for the surface's shadow and
gradients (vello keeps no gradient between frames) and one for the bars. Now 0.4 ms
GPU and 1.2 ms CPU. The open card went from 5.2 to 2.6 ms GPU and 6.2 to 3.4 ms CPU
a frame. surface.wgsl draws every design's shadow, fill and inner gradient in one
pass, with vello's own blurred-rectangle formula
This commit is contained in:
server2
2026-10-19 12:46:27 +02:00
parent 49a0bae039
commit 9b4334835a
7 changed files with 552 additions and 109 deletions
+11 -4
View File
@@ -132,7 +132,13 @@ The config window edits the same file: Settings at the foot of the control centr
### Glass ### 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`. 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) 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.
### Performance
Amane draws rectangles, borders, letters and pictures as cheap quads, and gradients, shadows and `Canvas` paths with vello, which renders over the whole window and then lays the result on top. vello keeps a result between frames only when its shapes didn't change and hold no gradient, so anything drawn with it on every frame costs a pass over the whole window. What redraws every frame is therefore kept off vello: the surfaces are a shader, the pill's bars are rounded rectangles, the candle ring and the equalizer's curve are shaders (`src/ring.wgsl`, `src/curve.wgsl`). A shader's file is written to `$XDG_RUNTIME_DIR/magic-island/` when it is first drawn. A shader must not contain the word `time`, or amane redraws its window on every frame.
`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).
@@ -177,14 +183,15 @@ 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/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/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/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 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/*.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/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 src/hypr.rs Hyprland's options for the config window: hyprctl descriptions/getoption, hl.config, amane.lua
src/settings.rs the config window, `config` IPC src/settings.rs the config window, `config` IPC
src/parse.rs pure text parsing with unit tests, no amane dependency src/parse.rs pure text parsing with unit tests, no amane dependency
matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme
island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq) island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq)
tools/ remote test driver, fake MPRIS players, a fake tray app (`fake_tray.py name|path`), see AGENTS.md tools/ remote test driver, fake MPRIS players, a fake tray app (`fake_tray.py name|path`), perf.sh (CPU, GPU, memory of a process), see AGENTS.md
``` ```
## Requirements ## Requirements
@@ -240,7 +247,7 @@ bind = SUPER, M, exec, amane ipc call island toggle
- `~/.config/hypr/amane.lua`: the Hyprland options set in the config window, as `hl.config`; rewritten by the shell. - `~/.config/hypr/amane.lua`: the Hyprland options set in the config window, as `hl.config`; rewritten by the shell.
- `$XDG_STATE_HOME/amane/` (`~/.local/state/amane/`): `last-played` (the song the island last heard, `key = value`) and a copy of its cover, for the card shown while nothing plays. - `$XDG_STATE_HOME/amane/` (`~/.local/state/amane/`): `last-played` (the song the island last heard, `key = value`) and a copy of its cover, for the card shown while nothing plays.
- `~/.config/amane/island-eq`: one line per player identity, `<player>\t<preset or Custom>\t<10 gains in dB>`. - `~/.config/amane/island-eq`: one line per player identity, `<player>\t<preset or Custom>\t<10 gains in dB>`.
- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`. On tmpfs, gone at logout. - `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-<key>.conf`, the shaders (`*.wgsl`). On tmpfs, gone at logout.
- Equalizer nodes are named `magic_island_eq.<key>`. The key is the player's MPRIS bus name without `org.mpris.MediaPlayer2.`, non-alphanumerics as `_` (e.g. `chromium_instance123`). - Equalizer nodes are named `magic_island_eq.<key>`. The key is the player's MPRIS bus name without `org.mpris.MediaPlayer2.`, non-alphanumerics as `_` (e.g. `chromium_instance123`).
## Known limits ## Known limits
+79
View File
@@ -0,0 +1,79 @@
// the equalizer's curve: a line through the sliders' knobs, eased between neighbours,
// shivering up and down with the music, a faint wide stroke under a thin bright one.
// island.rs fills the values:
// 0: x of the first point, width the points span, steps, bands
// 1: levels given, glow width, line width, unused
// 2: glow colour, 3: line colour (plain, 0 to 1)
// 4-6: the knobs' heights, one per band
// 7-15: the live levels, low to high, 0 to 1
const PI: f32 = 3.14159265359;
fn knob(band: u32) -> f32 {
return values[4u + band / 4u][band % 4u];
}
fn loudness(along: f32) -> f32 {
let count = u32(values[1].x);
if count == 0u {
return 0.0;
}
let index = min(u32(round(along * f32(count - 1u))), count - 1u);
return values[7u + index / 4u][index % 4u];
}
// the curve's point number `step`, as island.rs placed it on the old canvas
fn point(step: u32) -> vec2<f32> {
let steps = values[0].z;
let bands = u32(values[0].w);
let along = f32(step) / steps;
let place = along * f32(bands - 1u);
let band = min(u32(floor(place)), bands - 2u);
let eased = (1.0 - cos((place - f32(band)) * PI)) / 2.0;
let shiver = loudness(along) * 9.0 * select(0.6, -1.0, step % 2u == 0u);
return vec2<f32>(values[0].x + along * values[0].y, mix(knob(band), knob(band + 1u), eased) + shiver);
}
fn segment(p: vec2<f32>, a: vec2<f32>, b: vec2<f32>) -> f32 {
let ab = b - a;
let t = clamp(dot(p - a, ab) / max(dot(ab, ab), 1e-5), 0.0, 1.0);
return length(p - a - ab * t);
}
fn stroke(d: f32, width: f32, pixel: f32) -> f32 {
return clamp((width / 2.0 - d) / pixel + 0.5, 0.0, 1.0);
}
@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let p = uv * size;
let pixel = max(fwidth(p.x), 1e-4);
let steps = u32(values[0].z);
// the segment under this pixel and its neighbours: a stroke can't reach further
let here = i32(floor((p.x - values[0].x) / max(values[0].y, 1e-5) * values[0].z));
var d = 1e9;
for (var offset = -1; offset <= 1; offset++) {
let first = clamp(here + offset, 0, i32(steps) - 1);
d = min(d, segment(p, point(u32(first)), point(u32(first) + 1u)));
}
let glow = vec4<f32>(values[2].rgb * values[2].a, values[2].a) * stroke(d, values[1].y, pixel);
let line = vec4<f32>(values[3].rgb * values[3].a, values[3].a) * stroke(d, values[1].z, pixel);
let color = line + glow * (1.0 - line.a);
if color.a <= 0.0 {
return vec4<f32>(0.0);
}
return vec4<f32>(color.rgb / color.a, color.a);
}
+67 -77
View File
@@ -1,13 +1,13 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::f32::consts::{PI, TAU}; use std::path::PathBuf;
use std::sync::LazyLock;
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use amane::{ use amane::{
Animation, Argument, Blend, Bus, Canvas, Cap, Center, Color, Column, Easing, End, Full, Gradient, Image, Animation, Argument, Blend, Bus, Center, Color, Column, Easing, End, Full, Gradient, Image, InputArea, Layer,
InputArea, Layer, LayerWindow, Media, MediaPlayer, Monitor, Padding, Palette, Parent, Path, Pointer, LayerWindow, Media, MediaPlayer, Monitor, Padding, Palette, Parent, Pointer, Rectangle, Row, Scroll, ScrollArea,
Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack, Start, Text, Vertical, Service, Shadow, SpaceBetween, Stack, Start, Text, Vertical, Weight, Widget, Workspaces, Zone, children,
Weight, Widget, Workspaces, Zone, children, shapes,
}; };
use crate::art::Art; use crate::art::Art;
@@ -670,46 +670,35 @@ fn stepped(volume: u8, scroll: Scroll) -> u8 {
(i32::from(volume) + step).clamp(0, 100) as u8 (i32::from(volume) + step).clamp(0, 100) as u8
} }
static RING: LazyLock<PathBuf> = LazyLock::new(|| surface::shader_file("ring.wgsl", include_str!("ring.wgsl")));
static CURVE: LazyLock<PathBuf> = LazyLock::new(|| surface::shader_file("curve.wgsl", include_str!("curve.wgsl")));
/* /*
* every candle goes into one path per look (glow, body, flame) instead of a * the candles, drawn by ring.wgsl: as stroked paths they cost a vello pass over the whole
* shape each: 120 shapes cost this iGPU a third of its frame rate, 3 cost nothing * island window on every frame of the music. the shader gets half the ring's levels (the
* other half mirrors them), at most 44 of them, eased the way the paths were
*/ */
fn ring(levels: &[f32]) -> Canvas { fn ring(levels: &[f32]) -> Rectangle {
let center = c::disc::size() / 2.0; let candles = c::disc::candles();
let inner = c::disc::cover() / 2.0 + 7.0; let half = (candles / 2).max(1);
let given = half.min(44);
let mut glow = Path::new(); let theme = theme::now();
let mut body = Path::new(); let size = c::disc::size();
let mut flames = Path::new();
for index in 0..c::disc::candles() { let eased: Vec<f32> = (0..given).map(|slot| mirrored(levels, slot * half / given).powf(0.75)).collect();
// a gentle curve lifts the quiet bands, so the ring isn't all bass
let level = mirrored(levels, index).powf(0.75);
let (sin, cos) = (index as f32 / c::disc::candles() as f32 * TAU).sin_cos(); let mut values = vec![
[size / 2.0, c::disc::cover() / 2.0 + 7.0, candles as f32, given as f32],
[c::disc::candle_reach(), 9.0, 4.5, 4.0],
surface::plain(faded(theme.accent, 50)),
surface::plain(faded(theme.accent, 215)),
surface::plain(theme.flame),
];
let reach = inner + 3.0 + level * c::disc::candle_reach(); values.extend(surface::rows(&eased));
let (x0, y0) = (center + sin * inner, center - cos * inner); Rectangle::new().width(size).height(size).shader(RING.as_path()).shader_values(values)
let (x1, y1) = (center + sin * reach, center - cos * reach);
body = body.move_to(x0, y0).line_to(x1, y1);
// the loud ones glow, and burn at the tip
if level > 0.25 {
glow = glow.move_to(x0, y0).line_to(x1, y1);
}
if level > 0.45 {
flames = flames.move_to(x1 - sin * 0.5, y1 + cos * 0.5).line_to(x1, y1);
}
}
Canvas::new().width(c::disc::size()).height(c::disc::size()).shapes(shapes![
glow.stroke(9.0, faded(theme::now().accent, 50)).cap(Cap::Round),
body.stroke(4.5, faded(theme::now().accent, 215)).cap(Cap::Round),
flames.stroke(4.0, theme::now().flame).cap(Cap::Round),
])
} }
// low frequencies at the top, rising down both sides, so the ring is symmetric // low frequencies at the top, rising down both sides, so the ring is symmetric
@@ -1157,40 +1146,34 @@ fn equalizer(scene: &Scene) -> Column {
} }
// the curve through the knobs, shivering with the player's sound // the curve through the knobs, shivering with the player's sound
fn curve(gains: &[f32; 10], levels: &[f32]) -> Canvas { /*
let column = inner_w() / crate::config::sound::equalizer::bands().len() as f32; * drawn by curve.wgsl, for the same reason as the ring: the points are worked out there,
* from the knobs' heights and at most 36 live levels
*/
fn curve(gains: &[f32; 10], levels: &[f32]) -> Rectangle {
let bands = crate::config::sound::equalizer::bands().len().clamp(2, gains.len());
let column = inner_w() / bands as f32;
let points: Vec<(f32, f32)> = (0..=c::equalizer::curve_steps()) let given = levels.len().min(36);
.map(|step| { let sampled: Vec<f32> = (0..given).map(|slot| levels[if given > 1 { slot * (levels.len() - 1) / (given - 1) } else { 0 }]).collect();
let along = step as f32 / c::equalizer::curve_steps() as f32;
// cosine blend between neighbouring knobs keeps the curve round let knobs: Vec<f32> = gains[..bands].iter().map(|&gain| knob_y(gain)).collect();
let place = along * (crate::config::sound::equalizer::bands().len() - 1) as f32;
let band = (place.floor() as usize).min(crate::config::sound::equalizer::bands().len() - 2);
let eased = (1.0 - ((place - band as f32) * PI).cos()) / 2.0;
let y = mix(knob_y(gains[band]), knob_y(gains[band + 1]), eased); let mut values = vec![
[column / 2.0, inner_w() - column, c::equalizer::curve_steps() as f32, bands as f32],
[given as f32, 7.0, 2.0, 0.0],
surface::plain(faded(theme::now().accent, 45)),
surface::plain(faded(theme::now().accent, 235)),
];
let level = if levels.is_empty() { 0.0 } else { levels[((along * (levels.len() - 1) as f32).round() as usize).min(levels.len() - 1)] }; // three rows of knobs whatever their number, so the levels always start at row 7
let mut knob_rows = surface::rows(&knobs);
let shiver = level * 9.0 * if step % 2 == 0 { -1.0 } else { 0.6 }; knob_rows.resize(3, [0.0; 4]);
values.extend(knob_rows);
values.extend(surface::rows(&sampled));
(column / 2.0 + along * (inner_w() - column), y + shiver) Rectangle::new().width(inner_w()).height(c::equalizer::slider_height()).shader(CURVE.as_path()).shader_values(values)
})
.collect();
let stroke = |width: f32, alpha: u8| {
let mut path = Path::new().move_to(points[0].0, points[0].1);
for (x, y) in &points[1..] {
path = path.line_to(*x, *y);
}
path.stroke(width, faded(theme::now().accent, alpha)).cap(Cap::Round)
};
// a wide faint stroke under a thin bright one reads as a glow
Canvas::new().width(inner_w()).height(c::equalizer::slider_height()).shapes(shapes![stroke(7.0, 45), stroke(2.0, 235)])
} }
fn presets(scene: &Scene) -> Column { fn presets(scene: &Scene) -> Column {
@@ -1351,20 +1334,27 @@ pub fn cover(art: &str, size: f32, rim: f32, hole: bool) -> Rectangle {
/* /*
* the pill's visualiser: seven bars from the shown player's own sound, glowing when * the pill's visualiser: seven bars from the shown player's own sound, glowing when
* loud; a pause sign while paused, and still bars while the capture starts or the * loud; a pause sign while paused, and still bars while the capture starts or the
* player has no stream to listen to * player has no stream to listen to. the bars are rounded rectangles, which amane draws
* as quads: as stroked paths on a canvas they cost a vello pass over the whole island
* window on every frame of the music
*/ */
fn pill_levels(playing: bool, levels: &[f32]) -> Canvas { fn pill_levels(playing: bool, levels: &[f32]) -> Rectangle {
let (bars, width, height) = (c::pill::bars(), c::pill::bars_width(), c::pill::bars_height()); let (bars, width, height) = (c::pill::bars(), c::pill::bars_width(), c::pill::bars_height());
if !playing { if !playing {
return icon(Glyph::Pause, 13.0, theme::now().dim); return Rectangle::new().width(13.0).height(13.0).child(icon(Glyph::Pause, 13.0, theme::now().dim));
} }
// lows on the left, highs on the right, no mirroring; every bar grows up and down from the middle line // lows on the left, highs on the right, no mirroring; every bar grows up and down from the middle line
let middle = height / 2.0; let middle = height / 2.0;
let mut glow = Path::new(); // a round-capped line `thick` wide from y0 to y1 at x: a pill shape reaching past both ends by half its width
let mut body = Path::new(); let line = |x: f32, y0: f32, y1: f32, thick: f32, color: Color| {
Rectangle::new().width(thick).height(y1 - y0 + thick).radius(Full).fill(color).translate(x - thick / 2.0, y0 - thick / 2.0)
};
let mut glow: Vec<Box<dyn Widget>> = Vec::new();
let mut body: Vec<Box<dyn Widget>> = Vec::new();
for index in 0..bars { for index in 0..bars {
let level = if levels.is_empty() { let level = if levels.is_empty() {
@@ -1381,17 +1371,17 @@ fn pill_levels(playing: bool, levels: &[f32]) -> Canvas {
let half = 1.5 + level * (middle - 3.0); let half = 1.5 + level * (middle - 3.0);
let x = 3.0 + index as f32 * (width - 6.0) / bars.saturating_sub(1).max(1) as f32; let x = 3.0 + index as f32 * (width - 6.0) / bars.saturating_sub(1).max(1) as f32;
body = body.move_to(x, middle - half).line_to(x, middle + half); body.push(Box::new(line(x, middle - half, middle + half, 3.2, theme::now().accent)));
if level > 0.3 { if level > 0.3 {
glow = glow.move_to(x, middle - half).line_to(x, middle + half); glow.push(Box::new(line(x, middle - half, middle + half, 7.0, faded(theme::now().accent, 55))));
} }
} }
Canvas::new().width(width).height(height).shapes(shapes![ // every glow under every bar, as the two strokes were
glow.stroke(7.0, faded(theme::now().accent, 55)).cap(Cap::Round), glow.extend(body);
body.stroke(3.2, theme::now().accent).cap(Cap::Round),
]) Rectangle::new().width(width).height(height).child(Stack::new(glow).width(width).height(height))
} }
// the loudest level in one of `groups` equal slices of the spectrum, low to high // the loudest level in one of `groups` equal slices of the spectrum, low to high
+90
View File
@@ -0,0 +1,90 @@
// the ring of candles around the cd, one candle per angle step, each a line from the
// cover's edge outward as far as its level reaches, with a glow under the loud ones and
// a flame at the tip of the loudest. island.rs fills the values:
// 0: centre, inner radius, candles, levels given
// 1: reach at full level, glow width, body width, flame width
// 2: glow colour, 3: body colour, 4: flame colour (plain, 0 to 1)
// 5-15: levels for half the ring (the other half mirrors it), already eased, 0 to 1
const TAU: f32 = 6.28318530718;
fn level(step: u32, half: u32) -> f32 {
let count = u32(values[0].w);
if count == 0u {
return 0.0;
}
let index = min(step * count / max(half, 1u), count - 1u);
return values[5u + index / 4u][index % 4u];
}
// distance from p to the line from `start` to `end` along `direction`, out from the centre
fn line(p: vec2<f32>, direction: vec2<f32>, start: f32, end: f32) -> f32 {
let along = dot(p, direction);
let across = dot(p, vec2<f32>(-direction.y, direction.x));
return length(vec2<f32>(across, along - clamp(along, start, end)));
}
// how much of a pixel a round-capped stroke `width` wide covers, at distance d from its line
fn stroke(d: f32, width: f32, pixel: f32) -> f32 {
return clamp((width / 2.0 - d) / pixel + 0.5, 0.0, 1.0);
}
fn over(top: vec4<f32>, under: vec4<f32>) -> vec4<f32> {
return top + under * (1.0 - top.a);
}
@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let ring = values[0];
let widths = values[1];
let candles = u32(ring.z);
let half = candles / 2u;
let inner = ring.y;
let p = uv * size - vec2<f32>(ring.x);
let pixel = max(fwidth(p.x), 1e-4);
// the nearest candle by angle, 0 at the top going clockwise, and its neighbours
let angle = atan2(p.x, -p.y);
let nearest = i32(round(select(angle, angle + TAU, angle < 0.0) / TAU * f32(candles)));
var glow = 0.0;
var body = 0.0;
var flame = 0.0;
for (var offset = -1; offset <= 1; offset++) {
let index = u32((nearest + offset + i32(candles)) % i32(candles));
let step = select(candles - 1u - index, index, index < half);
let loud = level(step, half);
let a = f32(index) / f32(candles) * TAU;
let direction = vec2<f32>(sin(a), -cos(a));
let reach = inner + 3.0 + loud * widths.x;
let d = line(p, direction, inner, reach);
body = max(body, stroke(d, widths.z, pixel));
if loud > 0.25 {
glow = max(glow, stroke(d, widths.y, pixel));
}
if loud > 0.45 {
flame = max(flame, stroke(line(p, direction, reach - 0.5, reach), widths.w, pixel));
}
}
var color = vec4<f32>(values[2].rgb * values[2].a, values[2].a) * glow;
color = over(vec4<f32>(values[3].rgb * values[3].a, values[3].a) * body, color);
color = over(vec4<f32>(values[4].rgb * values[4].a, values[4].a) * flame, color);
if color.a <= 0.0 {
return vec4<f32>(0.0);
}
return vec4<f32>(color.rgb / color.a, color.a);
}
+128 -28
View File
@@ -1,7 +1,10 @@
use amane::{Blend, Color, Gradient, Rectangle, Shadow, Stack, children}; use std::path::PathBuf;
use std::sync::LazyLock;
use amane::{Blend, Color, Rectangle, Stack, children};
use crate::config::appearance::{frosted, liquid, solid, surface}; use crate::config::appearance::{frosted, liquid, solid, surface};
use crate::island::{backdrop, faded}; use crate::island::faded;
use crate::theme; use crate::theme;
/* /*
@@ -10,48 +13,74 @@ use crate::theme;
* `content` (already width x height, with its own padding and alignment) in the surface. * `content` (already width x height, with its own padding and alignment) in the surface.
* `lift` (0 to 1) is how raised it is: hovered, or a popup over the rest. * `lift` (0 to 1) is how raised it is: hovered, or a popup over the rest.
* see-through designs need Hyprland's blur behind the shell (README: Glass) * see-through designs need Hyprland's blur behind the shell (README: Glass)
*
* every design is a shadow, a gradient fill and maybe a second gradient just inside the
* rim, which surface.wgsl draws in one pass. drawn as amane's shadow and gradients they
* took two vello passes over the whole window on every frame (vello keeps no gradient
* between frames), so a playing pill, whose bars redraw it 60 times a second, cost the
* igpu about 3 ms a frame; the shader costs a few hundred thousand pixels of arithmetic.
* 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 { pub fn shell(width: f32, height: f32, radius: f32, colors: &[Color; 3], lift: f32, content: Rectangle) -> Rectangle {
let theme = theme::now(); let theme = theme::now();
let base = Rectangle::new().width(width).height(height).radius(radius);
match surface::design().as_str() { let look = match surface::design().as_str() {
// after Apple's materials: a thin tint, a hairline light rim (the accent when hovered), // after Apple's materials: a thin tint, a hairline light rim (the accent when hovered),
// a soft highlight along the top as if lit from above, one soft low shadow instead of a glow // a soft highlight along the top as if lit from above, one soft low shadow instead of a glow
"frosted" => { "frosted" => {
let sheen = inner(width, height, radius).fill(Gradient::linear(180.0, [(0.0, white(frosted::sheen())), (0.3, white(frosted::sheen() * 0.26)), (0.65, white(0.0))])); let a = alpha(frosted::opacity());
let sheen = frosted::sheen();
base.fill(backdrop(colors, alpha(frosted::opacity()))) Look {
.border(1.0, Color::blend(white(frosted::rim()), faded(theme.accent, 150), lift)) shadow: (Color::BLACK, frosted::shadow() + 0.1 * lift, 24.0, (0.0, 6.0)),
.shadow(Shadow::drop(Color::BLACK).opacity(frosted::shadow() + 0.1 * lift).blur(24.0).offset(0.0, 6.0)) fill: (150.0, vec![(0.0, faded(colors[0], a)), (0.55, faded(colors[1], a)), (1.0, faded(colors[2], a))]),
.child(Stack::new(children![sheen, content]).width(width).height(height)) inner: (180.0, vec![(0.0, white(sheen)), (0.3, white(sheen * 0.26)), (0.65, white(0.0))]),
rim: (1.0, Color::blend(white(frosted::rim()), faded(theme.accent, 150), lift)),
}
} }
// clear glass, denser toward the bottom right where the equalizer's labels sit; a bright // clear glass, denser toward the bottom right where the equalizer's labels sit; a bright
// edge where light enters at the top left and leaves at the bottom right; the accent's glow // edge where light enters at the top left and leaves at the bottom right; the accent's glow
"liquid" => { "liquid" => {
let glass = liquid::opacity(); let glass = liquid::opacity();
let fill = Gradient::linear(150.0, [(0.0, faded(colors[0], alpha(glass * 0.55))), (0.55, faded(colors[1], alpha(glass))), (1.0, faded(colors[2], alpha(glass * 1.6)))]);
let light = liquid::light(); let light = liquid::light();
let edge = inner(width, height, radius).fill(Gradient::linear(
135.0,
[(0.0, white(light)), (0.16, white(light * 0.23)), (0.4, white(0.0)), (0.82, white(0.0)), (1.0, white(light * 0.43))],
));
base.fill(fill) Look {
.border(liquid::rim_width(), Color::blend(white(liquid::rim()), faded(theme.flame, 200), lift)) shadow: (theme.accent, liquid::glow() + 0.3 * lift, 24.0, (0.0, 0.0)),
.shadow(Shadow::drop(theme.accent).opacity(liquid::glow() + 0.3 * lift).blur(24.0)) fill: (150.0, vec![(0.0, faded(colors[0], alpha(glass * 0.55))), (0.55, faded(colors[1], alpha(glass))), (1.0, faded(colors[2], alpha(glass * 1.6)))]),
.child(Stack::new(children![edge, content]).width(width).height(height)) inner: (135.0, vec![(0.0, white(light)), (0.16, white(light * 0.23)), (0.4, white(0.0)), (0.82, white(0.0)), (1.0, white(light * 0.43))]),
rim: (liquid::rim_width(), Color::blend(white(liquid::rim()), faded(theme.flame, 200), lift)),
}
} }
// the original: the wallpaper's gradient, a 1 px rim, a soft glow in the accent // the original: the wallpaper's gradient, a 1 px rim, a soft glow in the accent
_ => base _ => {
.fill(backdrop(colors, alpha(solid::opacity()))) let a = alpha(solid::opacity());
.border(1.0, Color::blend(theme.outline, faded(theme.accent, 110), lift))
.shadow(Shadow::drop(theme.accent).opacity(solid::glow() + (solid::hover_glow() - solid::glow()) * lift).blur(22.0)) Look {
.child(content), shadow: (theme.accent, solid::glow() + (solid::hover_glow() - solid::glow()) * lift, 22.0, (0.0, 0.0)),
} fill: (150.0, vec![(0.0, faded(colors[0], a)), (0.55, faded(colors[1], a)), (1.0, faded(colors[2], a))]),
inner: (0.0, Vec::new()),
rim: (1.0, Color::blend(theme.outline, faded(theme.accent, 110), lift)),
}
}
};
// room for the shadow: vello draws a blurred rectangle 2.5 deviations (half the blur each) past its edge
let (_, _, blur, (dx, dy)) = look.shadow;
let margin = (1.25 * blur + dx.abs().max(dy.abs())).ceil();
let paint = Rectangle::new()
.width(width + 2.0 * margin)
.height(height + 2.0 * margin)
.translate(-margin, -margin)
.shader(SHADER.as_path())
.shader_values(look.values(width, height, radius, margin));
let (thickness, rim_color) = look.rim;
let rim = Rectangle::new().width(width).height(height).radius(radius).border(thickness, rim_color);
Rectangle::new().width(width).height(height).child(Stack::new(children![paint, rim, content]).width(width).height(height))
} }
// whether the desktop shows through: then a veil can't hide a face, so faces fade with .opacity() // whether the desktop shows through: then a veil can't hide a face, so faces fade with .opacity()
@@ -59,9 +88,80 @@ pub fn clear() -> bool {
surface::design() != "solid" || solid::opacity() < 1.0 surface::design() != "solid" || solid::opacity() < 1.0
} }
// a layer just inside the rim, so the rim stays crisp over it static SHADER: LazyLock<PathBuf> = LazyLock::new(|| shader_file("surface.wgsl", include_str!("surface.wgsl")));
fn inner(width: f32, height: f32, radius: f32) -> Rectangle {
Rectangle::new().width(width - 2.0).height(height - 2.0).radius((radius - 1.0).max(0.0)).translate(1.0, 1.0) // 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 {
let path = crate::art::folder().join(name);
let _ = std::fs::write(&path, source);
path
}
// numbers as a shader's rows of four, the last one padded with 0
pub fn rows(numbers: &[f32]) -> Vec<[f32; 4]> {
numbers.chunks(4).map(|chunk| std::array::from_fn(|index| chunk.get(index).copied().unwrap_or(0.0))).collect()
}
type Stops = Vec<(f32, Color)>;
struct Look {
// colour, strength, blur, offset
shadow: (Color, f32, f32, (f32, f32)),
// angle and stops: up to 3 for the fill, 5 for the gradient inside the rim (none: no stops)
fill: (f32, Stops),
inner: (f32, Stops),
// width and colour
rim: (f32, Color),
}
impl Look {
// the rows surface.wgsl reads, see its top
fn values(&self, width: f32, height: f32, radius: f32, margin: f32) -> Vec<[f32; 4]> {
let (color, strength, blur, (dx, dy)) = self.shadow;
let mut shadow = plain(color);
shadow[3] *= strength.clamp(0.0, 1.0);
let mut rows = vec![
[width, height, radius, margin],
shadow,
[blur / 2.0, dx, dy, 1.0],
[self.fill.0, self.inner.0, self.fill.1.len().min(3) as f32, self.inner.1.len().min(5) as f32],
];
let (colors, offsets) = stops::<3>(&self.fill.1);
rows.extend(colors);
rows.push([offsets[0], offsets[1], offsets[2], 0.0]);
let (colors, offsets) = stops::<5>(&self.inner.1);
rows.extend(colors);
rows.push([offsets[0], offsets[1], offsets[2], offsets[3]]);
rows.push([offsets[4], 0.0, 0.0, 0.0]);
rows
}
}
// N (at most 5) stops as colours and their offsets, missing ones clear
fn stops<const N: usize>(stops: &[(f32, Color)]) -> ([[f32; 4]; N], [f32; 5]) {
let mut colors = [[0.0; 4]; N];
let mut offsets = [0.0; 5];
for (index, (offset, color)) in stops.iter().take(N).enumerate() {
colors[index] = plain(*color);
offsets[index] = *offset;
}
(colors, offsets)
}
// a colour as a shader reads it: plain, not premultiplied, 0 to 1
pub fn plain(color: Color) -> [f32; 4] {
[color.red(), color.green(), color.blue(), color.alpha()].map(|part| f32::from(part) / 255.0)
} }
// an opacity, 0 to 1, as a colour's alpha // an opacity, 0 to 1, as a colour's alpha
+148
View File
@@ -0,0 +1,148 @@
// a surface in one pass: its drop shadow, its gradient fill and a second gradient just
// inside the rim (frosted's sheen, liquid's lit edge), shaped by the rounded outline
// worked out here rather than by a path, so amane lays it straight onto the window
// without a layer of its own. 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, the inset of the inner gradient
// 3: fill angle, inner angle, fill stops (up to 3), inner stops (0 to 5)
// 4-6: fill colours, 7: their offsets
// 8-12: inner colours, 13: offsets of the first four, 14.x: the fifth's
// colours are plain (not premultiplied), 0 to 1. the shadow follows vello's own
// blurred rounded rectangle, the gradients amane's linear ones, so it looks the same
// vello's error function approximation (raphlinus.github.io, blurred rounded rects)
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
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;
}
// how much of a pixel `pixel` wide the shape covers at distance d
fn cover(d: f32, pixel: f32) -> f32 {
return clamp(0.5 - d / pixel, 0.0, 1.0);
}
// where p falls along amane's linear gradient over a box at `origin`, 0 to 1:
// a line through the centre, 0 degrees pointing up, long enough to reach the corners
fn along(p: vec2<f32>, origin: vec2<f32>, extent: vec2<f32>, angle: f32) -> f32 {
let a = radians(angle);
let direction = vec2<f32>(sin(a), -cos(a));
let half_length = (extent.x * abs(direction.x) + extent.y * abs(direction.y)) / 2.0;
return clamp(0.5 + dot(p - origin - extent / 2.0, direction) / max(2.0 * half_length, 1e-5), 0.0, 1.0);
}
fn premultiplied(c: vec4<f32>) -> vec4<f32> {
return vec4<f32>(c.rgb * c.a, c.a);
}
// the fill's colour at t, premultiplied; stops past the last repeat it
fn fill_color(t: f32) -> vec4<f32> {
let count = u32(values[3].z);
var color = premultiplied(values[4]);
for (var i = 1u; i < count; i++) {
let lo = values[7][i - 1u];
let hi = values[7][i];
let k = clamp((t - lo) / max(hi - lo, 1e-5), 0.0, 1.0);
color = select(color, mix(premultiplied(values[3u + i]), premultiplied(values[4u + i]), k), t > lo);
}
return color;
}
fn inner_offset(i: u32) -> f32 {
return select(values[13][min(i, 3u)], values[14].x, i == 4u);
}
fn inner_color(t: f32) -> vec4<f32> {
let count = u32(values[3].w);
var color = premultiplied(values[8]);
for (var i = 1u; i < count; i++) {
let lo = inner_offset(i - 1u);
let hi = inner_offset(i);
let k = clamp((t - lo) / max(hi - lo, 1e-5), 0.0, 1.0);
color = select(color, mix(premultiplied(values[7u + i]), premultiplied(values[8u + i]), k), t > lo);
}
return color;
}
@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
let shape = values[0];
let margin = shape.w;
let extent = shape.xy;
// this pixel, from the shape's top left corner
let p = uv * size - vec2<f32>(margin);
let pixel = max(fwidth(p.x), 1e-4);
let centre = extent / 2.0;
// the shadow, under everything
let offset = values[2].yz;
let shade = shadow(p - centre - offset, extent.x, extent.y, shape.z, values[2].x);
var color = premultiplied(vec4<f32>(values[1].rgb, values[1].a * shade));
// the fill over it
let inside = cover(rounded(p - centre, centre, shape.z), pixel);
let fill = fill_color(along(p, vec2<f32>(0.0), extent, values[3].x)) * inside;
color = fill + color * (1.0 - fill.a);
// the inner gradient, inset from the rim by values[2].w
if values[3].w > 0.0 {
let inset = values[2].w;
let inner = extent - vec2<f32>(2.0 * inset);
let within = cover(rounded(p - centre, inner / 2.0, max(shape.z - inset, 0.0)), pixel);
let light = inner_color(along(p, vec2<f32>(inset), inner, values[3].y)) * within;
color = light + color * (1.0 - light.a);
}
// amane blends a plain colour over the window
if color.a <= 0.0 {
return vec4<f32>(0.0);
}
return vec4<f32>(color.rgb / color.a, color.a);
}
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# perf.sh PID [SECONDS]: a process's cpu %, the cpu % of the commands it ran and waited for
# (children), its gpu busy % per engine (drm fdinfo, xe and i915) and its resident memory
# over SECONDS (default 10). cpu 100 % is one core
pid=${1:?pid}; secs=${2:-10}
sample() {
# utime + stime in clock ticks, then engine cycles summed over every drm fd as "engine used total"
awk '{print "cpu", $14 + $15, $16 + $17}' /proc/$pid/stat
cat /proc/$pid/fdinfo/* 2>/dev/null | awk '
/^drm-cycles-/ {e=substr($1, 12); sub(":", "", e); used[e]+=$2}
/^drm-total-cycles-/ {e=substr($1, 18); sub(":", "", e); total[e]=$2}
/^drm-engine-[a-z]+:/ && $3 == "ns" {e=substr($1, 12); sub(":", "", e); ns[e]+=$2}
END {for (e in used) print "cyc", e, used[e], total[e]; for (e in ns) print "ns", e, ns[e]}'
}
a=$(sample); start=$(date +%s%N); sleep "$secs"; b=$(sample); end=$(date +%s%N)
printf '%s\n--\n%s\n' "$a" "$b" | awk -v tck="$(getconf CLK_TCK)" -v wall="$(( (end - start) / 1000 ))" '
$1 == "--" {second=1; next}
$1 == "cpu" {if (second) {cpu1=$2; kid1=$3} else {cpu0=$2; kid0=$3}}
$1 == "cyc" {if (second) {u1[$2]=$3; t1[$2]=$4} else {u0[$2]=$3; t0[$2]=$4}}
$1 == "ns" {if (second) n1[$2]=$3; else n0[$2]=$3}
END {
printf "cpu %.1f %% children %.1f %%", (cpu1 - cpu0) / tck * 1e6 / wall * 100, (kid1 - kid0) / tck * 1e6 / wall * 100
for (e in u1) if (t1[e] > t0[e]) printf " %s %.2f %%", e, (u1[e] - u0[e]) / (t1[e] - t0[e]) * 100
for (e in n1) printf " %s %.2f %%", e, (n1[e] - n0[e]) / (wall * 1000) * 100
}'
printf ' rss %s MiB\n' "$(awk '/^VmRSS/ {printf "%.0f", $2 / 1024}' /proc/$pid/status)"