The ambient legibility scrim was a fixed BLACK layer at ambientDim — right for dark themes, but over a LIGHT theme it pushed the field to mid luminance, the worst ground for dark text, and the grey secondary/muted ramp drowned. Light themes now veil with their own near-white surfaceMain a notch stronger, with the in-shader (mode 1) and in-atmosphere (mode 2) darkening turned off there; dark themes keep the exact previous look. QbzTheme additionally steps textSecondary/textMuted toward textPrimary while ambient is on under a light theme, so the weak ramp keeps a contrast floor everywhere without call-site changes. cc
305 lines
16 KiB
QML
305 lines
16 KiB
QML
// AmbientField — the app-wide dynamic background (phase 14), mode 1.
|
|
//
|
|
// The Qt rendering of the Slint ambient scene (crates/qbz-ui/ui/shaders/
|
|
// ambient.wgsl, wgpu scene 7). Two arms:
|
|
//
|
|
// 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.
|
|
//
|
|
// 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
|
|
|
|
Item {
|
|
id: root
|
|
|
|
// 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
|
|
// Set false by the host to freeze the clock (inactive / hidden).
|
|
property bool running: true
|
|
// The in-shader legibility dim (multiply toward black). The host lowers
|
|
// it to 0 on LIGHT themes, where darkening is the wrong direction — the
|
|
// polarity-aware veil rectangle above the field takes over there
|
|
// (AppShell, 2026-08-31).
|
|
property real dim: QbzShell.ambientDim
|
|
|
|
// True unless the window is minimized or hidden — see the GATING RULE at
|
|
// the top. Focus is deliberately NOT part of this.
|
|
readonly property bool windowShowing: root.Window.window
|
|
? (root.Window.window.visibility !== Window.Minimized
|
|
&& root.Window.window.visibility !== Window.Hidden)
|
|
: true
|
|
|
|
// 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
|
|
|
|
// --- 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
|
|
|
|
// --- 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()
|
|
}
|
|
}
|
|
|
|
// --- 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
|
|
|
|
// 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. Host-fed:
|
|
// 0 on light themes (the veil rectangle handles legibility there).
|
|
property real dim: root.dim
|
|
|
|
onStatusChanged: {
|
|
if (status === ShaderEffect.Error)
|
|
console.warn("AmbientField: shader failed:", log)
|
|
}
|
|
|
|
fragmentShader: "../assets/shaders/ambient.frag.qsb"
|
|
// Paired vertex stage — Qt 6.11's built-in default emits no
|
|
// qt_TexCoord0, and the GL link requires both stages to declare the
|
|
// SAME uniform block (see ambient.vert / scene_pack.vert).
|
|
vertexShader: "../assets/shaders/ambient.vert.qsb"
|
|
}
|
|
|
|
// --- 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
|
|
|
|
Connections {
|
|
target: root
|
|
function onPrimaryChanged() { canvas.requestPaint() }
|
|
function onSecondaryChanged() { canvas.requestPaint() }
|
|
function onAccentChanged() { canvas.requestPaint() }
|
|
}
|
|
onWidthChanged: requestPaint()
|
|
onHeightChanged: requestPaint()
|
|
onVisibleChanged: if (visible) requestPaint()
|
|
|
|
// 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)
|
|
}
|
|
|
|
onPaint: {
|
|
var ctx = getContext("2d")
|
|
ctx.reset()
|
|
var W = width
|
|
var H = height
|
|
if (W <= 0 || H <= 0)
|
|
return
|
|
|
|
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)
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|