5 Commits
Author SHA1 Message Date
gitea-actions dcec9d205e Bump PLUGIN_VERSION to 0.1.4 2026-08-04 14:00:09 +00:00
darman 29070d17bf Merge pull request 'Performance optimizations' (#15) from fix/stable-device-box-rounding into develop
Reviewed-on: #15
2026-08-04 15:59:55 +02:00
darmanandClaude Opus 5 14394cdf50 Cache the glow's falloff separately from its colour
Version Bump / apply-version-bump (pull_request) Successful in 25s
Version Bump / check-bump-label (pull_request) Skipped
Splitting the gradient out of the per-layer fills left the falloff itself
independent of the border colour, so it no longer needs rebuilding when
only the colour changes - which is exactly what Hyprland does on every
focus change, animating m_realBorderColor across a fade. Every frame of
every focus change was rebuilding a falloff identical to the one it threw
away the frame before.

BuildGlowMask now returns the accumulated alpha, and GetGlowMask memoizes
it against only the geometry the falloff's shape depends on: size, extent,
chamfer, title bar height, glow size, glow strength. Not the gradient, not
the title bar width, not the outline. A focus fade re-renders the frame
and re-runs the masked gradient pass, and reuses everything expensive.

The mask is stored A8 rather than ARGB32 for two reasons. It is held for
the decoration's lifetime, so a quarter of the memory matters (~3.7MB at
1440p per window, and nothing at all with the glow off, which is the
default). And masking through A8 is itself faster than through ARGB32 -
enough that it more than pays for the extraction pass, making even the
cache-miss path cheaper than before. It is still rendered into an ARGB32
scratch, because cairo has no optimized compositing path for A8
destinations.

The extraction is exactly lossless: the layers are filled solid black, so
premultiplied ARGB32 carries the accumulated alpha verbatim in the alpha
byte. Verified - output is bit-identical to the previous commit, max
difference 0 across all three test sizes.

                  1080p          1440p            4K
  focus fade    9.4 -> 3.4ms   17.7 -> 5.8ms   47.7 -> 15.1ms
  size change   9.4 -> 9.3ms   17.7 -> 15.6ms  47.7 -> 35.6ms

Against the original per-layer-gradient implementation that is ~9.8x on a
focus fade and ~3.5x on a resize, at 1080p.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 15:23:50 +02:00
darmanandClaude Opus 5 ec9e2fb7cd Take the gradient out of the inward glow's per-layer fills
DrawInwardGlow filled each of its ~glowPx overlapping layers with the
border gradient directly. cairo evaluates a gradient source roughly eight
times slower than a solid colour, so that cost was paid once per layer:
at 1920x1080 with a 20px glow, 33ms per render, against 4.3ms for the
identical layers filled solid. At 3840x2160 it was 124ms - eight frames.

The layers now accumulate as alpha only, with a solid source, into a
scratch surface; the gradient is applied to the finished falloff in a
single masked pass. The product is what the per-layer gradient fills
produced before - gradientAlpha(p) * accumulated(p), in the gradient's own
colour - so the falloff math, the layer overlap, and every edge of every
layer are untouched.

Two non-obvious details, both measured rather than reasoned:

The scratch surface is ARGB32 despite only its alpha ever being read.
cairo has no optimized compositing path for A8 destinations, and
rendering these same layers into an A8 surface measured ~6x slower than
into ARGB32 (26.5ms vs 4.3ms).

The colorizing pass is clipped to the glow band. Left unclipped,
cairo_mask_surface evaluates the gradient across the mask's full extents -
the entire window - rather than the perimeter-deep sliver that is actually
non-zero, which was ~25ms of the total on its own. The clip is pushed
kGlowClipSlack past the glow on both edges: a clip edge lying exactly on
the mask's own antialiased edge multiplies the two coverages together and
darkens that boundary by up to a third, which is precisely the corner
seam the layer geometry is built to avoid. Slackened, max alpha error
against the old output drops from 27/255 to 6/255, the remainder being
8-bit quantization through the mask.

Net ~3.5x at 1080p (33ms -> 9.4ms), ~3.2x at 1440p, ~2.6x at 4K.

CreateGradientPattern's alphaScale parameter existed only to serve the
per-layer fills and is now dead, so it and its rationale are gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:57:23 +02:00
darmanandClaude Opus 5 74ec7c89f0 Stop position-only animations from invalidating the texture caches
ChromePassElement::draw() rounded its device-space box with CBox::round(),
which derives the size from the two rounded corners - round(x + w) -
round(x) - making the rounded width and height a function of the
*position's* fractional part. A box merely sliding at a constant size
therefore has its size flip by a pixel every few frames.

Every one of those flips misses the cachedTexSize check in
GetBorderTexture, GetShadowTexture and GetTitleTexture alike, and each
miss is a full cairo re-render plus a fresh GPU texture allocation and
upload for content that did not change appearance at all. That is exactly
what a workspace switch, a window move, or any other position-only
animation does - every frame, for every window on screen - and it is why
those animations stutter. Measured against hyprutils, a sliding window
resized nothing yet rebuilt its textures on 18-40% of frames at scale
1.0/1.25/1.5/1.6 (integer scales happened to be stable); rounding the
size on its own takes all of those to zero.

What round()'s coupling buys is a far edge landing on the same device
pixel as an adjacent box's near edge. Nothing abuts this box - it is a
free-floating decoration drawn over everything - so there is no seam here
to keep closed.

damageEntire() gains a one-pixel margin to match: with position and size
now rounded separately, the drawn box's far edge can land up to a device
pixel past where the logical damage box scales to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 14:16:26 +02:00
5 changed files with 181 additions and 26 deletions
+1 -1
View File
@@ -1 +1 @@
PLUGIN_VERSION=0.1.3 PLUGIN_VERSION=0.1.4
+3 -2
View File
@@ -88,7 +88,7 @@ Per-window decoration (`src/ChromeDecoration.hpp/.cpp`): implements `IHyprWindow
- `FullDecorationExtentGlobal()` computes the decoration's box in global logical coordinates, accounting for workspace animation offset and floating-window offset. It deliberately excludes the drop shadow (below), which is drawn outside it. - `FullDecorationExtentGlobal()` computes the decoration's box in global logical coordinates, accounting for workspace animation offset and floating-window offset. It deliberately excludes the drop shadow (below), which is drawn outside it.
- `GetShadowTexture(...)` renders the drop shadow. The silhouette is `AppendFrameOuterPath` — the same outline `GetBorderTexture` fills, extracted specifically so the two can't drift apart — filled solid into an A8 mask, blurred, then tinted. Three things about it are load-bearing: (1) the window's interior is cleared *after* the blur, not before, since punching it first would smear shadow inward across the window's own content; the cut lands exactly on the ring's inner boundary so the frame's opaque pixels hide it. (2) The shadow is not part of `getPositioningInfo`'s reserved extents — reserving it would push neighbouring windows away by the shadow's width — so it's simply drawn past the decoration's box, which is why `damageEntire()` and `boundingBox()` have to expand by `ShadowMarginLogical()` by hand. (3) It's rendered at most `kShadowMaxDim` px on the long edge and upscaled by the GPU; a blurred blob loses nothing to that, and it caps a cost that would otherwise be paid per frame of a resize animation. The blur itself is three box passes (`BlurA8Surface`), transposing between each so the vertical pass reuses the horizontal one's cache-friendly row code. - `GetShadowTexture(...)` renders the drop shadow. The silhouette is `AppendFrameOuterPath` — the same outline `GetBorderTexture` fills, extracted specifically so the two can't drift apart — filled solid into an A8 mask, blurred, then tinted. Three things about it are load-bearing: (1) the window's interior is cleared *after* the blur, not before, since punching it first would smear shadow inward across the window's own content; the cut lands exactly on the ring's inner boundary so the frame's opaque pixels hide it. (2) The shadow is not part of `getPositioningInfo`'s reserved extents — reserving it would push neighbouring windows away by the shadow's width — so it's simply drawn past the decoration's box, which is why `damageEntire()` and `boundingBox()` have to expand by `ShadowMarginLogical()` by hand. (3) It's rendered at most `kShadowMaxDim` px on the long edge and upscaled by the GPU; a blurred blob loses nothing to that, and it caps a cost that would otherwise be paid per frame of a resize animation. The blur itself is three box passes (`BlurA8Surface`), transposing between each so the vertical pass reuses the horizontal one's cache-friendly row code.
- The solid outline (`outline_size`/`outline_color`) is drawn by `DrawOutline`, tracing the frame's outer silhouette only. It sits *inside* the frame rather than centred on that edge: the outer path runs along the texture's own bounds, so half of a centred stroke would fall off the surface and vanish on those sides. Stroking at double width leaves exactly the inner half, which comes out to `outline_size` on every edge. The clip is the whole ring rather than just the outer path, so an outline thicker than the frame stops at the window's edge instead of spilling onto the window, and a miter spike at the plateau's dip stays confined to the frame. - The solid outline (`outline_size`/`outline_color`) is drawn by `DrawOutline`, tracing the frame's outer silhouette only. It sits *inside* the frame rather than centred on that edge: the outer path runs along the texture's own bounds, so half of a centred stroke would fall off the surface and vanish on those sides. Stroking at double width leaves exactly the inner half, which comes out to `outline_size` on every edge. The clip is the whole ring rather than just the outer path, so an outline thicker than the frame stops at the window's edge instead of spilling onto the window, and a miter spike at the plateau's dip stays confined to the frame.
- The inward glow (`glow_size`/`glow_strength`) is drawn by `DrawInwardGlow` into the hole the ring's even-odd fill leaves behind, so it lands on the window's own pixels (this decoration is `DECORATION_LAYER_OVER`, and the texture spans the whole window box, not just the ring). Its falloff is built from *overlapping* fills (one layer per px of depth, clamped to `kGlowMinLayers`/`kGlowMaxLayers`) — layer *i* covers the window edge inward to depth `glowPx * i / layers`, so a pixel `d` from the edge is painted by every layer deeper than `d`. The profile is stated explicitly (`strength * (1 - d/glowPx)^kGlowFalloffExponent` — fast off the edge, easing into a tail that reaches zero tangentially so there's no ring where it stops), and since each layer composites over every deeper one, a layer's own alpha is *not* its target: it's solved outermost-inward as `1 - a_j = (1 - T_j) / (1 - T_j+1)`. Changing the profile means changing `targetAt`, not the per-layer alphas. Abutting disjoint bands instead would leave an antialiasing seam at every shared edge; that's the reason for the overlap, don't "optimize" it away. Every layer's *outer* edge is the ring's inner boundary verbatim, chamfer vertices and all — filleting or otherwise altering it detaches the glow from the frame and opens a sliver of unpainted window at each corner. The corner softening lives entirely on the layers' *inner* edges, which are what the accumulated falloff's contours actually follow: each is inset by its own depth, filleted by `kGlowCornerSmoothing` × that depth (`AppendFilletedPolygon`, a quadratic Bezier through each vertex), and has its chamfer shrunk by `kChamferInsetShrink` × that depth. That last correction is not optional cosmetics — insetting a chamfered rect while holding its chamfer constant moves the 45° face in by `d·√2` rather than `d`, so without it the glow runs ~41% deeper at every corner than along the sides. The per-layer alpha is baked into the gradient pattern (`CreateGradientPattern`'s `alphaScale`) specifically so each layer can be a `cairo_fill` of its own band rather than a clip + `cairo_paint_with_alpha`, which would rasterize the clip's full extents — i.e. the whole window area — once per layer. - The inward glow (`glow_size`/`glow_strength`) is drawn by `DrawInwardGlow` into the hole the ring's even-odd fill leaves behind, so it lands on the window's own pixels (this decoration is `DECORATION_LAYER_OVER`, and the texture spans the whole window box, not just the ring). Its falloff is built from *overlapping* fills (one layer per px of depth, clamped to `kGlowMinLayers`/`kGlowMaxLayers`) — layer *i* covers the window edge inward to depth `glowPx * i / layers`, so a pixel `d` from the edge is painted by every layer deeper than `d`. The profile is stated explicitly (`strength * (1 - d/glowPx)^kGlowFalloffExponent` — fast off the edge, easing into a tail that reaches zero tangentially so there's no ring where it stops), and since each layer composites over every deeper one, a layer's own alpha is *not* its target: it's solved outermost-inward as `1 - a_j = (1 - T_j) / (1 - T_j+1)`. Changing the profile means changing `targetAt`, not the per-layer alphas. Abutting disjoint bands instead would leave an antialiasing seam at every shared edge; that's the reason for the overlap, don't "optimize" it away. Every layer's *outer* edge is the ring's inner boundary verbatim, chamfer vertices and all — filleting or otherwise altering it detaches the glow from the frame and opens a sliver of unpainted window at each corner. The corner softening lives entirely on the layers' *inner* edges, which are what the accumulated falloff's contours actually follow: each is inset by its own depth, filleted by `kGlowCornerSmoothing` × that depth (`AppendFilletedPolygon`, a quadratic Bezier through each vertex), and has its chamfer shrunk by `kChamferInsetShrink` × that depth. That last correction is not optional cosmetics — insetting a chamfered rect while holding its chamfer constant moves the 45° face in by `d·√2` rather than `d`, so without it the glow runs ~41% deeper at every corner than along the sides. The layers accumulate as *alpha only*, filled with a solid source into a scratch surface, and the gradient is applied to the finished falloff in one `cairo_mask_surface` pass — the product being the same `gradientAlpha(p) * accumulated(p)` the per-layer gradient fills used to produce. Three measured facts are load-bearing here and none are obvious: (1) cairo evaluates a gradient source roughly 8× slower than a solid one, so filling each of the ~`glowPx` layers with the gradient directly paid that cost per layer (33ms vs 4.3ms for the layers at 1920×1080, 20px glow); (2) the scratch surface is `ARGB32` even though only its alpha is ever read, because cairo has no optimized compositing path for `A8` *destinations* and rendering the layers into one is ~6× slower; (3) the colorizing pass is clipped to the glow band, because `cairo_mask_surface` otherwise evaluates the gradient across the mask's full extents — the whole window — instead of the perimeter-deep sliver that is actually non-zero (~5ms vs ~25ms). That clip is bounded by `kGlowClipSlack` on *both* edges and must stay that way: a clip edge sitting exactly on the mask's own antialiased edge multiplies the two coverages together and darkens that boundary by up to a third (measured max alpha error 27/255 → 6/255 once slackened). Because the falloff is now colour-independent, it is cached in its own right (`GetGlowMask`, an A8 surface held per-decoration) keyed on *only* what its shape depends on — size, extent, chamfer, title bar height, glow size, glow strength — and deliberately **not** on the gradient, the title bar width or the outline. Hyprland animates the border colour on every focus change, so that exclusion is what keeps a focus fade paying only the ~3.4ms colorize instead of the full ~9.3ms render (1080p). The mask is stored A8 rather than ARGB32 both because it lives for the window's lifetime (~3.7MB at 1440p, and nothing at all with the glow off) and because masking through A8 is itself faster; it is still *rendered* into an ARGB32 scratch and the alpha extracted, per (2) above. The extraction is exactly lossless — the layers are solid black, so premultiplied ARGB32 stores the accumulated alpha verbatim in the alpha byte.
Render-pass element (`src/ChromePassElement.hpp/.cpp`): an `EK_CUSTOM` pass element (`ChromePassElement::draw()`) that runs once per frame per window and: Render-pass element (`src/ChromePassElement.hpp/.cpp`): an `EK_CUSTOM` pass element (`ChromePassElement::draw()`) that runs once per frame per window and:
- Derives the corner chamfer live from the window's own `rounding()` (in device px, scaled by monitor scale) — so the border's cut corners track the window's rounding through config reloads, per-window rules, and animations. - Derives the corner chamfer live from the window's own `rounding()` (in device px, scaled by monitor scale) — so the border's cut corners track the window's rounding through config reloads, per-window rules, and animations.
@@ -107,5 +107,6 @@ Below `kFullSpanThresholdPx` (250px window width), `fullSpan` mode kicks in: the
## Key invariants to preserve when editing ## Key invariants to preserve when editing
- `PLUGIN_API_VERSION()` in `Main.cpp` must never be changed — it's read by Hyprland's loader before anything else runs. - `PLUGIN_API_VERSION()` in `Main.cpp` must never be changed — it's read by Hyprland's loader before anything else runs.
- Texture caching in `ChromeDecoration` (`cachedTexture`/`cachedTitleTex` + their parameter snapshots) exists to avoid re-rendering cairo/Pango content every frame; if you add a new parameter that affects the rendered output, it must be added to both the cache comparison and the fields being stored, or stale textures will silently persist. - Texture caching in `ChromeDecoration` (`cachedTexture`/`cachedTitleTex` + their parameter snapshots) exists to avoid re-rendering cairo/Pango content every frame; if you add a new parameter that affects the rendered output, it must be added to both the cache comparison and the fields being stored, or stale textures will silently persist. The same applies to `cachedGlowMask`, with the opposite hazard as well: its key covers only what the falloff's *shape* depends on, and widening it to anything the border colour animates (the gradient above all) would silently give back the ~3x that cache buys on every focus change.
- Device-space boxes are rounded position-and-size *separately* (`ChromePassElement::draw()`), never with `CBox::round()`, whose size depends on the position's fractional part — a sliding window would otherwise flip its rounded size every few frames and miss every cache above.
- `ChromeDecorationGeometry`'s fields are computed once in `ComputeBase` and reused by both the border cairo path and the title-bar-width clamping (`MinTitleBarWidth`/`MaxTitleBarWidth`) — keep those two consumers' assumptions about the same fields in sync (e.g. `MaxTitleBarWidth`'s cap exists specifically to prevent `GetBorderTexture`'s cairo path from self-intersecting). - `ChromeDecorationGeometry`'s fields are computed once in `ComputeBase` and reused by both the border cairo path and the title-bar-width clamping (`MinTitleBarWidth`/`MaxTitleBarWidth`) — keep those two consumers' assumptions about the same fields in sync (e.g. `MaxTitleBarWidth`'s cap exists specifically to prevent `GetBorderTexture`'s cairo path from self-intersecting).
+131 -22
View File
@@ -12,6 +12,7 @@
#include <algorithm> #include <algorithm>
#include <cmath> #include <cmath>
#include <cstdint>
#include <limits> #include <limits>
#include <numbers> #include <numbers>
#include <vector> #include <vector>
@@ -39,11 +40,7 @@ std::vector<size_t> Utf8CodepointStarts(const std::string& s) {
// compositor's own border, and it only costs anything on a cache miss. // compositor's own border, and it only costs anything on a cache miss.
constexpr int kStopsPerSegment = 8; constexpr int kStopsPerSegment = 8;
// `alphaScale` multiplies every stop's own alpha - the glow layers below cairo_pattern_t* CreateGradientPattern(const ChromeGradient& gradient, double w, double h) {
// reuse the border's gradient at a fraction of its opacity, and baking that
// into the pattern lets them cairo_fill() (which only touches the filled
// band) instead of clip+paint (which rasterizes the clip's whole extents).
cairo_pattern_t* CreateGradientPattern(const ChromeGradient& gradient, double w, double h, double alphaScale = 1.0) {
const auto axis = gradient.AxisFor(w, h); const auto axis = gradient.AxisFor(w, h);
const auto pattern = cairo_pattern_create_linear(axis.x0, axis.y0, axis.x1, axis.y1); const auto pattern = cairo_pattern_create_linear(axis.x0, axis.y0, axis.x1, axis.y1);
@@ -52,7 +49,7 @@ cairo_pattern_t* CreateGradientPattern(const ChromeGradient& gradient, double w,
for (int i = 0; i <= steps; ++i) { for (int i = 0; i <= steps; ++i) {
const double t = static_cast<double>(i) / steps; const double t = static_cast<double>(i) / steps;
const auto color = gradient.SampleAt(static_cast<float>(t)); const auto color = gradient.SampleAt(static_cast<float>(t));
cairo_pattern_add_color_stop_rgba(pattern, t, color.r, color.g, color.b, color.a * alphaScale); cairo_pattern_add_color_stop_rgba(pattern, t, color.r, color.g, color.b, color.a);
} }
return pattern; return pattern;
@@ -230,6 +227,15 @@ constexpr float kGlowCornerSmoothing = 0.5F;
// this much per px of inset makes the offset properly parallel instead. // this much per px of inset makes the offset properly parallel instead.
constexpr float kChamferInsetShrink = 2.F - std::numbers::sqrt2_v<float>; constexpr float kChamferInsetShrink = 2.F - std::numbers::sqrt2_v<float>;
// How far past the glow's own extent the colorizing pass' clip is pushed, on
// both of its edges. The clip is there purely to keep cairo from evaluating
// the gradient across the entire window (see DrawInwardGlow); it must never
// be what bounds the glow, because a clip edge lying exactly on the mask's
// own antialiased edge multiplies the two coverages together and darkens that
// boundary by up to a third. Slack puts the clip where the mask is already
// zero, so it costs nothing and cuts nothing.
constexpr float kGlowClipSlack = 2.F;
// Bleeds the border color inward past the window edge - over the window's own // Bleeds the border color inward past the window edge - over the window's own
// pixels, since this decoration renders on DECORATION_LAYER_OVER - fading out // pixels, since this decoration renders on DECORATION_LAYER_OVER - fading out
// over `glowPx` and peaking at `strength` (times the gradient's own alpha) at // over `glowPx` and peaking at `strength` (times the gradient's own alpha) at
@@ -249,18 +255,35 @@ constexpr float kChamferInsetShrink = 2.F - std::numbers::sqrt2_v<float>;
// 1 - a_j = (1 - T_j) / (1 - T_j+1) // 1 - a_j = (1 - T_j) / (1 - T_j+1)
// //
// with T_j the target alpha sampled at band j's midpoint, and T_n+1 = 0. // with T_j the target alpha sampled at band j's midpoint, and T_n+1 = 0.
void DrawInwardGlow(cairo_t* cr, const ChromeDecorationGeometry& geo, const ChromeGradient& gradient, float glowPx, float strength, int w, int h) { //
if (glowPx < 1.F || strength <= 0.F || geo.innerW <= 0 || geo.innerH <= 0) // The layers are accumulated as pure *alpha* (BuildGlowMask) and the gradient
return; // is applied to the finished falloff in a single masked pass (DrawInwardGlow).
// Filling each layer with the gradient directly, as this used to, makes every
// one of the ~glowPx layers pay for gradient evaluation, and cairo evaluates a
// gradient roughly eight times slower than a solid colour: at 1920x1080 with a
// 20px glow that measured 33ms per render against 4.3ms for the same layers
// filled solid.
//
// The split also means the falloff no longer depends on the border colour at
// all, which is what lets ChromeDecoration cache it across the colour
// animation Hyprland runs on every focus change - see GetGlowMask.
//
// Returned as A8: it is stored per-window for the lifetime of the decoration,
// where a quarter of the memory matters, and masking through it is faster than
// through ARGB32 besides (3.4ms vs 5.2ms at 1080p). It is nevertheless
// *rendered* into ARGB32 and the alpha channel extracted afterwards, because
// cairo has no optimized compositing path for A8 destinations and building
// these layers directly in one measured ~6x slower (26.5ms vs 4.3ms).
cairo_surface_t* BuildGlowMask(const ChromeDecorationGeometry& geo, float glowPx, float strength, int w, int h) {
const int layers = std::clamp(static_cast<int>(std::ceil(glowPx)), kGlowMinLayers, kGlowMaxLayers); const int layers = std::clamp(static_cast<int>(std::ceil(glowPx)), kGlowMinLayers, kGlowMaxLayers);
const double peak = std::clamp(strength, 0.F, 1.F); const double peak = std::clamp(strength, 0.F, 1.F);
const auto targetAt = [&](double depth) { const auto targetAt = [&](double depth) {
return peak * std::pow(1.0 - std::clamp(depth / glowPx, 0.0, 1.0), kGlowFalloffExponent); return peak * std::pow(1.0 - std::clamp(depth / glowPx, 0.0, 1.0), kGlowFalloffExponent);
}; };
cairo_save(cr); const auto scratch = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, w, h);
cairo_set_fill_rule(cr, CAIRO_FILL_RULE_EVEN_ODD); const auto scratchCr = cairo_create(scratch);
cairo_set_fill_rule(scratchCr, CAIRO_FILL_RULE_EVEN_ODD);
// Deepest (faintest) layer first, so each iteration already knows the // Deepest (faintest) layer first, so each iteration already knows the
// accumulated target of everything that will composite under it. // accumulated target of everything that will composite under it.
@@ -273,13 +296,12 @@ void DrawInwardGlow(cairo_t* cr, const ChromeDecorationGeometry& geo, const Chro
// sub-pixel shift at these layer counts. // sub-pixel shift at these layer counts.
const double target = targetAt(glowPx * (static_cast<double>(i) - 1.0) / layers); const double target = targetAt(glowPx * (static_cast<double>(i) - 1.0) / layers);
const auto pattern = CreateGradientPattern(gradient, w, h, 1.0 - (1.0 - target) / (1.0 - deeperTarget)); cairo_set_source_rgba(scratchCr, 0, 0, 0, 1.0 - (1.0 - target) / (1.0 - deeperTarget));
cairo_set_source(cr, pattern);
deeperTarget = target; deeperTarget = target;
// Every layer's outer edge is the ring's inner boundary verbatim, so the // Every layer's outer edge is the ring's inner boundary verbatim, so the
// glow always meets the frame exactly. // glow always meets the frame exactly.
AppendChamferedRect(cr, geo.innerX0, geo.innerY0, geo.innerX1, geo.innerY1, geo.innerChamfer); AppendChamferedRect(scratchCr, geo.innerX0, geo.innerY0, geo.innerX1, geo.innerY1, geo.innerChamfer);
// Punches this layer's un-glowed middle back out - inset by `depth`, with // Punches this layer's un-glowed middle back out - inset by `depth`, with
// the corner both parallel-corrected and rounded off in proportion to how // the corner both parallel-corrected and rounded off in proportion to how
// deep it is. Since it's these edges that the accumulated falloff's // deep it is. Since it's these edges that the accumulated falloff's
@@ -287,14 +309,67 @@ void DrawInwardGlow(cairo_t* cr, const ChromeDecorationGeometry& geo, const Chro
// and progressively rounder inward. On a window smaller than the glow is // and progressively rounder inward. On a window smaller than the glow is
// deep the middle collapses to nothing and the layer just covers all of // deep the middle collapses to nothing and the layer just covers all of
// it, which is the right answer anyway. // it, which is the right answer anyway.
AppendChamferedRect(cr, AppendChamferedRect(scratchCr,
geo.innerX0 + depth, geo.innerY0 + depth, geo.innerX1 - depth, geo.innerY1 - depth, geo.innerX0 + depth, geo.innerY0 + depth, geo.innerX1 - depth, geo.innerY1 - depth,
geo.innerChamfer - depth * kChamferInsetShrink, depth * kGlowCornerSmoothing); geo.innerChamfer - depth * kChamferInsetShrink, depth * kGlowCornerSmoothing);
cairo_fill(cr); cairo_fill(scratchCr);
cairo_pattern_destroy(pattern);
} }
cairo_surface_flush(scratch);
cairo_destroy(scratchCr);
const auto mask = cairo_image_surface_create(CAIRO_FORMAT_A8, w, h);
const auto* src = cairo_image_surface_get_data(scratch);
auto* dst = cairo_image_surface_get_data(mask);
const int srcStride = cairo_image_surface_get_stride(scratch);
const int dstStride = cairo_image_surface_get_stride(mask);
// CAIRO_FORMAT_ARGB32 is a native-endian 32-bit quantity with alpha in the
// high byte, so this is endian-correct read as uint32 (it would not be
// reading bytes).
for (int y = 0; y < h; ++y) {
const auto* s = reinterpret_cast<const uint32_t*>(src + static_cast<size_t>(y) * srcStride);
auto* d = dst + static_cast<size_t>(y) * dstStride;
for (int x = 0; x < w; ++x)
d[x] = static_cast<uint8_t>(s[x] >> 24);
}
cairo_surface_mark_dirty(mask);
cairo_surface_destroy(scratch);
return mask;
}
// Composites `mask` (from BuildGlowMask) into `cr` in the border's own
// gradient.
void DrawInwardGlow(cairo_t* cr, const ChromeDecorationGeometry& geo, const ChromeGradient& gradient, float glowPx, cairo_surface_t* mask, int w, int h) {
if (!mask)
return;
cairo_save(cr);
// Bound the gradient to the band it can actually land on. Without this,
// cairo_mask_surface evaluates the gradient over the mask's full extents -
// the whole window - rather than the perimeter-deep sliver that is non-zero,
// which at 1080p is the difference between ~5ms and ~25ms. kGlowClipSlack
// keeps both clip edges clear of the mask's own antialiasing.
cairo_set_fill_rule(cr, CAIRO_FILL_RULE_EVEN_ODD);
AppendChamferedRect(cr,
geo.innerX0 - kGlowClipSlack, geo.innerY0 - kGlowClipSlack,
geo.innerX1 + kGlowClipSlack, geo.innerY1 + kGlowClipSlack, geo.innerChamfer);
AppendChamferedRect(cr,
geo.innerX0 + glowPx + kGlowClipSlack, geo.innerY0 + glowPx + kGlowClipSlack,
geo.innerX1 - glowPx - kGlowClipSlack, geo.innerY1 - glowPx - kGlowClipSlack,
geo.innerChamfer - glowPx * kChamferInsetShrink, glowPx * kGlowCornerSmoothing);
cairo_clip(cr);
// The gradient carries its own per-stop alpha and the mask carries the
// falloff, so the product is what the per-layer gradient fills produced
// before: gradientAlpha(p) * accumulated(p), in the gradient's own colour.
const auto pattern = CreateGradientPattern(gradient, w, h);
cairo_set_source(cr, pattern);
cairo_mask_surface(cr, mask, 0, 0);
cairo_pattern_destroy(pattern);
cairo_restore(cr); cairo_restore(cr);
} }
@@ -414,6 +489,33 @@ ChromeDecoration::ChromeDecoration(PHLWINDOW window) : IHyprWindowDecoration(win
ChromeDecoration::~ChromeDecoration() { ChromeDecoration::~ChromeDecoration() {
g_pDecorationPositioner->uncacheDecoration(this); g_pDecorationPositioner->uncacheDecoration(this);
std::erase(PluginState->decorations, self); std::erase(PluginState->decorations, self);
if (cachedGlowMask)
cairo_surface_destroy(cachedGlowMask);
}
cairo_surface_t* ChromeDecoration::GetGlowMask(const ChromeDecorationGeometry& geo, const Vector2D& sizePx, float extentPx, float chamferPx, float titleBarHeightPx, float glowPx, float glowStrength) {
if (glowPx < 1.F || glowStrength <= 0.F || geo.innerW <= 0 || geo.innerH <= 0)
return nullptr;
if (cachedGlowMask && cachedGlowMaskSize == sizePx &&
cachedGlowMaskExtent == extentPx && cachedGlowMaskChamfer == chamferPx &&
cachedGlowMaskTitleBarHeight == titleBarHeightPx &&
cachedGlowMaskGlowSize == glowPx && cachedGlowMaskStrength == glowStrength)
return cachedGlowMask;
if (cachedGlowMask)
cairo_surface_destroy(cachedGlowMask);
cachedGlowMask = BuildGlowMask(geo, glowPx, glowStrength, static_cast<int>(sizePx.x), static_cast<int>(sizePx.y));
cachedGlowMaskSize = sizePx;
cachedGlowMaskExtent = extentPx;
cachedGlowMaskChamfer = chamferPx;
cachedGlowMaskTitleBarHeight = titleBarHeightPx;
cachedGlowMaskGlowSize = glowPx;
cachedGlowMaskStrength = glowStrength;
return cachedGlowMask;
} }
std::string ChromeDecoration::getDisplayName() { return "Chrome"; } std::string ChromeDecoration::getDisplayName() { return "Chrome"; }
@@ -460,7 +562,12 @@ void ChromeDecoration::damageEntire() {
// The shadow hangs outside the decoration's own box (it's drawn, not // The shadow hangs outside the decoration's own box (it's drawn, not
// reserved - see ShadowMarginLogical), so damaging just that box would // reserved - see ShadowMarginLogical), so damaging just that box would
// leave its outer reaches stale. // leave its outer reaches stale.
g_pHyprRenderer->damageBox(FullDecorationExtentGlobal().expand(ShadowMarginLogical())); //
// The extra pixel covers the gap ChromePassElement::draw()'s rounding can
// open: it rounds the device-space position and size separately, so the
// box's far edge can land up to a device pixel past where this logical box
// scales to. One logical px is at least that much on any scale >= 1.
g_pHyprRenderer->damageBox(FullDecorationExtentGlobal().expand(ShadowMarginLogical() + 1.0));
} }
double ChromeDecoration::ShadowMarginLogical() { double ChromeDecoration::ShadowMarginLogical() {
@@ -519,8 +626,10 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
DrawOutline(cr, geo, outlinePx, outlineColor); DrawOutline(cr, geo, outlinePx, outlineColor);
// Drawn after the ring, into the hole the fill above just left behind. // Drawn after the ring, into the hole the fill above just left behind. The
DrawInwardGlow(cr, geo, gradient, glowPx, glowStrength, w, h); // falloff itself is colour-independent and cached across focus fades; only
// this masked gradient pass is redone when the border colour changes.
DrawInwardGlow(cr, geo, gradient, glowPx, GetGlowMask(geo, sizePx, extentPx, chamferPx, titleBarHeightPx, glowPx, glowStrength), w, h);
cairo_surface_flush(surface); cairo_surface_flush(surface);
+26
View File
@@ -8,12 +8,15 @@
#include <hyprland/src/render/decorations/IHyprWindowDecoration.hpp> #include <hyprland/src/render/decorations/IHyprWindowDecoration.hpp>
#include <hyprutils/math/Box.hpp> #include <hyprutils/math/Box.hpp>
#include <hyprutils/math/Vector2D.hpp> #include <hyprutils/math/Vector2D.hpp>
#include <cairo/cairo.h>
#include <string> #include <string>
namespace Render { namespace Render {
class ITexture; class ITexture;
} }
struct ChromeDecorationGeometry;
// A decoration that draws a chamfered HUD-style border frame behind each // A decoration that draws a chamfered HUD-style border frame behind each
// window, expanding past the window's edges by `plugin:hyprchrome:extent` // window, expanding past the window's edges by `plugin:hyprchrome:extent`
// pixels on every side. The frame's corners are chamfered by an amount // pixels on every side. The frame's corners are chamfered by an amount
@@ -81,6 +84,29 @@ private:
PHLWINDOWREF windowRef; PHLWINDOWREF windowRef;
CBox assignedBox; CBox assignedBox;
// The inward glow's falloff as an alpha mask, cached separately from the
// border texture that consumes it. Its parameters are deliberately only the
// ones the falloff's *shape* depends on - notably not the gradient, which
// Hyprland animates on every focus change, nor the title bar width or
// outline. That is the whole point: a focus fade re-renders the frame but
// reuses this, which is the difference between ~9ms and ~3.5ms per frame of
// the fade at 1080p.
//
// Costs one A8 surface the size of the decoration per window for as long as
// the window lives (~3.7MB at 1440p) - but only when the glow is switched
// on at all, which it is not by default.
cairo_surface_t* cachedGlowMask = nullptr;
Vector2D cachedGlowMaskSize = {-1, -1};
float cachedGlowMaskExtent = -1.F;
float cachedGlowMaskChamfer = -1.F;
float cachedGlowMaskTitleBarHeight = -1.F;
float cachedGlowMaskGlowSize = -1.F;
float cachedGlowMaskStrength = -1.F;
// Returns the cached falloff mask, rebuilding it only if the geometry it
// depends on changed. Null when the glow is off or degenerate.
cairo_surface_t* GetGlowMask(const ChromeDecorationGeometry& geo, const Vector2D& sizePx, float extentPx, float chamferPx, float titleBarHeightPx, float glowPx, float glowStrength);
SP<Render::ITexture> cachedTexture; SP<Render::ITexture> cachedTexture;
Vector2D cachedTexSize = {-1, -1}; Vector2D cachedTexSize = {-1, -1};
float cachedExtent = -1.F; float cachedExtent = -1.F;
+20 -1
View File
@@ -21,7 +21,26 @@ std::vector<UP<IPassElement>> ChromePassElement::draw() {
return {}; return {};
auto box = data.decoration->FullDecorationExtentGlobal(); auto box = data.decoration->FullDecorationExtentGlobal();
box.translate(-monitor->m_position).scale(monitor->m_scale).round(); box.translate(-monitor->m_position).scale(monitor->m_scale);
// Round the position and the size independently, rather than via
// CBox::round(). That derives the size from the two *rounded corners*
// (round(x + w) - round(x)), which makes it a function of the position's
// fractional part - so a box that is merely sliding, at a perfectly
// constant size, has its rounded w/h flip by a pixel every few frames.
// Every one of those flips misses the caches in GetBorderTexture /
// GetShadowTexture / GetTitleTexture, each miss being a full cairo
// re-render plus a GPU re-upload of a texture that didn't actually change
// appearance - which is precisely what a workspace switch, a window move,
// or any other position-only animation does, every frame, for every window
// on screen. Rounded on its own, the size stays a pure function of the
// window's own size and can't be perturbed by translation at all.
//
// What round()'s coupling buys is a far edge that lands on the same device
// pixel as an adjacent box's near edge. Nothing abuts this box - it's a
// free-floating decoration drawn over everything - so there is no seam here
// to keep closed.
box = CBox{box.pos().round(), box.size().round()};
if (box.w < 1 || box.h < 1) if (box.w < 1 || box.h < 1)
return {}; return {};