Files
OpenFrontIO/src/client/render/gl/Renderer.ts
T
Evan 22c873cf55 perf(client): tick-dispatch timing harness + main-thread tick optimizations (late-game p95 −65%) (#4512)
## Problem

Every 100 ms the main thread's worker `onmessage` callback processes a
full game tick (`gameView.update` → `webglBuilder.update` →
`renderer.tick`). At 60 fps this competes with the 16.7 ms frame budget,
and on the Giant World Map it takes several ms — frame drops on low-end
hardware.

## Harness (`npm run perf:client-tick`)

Headless-Chromium harness that times every worker→main `game_update`
dispatch on the main thread, with structured-clone deserialization
measured separately from the handler body (via a
`Worker.prototype.addEventListener` wrapper installed as a page init
script — no product-code changes). It reports windowed distributions,
captures `.cpuprofile` files at chosen ticks, writes raw samples and an
end-of-run screenshot. `AnalyzeCpuProfile.ts` breaks a profile down by
inclusive time under the dispatch subtree.

Init scripts are passed as **strings**: tsx compiles function-form init
scripts with esbuild `keepNames`, whose injected `__name` helper doesn't
exist in-page and silently kills the game worker setup.

## Baseline (Giant World Map, 400 bots, headless)

Dispatch handler ms — cost **grows with game progression**:

| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.22 | 2.20 | 3.40 | 5.00 |
| tick 1506 | 2.60 | 2.00 | 7.00 | 10.40 |
| tick 2000 | 2.67 | 1.90 | **8.70** | **12.70** |

Deserialization is negligible (0.12 ms mean). CPU profiles attributed
the growing tail to the leaderboard's once-per-second refresh: its
Max-troops column calls `config().maxTroops(p)` for **all ~508
players**, and `PlayerView.units()` scanned **every unit in the game**
per call — O(players × units), growing as units accumulate.

## Round 1 — algorithmic fixes

- **GameView**: new `unitsOwnedBy(smallID)` — an active-units-by-owner
index built lazily at most once per tick. `PlayerView.units()` reads its
own units from it: O(own units) instead of O(all units). Also speeds up
unit display, player panel, and buildables queries.
- **NamePass.updateNames**: reads player state directly from the
caller's map by smallID instead of rebuilding three lookup maps per
tick; skips the slot-assignment sweep once every player has a slot.

After (same map, same spawn tile):

| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.12 | 2.00 | 3.10 | 5.20 |
| tick 1506 | 1.86 | 1.80 | 2.90 | 4.30 |
| tick 2000 | 1.74 | 1.60 | **2.40** | **4.70** |

Late-game p95 −65% (8.7 → 2.4 ms), worst dispatch −63% (12.7 → 4.7 ms),
and per-dispatch cost no longer grows with game progression. The
leaderboard disappeared from the dispatch profile entirely.

## Round 2 — allocation churn + time slicing

Aimed at GC pauses and low-end CPUs; measures flat vs round 1 on a fast
machine, as expected:

- **`FrameData.changedTiles`** is now the plain tile-ref array GameView
already builds instead of a per-tile `{ref, state}` object copy — heavy
battle ticks allocated tens of thousands of objects per tick for a
`state` field that was always 0. `TilePair` removed; `TerritoryPass`
buckets refs synchronously, so the live reference is safe.
- **`UnitView.lastPos`** is only re-sliced when a move actually appended
a position — the unconditional `slice(-1)` allocated an identical
1-element array per unit per tick, including for structures that never
move.
- **`NamePass.updateNames`** refreshes slots round-robin, a quarter per
tick — the full per-player diff pass spreads over ~400 ms, under the
existing 500 ms troop-text cadence; positions lerp continuously. Unnamed
slots and snap passes (seeks) are always processed so nothing pops in
late. Dispatch share: 17% → 13%.

Not sliced on purpose: tile ingest and frame upload need a consistent
per-tick snapshot (stale `GameMap` reads would leak into hover queries,
minimap, attack targeting) — a correctness risk not worth ~1 ms while
the worst dispatch already fits in a quarter of the frame budget.

## Verification

- `npx tsc --noEmit`, eslint clean; full suite green (1929 tests)
- 6 new GameView tests cover the owner index (grouping, inactive
exclusion, ownership capture, death, type filtering, copy semantics);
changedTiles tests updated to the ref-array contract
- Headless end-of-run screenshots verified after each round: leaderboard
Max-troops values, map names + troop counts + flags all render correctly
(including with name slicing active)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 13:46:02 -07:00

1329 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* GPURenderer v2 — normalized render pipeline.
*
* Draw order:
* DATA SYNC: tile flush → heat update → border compute
* BASE PASS (darkened by night): terrain → territory fill + stale-nuke ground
* NIGHT COMPOSITE (optional): lightmap → scene × (ambient + lightmap)
* FULL BRIGHTNESS (always): borders → railroads → ground units → structures →
* structure levels → bars → bloom → trails → missiles → fx → conquest → names
*/
import type { Config } from "../../../core/configuration/Config";
import type {
AttackRingInput,
BonusEvent,
ConquestFx,
DeadUnitFx,
GhostPreviewData,
NameEntry,
NukeTelegraphData,
NukeTrajectoryData,
PlayerState,
PlayerStatic,
PlayerStatusData,
RendererConfig,
UnitState,
} from "../types";
import { Camera } from "./Camera";
import { GLUnavailableError, initGL } from "./initGL";
import { BarPass } from "./passes/BarPass";
import { BorderComputePass } from "./passes/BorderComputePass";
import { BorderStampPass } from "./passes/BorderStampPass";
import { CoordinateGridPass } from "./passes/CoordinateGridPass";
import { CrosshairPass } from "./passes/CrosshairPass";
import { DefenseCoveragePass } from "./passes/DefenseCoveragePass";
import { FalloutBloomPass } from "./passes/FalloutBloomPass";
import { FalloutLightPass } from "./passes/FalloutLightPass";
import { FxPass } from "./passes/fx-pass";
import { LightmapPass } from "./passes/LightmapPass";
import { MoveIndicatorPass } from "./passes/MoveIndicatorPass";
import { NamePass } from "./passes/name-pass";
import { NightCompositePass } from "./passes/NightCompositePass";
import { NukeTelegraphPass } from "./passes/NukeTelegraphPass";
import { NukeTrajectoryPass } from "./passes/NukeTrajectoryPass";
import { PointLightPass } from "./passes/PointLightPass";
import { RailroadPass } from "./passes/RailroadPass";
import { RangeCirclePass } from "./passes/RangeCirclePass";
import { SAMRadiusPass } from "./passes/SamRadiusPass";
import { SelectionBoxPass } from "./passes/SelectionBoxPass";
import { SkinAtlasArray } from "./passes/SkinAtlasArray";
import type { SpawnCenter } from "./passes/SpawnOverlayPass";
import { SpawnOverlayPass } from "./passes/SpawnOverlayPass";
import { StructureLevelPass } from "./passes/StructureLevelPass";
import { StructurePass } from "./passes/StructurePass";
import { TerrainPass } from "./passes/TerrainPass";
import { TerritoryPass } from "./passes/TerritoryPass";
import { TrailPass } from "./passes/TrailPass";
import { UnitPass } from "./passes/UnitPass";
import { WorldTextPass } from "./passes/WorldTextPass";
import type { RenderSettings } from "./RenderSettings";
import { AffiliationPalette } from "./utils/Affiliation";
import {
EFFECT_PALETTE_BLOCKS,
getPaletteSize,
hexToRgb,
MAX_TRAIL_COLORS,
} from "./utils/ColorUtils";
import { renderDpr } from "./utils/Dpr";
import {
createTexture2D,
toScreen,
toTarget,
type RenderTarget,
} from "./utils/GlUtils";
import {
createGPUResources,
disposeGPUResources,
type GPUResources,
} from "./utils/GpuResources";
import { HeatManager } from "./utils/HeatManager";
/** Ghost types that trigger SAM radius overlay (matches upstream SAMRadiusLayer). */
const SAM_RADIUS_GHOST_TYPES = new Set([
"Missile Silo",
"SAM Launcher",
"City",
"Atom Bomb",
"Hydrogen Bomb",
]);
/** Subset for build-button hover — excludes City/Silo (SAM radii irrelevant). */
const SAM_RADIUS_HIGHLIGHT_TYPES = new Set([
"SAM Launcher",
"Atom Bomb",
"Hydrogen Bomb",
]);
const GRID_VIEW_KEY = "renderer:grid_view_enabled";
export class GPURenderer {
private gl: WebGL2RenderingContext;
private camera: Camera;
private res: GPUResources;
/**
* Set when the context is hardware-accelerated but its MAX_TEXTURE_SIZE is
* below what the game needs (fingerprinting protection, #4357). The game
* runs, but the map may render with black areas — the owner should warn.
*/
readonly glLimited: { renderer: string; maxTextureSize: number } | null;
// Passes
private terrainPass: TerrainPass;
private territoryPass: TerritoryPass;
private trailPass: TrailPass;
private borderStampPass: BorderStampPass;
private borderPass: BorderComputePass;
private defenseCoveragePass: DefenseCoveragePass;
private bloomPass: FalloutBloomPass;
private pointLightPass: PointLightPass;
private falloutLightPass: FalloutLightPass;
private lightmapPass: LightmapPass;
private nightCompositePass: NightCompositePass;
private structurePass: StructurePass;
private structureLevelPass: StructureLevelPass;
private unitPass: UnitPass;
private namePass: NamePass;
private fxPass: FxPass;
private rangeCirclePass: RangeCirclePass;
private samRadiusPass: SAMRadiusPass;
private crosshairPass: CrosshairPass;
private railroadPass: RailroadPass;
private barPass: BarPass;
private worldTextPass: WorldTextPass;
private selectionBoxPass: SelectionBoxPass;
private moveIndicatorPass: MoveIndicatorPass;
private nukeTrajectoryPass: NukeTrajectoryPass;
private nukeTelegraphPass: NukeTelegraphPass;
private heatManager: HeatManager;
private affiliationPalette: AffiliationPalette;
private coordinateGridPass: CoordinateGridPass;
private spawnOverlayPass: SpawnOverlayPass;
private inSpawnPhase = false;
private paletteTex: WebGLTexture;
private paletteData: Float32Array;
// Per-player trail-effect palette, keyed by smallID (RGBA32F,
// 4096×(MAX_TRAIL_COLORS·TRAIL_EFFECT_BLOCKS)): one MAX_TRAIL_COLORS-row block
// per trail effectType (block 0 = transportShipTrail, block 1 = nukeTrail).
// Sampled by TrailPass; the shader picks the block from the trail tile's nuke
// bit.
private effectTex: WebGLTexture;
private patternMetaTex: WebGLTexture;
private patternDataTex: WebGLTexture;
private skinAtlas: SkinAtlasArray;
private skinLayerTex: WebGLTexture;
/** CPU-side mirror of skinLayerTex (0 = no skin, otherwise layer + 1). */
private skinLayerCpu: Uint8Array;
/** Per-player anchor (x,y) for skin sampling. (0,0) = world-origin anchor. */
private skinAnchorTex: WebGLTexture;
private skinAnchorCpu: Uint16Array;
private canvas: HTMLCanvasElement;
private settings: RenderSettings;
private sceneTarget: RenderTarget;
private raf: typeof requestAnimationFrame;
private caf: typeof cancelAnimationFrame;
private animId: number | null = null;
private frameTick = 0;
private mapW = 0;
private mapH = 0;
// Last-uploaded unit/structure maps (selection box + bar pass inputs)
private lastUnits: Map<number, UnitState> = new Map();
private lastStructures: Map<number, UnitState> = new Map();
// Local player relationship data (for SAM radius coloring)
private localPlayerID = 0;
private playerTeams = new Map<number, string>(); // smallID → team
// Alt-view: affiliation recoloring (space hold)
private altView = false;
// Grid-view: coordinate grid overlay (M toggle)
private gridView = false;
// SAM radius visibility tracking (show if either source is true)
private samGhostVisible = false;
private samHighlightVisible = false;
// Warship selection — supports any number of selections.
private selectedUnitIds: number[] = [];
/** Reusable scratch buffer of {x,y,r,g,b} for the selection-box pass. */
private readonly selectionBoxEntries: import("./passes/SelectionBoxPass").SelectionEntry[] =
[];
constructor(
canvas: HTMLCanvasElement,
header: RendererConfig,
terrainSource: () => Uint8Array,
paletteData: Float32Array,
config: Config,
settings: RenderSettings,
raf: typeof requestAnimationFrame = requestAnimationFrame.bind(window),
caf: typeof cancelAnimationFrame = cancelAnimationFrame.bind(window),
) {
this.canvas = canvas;
// Settings are resolved (defaults + user overrides) by the caller and
// passed in, so every pass — including texture-baking ones like terrain —
// is built with the final values. Live changes mutate this object in place.
this.settings = settings;
this.raf = raf;
this.caf = caf;
// Demand a GPU-accelerated context. A software (SwiftShader) or missing
// WebGL2 context throws GLUnavailableError, which the game-start path
// turns into an actionable gate instead of letting the game crawl at
// ~1fps. A fingerprint-capped context ("limited" — MAX_TEXTURE_SIZE below
// the palette width, #4357) proceeds anyway; glLimited lets the owner
// warn the player that the map may render with black areas.
const res = initGL(canvas, {
alpha: false,
antialias: false,
powerPreference: "high-performance",
});
if (res.gl === null) {
throw new GLUnavailableError(res.status, res.renderer);
}
this.glLimited =
res.status === "limited"
? { renderer: res.renderer, maxTextureSize: res.maxTextureSize }
: null;
const gl = res.gl;
this.gl = gl;
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
const floatExt = gl.getExtension("EXT_color_buffer_float");
if (!floatExt)
console.warn("EXT_color_buffer_float not available — palette may fail");
const mapW = header.mapWidth;
const mapH = header.mapHeight;
this.mapW = mapW;
this.mapH = mapH;
this.camera = new Camera(mapW, mapH);
// --- Terrain (static) ---
// Bake once and let the array go — nothing below retains map-sized
// terrain bytes; re-bakes call terrainSource again.
const terrainBytes = terrainSource();
this.terrainPass = new TerrainPass(
gl,
terrainSource,
terrainBytes,
mapW,
mapH,
{
oceanColor: hexToRgb(this.settings.terrain.oceanColor) ?? undefined,
sandColor: hexToRgb(this.settings.terrain.sandColor) ?? undefined,
plainsColor: hexToRgb(this.settings.terrain.plainsColor) ?? undefined,
highlandColor:
hexToRgb(this.settings.terrain.highlandColor) ?? undefined,
mountainColor:
hexToRgb(this.settings.terrain.mountainColor) ?? undefined,
},
);
// --- Shared palette texture (RGBA32F, 4096×2) ---
this.paletteData = paletteData;
const palW = getPaletteSize();
this.paletteTex = createTexture2D(gl, {
width: palW,
height: 2,
internalFormat: gl.RGBA32F,
format: gl.RGBA,
type: gl.FLOAT,
data: paletteData,
filter: gl.NEAREST,
});
// Per-player effect texture: EFFECT_PALETTE_BLOCKS stacked blocks of
// MAX_TRAIL_COLORS rows (block 0 = transportShipTrail, block 1 = nukeTrail,
// block 2 = structures). Starts zeroed (color count 0 everywhere = no
// effect → territory/player color).
const effectRows = MAX_TRAIL_COLORS * EFFECT_PALETTE_BLOCKS;
this.effectTex = createTexture2D(gl, {
width: palW,
height: effectRows,
internalFormat: gl.RGBA32F,
format: gl.RGBA,
type: gl.FLOAT,
data: new Float32Array(palW * effectRows * 4),
filter: gl.NEAREST,
});
this.patternMetaTex = createTexture2D(gl, {
width: palW,
height: 1,
internalFormat: gl.RGBA32F,
format: gl.RGBA,
type: gl.FLOAT,
data: new Float32Array(palW * 4),
filter: gl.NEAREST,
});
this.patternDataTex = createTexture2D(gl, {
width: 1024,
height: palW,
internalFormat: gl.R8UI,
format: gl.RED_INTEGER,
type: gl.UNSIGNED_BYTE,
data: new Uint8Array(palW * 1024),
filter: gl.NEAREST,
});
// --- Skin atlas (TEXTURE_2D_ARRAY of PNG layers) + per-player layer map ---
this.skinLayerCpu = new Uint8Array(palW);
this.skinLayerTex = createTexture2D(gl, {
width: palW,
height: 1,
internalFormat: gl.R8UI,
format: gl.RED_INTEGER,
type: gl.UNSIGNED_BYTE,
data: this.skinLayerCpu,
filter: gl.NEAREST,
});
// Per-player skin anchor: RG16UI, 2× uint16 per player → 4 bytes each.
// (0,0) sentinel means "no anchor" — shader uses world origin.
this.skinAnchorCpu = new Uint16Array(palW * 2);
this.skinAnchorTex = createTexture2D(gl, {
width: palW,
height: 1,
internalFormat: gl.RG16UI,
format: gl.RG_INTEGER,
type: gl.UNSIGNED_SHORT,
data: this.skinAnchorCpu,
filter: gl.NEAREST,
});
// Construct with no URLs — the real atlas is built once initSkinAtlas() is
// called with the locked-in player skin URLs at game start.
this.skinAtlas = new SkinAtlasArray(gl, [], () => {});
// --- Border compute (creates its own borderTex) ---
// Need a temporary tileTex reference for border compute — we'll create
// GPUResources first, then wire everything.
// But borderPass creates its own borderTex internally, so we need to
// create GPUResources with it. Let's sequence carefully:
// 1. Create GPUResources (creates tileTex, trailTex, heatTexA/B)
// borderTex placeholder — we'll get it from borderPass
// First create a dummy, then replace after borderPass is created.
// Actually: borderPass creates its own internal borderTex (RGBA8).
// We need tileTex to exist before borderPass. So:
// a) Create shared resources (tileTex, trailTex, heatA/B)
// b) Create borderPass with tileTex → gives us borderTex
// c) Store borderTex in res
// Create shared textures except borderTex
this.res = createGPUResources(gl, mapW, mapH, this.paletteTex, null!);
// --- Border compute (needs tileTex) ---
this.borderPass = new BorderComputePass(
gl,
mapW,
mapH,
this.res.tileTex,
this.settings,
);
this.res.borderTex = this.borderPass.getBorderTex();
// --- Defense coverage (needs tileTex) — per-tile "defended by same-owner
// post" flag, stamped one instanced circle per post. Replaces the old
// 64-cap uniform loop; consumed by BorderStampPass. ---
this.defenseCoveragePass = new DefenseCoveragePass(
gl,
mapW,
mapH,
this.res.tileTex,
this.settings,
);
// --- Heat manager (needs tileTex, heatTexA/B) ---
this.heatManager = new HeatManager(
gl,
mapW,
mapH,
this.res.tileTex,
this.res.heatTexA,
this.res.heatTexB,
this.settings,
);
// --- Territory (needs tileTex, paletteTex, patternTexs, skinTexs) ---
this.territoryPass = new TerritoryPass(
gl,
mapW,
mapH,
this.res.tileTex,
this.paletteTex,
this.patternMetaTex,
this.patternDataTex,
this.skinAtlas.texture,
this.skinLayerTex,
this.skinAnchorTex,
this.settings,
);
// Route per-tile changes to the border pass so it can scatter-recompute
// just the affected tiles instead of rebuilding the whole map. A tile
// changing owner can also flip its defense-coverage flag (same-owner test),
// so mark the coverage stale too — one coalesced re-stamp happens per frame.
this.territoryPass.setBorderPatchConsumer((x, y, prevOwner, newOwner) => {
this.borderPass.patchTile(x, y, prevOwner, newOwner);
this.defenseCoveragePass.markTileDirty(x, y);
});
// Territory fill darkens on interior tiles defended by a same-owner post;
// borderTex lets the fill skip border tiles (those get the checkerboard).
this.territoryPass.setDefenseCoverageTex(
this.defenseCoveragePass.getCoverageTex(),
);
this.territoryPass.setBorderTex(this.res.borderTex);
// --- Spawn overlay (needs tileTex) ---
this.spawnOverlayPass = new SpawnOverlayPass(
gl,
mapW,
mapH,
this.res.tileTex,
this.settings.spawnOverlay,
);
// --- Trail (needs trailTex, paletteTex, effectTex) ---
this.trailPass = new TrailPass(
gl,
mapW,
mapH,
this.res.trailTex,
this.paletteTex,
this.effectTex,
this.settings,
);
// --- Border stamp (needs tileTex, paletteTex, borderTex) ---
this.borderStampPass = new BorderStampPass(
gl,
mapW,
mapH,
this.res.tileTex,
this.paletteTex,
this.res.borderTex,
this.settings,
);
this.borderStampPass.setDefenseCoverageTex(
this.defenseCoveragePass.getCoverageTex(),
);
// --- Fallout bloom (needs tileTex, heatManager) ---
this.bloomPass = new FalloutBloomPass(
gl,
mapW,
mapH,
this.res.tileTex,
this.heatManager,
this.settings,
);
// --- Point lights ---
this.pointLightPass = new PointLightPass(
gl,
header,
paletteData,
this.settings,
config,
);
// --- Fallout light (needs tileTex + heatManager; particle flicker is
// computed inline using the falloutBloom particle settings) ---
this.falloutLightPass = new FalloutLightPass(
gl,
mapW,
mapH,
this.res.tileTex,
this.heatManager,
this.settings,
);
// --- Lightmap orchestrator ---
this.lightmapPass = new LightmapPass(
gl,
mapW,
mapH,
this.pointLightPass,
this.falloutLightPass,
this.settings,
);
// --- Night composite ---
this.nightCompositePass = new NightCompositePass(gl, this.settings);
// --- Railroad (needs tileTex) ---
this.railroadPass = new RailroadPass(
gl,
mapW,
mapH,
this.res.tileTex,
this.paletteTex,
terrainBytes,
this.settings,
);
// --- Range circle (ghost preview radius) ---
this.rangeCirclePass = new RangeCirclePass(gl);
// --- SAM radius overlay (dashed green circles during build mode) ---
this.samRadiusPass = new SAMRadiusPass(gl, mapW, this.settings);
this.samRadiusPass.setPaletteData(paletteData);
// --- Crosshair (warship placement) ---
this.crosshairPass = new CrosshairPass(gl);
// --- Remaining passes (unchanged from v1) ---
this.structurePass = new StructurePass(
gl,
header,
this.paletteTex,
this.effectTex,
this.settings,
);
this.structureLevelPass = new StructureLevelPass(gl, header, this.settings);
this.unitPass = new UnitPass(
gl,
header,
this.paletteTex,
this.settings,
config,
);
this.namePass = new NamePass(
gl,
header,
paletteData,
this.settings,
config,
);
this.fxPass = new FxPass(gl, header, this.settings, config);
this.barPass = new BarPass(gl, header, this.settings, config);
this.worldTextPass = new WorldTextPass(gl, this.settings, config);
this.worldTextPass.setMapWidth(this.mapW);
this.selectionBoxPass = new SelectionBoxPass(gl);
this.moveIndicatorPass = new MoveIndicatorPass(gl, this.settings);
this.nukeTrajectoryPass = new NukeTrajectoryPass(gl, this.settings);
this.nukeTelegraphPass = new NukeTelegraphPass(gl, this.settings);
// --- Scene capture target (for night composite) ---
const sceneTex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, sceneTex);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA8,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
null,
);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
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);
const sceneFbo = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, sceneFbo);
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
sceneTex,
0,
);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
this.sceneTarget = { fbo: sceneFbo, tex: sceneTex, w: 1, h: 1 };
// --- Alt-view passes ---
this.affiliationPalette = new AffiliationPalette(gl, this.settings);
const affTex = this.affiliationPalette.getTexture();
this.borderStampPass.setAffiliationTex(affTex);
this.unitPass.setAffiliationTex(affTex);
this.structurePass.setAffiliationTex(affTex);
this.trailPass.setAffiliationTex(affTex);
this.coordinateGridPass = new CoordinateGridPass(
gl,
mapW,
mapH,
this.settings,
);
try {
this.gridView = window.localStorage.getItem(GRID_VIEW_KEY) === "true";
} catch {
this.setGridView(false);
}
for (const p of header.players) {
if (p.team !== null) this.playerTeams.set(p.smallID, p.team);
}
// Team mode = any player has a team. Drives skin tint behavior:
// FFA shows raw skin colors; teams multiply skin by team primary color.
this.territoryPass.setTeamMode(this.playerTeams.size > 0);
this.startLoop();
}
private renderLoop = (): void => {
this.draw();
this.animId = this.raf(this.renderLoop);
};
private startLoop(): void {
this.animId ??= this.raf(this.renderLoop);
}
private stopLoop(): void {
if (this.animId !== null) {
this.caf(this.animId);
this.animId = null;
}
}
// ---------------------------------------------------------------------------
// Canvas / Camera
// ---------------------------------------------------------------------------
resize(cssWidth: number, cssHeight: number): void {
const dpr = renderDpr();
this.canvas.width = Math.round(cssWidth * dpr);
this.canvas.height = Math.round(cssHeight * dpr);
this.camera.resize(cssWidth, cssHeight);
}
setCameraState(x: number, y: number, z: number): void {
this.camera.setCameraState(x, y, z);
}
// ---------------------------------------------------------------------------
// Data upload
// ---------------------------------------------------------------------------
uploadTileAndTrailState(
tileState: Uint16Array,
trailState: Uint16Array,
): void {
this.territoryPass.setLiveRef(tileState);
this.trailPass.setLiveRef(trailState);
}
uploadLiveDelta(
tileState: Uint16Array,
changedTiles: readonly number[],
): void {
this.territoryPass.applyLiveDelta(tileState, changedTiles);
}
uploadLiveTrailDelta(
trailState: Uint16Array,
dirtyRowMin: number,
dirtyRowMax: number,
): void {
this.trailPass.applyLiveDelta(trailState, dirtyRowMin, dirtyRowMax);
}
/** Re-upload palette data to the GPU texture (e.g. when players appear after initial startup). */
updatePalette(paletteData: Float32Array): void {
const gl = this.gl;
// Mutate the stored array in-place so all passes sharing the reference see the update.
this.paletteData.set(paletteData);
// Re-upload to the GPU texture
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.paletteTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
getPaletteSize(),
2,
gl.RGBA,
gl.FLOAT,
this.paletteData,
);
// SAM radius pass stores its own copy
this.samRadiusPass.setPaletteData(this.paletteData);
// Name pass caches per-player colors and bakes them into slot rows
this.namePass.refreshPlayerColors(this.paletteData);
}
/** Re-upload the per-player effect texture (style + colors by smallID). */
updateEffectPalette(effectData: Float32Array): void {
const gl = this.gl;
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.effectTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
getPaletteSize(),
MAX_TRAIL_COLORS * EFFECT_PALETTE_BLOCKS,
gl.RGBA,
gl.FLOAT,
effectData,
);
}
/** Register late-arriving players (updates palette + NamePass lookup maps). */
addPlayers(
players: PlayerStatic[],
paletteData: Float32Array,
patternMeta: Float32Array,
patternData: Uint8Array,
): void {
this.updatePalette(paletteData);
const gl = this.gl;
const palW = getPaletteSize();
gl.bindTexture(gl.TEXTURE_2D, this.patternMetaTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
palW,
1,
gl.RGBA,
gl.FLOAT,
patternMeta,
);
gl.bindTexture(gl.TEXTURE_2D, this.patternDataTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
1024,
palW,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
patternData,
);
this.namePass.addPlayers(players, this.paletteData);
for (const p of players) {
if (p.team !== null) this.playerTeams.set(p.smallID, p.team);
}
// Renderer was constructed with players: [] (real list arrives via this
// method), so team mode must be re-evaluated whenever new players arrive
// — otherwise team games never enable the skin-tint branch.
this.territoryPass.setTeamMode(this.playerTeams.size > 0);
}
/**
* Anchor a player's skin sampling at world coords (x, y). The center of the
* skin image lines up with this tile. Default (0,0) anchors at world origin.
*/
setPlayerSpawn(smallID: number, x: number, y: number): void {
const off = smallID * 2;
this.skinAnchorCpu[off] = x;
this.skinAnchorCpu[off + 1] = y;
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.skinAnchorTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
getPaletteSize(),
1,
gl.RG_INTEGER,
gl.UNSIGNED_SHORT,
this.skinAnchorCpu,
);
}
/**
* Allocate the skin atlas to exactly `urls.length` layers. The player set is
* locked at game start so this is called once with the complete URL list;
* URLs not in this set will be ignored by `setPlayerSkin`.
*
* Layers are zero-initialized (browsers do this for security regardless of
* the GL spec's "undefined" wording), so players whose images haven't
* decoded yet render with alpha=0 → falls through to base player color.
*/
initSkinAtlas(urls: readonly string[]): void {
this.skinAtlas.dispose();
this.skinAtlas = new SkinAtlasArray(this.gl, urls, () => {});
this.territoryPass.setSkinAtlas(this.skinAtlas.texture);
}
/**
* Map a player to a pre-registered skin layer. URLs not registered via
* `initSkinAtlas` are silently dropped. If the image is still decoding the
* layer renders transparent (zero-init) until decode completes.
*/
setPlayerSkin(smallID: number, url: string): void {
const layer = this.skinAtlas.getLayer(url);
if (layer < 0) return;
this.skinLayerCpu[smallID] = layer + 1;
this.uploadSkinLayerTex();
}
private uploadSkinLayerTex(): void {
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.skinLayerTex);
gl.texSubImage2D(
gl.TEXTURE_2D,
0,
0,
0,
getPaletteSize(),
1,
gl.RED_INTEGER,
gl.UNSIGNED_BYTE,
this.skinLayerCpu,
);
}
uploadRailroadState(data: Uint8Array): void {
this.railroadPass.uploadRailroadState(data);
}
updateUnits(units: Map<number, UnitState>, gameTick: number): void {
this.lastUnits = units;
this.frameTick++;
this.unitPass.updateUnits(units, this.frameTick);
this.barPass.updateBars(units, this.lastStructures, gameTick);
this.pointLightPass.updateLights(units);
this.heatManager.decayHeat();
}
updateNames(
names: Map<string, NameEntry>,
players: Map<number, PlayerState>,
snap: boolean,
statusData?: Map<number, PlayerStatusData>,
): void {
this.namePass.updateNames(names, players, snap, statusData);
// Extract local player's allies + teammates for SAM radius coloring
if (this.localPlayerID > 0) {
const localPS = players.get(this.localPlayerID);
const friendly = new Set(localPS?.allies ?? []);
const myTeam = this.playerTeams.get(this.localPlayerID);
if (myTeam !== undefined) {
for (const [sid, team] of this.playerTeams) {
if (team === myTeam && sid !== this.localPlayerID) friendly.add(sid);
}
}
this.samRadiusPass.setAllies(friendly);
this.unitPass.setAllies(friendly);
}
}
/** Re-resolve player name strings live (e.g. anonymous-names toggle). */
refreshNames(displayNames: Map<string, string>): void {
this.namePass.refreshNames(displayNames);
}
updateRelations(data: Uint8Array, size: number): void {
this.borderPass.updateRelations(data, size);
this.affiliationPalette.updateRelations(data, size);
}
updateStructures(units: Map<number, UnitState>): void {
this.lastStructures = units;
this.structurePass.updateStructures(units);
this.structureLevelPass.updateStructures(units);
this.samRadiusPass.updateStructures(units);
this.unitPass.setStructures(units);
const posts: { x: number; y: number; ownerID: number }[] = [];
const w = this.mapW;
for (const u of units.values()) {
if (u.unitType === "Defense Post" && !u.underConstruction) {
posts.push({
x: u.pos % w,
y: (u.pos - (u.pos % w)) / w,
ownerID: u.ownerID,
});
}
}
this.defenseCoveragePass.updateDefensePosts(posts);
}
applyDeadUnits(deadUnits: DeadUnitFx[]): void {
if (deadUnits.length > 0) this.fxPass.applyDeadUnits(deadUnits);
}
applyRailroadDust(tileRefs: number[]): void {
if (tileRefs.length > 0) this.fxPass.applyRailroadDust(tileRefs);
}
/**
* Update terrain texels for tiles whose terrain byte changed (e.g. water
* nukes converting land → water). `terrainBytes[i]` is the new byte for
* `refs[i]`. Forwards to both TerrainPass (RGBA color) and RailroadPass
* (R8UI water-detection for bridges).
*/
applyTerrainDelta(refs: readonly number[], terrainBytes: Uint8Array): void {
if (refs.length === 0) return;
this.terrainPass.applyTerrainDelta(refs, terrainBytes);
this.railroadPass.applyTerrainDelta(refs, terrainBytes);
}
/**
* Rebuild the terrain texture from the current `settings.terrain` colors.
* Terrain is baked into a GPU texture rather than read per-frame, so a
* settings change needs this explicit rebuild.
*/
rebuildTerrain(): void {
this.terrainPass.setTerrainColors({
oceanColor: hexToRgb(this.settings.terrain.oceanColor) ?? undefined,
sandColor: hexToRgb(this.settings.terrain.sandColor) ?? undefined,
plainsColor: hexToRgb(this.settings.terrain.plainsColor) ?? undefined,
highlandColor: hexToRgb(this.settings.terrain.highlandColor) ?? undefined,
mountainColor: hexToRgb(this.settings.terrain.mountainColor) ?? undefined,
});
}
applyConquestEvents(events: ConquestFx[]): void {
if (events.length > 0) {
this.fxPass.applyConquestEvents(events);
this.worldTextPass.applyConquestEvents(events);
}
}
setAttackTroopLabels(
labels: import("./passes/WorldTextPass").AttackTroopLabel[],
): void {
this.worldTextPass.setAttackTroopLabels(labels);
}
applyBonusEvents(events: BonusEvent[]): void {
if (events.length === 0) return;
// In live game, filter to local player only. In replay (localPlayerID=0), show all.
const filtered =
this.localPlayerID > 0
? events.filter((e) => e.smallID === this.localPlayerID)
: events;
if (filtered.length > 0) this.worldTextPass.applyBonusEvents(filtered);
}
updateAttackRings(rings: AttackRingInput[]): void {
this.fxPass.updateAttackRings(rings);
}
updateGhostPreview(data: GhostPreviewData | null): void {
this.structurePass.updateGhostPreview(data);
this.railroadPass.updateGhostPreview(data);
this.rangeCirclePass.updateGhostPreview(data);
this.crosshairPass.updateGhostPreview(data);
this.worldTextPass.setGhostCostLabel(
data && data.showCost && data.cost > 0
? {
tileX: data.tileX,
tileY: data.tileY,
cost: data.cost,
canAfford: data.canAfford,
canPlace: data.canBuild || data.canUpgrade,
}
: null,
);
this.samGhostVisible =
data !== null && SAM_RADIUS_GHOST_TYPES.has(data.ghostType);
this.samRadiusPass.setVisible(
this.samGhostVisible || this.samHighlightVisible,
);
}
updateNukeTrajectory(data: NukeTrajectoryData | null): void {
this.nukeTrajectoryPass.update(data);
}
updateNukeTelegraphs(data: NukeTelegraphData[]): void {
this.nukeTelegraphPass.update(data);
}
updateSpawnOverlay(inSpawnPhase: boolean, centers: SpawnCenter[]): void {
this.inSpawnPhase = inSpawnPhase;
this.spawnOverlayPass.update(inSpawnPhase, centers);
}
// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------
setHighlightOwner(ownerID: number): void {
this.borderPass.setHighlightOwner(ownerID);
this.territoryPass.setHighlightOwner(ownerID);
this.namePass.setHighlightOwner(ownerID);
this.structurePass.setHighlightOwner(ownerID);
}
setMouseWorldPos(x: number, y: number): void {
this.namePass.setMouseWorldPos(x, y);
}
setHighlightStructureTypes(unitTypes: string[] | null): void {
this.structurePass.setHighlightTypes(unitTypes);
this.structureLevelPass.setHighlightTypes(unitTypes);
this.samHighlightVisible =
unitTypes !== null &&
unitTypes.some((t) => SAM_RADIUS_HIGHLIGHT_TYPES.has(t));
this.samRadiusPass.setVisible(
this.samGhostVisible || this.samHighlightVisible,
);
}
setLocalPlayerID(id: number): void {
if (id === this.localPlayerID) return;
this.localPlayerID = id;
this.samRadiusPass.setLocalPlayer(id);
this.structurePass.setLocalPlayer(id);
this.affiliationPalette.setLocalPlayer(id);
this.unitPass.setLocalPlayer(id);
this.railroadPass.setLocalPlayer(id);
}
setLocalRailColor(r: number, g: number, b: number): void {
this.railroadPass.setLocalRailColor(r, g, b);
}
setSAMAllianceClusters(clusters: Map<number, number>): void {
this.samRadiusPass.setAllianceClusters(clusters);
}
setAltView(active: boolean): void {
this.altView = active;
this.territoryPass.setAltView(active);
this.borderStampPass.setAltView(active);
this.unitPass.setAltView(active);
this.structurePass.setAltView(active);
this.trailPass.setAltView(active);
}
setShowPatterns(active: boolean): void {
this.territoryPass.setShowPatterns(active);
}
setGridView(active: boolean): void {
this.gridView = active;
try {
window.localStorage.setItem(GRID_VIEW_KEY, active ? "true" : "false");
} catch {
// Ignore if we are unable to use localstorage.
}
}
getSettings(): RenderSettings {
return this.settings;
}
// ---------------------------------------------------------------------------
// Selection box (warship selection)
// ---------------------------------------------------------------------------
setSelectedUnits(unitIds: readonly number[]): void {
// Copy in (callers may mutate their array).
this.selectedUnitIds.length = 0;
for (let i = 0; i < unitIds.length; i++) {
this.selectedUnitIds.push(unitIds[i]);
}
if (this.selectedUnitIds.length === 0) {
this.selectionBoxPass.hide();
}
// Position + color are rebuilt each frame in updateSelectionBox() from
// lastUnits — dead units get dropped automatically.
}
private updateSelectionBox(): void {
if (this.selectedUnitIds.length === 0) return;
// Build the entries for this frame and prune dead unit IDs in place.
const entries = this.selectionBoxEntries;
entries.length = 0;
let writeIdx = 0;
for (let i = 0; i < this.selectedUnitIds.length; i++) {
const id = this.selectedUnitIds[i];
const unit = this.lastUnits.get(id);
if (!unit || !unit.isActive) continue; // dead — drop
this.selectedUnitIds[writeIdx++] = id;
const centerX = unit.pos % this.mapW;
const centerY = Math.floor(unit.pos / this.mapW);
// Lighten the owner's territory color by ~20% (mix toward white).
const off = unit.ownerID * 4;
const r = Math.min(
1,
this.paletteData[off] + (1 - this.paletteData[off]) * 0.3,
);
const g = Math.min(
1,
this.paletteData[off + 1] + (1 - this.paletteData[off + 1]) * 0.3,
);
const b = Math.min(
1,
this.paletteData[off + 2] + (1 - this.paletteData[off + 2]) * 0.3,
);
entries.push({ centerX, centerY, r, g, b });
}
this.selectedUnitIds.length = writeIdx;
this.selectionBoxPass.setSelections(entries);
}
// ---------------------------------------------------------------------------
// Move indicator (warship move-target chevrons)
// ---------------------------------------------------------------------------
showMoveIndicator(tileX: number, tileY: number, ownerID: number): void {
const off = ownerID * 4;
const r = Math.min(
1,
this.paletteData[off] + (1 - this.paletteData[off]) * 0.3,
);
const g = Math.min(
1,
this.paletteData[off + 1] + (1 - this.paletteData[off + 1]) * 0.3,
);
const b = Math.min(
1,
this.paletteData[off + 2] + (1 - this.paletteData[off + 2]) * 0.3,
);
this.moveIndicatorPass.show(tileX, tileY, r, g, b);
}
// ---------------------------------------------------------------------------
// Render — normalized draw order
// ---------------------------------------------------------------------------
draw(): void {
this.uploadTextures();
this.computeTextures();
this.renderFrame();
}
private uploadTextures(): void {
if (this.altView) this.affiliationPalette.flush();
if (this.inSpawnPhase) {
this.territoryPass.flushAllDripBuckets();
} else {
this.territoryPass.drainDripBucket();
}
// Full uploads need a full border recompute; scatter uploads already
// pushed per-tile border patches via the wired `borderPatchConsumer`.
if (this.territoryPass.flushTileTexture() === "full") {
this.borderPass.markGlobalDirty();
this.defenseCoveragePass.markDirty();
}
// Heat decay only runs while fallout is in play — (re)activate whenever a
// fallout bit flipped in the tile state that just reached the GPU.
if (this.territoryPass.consumeFalloutTouched()) {
this.heatManager.activate();
}
this.trailPass.flushTexture();
this.heatManager.updateHeat();
}
private computeTextures(): void {
if (this.settings.passEnabled.borderCompute) this.borderPass.draw();
// Re-stamp defense coverage if posts/territory changed (dirty-gated).
// Leaves the default framebuffer bound; renderFrame resets the viewport.
this.defenseCoveragePass.draw();
}
private renderFrame(): void {
const cam = this.camera.getMatrix();
const zoom = this.camera.zoom;
const cw = this.canvas.width;
const ch = this.canvas.height;
const compositingActive = this.isLightCompositingActive();
if (compositingActive) {
this.resizeSceneTargetIfNeeded(cw, ch);
const sceneTex = toTarget(this.gl, this.sceneTarget, () =>
this.drawBaseLayer(cam),
);
const lightTex = this.lightmapPass.draw(cam, cw, ch, this.frameTick);
toScreen(this.gl, cw, ch, () =>
this.nightCompositePass.draw(sceneTex, lightTex),
);
} else {
toScreen(this.gl, cw, ch, () => this.drawBaseLayer(cam));
}
this.renderOverlays(cam, zoom);
}
private isLightCompositingActive(): boolean {
return this.settings.lighting.enabled;
}
private resizeSceneTargetIfNeeded(cw: number, ch: number): void {
if (this.sceneTarget.w === cw && this.sceneTarget.h === ch) return;
this.sceneTarget.w = cw;
this.sceneTarget.h = ch;
const gl = this.gl;
gl.bindTexture(gl.TEXTURE_2D, this.sceneTarget.tex);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA8,
cw,
ch,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
null,
);
}
private drawBaseLayer(cam: Float32Array): void {
const gl = this.gl;
const pe = this.settings.passEnabled;
gl.clearColor(60 / 255, 60 / 255, 60 / 255, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.disable(gl.BLEND);
if (pe.terrain) this.terrainPass.draw(cam);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
if (pe.territory) this.territoryPass.draw(cam);
}
private renderOverlays(cam: Float32Array, zoom: number): void {
const gl = this.gl;
const pe = this.settings.passEnabled;
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
this.spawnOverlayPass.draw(cam);
if (pe.borderStamp) this.borderStampPass.draw(cam);
if (pe.railroad) this.railroadPass.draw(cam, zoom);
if (pe.unit) this.unitPass.drawGround(cam);
if (pe.falloutBloom) this.bloomPass.draw(cam, this.frameTick);
this.samRadiusPass.draw(cam);
this.rangeCirclePass.draw(cam);
this.nukeTrajectoryPass.draw(cam);
this.crosshairPass.draw(cam);
if (pe.structure) this.structurePass.draw(cam, zoom);
if (pe.structure) this.structureLevelPass.draw(cam, zoom);
if (pe.bar) this.barPass.draw(cam);
this.updateSelectionBox();
this.selectionBoxPass.draw(cam, this.frameTick);
this.moveIndicatorPass.draw(cam, zoom);
this.nukeTelegraphPass.draw(cam);
if (pe.trail) this.trailPass.draw(cam);
if (pe.unit) this.unitPass.drawMissiles(cam);
if (pe.fx) {
this.fxPass.tick();
this.fxPass.draw(cam, zoom);
}
// Grid shows on either trigger; names hide only under alt-view (space
// hold), not under the persistent M-key gridView toggle.
if (this.gridView || this.altView) this.coordinateGridPass.draw(cam, zoom);
if (pe.name && !this.altView)
this.namePass.draw(cam, this.nightCompositePass.getAmbient());
// World text (attack-troop labels, popups, ghost cost) draws on top of
// player names so attack callouts aren't hidden behind a centered name.
this.worldTextPass.tick(zoom);
this.worldTextPass.draw(cam, zoom);
gl.disable(gl.BLEND);
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
dispose(): void {
this.stopLoop();
this.terrainPass.dispose();
this.territoryPass.dispose();
this.trailPass.dispose();
this.borderStampPass.dispose();
this.borderPass.dispose();
this.defenseCoveragePass.dispose();
this.bloomPass.dispose();
this.pointLightPass.dispose();
this.falloutLightPass.dispose();
this.lightmapPass.dispose();
this.nightCompositePass.dispose();
this.heatManager.dispose();
this.affiliationPalette.dispose();
this.coordinateGridPass.dispose();
this.spawnOverlayPass.dispose();
this.railroadPass.dispose();
this.rangeCirclePass.dispose();
this.samRadiusPass.dispose();
this.crosshairPass.dispose();
this.structurePass.dispose();
this.structureLevelPass.dispose();
this.unitPass.dispose();
this.namePass.dispose();
this.fxPass.dispose();
this.worldTextPass.dispose();
this.selectionBoxPass.dispose();
this.moveIndicatorPass.dispose();
this.nukeTrajectoryPass.dispose();
this.nukeTelegraphPass.dispose();
this.barPass.dispose();
disposeGPUResources(this.gl, this.res);
this.gl.deleteTexture(this.paletteTex);
this.gl.deleteTexture(this.effectTex);
this.gl.deleteTexture(this.patternMetaTex);
this.gl.deleteTexture(this.patternDataTex);
this.gl.deleteTexture(this.skinLayerTex);
this.gl.deleteTexture(this.skinAnchorTex);
this.skinAtlas.dispose();
this.gl.deleteFramebuffer(this.sceneTarget.fbo);
this.gl.deleteTexture(this.sceneTarget.tex);
this.lastUnits = new Map();
this.lastStructures = new Map();
// Deleting GL resources isn't enough — the context itself counts against
// the browser's WebGL context limit until it's GC'd, which is unreliable
// on mobile. Explicitly drop it so repeated game starts don't overflow.
this.gl.getExtension("WEBGL_lose_context")?.loseContext();
}
}