Cache the glow's falloff separately from its colour
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:
+87
-27
@@ -12,6 +12,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <numbers>
|
||||
#include <vector>
|
||||
@@ -255,30 +256,34 @@ constexpr float kGlowClipSlack = 2.F;
|
||||
//
|
||||
// with T_j the target alpha sampled at band j's midpoint, and T_n+1 = 0.
|
||||
//
|
||||
// The layers are accumulated as pure *alpha*, into a scratch surface, with a
|
||||
// solid source - then the gradient is applied to the result in a single
|
||||
// masked pass. 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. Splitting them costs one extra full-surface
|
||||
// composite and still comes out ~3.5x ahead.
|
||||
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)
|
||||
return;
|
||||
|
||||
// The layers are accumulated as pure *alpha* (BuildGlowMask) and the gradient
|
||||
// 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 double peak = std::clamp(strength, 0.F, 1.F);
|
||||
const auto targetAt = [&](double depth) {
|
||||
return peak * std::pow(1.0 - std::clamp(depth / glowPx, 0.0, 1.0), kGlowFalloffExponent);
|
||||
};
|
||||
|
||||
// ARGB32 rather than A8 despite only the alpha channel being read: cairo has
|
||||
// no optimized compositing path for A8 destinations, and rendering these same
|
||||
// layers into one measured ~6x slower than into ARGB32.
|
||||
const auto mask = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, w, h);
|
||||
const auto maskCr = cairo_create(mask);
|
||||
cairo_set_fill_rule(maskCr, CAIRO_FILL_RULE_EVEN_ODD);
|
||||
const auto scratch = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, w, h);
|
||||
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
|
||||
// accumulated target of everything that will composite under it.
|
||||
@@ -291,12 +296,12 @@ void DrawInwardGlow(cairo_t* cr, const ChromeDecorationGeometry& geo, const Chro
|
||||
// sub-pixel shift at these layer counts.
|
||||
const double target = targetAt(glowPx * (static_cast<double>(i) - 1.0) / layers);
|
||||
|
||||
cairo_set_source_rgba(maskCr, 0, 0, 0, 1.0 - (1.0 - target) / (1.0 - deeperTarget));
|
||||
cairo_set_source_rgba(scratchCr, 0, 0, 0, 1.0 - (1.0 - target) / (1.0 - deeperTarget));
|
||||
deeperTarget = target;
|
||||
|
||||
// Every layer's outer edge is the ring's inner boundary verbatim, so the
|
||||
// glow always meets the frame exactly.
|
||||
AppendChamferedRect(maskCr, 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
|
||||
// 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
|
||||
@@ -304,14 +309,41 @@ void DrawInwardGlow(cairo_t* cr, const ChromeDecorationGeometry& geo, const Chro
|
||||
// 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
|
||||
// it, which is the right answer anyway.
|
||||
AppendChamferedRect(maskCr,
|
||||
AppendChamferedRect(scratchCr,
|
||||
geo.innerX0 + depth, geo.innerY0 + depth, geo.innerX1 - depth, geo.innerY1 - depth,
|
||||
geo.innerChamfer - depth * kChamferInsetShrink, depth * kGlowCornerSmoothing);
|
||||
cairo_fill(maskCr);
|
||||
cairo_fill(scratchCr);
|
||||
}
|
||||
|
||||
cairo_surface_flush(mask);
|
||||
cairo_destroy(maskCr);
|
||||
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);
|
||||
|
||||
@@ -339,7 +371,6 @@ void DrawInwardGlow(cairo_t* cr, const ChromeDecorationGeometry& geo, const Chro
|
||||
cairo_pattern_destroy(pattern);
|
||||
|
||||
cairo_restore(cr);
|
||||
cairo_surface_destroy(mask);
|
||||
}
|
||||
|
||||
// Traces the frame's outer silhouette with a solid `outlinePx`-thick line -
|
||||
@@ -458,6 +489,33 @@ ChromeDecoration::ChromeDecoration(PHLWINDOW window) : IHyprWindowDecoration(win
|
||||
ChromeDecoration::~ChromeDecoration() {
|
||||
g_pDecorationPositioner->uncacheDecoration(this);
|
||||
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"; }
|
||||
@@ -568,8 +626,10 @@ SP<Render::ITexture> ChromeDecoration::GetBorderTexture(const Vector2D& sizePx,
|
||||
|
||||
DrawOutline(cr, geo, outlinePx, outlineColor);
|
||||
|
||||
// Drawn after the ring, into the hole the fill above just left behind.
|
||||
DrawInwardGlow(cr, geo, gradient, glowPx, glowStrength, w, h);
|
||||
// Drawn after the ring, into the hole the fill above just left behind. The
|
||||
// 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);
|
||||
|
||||
|
||||
@@ -8,12 +8,15 @@
|
||||
#include <hyprland/src/render/decorations/IHyprWindowDecoration.hpp>
|
||||
#include <hyprutils/math/Box.hpp>
|
||||
#include <hyprutils/math/Vector2D.hpp>
|
||||
#include <cairo/cairo.h>
|
||||
#include <string>
|
||||
|
||||
namespace Render {
|
||||
class ITexture;
|
||||
}
|
||||
|
||||
struct ChromeDecorationGeometry;
|
||||
|
||||
// A decoration that draws a chamfered HUD-style border frame behind each
|
||||
// window, expanding past the window's edges by `plugin:hyprchrome:extent`
|
||||
// pixels on every side. The frame's corners are chamfered by an amount
|
||||
@@ -81,6 +84,29 @@ private:
|
||||
PHLWINDOWREF windowRef;
|
||||
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;
|
||||
Vector2D cachedTexSize = {-1, -1};
|
||||
float cachedExtent = -1.F;
|
||||
|
||||
Reference in New Issue
Block a user