Merge p10-w1-fps-smoothness into dev (P10 W1: wall-clock playhead interpolation for smooth 60 FPS scroll)
This commit is contained in:
@@ -171,10 +171,23 @@ interface Datum {
|
||||
}
|
||||
|
||||
interface Playback {
|
||||
/** Current playback head in seconds. */
|
||||
/**
|
||||
* Last playback head pushed from Blazor, in seconds. This is the *authoritative*
|
||||
* position the player last reported — it updates only on the ~10 Hz setPlayback
|
||||
* push, NOT every frame. The per-frame scroll uses the interpolated
|
||||
* effectivePlayhead (see draw()), anchored on this value.
|
||||
*/
|
||||
positionSeconds: number;
|
||||
/** Whether audio is actively playing — gates the rAF loop so a paused mix stays cool. */
|
||||
isPlaying: boolean;
|
||||
/**
|
||||
* performance.now() (ms) captured when positionSeconds was pushed. The rAF loop
|
||||
* advances the playhead by wall-clock elapsed since this anchor so the ribbon
|
||||
* scrolls smoothly at the display refresh rate between the sparse ~10 Hz pushes,
|
||||
* instead of stepping once per push (the ~10 FPS smoothness bug). Re-anchored on
|
||||
* every push, so each push is a small correction rather than a hard reset.
|
||||
*/
|
||||
pushWallClockMs: number;
|
||||
}
|
||||
|
||||
export interface MixVisualizerHandle {
|
||||
@@ -478,9 +491,22 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
|
||||
// ── Mutable state, fed by the component through the handle. ──────────────────
|
||||
let datum: Datum | null = null;
|
||||
let playback: Playback = { positionSeconds: 0, isPlaying: false };
|
||||
let playback: Playback = { positionSeconds: 0, isPlaying: false, pushWallClockMs: performance.now() };
|
||||
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).
|
||||
*/
|
||||
function effectivePlayhead(): number {
|
||||
if (!playback.isPlaying) return playback.positionSeconds;
|
||||
const elapsedSeconds = (performance.now() - playback.pushWallClockMs) / 1000;
|
||||
return playback.positionSeconds + elapsedSeconds;
|
||||
}
|
||||
|
||||
/** Resolve the gradient stops from the live palette vars on the canvas. */
|
||||
function readTheme(): ResolvedTheme {
|
||||
const resolved: ResolvedTheme = {
|
||||
@@ -499,6 +525,14 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
let disposed = false;
|
||||
const startTimeMs = performance.now();
|
||||
|
||||
// FPS diagnostic (verification aid for the smoothness fix — gated on DEBUG). Counts
|
||||
// actual rAF callbacks and logs the rate ~once/sec while playing. This distinguishes
|
||||
// the two failure modes: a rate near the display refresh (~60) with the playhead
|
||||
// interpolated means motion is smooth; a rate near ~10 would mean the loop is gated
|
||||
// to the playback pushes instead of free-running. Reset when the loop (re)starts.
|
||||
let fpsFrameCount = 0;
|
||||
let fpsWindowStartMs = 0;
|
||||
|
||||
// One-shot diagnostics: log the canvas dimensions + a post-draw gl.getError() the
|
||||
// first time we actually draw at a non-degenerate size. A 1×1 (or 300×150 default)
|
||||
// backing store here means the canvas had no layout box when the first draw ran —
|
||||
@@ -574,9 +608,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
gl.useProgram(program);
|
||||
gl.bindVertexArray(vao);
|
||||
|
||||
// Per-frame uniforms.
|
||||
// Per-frame uniforms. The playhead is the wall-clock-interpolated value, not
|
||||
// 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, playback.positionSeconds);
|
||||
gl.uniform1f(u.playheadSeconds, effectivePlayhead());
|
||||
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).
|
||||
@@ -624,9 +660,14 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
// DESIGN: The loop runs ONLY while playing. When paused or stopped, no frames
|
||||
// are scheduled — the GPU is idle. The still slice stays correct via one-shot
|
||||
// redraws triggered by the handle methods (setZoom/refreshTheme/setDatum) and
|
||||
// by the ResizeObserver. The shader clock (uTimeSeconds) advances every frame so
|
||||
// motion is smooth at 60 FPS between Blazor's ~10 Hz playback ticks, not stepping
|
||||
// at that cadence (spec §2e / §5.4).
|
||||
// by the ResizeObserver.
|
||||
//
|
||||
// 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
|
||||
// the playhead on the wall clock — each frame uploads effectivePlayhead(), which
|
||||
// advances the last pushed position by real time elapsed since the push. (The
|
||||
// separate uTimeSeconds 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. */
|
||||
function redrawOnce(): void {
|
||||
@@ -637,6 +678,10 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
/** Start the rAF loop. No-op if already running or disposed (rafId guard). */
|
||||
function startLoop(): void {
|
||||
if (disposed || rafId !== null) return;
|
||||
// Reset the FPS window so the first measured second reflects the run we're
|
||||
// starting, not a stale count from a previous play session.
|
||||
fpsFrameCount = 0;
|
||||
fpsWindowStartMs = performance.now();
|
||||
rafId = requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
@@ -650,9 +695,10 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
|
||||
/**
|
||||
* The animation loop. Runs only while playing. Each frame draws the scrolling
|
||||
* waveform at the current playback position + shader clock, then reschedules
|
||||
* itself — unless playback stopped since this frame was queued, in which case it
|
||||
* draws one final still frame (already done above) and exits the loop.
|
||||
* waveform at the wall-clock-interpolated playhead (effectivePlayhead, advancing
|
||||
* smoothly between the ~10 Hz pushes), then reschedules itself — unless playback
|
||||
* stopped since this frame was queued, in which case it draws one final still
|
||||
* frame (already done above) and exits the loop.
|
||||
*
|
||||
* A backgrounded tab gets rAF throttled by the browser automatically; on top of
|
||||
* that the loop does not run at all when paused, so a foregrounded-but-paused
|
||||
@@ -664,6 +710,22 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
return;
|
||||
}
|
||||
draw();
|
||||
|
||||
// FPS tally: count this callback, and once per elapsed second emit the rate.
|
||||
// performance.now() is cheap (no GPU stall, unlike gl.getError); the gated log
|
||||
// fires at most once/sec, so this adds no meaningful per-frame cost.
|
||||
if (DEBUG) {
|
||||
fpsFrameCount++;
|
||||
const nowMs = performance.now();
|
||||
const windowMs = nowMs - fpsWindowStartMs;
|
||||
if (windowMs >= 1000) {
|
||||
const fps = (fpsFrameCount * 1000) / windowMs;
|
||||
debugLog(`FPS ${fps.toFixed(1)} (${fpsFrameCount} frames in ${windowMs.toFixed(0)}ms) — playhead ${effectivePlayhead().toFixed(2)}s.`);
|
||||
fpsFrameCount = 0;
|
||||
fpsWindowStartMs = nowMs;
|
||||
}
|
||||
}
|
||||
|
||||
if (playback.isPlaying) {
|
||||
rafId = requestAnimationFrame(frame);
|
||||
} else {
|
||||
@@ -775,7 +837,11 @@ export function create(canvas: HTMLCanvasElement): MixVisualizerHandle {
|
||||
|
||||
setPlayback(positionSeconds: number, isPlaying: boolean): void {
|
||||
const wasPlaying = playback.isPlaying;
|
||||
playback = { positionSeconds, isPlaying };
|
||||
// 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.
|
||||
playback = { positionSeconds, isPlaying, pushWallClockMs: performance.now() };
|
||||
|
||||
if (isPlaying && !wasPlaying) {
|
||||
// Transition paused/stopped → playing: start the rAF loop.
|
||||
|
||||
Reference in New Issue
Block a user