diff --git a/src/core/GameLoop.test.ts b/src/core/GameLoop.test.ts index f081e9b..9a3a5eb 100644 --- a/src/core/GameLoop.test.ts +++ b/src/core/GameLoop.test.ts @@ -255,19 +255,26 @@ describe('GameLoop', () => { lastUpdateTime: number; accumulator: number; recentDeltas: number[]; + deltaHead: number; + deltaCount: number; }; /** - * Pre-populates the rolling baseline window so that the next tick is + * Pre-populates the ring-buffer baseline so that the next tick is * evaluated against an established baseline rather than skipped during - * warm-up. + * warm-up. Caller is responsible for passing a `count` no greater than + * the configured `BASELINE_WINDOW` (60); all current callers use 16. */ const primeBaseline = (loop: GameLoop, sampleMs: number, count: number = 16): void => { const p = loop as unknown as PrivateLoop; for (let i = 0; i < count; i++) { - p.recentDeltas.push(sampleMs); + // eslint-disable-next-line security/detect-object-injection -- test helper, index is a bounded loop counter + p.recentDeltas[i] = sampleMs; } + + p.deltaHead = count; + p.deltaCount = count; }; beforeEach(() => { @@ -427,13 +434,15 @@ describe('GameLoop', () => { primeBaseline(loop, 16.67); - const lengthBefore = p.recentDeltas.length; + const countBefore = p.deltaCount; + const headBefore = p.deltaHead; p.isRunning = true; p.lastUpdateTime = 0; p.tick(5000); // backgrounded gap - expect(p.recentDeltas.length).toBe(lengthBefore); + expect(p.deltaCount).toBe(countBefore); + expect(p.deltaHead).toBe(headBefore); }); it('should be a no-op when no callback is provided', () => { diff --git a/src/core/GameLoop.ts b/src/core/GameLoop.ts index c50dccc..dba7c4c 100644 --- a/src/core/GameLoop.ts +++ b/src/core/GameLoop.ts @@ -117,11 +117,18 @@ export class GameLoop { private readonly onFrameDrop: FrameDropCallback | null; /** - * Rolling window of recent rAF deltas, in milliseconds. The shortest - * value approximates the browser's actual vsync interval and is used as - * the baseline for drop detection. + * Ring buffer of recent rAF deltas, in milliseconds. Pre-allocated to + * {@link BASELINE_WINDOW} so writes are O(1) and no allocation occurs on + * the hot path. The shortest sample approximates the browser's actual + * vsync interval and is used as the baseline for drop detection. */ - private readonly recentDeltas: number[] = []; + private readonly recentDeltas: number[] = new Array(GameLoop.BASELINE_WINDOW).fill(0); + + /** Next write index in {@link recentDeltas}; advances modulo BASELINE_WINDOW. */ + private deltaHead: number = 0; + + /** Number of valid samples in {@link recentDeltas}; saturates at BASELINE_WINDOW. */ + private deltaCount: number = 0; // #endregion @@ -275,26 +282,34 @@ export class GameLoop { return; } - // Append to the rolling window, trimming the oldest sample if full. - this.recentDeltas.push(deltaTime); + // Ring-buffer write: O(1) overwrite of the oldest slot. + this.recentDeltas[this.deltaHead] = deltaTime; + this.deltaHead = (this.deltaHead + 1) % GameLoop.BASELINE_WINDOW; - if (this.recentDeltas.length > GameLoop.BASELINE_WINDOW) { - this.recentDeltas.shift(); + if (this.deltaCount < GameLoop.BASELINE_WINDOW) { + this.deltaCount++; } // Wait until enough samples have accumulated to trust the baseline. - if (this.recentDeltas.length < GameLoop.BASELINE_WARMUP_SAMPLES) { + if (this.deltaCount < GameLoop.BASELINE_WARMUP_SAMPLES) { return; } // Baseline = shortest recent delta. Robust to slow frames since drops // can only stretch deltas, never shorten them. let baseline = Number.POSITIVE_INFINITY; + let seen = 0; for (const sample of this.recentDeltas) { + if (seen >= this.deltaCount) { + break; + } + if (sample < baseline) { baseline = sample; } + + seen++; } if (deltaTime <= baseline * GameLoop.DROP_THRESHOLD_MULTIPLIER) { diff --git a/src/core/IBlitTechDemo.ts b/src/core/IBlitTechDemo.ts index 1bb1d9e..22bb680 100644 --- a/src/core/IBlitTechDemo.ts +++ b/src/core/IBlitTechDemo.ts @@ -20,11 +20,18 @@ export interface HardwareSettings { targetFPS: number; /** - * When true, the engine logs a coalesced warning to the console whenever - * the browser misses one or more vsync deadlines (the gap between - * consecutive `requestAnimationFrame` callbacks exceeds `1.5 / targetFPS` - * seconds). Useful for spotting stutters during development. Defaults to - * `false`. + * When true, the engine logs a `console.warn` whenever it detects that + * the browser missed one or more vsync deadlines. Useful for spotting + * stutters during development. Defaults to `false`. + * + * Detection runs in `GameLoop.detectFrameDrop()` and uses an + * auto-calibrated baseline -- the shortest `requestAnimationFrame` delta + * observed in a rolling window of recent frames -- rather than a fixed + * `1.5 / targetFPS` threshold. A frame is reported as dropped when its + * rAF delta exceeds 1.5x that baseline, which makes detection work on + * any display refresh rate (60 / 120 / 144 Hz, etc.) and on browsers + * such as Firefox where rAF often fires at the display rate rather than + * at `targetFPS`. */ detectDroppedFrames?: boolean; }