Improve border drawing performances (#751)

## Description:
When drawing the border colors, the territory layer is using the generic
`nearbyUnit` function to check if any allied outpost is nearby.
But `nearbyUnit` is uselesly computing the distance with all units,
which is very costly specially in late games with plenty of units.

Early game (<1 min):


![image](https://github.com/user-attachments/assets/faa90c82-a7dd-43db-b2ff-1f6cd24b24cd)

Late game (> 10 min):


![image](https://github.com/user-attachments/assets/863d4d1a-6d5e-4971-a66c-461a876ca53f)

This PR adds another function tailored for this requirement.
## Improvements:
- New `hasNearbyUnit()` function stopping at the first correct unit,
rather than computing the distance with every unit
- Check the unit type before computing its distance
- Selecting the correct cells:
The previous algorithm was very generous and looking at cells uselessly.
Admittedly this is marginal but since it is called on every border pixel
change, we should squeeze the most performances out of it.



![overflow](https://github.com/user-attachments/assets/22b26c49-ba9d-4050-8ccd-9dde48913720)


Performances after (with bots):
Early:

![image](https://github.com/user-attachments/assets/f78d08d4-938c-466b-b8b3-9d1ad57b5dfb)

Late:

![image](https://github.com/user-attachments/assets/c12a8793-4039-4278-9413-07b2af5c8f3d)

Both Chrome and Firefox seems to benefit from it:
Previous behavior on chrome:
![Sans
titre](https://github.com/user-attachments/assets/e10256f7-dcc0-47c7-8878-fa0ce8a02b39)
After:
![Sans
titre-1](https://github.com/user-attachments/assets/15747e02-69fd-45a2-90f8-389250f261cd)


## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced
- [x] I understand that submitting code with bugs that could have been
caught through manual testing blocks releases and new features for all
contributors

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

IngloriousTom
This commit is contained in:
DevelopingTom
2025-05-15 20:25:12 +02:00
committed by GitHub
parent 8b91fac717
commit 3b9e94ddb9
5 changed files with 233 additions and 28 deletions
+6 -7
View File
@@ -259,13 +259,12 @@ export class TerritoryLayer implements Layer {
if (this.game.isBorder(tile)) {
const playerIsFocused = owner && this.game.focusedPlayer() == owner;
if (
this.game
.nearbyUnits(
tile,
this.game.config().defensePostRange(),
UnitType.DefensePost,
)
.filter((u) => u.unit.owner() == owner).length > 0
this.game.hasUnitNearby(
tile,
this.game.config().defensePostRange(),
UnitType.DefensePost,
owner.id(),
)
) {
const borderColors = this.theme.defendedBorderColors(owner);
const x = this.game.x(tile);
+9
View File
@@ -391,6 +391,15 @@ export class GameView implements GameMap {
}>;
}
hasUnitNearby(
tile: TileRef,
searchRange: number,
type: UnitType,
playerId: PlayerID,
) {
return this.unitGrid.hasUnitNearby(tile, searchRange, type, playerId);
}
myClientID(): ClientID {
return this._myClientID;
}
+79 -21
View File
@@ -1,4 +1,4 @@
import { Unit, UnitType } from "./Game";
import { PlayerID, Unit, UnitType } from "./Game";
import { GameMap, TileRef } from "./GameMap";
import { UnitView } from "./GameView";
@@ -50,6 +50,46 @@ export class UnitGrid {
);
}
// Compute the exact cells in range of tile
private getCellsInRange(tile: TileRef, range: number) {
const x = this.gm.x(tile);
const y = this.gm.y(tile);
const cellSize = this.cellSize;
const [gridX, gridY] = this.getGridCoords(x, y);
const startGridX = Math.max(
0,
gridX - Math.ceil((range - (x % cellSize)) / cellSize),
);
const endGridX = Math.min(
this.grid[0].length - 1,
gridX + Math.ceil((range - (cellSize - (x % cellSize))) / cellSize),
);
const startGridY = Math.max(
0,
gridY - Math.ceil((range - (y % cellSize)) / cellSize),
);
const endGridY = Math.min(
this.grid.length - 1,
gridY + Math.ceil((range - (cellSize - (y % cellSize))) / cellSize),
);
return { startGridX, endGridX, startGridY, endGridY };
}
private squaredDistanceFromTile(
unit: Unit | UnitView,
tile: TileRef,
): number {
const x = this.gm.x(tile);
const y = this.gm.y(tile);
const tileX = this.gm.x(unit.tile());
const tileY = this.gm.y(unit.tile());
const dx = tileX - x;
const dy = tileY - y;
const distSquared = dx * dx + dy * dy;
return distSquared;
}
// Get all units within range of a point
// Returns [unit, distanceSquared] pairs for efficient filtering
nearbyUnits(
@@ -57,38 +97,56 @@ export class UnitGrid {
searchRange: number,
types: UnitType | UnitType[],
): Array<{ unit: Unit | UnitView; distSquared: number }> {
const x = this.gm.x(tile);
const y = this.gm.y(tile);
const [gridX, gridY] = this.getGridCoords(x, y);
const cellsToCheck = Math.ceil(searchRange / this.cellSize);
const nearby: Array<{ unit: Unit | UnitView; distSquared: number }> = [];
const startGridX = Math.max(0, gridX - cellsToCheck);
const endGridX = Math.min(this.grid[0].length - 1, gridX + cellsToCheck);
const startGridY = Math.max(0, gridY - cellsToCheck);
const endGridY = Math.min(this.grid.length - 1, gridY + cellsToCheck);
const { startGridX, endGridX, startGridY, endGridY } = this.getCellsInRange(
tile,
searchRange,
);
const rangeSquared = searchRange * searchRange;
const typeSet = Array.isArray(types) ? new Set(types) : new Set([types]);
for (let cy = startGridY; cy <= endGridY; cy++) {
for (let cx = startGridX; cx <= endGridX; cx++) {
for (const unit of this.grid[cy][cx]) {
if (unit.isActive()) {
const tileX = this.gm.x(unit.tile());
const tileY = this.gm.y(unit.tile());
const dx = tileX - x;
const dy = tileY - y;
const distSquared = dx * dx + dy * dy;
if (distSquared <= rangeSquared && typeSet.has(unit.type())) {
if (typeSet.has(unit.type()) && unit.isActive()) {
const distSquared = this.squaredDistanceFromTile(unit, tile);
if (distSquared <= rangeSquared) {
nearby.push({ unit, distSquared });
}
}
}
}
}
return nearby;
}
// Return true if it finds an owned specific unit in range
hasUnitNearby(
tile: TileRef,
searchRange: number,
type: UnitType,
playerId: PlayerID,
): boolean {
const { startGridX, endGridX, startGridY, endGridY } = this.getCellsInRange(
tile,
searchRange,
);
const rangeSquared = searchRange * searchRange;
for (let cy = startGridY; cy <= endGridY; cy++) {
for (let cx = startGridX; cx <= endGridX; cx++) {
for (const unit of this.grid[cy][cx]) {
if (
unit.type() == type &&
unit.owner().id() == playerId &&
unit.isActive()
) {
const distSquared = this.squaredDistanceFromTile(unit, tile);
if (distSquared <= rangeSquared) {
return true;
}
}
}
}
}
return false;
}
}
+139
View File
@@ -0,0 +1,139 @@
import { PlayerInfo, PlayerType, UnitType } from "../src/core/game/Game";
import { UnitGrid } from "../src/core/game/UnitGrid";
import { setup } from "./util/Setup";
async function checkRange(
mapName: string,
unitPosX: number,
rangeCheck: number,
range: number,
) {
const game = await setup(mapName, { infiniteGold: true, instantBuild: true });
const grid = new UnitGrid(game.map());
const player = game.addPlayer(
new PlayerInfo("us", "test_player", PlayerType.Human, null, "test_id"),
);
const unitTile = game.map().ref(unitPosX, 0);
grid.addUnit(player.buildUnit(UnitType.DefensePost, unitTile, {}));
const tileToCheck = game.map().ref(rangeCheck, 0);
return grid.hasUnitNearby(
tileToCheck,
range,
UnitType.DefensePost,
"test_id",
);
}
async function nearbyUnits(
mapName: string,
unitPosX: number,
rangeCheck: number,
range: number,
unitTypes: UnitType[],
) {
const game = await setup(mapName, { infiniteGold: true, instantBuild: true });
const grid = new UnitGrid(game.map());
const player = game.addPlayer(
new PlayerInfo("us", "test_player", PlayerType.Human, null, "test_id"),
);
const unitTile = game.map().ref(unitPosX, 0);
for (const unitType of unitTypes) {
grid.addUnit(player.buildUnit(unitType, unitTile, {}));
}
const tileToCheck = game.map().ref(rangeCheck, 0);
return grid.nearbyUnits(tileToCheck, range, unitTypes);
}
describe("Unit Grid range tests", () => {
const hasUnitCases = [
["Plains", 0, 10, 0, true], // Same spot
["Plains", 0, 10, 10, true], // Exactly on the range
["Plains", 0, 10, 11, false], // Exactly 1px outside
["BigPlains", 0, 198, 42, true], // Inside huge range
["BigPlains", 0, 198, 199, false], // Exactly 1px outside huge range
];
describe("Is unit in range", () => {
test.each(hasUnitCases)(
"on %p map, look if unit at position %p with a range of %p is in range of %p position, returns %p",
async (
mapName: string,
unitPosX: number,
range: number,
rangeCheck: number,
expectedResult: boolean,
) => {
const result = await checkRange(mapName, unitPosX, rangeCheck, range);
expect(result).toBe(expectedResult);
},
);
});
const unitsInRangeCases = [
["Plains", 0, 10, 0, [UnitType.Warship], 1], // Same spot
["Plains", 0, 10, 0, [UnitType.City, UnitType.Port], 2], // 2 in range
["Plains", 0, 10, 0, [], 0], // no unit
["Plains", 0, 10, 10, [UnitType.City], 1], // Exactly on the range
["Plains", 0, 10, 11, [UnitType.DefensePost], 0], // 1px outside
["BigPlains", 0, 198, 42, [UnitType.TradeShip], 1], // Inside huge range
["BigPlains", 0, 198, 199, [UnitType.TransportShip], 0], // 1px outside
];
describe("Retrieve all units in range", () => {
test.each(unitsInRangeCases)(
"on %p map, look if unit at position %p with a range of %p is in range of %p position, returns %p",
async (
mapName: string,
unitPosX: number,
range: number,
rangeCheck: number,
units: UnitType[],
expectedResult: number,
) => {
const result = await nearbyUnits(
mapName,
unitPosX,
rangeCheck,
range,
units,
);
expect(result.length).toBe(expectedResult);
},
);
test("Wrong unit type in range", async () => {
const game = await setup("Plains", {
infiniteGold: true,
instantBuild: true,
});
const grid = new UnitGrid(game.map());
const player = game.addPlayer(
new PlayerInfo("us", "test_player", PlayerType.Human, null, "test_id"),
);
const unitTile = game.map().ref(0, 0);
grid.addUnit(player.buildUnit(UnitType.City, unitTile, {}));
const tileToCheck = game.map().ref(0, 0);
expect(grid.nearbyUnits(tileToCheck, 10, [UnitType.Port])).toHaveLength(
0,
);
});
test("One inside, one outside of range", async () => {
const game = await setup("Plains", {
infiniteGold: true,
instantBuild: true,
});
const grid = new UnitGrid(game.map());
const player = game.addPlayer(
new PlayerInfo("us", "test_player", PlayerType.Human, null, "test_id"),
);
const unitType = UnitType.City;
const unitTile = game.map().ref(0, 0);
grid.addUnit(player.buildUnit(unitType, unitTile, {}));
const outsideTile = game.map().ref(99, 0);
grid.addUnit(player.buildUnit(unitType, outsideTile, {}));
const tileToCheck = game.map().ref(0, 0);
expect(grid.nearbyUnits(tileToCheck, 10, [unitType])).toHaveLength(1);
});
});
});
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB