fix(visualizer): ease playhead re-anchor to kill startup jitter; gate diagnostics off (P10 W1)
This commit is contained in:
@@ -67,6 +67,43 @@ const RIBBON_HALF_WIDTH_FRAC = 0.92;
|
||||
*/
|
||||
const MAX_DPR = 2;
|
||||
|
||||
/**
|
||||
* Playhead-correction smoothing time constant, in seconds. Governs how fast the
|
||||
* rendered playhead absorbs a re-anchor discontinuity at each ~10 Hz push.
|
||||
*
|
||||
* The problem: the player's position reports are irregular at startup (buffering /
|
||||
* playback ramp-up), so each push lands a position that doesn't match where the
|
||||
* wall-clock interpolation had advanced to. Hard-anchoring to each push (the prior
|
||||
* behaviour) made that gap a visible snap every push — the startup jitter.
|
||||
*
|
||||
* The fix (classic netcode-style entity reconciliation): the player stays the sole
|
||||
* source of truth, but instead of rendering the authoritative position directly, we
|
||||
* render authoritative + a small *correction offset* that decays toward zero every
|
||||
* frame. On each push we fold the re-anchor discontinuity into that offset so the
|
||||
* rendered playhead is continuous across the push, then bleed the offset off over
|
||||
* ~this time constant. This eases the snap into a sub-perceptible glide.
|
||||
*
|
||||
* Why an offset that decays to zero, not an absolute lerp toward target: a lerp
|
||||
* toward the target leaves a steady-state lag proportional to velocity (the render
|
||||
* always trailing real playback). Decaying the *error* to zero converges the
|
||||
* rendered playhead back onto the authoritative one, so once pushes steady the
|
||||
* offset is ~0 and behaviour is identical to the old hard-anchor — no lag, and
|
||||
* steady-state is unchanged as required.
|
||||
*
|
||||
* 0.12 s is a sensible default: long enough to dissolve the worst startup snaps
|
||||
* (tens of ms of position error), short enough that the correction is imperceptible
|
||||
* and the render never trails real playback by more than a few ms. Tunable.
|
||||
*/
|
||||
const PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS = 0.12;
|
||||
|
||||
/**
|
||||
* Below this absolute correction (seconds) we snap the offset to 0 and stop easing —
|
||||
* an exponential decay never mathematically reaches zero, and carrying a sub-ms
|
||||
* residual forever is pointless. ~0.5 ms is well under one frame of motion at any
|
||||
* real zoom, so collapsing it is invisible.
|
||||
*/
|
||||
const PLAYHEAD_CORRECTION_SNAP_SECONDS = 0.0005;
|
||||
|
||||
// ── Diagnostics ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Set true to surface the init/draw/datum/playback seams to the browser console
|
||||
@@ -75,7 +112,7 @@ const MAX_DPR = 2;
|
||||
// received/uploaded, first-draw dimensions, GL error after first draw) are gated
|
||||
// here so they can be silenced once the renderer is confirmed healthy. Leave it on
|
||||
// while the runtime fix is being verified through the browser.
|
||||
const DEBUG = true;
|
||||
const DEBUG = false;
|
||||
|
||||
const TAG = '[MixVisualizer]';
|
||||
function debugLog(...args: unknown[]): void {
|
||||
@@ -495,11 +532,12 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
let visibleSeconds = DEFAULT_VISIBLE_SECONDS;
|
||||
|
||||
/**
|
||||
* The playhead the shader actually scrolls to this frame. While playing it is the
|
||||
* last pushed position advanced by wall-clock elapsed since the push (computed in
|
||||
* draw()); while idle it equals the last pushed position. The player remains the
|
||||
* sole source of truth — this is display-only smoothing between pushes, never
|
||||
* written back (read-only contract, spec §D / §5.10).
|
||||
* The *authoritative* playhead for this instant: the last pushed position advanced
|
||||
* by wall-clock elapsed since the push while playing, or the pushed position while
|
||||
* idle. The player remains the sole source of truth — this is display-only and is
|
||||
* never written back (read-only contract, spec §D / §5.10). This is the target the
|
||||
* rendered playhead converges onto; the shader uploads the *rendered* value (see
|
||||
* renderedPlayhead) so a re-anchor at a push doesn't snap on screen.
|
||||
*/
|
||||
function effectivePlayhead(): number {
|
||||
if (!playback.isPlaying) return playback.positionSeconds;
|
||||
@@ -507,6 +545,46 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
return playback.positionSeconds + elapsedSeconds;
|
||||
}
|
||||
|
||||
// ── Rendered-playhead reconciliation (startup-jitter fix). ───────────────────────
|
||||
//
|
||||
// The shader scrolls to renderedPlayhead() = effectivePlayhead() + correctionOffset,
|
||||
// where correctionOffset decays exponentially toward 0 each frame (time constant
|
||||
// PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS). At a push, setPlayback re-anchors the
|
||||
// authoritative target; without correction that re-anchor would teleport the
|
||||
// rendered playhead. Instead we *preserve the rendered position across the push* by
|
||||
// folding the discontinuity into correctionOffset (see setPlayback), then bleed it
|
||||
// off — turning each snap into a brief, sub-perceptible glide.
|
||||
//
|
||||
// Steady-state: when pushes are regular, the authoritative target barely moves at a
|
||||
// push, so the folded discontinuity is ~0 and correctionOffset stays ~0 — behaviour
|
||||
// is then identical to uploading effectivePlayhead() directly (the prior renderer).
|
||||
let correctionOffset = 0;
|
||||
let lastRenderWallClockMs = performance.now();
|
||||
|
||||
/**
|
||||
* The playhead the shader actually scrolls to this frame. Equals the authoritative
|
||||
* effectivePlayhead() plus a correction offset that decays to zero, so the rendered
|
||||
* motion is continuous across the irregular startup pushes. Advances the decay by
|
||||
* real elapsed time since the previous render, making it frame-rate-independent
|
||||
* (same convergence on a 60 Hz and a 144 Hz display). Call exactly once per drawn
|
||||
* frame — it mutates the decay state.
|
||||
*/
|
||||
function renderedPlayhead(): number {
|
||||
const nowMs = performance.now();
|
||||
const dtSeconds = Math.max(0, (nowMs - lastRenderWallClockMs) / 1000);
|
||||
lastRenderWallClockMs = nowMs;
|
||||
|
||||
// Exponential decay of the error toward 0: offset *= e^(-dt/tau). Frame-rate
|
||||
// independent — the fraction retained depends only on wall-clock dt, not frame
|
||||
// count. Snap tiny residuals to 0 (an exponential never reaches it).
|
||||
if (correctionOffset !== 0) {
|
||||
correctionOffset *= Math.exp(-dtSeconds / PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS);
|
||||
if (Math.abs(correctionOffset) < PLAYHEAD_CORRECTION_SNAP_SECONDS) correctionOffset = 0;
|
||||
}
|
||||
|
||||
return effectivePlayhead() + correctionOffset;
|
||||
}
|
||||
|
||||
/** Resolve the gradient stops from the live palette vars on the canvas. */
|
||||
function readTheme(): ResolvedTheme {
|
||||
const resolved: ResolvedTheme = {
|
||||
@@ -612,7 +690,7 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
// the raw last-pushed position — that is what makes the scroll advance every
|
||||
// animation frame instead of stepping at Blazor's ~10 Hz push cadence.
|
||||
gl.uniform2f(u.resolution, canvas.width, canvas.height);
|
||||
gl.uniform1f(u.playheadSeconds, effectivePlayhead());
|
||||
gl.uniform1f(u.playheadSeconds, renderedPlayhead());
|
||||
gl.uniform1f(u.timeSeconds, (performance.now() - startTimeMs) / 1000);
|
||||
// Per-change / per-theme / per-datum uniforms (cheap to set every frame; no
|
||||
// separate dirty-tracking needed for scalars/vec3s).
|
||||
@@ -682,6 +760,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
// starting, not a stale count from a previous play session.
|
||||
fpsFrameCount = 0;
|
||||
fpsWindowStartMs = performance.now();
|
||||
// Re-base the decay clock to now so the first frame's dt is one frame, not the
|
||||
// (possibly long) idle gap since the last redrawOnce — otherwise a stale dt
|
||||
// would collapse the offset in one step. (Offset is 0 at play-start today, so
|
||||
// this is belt-and-braces, but it keeps the decay honest if that ever changes.)
|
||||
lastRenderWallClockMs = performance.now();
|
||||
rafId = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
@@ -837,12 +920,36 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
|
||||
setPlayback(positionSeconds: number, isPlaying: boolean): void {
|
||||
const wasPlaying = playback.isPlaying;
|
||||
|
||||
// Preserve on-screen continuity across the re-anchor. The rendered playhead
|
||||
// right now is effectivePlayhead() (old anchor) + correctionOffset; capture
|
||||
// it before we replace the anchor. We read effectivePlayhead() without going
|
||||
// through renderedPlayhead() so we don't advance the decay clock here — the
|
||||
// decay belongs to the render loop, ticked once per drawn frame.
|
||||
const renderedBefore = effectivePlayhead() + correctionOffset;
|
||||
|
||||
// Anchor the pushed position to wall-clock NOW: the rAF loop interpolates
|
||||
// forward from here each frame (effectivePlayhead), so the scroll advances
|
||||
// smoothly between these ~10 Hz pushes. Each push re-anchors, turning any
|
||||
// drift since the last push into a small correction rather than a snap.
|
||||
// smoothly between these ~10 Hz pushes.
|
||||
playback = { positionSeconds, isPlaying, pushWallClockMs: performance.now() };
|
||||
|
||||
// Fold the re-anchor discontinuity into the correction offset so the rendered
|
||||
// playhead doesn't jump: choose offset such that effectivePlayhead() (new
|
||||
// anchor) + offset == renderedBefore. The render loop then decays this offset
|
||||
// to zero over PLAYHEAD_CORRECTION_TIME_CONSTANT_SECONDS, converging onto the
|
||||
// authoritative position. When pushes are regular the gap is ~0, so offset is
|
||||
// ~0 and steady-state matches the prior hard-anchor behaviour exactly.
|
||||
//
|
||||
// Only smooth while continuously playing. On a play/pause edge or while idle
|
||||
// we want the exact authoritative position, not a glide from a stale render:
|
||||
// a resume should land on the real position, and a paused still frame must be
|
||||
// truthful (read-only contract — never show a position the player isn't at).
|
||||
if (isPlaying && wasPlaying) {
|
||||
correctionOffset = renderedBefore - effectivePlayhead();
|
||||
} else {
|
||||
correctionOffset = 0;
|
||||
}
|
||||
|
||||
if (isPlaying && !wasPlaying) {
|
||||
// Transition paused/stopped → playing: start the rAF loop.
|
||||
debugLog(`playback started — position ${positionSeconds.toFixed(2)}s, datum ${datum ? 'present' : 'ABSENT'}; starting rAF loop.`);
|
||||
|
||||
Reference in New Issue
Block a user