@woosh/meep-engine
Version:
Pure JavaScript game engine. Fully featured and production ready.
259 lines (240 loc) • 11.1 kB
JavaScript
import { assert } from "../../../core/assert.js";
import { clamp } from "../../../core/math/clamp.js";
/**
* Estimates the snapshot-interpolation render delay (in frames) that the
* receiver should hold the playhead behind the latest received frame, based
* on observed inter-arrival jitter.
*
* Why adaptive: a fixed delay is a tradeoff between smoothness (large delay
* absorbs more jitter) and responsiveness (small delay = less perceived lag).
* If we pick a fixed delay sized for the worst case, we pay that cost even
* when the network is calm. If we size it for the typical case, we stutter
* during jitter spikes. Source / Counter-Strike use an adaptive scheme that
* grows quickly when jitter is observed and decays slowly when the link is
* stable. This module is a small reusable implementation of that idea.
*
* Algorithm (per-frame lateness, not inter-arrival time):
* 1. Each `record_arrival(now_ms, frame_number)` computes
* `lateness = now_ms - frame_number * tick_period_ms`. The absolute value
* is meaningless (depends on time origin and one-way latency), but the
* *spread* across recent frames captures the jitter.
* 2. The rolling window holds the last N lateness samples; the recommended
* target delay (ms) = `(max(window) - min(window)) * safety_multiplier`.
* 3. Output ramps UP to a higher target instantly (so a jitter spike is
* absorbed at the next sample), and DOWN at most `decay_per_sample_ms`
* ms per arrival (so we don't flap when one calm packet follows a spike).
* 4. The output is clamped to `[min_delay_frames, max_delay_frames]` and
* converted to an integer frame count.
*
* Why not inter-arrival time: in a tick-driven receiver (e.g. JS engine
* polling a transport queue at 60 Hz), arrivals are quantized to tick
* boundaries. Bursts of frames in one packet share the same arrival time;
* the inter-arrival interval between consecutive applied frames is then
* either 0 (within a burst — useless) or ~tick_period (next tick — also
* useless). Massive network jitter shows up not as huge inter-arrival
* intervals but as `frame_number` deltas wildly out of step with wall time.
* Tracking lateness directly captures this.
*
* **`tick_period_ms` MUST match the sender's actual tick period.** Mismatch
* is silent and catastrophic: if the sender ticks at 240 Hz but you pass
* 1000/60 = 16.67 ms, the lateness math drifts ~12.5 ms per frame, the
* spread saturates within ~30 frames, and the estimator pins to
* `max_delay_frames` regardless of actual jitter. If your simulation runs
* on `requestAnimationFrame`, do NOT use the display refresh as your tick
* period — drive the simulation at a fixed timestep (accumulator pattern)
* and pass that fixed period here.
*
* No allocation per call, no I/O, no engine knowledge.
*
* @author Alex Goldring
* @copyright Company Named Limited (c) 2025
*/
export class AdaptiveRenderDelay {
#lateness;
#index;
#filled;
#last_arrival_ms;
#prev_arrival_ms;
#last_frame;
#current_delay_ms;
/**
* @param {{
* tick_period_ms: number,
* min_delay_frames?: number,
* max_delay_frames?: number,
* history_size?: number,
* safety_multiplier?: number,
* decay_per_sample_ms?: number,
* initial_delay_frames?: number,
* }} options
*
* `safety_multiplier`: how much headroom to add above the observed jitter
* spread. 2.0 (the default) follows the rule of thumb "render delay
* should cover 2× the observed jitter spread" and reproduces visibly
* smooth motion in practice.
*
* `decay_per_sample_ms`: how aggressively the recommendation drops once
* conditions improve. Smaller = stickier (the delay stays high even after
* a calm period), larger = snappier (the delay drops faster, possibly
* stuttering if jitter recurs). Default 1 ms / sample is a calm decay
* that takes ~1 s to drop by 60 ms at 60 Hz arrivals.
*/
constructor({
tick_period_ms,
min_delay_frames = 2,
max_delay_frames = 30,
history_size = 30,
safety_multiplier = 2.0,
decay_per_sample_ms = 1.0,
initial_delay_frames = 2,
}) {
assert.isNumber(tick_period_ms, 'tick_period_ms');
assert.ok(tick_period_ms > 0, 'tick_period_ms must be > 0');
assert.isNonNegativeInteger(min_delay_frames, 'min_delay_frames');
assert.isNonNegativeInteger(max_delay_frames, 'max_delay_frames');
assert.ok(max_delay_frames >= min_delay_frames, 'max_delay_frames must be >= min_delay_frames');
assert.isNonNegativeInteger(history_size, 'history_size');
assert.ok(history_size >= 1, 'history_size must be >= 1');
assert.isNumber(safety_multiplier, 'safety_multiplier');
assert.ok(safety_multiplier >= 1, 'safety_multiplier must be >= 1');
assert.isNumber(decay_per_sample_ms, 'decay_per_sample_ms');
assert.ok(decay_per_sample_ms >= 0, 'decay_per_sample_ms must be >= 0');
/** @readonly @type {number} */
this.tick_period_ms = tick_period_ms;
/** @readonly @type {number} */
this.min_delay_frames = min_delay_frames;
/** @readonly @type {number} */
this.max_delay_frames = max_delay_frames;
/** @readonly @type {number} */
this.safety_multiplier = safety_multiplier;
/** @readonly @type {number} */
this.decay_per_sample_ms = decay_per_sample_ms;
/**
* Rolling window of recent lateness samples in ms (`now - frame*tick`).
* Slot semantics: index `i % history_size` holds the i-th sample;
* `__index` is the next write position.
* @private
* @type {Float64Array}
*/
this.#lateness = new Float64Array(history_size);
/** @private @type {number} */
this.#index = 0;
/** @private @type {number} */
this.#filled = 0;
/**
* Wall-clock timestamp of the most recent arrival; the most recent
* frame number applied. Tracked for {@link last_interval_ms}.
* Non-monotonic frame numbers are accepted as samples — the residual
* `(now - frame*tick)` legitimately captures retransmission delay as
* part of the observed jitter spread.
* @private @type {number}
*/
this.#last_arrival_ms = -1;
/** @private @type {number} */
this.#prev_arrival_ms = -1;
/** @private @type {number} */
this.#last_frame = -1;
/**
* Smoothed delay recommendation in milliseconds. Updated per arrival
* with snap-up / decay-down. Persisted across calls so {@link delay_frames}
* is cheap.
* @private @type {number}
*/
this.#current_delay_ms = initial_delay_frames * tick_period_ms;
}
/**
* Record an arrival. Call this when a new frame from the sender has been
* applied locally.
*
* @param {number} now_ms wall-clock timestamp (e.g. `performance.now()`)
* @param {number} frame_number sender's monotonically-advancing frame index
*/
record_arrival(now_ms, frame_number) {
// Lateness sample for this frame. Subtracting `frame * tick_period`
// collapses the steady cadence component, leaving only the jitter
// signal in the residual.
const lateness = now_ms - frame_number * this.tick_period_ms;
// Push into the ring.
const cap = this.#lateness.length;
this.#lateness[this.#index] = lateness;
this.#index = (this.#index + 1) % cap;
if (this.#filled < cap) this.#filled++;
this.#prev_arrival_ms = this.#last_arrival_ms;
this.#last_arrival_ms = now_ms;
this.#last_frame = frame_number;
// Need at least 2 samples to measure a spread.
if (this.#filled < 2) return;
// Spread = max - min over the window. Captures jitter regardless of
// whether arrivals are bursty (several samples at once with monotonic-
// decreasing lateness) or sparse (lateness grows linearly with each
// missed expected slot).
let lo = Infinity, hi = -Infinity;
const filled = this.#filled;
for (let i = 0; i < filled; i++) {
const v = this.#lateness[i];
if (v < lo) lo = v;
if (v > hi) hi = v;
}
const spread_ms = hi - lo;
const target_delay_ms = spread_ms * this.safety_multiplier;
// Cap the internal state at `max_delay_frames * tick_period_ms`. Without
// this, a single huge lateness sample (a tab going to background for 30
// seconds, a long GC pause, an OS suspend) pins `__current_delay_ms` to
// an arbitrarily large value. The public delay() accessors clamp on
// read, but the internal state would then take `(stored - cap) /
// decay_per_sample_ms` more samples to decay back into range than the
// configuration nominally allows.
const cap_ms = this.max_delay_frames * this.tick_period_ms;
// Snap up immediately, decay down.
if (target_delay_ms > this.#current_delay_ms) {
this.#current_delay_ms = Math.min(target_delay_ms, cap_ms);
} else {
this.#current_delay_ms = Math.max(target_delay_ms, this.#current_delay_ms - this.decay_per_sample_ms);
}
}
/**
* Recommended render delay in frames, integer, clamped to the configured
* `[min_delay_frames, max_delay_frames]` window.
*
* @returns {number}
*/
delay_frames() {
const raw = Math.ceil(this.#current_delay_ms / this.tick_period_ms);
return clamp(raw, this.min_delay_frames, this.max_delay_frames);
}
/**
* Recommended render delay in milliseconds (the unsmoothed-by-frame-rounding
* value, useful for logging / HUD readouts).
* @returns {number}
*/
delay_ms() {
return clamp(
this.#current_delay_ms,
this.min_delay_frames * this.tick_period_ms,
this.max_delay_frames * this.tick_period_ms,
);
}
/**
* Most recent inter-arrival WALL TIME interval in ms. Returns -1 if fewer
* than 2 arrivals have been observed. NOTE: this is NOT what the
* estimator uses internally — it's just exposed for diagnostics / HUD.
* @returns {number}
*/
last_interval_ms() {
if (this.#prev_arrival_ms < 0) return -1;
return this.#last_arrival_ms - this.#prev_arrival_ms;
}
/**
* Drop all observed history. Useful after a reconnect or a level transition.
* Leaves `current_delay_ms` as-is so the displayed delay doesn't snap; it
* will adjust to new conditions over the next few samples.
*/
reset() {
this.#lateness.fill(0);
this.#index = 0;
this.#filled = 0;
this.#last_arrival_ms = -1;
this.#prev_arrival_ms = -1;
this.#last_frame = -1;
}
}