diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 0f137876e..03334c6ac 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -192,6 +192,8 @@ async function createClientGame( let sharedTileRingBuffers: SharedTileRingBuffers | undefined; let sharedTileRingViews: SharedTileRingViews | null = null; + let sharedDirtyBuffer: SharedArrayBuffer | undefined; + let sharedDirtyFlags: Uint8Array | null = null; const isIsolated = typeof (globalThis as any).crossOriginIsolated === "boolean" ? (globalThis as any).crossOriginIsolated === true @@ -210,8 +212,14 @@ async function createClientGame( // Capacity is number of tile updates that can be queued. // This is a compromise between memory usage and backlog tolerance. const TILE_RING_CAPACITY = 262144; - sharedTileRingBuffers = createSharedTileRingBuffers(TILE_RING_CAPACITY); + const numTiles = gameMap.gameMap.width() * gameMap.gameMap.height(); + sharedTileRingBuffers = createSharedTileRingBuffers( + TILE_RING_CAPACITY, + numTiles, + ); sharedTileRingViews = createSharedTileRingViews(sharedTileRingBuffers); + sharedDirtyBuffer = sharedTileRingBuffers.dirty; + sharedDirtyFlags = sharedTileRingViews.dirtyFlags; } const worker = new WorkerClient( @@ -219,6 +227,7 @@ async function createClientGame( lobbyConfig.clientID, sharedTileRingBuffers, sharedStateBuffer, + sharedDirtyBuffer, ); await worker.initialize(); const gameView = new GameView( @@ -247,6 +256,7 @@ async function createClientGame( worker, gameView, sharedTileRingViews, + sharedDirtyFlags, ); } @@ -278,6 +288,7 @@ export class ClientGameRunner { private pendingStart = 0; private isProcessingUpdates = false; private tileRingViews: SharedTileRingViews | null; + private dirtyFlags: Uint8Array | null; constructor( private lobby: LobbyConfig, @@ -288,9 +299,11 @@ export class ClientGameRunner { private worker: WorkerClient, private gameView: GameView, tileRingViews: SharedTileRingViews | null, + dirtyFlags: Uint8Array | null, ) { this.lastMessageTime = Date.now(); this.tileRingViews = tileRingViews; + this.dirtyFlags = dirtyFlags; } private saveGame(update: WinUpdate) { @@ -698,22 +711,31 @@ export class ClientGameRunner { ); const drainTime = performance.now() - drainStart; + // Deduplicate tile refs for this render slice + const uniqueTiles = new Set(); + for (const ref of tileRefs) { + uniqueTiles.add(ref); + } + // Calculate ring buffer utilization and overflow const TILE_RING_CAPACITY = 262144; - const utilization = (tileRefs.length / TILE_RING_CAPACITY) * 100; + const utilization = (uniqueTiles.size / TILE_RING_CAPACITY) * 100; const overflow = Atomics.load( this.tileRingViews.header, TILE_RING_HEADER_OVERFLOW, ); tileMetrics = { - count: tileRefs.length, + count: uniqueTiles.size, utilization, overflow, drainTime, }; - for (const ref of tileRefs) { + for (const ref of uniqueTiles) { + if (this.dirtyFlags) { + Atomics.store(this.dirtyFlags, ref, 0); + } combinedPackedTileUpdates.push(BigInt(ref)); } } else { diff --git a/src/core/game/TerrainMapLoader.ts b/src/core/game/TerrainMapLoader.ts index 2f6757630..3ea90cbf2 100644 --- a/src/core/game/TerrainMapLoader.ts +++ b/src/core/game/TerrainMapLoader.ts @@ -7,6 +7,7 @@ export type TerrainMapData = { gameMap: GameMap; miniGameMap: GameMap; sharedStateBuffer?: SharedArrayBuffer; + sharedDirtyBuffer?: SharedArrayBuffer; }; const loadedMaps = new Map(); @@ -104,6 +105,7 @@ export async function loadTerrainMap( stateBuffer instanceof SharedArrayBuffer ? stateBuffer : undefined, + sharedDirtyBuffer: undefined, // populated by consumer when needed }; // Always cache the result, but only use cache when appropriate loadedMaps.set(map, result); diff --git a/src/core/worker/SharedTileRing.ts b/src/core/worker/SharedTileRing.ts index 4ddf9403d..328def730 100644 --- a/src/core/worker/SharedTileRing.ts +++ b/src/core/worker/SharedTileRing.ts @@ -3,11 +3,13 @@ import { TileRef } from "../game/GameMap"; export interface SharedTileRingBuffers { header: SharedArrayBuffer; data: SharedArrayBuffer; + dirty: SharedArrayBuffer; } export interface SharedTileRingViews { header: Int32Array; buffer: Uint32Array; + dirtyFlags: Uint8Array; capacity: number; } @@ -18,10 +20,12 @@ export const TILE_RING_HEADER_OVERFLOW = 2; export function createSharedTileRingBuffers( capacity: number, + numTiles: number, ): SharedTileRingBuffers { const header = new SharedArrayBuffer(3 * Int32Array.BYTES_PER_ELEMENT); const data = new SharedArrayBuffer(capacity * Uint32Array.BYTES_PER_ELEMENT); - return { header, data }; + const dirty = new SharedArrayBuffer(numTiles * Uint8Array.BYTES_PER_ELEMENT); + return { header, data, dirty }; } export function createSharedTileRingViews( @@ -29,9 +33,11 @@ export function createSharedTileRingViews( ): SharedTileRingViews { const header = new Int32Array(buffers.header); const buffer = new Uint32Array(buffers.data); + const dirtyFlags = new Uint8Array(buffers.dirty); return { header, buffer, + dirtyFlags, capacity: buffer.length, }; } diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 586f77e66..3bdd356ca 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -24,6 +24,7 @@ let gameRunner: Promise | null = null; const mapLoader = new FetchGameMapLoader(`/maps`, version); let isProcessingTurns = false; let sharedTileRing: SharedTileRingViews | null = null; +let dirtyFlags: Uint8Array | null = null; function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) { // skip if ErrorUpdate @@ -73,19 +74,35 @@ ctx.addEventListener("message", async (e: MessageEvent) => { sharedTileRing = createSharedTileRingViews({ header: message.sharedTileRingHeader, data: message.sharedTileRingData, + dirty: message.sharedDirtyBuffer!, }); + dirtyFlags = sharedTileRing.dirtyFlags; } else { sharedTileRing = null; + dirtyFlags = null; } + console.log("[Worker.worker] init", { + hasSharedStateBuffer: !!message.sharedStateBuffer, + hasRingHeader: !!message.sharedTileRingHeader, + hasRingData: !!message.sharedTileRingData, + hasDirtyBuffer: !!message.sharedDirtyBuffer, + }); + gameRunner = createGameRunner( message.gameStartInfo, message.clientID, mapLoader, gameUpdate, - sharedTileRing - ? (tile: TileRef) => pushTileUpdate(sharedTileRing!, tile) - : undefined, + sharedTileRing && dirtyFlags + ? (tile: TileRef) => { + if (Atomics.compareExchange(dirtyFlags!, tile, 0, 1) === 0) { + pushTileUpdate(sharedTileRing!, tile); + } + } + : sharedTileRing + ? (tile: TileRef) => pushTileUpdate(sharedTileRing!, tile) + : undefined, message.sharedStateBuffer, ).then((gr) => { sendMessage({ diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index 4fd110732..1d824546d 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -25,6 +25,7 @@ export class WorkerClient { private clientID: ClientID, private sharedTileRingBuffers?: SharedTileRingBuffers, private sharedStateBuffer?: SharedArrayBuffer, + private sharedDirtyBuffer?: SharedArrayBuffer, ) { this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url)); this.messageHandlers = new Map(); @@ -76,6 +77,7 @@ export class WorkerClient { sharedTileRingHeader: this.sharedTileRingBuffers?.header, sharedTileRingData: this.sharedTileRingBuffers?.data, sharedStateBuffer: this.sharedStateBuffer, + sharedDirtyBuffer: this.sharedDirtyBuffer, }); // Add timeout for initialization diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index 8dbb45f33..c6b811418 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -38,6 +38,7 @@ export interface InitMessage extends BaseWorkerMessage { sharedTileRingHeader?: SharedArrayBuffer; sharedTileRingData?: SharedArrayBuffer; sharedStateBuffer?: SharedArrayBuffer; + sharedDirtyBuffer?: SharedArrayBuffer; } export interface TurnMessage extends BaseWorkerMessage {