feat(qt): immersive shader scenes — RHI items, scene menu, GL link pairing
Port the immersive GPU scenes: Plasma, Tunnel, Aurora, Ambient, Spectral Ribbon and Line Bed as QQuickRhiItems/ShaderEffects under a pulse-driven ShaderSceneLayer, plus the Qt-only Tunnel Flow (Tauri Canvas2D port) fed by the Viz16 stream. Scene rows live in the header view menu (no Off row); g cycles the ring. A fresh immersive open defaults to Split > Lyrics (owner ruling). OpenGL link fix (owner smoke: Tunnel black with "uniform _22 declared as type buf and type buf"): every ShaderEffect fragment with its own uniform layout now ships a paired vertex stage declaring the identical block — the shared spectrum.vert only ever linked under Vulkan. RHI hardening: render pass descriptors on the ping-pong targets (the Tunnel Flow segfault / Plasma dead window), mirrorVertically tied to the backend (!== OpenGL), ribbon sub-uploads carry setSourceSize. km
This commit is contained in:
74 files changed
+6036
-111
No files matched your search
@@ -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);
|
||||
}
|
||||
Binary file not shown.
@@ -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);
|
||||
}
|
||||
Binary file not shown.
@@ -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);
|
||||
}
|
||||
Binary file not shown.
@@ -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;
|
||||
}
|
||||
Binary file not shown.
@@ -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;
|
||||
}
|
||||
Binary file not shown.
@@ -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;
|
||||
}
|
||||
Binary file not shown.
@@ -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);
|
||||
}
|
||||
Binary file not shown.
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ----------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 ---------------------------------------------------
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in new issue
Block a user