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
This commit is contained in:
74 files changed
+6036
-111
No files matched your search
@@ -0,0 +1,431 @@
|
||||
// ShaderSceneLayer — the immersive GPU shader scenes (block A1 of the
|
||||
// 2026-08-15 immersive-completion contract, spec 01-shader-scenes-port.md).
|
||||
// The Qt counterpart of the Slint bottom-most `Image` bound to
|
||||
// `shader-texture` (ImmersiveView.slint:1293-1300): when
|
||||
// `QbzShaderScene.scene > 0` this layer REPLACES the atmosphere and all the
|
||||
// FOCUS/SPLIT panels (their `visible` bindings gain `scene === 0` in
|
||||
// ImmersiveView), while the chrome (header, player bar, song card) stays on
|
||||
// top.
|
||||
//
|
||||
// Scenes shipped: 1 Plasma (A2 — the feedback QQuickRhiItem, its own
|
||||
// loader below), 2 Tunnel, 3 Aurora, 4 Spectral Ribbon (A3 — the
|
||||
// spectrogram QQuickRhiItem + axes overlay), 5 Line Bed (A4), 7 Ambient,
|
||||
// 8 Tunnel Flow (B1 — the Qt-only Tauri port, feedback QQuickRhiItem fed by
|
||||
// the Viz16 stream, NOT the shader pack) (6 Liquid Spectrum is parked in
|
||||
// Slint too). Each ShaderEffect scene
|
||||
// is its own Component, instantiated ONLY
|
||||
// while active (the Loader): an invisible ShaderEffect that still ticks
|
||||
// costs presents, so the inactive scenes don't exist at all.
|
||||
//
|
||||
// THE PULSE LAW (00-CONTRACT §3 — non-negotiable): every clock here advances
|
||||
// on the shared shell pulse (QbzShell.pulseMs) via Connections, gated on
|
||||
// `active` (visible && immersive open && scene > 0 && window showing — the
|
||||
// AmbientField.qml:44-47 freeze rule). No Timer, no NumberAnimation, no
|
||||
// FrameAnimation, no Behavior on data-fed properties anywhere in this file.
|
||||
//
|
||||
// THE DATA FLOW (spec 01 §1/§3). The viz drain (viz_qt.rs) derives the audio
|
||||
// pack HOST-SIDE (bands8 pairing, level, level_smooth EMA, beat/transient
|
||||
// envelopes, phase with the 4096 wrap, spectral_peak EMA) and publishes it as
|
||||
// ONE batched `QbzShaderScene.packJson` per tick. This layer STASHES the
|
||||
// parsed document on publish (nothing binds the stash — no scene dirty) and
|
||||
// APPLIES it to the uniform properties on the pulse edge (the VizSettle
|
||||
// pattern), so the shader's uniforms move in the same event-loop turn as
|
||||
// every other animator and the window presents ONCE per pulse period with a
|
||||
// scene active. `time` is a LOCAL pulse clock (the Slint `time` is
|
||||
// seconds-since-start; the pack deliberately does not carry it).
|
||||
//
|
||||
// THE TIER GATE (spec 01 §4 — ONE source of truth): the picker rows and the
|
||||
// `g` key read `QbzShell.shaderScenesAvailable`, seeded from the Rust
|
||||
// renderer probe (renderer_qt::gpu_tier) — the exact analogue of Slint
|
||||
// seeding `shader-scenes-available` from the wgpu tier. This layer owns only
|
||||
// the shader-LOAD half: a ShaderEffect reporting Error latches
|
||||
// `shaderFailed`, un-instantiates the loader and resets the scene to Off, so
|
||||
// a load failure never blanks the screen (the ImmersiveAtmosphere.qml:123-128
|
||||
// rule).
|
||||
//
|
||||
// RENDER SIZE (shader_underlay.rs:37-39 parity): the effect renders INLINE
|
||||
// at the item's physical size (the same conclusion AmbientField reached —
|
||||
// inline is ONE present per pulse, no FBO double-present), and the resX/resY
|
||||
// uniforms are the item size × DPR CAPPED at 2560×1440 like the reference's
|
||||
// offscreen target ceiling.
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Window
|
||||
import com.blitzfc.qbz
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
readonly property int scene: QbzShaderScene.scene
|
||||
|
||||
// --- The tier gate -------------------------------------------------------
|
||||
// ONE SOURCE OF TRUTH for the picker/`g` gate: QbzShell.shaderScenesAvailable,
|
||||
// seeded from the Rust renderer probe (renderer_qt::gpu_tier — the gate
|
||||
// this feature was waiting on, shell_bridge.rs). This layer adds ONLY the
|
||||
// shader-LOAD half for its own instantiation safety: a scene effect that
|
||||
// reports Error latches `shaderFailed`, which un-instantiates the loader
|
||||
// and hands the background back to the atmosphere (a load failure must
|
||||
// never blank the screen — the ImmersiveAtmosphere.qml:123-128 rule).
|
||||
// The picker flag is NEVER fed from here.
|
||||
property bool shaderFailed: false
|
||||
readonly property bool available: QbzShell.shaderScenesAvailable
|
||||
&& !root.shaderFailed
|
||||
|
||||
function onShaderError(name, logText) {
|
||||
console.warn("ShaderSceneLayer: " + name + " shader failed:", logText)
|
||||
shaderFailed = true
|
||||
// Hand the background back to the atmosphere instead of showing black.
|
||||
QbzShaderScene.scene = 0
|
||||
}
|
||||
|
||||
// --- The freeze gate -----------------------------------------------------
|
||||
// True while the scene is actually on screen. Parent visibility does not
|
||||
// reach `visible` (that is the LOCAL flag), so the open state is part of
|
||||
// the predicate explicitly (ImmersiveView is self-gated on open anyway).
|
||||
readonly property bool windowShowing: root.Window.window
|
||||
? (root.Window.window.visibility !== Window.Minimized
|
||||
&& root.Window.window.visibility !== Window.Hidden)
|
||||
: true
|
||||
readonly property bool active: root.visible && QbzImmersive.open
|
||||
&& root.scene > 0 && root.available && root.windowShowing
|
||||
|
||||
// --- Render size (see the header) ---------------------------------------
|
||||
readonly property real dpr: root.Screen.devicePixelRatio
|
||||
readonly property real resW: Math.min(2560, Math.max(1, Math.round(width * dpr)))
|
||||
readonly property real resH: Math.min(1440, Math.max(1, Math.round(height * dpr)))
|
||||
|
||||
// --- Clocks ---------------------------------------------------------------
|
||||
// `tickMs` MUST match the pulse period (settings_qt::shell_pulse_ms,
|
||||
// default 33) — the AmbientField/ImmersiveAtmosphere convention.
|
||||
readonly property int tickMs: 33
|
||||
// The scene clock in seconds. Seeded like AmbientField so two launches
|
||||
// don't open on the same pose; never reset on re-activation (Slint's
|
||||
// `time` is seconds-since-start, so a scene switch never rewinds either).
|
||||
property real timeS: Math.random() * 40.0
|
||||
|
||||
// The APPLIED pack — the scene uniforms. Written ONLY on the pulse edge
|
||||
// (see the header); the scene effects bind these. `transientAmp` is the
|
||||
// pack's `transient` field — `transient` itself is a RESERVED QML keyword
|
||||
// (qmlcachegen rejects it), so the QML-side identifier changes and the
|
||||
// shader uniform is named to match.
|
||||
property real phase: 0
|
||||
property real beat: 0
|
||||
property real level: 0
|
||||
property real levelSmooth: 0
|
||||
property real transientAmp: 0
|
||||
property vector4d energyLo: Qt.vector4d(0, 0, 0, 0)
|
||||
property vector4d energyHi: Qt.vector4d(0, 0, 0, 0)
|
||||
property vector4d bandsLo: Qt.vector4d(0, 0, 0, 0)
|
||||
property vector4d bandsHi: Qt.vector4d(0, 0, 0, 0)
|
||||
|
||||
// The STASH. Nothing binds it, so the publish-edge write below costs no
|
||||
// scene dirty — the VizSettle pattern (a second, unsynchronised 30 Hz
|
||||
// application clock is the exact regression the pulse exists to kill).
|
||||
property var pendingPack: null
|
||||
// A4: the Line Bed depth ring stash — same pattern, QByteArray this
|
||||
// time. The C++ item reads it on the pulse edge via pulseTick().
|
||||
property var pendingHeights: null
|
||||
// A3: the Spectral Ribbon frame stash — same pattern again (the 517-byte
|
||||
// [col][reset][row] blob; layout pinned with ribbon_qt.rs).
|
||||
property var pendingRibbon: null
|
||||
// B1: the Tunnel Flow Viz16 stash — the 16-band QList<real> QbzViz
|
||||
// publishes every viz tick while immersive is open. Same zero-dirty
|
||||
// pattern: TunnelFlowScene.qml runs its smoothing/kick/phase
|
||||
// accumulators from the pulse edge, never from this notify.
|
||||
property var pendingBars: null
|
||||
|
||||
// The pack handed to the RhiItem scenes (A2 Plasma; A4 Line Bed takes
|
||||
// pendingHeights instead). Built on the pulse edge from the APPLIED
|
||||
// properties — one JS object per pulse, never bound.
|
||||
function rhiPack() {
|
||||
return {
|
||||
"time": root.timeS,
|
||||
"beat": root.beat,
|
||||
"level": root.level,
|
||||
"levelSmooth": root.levelSmooth,
|
||||
"energyLo": root.energyLo,
|
||||
"energyHi": root.energyHi,
|
||||
"primary": QbzShell.ambientPrimary,
|
||||
"secondary": QbzShell.ambientSecondary,
|
||||
"accent": QbzShell.ambientAccent
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: QbzShaderScene
|
||||
function onPackJsonChanged() {
|
||||
try {
|
||||
root.pendingPack = JSON.parse(QbzShaderScene.packJson)
|
||||
} catch (e) {
|
||||
// A malformed pack keeps the previous uniforms.
|
||||
}
|
||||
}
|
||||
function onLinebedHeightsChanged() {
|
||||
// Stash only (no binding consumes this — zero scene dirty).
|
||||
root.pendingHeights = QbzShaderScene.linebedHeights
|
||||
}
|
||||
function onRibbonFrameChanged() {
|
||||
// Stash only (same zero-dirty rule as the ring stash).
|
||||
root.pendingRibbon = QbzShaderScene.ribbonFrame
|
||||
}
|
||||
function onSceneChanged() {
|
||||
// A4/A3 publish gates: the viz drain only pushes/publishes the
|
||||
// scene feeds while their scene is actually on screen (the Rust
|
||||
// side reads an atomic; the drain thread cannot read the
|
||||
// QObject property). Boot order note: this handler also covers
|
||||
// every later write — menu rows, cycleScene, the open-reset,
|
||||
// the shader-error latch below.
|
||||
QbzShaderScene.setLinebedActive(QbzShaderScene.scene === 5)
|
||||
QbzShaderScene.setRibbonActive(QbzShaderScene.scene === 4)
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: QbzViz
|
||||
function onBarsChanged() {
|
||||
// B1: stash only (no binding consumes this — zero scene dirty,
|
||||
// the pendingPack pattern). Tunnel Flow reads the Viz16 stream,
|
||||
// not the shader pack.
|
||||
root.pendingBars = QbzViz.bars
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: QbzShell
|
||||
function onPulseMsChanged() {
|
||||
if (!root.active)
|
||||
return
|
||||
root.timeS += root.tickMs / 1000.0
|
||||
// A4: Line Bed — apply the stashed ring and repaint ONCE per
|
||||
// pulse (deliberately before the pendingPack early-return: the
|
||||
// item ticks at pulse rate while active even when the audio
|
||||
// feed is parked, exactly like timeS advances for the
|
||||
// ShaderEffect scenes).
|
||||
if (lineBedLoader.item) {
|
||||
lineBedLoader.item.pulseTick(root.pendingHeights)
|
||||
root.pendingHeights = null
|
||||
}
|
||||
// B1: Tunnel Flow — same pulse-edge repaint (the linebed
|
||||
// cadence comment above covers this too); the wrapper computes
|
||||
// the Viz16 smoothing/kick/phase accumulators itself.
|
||||
if (tunnelFlowLoader.item) {
|
||||
tunnelFlowLoader.item.pulseTick(root.timeS, root.pendingBars)
|
||||
root.pendingBars = null
|
||||
}
|
||||
// A2: Plasma — same pulse-edge repaint; the pack rides the
|
||||
// applied properties (no 200 KB ring, so no stash/gate).
|
||||
if (plasmaLoader.item)
|
||||
plasmaLoader.item.pulseTick(root.rhiPack())
|
||||
// A3: Spectral Ribbon — apply the stashed frame + the ceiling
|
||||
// EMA and repaint ONCE per pulse (the linebed cadence comment
|
||||
// above covers this too).
|
||||
if (ribbonLoader.item) {
|
||||
ribbonLoader.item.pulseTick(root.pendingRibbon, root.energyHi)
|
||||
root.pendingRibbon = null
|
||||
}
|
||||
var p = root.pendingPack
|
||||
if (p === null)
|
||||
return
|
||||
root.pendingPack = null
|
||||
root.phase = p.phase
|
||||
root.beat = p.beat
|
||||
root.level = p.level
|
||||
root.levelSmooth = p.levelSmooth
|
||||
root.transientAmp = p.transient
|
||||
root.energyLo = Qt.vector4d(p.energyLo[0], p.energyLo[1], p.energyLo[2], p.energyLo[3])
|
||||
root.energyHi = Qt.vector4d(p.energyHi[0], p.energyHi[1], p.energyHi[2], p.energyHi[3])
|
||||
root.bandsLo = Qt.vector4d(p.bandsLo[0], p.bandsLo[1], p.bandsLo[2], p.bandsLo[3])
|
||||
root.bandsHi = Qt.vector4d(p.bandsHi[0], p.bandsHi[1], p.bandsHi[2], p.bandsHi[3])
|
||||
}
|
||||
}
|
||||
|
||||
// --- The scenes -------------------------------------------------------------
|
||||
// ONLY the active scene is instantiated (see the header). The uniforms map
|
||||
// 1:1 to the spec 01 §1 block fields, matched BY NAME against the shader's
|
||||
// reflection data — `primary/secondary/accent` ride the shell's ambient
|
||||
// palette (the same ambient_qt source the Slint side feeds
|
||||
// shader_underlay::set_palette with), `time` is the local clock, and the
|
||||
// rest come from the applied pack.
|
||||
|
||||
Component {
|
||||
id: tunnelScene
|
||||
ShaderEffect {
|
||||
blending: false // opaque output (alpha = qt_Opacity = 1)
|
||||
property color primary: QbzShell.ambientPrimary
|
||||
property color secondary: QbzShell.ambientSecondary
|
||||
property color accent: QbzShell.ambientAccent
|
||||
property vector4d energyLo: root.energyLo
|
||||
property vector4d energyHi: root.energyHi
|
||||
property vector4d bandsLo: root.bandsLo
|
||||
property vector4d bandsHi: root.bandsHi
|
||||
property real time: root.timeS
|
||||
property real phase: root.phase
|
||||
property real beat: root.beat
|
||||
property real level: root.level
|
||||
property real resX: root.resW
|
||||
property real resY: root.resH
|
||||
property real levelSmooth: root.levelSmooth
|
||||
property real transientAmp: root.transientAmp
|
||||
onStatusChanged: {
|
||||
if (status === ShaderEffect.Error)
|
||||
root.onShaderError("tunnel", log)
|
||||
}
|
||||
fragmentShader: "../assets/shaders/tunnel.frag.qsb"
|
||||
// Paired vertex stage (Qt 6.11's built-in default emits no
|
||||
// qt_TexCoord0). PAIRED, not shared: the GL program link requires
|
||||
// both stages to declare the SAME uniform block (see
|
||||
// scene_pack.vert), so tunnel/aurora no longer ride spectrum.vert.
|
||||
vertexShader: "../assets/shaders/scene_pack.vert.qsb"
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: auroraScene
|
||||
ShaderEffect {
|
||||
blending: false
|
||||
property color primary: QbzShell.ambientPrimary
|
||||
property color secondary: QbzShell.ambientSecondary
|
||||
property color accent: QbzShell.ambientAccent
|
||||
property vector4d energyLo: root.energyLo
|
||||
property vector4d energyHi: root.energyHi
|
||||
property vector4d bandsLo: root.bandsLo
|
||||
property vector4d bandsHi: root.bandsHi
|
||||
property real time: root.timeS
|
||||
property real phase: root.phase
|
||||
property real beat: root.beat
|
||||
property real level: root.level
|
||||
property real resX: root.resW
|
||||
property real resY: root.resH
|
||||
property real levelSmooth: root.levelSmooth
|
||||
property real transientAmp: root.transientAmp
|
||||
onStatusChanged: {
|
||||
if (status === ShaderEffect.Error)
|
||||
root.onShaderError("aurora", log)
|
||||
}
|
||||
fragmentShader: "../assets/shaders/aurora.frag.qsb"
|
||||
vertexShader: "../assets/shaders/scene_pack.vert.qsb"
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: ambientScene
|
||||
// Ambient-as-scene (mode 7, spec 01 §2.6): the ALREADY-PORTED
|
||||
// ambient.frag (the app-wide background's GPU arm) mounted at the
|
||||
// immersive underlay slot with the scene uniform naming. Unlike the
|
||||
// app background this mount gets the audio breathe (levelSmooth from
|
||||
// the pack — the Slint ambient scene breathes too) and NO legibility
|
||||
// dim (dim is a shell-background scrim; the reference's ambient.wgsl
|
||||
// has no such term).
|
||||
ShaderEffect {
|
||||
blending: false
|
||||
property color primary: QbzShell.ambientPrimary
|
||||
property color secondary: QbzShell.ambientSecondary
|
||||
property color accent: QbzShell.ambientAccent
|
||||
property real time: root.timeS
|
||||
property real levelSmooth: root.levelSmooth
|
||||
property real resX: root.resW
|
||||
property real resY: root.resH
|
||||
property real dim: 0.0
|
||||
onStatusChanged: {
|
||||
if (status === ShaderEffect.Error)
|
||||
root.onShaderError("ambient", log)
|
||||
}
|
||||
fragmentShader: "../assets/shaders/ambient.frag.qsb"
|
||||
vertexShader: "../assets/shaders/ambient.vert.qsb"
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
anchors.fill: parent
|
||||
active: root.active
|
||||
sourceComponent: root.scene === 2 ? tunnelScene
|
||||
: root.scene === 3 ? auroraScene
|
||||
: root.scene === 7 ? ambientScene
|
||||
: null
|
||||
}
|
||||
|
||||
// A4: the Line Bed scene (mode 5) — the QQuickRhiItem, mounted via its
|
||||
// QML wrapper. Its OWN loader: the C++ item is not a ShaderEffect and
|
||||
// shares nothing with the sourceComponent switch above. The `active`
|
||||
// gate IS the pulse-law gate (visible && immersive open && scene === 5
|
||||
// && tier — root.active), so the item exists only while it is on
|
||||
// screen; every repaint inside it is driven by the pulse handler above.
|
||||
Component {
|
||||
id: linebedScene
|
||||
LineBedScene {
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: lineBedLoader
|
||||
anchors.fill: parent
|
||||
active: root.active && root.scene === 5
|
||||
sourceComponent: linebedScene
|
||||
}
|
||||
|
||||
// A2: the Plasma scene (mode 1) — the feedback-fluid QQuickRhiItem,
|
||||
// same mount idiom as Line Bed (its own loader, gated on the pulse-law
|
||||
// `active`).
|
||||
Component {
|
||||
id: plasmaScene
|
||||
PlasmaScene {
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: plasmaLoader
|
||||
anchors.fill: parent
|
||||
active: root.active && root.scene === 1
|
||||
sourceComponent: plasmaScene
|
||||
}
|
||||
|
||||
// A3: the Spectral Ribbon scene (mode 4) — the spectrogram
|
||||
// QQuickRhiItem PLUS its green-axes overlay (the Slint
|
||||
// ImmersiveSpectralOverlay, ImmersiveView.slint:1304-1309), same mount
|
||||
// idiom as Line Bed.
|
||||
Component {
|
||||
id: ribbonScene
|
||||
Item {
|
||||
// The pulse handler calls `ribbonLoader.item.pulseTick(...)` —
|
||||
// the item is THIS wrapper, so the tick must be forwarded to the
|
||||
// RibbonItem inside (a plain Item has no pulseTick; the direct
|
||||
// call on the wrapper threw TypeError every pulse and the
|
||||
// spectrogram never received a frame — owner smoke 2026-08-15).
|
||||
function pulseTick(f, peak) {
|
||||
ribbonItem.pulseTick(f, peak)
|
||||
}
|
||||
RibbonScene {
|
||||
id: ribbonItem
|
||||
anchors.fill: parent
|
||||
}
|
||||
SpectralOverlay {
|
||||
anchors.fill: parent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: ribbonLoader
|
||||
anchors.fill: parent
|
||||
active: root.active && root.scene === 4
|
||||
sourceComponent: ribbonScene
|
||||
}
|
||||
|
||||
// B1: the Tunnel Flow scene (mode 8, Qt-only — spec 02) — the feedback
|
||||
// QQuickRhiItem, same mount idiom as Plasma. The `active` gate IS the
|
||||
// pulse-law gate; the item exists only while on screen. The black base
|
||||
// is inherent (the scene owns the background while active) and the
|
||||
// 240 ms fade-in lives in the wrapper (a transient one-shot, allowed).
|
||||
Component {
|
||||
id: tunnelFlowScene
|
||||
TunnelFlowScene {
|
||||
}
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: tunnelFlowLoader
|
||||
anchors.fill: parent
|
||||
active: root.active && root.scene === 8
|
||||
sourceComponent: tunnelFlowScene
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user