This commit is contained in:
scamiv
2026-01-25 15:42:29 +01:00
parent e528988d50
commit 91a50cec36
8 changed files with 1388 additions and 8 deletions
+11 -2
View File
@@ -28,6 +28,7 @@ import {
readTerritoryShaderId,
} from "../webgpu/render/TerritoryShaderRegistry";
import { TerritoryRenderer } from "../webgpu/TerritoryRenderer";
import { TerritoryRendererProxy } from "../webgpu/TerritoryRendererProxy";
import { Layer } from "./Layer";
export class TerritoryLayer implements Layer {
@@ -42,7 +43,8 @@ export class TerritoryLayer implements Layer {
private theme: Theme;
private territoryRenderer: TerritoryRenderer | null = null;
private territoryRenderer: TerritoryRenderer | TerritoryRendererProxy | null =
null;
private alternativeView = false;
private lastPaletteSignature: string | null = null;
@@ -119,9 +121,11 @@ export class TerritoryLayer implements Layer {
}
private configureRenderer() {
const { renderer, reason } = TerritoryRenderer.create(
// Use proxy to render in worker thread
const { renderer, reason } = TerritoryRendererProxy.create(
this.game,
this.theme,
this.game.worker,
);
if (!renderer) {
throw new Error(reason ?? "WebGPU is required for territory rendering.");
@@ -189,6 +193,11 @@ export class TerritoryLayer implements Layer {
const canvas = this.territoryRenderer.canvas;
// Canvas must be HTMLCanvasElement for DOM operations (proxy always provides this)
if (!(canvas instanceof HTMLCanvasElement)) {
return;
}
// If the renderer recreated its canvas, detach the old one.
if (this.attachedTerritoryCanvas !== canvas) {
this.attachedTerritoryCanvas?.remove();
@@ -0,0 +1,301 @@
import { createCanvas } from "src/client/Utils";
import { Theme } from "../../../core/configuration/Config";
import { TileRef } from "../../../core/game/GameMap";
import { GameView } from "../../../core/game/GameView";
import { WorkerClient } from "../../../core/worker/WorkerClient";
import {
InitRendererMessage,
MarkAllDirtyMessage,
MarkTileMessage,
RefreshPaletteMessage,
RefreshTerrainMessage,
RenderFrameMessage,
SetAlternativeViewMessage,
SetHighlightedOwnerMessage,
SetShaderSettingsMessage,
SetViewSizeMessage,
SetViewTransformMessage,
TickRendererMessage,
} from "../../../core/worker/WorkerMessages";
export interface TerritoryWebGLCreateResult {
renderer: TerritoryRendererProxy | null;
reason?: string;
}
/**
* Proxy for TerritoryRenderer that forwards calls to worker thread.
* Manages canvas transfer and message routing.
*/
export class TerritoryRendererProxy {
public readonly canvas: HTMLCanvasElement;
private offscreenCanvas: OffscreenCanvas | null = null;
private worker: WorkerClient | null = null;
private ready = false;
private initPromise: Promise<void> | null = null;
private pendingMessages: any[] = [];
private constructor(
private readonly game: GameView,
private readonly theme: Theme,
) {
this.canvas = createCanvas();
this.canvas.style.pointerEvents = "none";
this.canvas.width = 1;
this.canvas.height = 1;
}
static create(
game: GameView,
theme: Theme,
worker: WorkerClient,
): TerritoryWebGLCreateResult {
const nav = globalThis.navigator as any;
if (!nav?.gpu || typeof nav.gpu.requestAdapter !== "function") {
return {
renderer: null,
reason: "WebGPU not available; GPU renderer disabled.",
};
}
if (typeof OffscreenCanvas === "undefined") {
return {
renderer: null,
reason: "OffscreenCanvas not supported; GPU renderer disabled.",
};
}
const state = game.tileStateView();
const expected = game.width() * game.height();
if (state.length !== expected) {
return {
renderer: null,
reason: "Tile state buffer size mismatch; GPU renderer disabled.",
};
}
const renderer = new TerritoryRendererProxy(game, theme);
renderer.worker = worker;
renderer.startInit();
return { renderer };
}
private startInit(): void {
if (this.initPromise) return;
this.initPromise = this.init();
}
private async init(): Promise<void> {
if (!this.worker) {
throw new Error("Worker not set");
}
// Transfer canvas control to offscreen
this.offscreenCanvas = this.canvas.transferControlToOffscreen();
// Send init message to worker
// Determine dark mode from theme (check if it has darkShore property, same as GroundTruthData)
const themeAny = this.theme as any;
const darkMode = themeAny.darkShore !== undefined;
const messageId = `init_renderer_${Date.now()}`;
const initMessage: InitRendererMessage = {
type: "init_renderer",
id: messageId,
offscreenCanvas: this.offscreenCanvas,
darkMode: darkMode,
};
// Transfer the offscreen canvas
this.worker.postMessage(initMessage, [this.offscreenCanvas]);
// Wait for renderer ready
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
this.worker?.removeMessageHandler(messageId);
reject(new Error("Renderer initialization timeout"));
}, 10000);
const handler = (message: any) => {
if (message.type === "renderer_ready" && message.id === messageId) {
clearTimeout(timeout);
this.worker?.removeMessageHandler(messageId);
this.ready = true;
// Send any pending messages
for (const msg of this.pendingMessages) {
this.sendToWorker(msg);
}
this.pendingMessages = [];
resolve();
}
};
this.worker?.addMessageHandler(messageId, handler);
});
}
private sendToWorker(message: any): void {
if (!this.worker) {
return;
}
if (!this.ready) {
this.pendingMessages.push(message);
return;
}
this.worker.postMessage(message);
}
setViewSize(width: number, height: number): void {
const message: SetViewSizeMessage = {
type: "set_view_size",
width,
height,
};
this.sendToWorker(message);
}
setViewTransform(scale: number, offsetX: number, offsetY: number): void {
const message: SetViewTransformMessage = {
type: "set_view_transform",
scale,
offsetX,
offsetY,
};
this.sendToWorker(message);
}
setAlternativeView(enabled: boolean): void {
const message: SetAlternativeViewMessage = {
type: "set_alternative_view",
enabled,
};
this.sendToWorker(message);
}
setHighlightedOwnerId(ownerSmallId: number | null): void {
const message: SetHighlightedOwnerMessage = {
type: "set_highlighted_owner",
ownerSmallId,
};
this.sendToWorker(message);
}
setTerritoryShader(shaderPath: string): void {
const message: SetShaderSettingsMessage = {
type: "set_shader_settings",
territoryShader: shaderPath,
};
this.sendToWorker(message);
}
setTerrainShader(shaderPath: string): void {
const message: SetShaderSettingsMessage = {
type: "set_shader_settings",
terrainShader: shaderPath,
};
this.sendToWorker(message);
}
setTerritoryShaderParams(
params0: Float32Array | number[],
params1: Float32Array | number[],
): void {
const message: SetShaderSettingsMessage = {
type: "set_shader_settings",
territoryShaderParams0: Array.from(params0),
territoryShaderParams1: Array.from(params1),
};
this.sendToWorker(message);
}
setTerrainShaderParams(
params0: Float32Array | number[],
params1: Float32Array | number[],
): void {
const message: SetShaderSettingsMessage = {
type: "set_shader_settings",
terrainShaderParams0: Array.from(params0),
terrainShaderParams1: Array.from(params1),
};
this.sendToWorker(message);
}
setPreSmoothing(
enabled: boolean,
shaderPath: string,
params0: Float32Array | number[],
): void {
const message: SetShaderSettingsMessage = {
type: "set_shader_settings",
preSmoothing: {
enabled,
shaderPath,
params0: Array.from(params0),
},
};
this.sendToWorker(message);
}
setPostSmoothing(
enabled: boolean,
shaderPath: string,
params0: Float32Array | number[],
): void {
const message: SetShaderSettingsMessage = {
type: "set_shader_settings",
postSmoothing: {
enabled,
shaderPath,
params0: Array.from(params0),
},
};
this.sendToWorker(message);
}
markTile(tile: TileRef): void {
const message: MarkTileMessage = {
type: "mark_tile",
tile,
};
this.sendToWorker(message);
}
markAllDirty(): void {
const message: MarkAllDirtyMessage = {
type: "mark_all_dirty",
};
this.sendToWorker(message);
}
refreshPalette(): void {
const message: RefreshPaletteMessage = {
type: "refresh_palette",
};
this.sendToWorker(message);
}
markDefensePostsDirty(): void {
this.markAllDirty();
}
refreshTerrain(): void {
const message: RefreshTerrainMessage = {
type: "refresh_terrain",
};
this.sendToWorker(message);
}
tick(): void {
const message: TickRendererMessage = {
type: "tick_renderer",
};
this.sendToWorker(message);
}
render(): void {
const message: RenderFrameMessage = {
type: "render_frame",
};
this.sendToWorker(message);
}
}
@@ -19,10 +19,12 @@ export class WebGPUDevice {
/**
* Initialize WebGPU device and canvas context.
* @param canvas Canvas element to configure
* @param canvas Canvas element to configure (HTMLCanvasElement or OffscreenCanvas)
* @returns WebGPUDevice instance or null if WebGPU is not available
*/
static async create(canvas: HTMLCanvasElement): Promise<WebGPUDevice | null> {
static async create(
canvas: HTMLCanvasElement | OffscreenCanvas,
): Promise<WebGPUDevice | null> {
const nav = globalThis.navigator as any;
if (!nav?.gpu || typeof nav.gpu.requestAdapter !== "function") {
return null;
+165
View File
@@ -0,0 +1,165 @@
import { Theme } from "../configuration/Config";
import { Game, UnitType } from "../game/Game";
import { GameUpdateViewData } from "../game/GameUpdates";
import { GameView } from "../game/GameView";
import { TerrainMapData } from "../game/TerrainMapLoader";
/**
* Adapter that makes Game work as GameView for rendering purposes.
* Provides the interface that GroundTruthData and rendering passes need,
* without requiring the full GameView infrastructure.
*/
export class GameViewAdapter implements Partial<GameView> {
private tileStateCache: Uint16Array | null = null;
private terrainDataCache: Uint8Array | null = null;
private lastUpdate: GameUpdateViewData | null = null;
constructor(
private game: Game,
private mapData: TerrainMapData,
private theme: Theme,
) {}
/**
* Update adapter with latest game update data.
* Invalidates caches so they're recomputed on next access.
*/
update(gu: GameUpdateViewData): void {
this.lastUpdate = gu;
// Invalidate caches when updated
this.tileStateCache = null;
this.terrainDataCache = null;
}
config() {
return this.game.config();
}
width(): number {
return this.game.width();
}
height(): number {
return this.game.height();
}
x(tile: bigint): number {
return this.game.x(tile);
}
y(tile: bigint): number {
return this.game.y(tile);
}
units(...types: UnitType[]): any[] {
return this.game.units(...types);
}
/**
* Build tile state view from game.
* Cached until next update.
*/
tileStateView(): Uint16Array {
if (this.tileStateCache) {
return this.tileStateCache;
}
// Build tile state from game
const width = this.game.width();
const height = this.game.height();
const state = new Uint16Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const tile = this.game.ref(x, y);
const owner = this.game.owner(tile);
const ownerId = owner ? owner.smallID() : 0;
const terrain = this.game.terrain(tile);
const terrainType = terrain.type();
const terrainMag = terrain.magnitude();
// Pack state: ownerId (12 bits) | terrainType (2 bits) | terrainMag (2 bits)
state[y * width + x] =
(ownerId & 0xfff) |
((terrainType & 0x3) << 12) |
((terrainMag & 0x3) << 14);
}
}
this.tileStateCache = state;
return state;
}
/**
* Build terrain data view from game.
* Cached until next update.
*/
terrainDataView(): Uint8Array {
if (this.terrainDataCache) {
return this.terrainDataCache;
}
// Build terrain data from game
const width = this.game.width();
const height = this.game.height();
const terrainData = new Uint8Array(width * height);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const tile = this.game.ref(x, y);
const terrain = this.game.terrain(tile);
terrainData[y * width + x] = terrain.type();
}
}
this.terrainDataCache = terrainData;
return terrainData;
}
/**
* Convert Game players to PlayerView-like objects for rendering.
* Computes colors from theme directly (no PlayerView needed).
*/
playerViews(): any[] {
const theme = this.game.config().theme();
return this.game.players().map((p) => {
// Get default colors from theme
const defaultTerritoryColor = theme.territoryColor(p as any);
const defaultBorderColor = theme.borderColor(defaultTerritoryColor);
const territoryRgb = defaultTerritoryColor.toRgb();
const borderRgb = defaultBorderColor.toRgb();
return {
smallID: () => p.smallID(),
territoryColor: () => ({
rgba: {
r: Math.round(territoryRgb.r),
g: Math.round(territoryRgb.g),
b: Math.round(territoryRgb.b),
a: Math.round((territoryRgb.a ?? 1) * 255),
},
}),
borderColor: () => ({
rgba: {
r: Math.round(borderRgb.r),
g: Math.round(borderRgb.g),
b: Math.round(borderRgb.b),
a: Math.round((borderRgb.a ?? 1) * 255),
},
}),
};
});
}
/**
* Get my player for highlighting (returns null in worker context).
*/
myPlayer(): any | null {
// Return null for now - this is used for highlighting
// Could be implemented if we track clientID in worker
return null;
}
/**
* Get recently updated tiles from last game update.
*/
recentlyUpdatedTiles(): bigint[] {
if (!this.lastUpdate) {
return [];
}
return Array.from(this.lastUpdate.packedTileUpdates);
}
}
+163 -1
View File
@@ -1,7 +1,12 @@
import version from "resources/version.txt?raw";
import { createGameRunner, GameRunner } from "../GameRunner";
import { Theme } from "../configuration/Config";
import { PastelTheme } from "../configuration/PastelTheme";
import { PastelThemeDark } from "../configuration/PastelThemeDark";
import { FetchGameMapLoader } from "../game/FetchGameMapLoader";
import { ErrorUpdate, GameUpdateViewData } from "../game/GameUpdates";
import { loadTerrainMap, TerrainMapData } from "../game/TerrainMapLoader";
import { createGameRunner, GameRunner } from "../GameRunner";
import { GameStartInfo } from "../Schemas";
import {
AttackAveragePositionResultMessage,
InitializedMessage,
@@ -9,19 +14,28 @@ import {
PlayerActionsResultMessage,
PlayerBorderTilesResultMessage,
PlayerProfileResultMessage,
RendererReadyMessage,
TransportShipSpawnResultMessage,
WorkerMessage,
} from "./WorkerMessages";
import { WorkerTerritoryRenderer } from "./WorkerTerritoryRenderer";
const ctx: Worker = self as any;
let gameRunner: Promise<GameRunner> | null = null;
let gameStartInfo: GameStartInfo | null = null;
const mapLoader = new FetchGameMapLoader(`/maps`, version);
let renderer: WorkerTerritoryRenderer | null = null;
let mapData: TerrainMapData | null = null;
function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) {
// skip if ErrorUpdate
if (!("updates" in gu)) {
return;
}
// Update renderer with game update
if (renderer) {
renderer.updateGameView(gu);
}
sendMessage({
type: "game_update",
gameUpdate: gu,
@@ -41,6 +55,7 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
break;
case "init":
try {
gameStartInfo = message.gameStartInfo;
gameRunner = createGameRunner(
message.gameStartInfo,
message.clientID,
@@ -170,6 +185,153 @@ ctx.addEventListener("message", async (e: MessageEvent<MainThreadMessage>) => {
console.error("Failed to spawn transport ship:", error);
}
break;
case "init_renderer":
try {
if (!gameRunner || !gameStartInfo) {
throw new Error("Game runner not initialized");
}
const gr = await gameRunner;
// Load map data if not already loaded
// Use gameStartInfo.config which has the original game map info
mapData ??= await loadTerrainMap(
gameStartInfo.config.gameMap,
gameStartInfo.config.gameMapSize,
mapLoader,
);
// Create theme based on darkMode flag from main thread
// (can't access userSettings in worker, so it's passed from main thread)
const theme: Theme = message.darkMode
? new PastelThemeDark()
: new PastelTheme();
renderer = new WorkerTerritoryRenderer();
await renderer.init(message.offscreenCanvas, gr, mapData, theme);
sendMessage({
type: "renderer_ready",
id: message.id,
} as RendererReadyMessage);
} catch (error) {
console.error("Failed to initialize renderer:", error);
sendMessage({
type: "renderer_ready",
id: message.id,
} as RendererReadyMessage);
throw error;
}
break;
case "set_view_size":
if (renderer) {
renderer.setViewSize(message.width, message.height);
}
break;
case "set_view_transform":
if (renderer) {
renderer.setViewTransform(
message.scale,
message.offsetX,
message.offsetY,
);
}
break;
case "set_alternative_view":
if (renderer) {
renderer.setAlternativeView(message.enabled);
}
break;
case "set_highlighted_owner":
if (renderer) {
renderer.setHighlightedOwnerId(message.ownerSmallId);
}
break;
case "set_shader_settings":
if (renderer) {
if (message.territoryShader) {
renderer.setTerritoryShader(message.territoryShader);
}
if (message.territoryShaderParams0 && message.territoryShaderParams1) {
renderer.setTerritoryShaderParams(
message.territoryShaderParams0,
message.territoryShaderParams1,
);
}
if (message.terrainShader) {
renderer.setTerrainShader(message.terrainShader);
}
if (message.terrainShaderParams0 && message.terrainShaderParams1) {
renderer.setTerrainShaderParams(
message.terrainShaderParams0,
message.terrainShaderParams1,
);
}
if (message.preSmoothing) {
renderer.setPreSmoothing(
message.preSmoothing.enabled,
message.preSmoothing.shaderPath,
message.preSmoothing.params0,
);
}
if (message.postSmoothing) {
renderer.setPostSmoothing(
message.postSmoothing.enabled,
message.postSmoothing.shaderPath,
message.postSmoothing.params0,
);
}
}
break;
case "mark_tile":
if (renderer) {
renderer.markTile(message.tile);
}
break;
case "mark_all_dirty":
if (renderer) {
renderer.markAllDirty();
}
break;
case "refresh_palette":
if (renderer) {
renderer.refreshPalette();
}
break;
case "refresh_terrain":
if (renderer) {
renderer.refreshTerrain();
}
break;
case "tick_renderer":
if (renderer) {
const start = performance.now();
renderer.tick();
const computeMs = performance.now() - start;
sendMessage({
type: "renderer_metrics",
computeMs,
});
}
break;
case "render_frame":
if (renderer) {
renderer.render();
}
break;
default:
console.warn("Unknown message :", message);
}
+25
View File
@@ -46,6 +46,7 @@ export class WorkerClient {
break;
case "initialized":
case "renderer_ready":
default:
if (message.id && this.messageHandlers.has(message.id)) {
const handler = this.messageHandlers.get(message.id)!;
@@ -56,6 +57,30 @@ export class WorkerClient {
}
}
/**
* Add a message handler for a specific message ID.
*/
addMessageHandler(
id: string,
handler: (message: WorkerMessage) => void,
): void {
this.messageHandlers.set(id, handler);
}
/**
* Remove a message handler.
*/
removeMessageHandler(id: string): void {
this.messageHandlers.delete(id);
}
/**
* Post a message to the worker with optional transferables.
*/
postMessage(message: any, transfer?: Transferable[]): void {
this.worker.postMessage(message, transfer);
}
initialize(): Promise<void> {
return new Promise((resolve, reject) => {
const messageId = generateID();
+116 -3
View File
@@ -23,7 +23,21 @@ export type WorkerMessageType =
| "attack_average_position"
| "attack_average_position_result"
| "transport_ship_spawn"
| "transport_ship_spawn_result";
| "transport_ship_spawn_result"
| "init_renderer"
| "renderer_ready"
| "set_view_size"
| "set_view_transform"
| "set_alternative_view"
| "set_highlighted_owner"
| "set_shader_settings"
| "mark_tile"
| "mark_all_dirty"
| "refresh_palette"
| "refresh_terrain"
| "tick_renderer"
| "render_frame"
| "renderer_metrics";
// Base interface for all messages
interface BaseWorkerMessage {
@@ -112,6 +126,91 @@ export interface TransportShipSpawnResultMessage extends BaseWorkerMessage {
result: TileRef | false;
}
// Renderer messages from main thread to worker
export interface InitRendererMessage extends BaseWorkerMessage {
type: "init_renderer";
offscreenCanvas: OffscreenCanvas;
darkMode: boolean; // Whether to use dark theme
}
export interface SetViewSizeMessage extends BaseWorkerMessage {
type: "set_view_size";
width: number;
height: number;
}
export interface SetViewTransformMessage extends BaseWorkerMessage {
type: "set_view_transform";
scale: number;
offsetX: number;
offsetY: number;
}
export interface SetAlternativeViewMessage extends BaseWorkerMessage {
type: "set_alternative_view";
enabled: boolean;
}
export interface SetHighlightedOwnerMessage extends BaseWorkerMessage {
type: "set_highlighted_owner";
ownerSmallId: number | null;
}
export interface SetShaderSettingsMessage extends BaseWorkerMessage {
type: "set_shader_settings";
territoryShader?: string;
territoryShaderParams0?: number[];
territoryShaderParams1?: number[];
terrainShader?: string;
terrainShaderParams0?: number[];
terrainShaderParams1?: number[];
preSmoothing?: {
enabled: boolean;
shaderPath: string;
params0: number[];
};
postSmoothing?: {
enabled: boolean;
shaderPath: string;
params0: number[];
};
}
export interface MarkTileMessage extends BaseWorkerMessage {
type: "mark_tile";
tile: TileRef;
}
export interface MarkAllDirtyMessage extends BaseWorkerMessage {
type: "mark_all_dirty";
}
export interface RefreshPaletteMessage extends BaseWorkerMessage {
type: "refresh_palette";
}
export interface RefreshTerrainMessage extends BaseWorkerMessage {
type: "refresh_terrain";
}
export interface TickRendererMessage extends BaseWorkerMessage {
type: "tick_renderer";
}
export interface RenderFrameMessage extends BaseWorkerMessage {
type: "render_frame";
}
// Renderer messages from worker to main thread
export interface RendererReadyMessage extends BaseWorkerMessage {
type: "renderer_ready";
}
export interface RendererMetricsMessage extends BaseWorkerMessage {
type: "renderer_metrics";
computeMs: number;
}
// Union types for type safety
export type MainThreadMessage =
| HeartbeatMessage
@@ -121,7 +220,19 @@ export type MainThreadMessage =
| PlayerProfileMessage
| PlayerBorderTilesMessage
| AttackAveragePositionMessage
| TransportShipSpawnMessage;
| TransportShipSpawnMessage
| InitRendererMessage
| SetViewSizeMessage
| SetViewTransformMessage
| SetAlternativeViewMessage
| SetHighlightedOwnerMessage
| SetShaderSettingsMessage
| MarkTileMessage
| MarkAllDirtyMessage
| RefreshPaletteMessage
| RefreshTerrainMessage
| TickRendererMessage
| RenderFrameMessage;
// Message send from worker
export type WorkerMessage =
@@ -131,4 +242,6 @@ export type WorkerMessage =
| PlayerProfileResultMessage
| PlayerBorderTilesResultMessage
| AttackAveragePositionResultMessage
| TransportShipSpawnResultMessage;
| TransportShipSpawnResultMessage
| RendererReadyMessage
| RendererMetricsMessage;
+603
View File
@@ -0,0 +1,603 @@
import { Theme } from "../configuration/Config";
import { TileRef } from "../game/GameMap";
import { GameUpdateViewData } from "../game/GameUpdates";
import { TerrainMapData } from "../game/TerrainMapLoader";
import { GameRunner } from "../GameRunner";
import { GameViewAdapter } from "./GameViewAdapter";
// Import rendering components from client (they should work with adapter)
import { ComputePass } from "../../client/graphics/webgpu/compute/ComputePass";
import { DefendedStrengthFullPass } from "../../client/graphics/webgpu/compute/DefendedStrengthFullPass";
import { DefendedStrengthPass } from "../../client/graphics/webgpu/compute/DefendedStrengthPass";
import { StateUpdatePass } from "../../client/graphics/webgpu/compute/StateUpdatePass";
import { TerrainComputePass } from "../../client/graphics/webgpu/compute/TerrainComputePass";
import { VisualStateSmoothingPass } from "../../client/graphics/webgpu/compute/VisualStateSmoothingPass";
import { GroundTruthData } from "../../client/graphics/webgpu/core/GroundTruthData";
import { WebGPUDevice } from "../../client/graphics/webgpu/core/WebGPUDevice";
import { RenderPass } from "../../client/graphics/webgpu/render/RenderPass";
import { TemporalResolvePass } from "../../client/graphics/webgpu/render/TemporalResolvePass";
import { TerritoryRenderPass } from "../../client/graphics/webgpu/render/TerritoryRenderPass";
/**
* Worker-compatible WebGPU territory renderer.
* Works with Game directly (not GameView) and uses OffscreenCanvas.
*/
export class WorkerTerritoryRenderer {
private device: WebGPUDevice | null = null;
private resources: GroundTruthData | null = null;
private gameViewAdapter: GameViewAdapter | null = null;
private ready = false;
// Compute passes
private computePasses: ComputePass[] = [];
private computePassOrder: ComputePass[] = [];
private frameComputePasses: ComputePass[] = [];
// Render passes
private renderPasses: RenderPass[] = [];
private renderPassOrder: RenderPass[] = [];
// Pass instances
private terrainComputePass: TerrainComputePass | null = null;
private stateUpdatePass: StateUpdatePass | null = null;
private defendedStrengthFullPass: DefendedStrengthFullPass | null = null;
private defendedStrengthPass: DefendedStrengthPass | null = null;
private visualStateSmoothingPass: VisualStateSmoothingPass | null = null;
private territoryRenderPass: TerritoryRenderPass | null = null;
private temporalResolvePass: TemporalResolvePass | null = null;
private territoryShaderPath = "render/territory.wgsl";
private territoryShaderParams0 = new Float32Array(4);
private territoryShaderParams1 = new Float32Array(4);
private terrainShaderPath = "compute/terrain-compute.wgsl";
private terrainShaderParams0 = new Float32Array(4);
private terrainShaderParams1 = new Float32Array(4);
private preSmoothingShaderPath = "compute/visual-state-smoothing.wgsl";
private preSmoothingParams0 = new Float32Array(4);
private postSmoothingShaderPath = "render/temporal-resolve.wgsl";
private postSmoothingParams0 = new Float32Array(4);
private preSmoothingEnabled = false;
private postSmoothingEnabled = false;
private defensePostRange: number;
/**
* Initialize renderer with offscreen canvas and game data.
*/
async init(
offscreenCanvas: OffscreenCanvas,
gameRunner: GameRunner,
mapData: TerrainMapData,
theme: Theme,
): Promise<void> {
const game = gameRunner.game;
this.defensePostRange = game.config().defensePostRange();
// Create adapter
this.gameViewAdapter = new GameViewAdapter(game, mapData, theme);
// Initialize WebGPU device with offscreen canvas
const webgpuDevice = await WebGPUDevice.create(offscreenCanvas);
if (!webgpuDevice) {
throw new Error("Failed to create WebGPU device in worker");
}
this.device = webgpuDevice;
// Create ground truth data using adapter
const state = this.gameViewAdapter.tileStateView();
this.resources = GroundTruthData.create(
webgpuDevice.device,
this.gameViewAdapter as any,
theme,
state,
);
this.resources.setTerritoryShaderParams(
this.territoryShaderParams0,
this.territoryShaderParams1,
);
this.resources.setTerrainShaderParams(
this.terrainShaderParams0,
this.terrainShaderParams1,
);
// Upload terrain data and params
this.resources.uploadTerrainData();
this.resources.uploadTerrainParams();
// Create compute passes
this.terrainComputePass = new TerrainComputePass();
void this.terrainComputePass.setShader(this.terrainShaderPath);
this.stateUpdatePass = new StateUpdatePass();
this.defendedStrengthFullPass = new DefendedStrengthFullPass();
this.defendedStrengthPass = new DefendedStrengthPass();
this.visualStateSmoothingPass = new VisualStateSmoothingPass();
this.computePasses = [
this.terrainComputePass,
this.stateUpdatePass,
this.defendedStrengthFullPass,
this.defendedStrengthPass,
];
this.frameComputePasses = [this.visualStateSmoothingPass];
// Create render passes
this.territoryRenderPass = new TerritoryRenderPass();
this.temporalResolvePass = new TemporalResolvePass();
this.renderPasses = [this.territoryRenderPass, this.temporalResolvePass];
// Initialize all passes
for (const pass of this.computePasses) {
await pass.init(webgpuDevice.device, this.resources);
}
for (const pass of this.frameComputePasses) {
await pass.init(webgpuDevice.device, this.resources);
}
for (const pass of this.renderPasses) {
await pass.init(
webgpuDevice.device,
this.resources,
webgpuDevice.canvasFormat,
);
}
if (this.territoryRenderPass) {
await this.territoryRenderPass.setShader(this.territoryShaderPath);
}
// Compute dependency order
this.computePassOrder = this.topologicalSort(this.computePasses);
this.renderPassOrder = this.topologicalSort(this.renderPasses);
this.ready = true;
}
/**
* Update game view adapter with latest game update.
*/
updateGameView(gu: GameUpdateViewData): void {
if (this.gameViewAdapter) {
this.gameViewAdapter.update(gu);
}
}
/**
* Topological sort of passes based on dependencies.
*/
private topologicalSort<T extends { name: string; dependencies: string[] }>(
passes: T[],
): T[] {
const passMap = new Map<string, T>();
for (const pass of passes) {
passMap.set(pass.name, pass);
}
const visited = new Set<string>();
const visiting = new Set<string>();
const result: T[] = [];
const visit = (pass: T): void => {
if (visiting.has(pass.name)) {
console.warn(
`Circular dependency detected involving pass: ${pass.name}`,
);
return;
}
if (visited.has(pass.name)) {
return;
}
visiting.add(pass.name);
for (const depName of pass.dependencies) {
const dep = passMap.get(depName);
if (dep) {
visit(dep);
}
}
visiting.delete(pass.name);
visited.add(pass.name);
result.push(pass);
};
for (const pass of passes) {
if (!visited.has(pass.name)) {
visit(pass);
}
}
return result;
}
setViewSize(width: number, height: number): void {
if (!this.resources || !this.device) {
return;
}
const nextWidth = Math.max(1, Math.floor(width));
const nextHeight = Math.max(1, Math.floor(height));
// OffscreenCanvas doesn't have width/height properties we can set
// The size is managed by the main thread canvas
this.resources.setViewSize(nextWidth, nextHeight);
this.device.reconfigure();
if (this.postSmoothingEnabled && this.resources) {
this.resources.ensurePostSmoothingTextures(
nextWidth,
nextHeight,
this.device.canvasFormat,
);
this.resources.invalidateHistory();
}
}
setViewTransform(scale: number, offsetX: number, offsetY: number): void {
if (!this.resources) {
return;
}
this.resources.setViewTransform(scale, offsetX, offsetY);
}
setAlternativeView(enabled: boolean): void {
if (!this.resources) {
return;
}
this.resources.setAlternativeView(enabled);
}
setHighlightedOwnerId(ownerSmallId: number | null): void {
if (!this.resources) {
return;
}
this.resources.setHighlightedOwnerId(ownerSmallId);
}
setTerritoryShader(shaderPath: string): void {
this.territoryShaderPath = shaderPath;
if (this.territoryRenderPass) {
void this.territoryRenderPass.setShader(shaderPath);
}
this.resources?.invalidateHistory();
}
setTerrainShader(shaderPath: string): void {
this.terrainShaderPath = shaderPath;
if (!this.terrainComputePass) {
return;
}
void this.terrainComputePass.setShader(shaderPath).then(() => {
this.refreshTerrain();
});
}
setTerritoryShaderParams(
params0: Float32Array | number[],
params1: Float32Array | number[],
): void {
for (let i = 0; i < 4; i++) {
this.territoryShaderParams0[i] = Number(params0[i] ?? 0);
this.territoryShaderParams1[i] = Number(params1[i] ?? 0);
}
if (!this.resources) {
return;
}
this.resources.setTerritoryShaderParams(
this.territoryShaderParams0,
this.territoryShaderParams1,
);
this.resources.invalidateHistory();
}
setTerrainShaderParams(
params0: Float32Array | number[],
params1: Float32Array | number[],
): void {
for (let i = 0; i < 4; i++) {
this.terrainShaderParams0[i] = Number(params0[i] ?? 0);
this.terrainShaderParams1[i] = Number(params1[i] ?? 0);
}
if (!this.resources) {
return;
}
this.resources.setTerrainShaderParams(
this.terrainShaderParams0,
this.terrainShaderParams1,
);
this.refreshTerrain();
}
setPreSmoothing(
enabled: boolean,
shaderPath: string,
params0: Float32Array | number[],
): void {
this.preSmoothingEnabled = enabled;
if (shaderPath) {
this.preSmoothingShaderPath = shaderPath;
}
for (let i = 0; i < 4; i++) {
this.preSmoothingParams0[i] = Number(params0[i] ?? 0);
}
this.applyPreSmoothingConfig();
}
setPostSmoothing(
enabled: boolean,
shaderPath: string,
params0: Float32Array | number[],
): void {
this.postSmoothingEnabled = enabled;
if (shaderPath) {
this.postSmoothingShaderPath = shaderPath;
}
for (let i = 0; i < 4; i++) {
this.postSmoothingParams0[i] = Number(params0[i] ?? 0);
}
this.applyPostSmoothingConfig();
}
private applyPreSmoothingConfig(): void {
if (!this.resources || !this.visualStateSmoothingPass) {
return;
}
this.resources.setUseVisualStateTexture(this.preSmoothingEnabled);
if (this.preSmoothingEnabled) {
this.resources.ensureVisualStateTexture();
void this.visualStateSmoothingPass.setShader(this.preSmoothingShaderPath);
this.visualStateSmoothingPass.setParams(this.preSmoothingParams0);
} else {
this.visualStateSmoothingPass.setParams(new Float32Array(4));
this.resources.releaseVisualStateTexture();
}
this.resources.invalidateHistory();
}
private applyPostSmoothingConfig(): void {
if (!this.resources || !this.temporalResolvePass || !this.device) {
return;
}
if (this.postSmoothingEnabled) {
void this.temporalResolvePass.setShader(this.postSmoothingShaderPath);
this.temporalResolvePass.setParams(this.postSmoothingParams0);
this.temporalResolvePass.setEnabled(true);
// Note: canvas size not available here, will be set on first setViewSize
if (this.resources) {
this.resources.ensurePostSmoothingTextures(
1,
1,
this.device.canvasFormat,
);
}
} else {
this.temporalResolvePass.setEnabled(false);
this.resources.releasePostSmoothingTextures();
}
this.resources.invalidateHistory();
}
markTile(tile: TileRef): void {
if (this.stateUpdatePass) {
// TileRef is number, StateUpdatePass.markTile expects number
this.stateUpdatePass.markTile(tile as number);
}
}
markAllDirty(): void {
this.resources?.markDefensePostsDirty();
}
refreshPalette(): void {
if (!this.resources) {
return;
}
this.resources.markPaletteDirty();
}
markDefensePostsDirty(): void {
if (!this.resources) {
return;
}
this.resources.markDefensePostsDirty();
}
refreshTerrain(): void {
if (!this.resources || !this.device) {
return;
}
this.resources.markTerrainParamsDirty();
if (this.terrainComputePass) {
this.terrainComputePass.markDirty();
this.computeTerrainImmediate();
}
}
private computeTerrainImmediate(): void {
if (
!this.ready ||
!this.device ||
!this.resources ||
!this.terrainComputePass
) {
return;
}
this.resources.uploadTerrainParams();
if (!this.terrainComputePass.needsUpdate()) {
return;
}
const encoder = this.device.device.createCommandEncoder();
this.terrainComputePass.execute(encoder, this.resources);
this.device.device.queue.submit([encoder.finish()]);
if (this.territoryRenderPass) {
(this.territoryRenderPass as any).rebuildBindGroup?.();
}
}
/**
* Perform one simulation tick.
* Runs compute passes to update ground truth data.
*/
tick(): void {
if (!this.ready || !this.device || !this.resources) {
return;
}
this.resources.updateTickTiming(performance.now() / 1000);
if (
this.gameViewAdapter?.config().defensePostRange() !==
this.defensePostRange
) {
throw new Error("defensePostRange changed at runtime; unsupported.");
}
// Upload palette if needed
this.resources.uploadPalette();
// Upload diplomacy relations
this.resources.uploadRelations();
// Upload defense posts if needed
this.resources.uploadDefensePosts();
// Initial state upload
this.resources.uploadState();
const stateUpdatesPending = this.stateUpdatePass?.needsUpdate() ?? false;
if (!stateUpdatesPending) {
this.resources.setLastStateUpdateCount(0);
}
const needsCompute =
(this.terrainComputePass?.needsUpdate() ?? false) ||
stateUpdatesPending ||
(this.defendedStrengthFullPass?.needsUpdate() ?? false) ||
(this.defendedStrengthPass?.needsUpdate() ?? false);
if (!needsCompute) {
return;
}
const encoder = this.device.device.createCommandEncoder();
if (this.preSmoothingEnabled && stateUpdatesPending) {
this.resources.ensureVisualStateTexture();
const visualStateTexture = this.resources.getVisualStateTexture();
if (visualStateTexture) {
encoder.copyTextureToTexture(
{ texture: this.resources.stateTexture },
{ texture: visualStateTexture },
{
width: this.resources.getMapWidth(),
height: this.resources.getMapHeight(),
depthOrArrayLayers: 1,
},
);
this.resources.consumeVisualStateSyncNeeded();
}
}
// Execute compute passes in dependency order
for (const pass of this.computePassOrder) {
if (!pass.needsUpdate()) {
continue;
}
pass.execute(encoder, this.resources);
}
this.device.device.queue.submit([encoder.finish()]);
}
/**
* Render one frame.
* Runs render passes to draw to the canvas.
*/
render(): void {
if (
!this.ready ||
!this.device ||
!this.resources ||
!this.territoryRenderPass
) {
return;
}
const nowSec = performance.now() / 1000;
this.resources.writeTemporalUniformBuffer(nowSec);
// If terrain needs recomputation, trigger it asynchronously
if (this.terrainComputePass?.needsUpdate()) {
this.resources.uploadTerrainParams();
const computeEncoder = this.device.device.createCommandEncoder();
this.terrainComputePass.execute(computeEncoder, this.resources);
this.device.device.queue.submit([computeEncoder.finish()]);
}
const encoder = this.device.device.createCommandEncoder();
const swapchainView = this.device.context.getCurrentTexture().createView();
if (
this.preSmoothingEnabled &&
this.resources.consumeVisualStateSyncNeeded()
) {
const visualStateTexture = this.resources.getVisualStateTexture();
if (visualStateTexture) {
encoder.copyTextureToTexture(
{ texture: this.resources.stateTexture },
{ texture: visualStateTexture },
{
width: this.resources.getMapWidth(),
height: this.resources.getMapHeight(),
depthOrArrayLayers: 1,
},
);
}
}
for (const pass of this.frameComputePasses) {
if (!pass.needsUpdate()) {
continue;
}
pass.execute(encoder, this.resources);
}
// Execute render passes in dependency order
for (const pass of this.renderPassOrder) {
if (!pass.needsUpdate()) {
continue;
}
if (pass === this.territoryRenderPass && this.postSmoothingEnabled) {
if (!this.resources.getCurrentColorTexture()) {
// Use view size from resources (stored via setViewSize)
const viewWidth = (this.resources as any).viewWidth ?? 1;
const viewHeight = (this.resources as any).viewHeight ?? 1;
this.resources.ensurePostSmoothingTextures(
viewWidth,
viewHeight,
this.device.canvasFormat,
);
}
const currentTexture = this.resources.getCurrentColorTexture();
if (currentTexture) {
pass.execute(encoder, this.resources, currentTexture.createView());
}
continue;
}
pass.execute(encoder, this.resources, swapchainView);
}
this.device.device.queue.submit([encoder.finish()]);
}
}