mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-30 12:32:14 +00:00
469 lines
14 KiB
TypeScript
469 lines
14 KiB
TypeScript
import { Theme } from "../../../core/configuration/Config";
|
|
import { TileRef } from "../../../core/game/GameMap";
|
|
import { GameView } from "../../../core/game/GameView";
|
|
import { createCanvas } from "../../Utils";
|
|
import { ComputePass } from "./compute/ComputePass";
|
|
import { DefendedClearPass } from "./compute/DefendedClearPass";
|
|
import { DefendedUpdatePass } from "./compute/DefendedUpdatePass";
|
|
import { StateUpdatePass } from "./compute/StateUpdatePass";
|
|
import { TerrainComputePass } from "./compute/TerrainComputePass";
|
|
import { GroundTruthData } from "./core/GroundTruthData";
|
|
import { WebGPUDevice } from "./core/WebGPUDevice";
|
|
import { RenderPass } from "./render/RenderPass";
|
|
import { TerritoryRenderPass } from "./render/TerritoryRenderPass";
|
|
|
|
export interface TerritoryWebGLCreateResult {
|
|
renderer: TerritoryRenderer | null;
|
|
reason?: string;
|
|
}
|
|
|
|
/**
|
|
* Main orchestrator for WebGPU territory rendering.
|
|
* Manages compute passes (tick-based) and render passes (frame-based).
|
|
*/
|
|
export class TerritoryRenderer {
|
|
public readonly canvas: HTMLCanvasElement;
|
|
|
|
private device: WebGPUDevice | null = null;
|
|
private resources: GroundTruthData | null = null;
|
|
private ready = false;
|
|
private initPromise: Promise<void> | null = null;
|
|
|
|
// Compute passes
|
|
private computePasses: ComputePass[] = [];
|
|
private computePassOrder: ComputePass[] = [];
|
|
|
|
// Render passes
|
|
private renderPasses: RenderPass[] = [];
|
|
private renderPassOrder: RenderPass[] = [];
|
|
|
|
// Pass instances
|
|
private terrainComputePass: TerrainComputePass | null = null;
|
|
private stateUpdatePass: StateUpdatePass | null = null;
|
|
private defendedClearPass: DefendedClearPass | null = null;
|
|
private defendedUpdatePass: DefendedUpdatePass | null = null;
|
|
private territoryRenderPass: TerritoryRenderPass | null = null;
|
|
|
|
// State tracking
|
|
private needsDefendedRebuild = true;
|
|
private needsDefendedHardClear = true;
|
|
|
|
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): TerritoryWebGLCreateResult {
|
|
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 nav = globalThis.navigator as any;
|
|
if (!nav?.gpu || typeof nav.gpu.requestAdapter !== "function") {
|
|
return {
|
|
renderer: null,
|
|
reason: "WebGPU not available; GPU renderer disabled.",
|
|
};
|
|
}
|
|
|
|
const renderer = new TerritoryRenderer(game, theme);
|
|
renderer.startInit();
|
|
return { renderer };
|
|
}
|
|
|
|
private startInit(): void {
|
|
if (this.initPromise) return;
|
|
this.initPromise = this.init();
|
|
}
|
|
|
|
private async init(): Promise<void> {
|
|
const webgpuDevice = await WebGPUDevice.create(this.canvas);
|
|
if (!webgpuDevice) {
|
|
return;
|
|
}
|
|
this.device = webgpuDevice;
|
|
|
|
const state = this.game.tileStateView();
|
|
this.resources = GroundTruthData.create(
|
|
webgpuDevice.device,
|
|
this.game,
|
|
this.theme,
|
|
state,
|
|
);
|
|
|
|
// Upload terrain data and params (terrain colors will be computed on GPU)
|
|
this.resources.uploadTerrainData();
|
|
this.resources.uploadTerrainParams();
|
|
|
|
// Create compute passes (terrain compute should run first)
|
|
this.terrainComputePass = new TerrainComputePass();
|
|
this.stateUpdatePass = new StateUpdatePass();
|
|
this.defendedClearPass = new DefendedClearPass();
|
|
this.defendedUpdatePass = new DefendedUpdatePass();
|
|
|
|
this.computePasses = [
|
|
this.terrainComputePass,
|
|
this.stateUpdatePass,
|
|
this.defendedClearPass,
|
|
this.defendedUpdatePass,
|
|
];
|
|
|
|
// Create render passes
|
|
this.territoryRenderPass = new TerritoryRenderPass();
|
|
this.renderPasses = [this.territoryRenderPass];
|
|
|
|
// Initialize all passes
|
|
for (const pass of this.computePasses) {
|
|
await pass.init(webgpuDevice.device, this.resources);
|
|
}
|
|
|
|
for (const pass of this.renderPasses) {
|
|
await pass.init(
|
|
webgpuDevice.device,
|
|
this.resources,
|
|
webgpuDevice.canvasFormat,
|
|
);
|
|
}
|
|
|
|
// Compute dependency order (topological sort)
|
|
this.computePassOrder = this.topologicalSort(this.computePasses);
|
|
this.renderPassOrder = this.topologicalSort(this.renderPasses);
|
|
|
|
this.ready = true;
|
|
}
|
|
|
|
/**
|
|
* Topological sort of passes based on dependencies.
|
|
* Ensures passes run in the correct order.
|
|
*/
|
|
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));
|
|
|
|
if (nextWidth === this.canvas.width && nextHeight === this.canvas.height) {
|
|
return;
|
|
}
|
|
|
|
this.canvas.width = nextWidth;
|
|
this.canvas.height = nextHeight;
|
|
this.resources.setViewSize(nextWidth, nextHeight);
|
|
this.device.reconfigure();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
markTile(tile: TileRef): void {
|
|
if (this.stateUpdatePass) {
|
|
this.stateUpdatePass.markTile(tile);
|
|
}
|
|
}
|
|
|
|
markAllDirty(): void {
|
|
this.needsDefendedRebuild = true;
|
|
if (this.defendedUpdatePass) {
|
|
this.defendedUpdatePass.markDirty();
|
|
}
|
|
}
|
|
|
|
refreshPalette(): void {
|
|
if (!this.resources) {
|
|
return;
|
|
}
|
|
this.resources.markPaletteDirty();
|
|
}
|
|
|
|
markDefensePostsDirty(): void {
|
|
if (!this.resources) {
|
|
return;
|
|
}
|
|
this.resources.markDefensePostsDirty();
|
|
this.needsDefendedRebuild = true;
|
|
if (this.defendedUpdatePass) {
|
|
this.defendedUpdatePass.markDirty();
|
|
}
|
|
}
|
|
|
|
refreshTerrain(): void {
|
|
if (!this.resources || !this.device) {
|
|
return;
|
|
}
|
|
this.resources.markTerrainParamsDirty();
|
|
if (this.terrainComputePass) {
|
|
this.terrainComputePass.markDirty();
|
|
// Immediately compute terrain to avoid blank rendering
|
|
this.computeTerrainImmediate();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Immediately execute terrain compute pass (for theme changes).
|
|
* This ensures terrain is recomputed before the next render.
|
|
*/
|
|
private computeTerrainImmediate(): void {
|
|
if (
|
|
!this.ready ||
|
|
!this.device ||
|
|
!this.resources ||
|
|
!this.terrainComputePass
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Upload terrain params if needed
|
|
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()]);
|
|
|
|
// Rebuild render pass bind group to ensure it uses the updated terrain texture
|
|
// This will be called again in render(), but doing it here ensures it's ready
|
|
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;
|
|
}
|
|
|
|
// Upload palette if needed
|
|
this.resources.uploadPalette();
|
|
|
|
// Upload terrain params if needed (theme changed)
|
|
this.resources.uploadTerrainParams();
|
|
|
|
// Upload defense posts if needed (tracks if it was dirty before upload)
|
|
const wasDefensePostsDirty = (this.resources as any)
|
|
.needsDefensePostsUpload;
|
|
this.resources.uploadDefensePosts();
|
|
|
|
// Initial state upload
|
|
this.resources.uploadState();
|
|
|
|
// Check if we need to run compute passes
|
|
const numUpdates = this.stateUpdatePass
|
|
? ((this.stateUpdatePass as any).pendingTiles?.size ?? 0)
|
|
: 0;
|
|
const needsTerrainCompute = this.terrainComputePass
|
|
? this.terrainComputePass.needsUpdate()
|
|
: false;
|
|
const range = this.game.config().defensePostRange();
|
|
const rangeChanged = range !== this.resources.getLastDefenseRange();
|
|
const countChanged =
|
|
this.resources.getDefensePostsCount() !==
|
|
this.resources.getLastDefensePostsCount();
|
|
const hasPosts = this.resources.getDefensePostsCount() > 0;
|
|
|
|
// Use explicit boolean checks to satisfy linter (|| is correct for boolean OR)
|
|
const shouldRebuildDefended =
|
|
this.needsDefendedRebuild === true ||
|
|
wasDefensePostsDirty === true ||
|
|
rangeChanged === true ||
|
|
countChanged === true ||
|
|
(hasPosts && numUpdates > 0);
|
|
|
|
const needsCompute =
|
|
needsTerrainCompute === true ||
|
|
numUpdates > 0 ||
|
|
shouldRebuildDefended === true ||
|
|
this.needsDefendedHardClear === true;
|
|
|
|
// Update defense params even if we early-out
|
|
if (!needsCompute) {
|
|
this.resources.writeDefenseParamsBuffer();
|
|
this.resources.setLastDefenseRange(range);
|
|
this.resources.setLastDefensePostsCount(
|
|
this.resources.getDefensePostsCount(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
const encoder = this.device.device.createCommandEncoder();
|
|
|
|
// Handle defended rebuild (before executing passes)
|
|
if (shouldRebuildDefended) {
|
|
// Increment epoch for this rebuild
|
|
const epochBefore = this.resources.getDefendedEpoch();
|
|
this.resources.incrementDefendedEpoch();
|
|
const epochAfter = this.resources.getDefendedEpoch();
|
|
|
|
// If epoch wrapped, we need a hard clear
|
|
if (epochAfter === 0 || epochAfter < epochBefore) {
|
|
this.needsDefendedHardClear = true;
|
|
this.resources.incrementDefendedEpoch();
|
|
}
|
|
|
|
this.needsDefendedRebuild = false;
|
|
}
|
|
|
|
// Update hard clear flag for DefendedClearPass
|
|
if (this.defendedClearPass) {
|
|
this.defendedClearPass.setNeedsHardClear(this.needsDefendedHardClear);
|
|
}
|
|
|
|
// Execute compute passes in dependency order (clear will run before update if needed)
|
|
for (const pass of this.computePassOrder) {
|
|
if (!pass.needsUpdate()) {
|
|
continue;
|
|
}
|
|
pass.execute(encoder, this.resources);
|
|
}
|
|
|
|
// After all passes, update defense params and clear flags
|
|
this.resources.writeDefenseParamsBuffer();
|
|
if (this.needsDefendedHardClear && this.defendedClearPass) {
|
|
this.needsDefendedHardClear = false;
|
|
this.defendedClearPass.setNeedsHardClear(false);
|
|
}
|
|
|
|
this.resources.setLastDefenseRange(range);
|
|
this.resources.setLastDefensePostsCount(
|
|
this.resources.getDefensePostsCount(),
|
|
);
|
|
|
|
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;
|
|
}
|
|
|
|
// Check if terrain needs recomputation (e.g., theme changed)
|
|
// If so, compute it in the same command buffer before rendering
|
|
if (this.terrainComputePass?.needsUpdate()) {
|
|
this.resources.uploadTerrainParams();
|
|
|
|
// Use a single encoder to ensure compute completes before render
|
|
const encoder = this.device.device.createCommandEncoder();
|
|
|
|
// Execute terrain compute first
|
|
this.terrainComputePass.execute(encoder, this.resources);
|
|
|
|
// Then execute render passes in the same command buffer
|
|
// The render pass will rebuild its bind group, which will now use the updated terrain texture
|
|
const textureView = this.device.context.getCurrentTexture().createView();
|
|
for (const pass of this.renderPassOrder) {
|
|
if (!pass.needsUpdate()) {
|
|
continue;
|
|
}
|
|
pass.execute(encoder, this.resources, textureView);
|
|
}
|
|
|
|
// Submit single command buffer with both compute and render
|
|
// This ensures compute completes before render reads the terrain texture
|
|
this.device.device.queue.submit([encoder.finish()]);
|
|
return;
|
|
}
|
|
|
|
const encoder = this.device.device.createCommandEncoder();
|
|
const textureView = this.device.context.getCurrentTexture().createView();
|
|
|
|
// Execute render passes in dependency order
|
|
for (const pass of this.renderPassOrder) {
|
|
if (!pass.needsUpdate()) {
|
|
continue;
|
|
}
|
|
pass.execute(encoder, this.resources, textureView);
|
|
}
|
|
|
|
this.device.device.queue.submit([encoder.finish()]);
|
|
}
|
|
}
|