From 931cb06d3883a6f7de5adc09e1b00c967f26368b Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:53:57 -0600 Subject: [PATCH] feat(qt): ease the visualizers on the pulse, and make Plasma react MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TWO FINDINGS, both of which falsified something that was being assumed. FLUIDITY IS NOT FRAME RATE. Driven at QBZ_PULSE_MS=10 the shell presented ~100 frames/s — qt-batches confirmed it, and the script now prints the pulse it ran with so two logs cannot be confused — and the bars looked exactly as they had at 30. They would: every published frame was applied as a STEP, so the extra presents repainted the same discrete values three times over. What the Slint build does differently is not rate, it is `animate bar-h { 90ms }` — it moves BETWEEN values. So the interpolation is back, ON THE PULSE, and it costs nothing. The old interpolator was expensive because of its private 16 ms Timer, not because it interpolated: it presented at its own rate, unsynchronised with everything else. Easing on the pulse presents exactly as often as stepping on the pulse did — one repaint per period either way, only the value differs. Self-parking survives: a converged ease writes nothing and schedules no repaint. The price is lag, and it is the honest one — k = 0.45 is ~83% there in three pulses (~100 ms), matching the reference's 90 ms, against 46 ms of FFT window and 77 ms of Rust smoothing already in the chain. The from/to/progress triple is gone with it, and its consumers moved to `cur`. Note for the next reader: `SpectrumBand` drove all three of its modes from `Connections` on those properties, and a Connections handler naming a property that no longer exists is NOT a compile error — QML warns at runtime and simply never fires. That is how the whole band went dead at once; grep for `onChanged` when removing a property, not only for direct reads. PLASMA WAS DRIVEN BY THE SLOWEST SIGNALS IN THE PIPELINE while the fast ones travelled beside it unconnected. `transientAmp`, `bandsLo` and `bandsHi` were published, parsed and stored, and never handed to the item — the shader declared them and received zeros — so everything ran off the energy bands, which decay x0.85 per frame (~473 ms) against the bars' x0.65 (~178 ms). Worse, `beat` stops being a beat on dense material: it decays x0.88 per tick, so above ~3 hits/s it never falls far enough to come back as a spike and settles into a shallow ripple around 0.7. A splat multiplied by it becomes a steady glow, and the busier the music the flatter the picture — the most frenetic input produced the least motion. `beatAc` subtracts a slow density floor, so a hit reads as a hit at any tempo. The scene itself was rebuilt around the reference the owner filmed (Butterchurn on a percussion-heavy track). Its recipe is three parts: one thin annulus injected per frame that the feedback warp marches inward, so the concentric rings ARE the last N frames of audio laid out in space; retention high enough for that to stack (this held 0.85, where a trail died in ~15 frames and nothing ever accumulated); and angular structure from reading the spectrum BY ANGLE. But a CLOSED ring was wrong, and the owner named why: a perfect inner circumference reads as a drawn shape, hard-edges the field, and at this retention successive rings pack into one solid disc — which is what killed the ambient feel. The annulus is gated by angle now, so a hit paints arcs the conveyor carries inward, the circle never closes, and the gaps stay open for the background. Noise bends, frays and thins those arcs so they are made of plasma rather than of compass. The worms are back at full strength with tighter cores and a contrast curve — a raw gaussian is all shoulder and reads as haze — and their orbits now reach past the arc band instead of being trapped inside it. Colour comes from ONE album-derived triad, so everything used to be a shade of the same hue; a Rodrigues hue turn splits it into worms, arcs and an ambient wash that sit apart whatever album is playing. That wash is what stops the background being void for the foreground to be a cut-out against. A soft limiter replaces the hard clamp: at 0.945 retention a clamp pins the middle to white and the structure vanishes. Release build clean, offscreen smoke green, 421/421. cc --- crates/qbz-qt/cxx/plasma_item.cpp | 9 + crates/qbz-qt/cxx/plasma_item.h | 16 ++ crates/qbz-qt/qml/assets/shaders/plasma.frag | 249 +++++++++++++++--- .../qbz-qt/qml/assets/shaders/plasma.frag.qsb | Bin 8849 -> 15103 bytes .../qml/immersive/AlbumReactivePanel.qml | 7 +- crates/qbz-qt/qml/immersive/PlasmaScene.qml | 17 +- .../qbz-qt/qml/immersive/ShaderSceneLayer.qml | 10 + crates/qbz-qt/qml/shell/SpectrumBand.qml | 30 ++- crates/qbz-qt/qml/shell/VizSettle.qml | 105 ++++++-- crates/qbz-qt/src/viz_qt.rs | 33 ++- scripts/qt-batches.sh | 12 +- 11 files changed, 414 insertions(+), 74 deletions(-) diff --git a/crates/qbz-qt/cxx/plasma_item.cpp b/crates/qbz-qt/cxx/plasma_item.cpp index 94627463a..337b1682d 100644 --- a/crates/qbz-qt/cxx/plasma_item.cpp +++ b/crates/qbz-qt/cxx/plasma_item.cpp @@ -125,6 +125,9 @@ public: m_levelSmooth = pl->m_levelSmooth; m_energyLo = pl->m_energyLo; m_energyHi = pl->m_energyHi; + m_transientAmp = pl->m_transientAmp; + m_bandsLo = pl->m_bandsLo; + m_bandsHi = pl->m_bandsHi; m_primary = colorVec(pl->m_primary); m_secondary = colorVec(pl->m_secondary); m_accent = colorVec(pl->m_accent); @@ -167,8 +170,11 @@ public: ud.res[0] = float(hist.width()); ud.res[1] = float(hist.height()); ud.levelSmooth = m_levelSmooth; + ud.transient = m_transientAmp; std::memcpy(ud.energyLo, &m_energyLo, sizeof(ud.energyLo)); std::memcpy(ud.energyHi, &m_energyHi, sizeof(ud.energyHi)); + std::memcpy(ud.bandsLo, &m_bandsLo, sizeof(ud.bandsLo)); + std::memcpy(ud.bandsHi, &m_bandsHi, sizeof(ud.bandsHi)); std::memcpy(ud.primary, &m_primary, sizeof(ud.primary)); std::memcpy(ud.secondary, &m_secondary, sizeof(ud.secondary)); std::memcpy(ud.accent, &m_accent, sizeof(ud.accent)); @@ -389,6 +395,9 @@ private: float m_levelSmooth = 0.0f; QVector4D m_energyLo{ 0.0f, 0.0f, 0.0f, 0.0f }; QVector4D m_energyHi{ 0.0f, 0.0f, 0.0f, 0.0f }; + float m_transientAmp = 0.0f; + QVector4D m_bandsLo{ 0.0f, 0.0f, 0.0f, 0.0f }; + QVector4D m_bandsHi{ 0.0f, 0.0f, 0.0f, 0.0f }; QVector4D m_primary{ 0.0f, 0.863f, 0.784f, 1.0f }; QVector4D m_secondary{ 0.588f, 0.196f, 1.0f, 1.0f }; QVector4D m_accent{ 0.247f, 0.851f, 0.784f, 1.0f }; diff --git a/crates/qbz-qt/cxx/plasma_item.h b/crates/qbz-qt/cxx/plasma_item.h index 256776d88..48a4c52e5 100644 --- a/crates/qbz-qt/cxx/plasma_item.h +++ b/crates/qbz-qt/cxx/plasma_item.h @@ -26,6 +26,19 @@ class PlasmaItem : public QQuickRhiItem Q_PROPERTY(float levelSmooth MEMBER m_levelSmooth) Q_PROPERTY(QVector4D energyLo MEMBER m_energyLo) Q_PROPERTY(QVector4D energyHi MEMBER m_energyHi) + // THE FAST HALF OF THE PACK, which used to arrive zeroed. + // + // `plasma.frag` has always declared `transientAmp`, `bandsLo` and + // `bandsHi` in its std140 block and the pack has always published them — + // `ShaderSceneLayer` even parses and stores all three. They simply never + // reached this item, so the shader read zeros and ran entirely off the + // ENERGY bands, which decay x0.85 per frame (~473 ms to a tenth) against + // the bars' x0.65 (~178 ms). Plasma was being driven by the slowest + // signals in the pipeline while the fast ones travelled beside them + // unconnected. + Q_PROPERTY(float transientAmp MEMBER m_transientAmp) + Q_PROPERTY(QVector4D bandsLo MEMBER m_bandsLo) + Q_PROPERTY(QVector4D bandsHi MEMBER m_bandsHi) Q_PROPERTY(QColor primary MEMBER m_primary) Q_PROPERTY(QColor secondary MEMBER m_secondary) Q_PROPERTY(QColor accent MEMBER m_accent) @@ -44,6 +57,9 @@ public: float m_levelSmooth = 0.0f; QVector4D m_energyLo{ 0.0f, 0.0f, 0.0f, 0.0f }; QVector4D m_energyHi{ 0.0f, 0.0f, 0.0f, 0.0f }; + float m_transientAmp = 0.0f; + QVector4D m_bandsLo{ 0.0f, 0.0f, 0.0f, 0.0f }; + QVector4D m_bandsHi{ 0.0f, 0.0f, 0.0f, 0.0f }; QColor m_primary{ 0, 220, 200 }; QColor m_secondary{ 150, 50, 255 }; QColor m_accent{ 63, 217, 200 }; diff --git a/crates/qbz-qt/qml/assets/shaders/plasma.frag b/crates/qbz-qt/qml/assets/shaders/plasma.frag index c2ca00125..2d2e20f36 100644 --- a/crates/qbz-qt/qml/assets/shaders/plasma.frag +++ b/crates/qbz-qt/qml/assets/shaders/plasma.frag @@ -73,68 +73,243 @@ vec2 curl_noise(vec2 p) { return vec2(dy, -dx) / (2.0 * e); } +// Rodrigues rotation about the grey axis — a hue turn that needs no RGB<->HSV +// round trip. The palette arrives as ONE album-derived triad, so without this +// every element in the scene is a shade of the same colour; turning a copy of +// it is how the worms and the beat arcs end up in different families whatever +// album is playing. +vec3 hueRot(vec3 c, float a) { + const vec3 k = vec3(0.57735026); + float ca = cos(a); + float sa = sin(a); + return c * ca + cross(k, c) * sa + k * dot(k, c) * (1.0 - ca); +} + // 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)); } +// -- WHY THIS LOOKS THE WAY IT DOES (2026-08-19 rework) -------------------- +// +// The reference is Butterchurn (MilkDrop in JS) on a percussion-heavy track, +// and the frame-by-frame gives the recipe in three parts: +// +// 1. CONCENTRIC RINGS. They are not drawn as rings. ONE thin annulus is +// injected per frame, and the feedback warp marches it inward — so the +// rings you see ARE the last N frames of audio, laid out in space. Time +// becomes geometry. That only works if a trail survives long enough to +// stack, which is the second part. +// 2. HIGH RETENTION. MilkDrop presets hold ~0.97+ per frame. This shader +// held 0.85: a trail died in ~15 frames, so nothing ever accumulated and +// four soft blobs orbited an empty field — "a Windows 98 wallpaper", +// accurately. +// 3. ANGULAR STRUCTURE. The spokes come from reading the spectrum BY ANGLE, +// so each direction carries its own band, and from folding the plane into +// mirrored sectors. +// +// Deliberately NOT here: a preset engine, per-preset equations, custom +// shapes/waves. This is Plasma made intense, not MilkDrop reimplemented. + +// The spectrum sampled AT AN ANGLE (0..1 around the circle), interpolated +// between the 8 bands so a spoke sweeps rather than steps. This is what turns +// a flat field into radial structure. +float bandAt(float a01, vec4 lo, vec4 hi) { + float x = fract(a01) * 8.0; + int i = int(x) % 8; + int j = (i + 1) % 8; + float f = fract(x); + float b[8] = float[8](lo.x, lo.y, lo.z, lo.w, hi.x, hi.y, hi.z, hi.w); + return mix(b[i], b[j], f * f * (3.0 - 2.0 * f)); +} + 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); + // SLOW (energy, x0.85/frame ~ 473 ms) drives STRUCTURE; FAST (bands, + // x0.65 ~ 178 ms) drives MOTION. The bands used to arrive zeroed — they + // were published and parsed but never handed to this item — which is why + // everything ran off the slowest signals in the pipeline. + 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 pres = clamp(u.energyLo.w, 0.0, 1.0); + float air = clamp(u.energyHi.x, 0.0, 1.0); + + vec4 fLo = clamp(u.bandsLo, vec4(0.0), vec4(1.0)); + vec4 fHi = clamp(u.bandsHi, vec4(0.0), vec4(1.0)); + // Sharpened copies: squaring drops the quiet floor without touching the + // peaks, so a dense mix reads as PEAKS instead of a lit plateau. + vec4 sLo = fLo * fLo; + vec4 sHi = fHi * fHi; + + // `beat` is the AC-COUPLED onset (PlasmaScene.qml): 0 at the local density + // floor, 1 on a hit above it, so it still reads as a hit at any tempo. + // `transientAmp` is the raw, shorter spike — the crack over the punch. + float beat = clamp(u.beat, 0.0, 1.0); + float hit = clamp(u.transientAmp, 0.0, 1.0); float level = clamp(u.level, 0.0, 1.0); - float lvl = clamp(u.levelSmooth, 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 r = length(c); + float ang = atan(c.y, c.x); - float pres = clamp(u.energyLo.w, 0.0, 1.0); + // TWO COLOUR FAMILIES out of the one album triad. The worms keep the + // palette as published; the beat arcs are turned ~130 degrees off it and + // the ambient wash sits opposite both. One triad rendered everything in a + // single hue before this — the scene was a teal disc with a purple hairline + // and nothing else. + vec3 wormA = u.primary.rgb; + vec3 wormB = u.secondary.rgb; + vec3 arcA = hueRot(u.accent.rgb, 2.27); + vec3 arcB = hueRot(u.secondary.rgb, 1.85); + vec3 ambA = hueRot(u.primary.rgb, 3.9); - // === 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); + // === Feedback advection — the ring conveyor ============================ + // A firm inward march: this is what turns one injected annulus per frame + // into a stack of concentric rings. Bass widens the step (the tunnel + // breathes) and an onset shoves it. + float zoom = 1.018 + (sub + bass) * 0.020 + beat * 0.030 + hit * 0.014; + // Rotation on the FAST mid — the term that follows a line instead of + // averaging it — plus a radius-dependent shear so the rings twist into + // spirals rather than sitting concentric and dead. + float rot = (0.004 + mids * 0.020 + sLo.w * 0.045) * sin(t * 0.4 + r * 5.0) + + beat * 0.05; + float ca = cos(rot); + float sa = sin(rot); 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); + // Curl octaves keep the rings from being perfect circles — the lattice + // texture in the reference. Coarse follows fast presence, fine follows + // fast brilliance, so filaments appear ON the attack. + w += curl_noise(c * 2.5 + vec2(t * 0.12, -t * 0.10)) * (0.002 + sHi.y * 0.014); + w += curl_noise(c * 6.5 - vec2(t * 0.09, t * 0.08)) * (0.001 + sHi.z * 0.010); 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; + // RETENTION — the single biggest change. 0.85 -> 0.945..0.975. Below ~0.93 + // nothing stacks and there is no structure to look at; the loud end stays + // short of 1.0 so the field always eventually forgets. + field *= 0.945 + lvl * 0.030; - // === 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); + // === Mirrored sectors — the kaleidoscope =============================== + // Fold the angle into N mirrored wedges. The count rides the SLOW mids so + // it changes with the section instead of flickering per frame. + float sectors = floor(5.0 + mids * 5.0); + float aFold = abs(fract(ang / 6.2831853 * sectors) * 2.0 - 1.0); - // === 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; + // === BEAT ARCS — not a circumference =================================== + // The Tauri reference this borrows from was BEAT DETECTION, and a closed + // ring is not that: a perfect inner circumference reads as a drawn shape, + // hard-edges the field, and at this retention the successive rings packed + // into one solid disc — which is exactly what killed the ambient feel. + // + // So the annulus is GATED BY ANGLE. Only the sectors whose gate opens emit, + // so a hit paints a few arcs that the conveyor then carries inward; the + // circle never closes and the space between arcs stays open for the + // background to show through. The gate threshold falls as the onset rises, + // so a big hit opens more of the circle and a quiet passage barely any. + float gateN = vnoise(vec2(ang * 2.9 - 3.0, t * 0.6)); + float gate = smoothstep(0.72 - beat * 0.34, 0.86 - beat * 0.30, gateN); - // Treble shimmer on the crests. + float ringR = 0.20 + bass * 0.10 + beat * 0.18 + sub * 0.06; + float spoke = bandAt(ang / 6.2831853 + 0.5 + t * 0.02, fLo, fHi); + float sharp = spoke * spoke; + + // MADE OF PLASMA, NOT OF COMPASS: a slow wobble bends the arc off-round, a + // fast one frays its edge, and the thickness breathes around the circle so + // it is never a uniform band. + float wobLo = vnoise(vec2(ang * 1.7, t * 0.30)) - 0.5; + float wobHi = vnoise(vec2(ang * 6.5, t * 0.90)) - 0.5; + ringR += wobLo * (0.045 + mids * 0.070) + wobHi * (0.010 + sHi.x * 0.024); + float ringW = 0.005 + level * 0.008 + hit * 0.010; + ringW *= 0.55 + vnoise(vec2(ang * 3.1 + 11.0, t * 0.55)) * 1.1; + float ring = exp(-pow(r - ringR, 2.0) / max(ringW * ringW, 1e-5)); + // Injection is well down from the closed-ring version: it no longer has to + // carry the scene, and at 0.945 retention a strong one silts up into a disc. + field += mix(arcA, arcB, aFold) * ring * gate * (0.04 + sharp * 0.75 + beat * 0.65); + + // The outer filigree, gated the same way and on the high bands. + float gate2 = smoothstep(0.66, 0.88, vnoise(vec2(ang * 4.7 + 9.0, t * 0.85))); + float ring2R = 0.40 + air * 0.10 + sHi.w * 0.08 + + (vnoise(vec2(ang * 3.3 + 21.0, t * 0.45)) - 0.5) * 0.060; + float ring2 = exp(-pow(r - ring2R, 2.0) / 1.225e-5); + field += arcB * ring2 * gate2 * (sHi.x * 0.8 + sHi.z * 0.6); + + // === THE WORMS — all four emitters, back at full strength ============== + // The filaments are these blobs stretched by the curl advection over many + // frames. The rework demoted them to faint accents and the rings then + // buried them; they are the ORIGINAL character of this scene and they run + // at full amplitude again. The higher retention actually makes their + // trails longer than they ever were — the only thing that had gone wrong + // was how little ink they were injecting. + // + // Each orbit angle carries a fast band term, so a worm accelerates on its + // own voice instead of all four drifting at one rate. + float p1 = t * 0.50 + sLo.z * 2.4; + float p2 = -t * 0.42 + 2.1 - sLo.w * 2.0; + float p3 = t * 0.74 + 1.0 + sHi.y * 2.8; + float p4 = t * 0.33 + 4.0 + sHi.z * 3.0; + // ORBITS REACH PAST THE ARCS on purpose. They used to sit inside the ring + // radius, so the worms were trapped in the disc and never crossed its edge; + // now they run from ~0.30 out to ~0.62, i.e. through the arc band and + // beyond it, and the field outside the arcs stops being empty. + vec2 e1 = vec2(sin(p1), cos(p1 * 1.2)) * (0.34 + fLo.y * 0.24); + vec2 e2 = vec2(sin(p2), cos(p2 * 0.9)) * (0.44 + sLo.w * 0.20); + vec2 e3 = vec2(sin(p3), cos(-p3 * 0.9 + 3.0)) * (0.30 + sHi.y * 0.26); + vec2 e4 = vec2(cos(p4), sin(p4 * 1.75 + 0.5)) * (0.50 + sHi.z * 0.20); + // DEFINITION: tighter cores and a smoothstep contrast curve. A raw gaussian + // is all shoulder — it reads as haze at any brightness. Squeezing the + // falloff and then pushing the mid-range gives an edge the advection can + // stretch into an actual filament instead of a smear. + float w1 = blob(c, e1, 0.022 + fLo.y * 0.024); + float w2 = blob(c, e2, 0.024 + sLo.w * 0.022); + float w3 = blob(c, e3, 0.018 + sHi.y * 0.020); + float w4 = blob(c, e4, 0.015 + sHi.z * 0.018); + w1 = smoothstep(0.06, 0.75, w1); + w2 = smoothstep(0.06, 0.75, w2); + w3 = smoothstep(0.05, 0.70, w3); + w4 = smoothstep(0.05, 0.70, w4); + field += wormA * w1 * (0.16 + sLo.x * 0.95); + field += wormB * w2 * (0.16 + sLo.z * 0.90); + field += hueRot(wormA, 0.9) * w3 * (0.13 + sHi.x * 0.95); + field += hueRot(wormB, -0.8) * w4 * (0.11 + sHi.z * 0.95); + + // === Onset flash — a bright core the conveyor drags outward ============ + // Injected at the CENTRE on purpose: the inward warp then carries it as a + // shrinking ring, so every hit leaves a visible wavefront. + float b1 = 0.03 + beat * 0.09; + float b2 = 0.014 + hit * 0.04; + field += u.accent.rgb * exp(-r * r / max(b1 * b1, 1e-5)) * beat * 1.9; + field += u.secondary.rgb * exp(-r * r / max(b2 * b2, 1e-5)) * hit * 1.4; + + // === AMBIENT WASH — the background stops being black =================== + // A slow, low-amplitude curl field across the WHOLE frame, in the hue + // turned furthest from both foreground families. Two jobs: it gives the + // arcs and worms something to read against (they were bright objects on + // void, which is what made the disc look like a cut-out), and it fills the + // space the arc gate deliberately leaves open. It is deliberately dim and + // slow — this is atmosphere, not a fourth animated element competing for + // attention. + vec2 aw = c * 1.6 + vec2(t * 0.045, -t * 0.038); + float amb = vnoise(aw + curl_noise(aw) * 0.35); + float vign = 1.0 - smoothstep(0.30, 0.95, r); + field += ambA * (0.030 + lvl * 0.055) * (0.35 + amb * 0.65) * (0.25 + vign * 0.75); + + // Treble sparkle on the crests — fast brilliance, so it fires with the + // cymbals instead of glowing with the average. 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 += u.accent.rgb * (sHi.z * 0.26 + sHi.w * 0.18) * smoothstep(0.35, 0.9, luma); - field = clamp(field, vec3(0.0), vec3(1.3)); + // SOFT LIMITER, and it is load-bearing at this retention. A hard clamp + // against 0.945+ feedback pins the middle to white and the structure + // disappears into a flat blob; this compresses instead, so bright areas + // keep their gradient and the rings stay readable through a loud passage. + field = field / (vec3(1.0) + field * 0.35); + field = clamp(field, vec3(0.0), vec3(1.0)); fragColor = vec4(field, 1.0); } diff --git a/crates/qbz-qt/qml/assets/shaders/plasma.frag.qsb b/crates/qbz-qt/qml/assets/shaders/plasma.frag.qsb index c2fd0cf1fcf3775c3fbdb723e9a5c1ab0076183f..e3452d13735390ce439946e288903583c2b3ab8f 100644 GIT binary patch literal 15103 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 diff --git a/crates/qbz-qt/qml/immersive/AlbumReactivePanel.qml b/crates/qbz-qt/qml/immersive/AlbumReactivePanel.qml index 9cc21edbd..b028814d0 100644 --- a/crates/qbz-qt/qml/immersive/AlbumReactivePanel.qml +++ b/crates/qbz-qt/qml/immersive/AlbumReactivePanel.qml @@ -58,13 +58,16 @@ Item { // and this one feeds geometry (artScale/glow), so each publish was a // whole-window present of its own (measured: the immersive ran at // 57 presents/s with 1 ms-apart pairs, 2026-08-13). - if (energySettle.to.length < 5) + // `cur` since VizSettle eases instead of stepping — same job (have we + // been handed a frame yet?) and the same dependency, because `cur` is + // the property the pulse reassigns. + if (energySettle.cur.length < 5) return 0.0 return (energySettle.at(0) + energySettle.at(1) + energySettle.at(2) + energySettle.at(3) + energySettle.at(4)) / 5.0 } readonly property real bassEnergy: { - if (energySettle.to.length < 2) + if (energySettle.cur.length < 2) return 0.0 return (energySettle.at(0) + energySettle.at(1)) / 2.0 } diff --git a/crates/qbz-qt/qml/immersive/PlasmaScene.qml b/crates/qbz-qt/qml/immersive/PlasmaScene.qml index 5e943ba5e..857fc34cf 100644 --- a/crates/qbz-qt/qml/immersive/PlasmaScene.qml +++ b/crates/qbz-qt/qml/immersive/PlasmaScene.qml @@ -35,7 +35,22 @@ PlasmaItem { // at the same edge). function pulseTick(p) { time = p.time - beat = p.beat + // THE AC-COUPLED ONSET, deliberately in the `beat` slot. + // + // Plasma multiplies its splats and its rotation jolt by this, and the + // raw envelope stops being a hit on dense material: it decays x0.88 per + // 33 ms, so above ~3 hits/s it never falls far enough to come back as a + // spike and settles into a shallow ripple around 0.7 — a splat that + // should detonate becomes a steady glow. `beatAc` subtracts the local + // density floor, so a hit reads as a hit whatever the tempo. The other + // scenes keep the raw `beat`; only this one wants the contrast. + beat = p.beatAc + // The RAW transient stays available for the short, sharp flash — it + // decays x0.85 and it is not floor-subtracted, so it is the crisper of + // the two. + transientAmp = p.transientAmp + bandsLo = p.bandsLo + bandsHi = p.bandsHi level = p.level levelSmooth = p.levelSmooth energyLo = p.energyLo diff --git a/crates/qbz-qt/qml/immersive/ShaderSceneLayer.qml b/crates/qbz-qt/qml/immersive/ShaderSceneLayer.qml index 3df2947b6..739ce73f2 100644 --- a/crates/qbz-qt/qml/immersive/ShaderSceneLayer.qml +++ b/crates/qbz-qt/qml/immersive/ShaderSceneLayer.qml @@ -113,6 +113,10 @@ Item { property real level: 0 property real levelSmooth: 0 property real transientAmp: 0 + /// The onset measured against its own density (viz_qt.rs) — 0 at the local + /// floor, 1 at a full-scale hit above it. `beat` alone flattens into a + /// shallow ripple on dense material; this is what still reads as a hit. + property real beatAc: 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) @@ -145,6 +149,11 @@ Item { "levelSmooth": root.levelSmooth, "energyLo": root.energyLo, "energyHi": root.energyHi, + // Parsed here since the port landed and never handed to a scene. + "transientAmp": root.transientAmp, + "bandsLo": root.bandsLo, + "bandsHi": root.bandsHi, + "beatAc": root.beatAc, "primary": QbzShell.ambientPrimary, "secondary": QbzShell.ambientSecondary, "accent": QbzShell.ambientAccent @@ -232,6 +241,7 @@ Item { root.level = p.level root.levelSmooth = p.levelSmooth root.transientAmp = p.transient + root.beatAc = p.beatAc || 0 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]) diff --git a/crates/qbz-qt/qml/shell/SpectrumBand.qml b/crates/qbz-qt/qml/shell/SpectrumBand.qml index 26b85642d..21411d0f6 100644 --- a/crates/qbz-qt/qml/shell/SpectrumBand.qml +++ b/crates/qbz-qt/qml/shell/SpectrumBand.qml @@ -156,9 +156,13 @@ Rectangle { } Connections { target: barsSettle - function onFromChanged() { barsMode.push() } - function onToChanged() { barsMode.push() } - function onProgressChanged() { barsMode.push() } + // ONE handler since VizSettle eases instead of stepping: it + // applies into `cur` and the three-property from/to/progress + // triple is gone. Three handlers on properties that no longer + // exist is not a compile error — QML warns at RUNTIME and the + // Connections simply never fires, which is exactly how this + // band went dead in all three modes. + function onCurChanged() { barsMode.push() } } // Shader arm health: no shaders at all (software backend) or a @@ -257,9 +261,13 @@ Rectangle { } Connections { target: waveSettle - function onFromChanged() { waveMode.push() } - function onToChanged() { waveMode.push() } - function onProgressChanged() { waveMode.push() } + // ONE handler since VizSettle eases instead of stepping: it + // applies into `cur` and the three-property from/to/progress + // triple is gone. Three handlers on properties that no longer + // exist is not a compile error — QML warns at RUNTIME and the + // Connections simply never fires, which is exactly how this + // band went dead in all three modes. + function onCurChanged() { waveMode.push() } } readonly property bool fxOk: !band._noShaders && waveFx.status !== ShaderEffect.Error @@ -372,9 +380,13 @@ Rectangle { } Connections { target: energySettle - function onFromChanged() { energyMode.push() } - function onToChanged() { energyMode.push() } - function onProgressChanged() { energyMode.push() } + // ONE handler since VizSettle eases instead of stepping: it + // applies into `cur` and the three-property from/to/progress + // triple is gone. Three handlers on properties that no longer + // exist is not a compile error — QML warns at RUNTIME and the + // Connections simply never fires, which is exactly how this + // band went dead in all three modes. + function onCurChanged() { energyMode.push() } } readonly property bool fxOk: !band._noShaders && energyFx.status !== ShaderEffect.Error diff --git a/crates/qbz-qt/qml/shell/VizSettle.qml b/crates/qbz-qt/qml/shell/VizSettle.qml index dc85a4ec9..4ab100fb3 100644 --- a/crates/qbz-qt/qml/shell/VizSettle.qml +++ b/crates/qbz-qt/qml/shell/VizSettle.qml @@ -32,11 +32,14 @@ // dependencies, so a pulse re-evaluates the bar heights and instantiates // nothing. // -// SMOOTHNESS NOTE: dropping the interpolator does NOT return the raw FFT -// step. qbz-audio smooths the bars in Rust with a per-frame EMA (attack 0.7 / -// decay 0.65, processor.rs:174-184), band-limiting the published signal to -// ~6 Hz, so 30 Hz application still renders the ease-in/decay shape — at the -// data rate, one present per published frame. +// SMOOTHNESS NOTE, CORRECTED 2026-08-19. This paragraph used to argue that +// dropping the interpolator did not cost smoothness, because qbz-audio already +// band-limits the bars in Rust (attack 0.7 / decay 0.65, +// processor.rs:174-184). That reasoning was wrong about what the eye reads: a +// band-limited signal APPLIED AS A STEP still moves in visible jumps, and the +// owner's side-by-side against the Slint build named it exactly — "no es que +// se vea mas rapido, la transicion entre estados es mas suave". The +// interpolation is back, on the pulse; see `easeK`. // // LATENCY: a frame is applied on the first pulse after its publish, i.e. up // to one period (33 ms) behind the tap. Against the pipeline that already @@ -61,9 +64,18 @@ Item { property bool live: true // ---- output (read via at(i)) ------------------------------------------- - property var from: [] - property var to: [] - property real progress: 1.0 + // + // ONE applied array, eased toward the newest published frame. It replaced + // a from/to/progress triple that only ever held progress = 1.0, i.e. a + // step — see the EASING note above `easeK`. + property var cur: [] + /// Newest published frame the ease is chasing. + property var tgt: null + /// PING-PONG buffers. Assigning a property a DIFFERENT array reference is + /// what notifies QML; mutating one in place does not. Two persistent + /// buffers give both — a new reference every pulse, and no allocation + /// after the first two. + readonly property var bufs: ({ a: [], b: [], flip: false }) // The stash. `property var` because QML object state has no other home; // NOTHING binds to it, so writing it emits a notify nobody hears and — @@ -74,12 +86,34 @@ Item { width: 0 height: 0 - // Applied amplitude for bar `i`. Reads all three outputs on every path — - // do NOT add an early return, it would drop the dependency. + // EASING — the transition between states, which is what reads as + // "fluid", and which is NOT the frame rate. + // + // Measured 2026-08-19, and it falsified the standing assumption: driven at + // QBZ_PULSE_MS=10 the shell presented ~100 frames/s (qt-batches confirmed + // it) and the bars looked exactly as they had at 30. They would: each + // published frame was applied as a STEP, so the extra presents repainted + // the same discrete values three times over. What Slint does differently + // is not rate, it is `animate bar-h { 90ms }` — it moves BETWEEN values. + // + // Restoring that costs NOTHING here, and that is the whole point. The old + // interpolator was expensive because of its private 16 ms Timer, not + // because of the interpolation: it presented at its own rate, unsynchronised + // with everything else. Easing ON THE PULSE presents exactly as often as + // stepping on the pulse did — one repaint per period either way. Only the + // VALUE differs. + // + // The price is LAG, and it is the honest one: an exponential ease at + // k = 0.45 is ~83% of the way there in 3 pulses (~100 ms), matching the + // reference's 90 ms. Against the 46 ms FFT window and 77 ms of Rust + // smoothing already in the chain, it is the same order as what is already + // accepted. + property real easeK: 0.45 + + // Applied amplitude for bar `i`. Reads `cur` on every path — do NOT add an + // early return, it would drop the dependency. function at(i) { - var a = settle.from[i] || 0; - var b = settle.to[i] || 0; - var v = a + (b - a) * settle.progress; + var v = settle.cur[i] || 0; return v < 0 ? 0 : (v > 1 ? 1 : v); } @@ -117,7 +151,7 @@ Item { onLiveChanged: { if (!settle.live) { settle.pending = null; - settle.progress = 1.0; + settle.tgt = null; } } @@ -132,15 +166,42 @@ Item { function onPulseMsChanged() { if (!settle.live) return; - var arr = settle.pending; - if (arr === null) + // Adopt the newest publish as the TARGET; the ease below is what + // actually moves. A publish arriving mid-ease simply moves the + // target — no from/to bookkeeping, no discontinuity. + if (settle.pending !== null) { + settle.tgt = settle.pending; + settle.pending = null; + } + var t = settle.tgt; + if (t === null) return; - settle.pending = null; - // Reference swap, not a copy — zero allocation per applied frame, - // same as the old driver. - settle.from = settle.to; - settle.to = arr; - settle.progress = 1.0; + var n = t.length; + if (n === 0) + return; + var src = settle.cur; + var dst = settle.bufs.flip ? settle.bufs.a : settle.bufs.b; + if (dst.length !== n) + dst.length = n; + var k = settle.easeK; + var moved = false; + for (var i = 0; i < n; ++i) { + var a = src[i] || 0; + var d = t[i] - a; + // SELF-PARKING, preserved. Once the ease has converged the + // values stop changing, this writes nothing and the pulse + // schedules no repaint — the same zero-presents-when-silent + // property the step version had. Without this guard a held + // signal would repaint forever chasing a target it already + // reached. + if (d > 0.0005 || d < -0.0005) + moved = true; + dst[i] = a + d * k; + } + if (!moved) + return; + settle.bufs.flip = !settle.bufs.flip; + settle.cur = dst; } } } diff --git a/crates/qbz-qt/src/viz_qt.rs b/crates/qbz-qt/src/viz_qt.rs index db6d3ee34..6e8327524 100644 --- a/crates/qbz-qt/src/viz_qt.rs +++ b/crates/qbz-qt/src/viz_qt.rs @@ -259,6 +259,8 @@ struct ShaderPackState { last_energy: [f32; 5], level_smooth: f32, beat: f32, + /// Slow floor the AC-coupled beat is measured against — see `beat_ac`. + beat_floor: f32, transient: f32, phase: f32, spectral_peak: f32, @@ -283,6 +285,31 @@ impl ShaderPackState { } self.transient *= 0.85; self.beat *= 0.88; + // AC-COUPLED BEAT — the onset measured against its OWN density. + // + // `beat` alone stops being a beat on busy material, and the arithmetic + // is why: it maxes in on a transient and decays x0.88 per 33 ms tick, + // so it needs ~600 ms to fall back to a tenth. Portnoy's double kick + // runs past 10 hits/s; between two of them the envelope only falls + // 0.88^3 ~= 0.68 before the next one tops it back up, so it does not + // saturate at 1 — it RIPPLES, shallow, around 0.7. Everything a scene + // multiplies by it (a splat that should detonate, a rotation that + // should jolt) turns into a constant, and the busier the music the + // flatter the picture. That is the inversion: the most frenetic input + // produces the least motion. + // + // Subtracting a slow floor restores the CONTRAST that the envelope + // loses. On sparse material the floor sits near zero and this is the + // plain envelope; on dense material the floor rises with it and only + // the peaks ABOVE the local density come through. The floor tracks + // asymmetrically — it follows the beat down quickly (x0.90) so a + // breakdown re-arms the punch within ~a third of a second, and creeps + // up slowly (0.02) so one loud hit does not deafen the next. + self.beat_floor = if self.beat < self.beat_floor { + self.beat_floor * 0.90 + } else { + self.beat_floor * 0.98 + self.beat * 0.02 + }; // 8 log bands paired from the 16 bars (visualizer.rs:205-208). let mut bands8 = [0.0f32; 8]; @@ -330,12 +357,16 @@ impl ShaderPackState { // in the pack: time is the QML layer's local pulse clock, resolution // is the item size, palette is QbzShell.ambient*. format!( - "{{\"phase\":{},\"beat\":{},\"level\":{},\"levelSmooth\":{},\ + "{{\"phase\":{},\"beat\":{},\"beatAc\":{},\"level\":{},\"levelSmooth\":{},\ \"transient\":{},\ \"energyLo\":[{},{},{},{}],\"energyHi\":[{},{},0,0],\ \"bandsLo\":[{},{},{},{}],\"bandsHi\":[{},{},{},{}]}}", self.phase, self.beat, + // Normalised so a scene can use it exactly where it used `beat`: + // 0 at the local floor, 1 at a full-scale onset above it. + ((self.beat - self.beat_floor).max(0.0) / (1.0 - self.beat_floor).max(0.15)) + .clamp(0.0, 1.0), level, self.level_smooth, self.transient, diff --git a/scripts/qt-batches.sh b/scripts/qt-batches.sh index 723e83667..14b016178 100755 --- a/scripts/qt-batches.sh +++ b/scripts/qt-batches.sh @@ -25,6 +25,12 @@ # ATLAS_OVERLAY=1 ./scripts/qt-batches.sh # tint atlased textures (diagnostic) # ATLAS=8192 ./scripts/qt-batches.sh # force a bigger texture atlas # BIN=crates/target/debug/qbz ./scripts/qt-batches.sh +# QBZ_PULSE_MS=25 ./scripts/qt-batches.sh # 40 Hz shell pulse instead of 30 +# +# QBZ_PULSE_MS is the shared repaint clock (settings_qt::shell_pulse_ms, +# default 33). It is THE knob this script's "presents/s" line measures, so the +# summary below prints the value it ran with: two logs whose pulse cannot be +# told apart are not an A/B. # # THE REFERENCE SCENE, so two runs are comparable: album page, NPB Large, the # spectrum band up (the eye toggle on), a track PLAYING, dynamic background on. @@ -61,6 +67,7 @@ if [[ -n "${ATLAS:-}" ]]; then echo "[batches] atlas forced to ${ATLAS}x${ATLAS}" fi +echo "[batches] shell pulse = ${QBZ_PULSE_MS:-33} ms (~$((1000 / ${QBZ_PULSE_MS:-33})) Hz)" echo "[batches] launching $BIN (renderer debug is ~1 MB/s of log -> $LOG)" env "${atlas_env[@]}" QSG_RENDERER_DEBUG=render QSG_RENDER_TIMING=1 "$BIN" > "$LOG" 2>&1 & pid=$! @@ -131,14 +138,15 @@ PY # source. ~60/s = two unsynchronised ones. 120+/s = something runs at display # rate. Five hypotheses died in this campaign for want of this one number. python3 - "$sample" <<'PYFPS' -import re, sys, statistics +import re, sys, statistics, os txt = open(sys.argv[1], errors="ignore").read() per = [int(m) for m in re.findall(r"elapsed since last call: (\d+) ms", txt)] per = [p for p in per if p > 0] if per: med = statistics.median(per) fast = sum(1 for p in per if p <= 8) - print(f" presents: {len(per)} sampled, median period {med:.0f} ms -> ~{1000/max(med,1):.0f}/s") + print(f" presents: {len(per)} sampled, median period {med:.0f} ms -> ~{1000/max(med,1):.0f}/s" + f" (pulse was {os.environ.get('QBZ_PULSE_MS', '33')} ms)") print(f" arriving <=8ms apart: {fast} ({100*fast/len(per):.0f}%) <- display-rate source if high") else: print(" presents: no timing lines (QSG_RENDER_TIMING not honoured?)")