perf(qt): draw the spectrum in one ShaderEffect per mode (macOS F3)

The 28/48/5 Rectangle delegates cost one dirty node + batch churn each
per frame on Qt's Metal path (QRhiMetal::setShaderResources per batch
is where the ~6x vs Linux lives). Each mode's Loader now instantiates
ONE ShaderEffect — one node, one draw call: heights go to scalar
uniforms (b0..bN) pushed in one JS pass per settle tick, bar/gap/
gradient geometry is computed in the fragment shader with the same
colors and 1px rounding as the Rectangle version. The pre-existing
Repeater delegates are kept verbatim as the fallback arm (software
renderer or shader-compile error — the renderer preference added in
#104 makes that arm user-reachable).

Runtime findings on Qt 6.11 (documented in the file header): inline
GLSL strings are dead (fragmentShader is a URL property), so shaders
are baked with qsb --glsl 440 --msl 12 and shipped under
qml/assets/shaders/ (auto-qrc); and the built-in default vertex stage
emits no qt_TexCoord0, so one explicit shared vertex shader is baked
alongside.

Verified: build + 204/204 tests + both QML audits; RFB (fallback arm,
all three modes live during playback); Xvfb/GL (shader arm, all three
modes, zero shader errors; QSG_VISUALIZE=batches shows one uniform
batch block for the strip).

km
This commit is contained in:
vicrodh committed 2026-08-02 11:54:01 -06:00
1 parent 273325f151
commit 9ce7ef4ca0
9 files changed
+541 -25

No files matched your search

+268 -25
View File
@@ -4,27 +4,81 @@
// QML port of the `spectrum := Rectangle` block in
// crates/qbz-ui/ui/shell/SidebarNowPlayingDock.slint:122-183 (VBar / WaveCol).
//
// PERF — this strip redraws next to a full-window ambient canvas, so:
// 1. The FFT streams REPLACE their QList each publish, so a Repeater bound to
// QbzViz.bars would rebuild every delegate every frame. The Repeaters use
// STATIC models (column counts) and bind only each bar's HEIGHT.
// F3 RENDER PATH (2026-08-02) — ONE node, ONE draw call per mode. The modes
// used to be Repeaters of 28/48/5 Rectangle delegates: on macOS (Metal RHI)
// the per-frame batch churn of 28-48 height-mutating nodes was a measured
// slice of the ~2.5 ms render-thread frame (62 fps). Each mode is now ONE
// ShaderEffect — a single scene-graph node — whose fragment shader draws
// every bar itself: the bar index comes from the x coordinate, the gap
// geometry is per mode (bars = 1px-gapped columns, waveform = centred
// columns, energy = 5 wide bars), the height comes from scalar uniforms
// b0..bN (QML maps `property real bN` onto a GLSL uniform BY NAME — uniform
// arrays are NOT supported by ShaderEffect, hence N loose properties), and
// the fill is the SAME vertical topColor->bottomColor gradient plus the 1px
// radius the delegates had (rounded-rect SDF with a 1px AA edge, including
// Rectangle's clamp of r to half the short side). Heights are pushed in ONE
// JS pass per VizSettle re-evaluation — the push hooks the exact same
// from/to/progress triplet the delegates' at(i) bindings used to capture —
// so a 16 ms tick dirties ONE node instead of 28-48. No Canvas anywhere:
// Canvas is CPU raster and is banned on this path.
//
// THE SHADER IS A .qsb, NOT AN INLINE STRING. Qt 6's ShaderEffect takes the
// vertexShader/fragmentShader properties as URLs of .qsb files produced by
// the qsb tool — an inline GLSL string is parsed as a URL (the leading
// `#version` even becomes a URL fragment) and the effect dies with
// "Failed to deserialize QShader … status Error". The GLSL 440 sources live
// in qml/assets/shaders/spectrum_{bars,wave,energy}.frag (+ the shared
// spectrum.vert — Qt 6.11's built-in default vertex stage emits no
// qt_TexCoord0, so the program link fails without an explicit one) and the
// baked shaders next to them as .qsb (GLSL 440 for the Linux OpenGL RHI,
// MSL 1.2 for Metal, SPIR-V always embedded for Vulkan). Regenerate after
// ANY shader-source edit, from crates/qbz-qt:
// for m in bars wave energy; do \
// /usr/lib64/qt6/bin/qsb --glsl 440 --msl 12 \
// -o qml/assets/shaders/spectrum_$m.frag.qsb \
// qml/assets/shaders/spectrum_$m.frag; \
// done
// /usr/lib64/qt6/bin/qsb --glsl 440 --msl 12 \
// -o qml/assets/shaders/spectrum.vert.qsb qml/assets/shaders/spectrum.vert
// (macOS: qsb is at $(brew --prefix qt)/bin/qsb.) A .qsb is keyed to the Qt
// Shader Tools version that baked it — rebake when the system Qt moves.
//
// FALLBACK ARM. ShaderEffect draws nothing where shaders do not exist —
// the software scene-graph backend (the Settings "Software" renderer,
// QT_QUICK_BACKEND=software, the offscreen/VNC platforms), detected exactly
// like theme/RoundedImage.qml via GraphicsInfo.api — or where the shader
// fails to load (ShaderEffect.status === Error; this fired HERE during
// development when the shader was an inline string, and the band kept
// rendering on the Repeater arm — a load failure must never blank the
// band). In both cases the ORIGINAL Repeater delegates take over (`model`
// flips from 0 back to N), so the band renders the same everywhere. The
// fallback is also what an offscreen/VNC smoke sees.
//
// PERF — notes 1-8 predate the shader arm. They now describe the FALLBACK
// Repeaters and the VizSettle feeding, which are byte-for-byte unchanged:
// 1. The FFT streams REPLACE their QList each publish, so a Repeater bound
// to QbzViz.bars would rebuild every delegate every frame. The Repeaters
// use STATIC models (column counts) and bind only each bar's HEIGHT.
// 2. Each mode marshals its QList into JS ONCE per publish (a `target`
// binding), never once per delegate — the 512-float waveform read is the
// single most expensive thing in the dock.
// 3. ONE shared Gradient for every bar, not one object per delegate.
// 3. ONE shared Gradient for every bar, not one object per delegate. (On
// the shader arm the gradient is two vec4 uniforms mixed in GLSL; the
// Gradient object remains for the fallback Repeaters.)
// 4. Motion comes from ONE VizSettle driver per mode, NOT 28 per-bar
// Behaviors (the Slint dock animates each bar; one driver is the cheap
// equivalent — see VizSettle.qml for the full argument).
// 5. Only the ACTIVE mode is instantiated, and only while the band is shown:
// hiding it unloads the delegates, and viz_qt.rs only ever publishes the
// one stream the visible mode consumes.
// one stream the visible mode consumes. (This is also what keeps an
// inactive mode's SHADER from loading: the Loader owns the effect.)
// 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.
// 7. ONE JS binding per bar, not two (fallback arm). `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. On the shader arm the anchor math is one
// line of GLSL (`px.y - (bandH - h)` / `* 0.5`).
// 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
@@ -32,6 +86,7 @@
// copied twice (VizSettle branches on Array.isArray).
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
import "../theme"
@@ -57,7 +112,14 @@ Rectangle {
readonly property color topColor: QbzShell.ambientPrimary
readonly property color bottomColor: QbzShell.ambientSecondary
// ONE gradient instance shared by every bar (perf note 3).
// No-shader renderer detection, verbatim from theme/RoundedImage.qml (read
// its ARM SELECTION note): test Software/Null NEGATIVELY — under the RHI an
// OpenGL backend reports GraphicsInfo.OpenGL and Metal reports Metal, so
// "not software" is the only test that does not break the Mac path.
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
// ONE gradient instance shared by every fallback bar (perf note 3).
Gradient {
id: barGradient
GradientStop { position: 0.0; color: band.topColor }
@@ -82,8 +144,67 @@ Rectangle {
live: band.capturing
}
// ONE JS pass per VizSettle re-evaluation, writing the 14 heights
// into the shader's scalar uniforms (the shader does the 14->28
// mirroring). The trigger is the same dependency triplet the
// delegates' at(i) bindings captured: from, to, progress.
function push() {
for (var i = 0; i < 14; ++i)
barsFx["b" + i] = barsSettle.at(barsMode.activeBins[i]);
}
Connections {
target: barsSettle
function onFromChanged() { barsMode.push() }
function onToChanged() { barsMode.push() }
function onProgressChanged() { barsMode.push() }
}
// Shader arm health: no shaders at all (software backend) or a
// rejected compile -> the fallback Repeater below renders.
readonly property bool fxOk: !band._noShaders && barsFx.status !== ShaderEffect.Error
ShaderEffect {
id: barsFx
anchors.fill: parent
visible: barsMode.fxOk
property real bandW: width
property real bandH: height
property color topColor: band.topColor
property color bottomColor: band.bottomColor
property real b0: 0.0
property real b1: 0.0
property real b2: 0.0
property real b3: 0.0
property real b4: 0.0
property real b5: 0.0
property real b6: 0.0
property real b7: 0.0
property real b8: 0.0
property real b9: 0.0
property real b10: 0.0
property real b11: 0.0
property real b12: 0.0
property real b13: 0.0
Component.onCompleted: barsMode.push()
onStatusChanged: {
if (status === ShaderEffect.Error)
console.warn("SpectrumBand: bars shader failed:", log);
}
// .qsb baked from qml/assets/shaders/spectrum_bars.frag — see the header.
fragmentShader: "../assets/shaders/spectrum_bars.frag.qsb"
// Shared default-equivalent vertex stage (Qt 6.11's
// built-in default emits no qt_TexCoord0, so the link
// fails without an explicit one).
vertexShader: "../assets/shaders/spectrum.vert.qsb"
}
// FALLBACK ARM — the pre-F3 delegates, verbatim. Geometry: x =
// index*slot+1, width slot-2, bottom-anchored, radius 1.
Repeater {
model: 28
model: barsMode.fxOk ? 0 : 28
delegate: Rectangle {
required property int index
// Mirror: columns 14..27 replay 13..0.
@@ -93,13 +214,6 @@ Rectangle {
x: index * barsMode.slot + 1
width: barsMode.slot - 2
height: Math.max(2, barsSettle.at(bin) * band.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
@@ -134,15 +248,100 @@ Rectangle {
}
}
// ONE JS pass per VizSettle re-evaluation (48 uniforms).
function push() {
for (var i = 0; i < 48; ++i)
waveFx["b" + i] = waveSettle.at(i);
}
Connections {
target: waveSettle
function onFromChanged() { waveMode.push() }
function onToChanged() { waveMode.push() }
function onProgressChanged() { waveMode.push() }
}
readonly property bool fxOk: !band._noShaders && waveFx.status !== ShaderEffect.Error
ShaderEffect {
id: waveFx
anchors.fill: parent
visible: waveMode.fxOk
property real bandW: width
property real bandH: height
property color topColor: band.topColor
property real b0: 0.0
property real b1: 0.0
property real b2: 0.0
property real b3: 0.0
property real b4: 0.0
property real b5: 0.0
property real b6: 0.0
property real b7: 0.0
property real b8: 0.0
property real b9: 0.0
property real b10: 0.0
property real b11: 0.0
property real b12: 0.0
property real b13: 0.0
property real b14: 0.0
property real b15: 0.0
property real b16: 0.0
property real b17: 0.0
property real b18: 0.0
property real b19: 0.0
property real b20: 0.0
property real b21: 0.0
property real b22: 0.0
property real b23: 0.0
property real b24: 0.0
property real b25: 0.0
property real b26: 0.0
property real b27: 0.0
property real b28: 0.0
property real b29: 0.0
property real b30: 0.0
property real b31: 0.0
property real b32: 0.0
property real b33: 0.0
property real b34: 0.0
property real b35: 0.0
property real b36: 0.0
property real b37: 0.0
property real b38: 0.0
property real b39: 0.0
property real b40: 0.0
property real b41: 0.0
property real b42: 0.0
property real b43: 0.0
property real b44: 0.0
property real b45: 0.0
property real b46: 0.0
property real b47: 0.0
Component.onCompleted: waveMode.push()
onStatusChanged: {
if (status === ShaderEffect.Error)
console.warn("SpectrumBand: waveform shader failed:", log);
}
// .qsb baked from qml/assets/shaders/spectrum_wave.frag — see the header.
fragmentShader: "../assets/shaders/spectrum_wave.frag.qsb"
// Shared default-equivalent vertex stage (Qt 6.11's
// built-in default emits no qt_TexCoord0, so the link
// fails without an explicit one).
vertexShader: "../assets/shaders/spectrum.vert.qsb"
}
// FALLBACK ARM — the pre-F3 delegates, verbatim. Geometry: x =
// index*slot+1, width slot-2, vertically centred, flat topColor.
Repeater {
model: 48
model: waveMode.fxOk ? 0 : 48
delegate: Rectangle {
required property int index
x: index * waveMode.slot + 1
width: waveMode.slot - 2
height: Math.max(2, waveSettle.at(index) * band.height)
// 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
@@ -165,14 +364,58 @@ Rectangle {
live: band.capturing
}
// ONE JS pass per VizSettle re-evaluation (5 uniforms).
function push() {
for (var i = 0; i < 5; ++i)
energyFx["b" + i] = energySettle.at(i);
}
Connections {
target: energySettle
function onFromChanged() { energyMode.push() }
function onToChanged() { energyMode.push() }
function onProgressChanged() { energyMode.push() }
}
readonly property bool fxOk: !band._noShaders && energyFx.status !== ShaderEffect.Error
ShaderEffect {
id: energyFx
anchors.fill: parent
visible: energyMode.fxOk
property real bandW: width
property real bandH: height
property color topColor: band.topColor
property color bottomColor: band.bottomColor
property real b0: 0.0
property real b1: 0.0
property real b2: 0.0
property real b3: 0.0
property real b4: 0.0
Component.onCompleted: energyMode.push()
onStatusChanged: {
if (status === ShaderEffect.Error)
console.warn("SpectrumBand: energy shader failed:", log);
}
// .qsb baked from qml/assets/shaders/spectrum_energy.frag — see the header.
fragmentShader: "../assets/shaders/spectrum_energy.frag.qsb"
// Shared default-equivalent vertex stage (Qt 6.11's
// built-in default emits no qt_TexCoord0, so the link
// fails without an explicit one).
vertexShader: "../assets/shaders/spectrum.vert.qsb"
}
// FALLBACK ARM — the pre-F3 delegates, verbatim. Geometry: x =
// index*slot+3, width slot-6, bottom-anchored, radius 1.
Repeater {
model: 5
model: energyMode.fxOk ? 0 : 5
delegate: Rectangle {
required property int index
x: index * energyMode.slot + 3
width: energyMode.slot - 6
height: Math.max(2, energySettle.at(index) * band.height)
// Was `y: band.height - height` — see the bars delegate.
anchors.bottom: parent.bottom
radius: 1
gradient: barGradient