UNPKG

@woosh/meep-engine

Version:

Pure JavaScript game engine. Fully featured and production ready.

128 lines 5.97 kB
/** * 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 { /** * @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, max_delay_frames, history_size, safety_multiplier, decay_per_sample_ms, initial_delay_frames, }: { 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; }); /** @readonly @type {number} */ readonly tick_period_ms: number; /** @readonly @type {number} */ readonly min_delay_frames: number; /** @readonly @type {number} */ readonly max_delay_frames: number; /** @readonly @type {number} */ readonly safety_multiplier: number; /** @readonly @type {number} */ readonly decay_per_sample_ms: number; /** * 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: number, frame_number: number): void; /** * Recommended render delay in frames, integer, clamped to the configured * `[min_delay_frames, max_delay_frames]` window. * * @returns {number} */ delay_frames(): number; /** * Recommended render delay in milliseconds (the unsmoothed-by-frame-rounding * value, useful for logging / HUD readouts). * @returns {number} */ delay_ms(): number; /** * 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(): number; /** * 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(): void; #private; } //# sourceMappingURL=AdaptiveRenderDelay.d.ts.map