Implement shared draw phase buffer and time base in game rendering

- Added support for a shared draw phase buffer in the ClientGameRunner and TerritoryWebGLRenderer to manage tile rendering phases.
- Introduced time base management to synchronize rendering updates based on the game state.
- Updated relevant classes and methods to accommodate the new shared draw phase and time base, enhancing the rendering pipeline.
This commit is contained in:
scamiv
2025-12-05 17:59:55 +01:00
parent 6000a3fa3b
commit c6dde7a021
7 changed files with 182 additions and 11 deletions
+7
View File
@@ -194,6 +194,7 @@ async function createClientGame(
let sharedTileRingViews: SharedTileRingViews | null = null;
let sharedDirtyBuffer: SharedArrayBuffer | undefined;
let sharedDirtyFlags: Uint8Array | null = null;
let sharedDrawPhaseBuffer: SharedArrayBuffer | undefined;
const isIsolated =
typeof (globalThis as any).crossOriginIsolated === "boolean"
? (globalThis as any).crossOriginIsolated === true
@@ -219,14 +220,18 @@ async function createClientGame(
sharedTileRingViews = createSharedTileRingViews(sharedTileRingBuffers);
sharedDirtyBuffer = sharedTileRingBuffers.dirty;
sharedDirtyFlags = sharedTileRingViews.dirtyFlags;
sharedDrawPhaseBuffer = sharedTileRingBuffers.drawPhase;
}
const timeBaseMs = Date.now();
const worker = new WorkerClient(
lobbyConfig.gameStartInfo,
lobbyConfig.clientID,
sharedTileRingBuffers,
sharedStateBuffer,
sharedDirtyBuffer,
sharedDrawPhaseBuffer,
timeBaseMs,
);
await worker.initialize();
const gameView = new GameView(
@@ -237,6 +242,8 @@ async function createClientGame(
lobbyConfig.gameStartInfo.gameID,
lobbyConfig.gameStartInfo.players,
usesSharedTileState,
sharedDrawPhaseBuffer,
timeBaseMs,
);
const canvas = createCanvas();
@@ -32,11 +32,15 @@ export class TerritoryWebGLRenderer {
private readonly stateTexture: WebGLTexture | null;
private readonly paletteTexture: WebGLTexture | null;
private readonly relationTexture: WebGLTexture | null;
private readonly drawPhaseTexture: WebGLTexture | null;
private readonly drawPhase: Uint32Array;
private readonly uniforms: {
resolution: WebGLUniformLocation | null;
state: WebGLUniformLocation | null;
palette: WebGLUniformLocation | null;
relations: WebGLUniformLocation | null;
drawPhase: WebGLUniformLocation | null;
nowMs: WebGLUniformLocation | null;
fallout: WebGLUniformLocation | null;
altSelf: WebGLUniformLocation | null;
altAlly: WebGLUniformLocation | null;
@@ -68,6 +72,7 @@ export class TerritoryWebGLRenderer {
private needsFullUpload = true;
private alternativeView = false;
private paletteWidth = 0;
private readonly timeBaseMs: number;
private hoverHighlightStrength = 0.7;
private hoverHighlightColor: [number, number, number] = [1, 1, 1];
private hoverPulseStrength = 0.25;
@@ -80,11 +85,17 @@ export class TerritoryWebGLRenderer {
private readonly theme: Theme,
sharedState: SharedArrayBuffer,
) {
this.timeBaseMs = game.timeBaseMs() ?? Date.now();
this.canvas = document.createElement("canvas");
this.canvas.width = game.width();
this.canvas.height = game.height();
this.state = new Uint16Array(sharedState);
const drawPhaseBuffer = game.sharedDrawPhaseBuffer();
const numTiles = this.canvas.width * this.canvas.height;
this.drawPhase = drawPhaseBuffer
? new Uint32Array(drawPhaseBuffer)
: new Uint32Array(numTiles);
this.gl = this.canvas.getContext("webgl2", {
premultipliedAlpha: true,
@@ -99,11 +110,14 @@ export class TerritoryWebGLRenderer {
this.stateTexture = null;
this.paletteTexture = null;
this.relationTexture = null;
this.drawPhaseTexture = null;
this.uniforms = {
resolution: null,
state: null,
palette: null,
relations: null,
drawPhase: null,
nowMs: null,
fallout: null,
altSelf: null,
altAlly: null,
@@ -139,11 +153,14 @@ export class TerritoryWebGLRenderer {
this.stateTexture = null;
this.paletteTexture = null;
this.relationTexture = null;
this.drawPhaseTexture = null;
this.uniforms = {
resolution: null,
state: null,
palette: null,
relations: null,
drawPhase: null,
nowMs: null,
fallout: null,
altSelf: null,
altAlly: null,
@@ -176,6 +193,8 @@ export class TerritoryWebGLRenderer {
state: gl.getUniformLocation(this.program, "u_state"),
palette: gl.getUniformLocation(this.program, "u_palette"),
relations: gl.getUniformLocation(this.program, "u_relations"),
drawPhase: gl.getUniformLocation(this.program, "u_drawPhase"),
nowMs: gl.getUniformLocation(this.program, "u_nowMs"),
fallout: gl.getUniformLocation(this.program, "u_fallout"),
altSelf: gl.getUniformLocation(this.program, "u_altSelf"),
altAlly: gl.getUniformLocation(this.program, "u_altAlly"),
@@ -278,12 +297,35 @@ export class TerritoryWebGLRenderer {
this.state,
);
this.drawPhaseTexture = gl.createTexture();
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, this.drawPhaseTexture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.R32UI,
this.canvas.width,
this.canvas.height,
0,
gl.RED_INTEGER,
gl.UNSIGNED_INT,
this.drawPhase,
);
this.uploadPalette();
gl.useProgram(this.program);
gl.uniform1i(this.uniforms.state, 0);
gl.uniform1i(this.uniforms.palette, 1);
gl.uniform1i(this.uniforms.relations, 2);
if (this.uniforms.drawPhase) {
gl.uniform1i(this.uniforms.drawPhase, 3);
}
if (this.uniforms.resolution) {
gl.uniform2f(
@@ -509,6 +551,10 @@ export class TerritoryWebGLRenderer {
const viewerId = this.game.myPlayer()?.smallID() ?? 0;
gl.uniform1i(this.uniforms.viewerId, viewerId);
}
if (this.uniforms.nowMs) {
const nowOffset = Math.max(0, Date.now() - this.timeBaseMs);
gl.uniform1ui(this.uniforms.nowMs, nowOffset >>> 0);
}
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
@@ -524,6 +570,7 @@ export class TerritoryWebGLRenderer {
gl.bindTexture(gl.TEXTURE_2D, this.stateTexture);
const bytesPerPixel = Uint16Array.BYTES_PER_ELEMENT;
const hasDrawPhase = !!this.drawPhaseTexture;
let rowsUploaded = 0;
let bytesUploaded = 0;
@@ -539,6 +586,22 @@ export class TerritoryWebGLRenderer {
gl.UNSIGNED_SHORT,
this.state,
);
if (hasDrawPhase) {
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, this.drawPhaseTexture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.R32UI,
this.canvas.width,
this.canvas.height,
0,
gl.RED_INTEGER,
gl.UNSIGNED_INT,
this.drawPhase,
);
gl.activeTexture(gl.TEXTURE0);
}
this.needsFullUpload = false;
this.dirtyRows.clear();
rowsUploaded = this.canvas.height;
@@ -565,6 +628,23 @@ export class TerritoryWebGLRenderer {
gl.UNSIGNED_SHORT,
rowSlice,
);
if (hasDrawPhase) {
gl.activeTexture(gl.TEXTURE3);
gl.bindTexture(gl.TEXTURE_2D, this.drawPhaseTexture);
const phaseSlice = this.drawPhase.subarray(offset, offset + width);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
span.minX,
y,
width,
1,
gl.RED_INTEGER,
gl.UNSIGNED_INT,
phaseSlice,
);
gl.activeTexture(gl.TEXTURE0);
}
rowsUploaded++;
bytesUploaded += width * bytesPerPixel;
}
@@ -719,8 +799,10 @@ export class TerritoryWebGLRenderer {
uniform usampler2D u_state;
uniform sampler2D u_palette;
uniform usampler2D u_relations;
uniform usampler2D u_drawPhase;
uniform int u_viewerId;
uniform vec2 u_resolution;
uniform uint u_nowMs;
uniform vec4 u_fallout;
uniform vec4 u_altSelf;
uniform vec4 u_altAlly;
@@ -784,6 +866,12 @@ export class TerritoryWebGLRenderer {
bool hasFallout = (state & 0x2000u) != 0u; // bit 13
bool isDefended = (state & 0x1000u) != 0u; // bit 12
uint revealTime = texelFetch(u_drawPhase, texCoord, 0).r;
if (u_nowMs < revealTime) {
outColor = vec4(0.0);
return;
}
if (owner == 0u) {
if (hasFallout) {
vec3 color = u_fallout.rgb;
+14
View File
@@ -593,6 +593,8 @@ export class GameView implements GameMap {
private _map: GameMap;
private readonly usesSharedTileState: boolean;
private readonly _sharedDrawPhaseBuffer?: SharedArrayBuffer;
private readonly _timeBaseMs?: number;
private readonly terraNullius = new TerraNulliusImpl();
constructor(
@@ -603,9 +605,13 @@ export class GameView implements GameMap {
private _gameID: GameID,
private humans: Player[],
usesSharedTileState: boolean = false,
sharedDrawPhaseBuffer?: SharedArrayBuffer,
timeBaseMs?: number,
) {
this._map = this._mapData.gameMap;
this.usesSharedTileState = usesSharedTileState;
this._sharedDrawPhaseBuffer = sharedDrawPhaseBuffer;
this._timeBaseMs = timeBaseMs;
this.lastUpdate = null;
this.unitGrid = new UnitGrid(this._map);
this._cosmetics = new Map(
@@ -930,6 +936,14 @@ export class GameView implements GameMap {
return buffer instanceof SharedArrayBuffer ? buffer : undefined;
}
sharedDrawPhaseBuffer(): SharedArrayBuffer | undefined {
return this._sharedDrawPhaseBuffer;
}
timeBaseMs(): number | undefined {
return this._timeBaseMs;
}
focusedPlayer(): PlayerView | null {
return this.myPlayer();
}
+8 -1
View File
@@ -4,12 +4,14 @@ export interface SharedTileRingBuffers {
header: SharedArrayBuffer;
data: SharedArrayBuffer;
dirty: SharedArrayBuffer;
drawPhase: SharedArrayBuffer;
}
export interface SharedTileRingViews {
header: Int32Array;
buffer: Uint32Array;
dirtyFlags: Uint8Array;
drawPhase: Uint32Array;
capacity: number;
}
@@ -25,7 +27,10 @@ export function createSharedTileRingBuffers(
const header = new SharedArrayBuffer(3 * Int32Array.BYTES_PER_ELEMENT);
const data = new SharedArrayBuffer(capacity * Uint32Array.BYTES_PER_ELEMENT);
const dirty = new SharedArrayBuffer(numTiles * Uint8Array.BYTES_PER_ELEMENT);
return { header, data, dirty };
const drawPhase = new SharedArrayBuffer(
numTiles * Uint32Array.BYTES_PER_ELEMENT,
);
return { header, data, dirty, drawPhase };
}
export function createSharedTileRingViews(
@@ -34,10 +39,12 @@ export function createSharedTileRingViews(
const header = new Int32Array(buffers.header);
const buffer = new Uint32Array(buffers.data);
const dirtyFlags = new Uint8Array(buffers.dirty);
const drawPhase = new Uint32Array(buffers.drawPhase);
return {
header,
buffer,
dirtyFlags,
drawPhase,
capacity: buffer.length,
};
}
+59 -10
View File
@@ -1,6 +1,7 @@
import version from "../../../resources/version.txt";
import { createGameRunner, GameRunner } from "../GameRunner";
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
import { Game } from "../game/Game";
import { TileRef } from "../game/GameMap";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import {
@@ -25,6 +26,13 @@ const mapLoader = new FetchGameMapLoader(`/maps`, version);
let isProcessingTurns = false;
let sharedTileRing: SharedTileRingViews | null = null;
let dirtyFlags: Uint8Array | null = null;
let sharedDrawPhase: Uint32Array | null = null;
let lastOwner: Uint16Array | null = null;
let timeBaseMs = Date.now();
let tickNowOffset = 0;
let nextCaptureOffset = 0;
const STAGGER_MS = 2;
let gameRef: Game | null = null;
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
// skip if ErrorUpdate
@@ -57,6 +65,8 @@ async function processPendingTurns() {
isProcessingTurns = true;
try {
while (gr.hasPendingTurns()) {
tickNowOffset = Math.max(0, Date.now() - timeBaseMs);
nextCaptureOffset = 0;
gr.executeNextTick();
}
} finally {
@@ -73,35 +83,74 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
if (
message.sharedTileRingHeader &&
message.sharedTileRingData &&
message.sharedDirtyBuffer
message.sharedDirtyBuffer &&
message.sharedDrawPhaseBuffer
) {
sharedTileRing = createSharedTileRingViews({
header: message.sharedTileRingHeader,
data: message.sharedTileRingData,
dirty: message.sharedDirtyBuffer,
drawPhase: message.sharedDrawPhaseBuffer,
});
dirtyFlags = sharedTileRing.dirtyFlags;
sharedDrawPhase = sharedTileRing.drawPhase;
} else {
sharedTileRing = null;
dirtyFlags = null;
sharedDrawPhase = null;
}
timeBaseMs = message.timeBaseMs ?? Date.now();
const tileUpdateSink =
sharedTileRing || sharedDrawPhase
? (tile: TileRef) => {
if (sharedTileRing && dirtyFlags) {
if (Atomics.compareExchange(dirtyFlags, tile, 0, 1) === 0) {
pushTileUpdate(sharedTileRing, tile);
}
} else if (sharedTileRing) {
pushTileUpdate(sharedTileRing, tile);
}
if (!sharedDrawPhase || !gameRef || !lastOwner) {
return;
}
const newOwner = gameRef.ownerID(tile);
const prevOwner = lastOwner[tile];
const ownerChanged = newOwner !== prevOwner;
lastOwner[tile] = newOwner;
const nowOffset = tickNowOffset;
let reveal = nowOffset;
if (ownerChanged) {
const offset = nowOffset - nextCaptureOffset * STAGGER_MS;
reveal = offset <= 0 ? 0 : offset >>> 0;
nextCaptureOffset++;
}
sharedDrawPhase[tile] = reveal >>> 0;
}
: undefined;
gameRunner = createGameRunner(
message.gameStartInfo,
message.clientID,
mapLoader,
gameUpdate,
sharedTileRing && dirtyFlags
? (tile: TileRef) => {
if (Atomics.compareExchange(dirtyFlags!, tile, 0, 1) === 0) {
pushTileUpdate(sharedTileRing!, tile);
}
}
: sharedTileRing
? (tile: TileRef) => pushTileUpdate(sharedTileRing!, tile)
: undefined,
tileUpdateSink,
message.sharedStateBuffer,
).then((gr) => {
gameRef = gr.game;
const map = gameRef.map();
const numTiles = map.width() * map.height();
lastOwner = new Uint16Array(numTiles);
map.forEachTile((tile) => {
lastOwner![tile] = map.ownerID(tile);
});
tickNowOffset = Math.max(0, Date.now() - timeBaseMs);
if (sharedDrawPhase) {
sharedDrawPhase.fill(tickNowOffset >>> 0);
}
sendMessage({
type: "initialized",
id: message.id,
+4
View File
@@ -26,6 +26,8 @@ export class WorkerClient {
private sharedTileRingBuffers?: SharedTileRingBuffers,
private sharedStateBuffer?: SharedArrayBuffer,
private sharedDirtyBuffer?: SharedArrayBuffer,
private sharedDrawPhaseBuffer?: SharedArrayBuffer,
private timeBaseMs?: number,
) {
this.worker = new Worker(new URL("./Worker.worker.ts", import.meta.url));
this.messageHandlers = new Map();
@@ -78,6 +80,8 @@ export class WorkerClient {
sharedTileRingData: this.sharedTileRingBuffers?.data,
sharedStateBuffer: this.sharedStateBuffer,
sharedDirtyBuffer: this.sharedDirtyBuffer,
sharedDrawPhaseBuffer: this.sharedDrawPhaseBuffer,
timeBaseMs: this.timeBaseMs,
});
// Add timeout for initialization
+2
View File
@@ -39,6 +39,8 @@ export interface InitMessage extends BaseWorkerMessage {
sharedTileRingData?: SharedArrayBuffer;
sharedStateBuffer?: SharedArrayBuffer;
sharedDirtyBuffer?: SharedArrayBuffer;
sharedDrawPhaseBuffer?: SharedArrayBuffer;
timeBaseMs?: number;
}
export interface TurnMessage extends BaseWorkerMessage {