mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 19:46:43 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface InitMessage extends BaseWorkerMessage {
|
||||
sharedTileRingData?: SharedArrayBuffer;
|
||||
sharedStateBuffer?: SharedArrayBuffer;
|
||||
sharedDirtyBuffer?: SharedArrayBuffer;
|
||||
sharedDrawPhaseBuffer?: SharedArrayBuffer;
|
||||
timeBaseMs?: number;
|
||||
}
|
||||
|
||||
export interface TurnMessage extends BaseWorkerMessage {
|
||||
|
||||
Reference in New Issue
Block a user