Files
qbz/crates/qbz-qt/qml/shell/AmbientField.qml
T
vicrodh 2e3c765616 feat(qt): polarity-aware ambient veil and weak-ramp hardening for light themes
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
2026-08-31 15:47:44 -06:00

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)
}
}
}