perf(qt): rounded covers use a real mask, the spectrum ticks at 62Hz, the derivative cache is bounded

The track's "ShaderEffect / ColorOverlay / MultiEffect render NOTHING on the
software path this port runs on" doctrine was measured FALSE on 2026-07-29:
qbz-qt runs on the GPU (OpenGL RHI, threaded render loop, vsync 5.56ms). The
claim came from an offscreen session, and QT_QPA_PLATFORM=offscreen forces
software by definition. Every design it justified needed re-deciding.

- RoundedImage was a Canvas (renderTarget: Canvas.Image) that rasterised with
  QPainter on the CPU AND uploaded a texture per repaint, for every cover in
  the app, purely to round a corner identical in every frame of that image's
  life. It is now layer.enabled + MultiEffect { maskSource } — an FBO rendered
  once and cached for static content. Rectangle { radius; clip: true } cannot
  do this: clip in Qt Quick is a RECTANGULAR scissor and ignores radius.
  maskThresholdMin/maskSpreadAtMin are 0.5/1.0, NOT the 0.0/0.0 that looks
  natural: 0.0 collapses Qt's mask smoothstep to step(0, alpha) and every
  pixel with any alpha snaps fully opaque — measured 0 antialiased pixels at
  radius 40 against the Canvas's 292. At 0.5/1.0 it is 308, ramp [0,51,219,255].
  A software machine keeps the Canvas arm (GraphicsInfo.api), and
  QBZ_QT_ROUND_MODE=canvas pins it on a GPU machine where the mask misbehaves.
- VizSettle drove the spectrum from FrameAnimation, i.e. every rendered frame:
  180fps on this box for data the producer publishes at TARGET_FPS = 30. Now a
  16ms Timer; the wall-clock maths is unchanged.
- SpectrumBand's per-bar y was a SECOND JS binding derived from height. An
  anchor does the same arithmetic in C++ with no binding at all, and each FFT
  frame is materialised into a plain array once per publish instead of being
  read element by element across the JS/C++ boundary. Bindings per tick: bars
  28 (was 56), waveform 48 (was 96), energy 5 (was 10).
  QQuickPaintedItem was investigated and rejected with evidence: cxx-qt 0.7.3
  emits X(QObject* parent) : base(parent), which does not compile against
  QQuickPaintedItem(QQuickItem*), paint() is pure virtual, and there is zero
  precedent for a custom base class in this workspace. Qt 6 ShaderEffect takes
  no GLSL, only precompiled .qsb, which would make the build depend on a Qt
  tool whose path differs on macOS.
- PlaylistCollage point-sampled its mosaic in its own Canvas
  (Context2D.drawImage does not filter when it scales — measured RMSE 0.000
  against nearest-neighbour). It now draws through RoundedImage.
- The scaled-derivative cache had no eviction at all and the .jpg -> .png key
  change had orphaned 1251 files. Boot housekeeping sweeps the orphans once and
  bounds the directory at 64 MB, LRU by mtime. Verified by effect: first run
  deleted 1259 files / 10.05 MB, every run since reports 0, and the directory
  is 14 MB. The eviction is only survivable because RoundedImage clears
  _scaled on Image.Error and re-requests.
- For You artwork patched the rails in place instead of republishing the
  document, which was resetting contentY. HomeView is destroyed on every
  navigation, so the patch map is re-handed on mount through
  QbzHome.refreshForyouArt() — otherwise the Radio rail and the Spotlight hero
  went blank for the rest of the session after one navigation, and the skeleton
  pulse Timer stayed pinned ON, defeating the fix it belonged to.
- Spotlight's placeholder gated on the local path instead of the remote url, so
  it flashed a glyph on a cold cache where the Slint does not.

The retired doctrine is corrected wherever it was load-bearing for a decision.
The pre-baked icon tints are deliberately NOT reopened and no drop-shadows are
added: both are visual changes, not perf fixes.

cc
This commit is contained in:
vicrodh committed 2026-07-29 21:23:10 -06:00
1 parent 0c159c141c
commit d4667f5ea9
14 files changed
+1069 -323

No files matched your search

@@ -84,8 +84,14 @@ Item {
// ---- Album cover -------------------------------------------------------
// Drop-shadow approximation of the Slint dock's 24px blur (two offset
// rects — QtQuick.Effects renders nothing on the software path, see
// RoundedImage.qml).
// rects).
// SUPERSEDED (2026-07-29): this used to say "QtQuick.Effects renders
// nothing on the software path". Effects need shaders, and this port runs
// on the GPU (OpenGL RHI, measured); that note came from an offscreen
// session, which forces the software renderer by definition — see
// theme/RoundedImage.qml, which now DETECTS the software path with
// `GraphicsInfo.api`. A real blurred shadow is possible; it is a visual
// change needing its own parity pass, so the approximation stays for now.
Rectangle {
x: 0
y: root.artY + 4
+8 -2
View File
@@ -63,8 +63,14 @@ Column {
// bottomPadding of 10 (5px bubble padding + the 5px caret zone).
//
// Slint also drop-shadows the bubble (blur 10, #00000099); that needs a
// shader layer effect, which renders nothing on the software path, so it
// is deliberately dropped.
// shader layer effect and is deliberately dropped.
// SUPERSEDED (2026-07-29): the reason used to be "renders nothing on the
// software path". Effects need shaders, and this port runs on the GPU
// (OpenGL RHI, measured); that note came from an offscreen session, which
// forces the software renderer by definition — theme/RoundedImage.qml now
// detects the software path with `GraphicsInfo.api` instead of assuming it.
// Adding the shadow is a visual change owing its own parity pass, so the
// bubble stays flat here.
component TipBubble: Item {
Rectangle {
anchors.fill: parent
+23 -3
View File
@@ -20,6 +20,16 @@
// one stream the visible mode consumes.
// 6. Capture itself is gated by the dock's vizShouldRun — a hidden band costs
// zero: the producer parks and the drain thread sleeps.
// 7. ONE JS binding per bar, not two. `y` used to be a DERIVED binding on
// `height`, so every height change evaluated two bindings per bar; a static
// anchor (bottom / verticalCenter) does the same arithmetic in C++ and
// evaluates nothing. Combined with note 8 and VizSettle's 16 ms tick, bars
// mode went from 180 Hz x 56 binding evaluations/s to 62 Hz x 28.
// 8. `at()` indexes a PLAIN JS ARRAY: VizSettle materialises each published
// QList_f32 frame once per publish (30/s) instead of letting every `at(i)`
// cross into the sequence wrapper twice per bar per tick. The waveform arm
// already builds a plain Array in its own `target` binding, so it is not
// copied twice (VizSettle branches on Array.isArray).
import QtQuick
import com.blitzfc.qbz
@@ -83,7 +93,14 @@ Rectangle {
x: index * barsMode.slot + 1
width: barsMode.slot - 2
height: Math.max(2, barsSettle.at(bin) * band.height)
y: band.height - height
// `y: band.height - height` was a SECOND JS binding that
// re-evaluated on every height change. An anchor does the
// same arithmetic in C++ and evaluates no JS at all.
// Geometry is identical (Slint's VBar does
// `y: root.area-h - self.height`). Only the VERTICAL axis
// is anchored — `x`/`width` stay positional, which QML
// allows and which keeps the slot arithmetic untouched.
anchors.bottom: parent.bottom
radius: 1
gradient: barGradient
}
@@ -124,7 +141,9 @@ Rectangle {
x: index * waveMode.slot + 1
width: waveMode.slot - 2
height: Math.max(2, waveSettle.at(index) * band.height)
y: (band.height - height) / 2
// Was `y: (band.height - height) / 2` — a second JS binding
// per column (48 of them). Same geometry, no JS.
anchors.verticalCenter: parent.verticalCenter
radius: 1
color: band.topColor
}
@@ -153,7 +172,8 @@ Rectangle {
x: index * energyMode.slot + 3
width: energyMode.slot - 6
height: Math.max(2, energySettle.at(index) * band.height)
y: band.height - height
// Was `y: band.height - height` — see the bars delegate.
anchors.bottom: parent.bottom
radius: 1
gradient: barGradient
}
+8 -2
View File
@@ -15,8 +15,14 @@
// metadata col = (card width - 48 - 2 * 24) / 3 (three equal columns)
// scrollbar = 14px, inset 4px from the card's right edge
// The Slint card carries drop-shadow-blur 32px; QML shadows need an effect
// (Qt5Compat / MultiEffect) and effects render NOTHING on the software path in
// this port, so the shadow is dropped rather than faked.
// (Qt5Compat / MultiEffect) and it is dropped here rather than faked.
// SUPERSEDED (2026-07-29): the reason used to be "effects render NOTHING on the
// software path in this port". Effects need shaders, and this port runs on the
// GPU (OpenGL RHI, measured); that note came from an offscreen session, which
// forces the software renderer by definition — theme/RoundedImage.qml now
// detects the software path with `GraphicsInfo.api` rather than assuming it.
// The shadow is still not added: it is a visual change owing its own parity
// pass against Slint's blur, not a perf fix.
//
// DATA — see the report's GLUE NEEDED; the Qt port has no track-info bridge
// yet. Contract: QbzAlbum.openTrackInfo(trackId) fetches and publishes
+63 -12
View File
@@ -25,13 +25,22 @@
// shared. Interpolating is the correct fix for a step artefact.
//
// COST CONTRACT.
// - Zero allocation per rendered frame: `from`/`to` are REFERENCE SWAPS of the
// arrays the bridge already marshals once per publish. Only `progress`
// changes per frame.
// - Zero allocation per rendered frame: `from` is a REFERENCE SWAP and only
// `progress` changes per tick. `to` is materialised into a plain JS array
// ONCE per publish (30/s) so `at()` does not cross into the QList_f32
// sequence wrapper twice per bar per tick — see `onTargetChanged`.
// - The DRIVER IS A 16 ms Timer, not a FrameAnimation. The producer publishes
// at 30/s; a per-frame trigger fired 180x/s on Linux (100x/s on macOS) for
// it. ~62 Hz keeps two interpolated steps per published frame, which is all
// the motion there is to buy.
// - SELF-PARKING, exactly like the Rust drain: the driver starts on a publish
// and stops itself the moment `progress` reaches 1. A paused player, a hidden
// band or a disabled tap publishes nothing, so the driver never runs and the
// render loop goes back to sleep. No idle wakeups.
// and stops itself the moment `progress` reaches 1 (within one 33 ms period
// of the last publish). A paused player, a hidden band or a disabled tap
// publishes nothing, so nothing restarts it. A Timer does NOT stop itself
// when the window renders nothing the way a FrameAnimation did, so the
// `onLiveChanged` gate below — fed by the dock's `vizShouldRun`, which drops
// when the window deactivates — is what keeps an occluded window at zero
// wakeups. Do not weaken it.
// - Delegates bind ONE call, `at(i)`, which reads `from`, `to` and `progress`
// unconditionally on every path — QML captures those three as dependencies,
// so a `progress` change re-evaluates the bar heights and instantiates
@@ -93,9 +102,34 @@ Item {
// 5..125 fps, everything a sane producer can emit.
if (dt > 8 && dt < 200)
settle.periodMs = settle.periodMs * 0.8 + dt * 0.2;
// Reference swap — no copy, no allocation.
// Plain-JS copy, ONCE per publish (30/s), so `at()` indexes a JS array
// instead of crossing into the QList_f32 sequence wrapper 2x per bar
// per tick (56 crossings for bars). `from = to` stays a reference
// swap — no copy, no allocation per rendered frame (the COST CONTRACT
// above).
//
// The waveform arm ALREADY hands over a plain Array (it folds |sample|
// in its own binding, SpectrumBand.qml:108-117), so skip the copy
// there instead of paying a second 48-element pass per publish.
// `t ? … : 0` is not defensive padding: the pre-fix body was a bare
// `settle.to = settle.target` and never dereferenced `target`, so the
// copy below is the FIRST deref in this file. All three producers
// (QbzViz.bars / .energy sequences, the waveform arm's own Array)
// always hand over a value, but a future fourth arm that publishes
// undefined would now throw inside a 30/s handler instead of degrading
// to a hold — which is the contract stated in the header.
var t = settle.target;
var arr;
if (Array.isArray(t)) {
arr = t;
} else {
var n = t ? t.length : 0;
arr = new Array(n);
for (var i = 0; i < n; ++i)
arr[i] = t[i];
}
settle.from = settle.to;
settle.to = settle.target;
settle.to = arr;
settle.lastTickMs = now;
settle.progress = 0.0;
if (!driver.running)
@@ -109,11 +143,28 @@ Item {
}
}
// Wall clock, not the driver's elapsedTime: elapsedTime only advances on a
// rendered frame, so using it would quantise the segment start to vsync and
// re-introduce the beat we are removing.
FrameAnimation {
// Wall clock, not a frame-driven elapsedTime: the time source must not
// quantise the segment start to vsync (that is the beat this object
// removes). The TRIGGER, however, must not run at the frame rate either —
// the producer publishes at TARGET_FPS = 30 (qbz-audio visualizer/mod.rs:32)
// and FrameAnimation fired 180x/s on Linux and 100x/s on macOS for it,
// i.e. 3-6x the frames the data justifies. 16 ms = ~62 Hz gives two
// interpolated steps per published frame, which is what buys the motion
// (Slint's equivalent is `animate bar-h { duration: 90ms; easing: ease; }`,
// SidebarNowPlayingDock.slint:45).
//
// `repeat: true` is MANDATORY — Timer defaults to false, and without it the
// driver fires once per publish and `progress` freezes at ~0.5.
// `triggeredOnStart` stays default false: `running` is only set from
// `onTargetChanged`, which has just written `progress = 0.0`, so an
// immediate tick would recompute p≈0 and do nothing.
// Coarse timer resolution is irrelevant: `progress` is derived from
// Date.now(), so a late tick lands a correspondingly larger `p` and a
// missed tick degrades to a hold, never a jump.
Timer {
id: driver
interval: 16
repeat: true
running: false
onTriggered: {
var p = (Date.now() - settle.lastTickMs) / settle.periodMs;