diff --git a/README.md b/README.md index c04f5f6..84cc664 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,13 @@ 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`. 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). @@ -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/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 +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/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/parse.rs pure text parsing with unit tests, no amane dependency matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme 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 @@ -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. - `$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, `\t\t<10 gains in dB>`. -- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-.conf`. On tmpfs, gone at logout. +- `$XDG_RUNTIME_DIR/magic-island/`: downloaded covers, `palette-cover`, `cava.conf`, `eq-.conf`, the shaders (`*.wgsl`). On tmpfs, gone at logout. - Equalizer nodes are named `magic_island_eq.`. The key is the player's MPRIS bus name without `org.mpris.MediaPlayer2.`, non-alphanumerics as `_` (e.g. `chromium_instance123`). ## Known limits diff --git a/src/curve.wgsl b/src/curve.wgsl new file mode 100644 index 0000000..e0fa874 --- /dev/null +++ b/src/curve.wgsl @@ -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 { + 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(values[0].x + along * values[0].y, mix(knob(band), knob(band + 1u), eased) + shiver); +} + +fn segment(p: vec2, a: vec2, b: vec2) -> 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) -> @location(0) vec4 { + 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(values[2].rgb * values[2].a, values[2].a) * stroke(d, values[1].y, pixel); + let line = vec4(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(0.0); + } + + return vec4(color.rgb / color.a, color.a); +} diff --git a/src/island.rs b/src/island.rs index 901a961..9985e3d 100644 --- a/src/island.rs +++ b/src/island.rs @@ -1,13 +1,13 @@ use std::collections::HashMap; -use std::f32::consts::{PI, TAU}; +use std::path::PathBuf; +use std::sync::LazyLock; use std::thread; use std::time::{Duration, Instant}; use amane::{ - Animation, Argument, Blend, Bus, Canvas, Cap, Center, Color, Column, Easing, End, Full, Gradient, Image, - InputArea, Layer, LayerWindow, Media, MediaPlayer, Monitor, Padding, Palette, Parent, Path, Pointer, - Rectangle, Row, Scroll, ScrollArea, Service, Shadow, Shape, SpaceBetween, Stack, Start, Text, Vertical, - Weight, Widget, Workspaces, Zone, children, shapes, + Animation, Argument, Blend, Bus, Center, Color, Column, Easing, End, Full, Gradient, Image, InputArea, Layer, + LayerWindow, Media, MediaPlayer, Monitor, Padding, Palette, Parent, Pointer, Rectangle, Row, Scroll, ScrollArea, + Service, Shadow, SpaceBetween, Stack, Start, Text, Vertical, Weight, Widget, Workspaces, Zone, children, }; use crate::art::Art; @@ -755,46 +755,35 @@ fn stepped(volume: u8, scroll: Scroll) -> u8 { (i32::from(volume) + step).clamp(0, 100) as u8 } +static RING: LazyLock = LazyLock::new(|| surface::shader_file("ring.wgsl", include_str!("ring.wgsl"))); +static CURVE: LazyLock = 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 - * shape each: 120 shapes cost this iGPU a third of its frame rate, 3 cost nothing + * the candles, drawn by ring.wgsl: as stroked paths they cost a vello pass over the whole + * 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 { - let center = c::disc::size() / 2.0; - let inner = c::disc::cover() / 2.0 + 7.0; +fn ring(levels: &[f32]) -> Rectangle { + let candles = c::disc::candles(); + let half = (candles / 2).max(1); + let given = half.min(44); - let mut glow = Path::new(); - let mut body = Path::new(); - let mut flames = Path::new(); + let theme = theme::now(); + let size = c::disc::size(); - for index in 0..c::disc::candles() { - // a gentle curve lifts the quiet bands, so the ring isn't all bass - let level = mirrored(levels, index).powf(0.75); + let eased: Vec = (0..given).map(|slot| mirrored(levels, slot * half / given).powf(0.75)).collect(); - 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); - 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), - ]) + Rectangle::new().width(size).height(size).shader(RING.as_path()).shader_values(values) } // low frequencies at the top, rising down both sides, so the ring is symmetric @@ -1242,40 +1231,34 @@ fn equalizer(scene: &Scene) -> Column { } // 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()) - .map(|step| { - let along = step as f32 / c::equalizer::curve_steps() as f32; + let given = levels.len().min(36); + let sampled: Vec = (0..given).map(|slot| levels[if given > 1 { slot * (levels.len() - 1) / (given - 1) } else { 0 }]).collect(); - // cosine blend between neighbouring knobs keeps the curve round - 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 knobs: Vec = gains[..bands].iter().map(|&gain| knob_y(gain)).collect(); - 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) - }) - .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)]) + Rectangle::new().width(inner_w()).height(c::equalizer::slider_height()).shader(CURVE.as_path()).shader_values(values) } fn presets(scene: &Scene) -> Column { @@ -1436,20 +1419,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 * 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()); 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 let middle = height / 2.0; - let mut glow = Path::new(); - let mut body = 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 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> = Vec::new(); + let mut body: Vec> = Vec::new(); for index in 0..bars { let level = if levels.is_empty() { @@ -1466,17 +1456,17 @@ fn pill_levels(playing: bool, levels: &[f32]) -> Canvas { 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; - 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 { - 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![ - glow.stroke(7.0, faded(theme::now().accent, 55)).cap(Cap::Round), - body.stroke(3.2, theme::now().accent).cap(Cap::Round), - ]) + // every glow under every bar, as the two strokes were + glow.extend(body); + + 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 diff --git a/src/ring.wgsl b/src/ring.wgsl new file mode 100644 index 0000000..b3d10a6 --- /dev/null +++ b/src/ring.wgsl @@ -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, direction: vec2, start: f32, end: f32) -> f32 { + let along = dot(p, direction); + let across = dot(p, vec2(-direction.y, direction.x)); + + return length(vec2(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, under: vec4) -> vec4 { + return top + under * (1.0 - top.a); +} + +@fragment +fn main(@location(0) uv: vec2) -> @location(0) vec4 { + 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(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(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(values[2].rgb * values[2].a, values[2].a) * glow; + + color = over(vec4(values[3].rgb * values[3].a, values[3].a) * body, color); + color = over(vec4(values[4].rgb * values[4].a, values[4].a) * flame, color); + + if color.a <= 0.0 { + return vec4(0.0); + } + + return vec4(color.rgb / color.a, color.a); +} diff --git a/src/surface.rs b/src/surface.rs index 6d8badb..2605b6b 100644 --- a/src/surface.rs +++ b/src/surface.rs @@ -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::island::{backdrop, faded}; +use crate::island::faded; use crate::theme; /* @@ -10,48 +13,74 @@ use crate::theme; * `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. * 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 { 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), // a soft highlight along the top as if lit from above, one soft low shadow instead of a glow "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()))) - .border(1.0, Color::blend(white(frosted::rim()), faded(theme.accent, 150), lift)) - .shadow(Shadow::drop(Color::BLACK).opacity(frosted::shadow() + 0.1 * lift).blur(24.0).offset(0.0, 6.0)) - .child(Stack::new(children![sheen, content]).width(width).height(height)) + Look { + shadow: (Color::BLACK, frosted::shadow() + 0.1 * lift, 24.0, (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))]), + 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 // edge where light enters at the top left and leaves at the bottom right; the accent's glow "liquid" => { 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 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) - .border(liquid::rim_width(), Color::blend(white(liquid::rim()), faded(theme.flame, 200), lift)) - .shadow(Shadow::drop(theme.accent).opacity(liquid::glow() + 0.3 * lift).blur(24.0)) - .child(Stack::new(children![edge, content]).width(width).height(height)) + Look { + shadow: (theme.accent, liquid::glow() + 0.3 * lift, 24.0, (0.0, 0.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)))]), + 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 - _ => base - .fill(backdrop(colors, 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)) - .child(content), - } + _ => { + let a = alpha(solid::opacity()); + + Look { + 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() @@ -59,9 +88,80 @@ pub fn clear() -> bool { surface::design() != "solid" || solid::opacity() < 1.0 } -// a layer just inside the rim, so the rim stays crisp over it -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) +static SHADER: LazyLock = LazyLock::new(|| shader_file("surface.wgsl", include_str!("surface.wgsl"))); + +// 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(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 diff --git a/src/surface.wgsl b/src/surface.wgsl new file mode 100644 index 0000000..3aea9d7 --- /dev/null +++ b/src/surface.wgsl @@ -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, 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; +} + +// 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, origin: vec2, extent: vec2, angle: f32) -> f32 { + let a = radians(angle); + let direction = vec2(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) -> vec4 { + return vec4(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 { + 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 { + 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) -> @location(0) vec4 { + 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(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(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(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(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(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(0.0); + } + + return vec4(color.rgb / color.a, color.a); +} diff --git a/tools/perf.sh b/tools/perf.sh new file mode 100644 index 0000000..d9debb5 --- /dev/null +++ b/tools/perf.sh @@ -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)"