Batch worker updates in client catch-up mode to reduce render cost

- Refactor worker update handling into processPendingUpdates so multiple GameUpdateViewData objects are batched per frame.
- Combine all tick updates in a batch into a single GameUpdateViewData before applying it to GameView, while still running per-tick side effects (turnComplete, hashes, backlog metrics, win saving).
- Ensure layers using updatesSinceLastTick and recentlyUpdatedTiles see all events in a batch, fixing visual artifacts during fast-forward resync.
This commit is contained in:
scamiv
2025-11-23 19:28:34 +01:00
parent e31ac7f2cc
commit d5f53af3da
+131 -52
View File
@@ -12,7 +12,7 @@ import {
import { createPartialGameRecord, replacer } from "../core/Util";
import { ServerConfig } from "../core/configuration/Config";
import { getConfig } from "../core/configuration/ConfigLoader";
import { PlayerActions, UnitType } from "../core/game/Game";
import { GameUpdates, PlayerActions, UnitType } from "../core/game/Game";
import { TileRef } from "../core/game/GameMap";
import { GameMapLoader } from "../core/game/GameMapLoader";
import {
@@ -218,6 +218,9 @@ export class ClientGameRunner {
private readonly CATCH_UP_EXIT_BACKLOG = 20; // turns behind to exit catch-up
private readonly CATCH_UP_HEARTBEATS_PER_FRAME = 5;
private pendingUpdates: GameUpdateViewData[] = [];
private isProcessingUpdates = false;
constructor(
private lobby: LobbyConfig,
private eventBus: EventBus,
@@ -302,57 +305,9 @@ export class ClientGameRunner {
this.stop();
return;
}
this.transport.turnComplete();
gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
});
this.gameView.update(gu);
this.renderer.tick();
// Update tick / backlog metrics
if (!("errMsg" in gu)) {
this.lastProcessedTick = gu.tick;
this.backlogTurns = Math.max(
0,
this.serverTurnHighWater - this.lastProcessedTick,
);
const wasCatchUp = this.catchUpMode;
if (
!this.catchUpMode &&
this.backlogTurns >= this.CATCH_UP_ENTER_BACKLOG
) {
this.catchUpMode = true;
} else if (
this.catchUpMode &&
this.backlogTurns <= this.CATCH_UP_EXIT_BACKLOG
) {
this.catchUpMode = false;
}
if (wasCatchUp !== this.catchUpMode) {
console.log(
`Catch-up mode ${this.catchUpMode ? "enabled" : "disabled"} (backlog: ${
this.backlogTurns
} turns)`,
);
}
}
// Emit tick metrics event for performance overlay
this.eventBus.emit(
new TickMetricsEvent(
gu.tickExecutionDuration,
this.currentTickDelay,
this.backlogTurns,
this.catchUpMode,
),
);
// Reset tick delay for next measurement
this.currentTickDelay = undefined;
if (gu.updates[GameUpdateType.Win].length > 0) {
this.saveGame(gu.updates[GameUpdateType.Win][0]);
this.pendingUpdates.push(gu);
if (!this.catchUpMode) {
this.processPendingUpdates();
}
});
const worker = this.worker;
@@ -364,6 +319,7 @@ export class ClientGameRunner {
for (let i = 0; i < beatsPerFrame; i++) {
worker.sendHeartbeat();
}
this.processPendingUpdates();
requestAnimationFrame(keepWorkerAlive);
}
};
@@ -503,6 +459,129 @@ export class ClientGameRunner {
}
}
private processPendingUpdates() {
if (this.isProcessingUpdates) {
return;
}
if (this.pendingUpdates.length === 0) {
return;
}
this.isProcessingUpdates = true;
const batch = this.pendingUpdates.splice(0);
let processedCount = 0;
let lastTickDuration: number | undefined;
let lastTick: number | undefined;
try {
for (const gu of batch) {
processedCount++;
this.transport.turnComplete();
gu.updates[GameUpdateType.Hash].forEach((hu: HashUpdate) => {
this.eventBus.emit(new SendHashEvent(hu.tick, hu.hash));
});
this.updateBacklogMetrics(gu.tick);
if (gu.updates[GameUpdateType.Win].length > 0) {
this.saveGame(gu.updates[GameUpdateType.Win][0]);
}
if (gu.tickExecutionDuration !== undefined) {
lastTickDuration = gu.tickExecutionDuration;
}
lastTick = gu.tick;
}
} finally {
this.isProcessingUpdates = false;
}
if (processedCount > 0 && lastTick !== undefined) {
const combinedGu = this.mergeGameUpdates(batch);
if (combinedGu) {
this.gameView.update(combinedGu);
}
this.renderer.tick();
this.eventBus.emit(
new TickMetricsEvent(
lastTickDuration,
this.currentTickDelay,
this.backlogTurns,
this.catchUpMode,
),
);
// Reset tick delay for next measurement
this.currentTickDelay = undefined;
}
}
private mergeGameUpdates(
batch: GameUpdateViewData[],
): GameUpdateViewData | null {
if (batch.length === 0) {
return null;
}
const last = batch[batch.length - 1];
const combinedUpdates: GameUpdates = {} as GameUpdates;
// Initialize combinedUpdates with empty arrays for each existing key
for (const key in last.updates) {
const type = Number(key) as GameUpdateType;
combinedUpdates[type] = [];
}
const combinedPackedTileUpdates: bigint[] = [];
for (const gu of batch) {
for (const key in gu.updates) {
const type = Number(key) as GameUpdateType;
// We don't care about the specific update subtype here; just treat it
// as an array we can concatenate.
const updatesForType = gu.updates[type] as unknown as any[];
(combinedUpdates[type] as unknown as any[]).push(...updatesForType);
}
gu.packedTileUpdates.forEach((tu) => {
combinedPackedTileUpdates.push(tu);
});
}
return {
tick: last.tick,
updates: combinedUpdates,
packedTileUpdates: new BigUint64Array(combinedPackedTileUpdates),
playerNameViewData: last.playerNameViewData,
tickExecutionDuration: last.tickExecutionDuration,
};
}
private updateBacklogMetrics(processedTick: number) {
this.lastProcessedTick = processedTick;
this.backlogTurns = Math.max(
0,
this.serverTurnHighWater - this.lastProcessedTick,
);
const wasCatchUp = this.catchUpMode;
if (!this.catchUpMode && this.backlogTurns >= this.CATCH_UP_ENTER_BACKLOG) {
this.catchUpMode = true;
} else if (
this.catchUpMode &&
this.backlogTurns <= this.CATCH_UP_EXIT_BACKLOG
) {
this.catchUpMode = false;
}
if (wasCatchUp !== this.catchUpMode) {
console.log(
`Catch-up mode ${this.catchUpMode ? "enabled" : "disabled"} (backlog: ${
this.backlogTurns
} turns)`,
);
}
}
private inputEvent(event: MouseUpEvent) {
if (!this.isActive || this.renderer.uiState.ghostStructure !== null) {
return;