diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 8aee55653..cf0be6538 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -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(); diff --git a/src/client/graphics/layers/TerritoryWebGLRenderer.ts b/src/client/graphics/layers/TerritoryWebGLRenderer.ts index 034ed0cad..1ea43c830 100644 --- a/src/client/graphics/layers/TerritoryWebGLRenderer.ts +++ b/src/client/graphics/layers/TerritoryWebGLRenderer.ts @@ -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; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index 532c2537f..fdc653876 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -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(); } diff --git a/src/core/worker/SharedTileRing.ts b/src/core/worker/SharedTileRing.ts index 328def730..06ad8f6e7 100644 --- a/src/core/worker/SharedTileRing.ts +++ b/src/core/worker/SharedTileRing.ts @@ -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, }; } diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index b6aeb7ec8..b5f9b59ca 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -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) => { 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, diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index 1d824546d..650c6cddb 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -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 diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index c6b811418..de2fb2198 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -39,6 +39,8 @@ export interface InitMessage extends BaseWorkerMessage { sharedTileRingData?: SharedArrayBuffer; sharedStateBuffer?: SharedArrayBuffer; sharedDirtyBuffer?: SharedArrayBuffer; + sharedDrawPhaseBuffer?: SharedArrayBuffer; + timeBaseMs?: number; } export interface TurnMessage extends BaseWorkerMessage {