Files
qbz/crates/qbz-qt/qml/immersive/ImmersiveAtmosphere.qml
T
vicrodh e6c058c2f2 feat(qt): immersive shader scenes — RHI items, scene menu, GL link pairing
Port the immersive GPU scenes: Plasma, Tunnel, Aurora, Ambient, Spectral
Ribbon and Line Bed as QQuickRhiItems/ShaderEffects under a pulse-driven
ShaderSceneLayer, plus the Qt-only Tunnel Flow (Tauri Canvas2D port) fed
by the Viz16 stream. Scene rows live in the header view menu (no Off row);
g cycles the ring. A fresh immersive open defaults to Split > Lyrics
(owner ruling).

OpenGL link fix (owner smoke: Tunnel black with "uniform _22 declared as
type buf and type buf"): every ShaderEffect fragment with its own uniform
layout now ships a paired vertex stage declaring the identical block —
the shared spectrum.vert only ever linked under Vulkan.

RHI hardening: render pass descriptors on the ping-pong targets (the
Tunnel Flow segfault / Plasma dead window), mirrorVertically tied to the
backend (!== OpenGL), ribbon sub-uploads carry setSourceSize.

km
2026-08-16 08:22:57 -06:00

284 lines
13 KiB
QML

// ImmersiveAtmosphere — the Kawarp-like animated atmosphere underlay (§6.1
// of the 2026-08-02 immersive-port contract), port of
// crates/qbz-ui/ui/immersive/ImmersiveAtmosphere.slint:8-83.
//
// FOUR animated layers of the SAME 128x128 atmosphere bitmap (host-generated:
// tiny artwork sample -> blur -> color adjust -> vignette), each oversized
// past the viewport and drifting on its own sine pair, plus a STATIC fallback
// (the plain cover at 0.35 opacity) when no atmosphere bitmap exists. A
// `dim` overlay and a 180-degree gradient scrim sit on top (:77-82). The
// component only animates/composites — it can be reused outside ImmersiveView.
//
// Mounted by ImmersiveView as layer 2 (§5.1): ALWAYS the underlay (the Slint
// shader-mode==0 gate is constant in v1, ruling 1), source
// QbzImmersive.atmosphereUrl, fallback QbzPlayer.npArtworkPath,
// `animated: QbzPlayer.npPlaying`, dim 0.15 (:1313-1321). Spectrum/WaveBed
// paint opaque #000 over it — intended faithful behavior
// (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 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
/// 128x128 atmosphere PNG (file:// URL; "" = not ready). NEVER cleared
/// on track change by the publisher (flicker fix, §3).
property string source: ""
/// Plain cover (file:// URL) shown when `source` is empty.
property string fallbackSource: ""
/// Dark overlay opacity (:11, :78).
property real dim: 0.15
/// Layer opacities (:12-13).
property real baseOpacity: 0.95
property real warpOpacity: 0.48
/// Gates the layer drift (:14); the mount binds QbzPlayer.npPlaying.
property bool animated: true
// 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
// 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
// --- 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"
// Paired vertex stage (Qt 6.11's built-in default emits no
// qt_TexCoord0); the GL link requires both stages to declare the
// SAME uniform block (see atmosphere.vert / scene_pack.vert).
vertexShader: "../assets/shaders/atmosphere.vert.qsb"
}
// --- FALLBACK arm: the original four-Image stack, verbatim ----------------
// --- Layer 1 (:21-32) -------------------------------------------------
Image {
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
height: root.height + 420
x: -260 + sx * 132
y: -210 + sy * 58
opacity: root.baseOpacity
}
// --- Layer 2 (:33-44) -------------------------------------------------
Image {
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
height: root.height + 500
x: -310 - sx * 156
y: -250 - sy * 74
opacity: root.warpOpacity
}
// --- Layer 3 (:45-56) -------------------------------------------------
Image {
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
height: root.height + 540
x: -350 + sx * 118
y: -270 + sy * 92
opacity: 0.24
}
// --- Layer 4 (:57-67) -------------------------------------------------
Image {
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
height: root.height + 620
x: -280 - sx * 102
y: -310 + sy * 128
opacity: 0.18
}
// --- Static fallback (:68-76) ------------------------------------------
Image {
visible: root.source === "" && root.fallbackSource !== ""
source: root.fallbackSource
fillMode: Image.PreserveAspectCrop
width: root.width + 220
height: root.height + 220
x: -110
y: -110
opacity: 0.35
}
// 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). 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" }
GradientStop { position: 1.0; color: "#80000000" }
}
}
}