Use SharedArrayBuffer tile state and ring buffer for worker updates

- Share GameMapImpl tile state between worker and main via SharedArrayBuffer
- Add SAB-backed tile update ring buffer to stream tile changes instead of postMessage payloads
- Wire shared state/ring through WorkerClient, Worker.worker, GameRunner, and ClientGameRunner
- Update GameView to skip updateTile when shared state is enabled and consume tile refs from the ring
This commit is contained in:
scamiv
2025-11-26 00:45:06 +01:00
parent 05181d7479
commit 314d8ef25a
8 changed files with 75 additions and 9 deletions
+7
View File
@@ -199,6 +199,11 @@ async function createClientGame(
typeof SharedArrayBuffer !== "undefined" &&
typeof Atomics !== "undefined" &&
isIsolated;
const sharedStateBuffer =
canUseSharedBuffers && gameMap.sharedStateBuffer
? gameMap.sharedStateBuffer
: undefined;
const usesSharedTileState = !!sharedStateBuffer;
if (canUseSharedBuffers) {
// Capacity is number of tile updates that can be queued.
@@ -212,6 +217,7 @@ async function createClientGame(
lobbyConfig.gameStartInfo,
lobbyConfig.clientID,
sharedTileRingBuffers,
sharedStateBuffer,
);
await worker.initialize();
const gameView = new GameView(
@@ -221,6 +227,7 @@ async function createClientGame(
lobbyConfig.clientID,
lobbyConfig.gameStartInfo.gameID,
lobbyConfig.gameStartInfo.players,
usesSharedTileState,
);
const canvas = createCanvas();
+2
View File
@@ -38,12 +38,14 @@ export async function createGameRunner(
mapLoader: GameMapLoader,
callBack: (gu: GameUpdateViewData | ErrorUpdate) => void,
tileUpdateSink?: (update: bigint) => void,
sharedStateBuffer?: SharedArrayBuffer,
): Promise<GameRunner> {
const config = await getConfig(gameStart.config, null);
const gameMap = await loadGameMap(
gameStart.config.gameMap,
gameStart.config.gameMapSize,
mapLoader,
sharedStateBuffer,
);
const random = new PseudoRandom(simpleHash(gameStart.gameID));
+12 -1
View File
@@ -80,6 +80,7 @@ export class GameMapImpl implements GameMap {
height: number,
terrainData: Uint8Array,
private numLandTiles_: number,
stateBuffer?: ArrayBufferLike,
) {
if (terrainData.length !== width * height) {
throw new Error(
@@ -89,7 +90,17 @@ export class GameMapImpl implements GameMap {
this.width_ = width;
this.height_ = height;
this.terrain = terrainData;
this.state = new Uint16Array(width * height);
if (stateBuffer !== undefined) {
const state = new Uint16Array(stateBuffer);
if (state.length !== width * height) {
throw new Error(
`State buffer length ${state.length} doesn't match dimensions ${width}x${height}`,
);
}
this.state = state;
} else {
this.state = new Uint16Array(width * height);
}
// Precompute the LUTs
let ref = 0;
this.refToX = new Array(width * height);
+13 -3
View File
@@ -479,6 +479,7 @@ export class GameView implements GameMap {
private _cosmetics: Map<string, PlayerCosmetics> = new Map();
private _map: GameMap;
private readonly usesSharedTileState: boolean;
constructor(
public worker: WorkerClient,
@@ -487,8 +488,10 @@ export class GameView implements GameMap {
private _myClientID: ClientID,
private _gameID: GameID,
private humans: Player[],
usesSharedTileState: boolean = false,
) {
this._map = this._mapData.gameMap;
this.usesSharedTileState = usesSharedTileState;
this.lastUpdate = null;
this.unitGrid = new UnitGrid(this._map);
this._cosmetics = new Map(
@@ -517,9 +520,16 @@ export class GameView implements GameMap {
this.lastUpdate = gu;
this.updatedTiles = [];
this.lastUpdate.packedTileUpdates.forEach((tu) => {
this.updatedTiles.push(this.updateTile(tu));
});
if (this.usesSharedTileState) {
this.lastUpdate.packedTileUpdates.forEach((tu) => {
const tileRef = Number(tu >> 16n);
this.updatedTiles.push(tileRef);
});
} else {
this.lastUpdate.packedTileUpdates.forEach((tu) => {
this.updatedTiles.push(this.updateTile(tu));
});
}
if (gu.updates === null) {
throw new Error("lastUpdate.updates not initialized");
+37 -5
View File
@@ -6,6 +6,7 @@ export type TerrainMapData = {
nations: Nation[];
gameMap: GameMap;
miniGameMap: GameMap;
sharedStateBuffer?: SharedArrayBuffer;
};
const loadedMaps = new Map<GameMapType, TerrainMapData>();
@@ -35,15 +36,37 @@ export async function loadTerrainMap(
map: GameMapType,
mapSize: GameMapSize,
terrainMapFileLoader: GameMapLoader,
sharedStateBuffer?: SharedArrayBuffer,
): Promise<TerrainMapData> {
const cached = loadedMaps.get(map);
if (cached !== undefined) return cached;
const useCache = sharedStateBuffer === undefined;
if (useCache) {
const cached = loadedMaps.get(map);
if (cached !== undefined) return cached;
}
const mapFiles = terrainMapFileLoader.getMapData(map);
const manifest = await mapFiles.manifest();
const stateBuffer =
sharedStateBuffer ??
(typeof SharedArrayBuffer !== "undefined" &&
typeof Atomics !== "undefined" &&
// crossOriginIsolated is only defined in browser contexts
typeof (globalThis as any).crossOriginIsolated === "boolean" &&
(globalThis as any).crossOriginIsolated === true
? new SharedArrayBuffer(
manifest.map.width *
manifest.map.height *
Uint16Array.BYTES_PER_ELEMENT,
)
: undefined);
const gameMap =
mapSize === GameMapSize.Normal
? await genTerrainFromBin(manifest.map, await mapFiles.mapBin())
? await genTerrainFromBin(
manifest.map,
await mapFiles.mapBin(),
stateBuffer,
)
: await genTerrainFromBin(manifest.map4x, await mapFiles.map4xBin());
const miniMap =
@@ -63,18 +86,26 @@ export async function loadTerrainMap(
});
}
const result = {
const result: TerrainMapData = {
nations: manifest.nations,
gameMap: gameMap,
miniGameMap: miniMap,
sharedStateBuffer:
typeof SharedArrayBuffer !== "undefined" &&
stateBuffer instanceof SharedArrayBuffer
? stateBuffer
: undefined,
};
loadedMaps.set(map, result);
if (useCache) {
loadedMaps.set(map, result);
}
return result;
}
export async function genTerrainFromBin(
mapData: MapMetadata,
data: Uint8Array,
stateBuffer?: ArrayBufferLike,
): Promise<GameMap> {
if (data.length !== mapData.width * mapData.height) {
throw new Error(
@@ -87,5 +118,6 @@ export async function genTerrainFromBin(
mapData.height,
data,
mapData.num_land_tiles,
stateBuffer,
);
}
+1
View File
@@ -85,6 +85,7 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
sharedTileRing
? (update: bigint) => pushTileUpdate(sharedTileRing!, update)
: undefined,
message.sharedStateBuffer,
).then((gr) => {
sendMessage({
type: "initialized",
+2
View File
@@ -24,6 +24,7 @@ export class WorkerClient {
private gameStartInfo: GameStartInfo,
private clientID: ClientID,
private sharedTileRingBuffers?: SharedTileRingBuffers,
private sharedStateBuffer?: SharedArrayBuffer,
) {
this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url));
this.messageHandlers = new Map();
@@ -74,6 +75,7 @@ export class WorkerClient {
clientID: this.clientID,
sharedTileRingHeader: this.sharedTileRingBuffers?.header,
sharedTileRingData: this.sharedTileRingBuffers?.data,
sharedStateBuffer: this.sharedStateBuffer,
});
// Add timeout for initialization
+1
View File
@@ -37,6 +37,7 @@ export interface InitMessage extends BaseWorkerMessage {
clientID: ClientID;
sharedTileRingHeader?: SharedArrayBuffer;
sharedTileRingData?: SharedArrayBuffer;
sharedStateBuffer?: SharedArrayBuffer;
}
export interface TurnMessage extends BaseWorkerMessage {