feat(sim/loop): decouple simulation from rAF with independent timer and separate TPS/FPS metrics (fixes #19)

This commit is contained in:
troytt 2026-09-17 08:12:57 +00:00
parent 1fa3f8b7a2
commit 167928a31f
3 changed files with 506 additions and 52 deletions

View File

@ -171,6 +171,9 @@ export interface ActSceneState {
facing: number
tick: number
tickRate: number
simTps: number
renderFps: number
fps: number
/**
* GPU draw calls the last frame actually issued.
*
@ -208,7 +211,7 @@ const state: ActSceneState = {
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
skippedDraws: 0, objects: 0, loadMs: 0,
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0,
missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, simTps: 0, renderFps: 0, fps: 0,
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
dialog: [], npcs: 0, npcsNear: [],
@ -1961,6 +1964,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
state.quadsDrawn = renderer.quadsSubmitted
state.culledDraws = culledDraws
state.tickRate = loop.tickRate
state.simTps = loop.simTps
state.renderFps = loop.fps
state.fps = loop.fps
state.zoom = Number(camera.zoom.toFixed(2))
state.canvasWidth = canvas.width
state.canvasHeight = canvas.height
@ -1973,11 +1979,14 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
// reader and stops the overlay from competing with the frame it describes.
if (renderStarted >= hudNextMs) {
hudNextMs = renderStarted + HUD_INTERVAL_MS
const simTpsText = loop.simTps.toFixed(1)
const renderFpsText = loop.fps.toFixed(0)
const renderMsText = (loop.frameMs > 0 ? loop.frameMs : state.renderMs).toFixed(1)
hud.textContent = `${runtime.level} · ${runtime.quadrant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}`
+ ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.walls.length)} 墙`
+ ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`
+ ` · 对象 ${String(runtime.objects)} · ${loop.tickRate.toFixed(1)} tps · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}`
+ ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})· ${state.renderMs.toFixed(1)} ms`
+ ` · 对象 ${String(runtime.objects)} · sim: ${simTpsText} tps | render: ${renderFpsText} fps (${renderMsText}ms) · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}`
+ ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})`
+ (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '')
}
},

View File

@ -4,10 +4,15 @@
* Diablo's simulation runs at 25 ticks per second, and that rate is not
* cosmetic: animation frames, movement, and monster AI all advance per tick,
* so anything derived from wall-clock time would diverge from the original
* behaviour. The loop therefore ticks at a fixed rate and lets rendering happen
* whenever the browser paints, interpolating only the camera-facing positions
* the caller asks to smooth.
* behaviour.
*
* To avoid tying simulation progress to rendering performance (where frame drops
* or heavy scenes would artificially drag down simulation rate or catastrophically
* drop backlog time), simulation ticks are driven by a dedicated timer mechanism
* at the deterministic rate (1000 / tickRate). Meanwhile, requestAnimationFrame
* handles rendering and frame metrics with proper interpolation alpha.
*/
/** Loop wiring. */
export interface LoopOptions {
/** Ticks per second (Diablo's simulation rate: 25). */
@ -22,25 +27,39 @@ export interface LoopOptions {
* @param frameMs - wall-clock duration of the previous frame.
*/
readonly onRender: (alpha: number, frameMs: number) => void
/** Most ticks to run in one frame before discarding the backlog. */
/** Most ticks to run in one timer/frame step before clamping the backlog. */
readonly maxCatchUp?: number
/** Optional custom clock source for testing. Defaults to performance.now. */
readonly clock?: () => number
}
/**
* A fixed-rate loop driven by `requestAnimationFrame`.
* A fixed-rate simulation loop decoupled from rendering.
*/
export class GameLoop {
private readonly options: LoopOptions
private readonly tickMs: number
private readonly maxCatchUp: number
private accumulator = 0
private lastTime = 0
private handle = 0
private running = false
readonly tickMs: number
readonly maxCatchUp: number
private rawAccumulator = 0
private consumed = 0
private rateWindowStart = 0
private rateWindowTicks = 0
private measuredRate = 0
private running = false
// Simulation timer & metrics
private simTimerHandle: ReturnType<typeof setTimeout> | null = null
private lastSimTime = 0
private simWindowStart = 0
private simTicksInWindow = 0
private simVirtualElapsed = 0
private measuredSimRate = 0
// Render loop & metrics
private renderHandle = 0
private lastRenderTime = 0
private lastFrameMs = 0
private renderWindowStart = 0
private renderFramesInWindow = 0
private measuredFps = 0
/**
* @param options - loop wiring.
@ -48,7 +67,12 @@ export class GameLoop {
constructor(options: LoopOptions) {
this.options = options
this.tickMs = 1000 / options.tickRate
this.maxCatchUp = options.maxCatchUp ?? 5
this.maxCatchUp = options.maxCatchUp ?? 10
}
/** Current time in ms from options.clock or performance.now. */
private now(): number {
return this.options.clock ? this.options.clock() : performance.now()
}
/** Ticks simulated since the loop started. */
@ -56,49 +80,239 @@ export class GameLoop {
return this.consumed
}
/** Measured ticks per second over the last window (0 until one has elapsed). */
/** Measured simulation ticks per second over the last window (or target rate when active). */
get tickRate(): number {
return this.measuredRate
return this.simTps
}
/** Begin ticking. Idempotent. */
/** Simulation ticks per second. */
get simTps(): number {
if (this.measuredSimRate > 0) return this.measuredSimRate
const elapsed = this.now() - this.simWindowStart
if (elapsed >= 200 && this.simTicksInWindow > 0) {
return (this.simTicksInWindow * 1000) / elapsed
}
if (this.simVirtualElapsed >= 200 && this.simTicksInWindow > 0) {
return (this.simTicksInWindow * 1000) / this.simVirtualElapsed
}
return this.running ? this.options.tickRate : 0
}
/** Measured render frames per second. */
get fps(): number {
return this.renderFps
}
/** Render frames per second. */
get renderFps(): number {
if (this.measuredFps > 0) return this.measuredFps
const elapsed = this.now() - this.renderWindowStart
if (elapsed >= 200 && this.renderFramesInWindow > 0) {
return (this.renderFramesInWindow * 1000) / elapsed
}
return this.lastFrameMs > 0 ? Math.min(120, 1000 / this.lastFrameMs) : 0
}
/** Duration of the last render frame in milliseconds. */
get frameMs(): number {
return this.lastFrameMs
}
/** Whether the loop is currently running. */
get isRunning(): boolean {
return this.running
}
/** Current simulation accumulator in milliseconds. */
get accumulator(): number {
const elapsedSinceLastSim = this.running ? Math.max(0, this.now() - this.lastSimTime) : 0
return this.rawAccumulator + elapsedSinceLastSim
}
/** Interpolation factor in 0..1 between the current tick and the next tick. */
get alpha(): number {
return Math.min(1, Math.max(0, this.accumulator / this.tickMs))
}
/**
* Manually advance the simulation by deltaMs milliseconds.
* Useful for headless simulation, test clocks, and deterministic testing.
*
* @param deltaMs - Milliseconds to advance.
* @param maxCatchUp - Optional override for max ticks to simulate in this step (0 = unlimited).
*/
step(deltaMs: number, maxCatchUp?: number): void {
if (deltaMs <= 0) return
this.rawAccumulator += deltaMs
this.lastSimTime = this.now()
const maxSteps = maxCatchUp !== undefined ? maxCatchUp : (this.options.maxCatchUp !== undefined ? this.maxCatchUp : 0)
let steps = 0
while (this.rawAccumulator >= this.tickMs && (maxSteps <= 0 || steps < maxSteps)) {
this.rawAccumulator -= this.tickMs
this.consumed += 1
this.simTicksInWindow += 1
this.options.onTick(this.consumed)
steps += 1
}
if (maxSteps > 0) {
const maxBacklog = this.tickMs * maxSteps
if (this.rawAccumulator > maxBacklog) {
this.rawAccumulator = maxBacklog
}
}
this.simVirtualElapsed += deltaMs
if (this.simVirtualElapsed >= 1000) {
this.measuredSimRate = (this.simTicksInWindow * 1000) / this.simVirtualElapsed
this.simVirtualElapsed = 0
this.simTicksInWindow = 0
} else if (this.simVirtualElapsed >= 200) {
this.measuredSimRate = (this.simTicksInWindow * 1000) / this.simVirtualElapsed
}
}
/**
* Manually trigger one render call.
*/
render(frameMs?: number): void {
const elapsed = frameMs ?? (this.lastFrameMs > 0 ? this.lastFrameMs : 16.6)
this.lastFrameMs = elapsed
this.options.onRender(this.alpha, elapsed)
}
/** Advance simulation according to elapsed wall-clock time. */
private advanceSimulation(now: number): void {
const elapsed = now - this.lastSimTime
if (elapsed <= 0) return
this.lastSimTime = now
this.rawAccumulator += elapsed
let steps = 0
while (this.rawAccumulator >= this.tickMs && (this.maxCatchUp <= 0 || steps < this.maxCatchUp)) {
this.rawAccumulator -= this.tickMs
this.consumed += 1
this.simTicksInWindow += 1
this.options.onTick(this.consumed)
steps += 1
}
// Prevent catastrophic discard: clamp to max backlog instead of wiping to 0
const maxBacklog = this.tickMs * this.maxCatchUp
if (this.rawAccumulator > maxBacklog) {
this.rawAccumulator = maxBacklog
}
const windowElapsed = now - this.simWindowStart
if (windowElapsed >= 1000) {
this.measuredSimRate = (this.simTicksInWindow * 1000) / windowElapsed
this.simWindowStart = now
this.simTicksInWindow = 0
} else if (this.measuredSimRate === 0 && windowElapsed >= 200) {
this.measuredSimRate = (this.simTicksInWindow * 1000) / windowElapsed
}
}
/** Begin ticking and rendering. Idempotent. */
start(): void {
if (this.running) return
this.running = true
this.lastTime = performance.now()
this.rateWindowStart = this.lastTime
this.rateWindowTicks = 0
const step = (now: number): void => {
if (!this.running) return
const elapsed = now - this.lastTime
this.lastTime = now
this.accumulator += elapsed
let steps = 0
while (this.accumulator >= this.tickMs && steps < this.maxCatchUp) {
this.accumulator -= this.tickMs
this.consumed += 1
this.rateWindowTicks += 1
this.options.onTick(this.consumed)
steps += 1
}
// A long stall (a backgrounded tab, a slow load) must not replay minutes
// of simulation on the next frame: drop the backlog instead.
if (this.accumulator > this.tickMs * this.maxCatchUp) this.accumulator = 0
if (now - this.rateWindowStart >= 1000) {
this.measuredRate = (this.rateWindowTicks * 1000) / (now - this.rateWindowStart)
this.rateWindowStart = now
this.rateWindowTicks = 0
}
this.options.onRender(this.accumulator / this.tickMs, elapsed)
this.handle = requestAnimationFrame(step)
}
this.handle = requestAnimationFrame(step)
const now = this.now()
this.lastSimTime = now
this.lastRenderTime = now
this.simWindowStart = now
this.renderWindowStart = now
this.simTicksInWindow = 0
this.renderFramesInWindow = 0
this.startSimTimer()
this.startRenderLoop()
}
/** Stop ticking. Idempotent. */
/** Start the self-correcting timer loop for simulation ticks. */
private startSimTimer(): void {
if (this.simTimerHandle !== null) return
let nextTick = this.now() + this.tickMs
const simStep = (): void => {
if (!this.running) return
const now = this.now()
this.advanceSimulation(now)
// Self-correct next tick timestamp to eliminate drift
nextTick += this.tickMs
if (nextTick < now - this.tickMs * this.maxCatchUp) {
nextTick = now + this.tickMs
} else {
while (nextTick <= now) {
nextTick += this.tickMs
}
}
const delay = Math.max(0, nextTick - this.now())
this.simTimerHandle = setTimeout(simStep, delay)
}
this.simTimerHandle = setTimeout(simStep, this.tickMs)
}
/** Start requestAnimationFrame render loop. */
private startRenderLoop(): void {
if (this.renderHandle !== 0) return
const renderStep = (): void => {
if (!this.running) return
const now = this.now()
const frameMs = this.lastRenderTime > 0 ? now - this.lastRenderTime : 16.6
this.lastRenderTime = now
this.lastFrameMs = frameMs
this.renderFramesInWindow += 1
const renderWindowElapsed = now - this.renderWindowStart
if (renderWindowElapsed >= 1000) {
this.measuredFps = (this.renderFramesInWindow * 1000) / renderWindowElapsed
this.renderWindowStart = now
this.renderFramesInWindow = 0
} else if (this.measuredFps === 0 && renderWindowElapsed >= 200) {
this.measuredFps = (this.renderFramesInWindow * 1000) / renderWindowElapsed
}
this.options.onRender(this.alpha, frameMs)
if (this.running) {
this.renderHandle = this.requestFrame(renderStep)
}
}
this.renderHandle = this.requestFrame(renderStep)
}
private requestFrame(cb: (time: number) => void): number {
if (typeof requestAnimationFrame === 'function') {
return requestAnimationFrame(cb)
}
return setTimeout(() => cb(this.now()), 16) as unknown as number
}
private cancelFrame(handle: number): void {
if (typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(handle)
return
}
clearTimeout(handle as unknown as ReturnType<typeof setTimeout>)
}
/** Stop ticking and rendering. Idempotent. */
stop(): void {
this.running = false
if (this.handle !== 0) cancelAnimationFrame(this.handle)
this.handle = 0
if (this.simTimerHandle !== null) {
clearTimeout(this.simTimerHandle)
this.simTimerHandle = null
}
if (this.renderHandle !== 0) {
this.cancelFrame(this.renderHandle)
this.renderHandle = 0
}
}
}

231
tests/loop.test.ts Normal file
View File

@ -0,0 +1,231 @@
import { describe, it, expect, vi } from 'vitest'
import { GameLoop } from '../src/sim/loop.ts'
describe('GameLoop (Issue #19: Decouple simulation from rAF)', () => {
it('1. Deterministic ticking at 25 tps in headless/step mode', () => {
let tickCount = 0
const ticks: number[] = []
const loop = new GameLoop({
tickRate: 25, // 40ms per tick
onTick: (tick) => {
tickCount++
ticks.push(tick)
},
onRender: () => {}
})
expect(loop.tickMs).toBe(40)
expect(loop.tick).toBe(0)
// Advancing by less than tickMs does not produce a tick
loop.step(20)
expect(tickCount).toBe(0)
expect(loop.accumulator).toBe(20)
expect(loop.alpha).toBeCloseTo(0.5, 2)
// Advancing another 20ms completes 40ms -> exactly 1 tick
loop.step(20)
expect(tickCount).toBe(1)
expect(loop.tick).toBe(1)
expect(loop.accumulator).toBe(0)
expect(loop.alpha).toBe(0)
// Advancing by 80ms produces exactly 2 ticks
loop.step(80)
expect(tickCount).toBe(3)
expect(loop.tick).toBe(3)
expect(loop.accumulator).toBe(0)
// Advancing by 1000ms produces exactly 25 ticks
loop.step(1000)
expect(tickCount).toBe(28) // 3 + 25
expect(loop.tick).toBe(28)
expect(loop.tickRate).toBe(25.0)
expect(loop.simTps).toBe(25.0)
})
it('1b. Deterministic ticking at 25 tps with timers', async () => {
let tickCount = 0
const loop = new GameLoop({
tickRate: 25, // 40ms per tick
onTick: () => {
tickCount++
},
onRender: () => {}
})
loop.start()
expect(loop.isRunning).toBe(true)
// Wait ~260ms -> should produce at least 5 ticks (40ms * 5 = 200ms)
await new Promise(resolve => setTimeout(resolve, 260))
loop.stop()
expect(loop.isRunning).toBe(false)
expect(tickCount).toBeGreaterThanOrEqual(5)
expect(tickCount).toBeLessThanOrEqual(8)
})
it('2. Artificial render delay test: when onRender has a 50ms stall, simulation ticks do NOT drop and world.tick advances smoothly', async () => {
const world = { tick: 0 }
let renderCalls = 0
const loop = new GameLoop({
tickRate: 25, // 40ms per tick
onTick: (tick) => {
world.tick = tick
},
onRender: () => {
renderCalls++
// Simulate heavy render frame stall of 50ms
const stallStart = performance.now()
while (performance.now() - stallStart < 50) {
// busy wait
}
}
})
loop.start()
// Run for ~220ms
// With 50ms render stall per frame, at most 4 render calls can happen (~20 FPS)
// But simulation runs at 25 TPS (40ms per tick) -> should produce ~5 ticks
await new Promise(resolve => setTimeout(resolve, 220))
loop.stop()
expect(renderCalls).toBeLessThanOrEqual(4)
expect(world.tick).toBeGreaterThanOrEqual(5)
// Simulation TPS remains high despite slowed rendering
expect(loop.simTps).toBeGreaterThanOrEqual(20)
})
it('3. Accumulator recovery and no catastrophic drop on transient lag', () => {
let tickCount = 0
const loop = new GameLoop({
tickRate: 25, // 40ms per tick
maxCatchUp: 5, // max 5 ticks (200ms) per step
onTick: () => {
tickCount++
},
onRender: () => {}
})
// Transient lag of 150ms (3.75 ticks)
loop.step(150)
// Consumed 3 ticks (120ms), 30ms remaining
expect(tickCount).toBe(3)
expect(loop.accumulator).toBe(30)
expect(loop.alpha).toBeCloseTo(30 / 40, 2)
// Next step of 20ms: 30ms + 20ms = 50ms >= 40ms -> consumes 1 tick, 10ms left
loop.step(20)
expect(tickCount).toBe(4)
expect(loop.accumulator).toBe(10)
expect(loop.alpha).toBeCloseTo(10 / 40, 2)
// Huge lag of 500ms (exceeding maxCatchUp * tickMs = 200ms)
// Old implementation had `if (this.accumulator > this.tickMs * this.maxCatchUp) this.accumulator = 0`
// which wiped out all remaining simulation time!
// New implementation clamps the backlog to maxBacklog (200ms) instead of wiping to 0.
loop.step(500)
// 5 ticks executed in this step (tickCount 4 + 5 = 9)
expect(tickCount).toBe(9)
// Accumulator is clamped to maxBacklog (200ms), NOT catastrophically dropped to 0!
expect(loop.accumulator).toBe(200)
// Subsequent steps recover and consume the preserved backlog
loop.step(40)
// Consumes 5 ticks from the 200ms backlog + 40ms new time
expect(tickCount).toBe(14)
expect(loop.accumulator).toBe(40)
loop.step(40)
expect(tickCount).toBe(16)
expect(loop.accumulator).toBe(0)
})
it('4. Clean start / stop lifecycle and timer cleanup', async () => {
let tickCount = 0
let renderCount = 0
const loop = new GameLoop({
tickRate: 25,
onTick: () => {
tickCount++
},
onRender: () => {
renderCount++
}
})
expect(loop.isRunning).toBe(false)
// Start
loop.start()
expect(loop.isRunning).toBe(true)
// Calling start() again is idempotent
loop.start()
expect(loop.isRunning).toBe(true)
await new Promise(resolve => setTimeout(resolve, 60))
expect(tickCount).toBeGreaterThanOrEqual(1)
expect(renderCount).toBeGreaterThanOrEqual(1)
// Stop
loop.stop()
expect(loop.isRunning).toBe(false)
const stoppedTicks = tickCount
const stoppedRenders = renderCount
// Calling stop() again is idempotent
loop.stop()
expect(loop.isRunning).toBe(false)
// Wait and verify timers are cleaned up and no more callbacks fire
await new Promise(resolve => setTimeout(resolve, 100))
expect(tickCount).toBe(stoppedTicks)
expect(renderCount).toBe(stoppedRenders)
// Restart cleanly
loop.start()
expect(loop.isRunning).toBe(true)
await new Promise(resolve => setTimeout(resolve, 60))
expect(tickCount).toBeGreaterThan(stoppedTicks)
expect(renderCount).toBeGreaterThan(stoppedRenders)
loop.stop()
})
it('5. Interpolation alpha calculation and manual render stepping', () => {
let lastAlpha = -1
let lastFrameMs = -1
const loop = new GameLoop({
tickRate: 25, // 40ms
onTick: () => {},
onRender: (alpha, frameMs) => {
lastAlpha = alpha
lastFrameMs = frameMs
}
})
loop.render(16.6)
expect(lastAlpha).toBe(0)
expect(lastFrameMs).toBeCloseTo(16.6, 1)
loop.step(10) // accumulator = 10ms (10/40 = 0.25)
loop.render(16.6)
expect(lastAlpha).toBeCloseTo(0.25, 2)
loop.step(20) // accumulator = 30ms (30/40 = 0.75)
loop.render(16.6)
expect(lastAlpha).toBeCloseTo(0.75, 2)
loop.step(10) // accumulator = 40ms -> ticks, accumulator = 0ms
loop.render(16.6)
expect(lastAlpha).toBe(0)
})
})