Merge p10-w1-jitter-cleanup into dev (P10 W1: startup-jitter easing, diagnostics gated off, review remediation)
This commit is contained in:
@@ -58,7 +58,7 @@ public partial class MixWaveformVisualizer : ComponentBase, IAsyncDisposable
|
|||||||
// datum-fetch / subscription / playback-coupling seams log to the browser console (prefixed
|
// datum-fetch / subscription / playback-coupling seams log to the browser console (prefixed
|
||||||
// `[MixVisualizer]`, same as the JS logs so the two interleave into one timeline). These pinpoint
|
// `[MixVisualizer]`, same as the JS logs so the two interleave into one timeline). These pinpoint
|
||||||
// which upstream link is broken when the ribbon stays blank — set false once confirmed healthy.
|
// which upstream link is broken when the ribbon stays blank — set false once confirmed healthy.
|
||||||
private const bool Debug = true;
|
private static readonly bool Debug = false;
|
||||||
private const string Tag = "[MixVisualizer]";
|
private const string Tag = "[MixVisualizer]";
|
||||||
|
|
||||||
private static void DebugLog(string message)
|
private static void DebugLog(string message)
|
||||||
|
|||||||
@@ -67,6 +67,43 @@ const RIBBON_HALF_WIDTH_FRAC = 0.92;
|
|||||||
*/
|
*/
|
||||||
const MAX_DPR = 2;
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Diagnostics ──────────────────────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Set true to surface the init/draw/datum/playback seams to the browser console
|
// 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
|
// 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
|
// 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.
|
// while the runtime fix is being verified through the browser.
|
||||||
const DEBUG = true;
|
const DEBUG = false;
|
||||||
|
|
||||||
const TAG = '[MixVisualizer]';
|
const TAG = '[MixVisualizer]';
|
||||||
function debugLog(...args: unknown[]): void {
|
function debugLog(...args: unknown[]): void {
|
||||||
@@ -495,11 +532,12 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
|||||||
let visibleSeconds = DEFAULT_VISIBLE_SECONDS;
|
let visibleSeconds = DEFAULT_VISIBLE_SECONDS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The playhead the shader actually scrolls to this frame. While playing it is the
|
* The *authoritative* playhead for this instant: the last pushed position advanced
|
||||||
* last pushed position advanced by wall-clock elapsed since the push (computed in
|
* by wall-clock elapsed since the push while playing, or the pushed position while
|
||||||
* draw()); while idle it equals the last pushed position. The player remains the
|
* idle. The player remains the sole source of truth — this is display-only and is
|
||||||
* sole source of truth — this is display-only smoothing between pushes, never
|
* never written back (read-only contract, spec §D / §5.10). This is the target the
|
||||||
* written back (read-only contract, spec §D / §5.10).
|
* 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 {
|
function effectivePlayhead(): number {
|
||||||
if (!playback.isPlaying) return playback.positionSeconds;
|
if (!playback.isPlaying) return playback.positionSeconds;
|
||||||
@@ -507,6 +545,46 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
|||||||
return playback.positionSeconds + elapsedSeconds;
|
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. */
|
/** Resolve the gradient stops from the live palette vars on the canvas. */
|
||||||
function readTheme(): ResolvedTheme {
|
function readTheme(): ResolvedTheme {
|
||||||
const resolved: 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
|
// 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.
|
// animation frame instead of stepping at Blazor's ~10 Hz push cadence.
|
||||||
gl.uniform2f(u.resolution, canvas.width, canvas.height);
|
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);
|
gl.uniform1f(u.timeSeconds, (performance.now() - startTimeMs) / 1000);
|
||||||
// Per-change / per-theme / per-datum uniforms (cheap to set every frame; no
|
// Per-change / per-theme / per-datum uniforms (cheap to set every frame; no
|
||||||
// separate dirty-tracking needed for scalars/vec3s).
|
// separate dirty-tracking needed for scalars/vec3s).
|
||||||
@@ -664,10 +742,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
|||||||
//
|
//
|
||||||
// Smoothness (spec §2e / §5.4): the scroll must advance every animation frame, not
|
// Smoothness (spec §2e / §5.4): the scroll must advance every animation frame, not
|
||||||
// step at Blazor's ~10 Hz playback-push cadence. We achieve that by interpolating
|
// step at Blazor's ~10 Hz playback-push cadence. We achieve that by interpolating
|
||||||
// the playhead on the wall clock — each frame uploads effectivePlayhead(), which
|
// the playhead on the wall clock — each frame uploads renderedPlayhead() (= effectivePlayhead()
|
||||||
// advances the last pushed position by real time elapsed since the push. (The
|
// + the decaying jitter-correction offset), which advances the last pushed position by real time
|
||||||
// separate uTimeSeconds monotonic clock is reserved for Wave 3's field/blob motion
|
// elapsed since the push and blends out any accumulated timing error. (The separate uTimeSeconds
|
||||||
// and is unused by this parity shader; it is NOT what drives the scroll here.)
|
// monotonic clock is reserved for Wave 3's field/blob motion and is unused by this parity shader;
|
||||||
|
// it is NOT what drives the scroll here.)
|
||||||
|
|
||||||
/** Draw one still frame immediately, without scheduling a new rAF. */
|
/** Draw one still frame immediately, without scheduling a new rAF. */
|
||||||
function redrawOnce(): void {
|
function redrawOnce(): void {
|
||||||
@@ -682,6 +761,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
|||||||
// starting, not a stale count from a previous play session.
|
// starting, not a stale count from a previous play session.
|
||||||
fpsFrameCount = 0;
|
fpsFrameCount = 0;
|
||||||
fpsWindowStartMs = performance.now();
|
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);
|
rafId = requestAnimationFrame(frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -837,12 +921,36 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
|||||||
|
|
||||||
setPlayback(positionSeconds: number, isPlaying: boolean): void {
|
setPlayback(positionSeconds: number, isPlaying: boolean): void {
|
||||||
const wasPlaying = playback.isPlaying;
|
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
|
// Anchor the pushed position to wall-clock NOW: the rAF loop interpolates
|
||||||
// forward from here each frame (effectivePlayhead), so the scroll advances
|
// forward from here each frame (effectivePlayhead), so the scroll advances
|
||||||
// smoothly between these ~10 Hz pushes. Each push re-anchors, turning any
|
// smoothly between these ~10 Hz pushes.
|
||||||
// drift since the last push into a small correction rather than a snap.
|
|
||||||
playback = { positionSeconds, isPlaying, pushWallClockMs: performance.now() };
|
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) {
|
if (isPlaying && !wasPlaying) {
|
||||||
// Transition paused/stopped → playing: start the rAF loop.
|
// Transition paused/stopped → playing: start the rAF loop.
|
||||||
debugLog(`playback started — position ${positionSeconds.toFixed(2)}s, datum ${datum ? 'present' : 'ABSENT'}; starting rAF loop.`);
|
debugLog(`playback started — position ${positionSeconds.toFixed(2)}s, datum ${datum ? 'present' : 'ABSENT'}; starting rAF loop.`);
|
||||||
|
|||||||
Reference in New Issue
Block a user