mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-10 23:44:13 +00:00
perf(client): main-thread memory harness + drop three map-sized render buffers (-23%) (#4511)
## Summary Follow-up to #4507, moving the memory-footprint campaign to the **main thread** (client). Two parts: a headless browser measurement harness, and a first optimization round that cuts the main-thread live heap on Giant World Map by **23%** (166 → 128 MB at tick 2000). ## Part 1 — `npm run perf:client-mem`: headless main-thread memory harness Drives a real singleplayer game in headless Chromium and measures the **page's isolate only** (the core sim worker is a separate CDP target): - Starts its own vite dev server on a private port (default 9017) so it always measures the current checkout. - Double-forced-GC checkpoints every `--window` ticks: JS heap, ArrayBuffer backing-store bytes (`Runtime.getHeapUsage`), DOM nodes, listeners, ticks/s. - `--snapshot-at <ticks>` writes V8 heap snapshots, analyzable with the retainer/summary tools from #4507. - Spoofs the unmasked WebGL renderer string via an init script so the software-GL gate (#4324) admits SwiftShader — no game code touched; rendering still runs software (hence the rAF throttle). - End-of-run screenshot as a rendering sanity check. Baseline (Giant World Map, 400 bots, 12,000 ticks): ~176 MB live, of which ~116 MB is **static per-tile buffers** allocated up front for the 8M-tile map — flat during play, no leaks. ## Part 2 — drop three map-sized render-layer buffer copies | Buffer | Before | After | |---|---|---| | `TrailPass.cpuTrailState` | 15.3 MB copy | **deleted** — dead code; every upload entry point sets the live reference to TrailManager's array | | `RailroadPass.cpuRailroadState` | 15.3 MB across 2 arrays | references `RailroadCache.railroadState` (stable identity, mutated in place) | | `RailroadPass.cpuGhostRailState` | ↑ | sparse `Map<ref, value>`; preview diffs applied as per-texel `texSubImage2D` writes (path-sized work instead of a full 8 MB texture upload per build-preview mouse move) | | `TerrainPass` + `MapRenderer` terrain bytes | 7.6 MB (one buffer, two retainers) | `terrainSource()` provider — re-bakes (theme change, context restore) regenerate from the live game map, which already reflects water-nuke conversions | Tick-2000 snapshot comparison (giant world, 400 bots): **166.4 → 128.4 MB**. ## Verification - `tsc --noEmit`, eslint, full test suite (1924 tests) pass. - 2000-tick headless giant-world game after the change: no GL pageerrors, end-of-run screenshot renders terrain/territory/borders/names correctly, sim speed unchanged (~5 ticks/s headless). - Ghost-rail ops flush before the zoom-fade early-return, so the op queue can't grow while previewing at low zoom. - WebGL context restore recreates all passes fresh and the owner re-uploads state (existing `onContextRestored` path), consistent with the new reference-based buffers. Note: heap snapshots in `tests/perf/output/` are gitignored; the numbers above are from runs recorded in the PR discussion. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -275,12 +275,20 @@ function createWebGLView(
|
||||
const mapWidth = gameMap.width();
|
||||
const mapHeight = gameMap.height();
|
||||
|
||||
const terrainBytes = new Uint8Array(mapWidth * mapHeight);
|
||||
for (let y = 0; y < mapHeight; y++) {
|
||||
for (let x = 0; x < mapWidth; x++) {
|
||||
terrainBytes[y * mapWidth + x] = gameMap.terrainByte(gameMap.ref(x, y));
|
||||
// Provider, not a buffer: per-tile terrain bytes are map-sized (8 MB on
|
||||
// the giant map), so consumers regenerate them on demand (initial bake,
|
||||
// context restore, theme change) instead of anyone retaining a copy.
|
||||
// gameMap is updated live by water-nuke conversions, so a regenerated
|
||||
// array always reflects them.
|
||||
const terrainSource = (): Uint8Array => {
|
||||
const terrainBytes = new Uint8Array(mapWidth * mapHeight);
|
||||
for (let y = 0; y < mapHeight; y++) {
|
||||
for (let x = 0; x < mapWidth; x++) {
|
||||
terrainBytes[y * mapWidth + x] = gameMap.terrainByte(gameMap.ref(x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
return terrainBytes;
|
||||
};
|
||||
|
||||
const glCanvas = createCanvas();
|
||||
glCanvas.id = "webgl-debug-canvas";
|
||||
@@ -324,7 +332,7 @@ function createWebGLView(
|
||||
// bound at construction time.
|
||||
maxPlayers: 1024,
|
||||
},
|
||||
terrainBytes,
|
||||
terrainSource,
|
||||
palette,
|
||||
config,
|
||||
settings,
|
||||
|
||||
@@ -47,7 +47,10 @@ export class MapRenderer {
|
||||
constructor(
|
||||
private canvas: HTMLCanvasElement,
|
||||
private header: RendererConfig,
|
||||
private terrainBytes: Uint8Array,
|
||||
// Called (not stored) whenever terrain bytes are needed — initial bake
|
||||
// and every context restore. Regenerating on demand avoids retaining a
|
||||
// map-sized buffer for the rare restore path.
|
||||
private terrainSource: () => Uint8Array,
|
||||
private paletteData: Float32Array,
|
||||
private config: Config,
|
||||
// Resolved render settings (defaults + overrides). Held so the same object
|
||||
@@ -79,7 +82,7 @@ export class MapRenderer {
|
||||
this.renderer = new GPURenderer(
|
||||
this.canvas,
|
||||
this.header,
|
||||
this.terrainBytes,
|
||||
this.terrainSource,
|
||||
this.paletteData,
|
||||
this.config,
|
||||
this.settings,
|
||||
|
||||
@@ -197,7 +197,7 @@ export class GPURenderer {
|
||||
constructor(
|
||||
canvas: HTMLCanvasElement,
|
||||
header: RendererConfig,
|
||||
terrainBytes: Uint8Array,
|
||||
terrainSource: () => Uint8Array,
|
||||
paletteData: Float32Array,
|
||||
config: Config,
|
||||
settings: RenderSettings,
|
||||
@@ -246,13 +246,25 @@ export class GPURenderer {
|
||||
this.camera = new Camera(mapW, mapH);
|
||||
|
||||
// --- Terrain (static) ---
|
||||
this.terrainPass = new TerrainPass(gl, 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,
|
||||
});
|
||||
// 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;
|
||||
|
||||
@@ -108,11 +108,23 @@ export class RailroadPass {
|
||||
private mapH: number;
|
||||
private settings: RenderSettings;
|
||||
|
||||
private cpuRailroadState: Uint8Array;
|
||||
/**
|
||||
* Reference to the caller-owned railroad state (RailroadCache's array;
|
||||
* stable identity, mutated in place). The pass keeps no copy — the array
|
||||
* must stay current until the flush. Null until the first upload.
|
||||
*/
|
||||
private liveRailroadRef: Uint8Array | null = null;
|
||||
private railroadDirty = false;
|
||||
|
||||
private cpuGhostRailState: Uint8Array;
|
||||
private ghostRailDirty = false;
|
||||
/**
|
||||
* Current ghost overlay content, sparse: tile ref → texel value (1-6 =
|
||||
* orientation, 7 = overlap highlight). Ghost paths cover at most a few
|
||||
* thousand tiles, so tracking them beats a full-map array + full-map
|
||||
* texture upload per preview change.
|
||||
*/
|
||||
private ghostTiles = new Map<number, number>();
|
||||
/** Pending ghost texel writes, interleaved [ref, value, …]. */
|
||||
private ghostOps: number[] = [];
|
||||
private ghostOwnerID = 0;
|
||||
|
||||
private localPlayerID = 0;
|
||||
@@ -132,8 +144,6 @@ export class RailroadPass {
|
||||
this.tileTex = tileTex;
|
||||
this.paletteTex = paletteTex;
|
||||
this.settings = settings;
|
||||
this.cpuRailroadState = new Uint8Array(mapW * mapH);
|
||||
this.cpuGhostRailState = new Uint8Array(mapW * mapH);
|
||||
|
||||
this.program = createProgram(
|
||||
gl,
|
||||
@@ -187,14 +197,14 @@ export class RailroadPass {
|
||||
filter: gl.NEAREST,
|
||||
});
|
||||
|
||||
// R8UI railroad texture
|
||||
// R8UI railroad texture (null data = zero-initialized per the WebGL spec)
|
||||
this.railroadTex = createTexture2D(gl, {
|
||||
width: mapW,
|
||||
height: mapH,
|
||||
internalFormat: gl.R8UI,
|
||||
format: gl.RED_INTEGER,
|
||||
type: gl.UNSIGNED_BYTE,
|
||||
data: this.cpuRailroadState,
|
||||
data: null,
|
||||
filter: gl.NEAREST,
|
||||
});
|
||||
|
||||
@@ -205,7 +215,7 @@ export class RailroadPass {
|
||||
internalFormat: gl.R8UI,
|
||||
format: gl.RED_INTEGER,
|
||||
type: gl.UNSIGNED_BYTE,
|
||||
data: this.cpuGhostRailState,
|
||||
data: null,
|
||||
filter: gl.NEAREST,
|
||||
});
|
||||
|
||||
@@ -213,7 +223,7 @@ export class RailroadPass {
|
||||
}
|
||||
|
||||
uploadRailroadState(railroadState: Uint8Array): void {
|
||||
this.cpuRailroadState.set(railroadState);
|
||||
this.liveRailroadRef = railroadState;
|
||||
this.railroadDirty = true;
|
||||
}
|
||||
|
||||
@@ -257,7 +267,7 @@ export class RailroadPass {
|
||||
}
|
||||
|
||||
updateGhostPreview(data: GhostPreviewData | null): void {
|
||||
this.cpuGhostRailState.fill(0);
|
||||
const next = new Map<number, number>();
|
||||
|
||||
if (data) {
|
||||
const maxRef = this.mapW * this.mapH;
|
||||
@@ -268,7 +278,7 @@ export class RailroadPass {
|
||||
const tiles = this.computePathOrientations(path);
|
||||
for (const t of tiles) {
|
||||
if (t.ref >= 0 && t.ref < maxRef) {
|
||||
this.cpuGhostRailState[t.ref] = t.type + 1;
|
||||
next.set(t.ref, t.type + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,7 +287,7 @@ export class RailroadPass {
|
||||
// overlappingRailroads contains resolved tile refs (not rail IDs)
|
||||
for (const ref of data.overlappingRailroads) {
|
||||
if (ref >= 0 && ref < maxRef) {
|
||||
this.cpuGhostRailState[ref] = 7;
|
||||
next.set(ref, 7);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +296,15 @@ export class RailroadPass {
|
||||
this.ghostOwnerID = 0;
|
||||
}
|
||||
|
||||
this.ghostRailDirty = true;
|
||||
// Queue texel writes for the diff: clear tiles that left the ghost,
|
||||
// (re)write tiles whose value is new or changed.
|
||||
for (const ref of this.ghostTiles.keys()) {
|
||||
if (!next.has(ref)) this.ghostOps.push(ref, 0);
|
||||
}
|
||||
for (const [ref, value] of next) {
|
||||
if (this.ghostTiles.get(ref) !== value) this.ghostOps.push(ref, value);
|
||||
}
|
||||
this.ghostTiles = next;
|
||||
}
|
||||
|
||||
/** Draw the railroad overlay. Must be called with alpha blending enabled. */
|
||||
@@ -294,6 +312,10 @@ export class RailroadPass {
|
||||
const gl = this.gl;
|
||||
const rs = this.settings.railroad;
|
||||
|
||||
// Flush queued ghost texel writes even when faded out, so the op queue
|
||||
// can't grow unboundedly while the player previews at low zoom.
|
||||
this.flushGhostOps();
|
||||
|
||||
// Fade out as zoom drops below railMinZoom; fully invisible at railMinZoom - railFadeRange
|
||||
const fadeRange = Math.max(rs.railFadeRange, 0);
|
||||
const fadeStart = rs.railMinZoom - fadeRange;
|
||||
@@ -305,8 +327,8 @@ export class RailroadPass {
|
||||
: Math.min(1, Math.max(0, (zoom - fadeStart) / fadeRange));
|
||||
if (fade <= 0) return;
|
||||
|
||||
// Flush CPU railroad state → GPU
|
||||
if (this.railroadDirty) {
|
||||
// Flush railroad state → GPU
|
||||
if (this.railroadDirty && this.liveRailroadRef !== null) {
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.railroadTex);
|
||||
gl.texSubImage2D(
|
||||
@@ -318,29 +340,11 @@ export class RailroadPass {
|
||||
this.mapH,
|
||||
gl.RED_INTEGER,
|
||||
gl.UNSIGNED_BYTE,
|
||||
this.cpuRailroadState,
|
||||
this.liveRailroadRef,
|
||||
);
|
||||
this.railroadDirty = false;
|
||||
}
|
||||
|
||||
// Flush ghost railroad state → GPU
|
||||
if (this.ghostRailDirty) {
|
||||
gl.activeTexture(gl.TEXTURE4);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.ghostRailTex);
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
this.mapW,
|
||||
this.mapH,
|
||||
gl.RED_INTEGER,
|
||||
gl.UNSIGNED_BYTE,
|
||||
this.cpuGhostRailState,
|
||||
);
|
||||
this.ghostRailDirty = false;
|
||||
}
|
||||
|
||||
gl.useProgram(this.program);
|
||||
gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix);
|
||||
gl.uniform2f(this.uMapSize, this.mapW, this.mapH);
|
||||
@@ -378,6 +382,39 @@ export class RailroadPass {
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply queued ghost texel writes to the ghost texture, one texel per
|
||||
* texSubImage2D. Ghost diffs are path-sized (at most a few thousand
|
||||
* texels), far cheaper than the full-map upload a dense mirror needs.
|
||||
*/
|
||||
private flushGhostOps(): void {
|
||||
const ops = this.ghostOps;
|
||||
if (ops.length === 0) return;
|
||||
const gl = this.gl;
|
||||
gl.activeTexture(gl.TEXTURE4);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.ghostRailTex);
|
||||
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
||||
const scratch = new Uint8Array(1);
|
||||
for (let i = 0; i < ops.length; i += 2) {
|
||||
const ref = ops[i];
|
||||
const x = ref % this.mapW;
|
||||
const y = (ref - x) / this.mapW;
|
||||
scratch[0] = ops[i + 1];
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
x,
|
||||
y,
|
||||
1,
|
||||
1,
|
||||
gl.RED_INTEGER,
|
||||
gl.UNSIGNED_BYTE,
|
||||
scratch,
|
||||
);
|
||||
}
|
||||
ops.length = 0;
|
||||
}
|
||||
|
||||
// ---- Rail orientation computation ----
|
||||
|
||||
private computePathOrientations(
|
||||
|
||||
@@ -40,7 +40,12 @@ export class TerrainPass {
|
||||
|
||||
constructor(
|
||||
private gl: WebGL2RenderingContext,
|
||||
private terrainBytes: Uint8Array,
|
||||
// Regenerates current per-tile terrain bytes (reflecting water-nuke
|
||||
// conversions) for the rare full re-bake in setTerrainColors. A provider
|
||||
// instead of a retained buffer: terrain bytes are map-sized (8 MB on the
|
||||
// giant map).
|
||||
private terrainSource: () => Uint8Array,
|
||||
terrainBytes: Uint8Array,
|
||||
mapW: number,
|
||||
mapH: number,
|
||||
terrainColors?: TerrainColorOverrides,
|
||||
@@ -86,7 +91,12 @@ export class TerrainPass {
|
||||
this.mapH,
|
||||
gl.RGBA,
|
||||
gl.UNSIGNED_BYTE,
|
||||
buildTerrainRGBA(this.terrainBytes, this.mapW, this.mapH, terrainColors),
|
||||
buildTerrainRGBA(
|
||||
this.terrainSource(),
|
||||
this.mapW,
|
||||
this.mapH,
|
||||
terrainColors,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,10 +104,8 @@ export class TerrainPass {
|
||||
* Update a subset of terrain tiles in-place (e.g. land→water from a water
|
||||
* nuke). `bytes[i]` is the new terrain byte for `refs[i]` (parallel arrays).
|
||||
* One 1×1 texSubImage2D per ref — fine for the small bursts a single nuke
|
||||
* produces.
|
||||
*
|
||||
* Also writes back into `terrainBytes` so a later full re-upload (e.g.
|
||||
* setTerrainColor) reflects these conversions instead of reverting them.
|
||||
* produces. A later full re-upload (setTerrainColors) regenerates from
|
||||
* terrainSource, whose backing game map already reflects these conversions.
|
||||
*/
|
||||
applyTerrainDelta(refs: readonly number[], bytes: Uint8Array): void {
|
||||
if (refs.length === 0) return;
|
||||
@@ -108,7 +116,6 @@ export class TerrainPass {
|
||||
const ref = refs[i];
|
||||
const x = ref % this.mapW;
|
||||
const y = (ref - x) / this.mapW;
|
||||
this.terrainBytes[ref] = bytes[i];
|
||||
encodeTerrainTile(bytes[i], this.pixelScratch, 0, this.terrainColors);
|
||||
gl.texSubImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* TrailPass — boat + nuke trail lines.
|
||||
*
|
||||
* Owns the CPU-side trail state (R16UI: 0=none, bits 0-11=ownerID, bit 12=nuke
|
||||
* trail), the dirty-row bookkeeping for partial GPU uploads, and the trail
|
||||
* Owns the dirty-row bookkeeping for partial GPU uploads and the trail
|
||||
* fragment shader that draws the colored breadcrumb behind moving units.
|
||||
* Trail state itself (R16UI: 0=none, bits 0-11=ownerID, bit 12=nuke trail)
|
||||
* is referenced from the caller's array, not copied.
|
||||
*/
|
||||
|
||||
import type { RenderSettings } from "../RenderSettings";
|
||||
@@ -37,11 +38,14 @@ export class TrailPass {
|
||||
// so the value stays small and sin()/fract() don't quantize over long sessions.
|
||||
private readonly startTime = performance.now();
|
||||
|
||||
/** CPU-side trail state (R16UI: 0=none, owner in bits 0-11, nuke bit 12). */
|
||||
private cpuTrailState: Uint16Array;
|
||||
private trailsDirty = false;
|
||||
|
||||
/** Live-game reference — bypasses memcpy. Null for replay path. */
|
||||
/**
|
||||
* Reference to the caller-owned trail state (R16UI: 0=none, owner in bits
|
||||
* 0-11, nuke bit 12). Every upload entry point provides it, so the pass
|
||||
* keeps no copy of its own; the caller's array must stay current until the
|
||||
* flush. Null until the first upload.
|
||||
*/
|
||||
private liveTrailRef: Uint16Array | null = null;
|
||||
|
||||
/** Dirty row range for partial trail upload. Infinity/-1 = full upload. */
|
||||
@@ -64,7 +68,6 @@ export class TrailPass {
|
||||
this.trailTex = trailTex;
|
||||
this.paletteTex = paletteTex;
|
||||
this.effectTex = effectTex;
|
||||
this.cpuTrailState = new Uint16Array(mapW * mapH);
|
||||
|
||||
this.program = createProgram(
|
||||
gl,
|
||||
@@ -128,8 +131,9 @@ export class TrailPass {
|
||||
/** Flush trail texture to GPU. Called once per render frame in uploadTextures. */
|
||||
flushTexture(): void {
|
||||
if (!this.trailsDirty) return;
|
||||
const src = this.liveTrailRef;
|
||||
if (src === null) return; // dirty is only ever set alongside the ref
|
||||
const gl = this.gl;
|
||||
const src = this.liveTrailRef ?? this.cpuTrailState;
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, this.trailTex);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user