Files
OpenFrontIO/src/core/pathfinding/navmesh/WaterComponents.ts
T
Arkadiusz Sygulski b090f2f624 HPA* Pathfinding (#2815)
## Pathfinding with HPA*

Hi! The primary objective of this PR is to replace per-tile A* with
hierarchical pathfinding - HPA*. In practice, this means we create an
abstract graph on top of the actual map with far fewer points and use it
to decide on general path structure. Only then we go back to tile-level
and build path between selected waypoints. This speeds up long distance
pathfinding by over 1000x in some cases. To make the review easier, it
comes with a benchmark and visual playground.

## PREPROCESSING

H part of HPA* means "hierarchical" and requires preprocessing.

This PR includes pre-processing as part inside `new Game()` constructor.
It takes about 135ms for `giantworldmap` on my machine, which increases
the effective initialization from ~95ms to ~230ms. This time could be
reduced in different ways, which are **out of scope** for this PR.

After confirming the initialization time is bearable on low-end devices,
I argue merging this PR as-is is acceptable tradeoff. It creates small
lag at the beginning of a round but pays for itself in the first minute
of the match.

## Nerdy details

**Architecture**
- HPA*-style hierarchical pathfinding
- 32×32 sectors on minimap with gateway nodes on borders
- Gateway graph built via BFS during preprocessing
- Water component optimization skips unreachable gateway pairs
- A* on gateway graph → local A* within sectors → Bresenham path
smoothing
- Minimap upscaling identical to currently used in MiniAStar

**Key Optimizations**
- Typed arrays instead of high-level primitives
- Stamp-based visited tracking (no need to recreate buffers, O(1)
clearing)
- Optional - enabled by default - caching of tile paths between gateways
- Line of sight smoothing for the final path

## Review Focus

Play with included tools, benchmark and visualization. Pathfinding
should be safe to merge as a black box - you do not need to understand
the details. Outcomes can be tested empirically in-game. Visualize (and
share!) edge cases with included playground. Confirm the 100x speedup is
real with benchmark.

If you plan to dive into the code, I suggest the following order:
- Pathfinding abstraction in `src/core/pathfinding/`
- Pathfinding tests in `tests/core/pathfinding/`
- NavMesh in `src/core/pathfinding/navmesh/` + integration with
`Game.ts`
- Benchmark in `tests/pathfinding/benchmark/`

Do not look at playground's code, it has been created with a clanker.
The design is 100% mine and I spent way too long polishing it, but I
haven't even once edited the code manually. There is probably no
abstraction whatsoever, just do not look at the code, let it play.

## Core Changes

#### Pathfinding (`src/core/pathfinding/navmesh/`)
- HPA* + refinement -> three phased pathfinding: A* over the graph ->
naive path -> refinement
- comes with A* and BFS optimized for for specific needs

#### Pre-Processing (`src/core/pathfinding/navmesh/`)
- identify water bodies to avoid pathfinding between disconnected nodes
- create high-level graph of gateways on top of tile map

#### Abstraction (`src/core/pathfinding/`)

- common `PathFinder` interface that can return full path and also act
as state machine (`.next()`)
- adapters for both new and legacy algorithm with fallback to legacy if
navigation mesh not available

#### Benchmark (`tests/pathfinding/benchmark/`)

- `npx tsx tests/pathfinding/benchmark/run.ts` - no guesswork, numbers
- `npx tsx tests/pathfinding/benchmark/run.ts --synthetic` - 1000s of
synthetic paths
- `npx tsc tests/pathfinding/benchmark/generate.ts` - generate more as
needed, test new maps
- includes ONE synthetic scenario to avoid PR bloat, generate more
locally / later

#### Playground (`tests/pathfinding/playground/`)

- `npx tsx tests/pathfinding/playground/server.ts` - visualize paths
with both new and legacy algorithm

## Benchmarks

### Compared with legacy in default - hand picked - scenario:
```
Initialization: 95.95ms -> 227.29ms
Pathfinding: 3038.43ms -> 6.45ms
Distance: 26972 -> 26810 tiles
```

### 42,000 synthetic routes across all maps
```
Running 42 synthetic scenarios with hpa.cached adapter...

 synthetic/achiran                   | Init:    93.42ms | Path:    139.07ms | Dist: 1481630 tiles | Routes: 1000/1000
 synthetic/africa                    | Init:    87.14ms | Path:    155.08ms | Dist: 1829414 tiles | Routes: 1000/1000
 synthetic/asia                      | Init:    57.60ms | Path:    112.55ms | Dist: 1204082 tiles | Routes: 1000/1000
 synthetic/australia                 | Init:    78.18ms | Path:     77.12ms | Dist:  978375 tiles | Routes: 1000/1000
 synthetic/baikal                    | Init:    78.26ms | Path:    152.14ms | Dist: 1600016 tiles | Routes: 1000/1000
 synthetic/baikalnukewars            | Init:    81.44ms | Path:    165.90ms | Dist: 1699283 tiles | Routes: 1000/1000
 synthetic/betweentwoseas            | Init:    29.29ms | Path:    114.99ms | Dist: 1338075 tiles | Routes: 1000/1000
 synthetic/blacksea                  | Init:    30.66ms | Path:     93.14ms | Dist:  949217 tiles | Routes: 1000/1000
 synthetic/britannia                 | Init:    74.12ms | Path:     85.62ms | Dist:  866752 tiles | Routes: 1000/1000
 synthetic/deglaciatedantarctica     | Init:   105.49ms | Path:    192.93ms | Dist: 1574684 tiles | Routes: 1000/1000
 synthetic/didier                    | Init:    81.51ms | Path:    153.70ms | Dist: 1734876 tiles | Routes: 1000/1000
 synthetic/eastasia                  | Init:    49.29ms | Path:    128.63ms | Dist: 1410270 tiles | Routes: 1000/1000
 synthetic/europe                    | Init:    92.55ms | Path:    178.35ms | Dist: 1525216 tiles | Routes: 1000/1000
 synthetic/europeclassic             | Init:    33.50ms | Path:    104.40ms | Dist: 1209759 tiles | Routes: 1000/1000
 synthetic/falklandislands           | Init:    63.00ms | Path:    107.41ms | Dist: 1080251 tiles | Routes: 1000/1000
 synthetic/faroeislands              | Init:    71.91ms | Path:     49.52ms | Dist:  604613 tiles | Routes: 1000/1000
 synthetic/fourislands               | Init:    45.75ms | Path:     78.91ms | Dist:  937439 tiles | Routes: 1000/1000
 synthetic/gatewaytotheatlantic      | Init:    81.00ms | Path:    257.06ms | Dist: 2555551 tiles | Routes: 1000/1000
 synthetic/giantworldmap             | Init:   214.25ms | Path:    220.42ms | Dist: 1976693 tiles | Routes: 1000/1000
 synthetic/gulfofstlawrence          | Init:    45.16ms | Path:     96.05ms | Dist: 1014604 tiles | Routes: 1000/1000
 synthetic/halkidiki                 | Init:    74.68ms | Path:    149.39ms | Dist: 1546781 tiles | Routes: 1000/1000
 synthetic/iceland                   | Init:    58.72ms | Path:     78.16ms | Dist: 1001554 tiles | Routes: 1000/1000
 synthetic/italia                    | Init:    29.78ms | Path:    139.93ms | Dist: 1412024 tiles | Routes: 1000/1000
 synthetic/japan                     | Init:   161.07ms | Path:    118.65ms | Dist: 1154393 tiles | Routes: 1000/1000
 synthetic/lemnos                    | Init:    52.59ms | Path:    136.69ms | Dist: 1481101 tiles | Routes: 1000/1000
 synthetic/lisbon                    | Init:    49.27ms | Path:     86.53ms | Dist: 1032011 tiles | Routes: 1000/1000
 synthetic/manicouagan               | Init:    53.74ms | Path:    110.52ms | Dist: 1307630 tiles | Routes: 1000/1000
 synthetic/mars                      | Init:    29.39ms | Path:     80.55ms | Dist: 1091702 tiles | Routes: 1000/1000
 synthetic/mena                      | Init:    26.37ms | Path:    120.09ms | Dist: 1272751 tiles | Routes: 1000/1000
 synthetic/montreal                  | Init:    26.08ms | Path:    106.77ms | Dist: 1187736 tiles | Routes: 1000/1000
 synthetic/newyorkcity               | Init:    56.60ms | Path:    181.19ms | Dist: 1753875 tiles | Routes: 1000/1000
 synthetic/northamerica              | Init:    96.29ms | Path:    123.02ms | Dist: 1217221 tiles | Routes: 1000/1000
 synthetic/oceania                   | Init:    52.81ms | Path:     51.96ms | Dist:  482373 tiles | Routes: 1000/1000
 synthetic/pangaea                   | Init:    21.29ms | Path:     56.58ms | Dist:  716189 tiles | Routes: 1000/1000
 synthetic/pluto                     | Init:    53.89ms | Path:    141.62ms | Dist: 1304362 tiles | Routes: 1000/1000
 synthetic/southamerica              | Init:    85.19ms | Path:    123.03ms | Dist: 1301403 tiles | Routes: 1000/1000
 synthetic/straitofgibraltar         | Init:    76.68ms | Path:    108.30ms | Dist: 1304592 tiles | Routes: 1000/1000
 synthetic/straitofhormuz            | Init:    38.97ms | Path:     67.78ms | Dist:  754920 tiles | Routes: 1000/1000
 synthetic/surrounded                | Init:    95.35ms | Path:     90.18ms | Dist: 1017142 tiles | Routes: 1000/1000
 synthetic/svalmel                   | Init:    60.58ms | Path:    104.75ms | Dist: 1235501 tiles | Routes: 1000/1000
 synthetic/twolakes                  | Init:    62.05ms | Path:     94.54ms | Dist: 1140807 tiles | Routes: 1000/1000
 synthetic/world                     | Init:    41.43ms | Path:     93.42ms | Dist:  873406 tiles | Routes: 1000/1000

Completed 42 scenarios
Total Initialization Time: 2796.32ms
Total Pathfinding Time: 5026.64ms
Total Distance: 53160274 tiles
```

## Playground

**That's the fun part**. Watch NavMesh running circles around legacy
`PathFinder.Mini` in real time. Debug inner workings, test edge cases,
share URLs for debugging.


https://github.com/user-attachments/assets/34e2e3f5-fbc1-4b1f-917d-820766e98d5d

## Discord Tag
`moleole`
2026-01-08 13:34:18 -08:00

201 lines
5.9 KiB
TypeScript

import { GameMap, TileRef } from "../../game/GameMap";
const LAND_MARKER = 0xff; // Must fit in Uint8Array
/**
* Manages water component identification using flood-fill.
* Pre-allocates buffers and provides explicit initialization.
*/
export class WaterComponents {
private readonly width: number;
private readonly height: number;
private readonly numTiles: number;
private readonly lastRowStart: number;
private readonly queue: Int32Array;
private componentIds: Uint8Array | Uint16Array | null = null;
constructor(
private readonly map: GameMap,
private readonly accessTerrainDirectly: boolean = true,
) {
this.width = map.width();
this.height = map.height();
this.numTiles = this.width * this.height;
this.lastRowStart = (this.height - 1) * this.width;
this.queue = new Int32Array(this.numTiles);
}
initialize(): void {
let ids: Uint8Array | Uint16Array = this.createPrefilledIds();
let nextId = 0;
// Scan all tiles and flood-fill each unvisited water component
for (let start = 0; start < this.numTiles; start++) {
const value = ids[start];
// Skip if already visited (land=0xFF or water component >0)
if (value === LAND_MARKER || value > 0) {
continue;
}
nextId++;
// Dynamically upgrade to Uint16Array when we hit component 254
if (nextId === 254 && ids instanceof Uint8Array) {
ids = this.upgradeToUint16Array(ids);
}
this.floodFillComponent(ids, start, nextId);
}
this.componentIds = ids;
}
/**
* Create and prefill a Uint8Array with land markers.
* Uses direct terrain access for performance.
*/
private createPrefilledIds(): Uint8Array {
const ids = new Uint8Array(this.numTiles);
if (this.accessTerrainDirectly) {
this.premarkLandTilesDirect(ids);
} else {
this.premarkLandTiles(ids);
}
return ids;
}
/**
* Pre-mark all land tiles in the ids array.
* Land tiles are marked with 0xFF, water tiles remain 0.
*/
private premarkLandTiles(ids: Uint8Array): void {
for (let i = 0; i < this.numTiles; i++) {
ids[i] = this.map.isWater(i) ? 0 : LAND_MARKER;
}
}
/**
* Pre-mark all land tiles in the ids array.
* Land tiles are marked with 0xFF, water tiles remain 0.
*
* This implementation accesses the terrain data **directly** without GameMap abstraction.
* In tests it is 30% to 50% faster than using isWater() method calls.
* As of 2026-01-05 it reduces avg. time for GWM from 15ms to 10ms.
*/
private premarkLandTilesDirect(ids: Uint8Array): void {
const terrain = (this.map as any).terrain as Uint8Array;
// Write 4 bytes at once using Uint32Array view for better performance
const numChunks = Math.floor(this.numTiles / 4);
const terrain32 = new Uint32Array(
terrain.buffer,
terrain.byteOffset,
numChunks,
);
const ids32 = new Uint32Array(ids.buffer, ids.byteOffset, numChunks);
for (let i = 0; i < numChunks; i++) {
const chunk = terrain32[i];
// Extract bit 7 from each byte, negate, and combine into single 32-bit write
// bit 7 = 0 (water) → -(0) = 0x00
// bit 7 = 1 (land) → -(1) = 0xFF (truncated to 8 bits)
const b0 = -((chunk >> 7) & 1) & 0xff;
const b1 = -((chunk >> 15) & 1) & 0xff;
const b2 = -((chunk >> 23) & 1) & 0xff;
const b3 = -((chunk >> 31) & 1); // Upper byte, no mask needed
ids32[i] = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
}
// Handle remaining tiles (when numTiles not divisible by 4)
for (let i = numChunks * 4; i < this.numTiles; i++) {
ids[i] = -(terrain[i] >> 7);
}
}
/**
* Upgrade from Uint8Array to Uint16Array when we exceed 254 components.
* Direct copy works because both use 0xFF for land marker.
*/
private upgradeToUint16Array(ids: Uint8Array): Uint16Array {
const newIds = new Uint16Array(this.numTiles);
for (let i = 0; i < this.numTiles; i++) {
newIds[i] = ids[i];
}
return newIds;
}
/**
* Flood-fill a single connected water component using scan-line algorithm.
* Processes horizontal spans of tiles for better memory locality and cache performance.
*
* Note: Land tiles are pre-marked, so ids[x] === 0 guarantees water tile.
*/
private floodFillComponent(
ids: Uint8Array | Uint16Array,
start: number,
componentId: number,
): void {
let head = 0;
let tail = 0;
this.queue[tail++] = start;
while (head < tail) {
const seed = this.queue[head++]!;
// Skip if already processed
if (ids[seed] !== 0) continue;
// Scan left to find the start of this horizontal water span
// No isWaterFast check needed - ids[x] === 0 guarantees water
let left = seed;
const rowStart = seed - (seed % this.width);
while (left > rowStart && ids[left - 1] === 0) {
left--;
}
// Scan right to find the end of this horizontal water span
let right = seed;
const rowEnd = rowStart + this.width - 1;
while (right < rowEnd && ids[right + 1] === 0) {
right++;
}
// Fill the entire horizontal span and check above/below for new spans
for (let x = left; x <= right; x++) {
ids[x] = componentId;
// Check tile above (if not in first row)
if (x >= this.width) {
const above = x - this.width;
if (ids[above] === 0) {
this.queue[tail++] = above;
}
}
// Check tile below (if not in last row)
if (x < this.lastRowStart) {
const below = x + this.width;
if (ids[below] === 0) {
this.queue[tail++] = below;
}
}
}
}
}
/**
* Get the component ID for a tile.
* Returns 0 for land tiles or if not initialized.
*/
getComponentId(tile: TileRef): number {
if (!this.componentIds) return 0;
return this.componentIds[tile] ?? 0;
}
}