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:
Evan
2026-07-04 20:18:52 -07:00
committed by GitHub
parent 36aa38bcd0
commit 571f58440d
10 changed files with 551 additions and 67 deletions
+14 -6
View File
@@ -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,
+5 -2
View File
@@ -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,
+20 -8
View File
@@ -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;
+71 -34
View File
@@ -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(
+14 -7
View File
@@ -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,
+11 -7
View File
@@ -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);