mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
Reference in New Issue
Block a user