Fix three high-impact renderer performance issues (#4251)

## Summary

A performance review of `src/client/render/` found three issues where
per-tick work silently defeated existing optimizations. All three are
surgical fixes with no behavior change.

### 1. Relation matrix forced a full-map border recompute every tick

`buildRelationMatrix` ran unconditionally every tick and
`updateRelations` was pushed unconditionally, so every tick paid:
- a 1 MB `fill(0)` + rebuild on the CPU,
- a 1 MB `texSubImage2D` upload (~10 MB/s steady-state),
- a **full map-resolution border fragment pass** via `globalDirty` —
which also called `scatter.clear()`, making the incremental
`BorderScatterPass`/`patchTile` path dead code during live play.

Now the matrix is rebuilt and uploaded only when alliances/embargoes
actually change. `PlayerUpdate`s are delta-encoded (`diffPlayerUpdate`
content-compares `allies`/`embargoes`), so field presence is a reliable
change signal. The WebGL context-restore path force-pushes relations,
matching the existing structures/railroads pattern.

### 2. Heat decay pass + full-map blit ran every frame, forever

`HeatManager.decayHeat()` set `heatActive = true` on every tick
regardless of whether any fallout existed. With `heatDecayPerTick: 1`
the drain window (255 ticks) was always re-armed before expiring, so the
map-sized decay/transition fragment pass **plus a full-map R16UI
`blitFramebuffer`** ran at 60 Hz for the entire game — even if no nuke
was ever fired. On large maps this was likely the biggest fixed GPU cost
in the renderer.

Now `TerritoryPass` flags FALLOUT-bit flips at GPU-write time (delta,
drip-drain, and conservatively on full uploads), and the renderer
activates the heat pipeline only then. While inactive, `updateHeat()`
does no GL work at all. Skipping the prev-tile blit while inactive is
safe because the transition shader only reads the fallout bit, and every
fallout flip activates the pipeline before its tile flush reaches the
GPU.

### 3. `computePlayerStatus` was O(players × units) per tick

The per-player loop scanned **all units** looking for that player's
nukes (~1M+ iterations/tick at scale). Inverted to a single pass over
units building per-owner `nukeActive`/`nukeTargetsMe` sets, then O(1)
lookups in the player loop.

## Testing

- Full suite passes (1386 + 65 tests), including the 19 existing
`computePlayerStatus` behavior tests; `tsc --noEmit` and ESLint clean.
- Verified in a live singleplayer game (headless Chromium): territory
fill, borders, names/troop counts, and leaderboard all render correctly.
- Fallout path verified end-to-end: built a missile silo, launched an
atom bomb (1235 fallout tiles in tile state), and the fallout glow
rendered at the impact site — under the new gating that glow can only
appear if the `falloutTouched → activate()` chain works.

🤖 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-06-12 17:06:53 -07:00
committed by GitHub
parent bca980f572
commit 7ec26df4b4
8 changed files with 94 additions and 40 deletions
+3 -1
View File
@@ -413,9 +413,11 @@ function mountWebGLFrameLoop(
const frameData = gameView.frameData();
view.uploadTileAndTrailState(frameData.tileState, frameData.trailState);
// Structures and railroads normally skip GPU upload unless marked dirty, now force
// Structures, railroads and relations normally skip GPU upload unless
// marked dirty, now force
view.updateStructures(frameData.units as Map<number, UnitState>);
view.uploadRailroadState(frameData.railroadState);
view.updateRelations(frameData.relationMatrix, frameData.relationSize);
builder.update(gameView);
};