perf: remove O(n) StructureIconsLayer render lookups (#3305)

Begins work on #3207

## Description:

This PR is the first optimization slice for #3207: it removes O(n)
render lookups in `StructureIconsLayer` by replacing array-first render
state with a unit-id keyed map, and tightens hot-path execution to
reduce per-tick allocations.

### What changed
- Refactored render state from array-first to `rendersByUnitId:
Map<number, StructureRenderInfo>`.
- Replaced O(n) lookup/delete paths with O(1) `Map#get` / `Map#delete`.
- Replaced `seenUnits` object-identity tracking with `seenUnitIds:
Set<number>`.
- Removed `tick()` array/closure chain (`map(...).forEach(...)`) and
switched to index-based loop.
- Reduced ghost-path allocation pressure by reusing a layer-level `Set`
for connected ally IDs instead of allocating `filter` + `map` + `new
Set` per ghost query.
- Added dirty-flag caching for structure visibility focus
(`visibilityStateDirty`) so expensive visibility-state scans recompute
only when toggles change.

### Performance validation (before/after)
Benchmark added: `tests/perf/StructureIconsLayerLookupPerf.ts`

Command:
`npm run perf`

Observed result:
- `StructureIconsLayer BEFORE (array O(n) lookup/delete) x 0.33 ops/sec
±13.28%`
- `StructureIconsLayer AFTER (unit-id map O(1) lookup/delete) x 95.65
ops/sec ±2.46%`
- Fastest implementation: AFTER (unit-id map)

#### Profiler screenshots are too noisy to be useful for such a focused
change

### Verification
- `npx tsc --noEmit` 
- `npx eslint src/client/graphics/layers/StructureIconsLayer.ts
tests/perf/StructureIconsLayerLookupPerf.ts` 
- `npm run perf` 

## Please complete the following:

~~- [ ] I have added screenshots for all UI updates~~ 
~~- [ ] I process any text displayed to the user through translateText()
and I've added it to the en.json file~~
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

skigim
This commit is contained in:
Skigim
2026-03-02 19:40:15 -06:00
committed by GitHub
parent c238af389a
commit 60f69a6408
2 changed files with 181 additions and 33 deletions
@@ -77,8 +77,10 @@ export class StructureIconsLayer implements Layer {
private readonly theme: Theme;
private renderer: PIXI.Renderer | null = null;
private rendererInitialized: boolean = false;
private renders: StructureRenderInfo[] = [];
private readonly seenUnits: Set<UnitView> = new Set();
private readonly rendersByUnitId: Map<number, StructureRenderInfo> =
new Map();
private readonly seenUnitIds: Set<number> = new Set();
private readonly connectedAllySmallIds: Set<number> = new Set();
private readonly mousePos = { x: 0, y: 0 };
private renderSprites = true;
private factory: SpriteFactory;
@@ -91,6 +93,8 @@ export class StructureIconsLayer implements Layer {
[UnitType.SAMLauncher, { visible: true }],
]);
private lastGhostQueryAt: number;
private visibilityStateDirty = true;
private hasHiddenStructure = false;
potentialUpgrade: StructureRenderInfo | undefined;
constructor(
@@ -185,18 +189,22 @@ export class StructureIconsLayer implements Layer {
}
tick() {
this.game
.updatesSinceLastTick()
?.[GameUpdateType.Unit]?.map((unit) => this.game.unit(unit.id))
?.forEach((unitView) => {
if (unitView === undefined) return;
const unitUpdates = this.game.updatesSinceLastTick()?.[GameUpdateType.Unit];
if (unitUpdates) {
for (let i = 0, len = unitUpdates.length; i < len; i++) {
const unitView = this.game.unit(unitUpdates[i].id);
if (unitView === undefined) {
continue;
}
const unitId = unitView.id();
if (unitView.isActive()) {
this.handleActiveUnit(unitView);
} else if (this.seenUnits.has(unitView)) {
} else if (this.seenUnitIds.has(unitId)) {
this.handleInactiveUnit(unitView);
}
});
}
}
this.renderSprites =
this.game.config().userSettings()?.structureSprites() ?? true;
}
@@ -224,7 +232,7 @@ export class StructureIconsLayer implements Layer {
this.renderGhost();
if (this.transformHandler.hasChanged()) {
for (const render of this.renders) {
for (const render of this.rendersByUnitId.values()) {
this.computeNewLocation(render);
}
}
@@ -269,13 +277,21 @@ export class StructureIconsLayer implements Layer {
(nukeType === UnitType.AtomBomb || nukeType === UnitType.HydrogenBomb)
) {
// Only check connected allies - nuking disconnected allies doesn't cause a traitor debuff
const allies = myPlayer.allies().filter((a) => !a.isDisconnected());
if (allies.length > 0) {
this.connectedAllySmallIds.clear();
const allies = myPlayer.allies();
for (let i = 0; i < allies.length; i++) {
const ally = allies[i];
if (!ally.isDisconnected()) {
this.connectedAllySmallIds.add(ally.smallID());
}
}
if (this.connectedAllySmallIds.size > 0) {
targetingAlly = wouldNukeBreakAlliance({
game: this.game,
targetTile: tileRef,
magnitude: this.game.config().nukeMagnitudes(nukeType),
allySmallIds: new Set(allies.map((a) => a.smallID())),
allySmallIds: this.connectedAllySmallIds,
threshold: this.game.config().nukeAllianceBreakThreshold(),
});
}
@@ -318,11 +334,14 @@ export class StructureIconsLayer implements Layer {
this.updateGhostRange(targetLevel, targetingAlly);
if (unit.canUpgrade) {
this.potentialUpgrade = this.renders.find(
(r) =>
r.unit.id() === unit.canUpgrade &&
r.unit.owner().id() === this.game.myPlayer()?.id(),
);
this.potentialUpgrade = this.rendersByUnitId.get(unit.canUpgrade);
if (
this.potentialUpgrade &&
this.potentialUpgrade.unit.owner().id() !==
this.game.myPlayer()?.id()
) {
this.potentialUpgrade = undefined;
}
if (this.potentialUpgrade) {
this.potentialUpgrade.iconContainer.filters = [
new OutlineFilter({ thickness: 2, color: "rgba(0, 255, 0, 1)" }),
@@ -546,19 +565,36 @@ export class StructureIconsLayer implements Layer {
toggleStructureType?.indexOf(structureType) !== -1 ||
toggleStructureType === null;
}
for (const render of this.renders) {
this.visibilityStateDirty = true;
for (const render of this.rendersByUnitId.values()) {
this.modifyVisibility(render);
}
}
private refreshVisibilityStateCache() {
if (!this.visibilityStateDirty) {
return;
}
this.hasHiddenStructure = false;
for (const infos of this.structures.values()) {
if (infos.visible === false) {
this.hasHiddenStructure = true;
break;
}
}
this.visibilityStateDirty = false;
}
private findRenderByUnit(
unitView: UnitView,
): StructureRenderInfo | undefined {
return this.renders.find((render) => render.unit.id() === unitView.id());
return this.rendersByUnitId.get(unitView.id());
}
private handleActiveUnit(unitView: UnitView) {
if (this.seenUnits.has(unitView)) {
if (this.seenUnitIds.has(unitView.id())) {
const render = this.findRenderByUnit(unitView);
if (render) {
this.checkForConstructionState(render, unitView);
@@ -572,6 +608,10 @@ export class StructureIconsLayer implements Layer {
}
private handleInactiveUnit(unitView: UnitView) {
if (!this.seenUnitIds.has(unitView.id())) {
return;
}
const render = this.findRenderByUnit(unitView);
if (render) {
this.deleteStructure(render);
@@ -579,20 +619,15 @@ export class StructureIconsLayer implements Layer {
}
private modifyVisibility(render: StructureRenderInfo) {
this.refreshVisibilityStateCache();
const structureType = render.unit.type();
const structureInfos = this.structures.get(structureType);
let focusStructure = false;
for (const infos of this.structures.values()) {
if (infos.visible === false) {
focusStructure = true;
break;
}
}
if (structureInfos) {
render.iconContainer.alpha = structureInfos.visible ? 1 : 0.3;
render.dotContainer.alpha = structureInfos.visible ? 1 : 0.3;
if (structureInfos.visible && focusStructure) {
if (structureInfos.visible && this.hasHiddenStructure) {
render.iconContainer.filters = [
new OutlineFilter({ thickness: 2, color: "rgb(255, 255, 255)" }),
];
@@ -714,7 +749,7 @@ export class StructureIconsLayer implements Layer {
}
private addNewStructure(unitView: UnitView) {
this.seenUnits.add(unitView);
this.seenUnitIds.add(unitView.id());
const render = new StructureRenderInfo(
unitView,
unitView.owner().id(),
@@ -724,7 +759,7 @@ export class StructureIconsLayer implements Layer {
unitView.level(),
unitView.isUnderConstruction(),
);
this.renders.push(render);
this.rendersByUnitId.set(unitView.id(), render);
this.computeNewLocation(render);
this.modifyVisibility(render);
}
@@ -754,7 +789,11 @@ export class StructureIconsLayer implements Layer {
render.iconContainer?.destroy();
render.levelContainer?.destroy();
render.dotContainer?.destroy();
this.renders = this.renders.filter((r) => r.unit !== render.unit);
this.seenUnits.delete(render.unit);
const unitId = render.unit.id();
this.rendersByUnitId.delete(unitId);
this.seenUnitIds.delete(unitId);
if (this.potentialUpgrade?.unit.id() === unitId) {
this.potentialUpgrade = undefined;
}
}
}
+109
View File
@@ -0,0 +1,109 @@
import Benchmark from "benchmark";
const STRUCTURE_COUNT = 50000;
const LOOKUP_COUNT = 50000;
const UPGRADE_LOOKUP_COUNT = 5000;
interface StructureRenderSample {
unitId: number;
ownerId: number;
level: number;
}
const rendersArray: StructureRenderSample[] = Array.from(
{ length: STRUCTURE_COUNT },
(_, index) => ({
unitId: index + 1,
ownerId: (index % 5) + 1,
level: (index % 4) + 1,
}),
);
const rendersMap = new Map<number, StructureRenderSample>();
for (const render of rendersArray) {
rendersMap.set(render.unitId, render);
}
const activeLookupIds = Array.from(
{ length: LOOKUP_COUNT },
(_, index) => ((index * 97) % STRUCTURE_COUNT) + 1,
);
const inactiveLookupIds = Array.from(
{ length: LOOKUP_COUNT },
(_, index) => ((index * 193) % STRUCTURE_COUNT) + 1,
);
const canUpgradeIds = Array.from(
{ length: UPGRADE_LOOKUP_COUNT },
(_, index) => ((index * 389) % STRUCTURE_COUNT) + 1,
);
const myOwnerId = 3;
const results: string[] = [];
new Benchmark.Suite()
.add("StructureIconsLayer BEFORE (array O(n) lookup/delete)", () => {
const localRenders = rendersArray.map((render) => ({ ...render }));
for (const unitId of activeLookupIds) {
const render = localRenders.find((entry) => entry.unitId === unitId);
if (render) {
render.level = render.level + 1;
}
}
for (const canUpgradeId of canUpgradeIds) {
const potentialUpgrade = localRenders.find(
(entry) => entry.unitId === canUpgradeId && entry.ownerId === myOwnerId,
);
if (potentialUpgrade) {
potentialUpgrade.level = potentialUpgrade.level + 1;
}
}
for (const unitId of inactiveLookupIds) {
const index = localRenders.findIndex((entry) => entry.unitId === unitId);
if (index !== -1) {
localRenders.splice(index, 1);
}
}
})
.add("StructureIconsLayer AFTER (unit-id map O(1) lookup/delete)", () => {
const localRenders = new Map<number, StructureRenderSample>();
for (const [unitId, render] of rendersMap) {
localRenders.set(unitId, { ...render });
}
for (const unitId of activeLookupIds) {
const render = localRenders.get(unitId);
if (render) {
render.level = render.level + 1;
}
}
for (const canUpgradeId of canUpgradeIds) {
const potentialUpgrade = localRenders.get(canUpgradeId);
if (potentialUpgrade && potentialUpgrade.ownerId === myOwnerId) {
potentialUpgrade.level = potentialUpgrade.level + 1;
}
}
for (const unitId of inactiveLookupIds) {
localRenders.delete(unitId);
}
})
.on("cycle", (event: Benchmark.Event) => {
results.push(String(event.target));
})
.on("complete", function () {
console.log("\n=== StructureIconsLayer Lookup Benchmark Results ===");
for (const result of results) {
console.log(result);
}
const fastest = this.filter("fastest").map("name");
console.log(`\nFastest implementation: ${fastest.join(", ")}`);
})
.run({ async: true });