type GetNow = () => number; export type InterpolationSample = { state: S; timestamp: number; invocation?: number; }; export type PredictionSample = { state: S; timestamp?: number; invocation?: number; }; export type StateInterpolatorOptions = { /** * Returns the current time in milliseconds */ getNow?: GetNow; /** * How far behind real time the renderer should target */ interpolationDelayMs?: number; /** * How long to keep history entries used for interpolation */ historyWindowMs?: number; /** * Maximum number of history entries */ maxHistoryEntries?: number; /** * Maximum blend factor to apply when lightly blending toward a predicted state */ maxPredictionBlend?: number; /** * Default timestamp offset to assume for predictions when none is provided */ defaultPredictionDelayMs?: number; /** * Retrieve the latest server invocation, used for gating prediction blending */ getLatestInvocation?: () => number | undefined; /** * Domain-specific interpolation function */ interpolate: (previous: S, next: S, t: number) => S; }; const DEFAULT_INTERPOLATION_DELAY_MS = 100; const DEFAULT_HISTORY_WINDOW_MS = 1000; const DEFAULT_MAX_HISTORY_ENTRIES = 6; const DEFAULT_MAX_PREDICTION_BLEND = 0.35; const DEFAULT_PREDICTION_DELAY_MS = 33; export class StateInterpolator { private history: InterpolationSample[]; private prediction?: PredictionSample; private options: Required< Pick< StateInterpolatorOptions, | "interpolate" | "getNow" | "interpolationDelayMs" | "historyWindowMs" | "maxHistoryEntries" | "maxPredictionBlend" | "defaultPredictionDelayMs" > > & { getLatestInvocation?: () => number | undefined; }; constructor(initial: InterpolationSample, options: StateInterpolatorOptions) { this.history = [initial]; this.options = { interpolate: options.interpolate, getNow: options.getNow ?? getNowMs, interpolationDelayMs: options.interpolationDelayMs ?? DEFAULT_INTERPOLATION_DELAY_MS, historyWindowMs: options.historyWindowMs ?? DEFAULT_HISTORY_WINDOW_MS, maxHistoryEntries: options.maxHistoryEntries ?? DEFAULT_MAX_HISTORY_ENTRIES, maxPredictionBlend: options.maxPredictionBlend ?? DEFAULT_MAX_PREDICTION_BLEND, defaultPredictionDelayMs: options.defaultPredictionDelayMs ?? DEFAULT_PREDICTION_DELAY_MS, getLatestInvocation: options.getLatestInvocation, }; } getLatestEntry() { return this.history.at(-1); } getHistory() { return this.history.slice(); } setPrediction(prediction: PredictionSample | undefined) { this.prediction = prediction; } clearPrediction() { this.prediction = undefined; } addConfirmedState( state: S, options: { timestamp?: number; invocation?: number; resetHistory?: boolean } = {} ) { const timestamp = options.timestamp ?? this.options.getNow(); const nextEntry: InterpolationSample = { state, timestamp, invocation: options.invocation, }; const baseHistory = options.resetHistory ? [] : this.history; const history = baseHistory.concat(nextEntry); const cutoff = timestamp - this.options.historyWindowMs; this.history = history .filter((entry) => entry.timestamp >= cutoff) .slice(-this.options.maxHistoryEntries); } getRenderState(now = this.options.getNow()) { if (this.history.length === 0) { throw new Error("StateInterpolator has no history to render"); } const targetTime = now - this.options.interpolationDelayMs; const latest = this.history[this.history.length - 1]; const previous = this.history .slice(0, -1) .reverse() .find((entry) => entry.timestamp <= targetTime); let nextState = latest.state; if (previous) { if (targetTime <= previous.timestamp || latest.timestamp === previous.timestamp) { nextState = previous.state; } else if (targetTime < latest.timestamp) { const alpha = (targetTime - previous.timestamp) / (latest.timestamp - previous.timestamp); nextState = this.options.interpolate(previous.state, latest.state, alpha); } } const latestInvocation = latest.invocation ?? this.options.getLatestInvocation?.(); const prediction = this.prediction; const predictionInvocation = prediction?.invocation; if ( prediction && latestInvocation !== undefined && predictionInvocation !== undefined && predictionInvocation === latestInvocation + 1 ) { const predictionTimestamp = prediction.timestamp ?? latest.timestamp + this.options.defaultPredictionDelayMs; const timeBlend = predictionTimestamp > latest.timestamp ? (targetTime - latest.timestamp) / (predictionTimestamp - latest.timestamp) : 0; const blendAmount = clamp(timeBlend, 0, this.options.maxPredictionBlend); if (blendAmount > 0) { nextState = this.options.interpolate(nextState, prediction.state, blendAmount); } } return nextState; } } function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } function getNowMs() { return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now(); }