UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

259 lines (240 loc) 11.1 kB
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; } }