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:
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).
|
||||
//
|
||||
// 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
|
||||
// `tick` (ms), reset to 0 whenever `animated` drops, so the static pose is
|
||||
// the Slint tick=0 pose exactly. All x/y offsets are plain bindings of tick
|
||||
// — nothing is animated per-layer.
|
||||
// 0 while !animated (:16). The Qt twin accumulates a local `tick` (ms) on the
|
||||
// shared shell pulse (QbzShell.pulseMs — see the cost note below), reset to 0
|
||||
// whenever `animated` drops, so the static pose is the Slint tick=0 pose
|
||||
// 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.Window
|
||||
import com.blitzfc.qbz
|
||||
|
||||
Item {
|
||||
id: root
|
||||
@@ -40,32 +59,150 @@ Item {
|
||||
/// Gates the layer drift (:14); the mount binds QbzPlayer.npPlaying.
|
||||
property bool animated: true
|
||||
|
||||
// Drift clock in ms (:16). 24h period, linear; reset to 0 on the
|
||||
// animated->static edge so the static pose is the Slint tick=0 pose.
|
||||
// Drift clock in ms (:16). Reset to 0 on the animated->static edge so the
|
||||
// static pose is the Slint tick=0 pose exactly.
|
||||
property real tick: 0
|
||||
NumberAnimation on tick {
|
||||
from: 0
|
||||
to: 86400000
|
||||
duration: 86400000
|
||||
loops: Animation.Infinite
|
||||
running: root.animated
|
||||
// Driven by THE SHELL PULSE (QbzShell.pulseMs, shell_bridge.rs) — not a
|
||||
// private Timer, and never a NumberAnimation, and the difference is the
|
||||
// whole cost of this component.
|
||||
//
|
||||
// A NumberAnimation is driven by the animation driver: it updates once per
|
||||
// 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
|
||||
|
||||
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 {
|
||||
anchors.fill: parent
|
||||
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) -------------------------------------------------
|
||||
Image {
|
||||
readonly property real sx: Math.sin(root.tick / 7600.0 * 6.283185)
|
||||
+ 0.35 * Math.sin((root.tick / 3300.0 + 1.9) * 6.283185)
|
||||
readonly property real sy: Math.sin((root.tick / 11200.0 + 1.1) * 6.283185)
|
||||
visible: root.source !== ""
|
||||
readonly property real sx: root.s1x
|
||||
readonly property real sy: root.s1y
|
||||
visible: !root.fxOk && root.source !== ""
|
||||
source: root.source
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
width: root.width + 520
|
||||
@@ -76,10 +213,9 @@ Item {
|
||||
}
|
||||
// --- Layer 2 (:33-44) -------------------------------------------------
|
||||
Image {
|
||||
readonly property real sx: 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 sy: Math.sin((root.tick / 14800.0 + 1.7) * 6.283185)
|
||||
visible: root.source !== ""
|
||||
readonly property real sx: root.s2x
|
||||
readonly property real sy: root.s2y
|
||||
visible: !root.fxOk && root.source !== ""
|
||||
source: root.source
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
width: root.width + 620
|
||||
@@ -90,10 +226,9 @@ Item {
|
||||
}
|
||||
// --- Layer 3 (:45-56) -------------------------------------------------
|
||||
Image {
|
||||
readonly property real sx: Math.sin((root.tick / 5700.0 + 1.6) * 6.283185)
|
||||
readonly property real sy: Math.sin((root.tick / 12300.0 + 0.2) * 6.283185)
|
||||
+ 0.28 * Math.sin((root.tick / 3600.0 + 2.8) * 6.283185)
|
||||
visible: root.source !== ""
|
||||
readonly property real sx: root.s3x
|
||||
readonly property real sy: root.s3y
|
||||
visible: !root.fxOk && root.source !== ""
|
||||
source: root.source
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
width: root.width + 700
|
||||
@@ -104,9 +239,9 @@ Item {
|
||||
}
|
||||
// --- Layer 4 (:57-67) -------------------------------------------------
|
||||
Image {
|
||||
readonly property real sx: Math.sin((root.tick / 15100.0 + 2.4) * 6.283185)
|
||||
readonly property real sy: Math.sin((root.tick / 6900.0 + 1.4) * 6.283185)
|
||||
visible: root.source !== ""
|
||||
readonly property real sx: root.s4x
|
||||
readonly property real sy: root.s4y
|
||||
visible: !root.fxOk && root.source !== ""
|
||||
source: root.source
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
width: root.width + 560
|
||||
@@ -127,15 +262,18 @@ Item {
|
||||
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 {
|
||||
anchors.fill: parent
|
||||
visible: !root.fxOk || root.source === ""
|
||||
color: Qt.rgba(0, 0, 0, root.dim)
|
||||
}
|
||||
// 180-degree scrim (:80-82): #00000066 top -> transparent 35% ->
|
||||
// #00000080 bottom (RRGGBBAA converted).
|
||||
// #00000080 bottom (RRGGBBAA converted). Same GPU-arm folding as dim.
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
visible: !root.fxOk || root.source === ""
|
||||
gradient: Gradient {
|
||||
GradientStop { position: 0.0; color: "#66000000" }
|
||||
GradientStop { position: 0.35; color: "transparent" }
|
||||
|
||||
@@ -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/
|
||||
// shaders/ambient.wgsl): FOUR album-colored blobs on the shader's EXACT
|
||||
// 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).
|
||||
// The Qt rendering of the Slint ambient scene (crates/qbz-ui/ui/shaders/
|
||||
// ambient.wgsl, wgpu scene 7). Two arms:
|
||||
//
|
||||
// POC-NOTEs (vs the WGSL original, ambient_qt.rs has the full list):
|
||||
// - No fbm domain warp and no r²/d² metaball potential — plain radial
|
||||
// gradients with 'lighter' compositing approximate the fusion.
|
||||
// - No audio breathe (the POC has no FFT tap; level_smooth = 0 → the
|
||||
// shader's breathe is a constant 1.0 there anyway) and no grain dither.
|
||||
// - Rendered with QPainter (renderTarget Image) so it works on the
|
||||
// software/offscreen path too; 30fps cap + paused when the window is not
|
||||
// being shown (the spec's cost controls §6).
|
||||
// GPU — a ShaderEffect over assets/shaders/ambient.frag rendered into a
|
||||
// ShaderEffectSource at 30 fps (see "THE SHADER IS RENDERED TO A
|
||||
// TEXTURE" below — that schedule is load-bearing, not an optimisation
|
||||
// detail), which is a line-for-line GLSL port of the WGSL:
|
||||
// two-octave fbm domain warp,
|
||||
// four album-coloured METABALLS (r^2/d^2, so the lobes fuse and
|
||||
// stretch), the wide `smoothstep(0.45, 3.4)` iso-surface, the
|
||||
// `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
|
||||
// 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".
|
||||
// WHAT CHANGED AND WHY (2026-08-11). The Canvas used to be the ONLY arm, and
|
||||
// it painted its blobs over a BLACK base, so every pixel outside a blob's
|
||||
// radius was #000000. That is not a small fidelity gap: the reference's colour
|
||||
// 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.Window
|
||||
import com.blitzfc.qbz
|
||||
|
||||
Canvas {
|
||||
Item {
|
||||
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 secondary: QbzShell.ambientSecondary
|
||||
property color accent: QbzShell.ambientAccent
|
||||
@@ -44,82 +69,230 @@ Canvas {
|
||||
&& root.Window.window.visibility !== Window.Hidden)
|
||||
: true
|
||||
|
||||
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
|
||||
// Same detection as SpectrumBand/RoundedImage: the software and null scene
|
||||
// graphs draw NOTHING for a ShaderEffect, so they take the Canvas 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
|
||||
|
||||
// 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
|
||||
|
||||
Timer {
|
||||
interval: 33
|
||||
repeat: true
|
||||
running: root.running && root.visible && root.windowShowing
|
||||
onTriggered: {
|
||||
root.t += interval / 1000.0 * 0.75
|
||||
root.requestPaint()
|
||||
// --- ONE PAINT SOURCE, ON THE SHARED PULSE -----------------------------
|
||||
//
|
||||
// Qt Quick has no dirty-region rendering: ANY dirty node redraws the whole
|
||||
// window, so the shell's GPU cost is linear in how many times per second
|
||||
// something asks for a repaint — measured on the owner's 4070 at half
|
||||
// screen, ~1.2% of GPU per repaint/s (30 Hz -> 36%, ~60 Hz -> 70-82%).
|
||||
// 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()
|
||||
onSecondaryChanged: requestPaint()
|
||||
onAccentChanged: requestPaint()
|
||||
onWidthChanged: requestPaint()
|
||||
onHeightChanged: requestPaint()
|
||||
// --- GPU arm ------------------------------------------------------------
|
||||
//
|
||||
// THE SHADER RENDERS INLINE, NOT INTO A TEXTURE — 2026-08-13, and the
|
||||
// history matters because this is the THIRD shape of this arm:
|
||||
//
|
||||
// 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
|
||||
// transparent (the shader's wide smoothstep falloff).
|
||||
function blob(ctx, cx, cy, r, c, a) {
|
||||
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r)
|
||||
g.addColorStop(0.0, Qt.rgba(c.r, c.g, c.b, a))
|
||||
g.addColorStop(0.55, Qt.rgba(c.r, c.g, c.b, a * 0.45))
|
||||
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)
|
||||
// Matched BY NAME against the uniform block in ambient.frag.
|
||||
property color primary: root.primary
|
||||
property color secondary: root.secondary
|
||||
property color accent: root.accent
|
||||
property real time: root.t
|
||||
// The absent audio breathe (see the header). The shader keeps the term
|
||||
// so wiring a level later is a one-line binding, not a shader edit.
|
||||
property real levelSmooth: 0.0
|
||||
// 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: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
var W = width
|
||||
var H = height
|
||||
if (W <= 0 || H <= 0) return
|
||||
// --- CPU fallback arm ---------------------------------------------------
|
||||
// Plain radial gradients cannot reproduce the metaball tail, so this is an
|
||||
// approximation by construction — but it is the SAME approximation the
|
||||
// reference makes at its extremes: a base of the album mix at the 0.42
|
||||
// floor, with the lobes added on top. What it drops versus the shader is
|
||||
// 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).
|
||||
ctx.fillStyle = "#000000"
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
Connections {
|
||||
target: root
|
||||
function onPrimaryChanged() { canvas.requestPaint() }
|
||||
function onSecondaryChanged() { canvas.requestPaint() }
|
||||
function onAccentChanged() { canvas.requestPaint() }
|
||||
}
|
||||
onWidthChanged: requestPaint()
|
||||
onHeightChanged: requestPaint()
|
||||
onVisibleChanged: if (visible) requestPaint()
|
||||
|
||||
var t = root.t
|
||||
// The shader's orbit constants (ambient.wgsl cA..cD, aspect folded
|
||||
// back out — window-fraction centers are the plain expressions).
|
||||
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)]
|
||||
// One lobe: radial gradient, centre colour at `a` alpha fading to
|
||||
// transparent (the shader's wide smoothstep falloff).
|
||||
function blob(ctx, cx, cy, r, c, a) {
|
||||
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, r)
|
||||
g.addColorStop(0.0, Qt.rgba(c.r, c.g, c.b, a))
|
||||
g.addColorStop(0.55, Qt.rgba(c.r, c.g, c.b, a * 0.45))
|
||||
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
|
||||
// the 0.42-1.12 shape spread — the alpha spread below reproduces
|
||||
// that mid-to-low brightness read under the host's dim scrim.
|
||||
var rr = 0.36 * Math.max(W, H)
|
||||
var c4 = Qt.rgba(
|
||||
(primary.r + accent.r) / 2, (primary.g + accent.g) / 2, (primary.b + accent.b) / 2, 1.0)
|
||||
onPaint: {
|
||||
var ctx = getContext("2d")
|
||||
ctx.reset()
|
||||
var W = width
|
||||
var H = height
|
||||
if (W <= 0 || H <= 0)
|
||||
return
|
||||
|
||||
ctx.globalCompositeOperation = "lighter"
|
||||
blob(ctx, cA[0] * W, cA[1] * H, rr, primary, 0.50)
|
||||
blob(ctx, cB[0] * W, cB[1] * H, rr * 0.95, secondary, 0.46)
|
||||
blob(ctx, cC[0] * W, cC[1] * H, rr * 0.88, accent, 0.44)
|
||||
blob(ctx, cD[0] * W, cD[1] * H, rr * 0.82, c4, 0.42)
|
||||
ctx.globalCompositeOperation = "source-over"
|
||||
var p = root.primary
|
||||
var s = root.secondary
|
||||
var a = root.accent
|
||||
var c4 = Qt.rgba((p.r + a.r) / 2, (p.g + a.g) / 2, (p.b + a.b) / 2, 1.0)
|
||||
|
||||
// Vertical shade: 16% darker at the very top/bottom edges (the
|
||||
// shader's vshade — chrome sits on calmer color).
|
||||
var v = ctx.createLinearGradient(0, 0, 0, H)
|
||||
v.addColorStop(0.0, Qt.rgba(0, 0, 0, 0.16))
|
||||
v.addColorStop(0.5, Qt.rgba(0, 0, 0, 0.0))
|
||||
v.addColorStop(1.0, Qt.rgba(0, 0, 0, 0.16))
|
||||
ctx.fillStyle = v
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
// THE FLOOR — the whole point of this rewrite. Where the shader has
|
||||
// no dominant lobe it still paints the weighted album colour scaled
|
||||
// by 0.42 (then the 0.92 master), so the base here is the four-way
|
||||
// mix at that same level. Never black.
|
||||
var f = 0.42 * 0.92
|
||||
ctx.fillStyle = Qt.rgba(
|
||||
(p.r + s.r + a.r + c4.r) / 4 * f,
|
||||
(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,9 @@
|
||||
// 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
|
||||
// JS pass per VizSettle application — 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:
|
||||
// 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.
|
||||
//
|
||||
// THE SHADER IS A .qsb, NOT AN INLINE STRING. Qt 6's ShaderEffect takes the
|
||||
@@ -104,7 +104,9 @@ Rectangle {
|
||||
visible: band.shown
|
||||
radius: theme.radiusMd
|
||||
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
|
||||
// of a second album-color pipeline — the Slint dock reads
|
||||
|
||||
@@ -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
|
||||
// and the drain is signal-driven (one publish per produced frame), so the bar
|
||||
// heights STEP 30 times a second while the compositor draws 60+. The step is
|
||||
// what reads as "low frame rate" — not missing data (see the bandwidth note
|
||||
// below). This object replays each 30 Hz frame across the render frames that
|
||||
// follow it, so the bars move at the display rate off 30 Hz data.
|
||||
// WHAT CHANGED 2026-08-13 (the single-pulse redesign). This object used to
|
||||
// REPLAY each 30 Hz published frame across the render frames that followed
|
||||
// it, driven by a private 16 ms Timer (~62 Hz) — the QML answer to the Slint
|
||||
// dock's per-bar `animate bar-h { 90ms }` (SidebarNowPlayingDock.slint:45).
|
||||
// That driver was the shell's dominant GPU term: Qt Quick has no dirty-region
|
||||
// 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 —
|
||||
// crates/qbz-ui/ui/shell/SidebarNowPlayingDock.slint:45
|
||||
// `animate bar-h { duration: 90ms; easing: ease; }` (VBar)
|
||||
// `animate col-h { duration: 70ms; easing: ease; }` (WaveCol)
|
||||
// The QML port dropped that as "28 concurrent animations would buy nothing";
|
||||
// what it actually bought was the smooth motion. This restores the behaviour
|
||||
// with ONE driver for the whole stream instead of one animation per bar.
|
||||
//
|
||||
// WHY NOT A HIGHER PRODUCER RATE. qbz-audio smooths the bars in Rust with a
|
||||
// per-frame EMA (attack 0.7 / decay 0.65, processor.rs:174-184). At 30 fps the
|
||||
// decay time constant is 33.3ms / -ln(0.65) = 77ms → -3dB at 2.1Hz; the fast
|
||||
// attack is 28ms → 5.8Hz. The published signal therefore carries nothing above
|
||||
// ~6Hz, and 30 Hz sampling is already >5x oversampled: doubling TARGET_FPS adds
|
||||
// no information, it only halves those time constants (a re-tune, not a frame
|
||||
// rate) and doubles the FFT cost — for BOTH frontends, since the constant is
|
||||
// shared. Interpolating is the correct fix for a step artefact.
|
||||
//
|
||||
// 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
|
||||
// THE CONTRACT NOW:
|
||||
// - A publish (onTargetChanged) STASHES the frame in a plain JS slot and
|
||||
// writes NO bound property — a publish alone never schedules a frame.
|
||||
// - The shared shell pulse (QbzShell.pulseMs, shell_bridge.rs) is the ONLY
|
||||
// driver. On its edge, and only if a frame is pending, the latest frame is
|
||||
// applied: `from`/`to` swap and every `at(i)` binding re-evaluates in the
|
||||
// SAME event-loop turn as every other pulse consumer, so the window
|
||||
// presents ONCE per period for the whole shell.
|
||||
// - SELF-PARKING is structural: a paused player, a hidden band or a
|
||||
// disabled tap publishes nothing (viz_qt.rs), nothing goes pending, and
|
||||
// the pulse handler writes nothing — zero frames, zero timers to park.
|
||||
// The `live` gate below — fed by the dock's `vizShouldRun`, which drops
|
||||
// when the window deactivates — keeps an occluded window at zero
|
||||
// repaints. 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 pulse re-evaluates the bar heights and instantiates
|
||||
// nothing.
|
||||
//
|
||||
// LATENCY: the band renders between the last two published frames, i.e. one
|
||||
// producer period (33ms) behind the tap. Against the pipeline that already
|
||||
// exists (46ms FFT window + 77ms smoothing) that is not perceptible, and it is
|
||||
// what buys motion with no overshoot and no extra damping.
|
||||
// SMOOTHNESS NOTE: dropping the interpolator does NOT return the raw FFT
|
||||
// step. qbz-audio smooths the bars in Rust with a per-frame EMA (attack 0.7 /
|
||||
// decay 0.65, processor.rs:174-184), band-limiting the published signal to
|
||||
// ~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 com.blitzfc.qbz
|
||||
|
||||
Item {
|
||||
id: settle
|
||||
@@ -62,8 +54,10 @@ Item {
|
||||
// that feeds this, so it runs once per publish instead of once per bar per
|
||||
// frame.
|
||||
property var target: []
|
||||
// Master gate — mirror the band's capture gate. false freezes on the last
|
||||
// frame and guarantees the driver is stopped.
|
||||
// Master gate — mirror the band's capture gate. false = frozen: the
|
||||
// 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
|
||||
|
||||
// ---- output (read via at(i)) -------------------------------------------
|
||||
@@ -71,17 +65,17 @@ Item {
|
||||
property var to: []
|
||||
property real progress: 1.0
|
||||
|
||||
// Publish period estimate, ms. Seeded at 1000/TARGET_FPS and tracked with a
|
||||
// slow EMA so a producer that drifts off 30 Hz still lands on time.
|
||||
property real periodMs: 33.3
|
||||
property real lastTickMs: 0
|
||||
// The stash. `property var` because QML object state has no other home;
|
||||
// NOTHING binds to it, so writing it emits a notify nobody hears and —
|
||||
// the whole point — schedules no frame.
|
||||
property var pending: null
|
||||
|
||||
visible: false
|
||||
width: 0
|
||||
height: 0
|
||||
|
||||
// Interpolated, clamped amplitude for bar `i`. Reads all three outputs on
|
||||
// every path — do NOT add an early return, it would drop the dependency.
|
||||
// Applied amplitude for bar `i`. Reads all three outputs on every path —
|
||||
// do NOT add an early return, it would drop the dependency.
|
||||
function at(i) {
|
||||
var a = settle.from[i] || 0;
|
||||
var b = settle.to[i] || 0;
|
||||
@@ -91,33 +85,22 @@ Item {
|
||||
|
||||
onTargetChanged: {
|
||||
if (!settle.live) {
|
||||
settle.from = settle.target;
|
||||
settle.to = settle.target;
|
||||
settle.progress = 1.0;
|
||||
// FROZEN — write NOTHING. The 2026-08-13 measurement that earns
|
||||
// this early return: an invisible consumer (the immersive panels
|
||||
// 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;
|
||||
}
|
||||
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
|
||||
// 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).
|
||||
//
|
||||
// instead of crossing into the QList_f32 sequence wrapper per bar.
|
||||
// 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)) {
|
||||
@@ -128,52 +111,36 @@ Item {
|
||||
for (var i = 0; i < n; ++i)
|
||||
arr[i] = t[i];
|
||||
}
|
||||
settle.from = settle.to;
|
||||
settle.to = arr;
|
||||
settle.lastTickMs = now;
|
||||
settle.progress = 0.0;
|
||||
if (!driver.running)
|
||||
driver.running = true;
|
||||
settle.pending = arr;
|
||||
}
|
||||
|
||||
onLiveChanged: {
|
||||
if (!settle.live) {
|
||||
driver.running = false;
|
||||
settle.pending = null;
|
||||
settle.progress = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
if (p >= 1.0) {
|
||||
settle.progress = 1.0;
|
||||
driver.running = false; // park until the next publish
|
||||
} else {
|
||||
settle.progress = p;
|
||||
}
|
||||
// THE driver: the shell's shared repaint pulse. Every consumer of this
|
||||
// edge (the ambient background drift included) dirties the scene in this
|
||||
// same event-loop turn, and the window presents once for all of them.
|
||||
// Writing nothing when there is no pending frame is what keeps a silent
|
||||
// or occluded shell at zero presents — the pulse NOTIFY alone never
|
||||
// schedules a repaint.
|
||||
Connections {
|
||||
target: QbzShell
|
||||
function onPulseMsChanged() {
|
||||
if (!settle.live)
|
||||
return;
|
||||
var arr = settle.pending;
|
||||
if (arr === null)
|
||||
return;
|
||||
settle.pending = null;
|
||||
// Reference swap, not a copy — zero allocation per applied frame,
|
||||
// same as the old driver.
|
||||
settle.from = settle.to;
|
||||
settle.to = arr;
|
||||
settle.progress = 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -484,9 +484,10 @@ pub fn wc_on_left() -> bool {
|
||||
// "ambient"). Same additive key patch.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Mode index, ui_prefs.rs `app_background_index` semantics, except the POC
|
||||
/// has no blurred-art route: 0 = off, 1 = on (ambient look) for BOTH
|
||||
/// "ambient" and "blurred" (see ambient_qt.rs POC-NOTE).
|
||||
/// Mode index, ui_prefs.rs `app_background_index` semantics, 1:1 with the
|
||||
/// Slint: 0 = Off, 1 = Ambient (the album-triad metaball field), 2 = Blurred
|
||||
/// 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 {
|
||||
let Some(path) = prefs_path() else {
|
||||
return 0;
|
||||
@@ -499,7 +500,10 @@ pub fn app_background_mode() -> i32 {
|
||||
.and_then(|q| q.as_str().map(str::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
|
||||
@@ -510,6 +514,82 @@ pub fn ambient_dim() -> f32 {
|
||||
.and_then(|v| v.parse().ok())
|
||||
.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 {
|
||||
std::env::var("QBZ_BG_SURFACE_ALPHA")
|
||||
.ok()
|
||||
@@ -523,24 +603,53 @@ pub fn ambient_bar_alpha() -> f32 {
|
||||
.unwrap_or(0.3)
|
||||
}
|
||||
|
||||
/// Flip + persist off <-> "ambient" (the owner's mode; "blurred" is not a
|
||||
/// POC route). Returns the new mode index (0/1).
|
||||
/// Flip + persist the app-wide background off <-> on. Returns the new mode
|
||||
/// 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
|
||||
/// 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).
|
||||
pub fn toggle_ambient_background() -> i32 {
|
||||
edit_prefs(|doc| {
|
||||
let was_off = doc
|
||||
let current = doc
|
||||
.get("app_background")
|
||||
.and_then(|q| q.as_str())
|
||||
.unwrap_or("off")
|
||||
== "off";
|
||||
doc.insert(
|
||||
"app_background".to_string(),
|
||||
serde_json::Value::String(if was_off { "ambient" } else { "off" }.to_string()),
|
||||
);
|
||||
(true, if was_off { 1 } else { 0 })
|
||||
.to_string();
|
||||
if current == "off" {
|
||||
let restored = doc
|
||||
.get("app_background_last")
|
||||
.and_then(|q| q.as_str())
|
||||
.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(|| {
|
||||
let current = app_background_mode();
|
||||
@@ -2285,8 +2394,9 @@ pub async fn settings_select(runtime: &Arc<AppRuntime<LoggingAdapter>>, key: &st
|
||||
return;
|
||||
};
|
||||
save_pref("app_background", serde_json::json!(mode));
|
||||
// Live (pure QML layering; blurred renders AS ambient in the POC).
|
||||
let ambient = if *mode == "off" { 0 } else { 1 };
|
||||
// Live (pure QML layering — AppShell mounts AmbientField for 1 and
|
||||
// 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));
|
||||
}
|
||||
"language" => {
|
||||
|
||||
@@ -215,7 +215,7 @@ pub mod qbz_shell {
|
||||
/// deliberately absent, and it was the reason the scenes could not
|
||||
/// ship.
|
||||
#[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.
|
||||
#[qproperty(bool, app_background_available)]
|
||||
/// 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
|
||||
/// a later push would arrive after the window is already on screen.
|
||||
#[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)]
|
||||
// Album-art triad (ambient_qt.rs), pushed on track change.
|
||||
#[qproperty(QString, ambient_primary)]
|
||||
@@ -253,6 +258,48 @@ pub mod qbz_shell {
|
||||
#[qproperty(QString, ambient_accent)]
|
||||
// Look knobs (Slint AppearanceState defaults; QBZ_BG_* env seed).
|
||||
#[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_bar_alpha)]
|
||||
|
||||
@@ -487,8 +534,8 @@ pub mod qbz_shell {
|
||||
fn toggle_system_title_bar(self: Pin<&mut QbzShell>);
|
||||
|
||||
/// App-menu ambient toggle: flip the persisted `app_background` pref
|
||||
/// off <-> "ambient" and apply LIVE (pure QML layering — no restart,
|
||||
/// unlike the titlebar).
|
||||
/// off <-> the last picked mode ("ambient" or "blurred") and apply
|
||||
/// LIVE (pure QML layering — no restart, unlike the titlebar).
|
||||
#[qinvokable]
|
||||
fn toggle_ambient_background(self: Pin<&mut QbzShell>);
|
||||
|
||||
@@ -638,6 +685,10 @@ pub struct QbzShellRust {
|
||||
ambient_secondary: QString,
|
||||
ambient_accent: QString,
|
||||
ambient_dim: f32,
|
||||
ambient_scale: f32,
|
||||
viz_tick_ms: i32,
|
||||
pane_layer: bool,
|
||||
pulse_ms: i32,
|
||||
ambient_surface_alpha: f32,
|
||||
ambient_bar_alpha: f32,
|
||||
force_canvas_art: bool,
|
||||
@@ -727,6 +778,10 @@ impl Default for QbzShellRust {
|
||||
ambient_secondary: QString::from("#9632ff"),
|
||||
ambient_accent: QString::from("#3fd9c8"),
|
||||
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_bar_alpha: crate::settings_qt::ambient_bar_alpha(),
|
||||
// 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
|
||||
/// 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
|
||||
@@ -853,6 +937,8 @@ impl qbz_shell::QbzShell {
|
||||
pub fn boot(self: Pin<&mut Self>) {
|
||||
if QT_THREAD.set(self.qt_thread()).is_err() {
|
||||
log::warn!("[qbz-qt] shell Qt thread already registered");
|
||||
} else {
|
||||
start_shell_pulse();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,13 +13,14 @@
|
||||
//! 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 publish rate is the producer's `TARGET_FPS` (30) and is NOT the band's
|
||||
//! frame rate: `qml/shell/VizSettle.qml` replays each published frame across
|
||||
//! the render frames that follow it, which is what the Slint dock gets from its
|
||||
//! per-bar `animate bar-h { 90ms }`. Consequences for this side: a late publish
|
||||
//! degrades to a hold, never a jump, and raising `TARGET_FPS` would buy no
|
||||
//! smoothness — only CPU — because the Rust EMA already band-limits these
|
||||
//! streams to ~6 Hz.
|
||||
//! The publish rate is the producer's `TARGET_FPS` (30) and IS the band's
|
||||
//! frame rate since the 2026-08-13 single-pulse redesign: `VizSettle.qml`
|
||||
//! stashes each published frame and applies the latest one on the shared
|
||||
//! shell pulse (`QbzShell.pulseMs`), so the spectrum moves at the data rate
|
||||
//! and the window presents once per period for every animator at once.
|
||||
//! Consequences for this side: a late publish degrades to a hold, never a
|
||||
//! 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),
|
||||
//! waveform (256 L + 256 R samples). Invokables: boot + set_enabled.
|
||||
|
||||
Reference in new issue
Block a user