From 7b969a72319fd69b3936dc1476420e1c4c108c39 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:12:24 +0100 Subject: [PATCH 1/2] Revert "dedup tileRef for tileUpdateSink(tileRef)" This reverts commit 08a2ff906b3ca833cc3babb026432fdf4fe4ce53. --- src/core/GameRunner.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index af063633f..eddb1223d 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -181,13 +181,9 @@ export class GameRunner { let packedTileUpdates: BigUint64Array; const tileUpdates = updates[GameUpdateType.Tile]; if (this.tileUpdateSink !== undefined) { - const seenTiles = new Set(); for (const u of tileUpdates) { const tileRef = Number(u.update >> 16n) as TileRef; - if (!seenTiles.has(tileRef)) { - seenTiles.add(tileRef); - this.tileUpdateSink(tileRef); - } + this.tileUpdateSink(tileRef); } packedTileUpdates = new BigUint64Array(); } else { From ecabf3577ed8838a85ebe4cbe2e9aadd385e7c94 Mon Sep 17 00:00:00 2001 From: scamiv <6170744+scamiv@users.noreply.github.com> Date: Wed, 26 Nov 2025 17:31:36 +0100 Subject: [PATCH 2/2] Use dirty flags to coalesce tile updates in SAB ring - Extend SharedTileRing to include a shared dirtyFlags buffer alongside header and data - Pass shared dirty buffer through WorkerClient/WorkerMessages and initialize views in Worker.worker - In SAB mode, mark tiles dirty via Atomics.compareExchange before enqueuing to ensure each tile is queued at most once until processed - On the main thread, clear dirty flags when draining the ring and build packedTileUpdates from distinct tile refs - Keep non-SAB behaviour unchanged while reducing ring pressure and making overflows reflect true backlog, not duplicate updates --- src/client/ClientGameRunner.ts | 30 ++++++++++++++++++++++++++---- src/core/game/TerrainMapLoader.ts | 2 ++ src/core/worker/SharedTileRing.ts | 8 +++++++- src/core/worker/Worker.worker.ts | 23 ++++++++++++++++++++--- src/core/worker/WorkerClient.ts | 2 ++ src/core/worker/WorkerMessages.ts | 1 + 6 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 83c4e5f40..d5b535500 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -173,6 +173,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 @@ -191,8 +193,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( @@ -200,6 +208,7 @@ async function createClientGame( lobbyConfig.clientID, sharedTileRingBuffers, sharedStateBuffer, + sharedDirtyBuffer, ); await worker.initialize(); const gameView = new GameView( @@ -228,6 +237,7 @@ async function createClientGame( worker, gameView, sharedTileRingViews, + sharedDirtyFlags, ); } @@ -260,6 +270,7 @@ export class ClientGameRunner { private pendingStart = 0; private isProcessingUpdates = false; private tileRingViews: SharedTileRingViews | null; + private dirtyFlags: Uint8Array | null; constructor( private lobby: LobbyConfig, @@ -270,9 +281,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) { @@ -682,22 +695,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 {