perf(ui): replace animated-sidebar drop shadows with pre-baked 9-patch overlay

QGraphicsDropShadowEffect forces the host widget through an off-screen
software Gaussian blur on every repaint. On the floating sidebar (HomeView,
blur 25) and the player right sidebar (PlayerView, blur 35), the blur was
re-rasterized on every frame of the 350ms slide animation, the main source
of UI jank.

Bake the blur once into a 9-patch PNG (cached to CacheLocation/shadows via
QGraphicsBlurEffect) and render it at runtime as a cheap 9-slice stretch with
zero per-frame blur cost:

- utils/shadowpixmap: off-screen bake + on-disk cache, dpr-aware.
- components/shadowoverlay: mouse-transparent sibling widget, lazy-baked,
  paints the 9-patch; optional body cutout for semi-transparent targets so
  the shadow does not bleed through the body.
- homeview/playerview: drop the effect; the overlay tracks the sidebar
  geometry via the existing eventFilter (Move/Resize/Show/Hide), so every
  setGeometry/move site and the slide animation drive it for free.
This commit is contained in:
dela
2026-06-18 17:03:57 +08:00
parent a608910148
commit 907de1feeb
8 changed files with 394 additions and 36 deletions
+90
View File
@@ -0,0 +1,90 @@
#include "shadowoverlay.h"
#include <QPainter>
ShadowOverlay::ShadowOverlay(QWidget *parent) : QWidget(parent)
{
setAttribute(Qt::WA_TransparentForMouseEvents, true);
setAttribute(Qt::WA_NoSystemBackground, true);
setFocusPolicy(Qt::NoFocus);
}
void ShadowOverlay::setShadow(int blurRadius, int cornerRadius, const QColor &color)
{
m_blurRadius = blurRadius;
m_cornerRadius = cornerRadius;
m_color = color;
m_patch = NinePatchShadow{}; // force rebuild on next paint
m_builtDpr = 0.0;
update();
}
void ShadowOverlay::setBodyCutout(bool on)
{
if (m_bodyCutout == on)
{
return;
}
m_bodyCutout = on;
update();
}
void ShadowOverlay::ensurePixmap(qreal dpr)
{
if (!m_patch.isNull() && qFuzzyCompare(m_builtDpr, dpr))
{
return;
}
m_patch = ShadowPixmap::make(m_blurRadius, m_cornerRadius, m_color, dpr);
m_builtDpr = dpr;
}
void ShadowOverlay::paintEvent(QPaintEvent *)
{
const qreal dpr = devicePixelRatioF();
ensurePixmap(dpr);
if (m_patch.isNull())
{
return;
}
const int b = m_patch.border;
const int t = m_patch.total;
const qreal s = m_patch.dpr;
const QRect r = rect();
// Degenerate target (smaller than the two fixed borders): nothing sensible
// to slice, skip rather than draw garbage.
if (r.width() < 2 * b || r.height() < 2 * b)
{
return;
}
// 3x3 grid: corners fixed, edges/centre stretched. Target columns/rows are
// logical; source columns/rows are physical (pixmap carries no dpr).
const int xt[4] = {r.left(), r.left() + b, r.right() + 1 - b, r.right() + 1};
const int yt[4] = {r.top(), r.top() + b, r.bottom() + 1 - b, r.bottom() + 1};
const qreal xs[4] = {0.0, b * s, (t - b) * s, t * s};
const qreal ys[4] = {0.0, b * s, (t - b) * s, t * s};
QPainter p(this);
if (m_bodyCutout)
{
// Paint only the ring outside the target's footprint.
const int reachPx = reach();
const QRect body = r.adjusted(reachPx, reachPx, -reachPx, -reachPx);
if (body.isValid())
{
p.setClipRegion(QRegion(r).subtracted(QRegion(body)));
}
}
for (int i = 0; i < 3; ++i)
{
for (int j = 0; j < 3; ++j)
{
const QRectF target(xt[i], yt[j], xt[i + 1] - xt[i], yt[j + 1] - yt[j]);
const QRectF source(xs[i], ys[j], xs[i + 1] - xs[i], ys[j + 1] - ys[j]);
p.drawPixmap(target, m_patch.pixmap, source);
}
}
}