Cache the glow's falloff separately from its colour
Version Bump / check-bump-label (pull_request) Skipped
Version Bump / apply-version-bump (pull_request) Successful in 25s

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>
This commit is contained in:
2026-08-02 15:23:50 +02:00
co-authored by Claude Opus 5
parent ec9e2fb7cd
commit 14394cdf50
3 changed files with 116 additions and 29 deletions
+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.
- `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 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).
- 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:
- 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
- `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).