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 c2fd0cf1f..e3452d137 100644 Binary files a/crates/qbz-qt/qml/assets/shaders/plasma.frag.qsb and b/crates/qbz-qt/qml/assets/shaders/plasma.frag.qsb differ 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?)")