perf(qt): one repaint pulse for the whole shell (GPU 95% -> 25%)

Qt Quick has no partial redraws: any dirty item presents the WHOLE
window, and on the owner's hybrid laptop each full-window present costs
~1.2% GPU flat (KWin composite + dGPU scanout; area-independent, while
the app's own render is 0.08 ms/frame). The bill was presents/s, and
the shell ran unsynchronised ~30 Hz clocks plus two leaks:

- QbzShell.pulseMs: ONE Rust-driven 33 ms edge that every continuous
  animator ticks on (knob QBZ_PULSE_MS). VizSettle now stashes on
  publish and applies on the pulse (its 16 ms interpolation timer is
  gone); AmbientField and ImmersiveAtmosphere ride the same edge.
- VizSettle writes NOTHING while not live: the always-mounted but
  invisible immersive panels were dirtying 28 bar rects per FFT
  publish (named via QT_LOGGING_RULES=qt.quick.dirty=true).
- AmbientField renders inline again: ShaderEffectSource
  scheduleUpdate() double-presents (minimal repro fbo_repro.qml in the
  investigation folder).
- Background mode 2 (ImmersiveAtmosphere) composites its four drifting
  layers + base + dim + scrim in ONE opaque ShaderEffect
  (atmosphere.frag, baked by build.rs); numeric parity vs the Image
  stack verified (atmo-shader-parity-check.py, delta 0.0). The
  four-Image stack stays as the software/failure fallback.

Measured on the owner's machine: 93-97% -> 25-26% GPU, ~34 W -> 12-13 W,
~33 presents/s in both background modes. Full story:
qbz-nix-docs/qt-frontend/2026-08-11-scenegraph-batches/GPU-COST-INVESTIGATION.md

km
This commit is contained in:
vicrodh committed 2026-08-13 13:01:50 -06:00
1 parent b136c9cf5d
commit f46c5c95d4
9 files changed
+814 -259

No files matched your search

@@ -0,0 +1,78 @@
#version 440
// atmosphere.frag — the ImmersiveAtmosphere "blurred art" background
// (app-wide background mode 2, ImmersiveView underlay, album/artist header
// route A) as ONE full-window pass.
//
// WHY THIS EXISTS (2026-08-13 single-pulse wave). The QML build of this
// component was FOUR oversized Images of the same 128x128 atmosphere bitmap
// (PreserveAspectCrop, sine-drifting origins, opacities .95/.48/.24/.18) over
// a base Rectangle, under a dim overlay and a black alpha scrim: seven
// full-window nodes, six of them blended, re-executed on EVERY present —
// and Qt Quick repaints the whole window on any dirty, so the visualiser's
// presents paid the background's six passes too. The fragment math below is
// the exact same composite (same crop, same src-over order, same overlays),
// so the background tier is one opaque draw instead of ~7 blended ones.
//
// THE COMPOSITE, in the QML stack's order (all arithmetic in the same sRGB
// space the scene graph blends in):
// c = #0a0a0b (base Rectangle)
// c = tex_i.rgb*o_i + c*(1 - a_i*o_i) x4 (Image src-over, textures
// premultiplied; a_i = 1 for
// the opaque bitmap, where
// this is plain mix)
// c *= 1 - dim (black dim overlay)
// c *= 1 - scrim(y) (black alpha gradient:
// .4 top -> 0 @35% -> ~.5
// bottom, linear in alpha —
// the QML GradientStop
// interpolation is exact here
// because every stop is black)
// The output is opaque, so the item sets `blending: false` and lands in the
// opaque pass (front-to-back, early-z) instead of the 500-node alpha stack.
//
// PreserveAspectCrop of a SQUARE source into a layer rect R = (rw, rh) at
// origin o: cover scale s = max(rw, rh), the crop is centred, so a fragment
// at item-pixel px samples uv = (px - (o + R/2)) / s + 0.5. QML packs
// (cx, cy, s, opacity) per layer — see ImmersiveAtmosphere.qml packLayer().
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
float resX;
float resY;
float dim;
vec4 l1; // xy: crop centre in item px, z: cover scale (px per uv), w: opacity
vec4 l2;
vec4 l3;
vec4 l4;
};
layout(binding = 1) uniform sampler2D tex;
vec3 overLayer(vec3 base, vec2 px, vec4 l) {
vec2 uv = (px - l.xy) / l.z + 0.5;
vec4 t = texture(tex, uv);
return t.rgb * l.w + base * (1.0 - t.a * l.w);
}
void main() {
vec2 px = qt_TexCoord0 * vec2(resX, resY);
vec3 c = vec3(10.0 / 255.0, 10.0 / 255.0, 11.0 / 255.0); // #0a0a0b
c = overLayer(c, px, l1);
c = overLayer(c, px, l2);
c = overLayer(c, px, l3);
c = overLayer(c, px, l4);
c *= 1.0 - dim;
float y = qt_TexCoord0.y;
float scrim = y < 0.35
? (102.0 / 255.0) * (1.0 - y / 0.35) // #66000000 -> transparent
: (128.0 / 255.0) * ((y - 0.35) / 0.65); // transparent -> #80000000
c *= 1.0 - scrim;
// Opaque on purpose (blending: false on the item): the base rect makes
// alpha 1 everywhere, and an opaque node leaves the alpha pass entirely.
fragColor = vec4(c, 1.0);
}
Binary file not shown.
@@ -17,12 +17,31 @@
// (SpectrumPanel.slint:45-46,118, ImmersiveWaveBedPanel.slint:49). // (SpectrumPanel.slint:45-46,118, ImmersiveWaveBedPanel.slint:49).
// //
// The drift clock: Slint reads animation-tick() pull-based and FREEZES it at // The drift clock: Slint reads animation-tick() pull-based and FREEZES it at
// 0 while !animated (:16). The Qt twin is ONE linear NumberAnimation on // 0 while !animated (:16). The Qt twin accumulates a local `tick` (ms) on the
// `tick` (ms), reset to 0 whenever `animated` drops, so the static pose is // shared shell pulse (QbzShell.pulseMs — see the cost note below), reset to 0
// the Slint tick=0 pose exactly. All x/y offsets are plain bindings of tick // whenever `animated` drops, so the static pose is the Slint tick=0 pose
// — nothing is animated per-layer. // exactly. All x/y offsets are plain bindings of tick — nothing is animated
// per-layer.
//
// TWO RENDER ARMS (2026-08-13), same detection as SpectrumBand/RoundedImage:
//
// GPU — ONE ShaderEffect (assets/shaders/atmosphere.frag) that composites
// all four crop-drifts, the base, the dim and the scrim in a single
// opaque pass. The four-Image stack paid ~7 full-window blended
// nodes on EVERY present — and Qt Quick repaints the whole window on
// any dirty, so the visualiser's 30 presents/s each paid the
// background's six blended passes too. One opaque node leaves the
// alpha pass entirely (front-to-back, early-z).
// FALLBACK — the original four-Image stack, verbatim, for the software /
// null scene graphs (Settings "Software" renderer, offscreen/VNC)
// and for a shader that fails to load: a load failure must never
// blank the background. This is also the arm an offscreen smoke
// sees, which is why the fallback is the parity reference and the
// shader must match IT.
import QtQuick import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
Item { Item {
id: root id: root
@@ -40,32 +59,150 @@ Item {
/// Gates the layer drift (:14); the mount binds QbzPlayer.npPlaying. /// Gates the layer drift (:14); the mount binds QbzPlayer.npPlaying.
property bool animated: true property bool animated: true
// Drift clock in ms (:16). 24h period, linear; reset to 0 on the // Drift clock in ms (:16). Reset to 0 on the animated->static edge so the
// animated->static edge so the static pose is the Slint tick=0 pose. // static pose is the Slint tick=0 pose exactly.
property real tick: 0 property real tick: 0
NumberAnimation on tick { // Driven by THE SHELL PULSE (QbzShell.pulseMs, shell_bridge.rs) — not a
from: 0 // private Timer, and never a NumberAnimation, and the difference is the
to: 86400000 // whole cost of this component.
duration: 86400000 //
loops: Animation.Infinite // A NumberAnimation is driven by the animation driver: it updates once per
running: root.animated // ANIMATION FRAME, i.e. at display rate — 180 Hz on the owner's panel. The
// sine bindings below read `tick`, so every one of those updates moves
// four oversized layers and dirties the scene. Qt Quick has no dirty-region
// rendering, so each is a WHOLE-WINDOW repaint, and on a hybrid laptop
// whose external monitor hangs off the dGPU each repaint is also a frame
// KWin must composite and scan out on that GPU.
//
// A PRIVATE 30 Hz Timer fixed the rate but not the bill: the visualizer's
// own ~30 Hz clock fires on a different phase, and two unsynchronised
// clocks still cost two full-window presents per period. Measured on that
// machine: ~95% GPU with this component up, unmoved by anything that
// touched what was DRAWN, because the cost was how OFTEN. The shared
// pulse makes every continuous animator in the shell dirty the scene in
// the SAME event-loop turn, so the window presents ONCE per period for
// all of them. That coalescing — not the period — is the fix.
//
// These layers drift on 5.7-15.1 s sine periods. 30 Hz is already ~6x more
// samples than that motion can show; the extra frames bought nothing and
// cost a full-window composite each.
//
// `tickMs` MUST match the pulse period (settings_qt::shell_pulse_ms,
// default 33): `tick` is a local accumulation advanced on the pulse edge,
// so a mismatch only mis-speeds the drift, never the repaint rate.
readonly property int tickMs: 33
Connections {
target: QbzShell
function onPulseMsChanged() {
if (root.animated)
root.tick += root.tickMs
}
} }
onAnimatedChanged: if (!animated) tick = 0 onAnimatedChanged: if (!animated) tick = 0
clip: true clip: true
// Root surface (:18). // --- The drift, ONE source of truth for both arms -----------------------
// The eight sine pairs of the .slint (:24-31, :36-43, :48-55, :60-67),
// hoisted from the four Images so the shader arm's uniforms move on the
// SAME values the fallback stack positions with.
readonly property real s1x: Math.sin(root.tick / 7600.0 * 6.283185)
+ 0.35 * Math.sin((root.tick / 3300.0 + 1.9) * 6.283185)
readonly property real s1y: Math.sin((root.tick / 11200.0 + 1.1) * 6.283185)
readonly property real s2x: Math.sin((root.tick / 9200.0 + 0.4) * 6.283185)
+ 0.42 * Math.sin((root.tick / 4100.0 + 2.6) * 6.283185)
readonly property real s2y: Math.sin((root.tick / 14800.0 + 1.7) * 6.283185)
readonly property real s3x: Math.sin((root.tick / 5700.0 + 1.6) * 6.283185)
readonly property real s3y: Math.sin((root.tick / 12300.0 + 0.2) * 6.283185)
+ 0.28 * Math.sin((root.tick / 3600.0 + 2.8) * 6.283185)
readonly property real s4x: Math.sin((root.tick / 15100.0 + 2.4) * 6.283185)
readonly property real s4y: Math.sin((root.tick / 6900.0 + 1.4) * 6.283185)
// Same detection as SpectrumBand/RoundedImage: the software and null scene
// graphs draw NOTHING for a ShaderEffect, so they take the fallback arm.
readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
|| GraphicsInfo.api === GraphicsInfo.Null
// A shader that fails to LOAD must not blank the background either (the
// failure SpectrumBand hit in development), so the status is part of the
// predicate, not just the backend.
readonly property bool fxOk: !root._noShaders && fx.status !== ShaderEffect.Error
// Root surface (:18). Stays mounted under the GPU arm too — an opaque
// node under an opaque effect is early-z discard, and the static-cover
// path (:68-76) still needs it.
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
color: "#0a0a0b" color: "#0a0a0b"
} }
// --- GPU arm: ONE opaque pass --------------------------------------------
// The bitmap as a plain sampler (never shown itself).
Image {
id: atmoTex
visible: false
source: root.source
// The shader samples the whole 128x128; the layers' oversize+crop
// math lives in packLayer, not here.
smooth: true
}
// Pack one layer the way PreserveAspectCrop of a SQUARE source composes:
// cover scale s = max(rw, rh), centred crop, so a fragment at item-pixel
// px samples uv = (px - (o + R/2)) / s + 0.5 (see atmosphere.frag).
// Returns (cx, cy, s, opacity).
function packLayer(dw, dh, ox, oy, o) {
var rw = root.width + dw
var rh = root.height + dh
var s = Math.max(rw, rh)
return Qt.vector4d(ox + rw / 2, oy + rh / 2, s, o)
}
// The four layers, byte-for-byte the geometry of the fallback Images
// below (oversize, origin, drift amplitude, opacity).
readonly property vector4d layer1:
packLayer(520, 420, -260 + s1x * 132, -210 + s1y * 58, root.baseOpacity)
readonly property vector4d layer2:
packLayer(620, 500, -310 - s2x * 156, -250 - s2y * 74, root.warpOpacity)
readonly property vector4d layer3:
packLayer(700, 540, -350 + s3x * 118, -270 + s3y * 92, 0.24)
readonly property vector4d layer4:
packLayer(560, 620, -280 - s4x * 102, -310 + s4y * 128, 0.18)
ShaderEffect {
id: fx
anchors.fill: parent
visible: root.fxOk && root.source !== ""
// The composite is opaque (base #0a0a0b, see the shader), so this
// node renders in the OPAQUE pass — not the 500-node alpha stack the
// four-Image arm lands in.
blending: false
property var tex: atmoTex
property real resX: width
property real resY: height
property real dim: root.dim
property vector4d l1: root.layer1
property vector4d l2: root.layer2
property vector4d l3: root.layer3
property vector4d l4: root.layer4
onStatusChanged: {
if (status === ShaderEffect.Error)
console.warn("ImmersiveAtmosphere: atmosphere shader failed:", log);
}
fragmentShader: "../assets/shaders/atmosphere.frag.qsb"
// Shared default-equivalent vertex stage (Qt 6.11's built-in default
// emits no qt_TexCoord0, so the program link fails without an
// explicit one — the same file SpectrumBand/AmbientField use).
vertexShader: "../assets/shaders/spectrum.vert.qsb"
}
// --- FALLBACK arm: the original four-Image stack, verbatim ----------------
// --- Layer 1 (:21-32) ------------------------------------------------- // --- Layer 1 (:21-32) -------------------------------------------------
Image { Image {
readonly property real sx: Math.sin(root.tick / 7600.0 * 6.283185) readonly property real sx: root.s1x
+ 0.35 * Math.sin((root.tick / 3300.0 + 1.9) * 6.283185) readonly property real sy: root.s1y
readonly property real sy: Math.sin((root.tick / 11200.0 + 1.1) * 6.283185) visible: !root.fxOk && root.source !== ""
visible: root.source !== ""
source: root.source source: root.source
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
width: root.width + 520 width: root.width + 520
@@ -76,10 +213,9 @@ Item {
} }
// --- Layer 2 (:33-44) ------------------------------------------------- // --- Layer 2 (:33-44) -------------------------------------------------
Image { Image {
readonly property real sx: Math.sin((root.tick / 9200.0 + 0.4) * 6.283185) readonly property real sx: root.s2x
+ 0.42 * Math.sin((root.tick / 4100.0 + 2.6) * 6.283185) readonly property real sy: root.s2y
readonly property real sy: Math.sin((root.tick / 14800.0 + 1.7) * 6.283185) visible: !root.fxOk && root.source !== ""
visible: root.source !== ""
source: root.source source: root.source
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
width: root.width + 620 width: root.width + 620
@@ -90,10 +226,9 @@ Item {
} }
// --- Layer 3 (:45-56) ------------------------------------------------- // --- Layer 3 (:45-56) -------------------------------------------------
Image { Image {
readonly property real sx: Math.sin((root.tick / 5700.0 + 1.6) * 6.283185) readonly property real sx: root.s3x
readonly property real sy: Math.sin((root.tick / 12300.0 + 0.2) * 6.283185) readonly property real sy: root.s3y
+ 0.28 * Math.sin((root.tick / 3600.0 + 2.8) * 6.283185) visible: !root.fxOk && root.source !== ""
visible: root.source !== ""
source: root.source source: root.source
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
width: root.width + 700 width: root.width + 700
@@ -104,9 +239,9 @@ Item {
} }
// --- Layer 4 (:57-67) ------------------------------------------------- // --- Layer 4 (:57-67) -------------------------------------------------
Image { Image {
readonly property real sx: Math.sin((root.tick / 15100.0 + 2.4) * 6.283185) readonly property real sx: root.s4x
readonly property real sy: Math.sin((root.tick / 6900.0 + 1.4) * 6.283185) readonly property real sy: root.s4y
visible: root.source !== "" visible: !root.fxOk && root.source !== ""
source: root.source source: root.source
fillMode: Image.PreserveAspectCrop fillMode: Image.PreserveAspectCrop
width: root.width + 560 width: root.width + 560
@@ -127,15 +262,18 @@ Item {
opacity: 0.35 opacity: 0.35
} }
// dim overlay (:77-79). // dim overlay (:77-79). Baked INTO the shader on the GPU arm; only the
// fallback stack and the static-cover path paint it as a node.
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
visible: !root.fxOk || root.source === ""
color: Qt.rgba(0, 0, 0, root.dim) color: Qt.rgba(0, 0, 0, root.dim)
} }
// 180-degree scrim (:80-82): #00000066 top -> transparent 35% -> // 180-degree scrim (:80-82): #00000066 top -> transparent 35% ->
// #00000080 bottom (RRGGBBAA converted). // #00000080 bottom (RRGGBBAA converted). Same GPU-arm folding as dim.
Rectangle { Rectangle {
anchors.fill: parent anchors.fill: parent
visible: !root.fxOk || root.source === ""
gradient: Gradient { gradient: Gradient {
GradientStop { position: 0.0; color: "#66000000" } GradientStop { position: 0.0; color: "#66000000" }
GradientStop { position: 0.35; color: "transparent" } GradientStop { position: 0.35; color: "transparent" }
+259 -86
View File
@@ -1,36 +1,61 @@
// AmbientField — the app-wide dynamic background ("modo Cider", phase 14). // AmbientField — the app-wide dynamic background (phase 14), mode 1.
// //
// The QML rendering of the Slint ambient WGSL scene (crates/qbz-ui/ui/ // The Qt rendering of the Slint ambient scene (crates/qbz-ui/ui/shaders/
// shaders/ambient.wgsl): FOUR album-colored blobs on the shader's EXACT // ambient.wgsl, wgpu scene 7). Two arms:
// wandering orbits (10-18s, t scaled 0.75), additive-blended over a black
// base so near blobs fuse like the metaball field, plus the shader's
// vertical edge shade. Colors come from the ambient_qt album triad
// (QbzShell.ambientPrimary/Secondary/Accent, recomputed on track change —
// the field crossfades because Canvas repaints are continuous, ~0.6s of
// visible blend per the blob softness).
// //
// POC-NOTEs (vs the WGSL original, ambient_qt.rs has the full list): // GPU — a ShaderEffect over assets/shaders/ambient.frag rendered into a
// - No fbm domain warp and no r²/d² metaball potential — plain radial // ShaderEffectSource at 30 fps (see "THE SHADER IS RENDERED TO A
// gradients with 'lighter' compositing approximate the fusion. // TEXTURE" below — that schedule is load-bearing, not an optimisation
// - No audio breathe (the POC has no FFT tap; level_smooth = 0 → the // detail), which is a line-for-line GLSL port of the WGSL:
// shader's breathe is a constant 1.0 there anyway) and no grain dither. // two-octave fbm domain warp,
// - Rendered with QPainter (renderTarget Image) so it works on the // four album-coloured METABALLS (r^2/d^2, so the lobes fuse and
// software/offscreen path too; 30fps cap + paused when the window is not // stretch), the wide `smoothstep(0.45, 3.4)` iso-surface, the
// being shown (the spec's cost controls §6). // `mix(0.42, 1.12)` bright/dark spread, the 1.28 saturation push,
// the quadratic vertical shade, the 0.92 master and the grain.
// CPU — a Canvas fallback for the software scene graph (the Settings
// "Software" renderer, QT_QUICK_BACKEND=software, offscreen/VNC),
// detected via GraphicsInfo exactly like theme/RoundedImage.qml.
// //
// GATING RULE (owner, 2026-07-28): freeze on NOT VISIBLE, never on lost // WHAT CHANGED AND WHY (2026-08-11). The Canvas used to be the ONLY arm, and
// focus. A tiling/mosaic desktop keeps windows visible and unfocused all the // it painted its blobs over a BLACK base, so every pixel outside a blob's
// time — pausing on focus loss would stop the field while the user is // radius was #000000. That is not a small fidelity gap: the reference's colour
// looking straight at it. Minimized/hidden is the real "nobody can see it". // is the metaball-WEIGHTED album colour, which is defined everywhere, scaled by
// at LEAST 0.42 — a full-window wash that brightens into lobes. Measured on the
// owner's side-by-side, Qt was (0,0,0) at (1600,600) and (1690,880) where Slint
// was (58,51,5) and (29,27,10): a black top-right quadrant against a continuous
// olive field. The Canvas arm now paints that 0.42 floor as its base colour and
// corrects the radius (see below), so even the software path stops going black.
//
// THE RADIUS BUG, worth naming: the WGSL works in aspect-corrected uv, where x
// spans [0, aspect] and y spans [0, 1] — so `rr = 0.34` is 0.34 of the window
// HEIGHT. The Canvas port read it as `0.36 * max(W, H)`, which on a 1700x1400
// window is 612 px against the reference's 476: blobs 29% too big, on top of
// the black base.
//
// STILL ABSENT, deliberately: the audio breathe. The reference multiplies the
// blob radius by `1 + 0.12 * level_smooth`, and Slint keeps its 30 fps FFT
// drain running for mode 1 — but there it has no choice, because that same
// drain is what renders the shader texture the background shows. This arm
// drives itself off the shared shell pulse below, so the tap would be
// switched on app-wide purely for a +-12% radius pulse, which is the wrong
// trade in a port measured on idle CPU. At level 0 the reference's `breathe`
// is exactly 1.0, so the field's geometry here is the reference's geometry.
//
// GATING RULE (owner, 2026-07-28): freeze on NOT VISIBLE, never on lost focus.
// A tiling/mosaic desktop keeps windows visible and unfocused all the time —
// pausing on focus loss would stop the field while the user is looking straight
// at it. Minimized/hidden is the real "nobody can see it".
import QtQuick import QtQuick
import QtQuick.Window import QtQuick.Window
import com.blitzfc.qbz import com.blitzfc.qbz
Canvas { Item {
id: root id: root
// The album triad (hex strings from ambient_qt). // The album triad (hex strings from ambient_qt, recomputed on track
// change). The field crossfades on its own: both arms redraw continuously,
// so the new colours melt in over the blob softness.
property color primary: QbzShell.ambientPrimary property color primary: QbzShell.ambientPrimary
property color secondary: QbzShell.ambientSecondary property color secondary: QbzShell.ambientSecondary
property color accent: QbzShell.ambientAccent property color accent: QbzShell.ambientAccent
@@ -44,82 +69,230 @@ Canvas {
&& root.Window.window.visibility !== Window.Hidden) && root.Window.window.visibility !== Window.Hidden)
: true : true
renderTarget: Canvas.Image // Same detection as SpectrumBand/RoundedImage: the software and null scene
// See RoundedImage.qml. This one repaints 30x/s for the whole window // graphs draw NOTHING for a ShaderEffect, so they take the Canvas arm.
// lifetime, so it is the canvas most likely to be mid-raster when another readonly property bool _noShaders: GraphicsInfo.api === GraphicsInfo.Software
// is torn down on the same render thread. || GraphicsInfo.api === GraphicsInfo.Null
renderStrategy: Canvas.Immediate // A shader that fails to LOAD must not blank the background either (the
// failure SpectrumBand hit in development), so the status is part of the
// predicate, not just the backend.
readonly property bool fxOk: !root._noShaders && fx.status !== ShaderEffect.Error
// The shader clock (t = time * 0.75). Accumulated at ~30fps. // --- Render size (shader_underlay.rs:32-38) ------------------------------
// The reference renders into an offscreen target that TRACKS the window's
// physical pixel size (capped at 2560x1440) and shows it with
// `image-fit: fill`. Rendering INLINE now (see the GPU arm below), the
// target IS the framebuffer, so the size is simply the item's physical
// size — below the 2560x1440 cap this is pixel-for-pixel what the
// reference renders at this window size anyway.
readonly property real dpr: root.Screen.devicePixelRatio
readonly property real resW: Math.max(1, Math.round(width * dpr))
readonly property real resH: Math.max(1, Math.round(height * dpr))
// The scene clock, in seconds. Accumulated at ~30 fps — the spec's cost
// control (§6): the field is a background that must never cost more than
// the data it shows, and the orbits are 10-18 s long, so 30 fps is already
// far more than the motion needs. The seed keeps two launches from opening
// on the same pose.
property real t: Math.random() * 40.0 property real t: Math.random() * 40.0
Timer { // --- ONE PAINT SOURCE, ON THE SHARED PULSE -----------------------------
interval: 33 //
repeat: true // Qt Quick has no dirty-region rendering: ANY dirty node redraws the whole
running: root.running && root.visible && root.windowShowing // window, so the shell's GPU cost is linear in how many times per second
onTriggered: { // something asks for a repaint — measured on the owner's 4070 at half
root.t += interval / 1000.0 * 0.75 // screen, ~1.2% of GPU per repaint/s (30 Hz -> 36%, ~60 Hz -> 70-82%).
root.requestPaint() // WHAT is redrawn barely matters: halving the field's resolution
// (QBZ_BG_SCALE=0.5), caching the pane in a layer (QBZ_PANE_LAYER=1) and
// slowing the band's interpolation to 10 Hz (QBZ_VIZ_TICK=100) each moved
// the number by nothing or almost nothing.
//
// 2026-08-13 single-pulse redesign: the field advances on the shell pulse
// (QbzShell.pulseMs, shell_bridge.rs) — the SAME edge VizSettle and
// ImmersiveAtmosphere tick on — so every continuous animator in the shell
// dirties the scene in one event-loop turn and the window presents ONCE
// per period for all of them. This replaces both earlier sources at once:
// the private 33 ms fallback timer AND the publish-edge advance (which
// stopped being "a repaint the window was going to do anyway" the moment
// VizSettle's publish path went stash-only — an onBarsChanged advance
// would now be a SECOND, unsynchronised 30 Hz clock, the exact regression
// the pulse exists to kill).
readonly property int tickMs: 33
function advance() {
// The GPU arm needs NOTHING but the clock bump: `fx.time` binds root.t,
// so this alone dirties the shader and the next present renders it.
root.t += root.tickMs / 1000.0
if (!root.fxOk)
canvas.requestPaint()
}
// The pulse edge. A handler that writes nothing schedules no frame, so
// the gates (host freeze, unmounted, minimized/hidden — the GATING RULE
// at the top) live here, not in a Timer's `running`.
Connections {
target: QbzShell
function onPulseMsChanged() {
if (root.running && root.visible && root.windowShowing)
root.advance()
} }
} }
onPrimaryChanged: requestPaint() // --- GPU arm ------------------------------------------------------------
onSecondaryChanged: requestPaint() //
onAccentChanged: requestPaint() // THE SHADER RENDERS INLINE, NOT INTO A TEXTURE — 2026-08-13, and the
onWidthChanged: requestPaint() // history matters because this is the THIRD shape of this arm:
onHeightChanged: requestPaint() //
// 1. Inline (first cut) pinned an RTX 4070 near 100%: pre-pulse the shell
// presented 62-180x/s (VizSettle's 16 ms timer, display-rate
// animations) and every present re-ran 4 fbm octaves over the window.
// 2. ShaderEffectSource + live:false fixed THAT — the fbm ran only on
// scheduleUpdate() and other frames sampled the texture. But a minimal
// repro (100 ms timer + scheduleUpdate, QSG_RENDER_TIMING) measures
// TWO presents per update: one renders the FBO, then the changed
// texture dirties the scene again. Under the single-pulse regime the
// FBO's rate-decoupling buys nothing (the window presents at the pulse
// rate anyway) and the doubled present is pure cost — presents/s is
// THE GPU term on the owner's hybrid stack (~1.2% GPU per
// full-window present/s, area-independent, measured 2026-08-13).
// 3. Inline again (this cut): ONE present per pulse, fbm once per present
// at window resolution. The residual exposure — scroll/hover bursts
// re-running the field at interaction rate — is transient by
// definition, and the field's fragment cost was measured NOT to be the
// dominant term (QBZ_BG_SCALE=0.5 moved the GPU ~5 points).
//
// The picture is UNCHANGED — same program, same uniforms.
ShaderEffect {
id: fx
anchors.fill: parent
visible: root.fxOk
// The output is opaque (alpha = qt_Opacity = 1), so there is nothing to
// blend against — this is the bottom-most layer in the window.
blending: false
// One blob: radial gradient, center color at `a` alpha fading to // Matched BY NAME against the uniform block in ambient.frag.
// transparent (the shader's wide smoothstep falloff). property color primary: root.primary
function blob(ctx, cx, cy, r, c, a) { property color secondary: root.secondary
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r) property color accent: root.accent
g.addColorStop(0.0, Qt.rgba(c.r, c.g, c.b, a)) property real time: root.t
g.addColorStop(0.55, Qt.rgba(c.r, c.g, c.b, a * 0.45)) // The absent audio breathe (see the header). The shader keeps the term
g.addColorStop(1.0, Qt.rgba(c.r, c.g, c.b, 0.0)) // so wiring a level later is a one-line binding, not a shader edit.
ctx.fillStyle = g property real levelSmooth: 0.0
ctx.fillRect(cx - r, cy - r, 2 * r, 2 * r) // The FRAMEBUFFER's size (inline arm): the aspect correction and the
// grain frequency describe the pixels actually being written.
property real resX: root.resW
property real resY: root.resH
// The dark legibility scrim, applied inside the program rather than as
// a second full-window Rectangle above it — see the shader.
property real dim: QbzShell.ambientDim
onStatusChanged: {
if (status === ShaderEffect.Error)
console.warn("AmbientField: shader failed:", log)
}
fragmentShader: "../assets/shaders/ambient.frag.qsb"
// Shared default-equivalent vertex stage — Qt 6.11's built-in default
// emits no qt_TexCoord0, so the program link fails without an explicit
// one (the same file SpectrumBand uses).
vertexShader: "../assets/shaders/spectrum.vert.qsb"
} }
onPaint: { // --- CPU fallback arm ---------------------------------------------------
var ctx = getContext("2d") // Plain radial gradients cannot reproduce the metaball tail, so this is an
ctx.reset() // approximation by construction — but it is the SAME approximation the
var W = width // reference makes at its extremes: a base of the album mix at the 0.42
var H = height // floor, with the lobes added on top. What it drops versus the shader is
if (W <= 0 || H <= 0) return // the fbm warp (the lobes are circles, not amoebas) and the grain.
Canvas {
id: canvas
anchors.fill: parent
visible: !root.fxOk
renderTarget: Canvas.Image
// See RoundedImage.qml. This one repaints 30x/s for the whole window
// lifetime, so it is the canvas most likely to be mid-raster when
// another is torn down on the same render thread.
renderStrategy: Canvas.Immediate
// Base: the shader's field->0 color (black). Connections {
ctx.fillStyle = "#000000" target: root
ctx.fillRect(0, 0, W, H) function onPrimaryChanged() { canvas.requestPaint() }
function onSecondaryChanged() { canvas.requestPaint() }
function onAccentChanged() { canvas.requestPaint() }
}
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
onVisibleChanged: if (visible) requestPaint()
var t = root.t // One lobe: radial gradient, centre colour at `a` alpha fading to
// The shader's orbit constants (ambient.wgsl cA..cD, aspect folded // transparent (the shader's wide smoothstep falloff).
// back out — window-fraction centers are the plain expressions). function blob(ctx, cx, cy, r, c, a) {
var cA = [0.32 + 0.30 * Math.sin(t * 0.40), 0.42 + 0.30 * Math.cos(t * 0.33)] var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r)
var cB = [0.66 + 0.30 * Math.sin(t * 0.35 + 2.1), 0.56 + 0.32 * Math.cos(t * 0.29 + 1.3)] g.addColorStop(0.0, Qt.rgba(c.r, c.g, c.b, a))
var cC = [0.50 + 0.34 * Math.cos(t * 0.31 + 4.0), 0.36 + 0.30 * Math.sin(t * 0.45 + 3.2)] g.addColorStop(0.55, Qt.rgba(c.r, c.g, c.b, a * 0.45))
var cD = [0.46 + 0.32 * Math.sin(t * 0.27 + 5.3), 0.64 + 0.28 * Math.cos(t * 0.49 + 0.7)] g.addColorStop(1.0, Qt.rgba(c.r, c.g, c.b, 0.0))
ctx.fillStyle = g
ctx.fillRect(cx - r, cy - r, 2 * r, 2 * r)
}
// rr = 0.34 of the field; the shader's brightness lands ~0.92 with onPaint: {
// the 0.42-1.12 shape spread — the alpha spread below reproduces var ctx = getContext("2d")
// that mid-to-low brightness read under the host's dim scrim. ctx.reset()
var rr = 0.36 * Math.max(W, H) var W = width
var c4 = Qt.rgba( var H = height
(primary.r + accent.r) / 2, (primary.g + accent.g) / 2, (primary.b + accent.b) / 2, 1.0) if (W <= 0 || H <= 0)
return
ctx.globalCompositeOperation = "lighter" var p = root.primary
blob(ctx, cA[0] * W, cA[1] * H, rr, primary, 0.50) var s = root.secondary
blob(ctx, cB[0] * W, cB[1] * H, rr * 0.95, secondary, 0.46) var a = root.accent
blob(ctx, cC[0] * W, cC[1] * H, rr * 0.88, accent, 0.44) var c4 = Qt.rgba((p.r + a.r) / 2, (p.g + a.g) / 2, (p.b + a.b) / 2, 1.0)
blob(ctx, cD[0] * W, cD[1] * H, rr * 0.82, c4, 0.42)
ctx.globalCompositeOperation = "source-over"
// Vertical shade: 16% darker at the very top/bottom edges (the // THE FLOOR — the whole point of this rewrite. Where the shader has
// shader's vshade — chrome sits on calmer color). // no dominant lobe it still paints the weighted album colour scaled
var v = ctx.createLinearGradient(0, 0, 0, H) // by 0.42 (then the 0.92 master), so the base here is the four-way
v.addColorStop(0.0, Qt.rgba(0, 0, 0, 0.16)) // mix at that same level. Never black.
v.addColorStop(0.5, Qt.rgba(0, 0, 0, 0.0)) var f = 0.42 * 0.92
v.addColorStop(1.0, Qt.rgba(0, 0, 0, 0.16)) ctx.fillStyle = Qt.rgba(
ctx.fillStyle = v (p.r + s.r + a.r + c4.r) / 4 * f,
ctx.fillRect(0, 0, W, H) (p.g + s.g + a.g + c4.g) / 4 * f,
(p.b + s.b + a.b + c4.b) / 4 * f,
1.0)
ctx.fillRect(0, 0, W, H)
// The shader's orbit constants, with the aspect folded back out —
// in the WGSL's uv the centres are (expr * aspect, expr) over
// x in [0, aspect] and y in [0, 1], so as WINDOW FRACTIONS they are
// the plain expressions.
var t = root.t * 0.75
var cA = [0.32 + 0.30 * Math.sin(t * 0.40), 0.42 + 0.30 * Math.cos(t * 0.33)]
var cB = [0.66 + 0.30 * Math.sin(t * 0.35 + 2.1), 0.56 + 0.32 * Math.cos(t * 0.29 + 1.3)]
var cC = [0.50 + 0.34 * Math.cos(t * 0.31 + 4.0), 0.36 + 0.30 * Math.sin(t * 0.45 + 3.2)]
var cD = [0.46 + 0.32 * Math.sin(t * 0.27 + 5.3), 0.64 + 0.28 * Math.cos(t * 0.49 + 0.7)]
// rr = 0.34 of the field HEIGHT (see the header): the uv space the
// constant lives in has y spanning exactly 1.
var rr = 0.34 * H
ctx.globalCompositeOperation = "lighter"
blob(ctx, cA[0] * W, cA[1] * H, rr, p, 0.50)
blob(ctx, cB[0] * W, cB[1] * H, rr * 0.95, s, 0.46)
blob(ctx, cC[0] * W, cC[1] * H, rr * 0.88, a, 0.44)
blob(ctx, cD[0] * W, cD[1] * H, rr * 0.82, c4, 0.42)
ctx.globalCompositeOperation = "source-over"
// Vertical shade — the shader's `1 - 0.16 * ((|y-0.5|*2)^2)` is a
// QUADRATIC multiply, so the darkening is nearly nothing across the
// middle and reaches 16% only at the very edges. The five stops
// below sample that curve instead of the straight ramp the previous
// version used (which shaded the whole upper and lower thirds).
var v = ctx.createLinearGradient(0, 0, 0, H)
v.addColorStop(0.00, Qt.rgba(0, 0, 0, 0.16))
v.addColorStop(0.25, Qt.rgba(0, 0, 0, 0.04))
v.addColorStop(0.50, Qt.rgba(0, 0, 0, 0.0))
v.addColorStop(0.75, Qt.rgba(0, 0, 0, 0.04))
v.addColorStop(1.00, Qt.rgba(0, 0, 0, 0.16))
ctx.fillStyle = v
ctx.fillRect(0, 0, W, H)
}
} }
} }
+5 -3
View File
@@ -17,9 +17,9 @@
// the fill is the SAME vertical topColor->bottomColor gradient plus the 1px // 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 // 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 // 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 // JS pass per VizSettle application — the push hooks the exact same
// from/to/progress triplet the delegates' at(i) bindings used to capture — // 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: // so a shell-pulse edge dirties ONE node instead of 28-48. No Canvas anywhere:
// Canvas is CPU raster and is banned on this path. // 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 // THE SHADER IS A .qsb, NOT AN INLINE STRING. Qt 6's ShaderEffect takes the
@@ -104,7 +104,9 @@ Rectangle {
visible: band.shown visible: band.shown
radius: theme.radiusMd radius: theme.radiusMd
color: "#59000000" color: "#59000000"
clip: true // No clip: the shader arm is anchors.fill, and the fallback bars take
// settle.at(i) hard-clamped to 0..1 (VizSettle.qml:86-91) against a
// hardcoded 42px band, so nothing can poke out.
// Album-derived gradient, reusing the ambient triad (ambient_qt.rs) instead // Album-derived gradient, reusing the ambient triad (ambient_qt.rs) instead
// of a second album-color pipeline — the Slint dock reads // of a second album-color pipeline — the Slint dock reads
+82 -115
View File
@@ -1,57 +1,49 @@
// VizSettle — ONE shared inter-frame interpolator for a whole visualizer stream. // VizSettle — ONE shared frame-applier for a whole visualizer stream.
// //
// WHY THIS EXISTS. The FFT producer in qbz-audio publishes at TARGET_FPS = 30 // WHAT CHANGED 2026-08-13 (the single-pulse redesign). This object used to
// and the drain is signal-driven (one publish per produced frame), so the bar // REPLAY each 30 Hz published frame across the render frames that followed
// heights STEP 30 times a second while the compositor draws 60+. The step is // it, driven by a private 16 ms Timer (~62 Hz) — the QML answer to the Slint
// what reads as "low frame rate" — not missing data (see the bandwidth note // dock's per-bar `animate bar-h { 90ms }` (SidebarNowPlayingDock.slint:45).
// below). This object replays each 30 Hz frame across the render frames that // That driver was the shell's dominant GPU term: Qt Quick has no dirty-region
// follow it, so the bars move at the display rate off 30 Hz data. // rendering, so every tick repainted the WHOLE window, and 62 ticks/s against
// the atmosphere's own 33 ms Timer — unsynchronised — is what the
// 2026-08-11 GPU investigation measured as ~62 presents/s, render = 2 ms,
// swap = 12-24 ms, 93-97% GPU on the owner's 4070. Interpolating between 30
// Hz publishes is a luxury that only pays when the repaint is a rectangle;
// when the repaint is the window, the spectrum moves at the rate the data
// arrives and that is the deal (GPU-COST-INVESTIGATION.md §4).
// //
// PARITY: the Slint dock does the same thing per bar — // THE CONTRACT NOW:
// crates/qbz-ui/ui/shell/SidebarNowPlayingDock.slint:45 // - A publish (onTargetChanged) STASHES the frame in a plain JS slot and
// `animate bar-h { duration: 90ms; easing: ease; }` (VBar) // writes NO bound property — a publish alone never schedules a frame.
// `animate col-h { duration: 70ms; easing: ease; }` (WaveCol) // - The shared shell pulse (QbzShell.pulseMs, shell_bridge.rs) is the ONLY
// The QML port dropped that as "28 concurrent animations would buy nothing"; // driver. On its edge, and only if a frame is pending, the latest frame is
// what it actually bought was the smooth motion. This restores the behaviour // applied: `from`/`to` swap and every `at(i)` binding re-evaluates in the
// with ONE driver for the whole stream instead of one animation per bar. // SAME event-loop turn as every other pulse consumer, so the window
// // presents ONCE per period for the whole shell.
// WHY NOT A HIGHER PRODUCER RATE. qbz-audio smooths the bars in Rust with a // - SELF-PARKING is structural: a paused player, a hidden band or a
// per-frame EMA (attack 0.7 / decay 0.65, processor.rs:174-184). At 30 fps the // disabled tap publishes nothing (viz_qt.rs), nothing goes pending, and
// decay time constant is 33.3ms / -ln(0.65) = 77ms → -3dB at 2.1Hz; the fast // the pulse handler writes nothing — zero frames, zero timers to park.
// attack is 28ms → 5.8Hz. The published signal therefore carries nothing above // The `live` gate below — fed by the dock's `vizShouldRun`, which drops
// ~6Hz, and 30 Hz sampling is already >5x oversampled: doubling TARGET_FPS adds // when the window deactivates — keeps an occluded window at zero
// no information, it only halves those time constants (a re-tune, not a frame // repaints. Do not weaken it.
// rate) and doubles the FFT cost — for BOTH frontends, since the constant is // - Delegates bind ONE call, `at(i)`, which reads `from`, `to` and
// shared. Interpolating is the correct fix for a step artefact. // `progress` unconditionally on every path — QML captures those three as
// // dependencies, so a pulse re-evaluates the bar heights and instantiates
// COST CONTRACT.
// - 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 (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
// nothing. // nothing.
// //
// LATENCY: the band renders between the last two published frames, i.e. one // SMOOTHNESS NOTE: dropping the interpolator does NOT return the raw FFT
// producer period (33ms) behind the tap. Against the pipeline that already // step. qbz-audio smooths the bars in Rust with a per-frame EMA (attack 0.7 /
// exists (46ms FFT window + 77ms smoothing) that is not perceptible, and it is // decay 0.65, processor.rs:174-184), band-limiting the published signal to
// what buys motion with no overshoot and no extra damping. // ~6 Hz, so 30 Hz application still renders the ease-in/decay shape — at the
// data rate, one present per published frame.
//
// LATENCY: a frame is applied on the first pulse after its publish, i.e. up
// to one period (33 ms) behind the tap. Against the pipeline that already
// exists (46 ms FFT window + 77 ms smoothing) that is not perceptible.
import QtQuick import QtQuick
import com.blitzfc.qbz
Item { Item {
id: settle id: settle
@@ -62,8 +54,10 @@ Item {
// that feeds this, so it runs once per publish instead of once per bar per // that feeds this, so it runs once per publish instead of once per bar per
// frame. // frame.
property var target: [] property var target: []
// Master gate — mirror the band's capture gate. false freezes on the last // Master gate — mirror the band's capture gate. false = frozen: the
// frame and guarantees the driver is stopped. // publish path writes NOTHING (an invisible consumer's writes still dirty
// the whole window — see onTargetChanged), the pulse handler returns
// early, and the last applied frame holds.
property bool live: true property bool live: true
// ---- output (read via at(i)) ------------------------------------------- // ---- output (read via at(i)) -------------------------------------------
@@ -71,17 +65,17 @@ Item {
property var to: [] property var to: []
property real progress: 1.0 property real progress: 1.0
// Publish period estimate, ms. Seeded at 1000/TARGET_FPS and tracked with a // The stash. `property var` because QML object state has no other home;
// slow EMA so a producer that drifts off 30 Hz still lands on time. // NOTHING binds to it, so writing it emits a notify nobody hears and —
property real periodMs: 33.3 // the whole point — schedules no frame.
property real lastTickMs: 0 property var pending: null
visible: false visible: false
width: 0 width: 0
height: 0 height: 0
// Interpolated, clamped amplitude for bar `i`. Reads all three outputs on // Applied amplitude for bar `i`. Reads all three outputs on every path —
// every path — do NOT add an early return, it would drop the dependency. // do NOT add an early return, it would drop the dependency.
function at(i) { function at(i) {
var a = settle.from[i] || 0; var a = settle.from[i] || 0;
var b = settle.to[i] || 0; var b = settle.to[i] || 0;
@@ -91,33 +85,22 @@ Item {
onTargetChanged: { onTargetChanged: {
if (!settle.live) { if (!settle.live) {
settle.from = settle.target; // FROZEN — write NOTHING. The 2026-08-13 measurement that earns
settle.to = settle.target; // this early return: an invisible consumer (the immersive panels
settle.progress = 1.0; // stay mounted while the overlay is closed) that writes from/to
// on every publish still re-evaluates its delegates' geometry
// bindings, and an invisible item's geometry change marks the
// WHOLE WINDOW dirty — 28 bars x 30 Hz of phantom presents,
// unsynchronised with the pulse (qt.quick.dirty named the nodes).
// The last applied frame simply holds; the next live edge picks
// up whatever publishes after it.
return; return;
} }
var now = Date.now();
var dt = now - settle.lastTickMs;
// Ignore the resume/first-frame gap and any absurd outlier; 8..200ms is
// 5..125 fps, everything a sane producer can emit.
if (dt > 8 && dt < 200)
settle.periodMs = settle.periodMs * 0.8 + dt * 0.2;
// Plain-JS copy, ONCE per publish (30/s), so `at()` indexes a JS array // 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 // instead of crossing into the QList_f32 sequence wrapper 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| // The waveform arm ALREADY hands over a plain Array (it folds |sample|
// in its own binding, SpectrumBand.qml:108-117), so skip the copy // in its own binding, SpectrumBand.qml:108-117), so skip the copy
// there instead of paying a second 48-element pass per publish. // 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 t = settle.target;
var arr; var arr;
if (Array.isArray(t)) { if (Array.isArray(t)) {
@@ -128,52 +111,36 @@ Item {
for (var i = 0; i < n; ++i) for (var i = 0; i < n; ++i)
arr[i] = t[i]; arr[i] = t[i];
} }
settle.from = settle.to; settle.pending = arr;
settle.to = arr;
settle.lastTickMs = now;
settle.progress = 0.0;
if (!driver.running)
driver.running = true;
} }
onLiveChanged: { onLiveChanged: {
if (!settle.live) { if (!settle.live) {
driver.running = false; settle.pending = null;
settle.progress = 1.0; settle.progress = 1.0;
} }
} }
// Wall clock, not a frame-driven elapsedTime: the time source must not // THE driver: the shell's shared repaint pulse. Every consumer of this
// quantise the segment start to vsync (that is the beat this object // edge (the ambient background drift included) dirties the scene in this
// removes). The TRIGGER, however, must not run at the frame rate either — // same event-loop turn, and the window presents once for all of them.
// the producer publishes at TARGET_FPS = 30 (qbz-audio visualizer/mod.rs:32) // Writing nothing when there is no pending frame is what keeps a silent
// and FrameAnimation fired 180x/s on Linux and 100x/s on macOS for it, // or occluded shell at zero presents — the pulse NOTIFY alone never
// i.e. 3-6x the frames the data justifies. 16 ms = ~62 Hz gives two // schedules a repaint.
// interpolated steps per published frame, which is what buys the motion Connections {
// (Slint's equivalent is `animate bar-h { duration: 90ms; easing: ease; }`, target: QbzShell
// SidebarNowPlayingDock.slint:45). function onPulseMsChanged() {
// if (!settle.live)
// `repeat: true` is MANDATORY — Timer defaults to false, and without it the return;
// driver fires once per publish and `progress` freezes at ~0.5. var arr = settle.pending;
// `triggeredOnStart` stays default false: `running` is only set from if (arr === null)
// `onTargetChanged`, which has just written `progress = 0.0`, so an return;
// immediate tick would recompute p≈0 and do nothing. settle.pending = null;
// Coarse timer resolution is irrelevant: `progress` is derived from // Reference swap, not a copy — zero allocation per applied frame,
// Date.now(), so a late tick lands a correspondingly larger `p` and a // same as the old driver.
// missed tick degrades to a hold, never a jump. settle.from = settle.to;
Timer { settle.to = arr;
id: driver settle.progress = 1.0;
interval: 16
repeat: true
running: false
onTriggered: {
var p = (Date.now() - settle.lastTickMs) / settle.periodMs;
if (p >= 1.0) {
settle.progress = 1.0;
driver.running = false; // park until the next publish
} else {
settle.progress = p;
}
} }
} }
} }
+125 -15
View File
@@ -484,9 +484,10 @@ pub fn wc_on_left() -> bool {
// "ambient"). Same additive key patch. // "ambient"). Same additive key patch.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Mode index, ui_prefs.rs `app_background_index` semantics, except the POC /// Mode index, ui_prefs.rs `app_background_index` semantics, 1:1 with the
/// has no blurred-art route: 0 = off, 1 = on (ambient look) for BOTH /// Slint: 0 = Off, 1 = Ambient (the album-triad metaball field), 2 = Blurred
/// "ambient" and "blurred" (see ambient_qt.rs POC-NOTE). /// art (the ImmersiveAtmosphere blurred-cover look). The two are DIFFERENT
/// looks and each has its own layer in AppShell.qml — an unknown key is Off.
pub fn app_background_mode() -> i32 { pub fn app_background_mode() -> i32 {
let Some(path) = prefs_path() else { let Some(path) = prefs_path() else {
return 0; return 0;
@@ -499,7 +500,10 @@ pub fn app_background_mode() -> i32 {
.and_then(|q| q.as_str().map(str::to_string)) .and_then(|q| q.as_str().map(str::to_string))
}) })
.unwrap_or_else(|| "off".to_string()); .unwrap_or_else(|| "off".to_string());
if key == "off" { 0 } else { 1 } APP_BACKGROUND_VALUES
.iter()
.position(|v| *v == key)
.unwrap_or(0) as i32
} }
/// Live-tuning knobs (Slint AppearanceState defaults; the QBZ_BG_* envs are /// Live-tuning knobs (Slint AppearanceState defaults; the QBZ_BG_* envs are
@@ -510,6 +514,82 @@ pub fn ambient_dim() -> f32 {
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(0.35) .unwrap_or(0.35)
} }
/// RETIRED 2026-08-13 (single-pulse redesign): VizSettle no longer owns a
/// driver timer — every whole-window animator ticks off the shared shell
/// pulse (`shell_pulse_ms` below / `QbzShell.pulseMs`). This knob still feeds
/// the `vizTickMs` qproperty, which nothing reads anymore; it stays only
/// because deleting a qproperty churns the bridge for no gain.
///
/// Was: VizSettle's interpolation tick in ms, QBZ_VIZ_TICK (default 16).
/// This was the single biggest GPU lever in the shell and it was NOT about
/// the spectrum: Qt Quick has no dirty-region rendering, so every tick of
/// that timer redrew the WHOLE window. Measured on the owner's 4070 at
/// half-screen with the Large band up — band alone 45-69%, band + ambient
/// 76-82%, ambient alone 36%. The band was the dominant term because it set
/// the redraw RATE, and the ambient is expensive because it turns the whole
/// scene into an all-alpha stack that then gets redrawn at that rate.
pub fn viz_tick_ms() -> i32 {
std::env::var("QBZ_VIZ_TICK")
.ok()
.and_then(|v| v.parse::<i32>().ok())
.map(|v| v.clamp(8, 100))
.unwrap_or(16)
}
/// The shell repaint pulse period in ms, QBZ_PULSE_MS (default 33).
///
/// THE single-clock knob of the 2026-08-13 redesign: one Rust thread hops to
/// the Qt loop every `period` and bumps `QbzShell.pulseMs`, and every
/// continuous animator (the ambient background drift, VizSettle's frame
/// application) ticks off that ONE notify edge, so all of them dirty the
/// scene in the same event-loop turn and the window presents ONCE per
/// period — not once per animator. Qt Quick has no dirty-region rendering,
/// so presents/s IS the shell's GPU term; two unsynchronised ~30 Hz clocks
/// measured ~62 presents/s and 93-97% GPU on the owner's 4070.
///
/// 33 ms matches the FFT producer's TARGET_FPS = 30 (qbz-audio
/// visualizer/mod.rs:32): one pulse per published frame, so no published
/// frame is ever skipped and none is rendered twice. Clamped to [10, 200] —
/// below 10 ms you are paying presents the data cannot fill, above 200 ms
/// the motion reads as broken, not calm.
pub fn shell_pulse_ms() -> i32 {
std::env::var("QBZ_PULSE_MS")
.ok()
.and_then(|v| v.parse::<i32>().ok())
.map(|v| v.clamp(10, 200))
.unwrap_or(33)
}
/// Cache the content pane in a texture layer, QBZ_PANE_LAYER (default off).
///
/// `layer.enabled` collapses the pane's whole subtree into ONE textured quad,
/// re-rendered only when something inside it changes. With the dynamic
/// background up every element in there is alpha-blended and unbatchable, so a
/// static track list currently costs its full draw list on every one of those
/// whole-window redraws. Off by default because it trades an FBO the size of
/// the pane, and because a SCROLLING list invalidates it every frame — it is
/// here to be measured, not assumed.
pub fn pane_layer() -> bool {
matches!(std::env::var("QBZ_PANE_LAYER").as_deref(), Ok("1") | Ok("true"))
}
/// Offscreen render-target multiplier for the ambient field, QBZ_BG_SCALE.
///
/// RETIRED 2026-08-13: the field renders INLINE now (no FBO to size), and the
/// knob was measured to be a non-lever anyway (0.5 moved the GPU ~5 points in
/// mode 1 — the per-present cost, not the fragment cost, dominates). The
/// qproperty stays to avoid bridge churn; nothing reads it.
///
/// Was: 1.0 = the reference's own sizing, clamped to [0.2, 1.0] — below 0.2
/// the metaball lobes started to band on the upscale, above 1.0 there was
/// nothing to gain.
pub fn ambient_scale() -> f32 {
std::env::var("QBZ_BG_SCALE")
.ok()
.and_then(|v| v.parse::<f32>().ok())
.map(|v| v.clamp(0.2, 1.0))
.unwrap_or(1.0)
}
pub fn ambient_surface_alpha() -> f32 { pub fn ambient_surface_alpha() -> f32 {
std::env::var("QBZ_BG_SURFACE_ALPHA") std::env::var("QBZ_BG_SURFACE_ALPHA")
.ok() .ok()
@@ -523,24 +603,53 @@ pub fn ambient_bar_alpha() -> f32 {
.unwrap_or(0.3) .unwrap_or(0.3)
} }
/// Flip + persist off <-> "ambient" (the owner's mode; "blurred" is not a /// Flip + persist the app-wide background off <-> on. Returns the new mode
/// POC route). Returns the new mode index (0/1). /// index (0 = off, 1 = ambient, 2 = blurred art).
///
/// Turning it back ON restores the mode the user had PICKED in Appearance,
/// not a hardcoded "ambient": now that "Blurred art" is a distinct look, a
/// menu toggle that silently rewrote it to the metaball field would be a
/// setting the app changes behind the user's back. The previous mode rides
/// the additive `app_background_last` key (same one-document patch style as
/// every other pref here); absent or unusable, it falls back to "ambient".
/// ///
/// Same one-document rule as `toggle_system_title_bar`: the current key is /// Same one-document rule as `toggle_system_title_bar`: the current key is
/// read inside the write closure, so a torn read can no longer make the app /// read inside the write closure, so a torn read can no longer make the app
/// commit "ambient" over a user who had just turned it off (or the reverse). /// commit "ambient" over a user who had just turned it off (or the reverse).
pub fn toggle_ambient_background() -> i32 { pub fn toggle_ambient_background() -> i32 {
edit_prefs(|doc| { edit_prefs(|doc| {
let was_off = doc let current = doc
.get("app_background") .get("app_background")
.and_then(|q| q.as_str()) .and_then(|q| q.as_str())
.unwrap_or("off") .unwrap_or("off")
== "off"; .to_string();
doc.insert( if current == "off" {
"app_background".to_string(), let restored = doc
serde_json::Value::String(if was_off { "ambient" } else { "off" }.to_string()), .get("app_background_last")
); .and_then(|q| q.as_str())
(true, if was_off { 1 } else { 0 }) .filter(|v| *v != "off" && APP_BACKGROUND_VALUES.contains(v))
.unwrap_or("ambient")
.to_string();
let index = APP_BACKGROUND_VALUES
.iter()
.position(|v| *v == restored)
.unwrap_or(1) as i32;
doc.insert(
"app_background".to_string(),
serde_json::Value::String(restored),
);
(true, index)
} else {
doc.insert(
"app_background_last".to_string(),
serde_json::Value::String(current),
);
doc.insert(
"app_background".to_string(),
serde_json::Value::String("off".to_string()),
);
(true, 0)
}
}) })
.unwrap_or_else(|| { .unwrap_or_else(|| {
let current = app_background_mode(); let current = app_background_mode();
@@ -2285,8 +2394,9 @@ pub async fn settings_select(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &st
return; return;
}; };
save_pref("app_background", serde_json::json!(mode)); save_pref("app_background", serde_json::json!(mode));
// Live (pure QML layering; blurred renders AS ambient in the POC). // Live (pure QML layering — AppShell mounts AmbientField for 1 and
let ambient = if *mode == "off" { 0 } else { 1 }; // ImmersiveAtmosphere for 2, exactly like AppShell.slint:213-231).
let ambient = index as i32;
crate::shell_bridge::ui(move |mut b| b.as_mut().set_ambient_mode(ambient)); crate::shell_bridge::ui(move |mut b| b.as_mut().set_ambient_mode(ambient));
} }
"language" => { "language" => {
+89 -3
View File
@@ -215,7 +215,7 @@ pub mod qbz_shell {
/// deliberately absent, and it was the reason the scenes could not /// deliberately absent, and it was the reason the scenes could not
/// ship. /// ship.
#[qproperty(bool, shader_scenes_available)] #[qproperty(bool, shader_scenes_available)]
/// May the app-wide dynamic background ("modo Cider") be offered? /// May the app-wide dynamic background be offered?
/// Same tier, same reason (`main.rs:8563`); gates the whole picker row. /// Same tier, same reason (`main.rs:8563`); gates the whole picker row.
#[qproperty(bool, app_background_available)] #[qproperty(bool, app_background_available)]
/// Is the kiosk touch shell the one currently mounted? /// Is the kiosk touch shell the one currently mounted?
@@ -246,6 +246,11 @@ pub mod qbz_shell {
/// `visibility` binding must be correct on the FIRST mapped frame, and /// `visibility` binding must be correct on the FIRST mapped frame, and
/// a later push would arrive after the window is already on screen. /// a later push would arrive after the window is already on screen.
#[qproperty(bool, kiosk_fullscreen_boot)] #[qproperty(bool, kiosk_fullscreen_boot)]
/// App-wide dynamic background mode, `AppearanceState
/// .app-background-mode-index` semantics: 0 = Off, 1 = Ambient (the
/// album-triad metaball field), 2 = Blurred art (the
/// ImmersiveAtmosphere cover look). AppShell mounts a DIFFERENT layer
/// for 1 and 2 — never treat this as a bool.
#[qproperty(i32, ambient_mode)] #[qproperty(i32, ambient_mode)]
// Album-art triad (ambient_qt.rs), pushed on track change. // Album-art triad (ambient_qt.rs), pushed on track change.
#[qproperty(QString, ambient_primary)] #[qproperty(QString, ambient_primary)]
@@ -253,6 +258,48 @@ pub mod qbz_shell {
#[qproperty(QString, ambient_accent)] #[qproperty(QString, ambient_accent)]
// Look knobs (Slint AppearanceState defaults; QBZ_BG_* env seed). // Look knobs (Slint AppearanceState defaults; QBZ_BG_* env seed).
#[qproperty(f32, ambient_dim)] #[qproperty(f32, ambient_dim)]
/// Debug knob QBZ_BG_SCALE — RETIRED 2026-08-13: the ambient field
/// renders inline now (no offscreen target to scale), and the knob
/// measured as a non-lever anyway. Kept to avoid bridge churn.
#[qproperty(f32, ambient_scale)]
/// Debug knobs QBZ_VIZ_TICK / QBZ_PANE_LAYER — the two whole-window
/// redraw levers (settings_qt.rs documents what each measures).
/// NOTE 2026-08-13: viz_tick_ms is RETIRED (VizSettle ticks off the
/// pulse now); it stays only to avoid bridge churn.
#[qproperty(i32, viz_tick_ms)]
#[qproperty(bool, pane_layer)]
/// THE shell repaint pulse (2026-08-13 single-clock redesign).
///
/// One Rust thread (`start_shell_pulse`) bumps this every
/// `settings_qt::shell_pulse_ms()` (default 33 ms, the FFT producer's
/// TARGET_FPS = 30 period). Every continuous whole-window animator
/// ticks off its NOTIFY EDGE — the ambient background drift
/// (ImmersiveAtmosphere) and the visualizer's frame application
/// (VizSettle) — instead of owning a private Timer. Qt Quick has no
/// dirty-region rendering, so N unsynchronised ~30 Hz clocks cost
/// N x 30 full-window presents a second; on one edge they all dirty
/// the scene in the SAME event-loop turn and the window presents
/// once per period. That rate is the shell's dominant GPU term
/// (render = 2 ms, swap = 12-24 ms on the owner's 4070 — the frames
/// are GPU-bound, so presents/s x window area is the whole bill).
///
/// CONTRACT for QML: tick ONLY off this edge (a Connections on
/// QbzShell with `onPulseMsChanged`), and a handler that writes no
/// property costs no frame — the notify alone never schedules a
/// repaint, so an idle shell stays at zero presents. Never introduce
/// a component-local Timer/NumberAnimation/FrameAnimation for a
/// continuous animation; that is the exact regression this pulse
/// replaced.
///
/// The value is a wrapping millisecond counter (24 h wrap) whose
/// absolute value means nothing — consumers accumulate their own
/// local tick on the edge, so a component can freeze/reset its pose
/// without touching the shared clock. The thread runs unconditionally
/// (unlike the viz drain, which owns its enable bit): ~30 event-loop
/// wakeups a second with zero frames when nothing animates, because
/// Rust cannot see QML mount state and a gate it cannot see would be
/// a lie.
#[qproperty(i32, pulse_ms)]
#[qproperty(f32, ambient_surface_alpha)] #[qproperty(f32, ambient_surface_alpha)]
#[qproperty(f32, ambient_bar_alpha)] #[qproperty(f32, ambient_bar_alpha)]
@@ -487,8 +534,8 @@ pub mod qbz_shell {
fn toggle_system_title_bar(self: Pin<&mut QbzShell>); fn toggle_system_title_bar(self: Pin<&mut QbzShell>);
/// App-menu ambient toggle: flip the persisted `app_background` pref /// App-menu ambient toggle: flip the persisted `app_background` pref
/// off <-> "ambient" and apply LIVE (pure QML layering — no restart, /// off <-> the last picked mode ("ambient" or "blurred") and apply
/// unlike the titlebar). /// LIVE (pure QML layering — no restart, unlike the titlebar).
#[qinvokable] #[qinvokable]
fn toggle_ambient_background(self: Pin<&mut QbzShell>); fn toggle_ambient_background(self: Pin<&mut QbzShell>);
@@ -638,6 +685,10 @@ pub struct QbzShellRust {
ambient_secondary: QString, ambient_secondary: QString,
ambient_accent: QString, ambient_accent: QString,
ambient_dim: f32, ambient_dim: f32,
ambient_scale: f32,
viz_tick_ms: i32,
pane_layer: bool,
pulse_ms: i32,
ambient_surface_alpha: f32, ambient_surface_alpha: f32,
ambient_bar_alpha: f32, ambient_bar_alpha: f32,
force_canvas_art: bool, force_canvas_art: bool,
@@ -727,6 +778,10 @@ impl Default for QbzShellRust {
ambient_secondary: QString::from("#9632ff"), ambient_secondary: QString::from("#9632ff"),
ambient_accent: QString::from("#3fd9c8"), ambient_accent: QString::from("#3fd9c8"),
ambient_dim: crate::settings_qt::ambient_dim(), ambient_dim: crate::settings_qt::ambient_dim(),
ambient_scale: crate::settings_qt::ambient_scale(),
viz_tick_ms: crate::settings_qt::viz_tick_ms(),
pane_layer: crate::settings_qt::pane_layer(),
pulse_ms: 0,
ambient_surface_alpha: crate::settings_qt::ambient_surface_alpha(), ambient_surface_alpha: crate::settings_qt::ambient_surface_alpha(),
ambient_bar_alpha: crate::settings_qt::ambient_bar_alpha(), ambient_bar_alpha: crate::settings_qt::ambient_bar_alpha(),
// Read here, not in boot(): the first QML access to this singleton // Read here, not in boot(): the first QML access to this singleton
@@ -818,6 +873,35 @@ pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzShell>) + Send + 'static) {
} }
} }
// ---------------------------------------------------------------------------
// The shell repaint pulse (2026-08-13 single-clock redesign)
// ---------------------------------------------------------------------------
/// 24 h wrap for the pulse counter. Consumers only use the notify EDGE (they
/// accumulate their own local tick), so the wrap is invisible; it exists to
/// keep the counter an i32 forever.
const PULSE_WRAP_MS: i32 = 86_400_000;
/// Spawn the one shared repaint clock. Started from `boot()` (the first boot
/// only — a duplicate registration warns and skips), ticks every
/// `settings_qt::shell_pulse_ms()`, and hops to the Qt loop to bump
/// `pulse_ms`. Runs unconditionally: a bump no QML handler reacts to costs an
/// event-loop wakeup and no frame (see the qproperty contract).
fn start_shell_pulse() {
let period = crate::settings_qt::shell_pulse_ms();
std::thread::Builder::new()
.name("qbz-qt-shell-pulse".to_string())
.spawn(move || {
let mut tick: i32 = 0;
loop {
std::thread::sleep(std::time::Duration::from_millis(period as u64));
tick = (tick + period) % PULSE_WRAP_MS;
ui(move |mut shell| shell.as_mut().set_pulse_ms(tick));
}
})
.expect("spawn shell pulse thread");
}
/// Rust-side mirror of the `queue_open` property — `toggle_queue` below is /// Rust-side mirror of the `queue_open` property — `toggle_queue` below is
/// the ONLY writer (the NPB button and the queue panel's close both call it; /// the ONLY writer (the NPB button and the queue panel's close both call it;
/// QML never writes the property directly). Read by the hotkeys §1.2 Escape /// QML never writes the property directly). Read by the hotkeys §1.2 Escape
@@ -853,6 +937,8 @@ impl qbz_shell::QbzShell {
pub fn boot(self: Pin<&mut Self>) { pub fn boot(self: Pin<&mut Self>) {
if QT_THREAD.set(self.qt_thread()).is_err() { if QT_THREAD.set(self.qt_thread()).is_err() {
log::warn!("[qbz-qt] shell Qt thread already registered"); log::warn!("[qbz-qt] shell Qt thread already registered");
} else {
start_shell_pulse();
} }
} }
+8 -7
View File
@@ -13,13 +13,14 @@
//! Slint side's persistent `VecModel` + `set_row_data` (qbz/src/visualizer.rs). //! Slint side's persistent `VecModel` + `set_row_data` (qbz/src/visualizer.rs).
//! The lengths are FIXED (16 / 5 / 512) so an index binding is always safe. //! The lengths are FIXED (16 / 5 / 512) so an index binding is always safe.
//! //!
//! The publish rate is the producer's `TARGET_FPS` (30) and is NOT the band's //! The publish rate is the producer's `TARGET_FPS` (30) and IS the band's
//! frame rate: `qml/shell/VizSettle.qml` replays each published frame across //! frame rate since the 2026-08-13 single-pulse redesign: `VizSettle.qml`
//! the render frames that follow it, which is what the Slint dock gets from its //! stashes each published frame and applies the latest one on the shared
//! per-bar `animate bar-h { 90ms }`. Consequences for this side: a late publish //! shell pulse (`QbzShell.pulseMs`), so the spectrum moves at the data rate
//! degrades to a hold, never a jump, and raising `TARGET_FPS` would buy no //! and the window presents once per period for every animator at once.
//! smoothness — only CPU — because the Rust EMA already band-limits these //! Consequences for this side: a late publish degrades to a hold, never a
//! streams to ~6 Hz. //! jump, and raising `TARGET_FPS` would buy no smoothness — only CPU —
//! because the Rust EMA already band-limits these streams to ~6 Hz.
//! //!
//! Props: bars (16 log-spaced FFT bins), energy (5 semantic bands), //! Props: bars (16 log-spaced FFT bins), energy (5 semantic bands),
//! waveform (256 L + 256 R samples). Invokables: boot + set_enabled. //! waveform (256 L + 256 R samples). Invokables: boot + set_enabled.