feat: async pathfinding with provisional paths and bit-grid snapshots

- Add StandableBitGrid: O(1) bit-packed snapshot (~6KB per 50k tiles vs HashMap overhead)
- Implement two-tier pathfinding: sync for short paths (<64 tiles), provisional+async for long paths
- calculate_provisional_path: capped A* returning path to best heuristic node
- calculate_async_path: A* using bit-grid (Send+Sync, no thread_local)
- prepare_paths system: dispatches provisional paths immediately, spawns async for full paths
- poll_async_paths + splice_completed_async_paths: seamless path transition when async completes
- Entities start walking immediately on provisional path while full path computes in background

Architecture:
  FixedUpdate: prepare_paths → update_wandering_targets → movement
  PostUpdate: poll_async_paths → splice_completed_async_paths

Priority: DF-like pathing (immediate movement) > performance > memory
This commit is contained in:
2026-03-18 16:35:39 +00:00
parent 79a386afa6
commit c902cff908
9 changed files with 626 additions and 2417 deletions
+97
View File
@@ -1,6 +1,8 @@
use bevy::prelude::*;
use rustc_hash::FxHashMap;
use crate::constants::ITILE_SIZE;
/// Packed floor tile data for efficient storage. ~35 bytes vs 76 bytes tuple.
#[derive(Clone, Copy, Debug)]
pub struct FloorTileData {
@@ -193,3 +195,98 @@ impl TileMap {
self.floor_tiles.get_mut(pos)
}
}
/// Bit-packed bounding-box snapshot for async pathfinding.
/// 1 bit per tile = ~6KB for 50,000 tiles vs HashMap overhead.
/// Must be Send+Sync — no RefCell, no Arc.
#[derive(Clone, Debug)]
pub struct StandableBitGrid {
pub origin: IVec3,
pub size: UVec3,
pub bits: Vec<u64>,
}
impl StandableBitGrid {
/// Create a bit-grid snapshot of all standable tiles within bounding box.
/// origin: min corner (inclusive), snapped to ITILE_SIZE
/// size: dimensions in tiles (not pixels)
pub fn new(origin: IVec3, size: UVec3, tilemap: &TileMap) -> Self {
let total_bits = (size.x * size.y * size.z) as usize;
let words = (total_bits + 63) / 64;
let mut bits = vec![0u64; words];
for bz in 0..size.z {
for by in 0..size.y {
for bx in 0..size.x {
let pos = IVec3::new(
origin.x + (bx as i32) * ITILE_SIZE,
origin.y + (by as i32) * ITILE_SIZE,
origin.z + (bz as i32) * ITILE_SIZE,
);
if Self::tile_is_standable(tilemap, pos) {
let idx = ((bz * size.y * size.x) + (by * size.x) + bx) as usize;
bits[idx / 64] |= 1u64 << (idx % 64);
}
}
}
}
Self { origin, size, bits }
}
/// Standable check using TileMap (mirrors pathfinding.rs::is_standable_tile)
#[inline]
fn tile_is_standable(tilemap: &TileMap, pos: IVec3) -> bool {
let can_stand_in_tile = tilemap
.floor_tiles
.get(&pos)
.map(|t| t.can_stand_in())
.unwrap_or(false);
let can_stand_in_fixture = tilemap
.fixture_tiles
.get(&pos)
.map(|t| t.can_stand_in())
.unwrap_or(false);
let pos_below = pos - IVec3::new(0, 0, ITILE_SIZE);
let can_stand_on_tile_below = tilemap
.floor_tiles
.get(&pos_below)
.map(|t| t.can_stand_on())
.unwrap_or(false);
let can_stand_on_fixture_below = tilemap
.fixture_tiles
.get(&pos_below)
.map(|t| t.can_stand_on())
.unwrap_or(false);
(can_stand_in_tile || can_stand_in_fixture)
&& (can_stand_on_tile_below || can_stand_on_fixture_below)
}
/// O(1) standable check using bit-grid coordinates.
#[inline]
pub fn is_standable_at(&self, bx: u32, by: u32, bz: u32) -> bool {
if bx >= self.size.x || by >= self.size.y || bz >= self.size.z {
return false;
}
let idx = ((bz * self.size.y * self.size.x) + (by * self.size.x) + bx) as usize;
self.bits[idx / 64] & (1u64 << (idx % 64)) != 0
}
/// Convert IVec3 world position to bit-grid coordinates.
/// Returns None if position is outside the grid bounds.
#[inline]
pub fn to_bit_coords(&self, pos: IVec3) -> Option<(u32, u32, u32)> {
let local = pos - self.origin;
if local.x < 0 || local.y < 0 || local.z < 0 {
return None;
}
let bx = (local.x / ITILE_SIZE) as u32;
let by = (local.y / ITILE_SIZE) as u32;
let bz = (local.z / ITILE_SIZE) as u32;
if bx >= self.size.x || by >= self.size.y || bz >= self.size.z {
return None;
}
Some((bx, by, bz))
}
}