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:
vicrodh committed 2026-08-16 08:22:57 -06:00
1 parent 8bd02e35d3
commit e6c058c2f2
74 files changed
+6036 -111

No files matched your search

+10 -4
View File
@@ -288,6 +288,12 @@ ApplicationWindow {
// atmosphere_url to this singleton on every track change — a missing
// boot line would drop those publishes silently (see above).
QbzImmersive.boot()
// Shader scenes (2026-08-15 immersive-completion contract, block A1).
// Booted right after QbzImmersive: its open funnel resets the scene
// through this singleton's hop, and the viz drain publishes the audio
// pack here on every tick while immersive is open — a missing boot
// line makes both silent no-ops (TRACK-RULES, singleton boot order).
QbzShaderScene.boot()
// Immersive Suggestions (the same contract, block B4 §4.5) — booted
// WITH its bridge: every publish the suggestions loader makes rides
// this hop, so a missing line is the forever-"{}" silent no-op the
@@ -646,10 +652,10 @@ ApplicationWindow {
// false, clear the maximized latch in the FullScreen branch too. Qt
// collapses both into one enum, so the maximized latch is cleared ONLY
// by an explicit return to Windowed — never by entering fullscreen.
// Nothing in QBZ's chrome sets FullScreen (both toggles go
// Maximized <-> Windowed, shell/HeaderBar.qml:76,830); it arrives from
// the WM keybind, and quitting from there must not un-maximize the
// next launch of either frontend.
// FullScreen arrives from the WM keybind OR from the immersive
// chrome (immersive/ImmersiveHeader.qml fs toggle — the first
// FullScreen write in this frontend, 2026-08-02 §5.2); quitting from
// there must not un-maximize the next launch of either frontend.
if (vis === Window.Maximized) {
window.maximizedLatch = true
window.fullScreenLatch = false
@@ -0,0 +1,31 @@
#version 440
// PAIRED with ambient.frag (AmbientField + the immersive Ambient scene).
// THE PAIRING RULE (see scene_pack.vert): both stages must declare the SAME
// uniform block, member for member, or the OpenGL program link rejects the
// two layouts (Vulkan never links, so it tolerated the mismatch). Keep this
// block byte-identical to ambient.frag's.
layout(location = 0) in vec4 qt_Vertex;
layout(location = 1) in vec2 qt_MultiTexCoord0;
layout(location = 0) out vec2 qt_TexCoord0;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
vec4 primary;
vec4 secondary;
vec4 accent;
float qt_Opacity;
float time;
float levelSmooth;
float resX;
float resY;
float dim;
};
out gl_PerVertex { vec4 gl_Position; };
void main() {
qt_TexCoord0 = qt_MultiTexCoord0;
gl_Position = qt_Matrix * qt_Vertex;
}
Binary file not shown.
@@ -0,0 +1,29 @@
#version 440
// PAIRED with atmosphere.frag (ImmersiveAtmosphere). THE PAIRING RULE (see
// scene_pack.vert): both stages must declare the SAME uniform block, member
// for member, or the OpenGL program link rejects the two layouts. Keep this
// block byte-identical to atmosphere.frag's.
layout(location = 0) in vec4 qt_Vertex;
layout(location = 1) in vec2 qt_MultiTexCoord0;
layout(location = 0) out vec2 qt_TexCoord0;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
float qt_Opacity;
float resX;
float resY;
float dim;
vec4 l1;
vec4 l2;
vec4 l3;
vec4 l4;
};
out gl_PerVertex { vec4 gl_Position; };
void main() {
qt_TexCoord0 = qt_MultiTexCoord0;
gl_Position = qt_Matrix * qt_Vertex;
}
Binary file not shown.
@@ -0,0 +1,124 @@
#version 440
// Aurora (immersive scene, mode 3) — a line-faithful GLSL 440 port of
// crates/qbz-ui/ui/shaders/aurora.wgsl (AURORA WARP, original clean-room
// shader; the stage-0 spike scene of the 2026-08-15 immersive-completion
// contract, chosen because it exercises the full uniform pack with zero
// extra GPU resources). Every constant and every formula is the reference's;
// the differences are mechanical (same list as tunnel.frag): uniform block
// instead of a bind group, resolution as resX/resY, energy/bands vec4s by
// their QML property names, `let` -> local floats, uv = qt_TexCoord0 with NO
// y-flip (the ambient.frag convention), premultiplied output.
//
// Three sine-domain-warped curtains take their colors from the album-art
// palette (primary / secondary / accent); bass swells the low curtain, mids
// drive sway speed/amplitude, presence the top curtain, air = shimmer
// frequency (5.0 + air*8.0) cross-fading toward accent, beat kicks the
// brightness and lifts the curtains, level_smooth sets the baseline bloom.
// Fixed work per pixel, no loops — GPU-cheap.
//
// Pure float math (no integer ops), so this shader keeps the FULL qsb
// variant set including 100 es (unlike ambient.frag / tunnel.frag).
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
vec4 primary;
vec4 secondary;
vec4 accent;
vec4 energyLo;
vec4 energyHi;
vec4 bandsLo;
vec4 bandsHi;
float qt_Opacity;
float time;
float phase;
float beat;
float level;
float resX;
float resY;
float levelSmooth;
// Named transientAmp, not `transient` — a RESERVED QML keyword (see
// tunnel.frag). The JSON pack key stays `transient`.
float transientAmp;
};
// A small smooth flow field: cheap sine lobes that act like a fake curl, so
// the curtains ripple horizontally instead of sliding rigidly.
float flow(vec2 p, float t) {
float a = sin(p.x * 2.3 + t * 0.7) * 0.5;
float b = sin(p.x * 4.7 - t * 0.45 + p.y * 1.3) * 0.25;
float c = sin((p.x + p.y) * 1.7 + t * 0.9) * 0.18;
return a + b + c;
}
void main() {
float t = time;
float bass = clamp(energyLo.y, 0.0, 1.0);
float mids = clamp(energyLo.z, 0.0, 1.0);
float presence = clamp(energyLo.w, 0.0, 1.0);
float air = clamp(energyHi.x, 0.0, 1.0);
float beatC = clamp(beat, 0.0, 1.0);
float lvl = clamp(levelSmooth, 0.0, 1.0);
float aspect = resX / max(resY, 1.0);
// x spread by aspect so the curtains aren't squashed on wide windows.
vec2 p = vec2((qt_TexCoord0.x - 0.5) * aspect, qt_TexCoord0.y);
// Mids drive the sway amplitude & speed.
float swaySpeed = 0.6 + mids * 0.9;
float warp1 = flow(p * 3.0, t * swaySpeed);
float warp2 = flow(p * 2.0 + vec2(7.3, 0.0), t * (0.8 * swaySpeed) + 2.0);
float warp3 = flow(p * 2.6 + vec2(-4.1, 1.0), t * (0.65 * swaySpeed) + 4.0);
// Curtain widths: bass swells the low curtain; beat lifts; presence the
// top.
float bw1 = 0.10 + bass * 0.16 + beatC * 0.03;
float bw2 = 0.09 + mids * 0.10;
float bw3 = 0.07 + presence * 0.10;
// Anchors drift on a slow vertical LFO + a beat lift; sway from warp +
// mids.
float lift = beatC * 0.03;
float sway = 0.08 + mids * 0.12;
float center1 = 0.40 + 0.02 * sin(t * 0.23) + warp1 * sway - lift;
float center2 = 0.60 + 0.02 * sin(t * 0.19 + 1.7) + warp2 * sway - lift;
float center3 = 0.50 + 0.025 * sin(t * 0.16 + 3.1) + warp3 * (sway * 0.9) - lift;
float band1 = bw1 / (abs(p.y - center1) + bw1);
float band2 = bw2 / (abs(p.y - center2) + bw2);
float band3 = bw3 / (abs(p.y - center3) + bw3);
// Treble shimmer cross-fades each curtain base color toward accent.
float shFreq = 5.0 + air * 8.0;
float sh1 = 0.5 + 0.5 * sin(p.x * shFreq + t * 1.3);
float sh2 = 0.5 + 0.5 * sin(p.x * (shFreq * 0.8) - t * 1.1 + 1.0);
float sh3 = 0.5 + 0.5 * sin(p.x * (shFreq * 1.2) + t * 1.6 + 2.0);
float shGain = 0.25 + air * 0.5;
vec3 col1 = mix(primary.rgb, accent.rgb, sh1 * shGain);
vec3 col2 = mix(secondary.rgb, accent.rgb, sh2 * shGain);
vec3 col3 = mix(accent.rgb, primary.rgb, sh3 * shGain);
vec3 col = col1 * band1 * (0.55 + sh1 * 0.45);
col += col2 * band2 * (0.55 + sh2 * 0.45);
col += col3 * band3 * (0.5 + sh3 * 0.4) * (0.4 + presence * 0.8);
// Baseline brightness from the smoothed level + beat; dim primary floor
// glow.
col *= 0.5 + lvl * 0.9;
col *= 1.0 + beatC * 0.5;
col += primary.rgb * 0.04;
// Beat bloom: vertical shimmer streaks where the curtains already are.
float streak = abs(fract(p.y * 6.0 - t * 2.0) - 0.5);
float bloom = (0.5 / (streak * 8.0 + 0.5)) * beatC;
col += accent.rgb * bloom * (band1 + band2 + band3) * 0.4;
col = clamp(col, vec3(0.0), vec3(1.0));
// Premultiplied, like every other effect in this tree (blending: false at
// the mount, so qt_Opacity is 1 and this equals the reference frame).
fragColor = vec4(col * qt_Opacity, qt_Opacity);
}
Binary file not shown.
@@ -0,0 +1,41 @@
#version 440
// Line Bed (immersive scene, mode 5) — FRAGMENT stage. Line-faithful port
// of `fs_main` in crates/qbz-ui/ui/shaders/line_bed.wgsl:
//
// * the bottom cutoff (`in.pos.y > u.resolution.y * 0.86 → discard`)
// reads the `yFrac` varying the VS computed from its own screen-space
// position — identical value to the reference's framebuffer-space
// builtin, portable across the gl_FragCoord y-orientation split (see
// the vertex shader's header);
// * the gradient: valleys take `primary`, peaks tip toward `accent`
// (`g = clamp(intensity * 1.25, 0, 1)`), peaks also brighten via alpha
// (0.5 + g*0.4) under the pipeline's SrcAlpha blend.
//
// QSB-SKIP-GLES100 — paired with the vertex stage (texelFetch); the ES 2.0
// variant cannot carry the scene and the tier gate keeps it off that
// hardware anyway.
layout(std140, binding = 0) uniform buf {
vec2 resolution;
vec4 primary;
vec4 accent;
} u;
layout(location = 0) in float intensity;
layout(location = 1) in float yFrac;
layout(location = 0) out vec4 fragColor;
void main() {
// Bottom cutoff — don't render below ~86% of the view, so the bed ends
// above the player bar instead of spilling to the window bottom.
if (yFrac > 0.86)
discard;
// Height gradient: the bed/valleys take `primary`, the mountain peaks
// tip toward `accent`, gradually — peaks also brighten.
float g = clamp(intensity * 1.25, 0.0, 1.0);
vec3 col = mix(u.primary.rgb, u.accent.rgb, g);
float a = 0.5 + g * 0.4;
fragColor = vec4(col, a);
}
Binary file not shown.
@@ -0,0 +1,140 @@
#version 440
// Line Bed (immersive scene, mode 5) — VERTEX stage. A line-faithful GLSL
// 440 port of `vs_main` in crates/qbz-ui/ui/shaders/line_bed.wgsl (itself a
// clean-room port of the Tauri LinebedPanel projection math): 200
// depth-stacked polylines, one historical 256-point spectrum each (X =
// frequency, Y = magnitude, Z = age), projected with a LEVELED pitch-only
// camera, each band span subdivided with a Catmull-Rom-STYLE uniform cubic
// B-spline across the 4 neighboring bands so the polylines read as smooth
// curves. The 256x200 heights arrive as an R32F texture (binding 4),
// DEPTH-ORDERED row 0 = newest, fed by the Rust LineBedState
// (src/linebed_qt.rs) — `textureLoad` becomes `texelFetch`.
//
// The ONLY mechanical differences from the WGSL, enumerated:
// * @builtin(vertex_index) / @builtin(instance_index) become two FLOAT
// vertex attributes (`vid`, `lineIdx`) driven from the 1531-vert
// template buffer and a 200-entry per-instance buffer — the portable
// spelling under QRhi (GLES 3.0 has no gl_VertexID; the C++ side
// uploads 0..1530 / 0..199 verbatim).
// * `u.resolution: vec2` sits at offset 0 of a TRIMMED uniform block
// (this scene reads only resolution in the VS and primary/accent in the
// FS — line_bed.wgsl has no time/level/energy input); same values,
// same meaning as the 144-byte block's fields.
// * the FS cutoff coordinate is exported as the `yFrac` varying
// (screen_y / resolution.y) instead of being re-read from
// @builtin(position) in the FS — gl_FragCoord.y is BOTTOM-up on GL,
// top-down on wgpu/Vulkan, so the portable route is to interpolate the
// value the VS already computed. w == 1 throughout, so the interpolated
// yFrac equals the reference's per-pixel pos.y/resolution.y exactly.
//
// QSB-SKIP-GLES100 — texelFetch (vertex texture fetch) needs GLSL ES 3.00;
// the ES 2.0 variant cannot carry this shader and the tier gate keeps the
// scene off that hardware anyway.
// QSB-SKIP-BATCH — a QQuickRhiItem shader, NOT a Qt Quick ShaderEffect
// stage: the `-b` batching rewrite is only valid for scene-graph batched
// items and would corrupt the custom attribute layout.
layout(location = 0) in float vid; // 0..1530: subdivided point index
layout(location = 1) in float lineIdx; // 0..199: depth row (per instance)
layout(std140, binding = 0) uniform buf {
vec2 resolution;
vec4 primary;
vec4 accent;
} u;
layout(binding = 4) uniform sampler2D heights_tex;
layout(location = 0) out float intensity;
layout(location = 1) out float yFrac;
out gl_PerVertex { vec4 gl_Position; };
// --- Camera constants (verbatim from line_bed.wgsl:42-59, which took them
// verbatim from LinebedPanel.svelte) ---------------------------------------
const float LINE_LENGTH = 9.0;
const float WORLD_AMPLITUDE = 2.4;
const float PLANE_HALF_WIDTH = 1147.5; // (255*9)/2
const float PLANE_HALF_DEPTH = 1990.0; // (199*20)/2
const float CAM_X = 26.1;
const float CAM_Y = 1738.6;
const float CAM_Z = 868.8;
const float CAMERA_NEAR = 80.0;
const float FOV_DEG = 45.0; // vertical
const int NUM_BANDS = 256;
// MUST match the host's LINEBED_SUBDIV (shader_underlay.rs:54 /
// linebed_qt.rs) and the 1531-vert template the C++ uploads.
const float SUBDIV = 6.0;
// Vertical screen position of the projection origin (lower = the bed sits
// HIGHER). 0.30 so the bed lifts off the bottom, clears the player bar,
// and uses the dead space up top.
const float VCENTER = 0.30;
float height_at(int line, int band) {
int b = clamp(band, 0, NUM_BANDS - 1);
return texelFetch(heights_tex, ivec2(b, line), 0).r;
}
// Uniform cubic B-spline through the 4 neighboring bands — C2-continuous
// (no kinks at the band points) and never overshoots; it APPROXIMATES
// (gently smooths) the samples, so the whole line reads as one continuous
// curve instead of stitched segments.
float curve_height(int line, float band_f) {
int b0 = int(floor(band_f));
float t = band_f - float(b0);
float p0 = height_at(line, b0 - 1);
float p1 = height_at(line, b0);
float p2 = height_at(line, b0 + 1);
float p3 = height_at(line, b0 + 2);
float t2 = t * t;
float t3 = t2 * t;
float w0 = (1.0 - 3.0 * t + 3.0 * t2 - t3) / 6.0;
float w1 = (4.0 - 6.0 * t2 + 3.0 * t3) / 6.0;
float w2 = (1.0 + 3.0 * t + 3.0 * t2 - 3.0 * t3) / 6.0;
float w3 = t3 / 6.0;
return p0 * w0 + p1 * w1 + p2 * w2 + p3 * w3;
}
void main() {
int line = int(lineIdx);
// Continuous (subdivided) band position along the line, 0..255.
float band_f = vid / SUBDIV;
float h = curve_height(line, band_f);
// World position. Only Y is audio-driven; X/Z are the fixed lattice.
float world_x = band_f * LINE_LENGTH - PLANE_HALF_WIDTH;
float world_y = h * WORLD_AMPLITUDE;
float depth_factor = lineIdx / 199.0;
float world_z = -PLANE_HALF_DEPTH + depth_factor * (PLANE_HALF_DEPTH * 2.0);
// LEVELED view transform: pitch ONLY (Rx). Yaw and roll are ZEROED so
// every line sits flat.
float cosX = cos(0.6543);
float sinX = sin(0.6543);
float tX = world_x - CAM_X;
float tY = world_y - CAM_Y;
float tZ = world_z - CAM_Z;
// Rz, Ry = identity (leveled); apply Rx (pitch about the X axis).
float rX = tX;
float rY = tY * cosX - tZ * sinX;
float rZ = tY * sinX + tZ * cosX;
// Near-clip (v1 — no polyline split, like the reference).
float depth = max(-rZ, CAMERA_NEAR);
// SAME focal for X and Y (no aspect correction — faithful to Tauri).
float focal = u.resolution.y * 0.5 / tan(FOV_DEG * 3.14159265 / 360.0);
float screen_x = u.resolution.x * 0.5 + rX * focal / depth;
float screen_y = u.resolution.y * VCENTER - rY * focal / depth;
// Screen px -> clip/NDC (flip Y — QRhi's NDC is y-up like wgpu's).
float ndc_x = screen_x / u.resolution.x * 2.0 - 1.0;
float ndc_y = 1.0 - screen_y / u.resolution.y * 2.0;
gl_Position = vec4(ndc_x, ndc_y, 0.0, 1.0);
intensity = clamp(h / 84.0, 0.0, 1.0);
yFrac = screen_y / u.resolution.y;
}
Binary file not shown.
@@ -0,0 +1,140 @@
#version 440
// Plasma (immersive scene, mode 1) — FRAGMENT stage. Line-faithful GLSL 440
// port of `fs_main` in crates/qbz-ui/ui/shaders/plasma.wgsl (the MilkDrop-
// class feedback fluid: sample the previous frame at a warped UV, decay,
// inject orbiting ink + beat splats). The feedback history lives in two
// RGBA8 ping-pong textures owned by the C++ PlasmaItem (the reference keeps
// ONE history and copies the frame back — shader_underlay.rs:1003-1025 —
// which is the same recurrence; ping-pong is how you say "sample last frame,
// write this frame" without a surface readback).
//
// Mechanical differences from the WGSL, enumerated:
// * textureSample(prev_tex, prev_samp, uv) -> texture(prev_tex, uv)
// (combined image sampler, binding 1).
// * bitcast<u32>(i32(x)) -> uint(int(x)): int->uint is a modulo-2^32
// conversion in GLSL, i.e. the same bit pattern the WGSL bitcast keeps.
// * The uniform block is the FULL 144-byte std140 scene block (spec 01
// §1) — this scene reads time/resolution/energy_lo/energy_hi.x/beat/
// level/level_smooth/primary/secondary/accent; the remaining fields
// arrive zeroed.
//
// QSB-SKIP-GLES100 — the lattice hash is UINT arithmetic (plasma.wgsl:55-67
// records why a float hash draws "wallpaper join" seams); ES 2.0 has no
// uint. The tier gate keeps the scene off that hardware.
layout(std140, binding = 0) uniform buf {
float time;
float phase;
float beat;
float level;
vec2 resolution;
float levelSmooth;
float transientAmp;
vec4 energyLo;
vec4 energyHi;
vec4 bandsLo;
vec4 bandsHi;
vec4 primary;
vec4 secondary;
vec4 accent;
} u;
layout(binding = 1) uniform sampler2D prev_tex;
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 fragColor;
// INTEGER lattice hash (bit-exact) — see the WGSL header above.
float hash2(vec2 p) {
uint h = uint(int(p.x)) * 0x8da6b343u + uint(int(p.y)) * 0xd8163841u;
h = (h ^ (h >> 15u)) * 0x2c1b3c6du;
h = (h ^ (h >> 12u)) * 0x297a2d39u;
h = h ^ (h >> 15u);
return float(h >> 8u) * (1.0 / 16777216.0);
}
float vnoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = hash2(i);
float b = hash2(i + vec2(1.0, 0.0));
float c = hash2(i + vec2(0.0, 1.0));
float d = hash2(i + vec2(1.0, 1.0));
vec2 uf = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, uf.x), mix(c, d, uf.x), uf.y);
}
// Divergence-free flow from a scalar potential: curl = (dphi/dy, -dphi/dx).
vec2 curl_noise(vec2 p) {
float e = 0.05;
float dx = vnoise(p + vec2(e, 0.0)) - vnoise(p - vec2(e, 0.0));
float dy = vnoise(p + vec2(0.0, e)) - vnoise(p - vec2(0.0, e));
return vec2(dy, -dx) / (2.0 * e);
}
// Soft gaussian blob — an ink emitter.
float blob(vec2 p, vec2 c, float radius) {
vec2 d = p - c;
return exp(-dot(d, d) / max(radius * radius, 1e-4));
}
void main() {
float t = u.time;
float aspect = u.resolution.x / max(u.resolution.y, 1.0);
float sub = clamp(u.energyLo.x, 0.0, 1.0);
float bass = clamp(u.energyLo.y, 0.0, 1.0);
float mids = clamp(u.energyLo.z, 0.0, 1.0);
float air = clamp(u.energyHi.x, 0.0, 1.0);
float beat = clamp(u.beat, 0.0, 1.0);
float level = clamp(u.level, 0.0, 1.0);
float lvl = clamp(u.levelSmooth, 0.0, 1.0);
// Centered, aspect-correct coords.
vec2 c = (uv - vec2(0.5)) * vec2(aspect, 1.0);
float pres = clamp(u.energyLo.w, 0.0, 1.0);
// === Feedback advection — turbulent, music-cranked. ===
// Bass inhale/zoom (stronger) + a beat kick.
float zoom = 1.0 + 0.006 + (sub + bass) * 0.03 + beat * 0.02;
// Mids swirl harder and faster; beats jolt the rotation.
float ang = (0.006 + mids * 0.05) * sin(t * 0.5 + length(c) * 4.0) + beat * 0.035;
float ca = cos(ang);
float sa = sin(ang);
vec2 w = c * zoom;
w = vec2(w.x * ca - w.y * sa, w.x * sa + w.y * ca);
// Two curl-noise octaves (treble + presence) -> filaments + fine turbulence.
w += curl_noise(c * 2.5 + vec2(t * 0.12, -t * 0.10)) * (0.003 + air * 0.015);
w += curl_noise(c * 5.5 - vec2(t * 0.09, t * 0.08)) * (0.0015 + pres * 0.009);
vec2 warpUv = w / vec2(aspect, 1.0) + vec2(0.5);
vec3 field = texture(prev_tex, warpUv).rgb;
// Decay: faster so it stays ALIVE (not muddy); louder lingers a bit longer.
field *= 0.85 + lvl * 0.06;
// === FOUR emitters, fast orbits — more elements, more motion. ===
vec2 e1 = vec2(sin(t * 0.5), cos(t * 0.6)) * (0.25 + bass * 0.2);
vec2 e2 = vec2(sin(-t * 0.42 + 2.1), cos(-t * 0.38 + 1.3)) * (0.3 + mids * 0.18);
vec2 e3 = vec2(sin(t * 0.74 + 1.0), cos(-t * 0.66 + 3.0)) * (0.22 + pres * 0.2);
vec2 e4 = vec2(cos(t * 0.33 + 4.0), sin(t * 0.58 + 0.5)) * (0.34 + air * 0.16);
field += u.primary.rgb * blob(c, e1, 0.045 + bass * 0.06) * (0.14 + level * 0.8);
field += u.secondary.rgb * blob(c, e2, 0.05 + mids * 0.05) * (0.14 + level * 0.65);
field += u.accent.rgb * blob(c, e3, 0.04 + pres * 0.05) * (0.10 + level * 0.6);
field += u.primary.rgb * blob(c, e4, 0.035 + air * 0.04) * (0.07 + air * 0.6);
// === Beat splats — TWO, bigger and brighter, detonating on the onset. ===
vec2 sp1 = vec2(sin(t * 0.9) * 0.35, cos(t * 0.7) * 0.28);
vec2 sp2 = vec2(cos(t * 1.1 + 2.0) * 0.3, sin(t * 0.85 + 1.0) * 0.32);
field += u.accent.rgb * blob(c, sp1, 0.05 + beat * 0.14) * beat * 2.2;
field += u.secondary.rgb * blob(c, sp2, 0.04 + beat * 0.10) * beat * 1.6;
// Treble shimmer on the crests.
float luma = dot(field, vec3(0.33, 0.34, 0.33));
field += u.accent.rgb * air * smoothstep(0.4, 0.9, luma) * 0.18;
field = clamp(field, vec3(0.0), vec3(1.3));
fragColor = vec4(field, 1.0);
}
Binary file not shown.
@@ -0,0 +1,19 @@
#version 440
// Plasma (immersive scene, mode 1) — BLIT stage: copy the just-rendered
// history texture into the item's render target. The feedback pass renders
// into an owned ping-pong texture (never into the item target directly),
// so the item never has to be read back — this 1:1 textured triangle is the
// composite step. Same vertex stage as plasma.vert.
//
// QSB-SKIP-GLES100 — paired with the plasma passes (the tier gate keeps the
// scene off ES 2.0 hardware; a blit this small needs no ES 2.0 variant).
layout(binding = 1) uniform sampler2D src_tex;
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 fragColor;
void main() {
fragColor = texture(src_tex, uv);
}
@@ -0,0 +1,25 @@
#version 440
// Shared fullscreen-triangle VERTEX stage for the QQuickRhiItem scenes
// (Plasma A2: the feedback AND blit passes; Spectral Ribbon A3). The Slint
// references derive the triangle from @builtin(vertex_index)
// (plasma.wgsl:42-53, spectral_ribbon.wgsl:44-51); GLES 3.0 has no
// gl_VertexID, so the C++ side uploads the three clip positions as a vertex
// buffer (the linebed.vert idiom).
//
// QSB-SKIP-BATCH — a QQuickRhiItem shader, NOT a Qt Quick ShaderEffect
// stage: the `-b` batching rewrite is only valid for scene-graph batched
// items and would corrupt the custom attribute layout.
layout(location = 0) in vec2 pos; // clip-space fullscreen triangle
layout(location = 0) out vec2 uv;
out gl_PerVertex { vec4 gl_Position; };
void main() {
// plasma.wgsl:51 — uv = p * 0.5 + 0.5. Self-consistent for every pass
// that renders and samples with this convention.
uv = pos * 0.5 + vec2(0.5);
gl_Position = vec4(pos, 0.0, 1.0);
}
@@ -0,0 +1,45 @@
#version 440
// PAIRED with tunnel.frag AND aurora.frag (their uniform layouts are
// identical, so one vertex stage serves both).
//
// THE PAIRING RULE (2026-08-15 black-Tunnel report): a ShaderEffect's two
// stages are LINKED into one program under the OpenGL RHI backend, and GLSL
// refuses one block name with two layouts ("uniform `_22' declared as type
// `buf' and type `buf'"). Vulkan never links stages, so the shared
// spectrum.vert (block = qt_Matrix + qt_Opacity) paired with these wider
// fragment blocks rendered fine there and died only on GL. The fix is Qt's
// own documented pattern: BOTH stages declare the SAME block, member for
// member, in the same order — the vertex stage simply never reads the
// fragment-only fields. Keep this block byte-identical to tunnel.frag's.
layout(location = 0) in vec4 qt_Vertex;
layout(location = 1) in vec2 qt_MultiTexCoord0;
layout(location = 0) out vec2 qt_TexCoord0;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
vec4 primary;
vec4 secondary;
vec4 accent;
vec4 energyLo;
vec4 energyHi;
vec4 bandsLo;
vec4 bandsHi;
float qt_Opacity;
float time;
float phase;
float beat;
float level;
float resX;
float resY;
float levelSmooth;
float transientAmp;
};
out gl_PerVertex { vec4 gl_Position; };
void main() {
qt_TexCoord0 = qt_MultiTexCoord0;
gl_Position = qt_Matrix * qt_Vertex;
}
Binary file not shown.
@@ -0,0 +1,106 @@
#version 440
// Spectral Ribbon (immersive scene, mode 4) — FRAGMENT stage. Line-faithful
// GLSL 440 port of `fs_main` in crates/qbz-ui/ui/shaders/spectral_ribbon.wgsl
// (the paint-as-you-play sonogram: frequency on Y, time on X, the Spek
// purple→orange heatmap). The CPU side keeps the persistent R8 spectrogram
// (512 freq bands × 2048 time columns, shader_underlay.rs:44-45); the Qt
// port's Rust half publishes one 512-byte row per viz tick and the C++
// RibbonItem uploads it at the playback-time column with the same gap-fill
// (shader_underlay.rs:897-927).
//
// Mechanical differences from the WGSL, enumerated:
// * textureSample(spectrogram, samp, uv) -> texture(spectrogram, uv)
// (combined image sampler, binding 3 like the reference).
// * log(x)/log(10.0) stays as-is (GLSL has no log10 on all targets; the
// two-log spelling IS the reference's).
// The plot-rect Y convention (PLOT_Y0/Y1 already flipped vs the Slint
// overlay, spectral_ribbon.wgsl:77-81) is UNCHANGED — the QML overlay
// (SpectralOverlay.qml) keeps the overlay's top-down fractions, and the
// item's mirrorVertically makes the GL composite match wgpu's display.
layout(std140, binding = 0) uniform buf {
float time;
float phase;
float beat;
float level;
vec2 resolution;
float levelSmooth;
float transientAmp;
vec4 energyLo;
vec4 energyHi;
vec4 bandsLo;
vec4 bandsHi;
vec4 primary;
vec4 secondary;
vec4 accent;
} u;
layout(binding = 3) uniform sampler2D spectrogram;
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 fragColor;
// Spek-like 7-stop purple→magenta→orange ramp (Tauri spekColor), linear RGB.
vec3 spek_color(float x) {
float t = clamp(x, 0.0, 1.0);
// stops: 0.00 black, 0.36 deep-blue, 0.60 indigo, 0.78 purple,
// 0.92 magenta, 0.98 red-orange, 1.00 orange.
if (t < 0.36) {
return mix(vec3(0.0, 0.0, 0.0), vec3(0.0, 0.0, 0.243), t / 0.36);
} else if (t < 0.60) {
return mix(vec3(0.0, 0.0, 0.243), vec3(0.055, 0.0, 0.392), (t - 0.36) / 0.24);
} else if (t < 0.78) {
return mix(vec3(0.055, 0.0, 0.392), vec3(0.361, 0.0, 0.439), (t - 0.60) / 0.18);
} else if (t < 0.92) {
return mix(vec3(0.361, 0.0, 0.439), vec3(0.745, 0.0, 0.282), (t - 0.78) / 0.14);
} else if (t < 0.98) {
return mix(vec3(0.745, 0.0, 0.282), vec3(0.863, 0.188, 0.0), (t - 0.92) / 0.06);
} else {
return mix(vec3(0.863, 0.188, 0.0), vec3(0.933, 0.471, 0.125), (t - 0.98) / 0.02);
}
}
// Plot rectangle in 0..1 screen space (leaves a margin for axes; the bottom
// band clears the song card / time axis). MUST match SpectralOverlay.qml.
const float PLOT_X0 = 0.055;
const float PLOT_X1 = 0.970;
const float PLOT_Y0 = 0.220;
const float PLOT_Y1 = 0.930;
void main() {
vec3 bg = vec3(0.012, 0.027, 0.047); // #03070c
// Inside the plot rectangle?
if (uv.x < PLOT_X0 || uv.x > PLOT_X1 || uv.y < PLOT_Y0 || uv.y > PLOT_Y1) {
fragColor = vec4(bg, 1.0);
return;
}
// Plot-local coords. tf = time fraction (left→right), ff = freq
// fraction (band 0/bass at the BOTTOM, 511/Nyquist at the TOP).
float tf = (uv.x - PLOT_X0) / (PLOT_X1 - PLOT_X0);
float ff = (uv.y - PLOT_Y0) / (PLOT_Y1 - PLOT_Y0);
// Sample the spectrogram: u = frequency band (0..1 over 512), v = time
// column. Un-played columns are zero -> background.
float amp = texture(spectrogram, vec2(ff, tf)).r;
// dB + gamma (Tauri): db in [-120,0] -> normalized -> ^2.15.
float db = 20.0 * log(max(1e-6, amp)) / log(10.0);
float db_norm = clamp((db + 120.0) / 120.0, 0.0, 1.0);
float toned = pow(db_norm, 2.15);
vec3 col = spek_color(toned);
float a = (10.0 + toned * 156.0) / 255.0;
// Composite the heatmap over the dark background.
vec3 outc = mix(bg, col, a);
// Real-time ceiling line: a horizontal line at the highest active
// frequency (u.energyHi.y = smoothed peak band fraction), spanning the
// full plot width, in the axis green.
float on_line = 1.0 - smoothstep(0.0, 0.006, abs(ff - u.energyHi.y));
outc = mix(outc, vec3(0.373, 0.722, 0.478), on_line * 0.65);
fragColor = vec4(outc, 1.0);
}
@@ -0,0 +1,41 @@
#version 440
// PAIRED with spectrum_bars.frag (SpectrumBand). THE PAIRING RULE (see
// scene_pack.vert): both stages must declare the SAME uniform block,
// member for member, or the OpenGL program link rejects the two layouts
// (Vulkan never links, so it tolerated the mismatch). Keep this block
// byte-identical to spectrum_bars.frag's.
layout(location = 0) in vec4 qt_Vertex;
layout(location = 1) in vec2 qt_MultiTexCoord0;
layout(location = 0) out vec2 qt_TexCoord0;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
vec4 topColor;
vec4 bottomColor;
float qt_Opacity;
float bandW;
float bandH;
float b0;
float b1;
float b2;
float b3;
float b4;
float b5;
float b6;
float b7;
float b8;
float b9;
float b10;
float b11;
float b12;
float b13;
};
out gl_PerVertex { vec4 gl_Position; };
void main() {
qt_TexCoord0 = qt_MultiTexCoord0;
gl_Position = qt_Matrix * qt_Vertex;
}
@@ -0,0 +1,31 @@
#version 440
// PAIRED with spectrum_energy.frag (SpectrumBand). THE PAIRING RULE (see
// scene_pack.vert): both stages must declare the SAME uniform block,
// member for member, or the OpenGL program link rejects the two layouts.
// Keep this block byte-identical to spectrum_energy.frag's.
layout(location = 0) in vec4 qt_Vertex;
layout(location = 1) in vec2 qt_MultiTexCoord0;
layout(location = 0) out vec2 qt_TexCoord0;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
vec4 topColor;
vec4 bottomColor;
float qt_Opacity;
float bandW;
float bandH;
float b0;
float b1;
float b2;
float b3;
float b4;
};
out gl_PerVertex { vec4 gl_Position; };
void main() {
qt_TexCoord0 = qt_MultiTexCoord0;
gl_Position = qt_Matrix * qt_Vertex;
}
@@ -0,0 +1,73 @@
#version 440
// PAIRED with spectrum_wave.frag (SpectrumBand). THE PAIRING RULE (see
// scene_pack.vert): both stages must declare the SAME uniform block,
// member for member, or the OpenGL program link rejects the two layouts.
// Keep this block byte-identical to spectrum_wave.frag's.
layout(location = 0) in vec4 qt_Vertex;
layout(location = 1) in vec2 qt_MultiTexCoord0;
layout(location = 0) out vec2 qt_TexCoord0;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
vec4 topColor;
float qt_Opacity;
float bandW;
float bandH;
float b0;
float b1;
float b2;
float b3;
float b4;
float b5;
float b6;
float b7;
float b8;
float b9;
float b10;
float b11;
float b12;
float b13;
float b14;
float b15;
float b16;
float b17;
float b18;
float b19;
float b20;
float b21;
float b22;
float b23;
float b24;
float b25;
float b26;
float b27;
float b28;
float b29;
float b30;
float b31;
float b32;
float b33;
float b34;
float b35;
float b36;
float b37;
float b38;
float b39;
float b40;
float b41;
float b42;
float b43;
float b44;
float b45;
float b46;
float b47;
};
out gl_PerVertex { vec4 gl_Position; };
void main() {
qt_TexCoord0 = qt_MultiTexCoord0;
gl_Position = qt_Matrix * qt_Vertex;
}
@@ -0,0 +1,197 @@
#version 440
// Tunnel (immersive scene, mode 2) — a line-faithful GLSL 440 port of
// crates/qbz-ui/ui/shaders/tunnel.wgsl (DOOM TUNNEL, original clean-room
// shader). Every constant and every formula below is the reference's number;
// the differences are mechanical and are enumerated here:
//
// * uniform block instead of the bind group (the ShaderEffect contract):
// qt_Matrix/qt_Opacity are Qt-supplied, everything else is a property on
// the effect item, resolved BY NAME out of the .qsb reflection data (see
// ambient.frag). `resolution: vec2` arrives as resX/resY floats, the
// energy/bands vec4s as energyLo/energyHi/bandsLo/bandsHi — the QML
// property names are spelled identically on both sides.
// * uv = qt_TexCoord0 replaces in.uv DIRECTLY (no y-flip) — the same
// convention the ambient.frag port uses and the owner validated against
// the reference frame. Vertically this mirrors the wgpu frame; the scene
// is y-symmetric except the wind/sway phases, so the picture is the
// reference's mirrored, consistent with the Ambient port.
// * `let` -> local float decls; `array<f32,8>(...)` -> `float[8](...)`;
// `atan2(y,x)` -> `atan(y,x)`; `select(f,t,cond)` -> ternary;
// `u32(ringId & 7)` -> int index, `b[ringId & 7]` — two's-complement
// `& 7` on a negative ringId wraps identically to WGSL i32&u32 in both
// languages (-1 & 7 = 7), and the 4096 phase wrap (host side) keeps the
// ring math seamless exactly like the reference.
// * Output is premultiplied (`col * qt_Opacity`), like every other effect
// in this tree; the layer mounts it with blending: false, so opacity is
// 1.0 and the picture equals the reference's `vec4(col, 1.0)`.
//
// QSB-SKIP-GLES100 — build.rs drops the `100 es` variant for this file: GLSL
// ES 1.00 has no integer bitwise ops (band_at's `& 7`), and there is no
// Canvas fallback arm for scenes — on a tier that cannot run the effect the
// picker gate (QbzShell.shaderScenesAvailable) never offers the scene, and a
// load failure makes ShaderSceneLayer hand the background back to the
// atmosphere.
//
// The algorithm (tunnel.wgsl:78-184): NOT a raymarcher — a single fragment
// pass. Chebyshev/box distance r = max(|p.x|, |p.y|) gives SQUARE rings; the
// curvature lives in the PATH (the corridor snakes), never in the
// cross-section; the host forward-motion clock (phase, wrapped at 4096) is
// amplified by FLIGHT_SPEED; colors come from the album-art palette.
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;
layout(std140, binding = 0) uniform buf {
mat4 qt_Matrix;
vec4 primary;
vec4 secondary;
vec4 accent;
vec4 energyLo;
vec4 energyHi;
vec4 bandsLo;
vec4 bandsHi;
float qt_Opacity;
float time;
float phase;
float beat;
float level;
float resX;
float resY;
float levelSmooth;
// Named transientAmp, not `transient`: the uniform is matched BY NAME to
// a QML property and `transient` is a RESERVED QML keyword (qmlcachegen
// rejects it). Same field, same JSON pack key (`transient`) — only the
// QML-side identifier changes.
float transientAmp;
};
// One of the 8 log FFT bands, selected by ring index (per-ring spectral
// sweep). WGSL masks with `& 7u`; the int two's-complement equivalent wraps
// negatives the same way.
float band_at(int i) {
float b[8] = float[8](
bandsLo.x, bandsLo.y, bandsLo.z, bandsLo.w,
bandsHi.x, bandsHi.y, bandsHi.z, bandsHi.w
);
return b[i & 7];
}
// --- Tunables (the reference's numbers, tunnel.wgsl:65-75) -----------------
// Forward-flight multiplier on the host phase clock. The clock wraps at 4096
// (an integer multiple of 8), so any INTEGER multiplier keeps floor()/fract()/
// `& 7` ring math seamless across the (≈hourly) wrap.
const float FLIGHT_SPEED = 6.0;
// How hard the centerline snakes. The bend is applied to the PATH, so the
// rectangles only translate — they never warp.
const float BEND_AMT = 0.14;
// Number of radial speed-lines fanning out of the vanishing point (even, so
// they stay continuous across the atan seam at ±π).
const float SPOKES = 16.0;
void main() {
float t = time;
vec2 uv = qt_TexCoord0;
// FFT aggregates from the 8 log bands (match the Tauri canvas crossovers:
// bars 0-3 / 4-9 / 10-15 land on even band-pair boundaries).
float bass = clamp((bandsLo.x + bandsLo.y) * 0.5, 0.0, 1.0);
float mid = clamp((bandsLo.z + bandsLo.w + bandsHi.x) / 3.0, 0.0, 1.0);
float high = clamp((bandsHi.y + bandsHi.z + bandsHi.w) / 3.0, 0.0, 1.0);
float beatC = clamp(beat, 0.0, 1.0);
// FAST flight: the host forward-motion clock (base 0.012 + level + beat
// per frame, wrapped at 4096) amplified.
float flight = phase * FLIGHT_SPEED;
// Corridor cross-section is WIDER than tall (a hallway, not a square
// shaft): wider-than-tall scale derived from the viewport aspect.
float viewAspect = resX / max(resY, 1.0);
float scaleX = clamp(1.04 + viewAspect * 0.25, 1.22, 1.58);
float scaleY = clamp(1.38 - scaleX * 0.45, 0.62, 0.82);
// Vanishing point: a gentle Lissajous sway (from `time` only, so its own
// phase never jumps). Kept small — the real motion is the depth-wind.
float cphase = t * 0.18;
vec2 center = vec2(0.5, 0.5);
center.x += sin(cphase) * 0.03 + sin(cphase * 2.08 + 1.2) * 0.012;
center.y += cos(cphase * 0.82 + 0.7) * 0.02;
// First pass: unbent cross-section + depth, so we know how DEEP this
// pixel sits before bending the path.
vec2 p0 = (uv - center) / vec2(scaleX, scaleY);
float r0 = max(abs(p0.x), abs(p0.y)) + 1e-4;
float depth0 = 1.0 / r0;
float z0 = log2(depth0) - flight;
// WINDING CENTERLINE — the curvature is in the PATH, not the rectangle.
// The wave scrolls toward you with `flight`; the near mouth barely bends,
// deep sections swing the most. Louder music = a livelier road.
float windE = 0.75 + levelSmooth * 0.6 + beatC * 0.3;
float windX = sin(z0 * 0.65 + t * 0.6) + 0.5 * sin(z0 * 1.27 - t * 0.9);
float windY = 0.6 * cos(z0 * 0.85 + t * 0.5) + 0.35 * sin(z0 * 1.6 + t * 0.7);
float bendDepth = smoothstep(0.0, 2.6, depth0); // ~0 at the mouth -> 1 deep in
vec2 bend = vec2(windX, windY) * (bendDepth * BEND_AMT * windE);
// Second pass: BENT cross-section coords; BOX distance (Chebyshev) ->
// SQUARE rings that ride the winding path.
vec2 p = p0 - bend;
float r = max(abs(p.x), abs(p.y)) + 1e-4;
float depth = 1.0 / r;
float z = log2(depth) - flight; // minus -> rings grow outward (forward)
int ringId = int(floor(z));
float ringFrac = fract(z);
// Rectangle outline: bright on the frame edge, dark in the gap. Thickness
// breathes with bass + beat.
float lineW = 0.06 + bass * 0.16 + beatC * 0.10;
float edge = smoothstep(0.0, lineW, ringFrac) * smoothstep(0.0, lineW, 1.0 - ringFrac);
float frame = 1.0 - edge;
// Per-ring spectral pulse — the band sweeping bass->treble into depth.
float ringPulse = band_at(ringId);
// Four corridor corner lines (|p.x| == |p.y|) converging to the portal —
// the strongest "hallway" cue.
float corner = 1.0 - smoothstep(0.0, 0.05, abs(abs(p.x) - abs(p.y)));
// RADIAL SPEED-LINES fanning OUT of the vanishing point: thin spokes at
// SPOKES angles, invisible at the portal, fading in just outside it and
// streaming toward the mouth. They rotate very slowly and shimmer hard
// with treble + the beat, so they read as light rushing past.
float ang = atan(p.y, p.x);
float spokeWave = 0.5 + 0.5 * cos(ang * SPOKES + t * 0.25);
float spoke = pow(spokeWave, 12.0)
* smoothstep(0.02, 0.14, r) // emanate from the vanishing point
* (1.0 - smoothstep(0.85, 1.3, r)); // ease off at the near mouth
// Depth shading: black square portal at the center, lit corridor outward.
float depthShade = smoothstep(0.04, 0.55, r);
float nearWeight = smoothstep(0.35, 1.1, r);
// Palette: rings recede primary (near mouth) -> secondary -> accent (far).
float palT = 1.0 - smoothstep(0.05, 0.6, r);
vec3 ringCol = mix(primary.rgb, secondary.rgb, smoothstep(0.0, 0.5, palT));
ringCol = mix(ringCol, accent.rgb, smoothstep(0.5, 1.0, palT));
// Walls: lit gray gradient distinguishing left/right from top/bottom.
float wallLit = (abs(p.x) > abs(p.y)) ? 0.16 : 0.10;
vec3 col = vec3(wallLit) * depthShade * (0.6 + mid * 0.8);
// Ring frames (palette + spectral pulse + bass) and the corner lines.
float ringBright = frame * depthShade * (0.45 + ringPulse * 1.3 + bass * 0.5);
col += ringCol * ringBright;
col += accent.rgb * corner * depthShade * (0.35 + high * 0.7) * frame;
// Radial speed-lines (toward `primary` so they read as bright streaks).
col += primary.rgb * spoke * (0.5 + high * 1.4 + beatC * 0.8);
// Beat punch + a treble rim sparkle on the nearest ring (toward accent).
col *= 1.0 + beatC * 0.6;
col += accent.rgb * high * nearWeight * frame * 0.25;
col = clamp(col, vec3(0.0), vec3(1.0));
// Premultiplied, like every other effect in this tree (blending: false at
// the mount, so qt_Opacity is 1 and this equals the reference frame).
fragColor = vec4(col * qt_Opacity, qt_Opacity);
}
Binary file not shown.
@@ -0,0 +1,533 @@
#version 440
// Tunnel Flow (immersive shader scene, mode 8 — Qt-only) — FRAGMENT stage.
// Block B1 of the 2026-08-15 immersive-completion contract (spec
// 02-tauri-tunnel-port.md). A REWRITE of the legacy Tauri Canvas2D panel
// (qbz-worktrees/legacy-tauri .../panels/TunnelFlowPanel.svelte) as a
// feedback fragment shader: 18 warped square ring shells traveling outward,
// feedback trails (history sampled with the inverse zoom/rotation/drift
// transform at alpha ~0.74, then the near-black fade fill), the background
// wash, the portal (black hole + throat ring + halo), and the corner
// vignette. The feedback history lives in two RGBA8 ping-pong textures owned
// by the C++ TunnelFlowItem (cxx/tunnelflow_item.* — the PlasmaItem shape).
//
// Mechanical differences from the Canvas2D source, enumerated:
// * Vector polygon fills/strokes become PER-PIXEL ANALYTIC evaluation: each
// ring is a square SHELL measured with the Chebyshev distance
// (max(|x|/hx, |y|/hy)) to the warped square. The per-corner jitter of
// makeWarpedSquare is kept as four scalars per square and interpolated
// piecewise-linearly along the perimeter toward the pixel (the side the
// pixel direction hits, between that side's two corners); the boundary
// moves by 0.64*jitter — the outward-normal component of the source's
// (sx*0.6+sy*0.22, sy*0.6-sx*0.22) diagonal displacement.
// * Canvas2D blend modes become explicit color math: `screen` is
// mix(dst, 1-(1-dst)(1-src), srcAlpha) per layer, `multiply` is
// dst *= 1-alpha (black source), and `soft-light` (the diagonal haze) is
// the W3C per-pixel soft-light formula composited with the source alpha
// (spec 02 §3 sanctions "the standard soft-light formula applied
// per-pixel" — this is that choice).
// * The strut sort (rings by perspective) needs no sort: perspective is
// monotonic in travel, so the sorted order is a MODULAR ROTATION of the
// ring indices (sorted rank j = ringIndex (i0+j) % 18, i0 = the ring
// whose travel just wrapped). The gate/span/lane/start formulas are
// ported verbatim — the "simplified deterministic pick" spec 02 §3
// allows, with zero visual approximation beyond the sort itself.
// * drawSpeedMarks is dead code in the source (STREAK_COUNT = 0) and is
// NOT ported (spec 02 §3).
// * The source rendered at 0.58-0.7x CSS size and was upscaled; Qt renders
// at physical size (x DPR, capped 2560x1440 — the plasma_item pattern).
// Stroke widths are multiplied by WIDTH_COMP (1.56 ~ mean 1/scale) so
// the lines keep their on-screen weight; every other constant is
// verbatim.
// * Canvas y is DOWN, texture uv y is UP (uv = pos*0.5+0.5, the plasma
// convention; the GL composite flip is mirrorVertically on the item).
// All motion terms are symmetric sinusoids, so only fixed asymmetries
// would differ — there are none that read as orientation.
// * The 36x36-artwork palette arrives as four vec4 uniforms (Rust port of
// extractLinePaletteFromArtwork, src/tunnelflow_qt.rs); the audio
// smoothing/kick/phase accumulators run in TunnelFlowScene.qml on the
// pulse edge (the pulse law — nothing ticks off-pulse).
//
// No uint arithmetic -> the full qsb variant bake (no QSB-SKIP-GLES100).
layout(std140, binding = 0) uniform buf {
float time; // seconds (the shader converts to the source's ms)
float phase; // the TAURI phase accumulator (TunnelFlowScene.qml)
float bass; // mean of smoothed Viz16 bands 0..3
float mid; // bands 4..9
float high; // bands 10..15
float kick; // kickPulse (decays 0.9/pulse)
vec2 resolution; // history target px (capped 2560x1440)
vec4 palette0;
vec4 palette1;
vec4 palette2;
vec4 palette3;
} u;
layout(binding = 1) uniform sampler2D prev_tex;
layout(location = 0) in vec2 uv;
layout(location = 0) out vec4 fragColor;
// Stroke-width compensation for the source's 0.58-0.7 render scale (see the
// header): 1/0.7 .. 1/0.58 = 1.43..1.72, midpoint ~1.56.
const float WIDTH_COMP = 1.56;
// (e^3.2 - 1) denominator of the spacing curve.
const float SPACING_DENOM = 23.5325301971; // exp(3.2) - 1
const int RING_COUNT = 18;
// Frame-scope values the ring/portal helpers share (set once in main).
float gT; // time, MILLISECONDS (the source's timeMs domain)
float gPhase;
float gBass, gMid, gHigh, gKick;
float gMinDim;
vec2 gTC; // wandering tunnel center, px
vec2 gTScale; // tunnelScaleX/Y (the elliptical cross-section)
vec2 gPx; // the pixel being shaded, px
vec4 pal(int i) {
int k = i - (i / 4) * 4; // i % 4 without the % int pitfalls
if (k == 0) return u.palette0;
if (k == 1) return u.palette1;
if (k == 2) return u.palette2;
return u.palette3;
}
float clamp01(float x) { return clamp(x, 0.0, 1.0); }
// Canvas2D `screen` with source alpha (dst is opaque).
vec3 screenOver(vec3 dst, vec3 src, float a) {
return mix(dst, vec3(1.0) - (vec3(1.0) - dst) * (vec3(1.0) - src), clamp(a, 0.0, 1.0));
}
// W3C soft-light, one channel.
float softLightCh(float s, float d) {
if (s <= 0.5)
return d - (1.0 - 2.0 * s) * d * (1.0 - d);
float g = d <= 0.25 ? ((16.0 * d - 12.0) * d + 4.0) * d : sqrt(d);
return d + (2.0 * s - 1.0) * (g - d);
}
vec3 softLight(vec3 src, vec3 dst) {
return vec3(softLightCh(src.r, dst.r), softLightCh(src.g, dst.g), softLightCh(src.b, dst.b));
}
// makeWarpedSquare's per-corner jitter scalar (verbatim waves).
float cornerJitter(float halfSize, float warp, float seed, int k) {
float fk = float(k);
float waveA = sin(gT * 0.0015 + seed * 0.9 + fk * 1.7);
float waveB = cos(gT * 0.0012 - seed * 0.7 + fk * 1.3);
return halfSize * warp * (waveA * 0.6 + waveB * 0.4);
}
// Warped-square corner position (verbatim displacement).
vec2 cornerPos(vec2 c, float halfSize, float jitter, int k) {
vec2 s = vec2((k == 0 || k == 3) ? -1.0 : 1.0, (k < 2) ? -1.0 : 1.0);
return c + vec2(s.x * halfSize * gTScale.x + (s.x * 0.6 + s.y * 0.22) * jitter * gTScale.x,
s.y * halfSize * gTScale.y + (s.y * 0.6 - s.x * 0.22) * jitter * gTScale.y);
}
// Piecewise-linear interpolation of the four corner jitters along the
// perimeter, toward the pixel direction nq (square-normalized coords).
float perimJitter(vec2 nq, float j0, float j1, float j2, float j3) {
if (abs(nq.x) >= abs(nq.y)) {
float s = clamp(nq.y / max(abs(nq.x), 1e-5), -1.0, 1.0);
return nq.x > 0.0 ? mix(j1, j2, s * 0.5 + 0.5) : mix(j0, j3, s * 0.5 + 0.5);
}
float s = clamp(nq.x / max(abs(nq.y), 1e-5), -1.0, 1.0);
return nq.y > 0.0 ? mix(j3, j2, s * 0.5 + 0.5) : mix(j0, j1, s * 0.5 + 0.5);
}
float segDist(vec2 p, vec2 a, vec2 b) {
vec2 pa = p - a;
vec2 ba = b - a;
float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0);
return length(pa - ba * h);
}
// Antialiased stroke coverage: width w px centered on the path.
float strokeCov(float d, float w) {
float aa = max(fwidth(d), 0.6);
return 1.0 - smoothstep(w * 0.5 - aa, w * 0.5 + aa, d);
}
// Per-ring derived geometry (drawTunnelLayers, verbatim constants).
struct Ring {
vec2 c; // bent center, px
float outerHalf;
float innerHalf;
float warpO; // outer warp
float warpI; // inner warp (x0.72)
float fade;
float travel;
float spacing;
};
Ring ringAt(int i, float travelRate) {
float fi = float(i);
float travel = fract(fi / float(RING_COUNT) + travelRate);
float persp = pow(travel, 1.42);
float spacing = (exp(persp * 3.2) - 1.0) / SPACING_DENOM;
float appear = clamp01((persp - 0.18) / 0.26);
float vanish = 1.0 - clamp01((persp - 0.9) / 0.1);
Ring r;
r.fade = pow(max(appear * vanish, 0.0), 0.82);
r.outerHalf = gMinDim * (0.084 + spacing * 0.92);
float thickness = gMinDim * (0.005 + (1.0 - spacing) * (0.028 + gBass * 0.016) + gKick * 0.0036);
r.innerHalf = max(gMinDim * 0.028, r.outerHalf - thickness);
r.warpO = 0.0025 + (1.0 - spacing) * 0.013 + gHigh * 0.004;
r.warpI = r.warpO * 0.72;
float bendStrength = gMinDim * (0.018 + gHigh * 0.01 + gKick * 0.008) * (1.0 - spacing);
r.c = gTC + vec2(sin(gT * 0.0011 + fi * 0.31 + gPhase * 0.12) * bendStrength,
cos(gT * 0.00086 + fi * 0.24 + gPhase * 0.1) * bendStrength * 0.66);
r.travel = travel;
r.spacing = spacing;
return r;
}
void main() {
vec2 res = u.resolution;
float W = res.x;
float minDim = min(res.x, res.y);
float aspect = res.x / max(res.y, 1.0);
gT = u.time * 1000.0;
gPhase = u.phase;
gBass = u.bass;
gMid = u.mid;
gHigh = u.high;
gKick = u.kick;
gMinDim = minDim;
gPx = uv * res; // texture space, y-up (see the header)
float tunnelScaleX = clamp(1.04 + aspect * 0.25, 1.22, 1.58);
float tunnelScaleY = clamp(1.38 - tunnelScaleX * 0.45, 0.62, 0.82);
gTScale = vec2(tunnelScaleX, tunnelScaleY);
// --- Tunnel center wander (render(), verbatim) -------------------------
vec2 canvasCenter = res * 0.5;
float curvePhase = gT * 0.0006 + gPhase * 0.14;
gTC = vec2(
canvasCenter.x + sin(curvePhase) * (W * (0.14 + gHigh * 0.05 + gKick * 0.04))
+ sin(curvePhase * 2.08 + 1.2) * W * 0.042,
canvasCenter.y + cos(curvePhase * 0.82 + 0.7) * (minDim * (0.036 + gBass * 0.018 + gKick * 0.014))
+ cos(curvePhase * 1.7 + 0.4) * minDim * 0.012);
float lateralCurve = clamp((gTC.x - canvasCenter.x) / max(1.0, W * 0.25), -1.0, 1.0);
// --- 1. Feedback (drawFeedback) -----------------------------------------
// The source draws the previous frame with
// M = T(tc) * R(a) * S(zoom) * T(-tc + drift)
// so destination pixel p shows the history at M^-1(p) =
// tc - drift + R(-a) * (p - tc) / zoom.
float fbAngle = sin(gT * 0.0011) * 0.0022 + (gHigh - gBass) * 0.003;
float zoom = 1.004 + gBass * 0.014 + gHigh * 0.01 + gKick * 0.011;
float driftRadius = minDim * (0.006 + gBass * 0.016 + gHigh * 0.01 + gKick * 0.012);
vec2 drift = vec2(cos(gT * 0.001 + gPhase * 0.18), sin(gT * 0.0012 + gPhase * 0.16)) * driftRadius;
vec2 d = gPx - gTC;
float ca = cos(fbAngle);
float sa = sin(fbAngle);
vec2 rd = vec2(d.x * ca + d.y * sa, -d.x * sa + d.y * ca) / zoom; // R(-a)*d
vec2 histUv = (gTC - drift + rd) / res;
vec3 col = texture(prev_tex, histUv).rgb * (0.74 + gKick * 0.08);
// --- 2. Dark fade fill: rgba(3,0,5, 0.28+high*0.08), source-over --------
float fadeA = 0.28 + gHigh * 0.08;
col = col * (1.0 - fadeA) + vec3(3.0, 0.0, 5.0) / 255.0 * fadeA;
// --- 3. Background wash (drawBackground) --------------------------------
// Radial center wash, source-over.
float washT = clamp01((length(gPx - gTC) - minDim * 0.06) / (minDim * 1.16 - minDim * 0.06));
float washA;
vec3 washC;
float a0 = 0.008 + gBass * 0.008;
float a1 = 0.006 + gHigh * 0.006;
if (washT < 0.45) {
float k = washT / 0.45;
washC = mix(vec3(1.0), vec3(180.0 / 255.0), k);
washA = mix(a0, a1, k);
} else {
float k = (washT - 0.45) / 0.55;
washC = vec3(180.0 / 255.0) * (1.0 - k);
washA = a1 * (1.0 - k);
}
col = mix(col, washC, washA);
// Diagonal haze, soft-light (see the header for the formula choice).
float hazeT = clamp01((gPx.x * res.x + gPx.y * res.y) / (res.x * res.x + res.y * res.y));
float hazeA;
vec3 hazeC;
float h0 = 0.004 + gHigh * 0.005;
float h1 = 0.005 + gBass * 0.007;
if (hazeT < 0.5) {
float k = hazeT * 2.0;
hazeC = vec3(1.0 - k);
hazeA = h0 * (1.0 - k);
} else {
float k = (hazeT - 0.5) * 2.0;
hazeC = vec3(180.0 / 255.0) * k;
hazeA = h1 * k;
}
col = mix(col, softLight(hazeC, col), hazeA);
// --- 4. Rings (drawTunnelLayers, screen composite) ----------------------
float travelRate = gPhase * (1.28 + gBass * 0.24 + gKick * 0.34);
Ring prevRing;
float prevJI[4];
for (int i = 0; i < RING_COUNT; ++i) {
Ring r = ringAt(i, travelRate);
float jI[4];
for (int k = 0; k < 4; ++k)
jI[k] = cornerJitter(r.innerHalf, r.warpI, float(i) + 1.7, k);
if (r.fade > 0.0008) {
float jO[4];
for (int k = 0; k < 4; ++k)
jO[k] = cornerJitter(r.outerHalf, r.warpO, float(i) + 0.3, k);
// Warped shell distances (Chebyshev + perimeter jitter).
vec2 q = gPx - r.c;
vec2 nqO = q / vec2(r.outerHalf * gTScale.x, r.outerHalf * gTScale.y);
float jOP = perimJitter(nqO, jO[0], jO[1], jO[2], jO[3]);
float chebO = max(abs(nqO.x), abs(nqO.y)) - 0.64 * jOP / r.outerHalf;
vec2 nqI = q / vec2(r.innerHalf * gTScale.x, r.innerHalf * gTScale.y);
float jIP = perimJitter(nqI, jI[0], jI[1], jI[2], jI[3]);
float chebI = max(abs(nqI.x), abs(nqI.y)) - 0.64 * jIP / r.innerHalf;
// Wall gradient quad (inner->outer): gray a*0.22 -> white a*1.16
// at 44% -> transparent, masked to the shell.
float rI = (r.innerHalf + 0.64 * jIP) / r.outerHalf;
float gRing = (chebO - rI) / max(1.0 - rI, 1e-4);
float alpha = r.fade * (0.2 + gBass * 0.08 + gKick * 0.06);
float wallA;
vec3 wallC;
if (gRing < 0.44) {
float k = clamp(gRing / 0.44, 0.0, 1.0);
wallC = mix(vec3(210.0 / 255.0), vec3(1.0), k);
wallA = mix(alpha * 0.22, min(alpha * 1.16, 1.0), k);
} else {
float k = clamp((gRing - 0.44) / 0.56, 0.0, 1.0);
wallC = vec3(1.0);
wallA = min(alpha * 1.16, 1.0) * (1.0 - k);
}
float aa = max(fwidth(gRing), 0.02);
wallA *= smoothstep(0.0, aa, gRing);
col = screenOver(col, wallC, wallA);
// Sparse wall stains ((i + side) % 3 == 0), radial palette blobs.
for (int side = 0; side < 4; ++side) {
if ((i + side) - ((i + side) / 3) * 3 != 0)
continue;
vec2 nrm = vec2(side == 1 ? 1.0 : (side == 3 ? -1.0 : 0.0),
side == 2 ? 1.0 : (side == 0 ? -1.0 : 0.0));
vec2 tng = vec2(side == 0 ? 1.0 : (side == 2 ? -1.0 : 0.0),
side == 1 ? 1.0 : (side == 3 ? -1.0 : 0.0));
float lane = sin(float(i) * 1.13 + float(side) * 1.7 + gT * 0.0012 + r.travel * 22.0);
vec2 radial = vec2(r.outerHalf * gTScale.x, r.outerHalf * gTScale.y);
vec2 bc = gTC + nrm * radial * 0.82 + tng * radial * 0.58 * lane;
float blobSize = minDim * (0.008 + r.fade * (0.018 + gBass * 0.01));
float bd = length(gPx - bc);
float bt = clamp01((bd - blobSize * 0.14) / (blobSize * 0.94));
vec3 bcC;
float bcA;
if (bt < 0.62) {
float k = bt / 0.62;
bcC = mix(pal(i + side).rgb, pal(i + side + 1).rgb, k);
bcA = mix(alpha * (0.56 + gMid * 0.18), alpha * (0.3 + gHigh * 0.12), k);
} else {
float k = (bt - 0.62) / 0.38;
bcC = pal(i + side + 1).rgb;
bcA = alpha * (0.3 + gHigh * 0.12) * (1.0 - k);
}
float edge = 1.0 - smoothstep(blobSize - max(fwidth(bd), 0.6),
blobSize + max(fwidth(bd), 0.6), bd);
col = screenOver(col, bcC, bcA * edge);
}
// Palette-colored edge stroke on the inner square.
float edgeAlpha = r.fade * (0.34 + gBass * 0.12 + gKick * 0.08);
float edgeWidth = 0.86 + r.fade * (1.78 + gBass * 0.72 + gKick * 0.56);
vec3 edgeColor = pal(i).rgb;
float pxScale = abs(nqI.x) >= abs(nqI.y) ? r.innerHalf * gTScale.x
: r.innerHalf * gTScale.y;
float edgeDist = abs(chebI - 1.0) * pxScale;
col = screenOver(col, edgeColor,
edgeAlpha * strokeCov(edgeDist, edgeWidth * WIDTH_COMP));
// Corner connectors to the previous ring's inner square (same
// stroke style, width x0.88, min 0.74).
if (i > 0) {
float connW = max(0.74, edgeWidth * 0.88) * WIDTH_COMP;
for (int k = 0; k < 4; ++k) {
vec2 pa = cornerPos(prevRing.c, prevRing.innerHalf, prevJI[k], k);
vec2 pb = cornerPos(r.c, r.innerHalf, jI[k], k);
col = screenOver(col, edgeColor,
edgeAlpha * strokeCov(segDist(gPx, pa, pb), connW));
}
}
}
prevRing = r;
for (int k = 0; k < 4; ++k)
prevJI[k] = jI[k];
}
// --- Struts (the sorted cross-ring lines) -------------------------------
// The sort is a modular rotation (see the header); all gate math verbatim.
{
float audioDrive = clamp01(gBass * 0.38 + gHigh * 0.28 + gKick * 1.04);
int linesPerSide = 1 + int(floor(audioDrive * 1.6));
float eventStepMs = max(92.0, 172.0 - audioDrive * 84.0);
float bucket = floor(gT / eventStepMs);
// i0: the ring with the smallest travel (sorted rank 0).
float w = fract(travelRate) * float(RING_COUNT);
int i0 = int(ceil(float(RING_COUNT) - w)) % RING_COUNT;
for (int side = 0; side < 4; ++side) {
int nextSide = (side + 1) % 4;
for (int tr = 0; tr < 2; ++tr) {
if (tr >= linesPerSide)
break;
float seed = float(side) * 11.37 + float(tr) * 3.83 + bucket * 0.41;
float gate = (sin(bucket * 0.47 + gPhase * 2.2 + seed) + 1.0) * 0.5;
if (gate < 0.6 - audioDrive * 0.32)
continue;
int span = gate > 0.76 ? 3 : 2;
int maxStart = RING_COUNT - span - 1;
float startSel = (sin(gT * 0.0014 + gPhase * 1.8 + seed * 0.9) + 1.0) * 0.5;
int startIndex = min(maxStart, int(floor(startSel * float(maxStart + 1))));
int endIndex = min(RING_COUNT - 1, startIndex + span);
float laneSel = clamp01((sin(bucket * 0.73 + seed * 1.3) + 1.0) * 0.5);
float lane = laneSel < 0.333333 ? 0.2 : (laneSel < 0.666667 ? 0.5 : 0.8);
Ring rs = ringAt((i0 + startIndex) % RING_COUNT, travelRate);
Ring re = ringAt((i0 + endIndex) % RING_COUNT, travelRate);
float lineFade = clamp01((rs.fade + re.fade) * 0.7);
vec2 a0 = cornerPos(rs.c, rs.innerHalf,
cornerJitter(rs.innerHalf, rs.warpI,
float((i0 + startIndex) % RING_COUNT) + 1.7, side),
side);
vec2 b0 = cornerPos(rs.c, rs.innerHalf,
cornerJitter(rs.innerHalf, rs.warpI,
float((i0 + startIndex) % RING_COUNT) + 1.7, nextSide),
nextSide);
vec2 a1 = cornerPos(re.c, re.innerHalf,
cornerJitter(re.innerHalf, re.warpI,
float((i0 + endIndex) % RING_COUNT) + 1.7, side),
side);
vec2 b1 = cornerPos(re.c, re.innerHalf,
cornerJitter(re.innerHalf, re.warpI,
float((i0 + endIndex) % RING_COUNT) + 1.7, nextSide),
nextSide);
vec2 p0 = mix(a0, b0, lane);
vec2 p1 = mix(a1, b1, lane);
float strutAlpha = (0.32 + audioDrive * 0.34) * lineFade;
col = screenOver(col, pal(side * 3 + tr + startIndex).rgb,
strutAlpha * strokeCov(segDist(gPx, p0, p1),
(1.5 + audioDrive * 1.4) * WIDTH_COMP));
}
}
}
// --- 5. Portal (drawPortal) ----------------------------------------------
{
float portalHalf = minDim * 0.057;
float throatOuterHalf = portalHalf * 1.58;
float warpO = 0.008 + gHigh * 0.006;
float warpI = 0.004 + gHigh * 0.004;
// Throat ring (screen), gradient white a -> gray a*0.24 at 50% -> 0.
float jO0 = cornerJitter(throatOuterHalf, warpO, 941.0, 0);
float jO1 = cornerJitter(throatOuterHalf, warpO, 941.0, 1);
float jO2 = cornerJitter(throatOuterHalf, warpO, 941.0, 2);
float jO3 = cornerJitter(throatOuterHalf, warpO, 941.0, 3);
float jI0 = cornerJitter(portalHalf, warpI, 942.0, 0);
float jI1 = cornerJitter(portalHalf, warpI, 942.0, 1);
float jI2 = cornerJitter(portalHalf, warpI, 942.0, 2);
float jI3 = cornerJitter(portalHalf, warpI, 942.0, 3);
vec2 q = gPx - gTC;
vec2 nqO = q / vec2(throatOuterHalf * gTScale.x, throatOuterHalf * gTScale.y);
float chebO = max(abs(nqO.x), abs(nqO.y))
- 0.64 * perimJitter(nqO, jO0, jO1, jO2, jO3) / throatOuterHalf;
vec2 nqI = q / vec2(portalHalf * gTScale.x, portalHalf * gTScale.y);
float jIP = perimJitter(nqI, jI0, jI1, jI2, jI3);
float chebI = max(abs(nqI.x), abs(nqI.y)) - 0.64 * jIP / portalHalf;
float sideAlpha = 0.032 + gHigh * 0.015;
float rI = portalHalf / throatOuterHalf;
float gT2 = (chebO - rI) / (1.0 - rI);
float throatA;
vec3 throatC;
if (gT2 < 0.5) {
float k = clamp(gT2 * 2.0, 0.0, 1.0);
throatC = mix(vec3(1.0), vec3(185.0 / 255.0), k);
throatA = mix(sideAlpha, sideAlpha * 0.24, k);
} else {
float k = clamp((gT2 - 0.5) * 2.0, 0.0, 1.0);
throatC = vec3(185.0 / 255.0);
throatA = sideAlpha * 0.24 * (1.0 - k);
}
throatA *= smoothstep(0.0, max(fwidth(gT2), 0.02), gT2);
col = screenOver(col, throatC, throatA);
// The black hole (0.82x the inner square), source-over a=0.98.
float holeDist = (chebI - 0.82) * min(portalHalf * gTScale.x, portalHalf * gTScale.y);
float holeCov = 1.0 - smoothstep(-max(fwidth(holeDist), 0.6), max(fwidth(holeDist), 0.6), holeDist);
col = mix(col, vec3(0.0), 0.98 * holeCov);
// Multiply dark halo out to 4.8x portal (radial, circular).
float haloT = clamp01((length(q) - portalHalf * 0.6) / (portalHalf * 4.8 - portalHalf * 0.6));
float haloA;
if (haloT < 0.32) {
haloA = mix(0.72, 0.48, haloT / 0.32);
} else if (haloT < 0.72) {
haloA = mix(0.48, 0.18, (haloT - 0.32) / 0.4);
} else {
haloA = 0.18 * (1.0 - (haloT - 0.72) / 0.28);
}
col *= 1.0 - haloA;
// Directional front shadow on the curve-leading side (multiply).
float portalEdgeWidth = 0.96 + gBass * 0.26;
float lateralAbs = abs(lateralCurve);
if (lateralAbs > 0.06) {
float shadowStrength = clamp01((lateralAbs - 0.06) / 0.58);
// The curve-leading SIDE of the portal band: x-dominant (for the
// square, the side region is |x| >= |y|) on the sign the tunnel
// center wanders toward.
bool lead = abs(nqI.x) >= abs(nqI.y) && nqI.x * sign(lateralCurve) > 0.0;
float gSh = clamp01((chebI - 0.82) / 0.18);
float shA;
if (gSh < 0.72) {
shA = mix(0.56 + shadowStrength * 0.22, 0.22 + shadowStrength * 0.18, gSh / 0.72);
} else {
shA = (0.22 + shadowStrength * 0.18) * (1.0 - (gSh - 0.72) / 0.28);
}
float inBand = smoothstep(0.0, max(fwidth(gSh), 0.02), gSh)
* (1.0 - smoothstep(1.0 - max(fwidth(gSh), 0.02), 1.0, gSh));
col *= 1.0 - shA * (lead ? inBand : 0.0);
// Dark stroke on the hole edge, leading side only.
float holeEdge = abs(chebI - 0.82) * min(portalHalf * gTScale.x, portalHalf * gTScale.y);
col *= 1.0 - (0.42 + shadowStrength * 0.22)
* (lead ? strokeCov(holeEdge, max(1.0, portalEdgeWidth * 1.2) * WIDTH_COMP) : 0.0);
}
// Portal edge highlight + hole rim (source-over, palette0).
float pxScale = abs(nqI.x) >= abs(nqI.y) ? portalHalf * gTScale.x
: portalHalf * gTScale.y;
float edgeDist = abs(chebI - 1.0) * pxScale;
col = mix(col, pal(0).rgb,
(0.18 + gHigh * 0.06) * strokeCov(edgeDist, portalEdgeWidth * WIDTH_COMP));
float rimDist = abs(chebI - 0.82) * pxScale;
col = mix(col, pal(0).rgb,
(0.14 + gHigh * 0.04) * strokeCov(rimDist, max(0.76, portalEdgeWidth * 0.8) * WIDTH_COMP));
}
// --- 6. Corner vignette (drawCornerVignette, multiply) -------------------
for (int c = 0; c < 4; ++c) {
vec2 corner = vec2((c == 1 || c == 3) ? res.x : 0.0, (c < 2) ? 0.0 : res.y);
float vt = clamp01(length(gPx - corner) / (minDim * 0.86));
float va;
if (vt < 0.44) {
va = mix(0.72, 0.38, vt / 0.44);
} else {
va = 0.38 * (1.0 - (vt - 0.44) / 0.56);
}
col *= 1.0 - va;
}
fragColor = vec4(col, 1.0);
}
@@ -83,6 +83,26 @@ Item {
property real glowOpacity: Math.min(0.1 + root.globalEnergy * 1.5, 1.0)
// --- Base art box (:84-92) ---------------------------------------------
// D2 (contract 04 §4): size-aware large art. `artSource` prefers the
// size-resolved large feed and falls back to the small one while it
// resolves (the Slint `artwork-large : artwork` pattern); the probe reads
// the SAME source so the native cap tracks the image actually shown.
readonly property string artSource: QbzPlayer.npArtworkPathLarge !== ""
? QbzPlayer.npArtworkPathLarge : QbzPlayer.npArtworkPath
// The baseArt slot expression WITHOUT the native cap — the request must
// reflect the SLOT so the feed can serve a variant big enough for it.
readonly property real artSlot: Math.max(180,
Math.min(Math.min((root.height - 384) / 1.25, root.width * 0.62), 660))
// Bucketed in Rust (one re-resolve per variant tier crossed, none per
// pixel); gated on visible so a hidden panel writes nothing (pulse law).
function requestArtSize() {
if (root.visible)
QbzPlayer.requestNpArtworkSize(Math.round(root.artSlot))
}
onArtSlotChanged: requestArtSize()
onVisibleChanged: requestArtSize()
Component.onCompleted: requestArtSize()
// Native source resolution — cap the RESTING art so the cover is never
// UPSCALED beyond its own size (owner: oversized blurry art looked
// nefasto); the audio breathing still grows it momentarily. Slint reads
@@ -92,7 +112,7 @@ Item {
Image {
id: artProbe
visible: false
source: QbzPlayer.npArtworkPath
source: root.artSource
}
readonly property real srcNative: artProbe.status === Image.Ready
? artProbe.implicitWidth : 0
@@ -175,7 +195,7 @@ Item {
RoundedImage {
anchors.fill: parent
radius: 8
source: QbzPlayer.npArtworkPath
source: root.artSource
}
}
}
+22 -6
View File
@@ -74,6 +74,17 @@ Item {
Math.min(Math.min(root.height - 372, root.width * 0.52), 560))
readonly property real pitch: root.baseArt * 0.42
// D2 (contract 04 §4): the CENTER card is a large slot (up to 560 CSS
// px) — report its size so the large-art feed re-resolves per bucket;
// the card below binds large-feed-first with the row cover as last
// fallback. Bucketed in Rust; invisible panel writes nothing (pulse law).
function requestArtSize() {
if (root.visible)
QbzPlayer.requestNpArtworkSize(Math.round(root.baseArt))
}
onBaseArtChanged: requestArtSize()
onVisibleChanged: requestArtSize()
// --- Animated scroll — the ONLY motion (:152-153) -----------------------
property real scroll: root.cfDoc.index
Behavior on scroll { NumberAnimation { duration: 320; easing.type: Easing.InOutQuad } }
@@ -88,7 +99,10 @@ Item {
root.coverMap = Object.assign({}, m)
}
}
Component.onCompleted: root.dispatchCovers()
Component.onCompleted: {
root.dispatchCovers()
requestArtSize() // D2 initial large-art request
}
onCfDocChanged: root.dispatchCovers()
function dispatchCovers() {
var tracks = root.cfDoc.tracks
@@ -274,9 +288,9 @@ Item {
// CENTER card — drawn LAST so it paints over its neighbors
// (:255-273). Bound to the current index's offset (eases to
// 0 with `scroll`). Prefers the unified large now-playing
// cover; falls back to the model row's cover while it
// resolves. Non-interactive.
// 0 with `scroll`). Prefers the size-resolved large
// now-playing cover (D2), then the small feed, then the
// model row's cover while they resolve. Non-interactive.
CoverCard {
readonly property string rowCover: root.centerIdx >= 0
&& root.centerIdx < root.cfLen
@@ -286,8 +300,10 @@ Item {
pitch: root.pitch
stageCx: fanStage.cx
stageHeight: fanStage.height
artSource: QbzPlayer.npArtworkPath !== ""
? QbzPlayer.npArtworkPath : rowCover
artSource: QbzPlayer.npArtworkPathLarge !== ""
? QbzPlayer.npArtworkPathLarge
: (QbzPlayer.npArtworkPath !== ""
? QbzPlayer.npArtworkPath : rowCover)
hasArt: artSource !== ""
interactive: false
}
@@ -191,10 +191,10 @@ Item {
}
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"
// 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 ----------------
+99 -15
View File
@@ -75,6 +75,8 @@ Item {
}
// One row model entry: vm 0 = FOCUS (mode m), vm 1 = SPLIT (panel sp).
// Block A1 adds `sc`: >= 0 = a SHADER SCENE row (the Slint shader
// rows at ImmersiveView.slint:654-711), -1/absent = a panel row.
// `entry` names the §5.5 entry load fired alongside setView:
// "queue" -> QbzQueue.queuePanelOpened()
// "trackinfo" -> QbzAlbum.openTrackInfo(QbzPlayer.npTrackId)
@@ -82,6 +84,19 @@ Item {
// "" -> none (Lyrics rows: Qt fetches lyrics
// automatically per track, §5.5).
function fireRow(row) {
if (row.sc !== undefined && row.sc >= 0) {
// Shader scene row: FOCUS view + scene N (Slint sets
// view-mode=0 and shader-mode=N and leaves `mode` alone;
// setView is the ONLY mode mutator here, §3.2).
if (row.sc > 0 && QbzImmersive.viewMode !== 0)
QbzImmersive.setView(0, QbzImmersive.mode, QbzImmersive.splitPanel)
QbzShaderScene.scene = row.sc
viewMenu.close()
return
}
// Panel rows turn the scene OFF — every Slint panel row sets
// shader-mode = 0 (:586-650) — then setView as before.
QbzShaderScene.scene = 0
QbzImmersive.setView(
row.vm === 0 ? 0 : 1,
row.vm === 0 ? row.m : QbzImmersive.mode,
@@ -99,9 +114,19 @@ Item {
id: menuRow
Rectangle {
required property var modelData
readonly property bool active: modelData.vm === 0
? (QbzImmersive.viewMode === 0 && QbzImmersive.mode === modelData.m)
: (QbzImmersive.viewMode === 1 && QbzImmersive.splitPanel === modelData.sp)
// A1: shader rows (sc >= 0) are active when their scene is
// on; FOCUS panel rows additionally require the scene OFF
// (Slint: `active: is-focus && sh == 0 && md == N`).
readonly property bool active: {
if (modelData.sc !== undefined && modelData.sc >= 0)
return QbzShaderScene.scene === modelData.sc
if (modelData.vm === 0)
return QbzImmersive.viewMode === 0
&& QbzImmersive.mode === modelData.m
&& QbzShaderScene.scene === 0
return QbzImmersive.viewMode === 1
&& QbzImmersive.splitPanel === modelData.sp
}
width: parent ? parent.width : 0
height: 33
radius: 8
@@ -157,7 +182,7 @@ Item {
Repeater {
// FOCUS rows IN SLINT ORDER (:586-650): Album Reactive(0),
// Static(1), Coverflow(2), Spectrum(3), Wave Bed(6), Lyrics(4),
// Queue(5). NO shader rows (owner ruling 1).
// Queue(5).
model: [
{ "vm": 0, "m": 0, "sp": -1, "entry": "",
"label": QbzSession.tr("Album Reactive", QbzSession.trRev) },
@@ -176,6 +201,43 @@ Item {
]
delegate: menuRow
}
Repeater {
// A1 shader rows (:654-711), inside the FOCUS group like Slint
// (no separate header there). SHIPPED scenes only — the Slint
// order is Plasma, Tunnel, Aurora, Ambient, Spectral Ribbon,
// Line Bed: Plasma shipped with A2, the Ribbon with A3; Liquid
// Spectrum is parked in Slint itself, so its row DOES NOT
// appear (00-CONTRACT ruling 1); Line Bed shipped with A4 and
// appears LAST here, the shipped-set tail.
// There is NO "Off" row (owner ruling 2026-08-15: Slint has
// none — a panel row already turns the scene off, and `g`
// cycles back to 0). The whole group hides on tiers that
// cannot run the shaders (the Slint `if shader-scenes-available`
// gates) — QbzShell.shaderScenesAvailable is the single source
// of truth, seeded from the Rust renderer probe. The six A-block
// labels are EXISTING qbz-ui msgids; B1's "Tunnel Flow" is the
// one NEW msgid (added to all 8 locales).
model: QbzShell.shaderScenesAvailable ? [
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 1,
"label": QbzSession.tr("Plasma", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 2,
"label": QbzSession.tr("Tunnel", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 3,
"label": QbzSession.tr("Aurora", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 7,
"label": QbzSession.tr("Ambient", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 4,
"label": QbzSession.tr("Spectral Ribbon", QbzSession.trRev) },
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 5,
"label": QbzSession.tr("Line Bed", QbzSession.trRev) },
// B1: Tunnel Flow (sc 8) — the QT-ONLY scene (spec 02),
// after Line Bed. Menu-only: NOT in the `g` ring (spec 02
// §5). NEW msgid (added to all 8 locales).
{ "vm": 0, "m": -1, "sp": -1, "entry": "", "sc": 8,
"label": QbzSession.tr("Tunnel Flow", QbzSession.trRev) },
] : []
delegate: menuRow
}
Text {
height: 26
leftPadding: 10
@@ -252,15 +314,24 @@ Item {
}
// --- RIGHT: the window-controls capsule --------------------------------
// Collapsed: 44px circle, sliders glyph. Hover expands it LEFTWARD to
// 112px (150ms ease-in-out, anchored right) revealing the fullscreen
// toggle and the X exit. Minimize/maximize/drag parked (Slint :1173-1183).
// Collapsed: 44px circle, sliders glyph. Expands LEFTWARD to 112px
// (150ms ease-in-out, anchored right) revealing the fullscreen toggle
// and the X exit. Minimize/maximize/drag parked (Slint :1173-1183).
//
// The expansion is the Slint state machine ported 1:1
// (ImmersiveView.slint:1057-1184): `expanded = pinned || anyHover` is a
// BOOL and the buttons gate on that bool — NEVER on the animated width.
// (2026-08-15 flicker root cause: gating on `capsule.width > 100`
// mid-animation let the binding flip while the capsule was traveling,
// and the expanding left edge could cross a resting cursor frame by
// frame — a visible 44↔112 bounce. Clicking the circle toggles `pinned`
// (Slint circle-ta, :1159-1162), which is what "stays 100% open" means.)
Rectangle {
id: capsule
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
height: 36
width: capHover.containsMouse ? 112 : 44
width: expanded ? 112 : 44
radius: 18
color: "#80000000"
border.width: 1
@@ -270,10 +341,23 @@ Item {
NumberAnimation { duration: 150; easing.type: Easing.InOutQuad }
}
// Click-pin state; survives the auto-hide (Slint keeps it too).
property bool pinned: false
// Hover over ANY part of the capsule: the background or either
// button (their hover areas extend 9px past the glyphs).
readonly property bool anyHover: capHover.containsMouse
|| fsArea.containsMouse || xArea.containsMouse
readonly property bool expanded: pinned || anyHover
MouseArea {
id: capHover
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Collapsed-circle click = pin open (Slint circle-ta). Clicking
// the expanded body un-pins; hover still holds it open, so this
// can never collapse anything out from under the cursor.
onClicked: capsule.pinned = !capsule.pinned
}
// Collapsed-state glyph (fades out as the capsule opens).
@@ -283,7 +367,7 @@ Item {
height: 16
anchors.centerIn: parent
tintName: "white"
opacity: capsule.width < 100 ? 1 : 0
opacity: capsule.expanded ? 0 : 1
Behavior on opacity { NumberAnimation { duration: 100 } }
}
@@ -299,18 +383,18 @@ Item {
x: capsule.width - 72
anchors.verticalCenter: parent.verticalCenter
tintName: fsArea.containsMouse ? "white" : "muted"
opacity: capsule.width > 100 ? 1 : 0
opacity: capsule.expanded ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 100 } }
MouseArea {
id: fsArea
anchors.fill: parent
anchors.margins: -9
// Gated on the expansion: the two button MouseAreas are
// Gated on the expansion BOOL: the two button MouseAreas are
// INVISIBLE while collapsed but would still cover the 44px
// circle (the -9 margins make the X's hit area span almost
// all of it), so a click on the collapsed capsule exited
// immersive instead of doing nothing.
enabled: capsule.width > 100
// immersive instead of pinning it.
enabled: capsule.expanded
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
@@ -329,13 +413,13 @@ Item {
x: capsule.width - 36
anchors.verticalCenter: parent.verticalCenter
tintName: xArea.containsMouse ? "white" : "muted"
opacity: capsule.width > 100 ? 1 : 0
opacity: capsule.expanded ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 100 } }
MouseArea {
id: xArea
anchors.fill: parent
anchors.margins: -9
enabled: capsule.width > 100
enabled: capsule.expanded
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
// Exit path 2 (§5.4) — every exit funnels through the bridge's
+138 -28
View File
@@ -17,8 +17,13 @@
//
// Layer order bottom->top (§5.1):
// 1. root color #0a0a0b, clip (:1199-1200)
// 2. ImmersiveAtmosphere — ALWAYS the underlay (the Slint shader-mode==0
// gate is constant in v1, ruling 1; :1313-1321)
// 1b. ShaderSceneLayer (block A1, :1293-1300) — the GPU shader scene,
// bottom-most VISUAL layer when QbzShaderScene.scene > 0, replacing
// the atmosphere AND the FOCUS/SPLIT panels (their gates below gain
// `scene === 0`, parity :1313-1405); chrome stays on top
// 2. ImmersiveAtmosphere — the underlay while NO scene is active
// (gated `scene === 0`; in v1 the shader-mode==0 gate was constant,
// ruling 1; :1313-1321)
// 2b. FULL-COVERAGE click-blocker MouseArea (load-bearing — without it
// clicks pass through to the desktop header search field, the dock's
// spectrum band and the viz eye toggle; port of the Slint root
@@ -117,15 +122,27 @@ Item {
color: "#0a0a0b"
}
// --- Layer 1b: the shader scene (block A1, :1293-1300) ------------------
// Bottom-most VISUAL layer, ABOVE the root surface, below everything
// else. Self-gated: it renders and ticks only while a scene is active
// (scene > 0 && open && available — see the file's header). The
// atmosphere below and every FOCUS/SPLIT panel gate themselves off on
// `scene === 0`, so the scene owns the background (parity :1313-1405).
ShaderSceneLayer {
anchors.fill: parent
visible: QbzShaderScene.scene > 0
}
// --- Layer 2: the atmosphere underlay (§5.1, :1313-1321) ----------------
// ALWAYS the underlay in v1 (the Slint shader-mode==0 gate is constant,
// ruling 1). Source = the host-generated 128x128 atmosphere PNG, fallback
// The underlay WHILE NO SCENE IS ACTIVE (the Slint shader-mode==0 gate,
// :1313). Source = the host-generated 128x128 atmosphere PNG, fallback
// the plain cover; dim 0.15. `animated` binds npPlaying (:1321); the
// && open arm stops the drift clock while the overlay is closed (the
// Slint view is UNMOUNTED then — same zero cost). Spectrum/WaveBed paint
// opaque #000 over it — intended (§5.1).
ImmersiveAtmosphere {
anchors.fill: parent
visible: QbzShaderScene.scene === 0
source: QbzImmersive.atmosphereUrl
fallbackSource: QbzPlayer.npArtworkPath
animated: QbzPlayer.npPlaying && QbzImmersive.open
@@ -137,6 +154,16 @@ Item {
anchors.fill: parent
acceptedButtons: Qt.AllButtons
hoverEnabled: true
// C1 fullscreen integration (contract 03 §5, divergence D2 — not a
// Slint parity item): blank the cursor when the chrome has
// auto-hidden in FullScreen; wake() restores it. Windowed mode keeps
// the cursor always. Note: panels with their own cursor-less
// MouseAreas above this layer still show the default arrow — the
// blank covers the bare-atmosphere regions, which is where a
// stationary cursor rests while watching fullscreen.
cursorShape: (root.Window.window !== null
&& root.Window.window.visibility === Window.FullScreen
&& !root.chromeVisible) ? Qt.BlankCursor : Qt.ArrowCursor
onPressed: function (mouse) { mouse.accepted = true }
onReleased: function (mouse) { mouse.accepted = true }
onClicked: function (mouse) { mouse.accepted = true }
@@ -146,20 +173,40 @@ Item {
// --- §5.3 auto-hide / wake ----------------------------------------------
// hideTimer 6000ms (:1271); hides chrome only when !immSearchOpen &&
// !pointerInChrome (:1275); pointerInChrome = pointerInWindow &&
// (y <= 64 || y >= height-132) (:1196-1197, HoverHandler on the root);
// wake on any mouse move (:1283-1286); 300ms fades on the chrome itself.
// !pointerInChrome; wake on any REAL mouse move (delta guard below);
// 300ms fades on the chrome itself.
//
// 2026-08-15 C1 (contract 03 §4): pointerInChrome is the union of the
// REAL chrome rects (header ∪ player bar ∪ song card), not the Slint
// y-only bands (:1196-1197). The bands kept the chrome awake over dead
// strips (the 24px side margins, the 18px above the bar) which read as
// "mouse-sensitive in some zones", and spanned the full width.
property bool chromeVisible: true
readonly property bool pointerInWindow: rootHover.hovered
readonly property bool pointerInChrome: rootHover.hovered
&& (rootHover.point.position.y <= 64
|| rootHover.point.position.y >= root.height - 132)
&& (pointInChromeItem(header) || pointInChromeItem(playerBar)
|| pointInChromeItem(songCard))
function pointInChromeItem(item) {
if (!item.visible)
return false
var p = item.mapFromItem(root, rootHover.point.position.x,
rootHover.point.position.y)
return p.x >= 0 && p.y >= 0 && p.x <= item.width && p.y <= item.height
}
function wake() {
chromeVisible = true
hideTimer.restart()
}
// C1 macOS traffic-light inset (contract 03 §6) — same rule as the
// shell's HeaderBar: ask SafeArea, floor at 78, only when the native
// lights float over client area (i.e. not with the system title bar).
readonly property int chromeLeftInset:
QbzShell.isMacos && !QbzShell.systemTitleBar
? Math.max(root.SafeArea.margins.left, 78) : 0
// Last hover position that WOKE the chrome. Load-bearing delta guard:
// Qt re-delivers HoverMove at the unchanged position whenever the scene
// under a stationary cursor updates (constant on any animated surface —
@@ -198,26 +245,33 @@ Item {
// --- Layer 3: the FOCUS panels (B3 + B4) --------------------------------
// FOCUS: one panel per mode, gated viewMode==0 && mode==N, full-viewport
// — each panel carries its own Slint-internal reserves (pad-top 52/70,
// the 132px player clearance; §6.1/§6.2/§6.6).
// the 132px player clearance; §6.1/§6.2/§6.6). Block A1 adds the
// `scene === 0` gate to every panel (parity :1313-1405 — a shader scene
// REPLACES the panels; chrome stays on top).
AlbumReactivePanel {
anchors.fill: parent
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 0
visible: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 0
}
StaticPanel {
anchors.fill: parent
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 1
visible: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 1
}
CoverflowPanel {
anchors.fill: parent
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 2
visible: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 2
}
SpectrumPanel {
anchors.fill: parent
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 3
visible: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 3
}
WaveBedPanel {
anchors.fill: parent
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 6
visible: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 6
}
// FOCUS Lyrics (mode 4, B4 §6.6): the giant-line variant. It shares the
// immersive LyricsSyncEngine with the split lyrics mount (trap 22 — the
@@ -225,7 +279,8 @@ Item {
// fetches lyrics automatically per track (§5.5).
LyricsFocusPanel {
anchors.fill: parent
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 4
visible: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 4
lines: root.immLyricsLines
synced: root.immLyricsSynced
status: root.immLyricsStatus
@@ -235,7 +290,8 @@ Item {
// placement, full-screen (centered: false -> min(vw-96, 720) column).
QueueTabsPanel {
anchors.fill: parent
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 5
visible: QbzShaderScene.scene === 0
&& QbzImmersive.viewMode === 0 && QbzImmersive.mode === 5
centered: false
}
@@ -260,6 +316,7 @@ Item {
LyricsSyncEngine {
id: immLyricsEngine
gateOpen: QbzImmersive.open
&& QbzShaderScene.scene === 0 // A1: the scenes replace the panels
&& ((QbzImmersive.viewMode === 0 && QbzImmersive.mode === 4)
|| (QbzImmersive.viewMode === 1 && QbzImmersive.splitPanel === 0))
}
@@ -284,7 +341,11 @@ Item {
Item {
id: splitRoot
anchors.fill: parent
visible: QbzImmersive.viewMode === 1
// A1: a shader scene replaces the SPLIT layout too (:1416 parity).
visible: QbzShaderScene.scene === 0 && QbzImmersive.viewMode === 1
// D2: re-request the large art when the SPLIT layout becomes visible
// (leftCol's own request is gated on this visibility).
onVisibleChanged: if (visible) leftCol.requestArtSize()
// col-h: the column height below the header/player clearance
// (:1415). colW: the explicit half (:1435).
@@ -306,6 +367,24 @@ Item {
width: splitRoot.colW
height: parent.height
// D2 (contract 04 §4): size-aware large art — prefer the
// size-resolved large feed, fall back to the small one while
// it resolves (the StaticPanel/AlbumReactivePanel pattern).
readonly property string artSource: QbzPlayer.npArtworkPathLarge !== ""
? QbzPlayer.npArtworkPathLarge : QbzPlayer.npArtworkPath
// The artSize slot expression WITHOUT the native cap — the
// request must reflect the SLOT; the probe cap below stays
// the final upscale safety. Bucketed in Rust (one re-resolve
// per variant tier crossed, none per pixel).
readonly property real artSlot: Math.max(240,
Math.min(splitRoot.colW, splitRoot.colH - 200))
function requestArtSize() {
if (splitRoot.visible)
QbzPlayer.requestNpArtworkSize(Math.round(leftCol.artSlot))
}
onArtSlotChanged: requestArtSize()
Component.onCompleted: requestArtSize()
// Native source resolution (physical px) of the active
// cover, so the art is never UPSCALED beyond its own size
// (:1449-1452) — the AlbumReactivePanel.qml artProbe idiom
@@ -314,7 +393,7 @@ Item {
Image {
id: splitArtProbe
visible: false
source: QbzPlayer.npArtworkPath
source: leftCol.artSource
}
readonly property real srcNative: splitArtProbe.status === Image.Ready
? splitArtProbe.implicitWidth : 0
@@ -365,7 +444,7 @@ Item {
height: leftCol.artSize
visible: QbzPlayer.npArtworkPath !== ""
radius: 12
source: QbzPlayer.npArtworkPath
source: leftCol.artSource
}
}
@@ -463,15 +542,20 @@ Item {
}
// --- Layer 4: ImmersiveSongCard (§5.1, :1602-1605) ----------------------
// Visible ONLY viewMode==0 && mode==6 && npHasTrack (trap 19);
// bottom-right 24px insets; NON-interactive; does NOT fade with the
// auto-hide chrome (no opacity binding here — that is deliberate).
// Slint shows it bottom-right ONLY over the shader backgrounds OR in
// WaveBed (shader-mode > 0 || (view-mode == 0 && mode == 6)) &&
// has-track — the focus panels render their own metadata. In v1
// shader-mode was constant 0 so the first arm was dead (trap 19); A1
// makes it real. NON-interactive; does NOT fade with the auto-hide
// chrome (no opacity binding here — that is deliberate).
ImmersiveSongCard {
id: songCard
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.rightMargin: 24
anchors.bottomMargin: 24
visible: QbzImmersive.viewMode === 0 && QbzImmersive.mode === 6
visible: (QbzShaderScene.scene > 0
|| (QbzImmersive.viewMode === 0 && QbzImmersive.mode === 6))
&& QbzPlayer.npHasTrack
}
@@ -481,7 +565,8 @@ Item {
Connections {
target: QbzPlayer
function onNpTrackIdChanged() {
if (!root.visible || QbzImmersive.viewMode !== 1)
if (!root.visible || QbzImmersive.viewMode !== 1
|| QbzShaderScene.scene !== 0) // A1: split panels unmounted under a scene
return
if (QbzImmersive.splitPanel === 1)
QbzAlbum.openTrackInfo(QbzPlayer.npTrackId)
@@ -507,6 +592,24 @@ Item {
QbzImmersive.open = false
event.accepted = true
}
// Block A1: `g` cycles the SHIPPED shader scenes (0→1→2→3→4→5→7→0 with
// Plasma A2, Ribbon A3 and Line Bed A4 shipped — the ring lives in
// QbzShaderScene.cycleScene; Tunnel Flow 8 is menu-only, NOT in the
// ring). No-op on tiers where the scenes can't run
// (QbzShell.shaderScenesAvailable — the same flag the FOCUS menu rows
// check), and the textInputActive gate keeps a 'g' typed in the search
// field from cycling (§5.4). wake() on cycle, like Slint. The key is
// ACCEPTED even when unavailable — Slint's `return accept` — so it never
// falls through to another handler.
Keys.onPressed: function (event) {
if (event.key === Qt.Key_G && !root.textInputActive) {
if (QbzShell.shaderScenesAvailable) {
QbzShaderScene.cycleScene()
root.wake()
}
event.accepted = true
}
}
// The seek arrows are GONE (2026-08-03 hotkeys-port §1.3): ±5s/±10s now
// fire as the REBINDABLE focus.seekForward/Back/Long actions through the
// AppShell dispatcher, with the Context::Immersive gate and the central
@@ -517,6 +620,9 @@ Item {
// --- Layer 5: the player bar (§5.7, :1607-1616) -------------------------
// Centered, y = height-90-24; fades with the auto-hide chrome on the
// SAME binding as the header (300ms), inert while hidden.
// C1: hit-enabled follows the STATE (`chromeVisible`), not the animated
// opacity — gating on `opacity > 0` left the bar clickable mid-fade and
// re-triggered the Behavior on wake/hide races (contract 03 §2 item 4).
ImmersivePlayerBar {
id: playerBar
anchors.horizontalCenter: parent.horizontalCenter
@@ -524,7 +630,7 @@ Item {
viewportWidth: root.width
opacity: root.chromeVisible ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 300 } }
enabled: opacity > 0
enabled: root.chromeVisible
onWakeRequested: root.wake()
}
@@ -534,13 +640,17 @@ Item {
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.leftMargin: 24
// C1 macOS: clear the native traffic-light cluster (it floats over
// the top-left corner, x ≈ 7-85 — the view trigger's exact spot) with
// the same inset the shell HeaderBar computes
// (HeaderBar.qml chromeLeftInset: SafeArea left margin, 78 floor).
anchors.leftMargin: 24 + root.chromeLeftInset
anchors.rightMargin: 24
anchors.topMargin: 16
height: 36
opacity: root.chromeVisible ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 300 } }
enabled: opacity > 0
enabled: root.chromeVisible
}
// --- Layer 7: the search cortinilla (§3.4, :1695-1705) -------------------
@@ -0,0 +1,48 @@
// Line Bed (immersive shader scene, mode 5) — the QML wrapper for the C++
// `LineBedItem` (cxx/linebed_item.*, the project's first QQuickRhiItem).
// Block A4 of the 2026-08-15 immersive-completion contract (spec 01 §2.5).
//
// Its own file (not an inline Component in ShaderSceneLayer.qml) so the RHI
// item's contract is one screen of QML — and so the C++ type name only has
// to resolve when this file compiles, which the tier gate already ensures
// never happens where the RHI can't run it (the Loader in
// ShaderSceneLayer.qml activates only while `root.active && scene === 5`).
//
// THE PULSE LAW: the item repaints ONLY from `pulseTick()`, which
// ShaderSceneLayer calls on the shared shell pulse (QbzShell.pulseMs) with
// the ring publish it stashed on the viz tick. `heights` has no NOTIFY and
// nothing binds it; the palette colors ride QbzShell's ambient triad (the
// same ambient_qt source every other scene uses — they change on track
// change, not per tick).
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
LineBedItem {
id: root
// The empirical orientation matrix (2026-08-15, BOTH verified today):
// owner's Vulkan + mirror=false -> bed UPSIDE-DOWN
// Xvfb Mesa GL + mirror=true -> bed UPSIDE-DOWN (peaks hang down)
// So the correct rule is mirror on everything EXCEPT OpenGL — my first
// guess (GL-only) had it exactly backwards, and "unconditional" broke GL.
mirrorVertically: GraphicsInfo.api !== GraphicsInfo.OpenGL
// The ambient palette triad (shell_bridge.rs; pushed per track by
// playback_qt.rs). The Slint reference defaults #00dcc8 / #3fd9c8 live
// in the C++ member initializers, so a pre-art track still looks right.
primary: QbzShell.ambientPrimary
accent: QbzShell.ambientAccent
// Called by ShaderSceneLayer on the pulse edge ONLY. `h` is the stashed
// 256x200 f32 ring (QbzShaderScene.linebedHeights, raw bytes) or null
// when the viz drain published nothing new (player paused); the repaint
// still runs so the item tracks its parent's resizes/palette changes at
// the same cadence the sibling ShaderEffect scenes tick their clocks.
function pulseTick(h) {
if (h !== null)
heights = h
update()
}
}
@@ -0,0 +1,48 @@
// Plasma (immersive shader scene, mode 1) — the QML wrapper for the C++
// `PlasmaItem` (cxx/plasma_item.*, the feedback-fluid QQuickRhiItem).
// Block A2 of the 2026-08-15 immersive-completion contract (spec 01 §2.1).
//
// Its own file (the LineBedScene.qml idiom) so the RHI item's contract is
// one screen of QML — and so the C++ type name only has to resolve when
// this file compiles, which the tier gate already ensures never happens
// where the RHI can't run it (the Loader in ShaderSceneLayer.qml activates
// only while `root.active && scene === 1`).
//
// THE PULSE LAW: the item repaints ONLY from `pulseTick()`, which
// ShaderSceneLayer calls on the shared shell pulse (QbzShell.pulseMs) with
// the applied pack it stashed on the viz tick. The properties have no
// NOTIFY and nothing binds them; the palette colors ride QbzShell's ambient
// triad (they change on track change, not per tick).
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
PlasmaItem {
id: root
// mirrorVertically rule (2026-08-15, verified BOTH ways today): mirror
// on Vulkan/Metal/D3D, NOT on OpenGL — the empirical matrix lives in
// LineBedScene.qml (owner's Vulkan + no mirror = flipped; Mesa GL +
// mirror = flipped). The feedback passes are self-consistent in texture
// space; this only affects the composite.
mirrorVertically: GraphicsInfo.api !== GraphicsInfo.OpenGL
// Called by ShaderSceneLayer on the pulse edge ONLY. `p` carries the
// applied pack values + the palette + the local scene clock; assigning
// the MEMBER properties and repainting ONCE per pulse is the whole
// cadence contract (the sibling ShaderEffect scenes tick their clocks
// at the same edge).
function pulseTick(p) {
time = p.time
beat = p.beat
level = p.level
levelSmooth = p.levelSmooth
energyLo = p.energyLo
energyHi = p.energyHi
primary = p.primary
secondary = p.secondary
accent = p.accent
update()
}
}
@@ -0,0 +1,43 @@
// Spectral Ribbon (immersive shader scene, mode 4) — the QML wrapper for
// the C++ `RibbonItem` (cxx/ribbon_item.*, the persistent-spectrogram
// QQuickRhiItem). Block A3 of the 2026-08-15 immersive-completion contract
// (spec 01 §2.4).
//
// Its own file (the LineBedScene.qml idiom) so the RHI item's contract is
// one screen of QML — and so the C++ type name only has to resolve when
// this file compiles, which the tier gate already ensures never happens
// where the RHI can't run it (the Loader in ShaderSceneLayer.qml activates
// only while `root.active && scene === 4`).
//
// THE PULSE LAW: the item repaints ONLY from `pulseTick()`, which
// ShaderSceneLayer calls on the shared shell pulse (QbzShell.pulseMs) with
// the ribbon frame it stashed on the viz tick. `frame` has no NOTIFY and
// nothing binds it.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
RibbonItem {
id: root
// mirrorVertically rule (2026-08-15, verified BOTH ways today): mirror
// on Vulkan/Metal/D3D, NOT on OpenGL — the empirical matrix lives in
// LineBedScene.qml (owner's Vulkan + no mirror = flipped; Mesa GL +
// mirror = flipped). The feedback passes are self-consistent in texture
// space; this only affects the composite.
mirrorVertically: GraphicsInfo.api !== GraphicsInfo.OpenGL
// Called by ShaderSceneLayer on the pulse edge ONLY. `f` is the stashed
// 517-byte frame (QbzShaderScene.ribbonFrame, raw bytes) or null when
// the viz drain published nothing new (player paused); `peak` is the
// pack's energyHi (the real-time ceiling line). The repaint still runs
// on null so the item tracks resizes at the same cadence the sibling
// scenes tick their clocks.
function pulseTick(f, peak) {
if (f !== null)
frame = f
energyHi = peak
update()
}
}
@@ -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
}
}
@@ -0,0 +1,114 @@
// Spectral Ribbon overlay (immersive scene mode 4) — green axes + labels +
// the stream header, drawn OVER the RibbonItem's GPU spectrogram. QML port
// of qbz-ui's ImmersiveSpectralOverlay.slint (B-block idiom: everything is
// positioned as FRACTIONS of the viewport so the axes grow/shrink with the
// window). The plot rectangle MUST match spectral_ribbon.frag
// (PLOT_X0..Y1); the overlay fractions are the Slint TOP-DOWN ones
// (0.070/0.780 = 1 - the shader's 0.930/0.220).
//
// No MouseArea anywhere — input passes through to the player bar below
// (the Slint overlay's "plain Rectangle, no TouchArea" rule).
import QtQuick
import com.blitzfc.qbz
Item {
id: root
anchors.fill: parent
// Plot rect — the Slint overlay's fractions (see the header).
readonly property real plotX0: width * 0.055
readonly property real plotX1: width * 0.970
readonly property real plotY0: height * 0.070
readonly property real plotY1: height * 0.780
readonly property real plotW: plotX1 - plotX0
readonly property real plotH: plotY1 - plotY0
readonly property color axis: "#5fb87a"
// Nyquist in kHz (delivered rate / 2). Falls back to 22.05 (44.1k)
// when nothing is reported yet (npEffRateHz 0).
readonly property real nyqKhz: QbzPlayer.npEffRateHz > 0
? QbzPlayer.npEffRateHz / 2000.0
: 22.05
readonly property int dur: QbzPlayer.npDurationSecs
// --- Stream header (top center, green box) ---------------------------
Rectangle {
// The immersive search cortinilla lives at the same height in the
// header row — while the search is open it wins the space (owner
// smoke 2026-08-15: they overlapped).
visible: !QbzImmersive.immSearchOpen
x: (root.width - width) / 2
y: root.height * 0.045
width: hdr.implicitWidth + 28
height: 22
radius: 5
border.width: 1
border.color: root.axis
color: "#e60a140d"
Text {
id: hdr
anchors.centerIn: parent
text: "Stream 1/1: Audio Stream, " + QbzPlayer.npEffRateHz + " Hz, "
+ QbzPlayer.npEffBits + " bits, FFT:4096, Bands:512"
color: root.axis
font.pixelSize: 12
}
}
// --- Axis frame (L-shape) --------------------------------------------
Rectangle { // vertical (frequency)
x: root.plotX0
y: root.plotY0
width: 1
height: root.plotH
color: root.axis
}
Rectangle { // horizontal (time)
x: root.plotX0
y: root.plotY1
width: root.plotW
height: 1
color: root.axis
}
// --- Frequency axis labels (Y, left) — 0 .. Nyquist ------------------
Repeater {
model: [0.0, 0.25, 0.5, 0.75, 1.0]
Text {
required property real modelData
x: root.plotX0 - 46
y: root.plotY1 - modelData * root.plotH - height / 2
width: 40
horizontalAlignment: Text.AlignRight
text: Math.round(root.nyqKhz * modelData) + "k"
color: root.axis
font.pixelSize: 11
}
}
// --- Time axis labels (X, bottom) — 30 s ticks -----------------------
Repeater {
model: 31
Text {
required property int index
visible: root.dur > 0 && (index * 30) <= root.dur
x: root.plotX0 + (root.dur > 0 ? root.plotW * ((index * 30) / root.dur) : 0) - width / 2
y: root.plotY1 + 6
text: Math.floor((index * 30) / 60) + ":"
+ ((index * 30) % 60 < 10 ? "0" : "") + ((index * 30) % 60)
color: root.axis
font.pixelSize: 10
}
}
// Final duration marker at the right edge.
Text {
visible: root.dur > 0
x: root.plotX1 - width / 2
y: root.plotY1 + 6
text: Math.floor(root.dur / 60) + ":"
+ (root.dur % 60 < 10 ? "0" : "") + (root.dur % 60)
color: root.axis
font.pixelSize: 10
}
}
+14 -1
View File
@@ -89,6 +89,19 @@ Rectangle {
readonly property real artSize: Math.max(160,
Math.min(Math.min(root.height * 0.45, root.width * 0.5), 360))
// D2 (contract 04 §4): size-aware large art — this card draws up to
// 360 CSS px, past what the thumbnail-tier small feed covers. Same
// bucketed request + fallback binding as StaticPanel/AlbumReactivePanel.
readonly property string artSource: QbzPlayer.npArtworkPathLarge !== ""
? QbzPlayer.npArtworkPathLarge : QbzPlayer.npArtworkPath
function requestArtSize() {
if (root.visible)
QbzPlayer.requestNpArtworkSize(Math.round(root.artSize))
}
onArtSizeChanged: requestArtSize()
onVisibleChanged: requestArtSize()
Component.onCompleted: requestArtSize()
Item {
anchors.fill: parent
anchors.topMargin: 70
@@ -122,7 +135,7 @@ Rectangle {
x: (parent.width - width) / 2
y: (parent.height - height) / 2
radius: 8
source: QbzPlayer.npArtworkPath
source: root.artSource
}
}
+23 -2
View File
@@ -28,6 +28,27 @@ import "../theme"
Item {
id: root
// D2 (contract 04 §4): size-aware large art. `artSource` prefers the
// size-resolved large feed and falls back to the small one while it
// resolves (the Slint `artwork-large : artwork` pattern); the probe reads
// the SAME source so the native cap tracks the image actually shown.
readonly property string artSource: QbzPlayer.npArtworkPathLarge !== ""
? QbzPlayer.npArtworkPathLarge : QbzPlayer.npArtworkPath
// The baseArt slot expression WITHOUT the native cap — the cap stays the
// final upscale safety; the request must reflect the SLOT so the feed can
// serve a variant big enough for it.
readonly property real artSlot: Math.max(180,
Math.min(Math.min(root.height - 364, root.width * 0.62), 640))
// Bucketed in Rust (one re-resolve per variant tier crossed, none per
// pixel); gated on visible so a hidden panel writes nothing (pulse law).
function requestArtSize() {
if (root.visible)
QbzPlayer.requestNpArtworkSize(Math.round(root.artSlot))
}
onArtSlotChanged: requestArtSize()
onVisibleChanged: requestArtSize()
Component.onCompleted: requestArtSize()
// Native source resolution — cap so the cover is never UPSCALED beyond
// its own size (:46-49). Hidden probe on the same file:// cache path
// (shared image cache; see AlbumReactivePanel.qml). Empty art -> 0 ->
@@ -35,7 +56,7 @@ Item {
Image {
id: artProbe
visible: false
source: QbzPlayer.npArtworkPath
source: root.artSource
}
readonly property real srcNative: artProbe.status === Image.Ready
? artProbe.implicitWidth : 0
@@ -88,7 +109,7 @@ Item {
x: (parent.width - width) / 2
y: (parent.height - height) / 2
radius: 8
source: QbzPlayer.npArtworkPath
source: root.artSource
}
}
@@ -0,0 +1,135 @@
// Tunnel Flow (immersive shader scene, mode 8 — Qt-only) — the QML wrapper
// for the C++ `TunnelFlowItem` (cxx/tunnelflow_item.*, the feedback-tunnel
// QQuickRhiItem). Block B1 of the 2026-08-15 immersive-completion contract
// (spec 02-tauri-tunnel-port.md): the legacy Tauri Canvas2D panel
// (TunnelFlowPanel.svelte) rewritten as qml/assets/shaders/tunnel_flow.frag.
//
// Its own file (the PlasmaScene.qml idiom) so the RHI item's contract is one
// screen of QML — and so the C++ type name only has to resolve when this
// file compiles (the Loader in ShaderSceneLayer.qml activates only while
// `root.active && scene === 8`).
//
// THE PULSE LAW: the item repaints ONLY from `pulseTick()`, which
// ShaderSceneLayer calls on the shared shell pulse (QbzShell.pulseMs) with
// the local scene clock and the Viz16 bars it stashed on the viz tick. The
// properties have no NOTIFY and nothing binds them.
//
// THE AUDIO STATE LIVES HERE (spec 02 §2): Tunnel Flow consumes the 16-band
// Viz16 stream, NOT the shader pack. This wrapper ports the Tauri frontend
// accumulators verbatim and runs them ONCE per pulse:
// - smoothing s = s*0.72 + new*0.28 per band (SMOOTHING = 0.72);
// - bass = mean(bands 0..3), mid = mean(4..9), high = mean(10..15);
// - kick detector: bassDelta > 0.05 || highDelta > 0.05 || bass > 0.7 ->
// kickPulse = clamp01(0.24 + bass*0.52 + high*0.34 + max(0,bassDelta)*1.8),
// then kickPulse *= 0.9 per frame;
// - the TAURI phase accumulator phase += 0.012 + bass*0.026 + high*0.014 +
// kickPulse*0.018 — deliberately SEPARATE from the pack's phase.
// The reference detects the kick on the 30 Hz viz event and decays per
// requestAnimationFrame; here both run on the ~30 Hz pulse — the same
// cadence, one accumulator site.
//
// THE PALETTE rides QbzShaderScene.tunnelPaletteJson (the tunnelflow_qt.rs
// port of extractLinePaletteFromArtwork, published per track): stashed on
// the notify (zero-dirty — nothing binds the stash), applied on the pulse
// edge like every other uniform.
import QtQuick
import QtQuick.Window
import com.blitzfc.qbz
TunnelFlowItem {
id: root
// mirrorVertically rule (2026-08-15, verified BOTH ways today): mirror
// on Vulkan/Metal/D3D, NOT on OpenGL — the empirical matrix lives in
// LineBedScene.qml (owner's Vulkan + no mirror = flipped; Mesa GL +
// mirror = flipped). The feedback passes are self-consistent in texture
// space; this only affects the composite.
mirrorVertically: GraphicsInfo.api !== GraphicsInfo.OpenGL
// Fade-in 240 ms on activation (spec 02 §5; TunnelFlowPanel.svelte
// :1005-1024) — a TRANSIENT one-shot, not a continuous animator, so the
// pulse law allows it.
opacity: 0
Component.onCompleted: fadeIn.start()
NumberAnimation {
id: fadeIn
target: root
property: "opacity"
from: 0
to: 1
duration: 240
}
// --- The JS audio accumulators (see the header) -------------------------
property var smooth: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
property real tfPhase: 0
property real kickPulse: 0
property real prevBass: 0
property real prevHigh: 0
// The palette stash (zero-dirty, the pendingPack pattern).
property var paletteStash: null
Connections {
target: QbzShaderScene
function onTunnelPaletteJsonChanged() {
try {
root.paletteStash = JSON.parse(QbzShaderScene.tunnelPaletteJson)
} catch (e) {
// A malformed palette keeps the previous colors.
}
}
}
// Called by ShaderSceneLayer on the pulse edge ONLY. `timeS` is the
// layer's local pulse clock (seconds); `bars` is the stashed Viz16 array
// or null when the viz drain published nothing new (player paused) — the
// repaint still runs so the feedback decay carries the motion (the idle
// drift: phase advances 0.012/pulse minimum, spec 02 §6).
function pulseTick(timeS, bars) {
var s = root.smooth
if (bars !== null && bars.length >= 16) {
for (var i = 0; i < 16; i++)
s[i] = s[i] * 0.72 + bars[i] * 0.28
// The kick detector fires on FRESH viz data only (the reference
// runs it in the viz-event handler): with the feed parked the
// deltas are stale and a held bass > 0.7 would retrigger forever.
var bass = (s[0] + s[1] + s[2] + s[3]) / 4
var high = (s[10] + s[11] + s[12] + s[13] + s[14] + s[15]) / 6
var bassDelta = bass - root.prevBass
var highDelta = high - root.prevHigh
root.prevBass = bass
root.prevHigh = high
if (bassDelta > 0.05 || highDelta > 0.05 || bass > 0.7) {
root.kickPulse = Math.max(0, Math.min(1,
0.24 + bass * 0.52 + high * 0.34 + Math.max(0, bassDelta) * 1.8))
}
}
var bass = (s[0] + s[1] + s[2] + s[3]) / 4
var mid = (s[4] + s[5] + s[6] + s[7] + s[8] + s[9]) / 6
var high = (s[10] + s[11] + s[12] + s[13] + s[14] + s[15]) / 6
// The palette, applied on the pulse edge like the uniforms.
var pal = root.paletteStash
if (pal !== null && pal.length >= 4) {
palette0 = pal[0]
palette1 = pal[1]
palette2 = pal[2]
palette3 = pal[3]
}
// Uniforms use the CURRENT phase/kick (the reference draws, then
// advances the accumulators at the end of render()).
time = timeS
phase = root.tfPhase
root.bass = bass
root.mid = mid
root.high = high
kick = root.kickPulse
root.tfPhase += 0.012 + bass * 0.026 + high * 0.014 + root.kickPulse * 0.018
root.kickPulse *= 0.9
update()
}
}
+4 -4
View File
@@ -192,10 +192,10 @@ Item {
}
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"
// 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 ---------------------------------------------------
+10 -12
View File
@@ -197,10 +197,10 @@ Rectangle {
// .qsb baked from qml/assets/shaders/spectrum_bars.frag — see the header.
fragmentShader: "../assets/shaders/spectrum_bars.frag.qsb"
// Shared default-equivalent vertex stage (Qt 6.11's
// built-in default emits no qt_TexCoord0, so the link
// fails without an explicit one).
vertexShader: "../assets/shaders/spectrum.vert.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 spectrum_bars.vert).
vertexShader: "../assets/shaders/spectrum_bars.vert.qsb"
}
// FALLBACK ARM — the pre-F3 delegates, verbatim. Geometry: x =
@@ -329,10 +329,9 @@ Rectangle {
// .qsb baked from qml/assets/shaders/spectrum_wave.frag — see the header.
fragmentShader: "../assets/shaders/spectrum_wave.frag.qsb"
// Shared default-equivalent vertex stage (Qt 6.11's
// built-in default emits no qt_TexCoord0, so the link
// fails without an explicit one).
vertexShader: "../assets/shaders/spectrum.vert.qsb"
// Paired vertex stage — same uniform block in both stages
// (the GL link requires it; see spectrum_wave.vert).
vertexShader: "../assets/shaders/spectrum_wave.vert.qsb"
}
// FALLBACK ARM — the pre-F3 delegates, verbatim. Geometry: x =
@@ -403,10 +402,9 @@ Rectangle {
// .qsb baked from qml/assets/shaders/spectrum_energy.frag — see the header.
fragmentShader: "../assets/shaders/spectrum_energy.frag.qsb"
// Shared default-equivalent vertex stage (Qt 6.11's
// built-in default emits no qt_TexCoord0, so the link
// fails without an explicit one).
vertexShader: "../assets/shaders/spectrum.vert.qsb"
// Paired vertex stage — same uniform block in both stages
// (the GL link requires it; see spectrum_energy.vert).
vertexShader: "../assets/shaders/spectrum_energy.vert.qsb"
}
// FALLBACK ARM — the pre-F3 delegates, verbatim. Geometry: x =