refactor: simplify to time-sliced synchronous path queue

The async pathfinding with StandableBitGrid caused massive lag due to synchronous
bounding box calculation in the main thread (millions of hashmap lookups for long paths).
Additionally, the splicing logic had a catastrophic bug where it applied a single
finished path to ALL entities unconditionally.

Solution:
- Revert to thread-local synchronous A*
- Implement PathRequestQueue to process max 8 paths per frame
- Keep provisional paths for immediate movement
- Since entity walks provisional path, full path calculates from current position,
  eliminating the need for complex splicing logic.
This commit is contained in:
2026-03-18 17:23:48 +00:00
parent 31c93f7926
commit 00cbd92491
5 changed files with 45 additions and 454 deletions
@@ -1,5 +1,4 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::Task;
#[derive(Component)] #[derive(Component)]
pub struct Ambulatory { pub struct Ambulatory {
@@ -19,15 +18,6 @@ pub struct PendingPath {
pub request_id: u64, pub request_id: u64,
} }
#[derive(Component)]
pub struct PendingAsyncPath {
pub request_id: u64,
pub task: Task<Vec<Vec3>>,
pub goal: IVec3,
pub provisional_path: Vec<Vec3>,
pub provisional_path_index: usize,
}
#[derive(Resource, Default)] #[derive(Resource, Default)]
pub struct PathRequestCounter { pub struct PathRequestCounter {
pub next_id: u64, pub next_id: u64,
@@ -1,296 +0,0 @@
use bevy::prelude::*;
use bevy::tasks::AsyncComputeTaskPool;
use rustc_hash::{FxHashMap, FxHashSet};
use std::collections::BinaryHeap;
use crate::constants::{ITILE_SIZE, PATHFINDER_MAX_NODES};
use crate::entities::shared_components::{CompletedPaths, PendingAsyncPath};
use crate::world::tiles::tilemap::StandableBitGrid;
use crate::world::tiles::TileMap;
pub struct AsyncPathfindingPlugin;
impl Plugin for AsyncPathfindingPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(AsyncPathCounter::default())
.add_systems(PostUpdate, poll_async_paths);
}
}
#[derive(Resource, Default)]
pub struct AsyncPathCounter {
pub next_id: u64,
}
impl AsyncPathCounter {
pub fn next(&mut self) -> u64 {
let id = self.next_id;
self.next_id += 1;
id
}
}
#[derive(Clone, Copy, Eq, PartialEq, Debug)]
struct AsyncPathNode {
position: IVec3,
f_score: i32,
g_score: i32,
}
impl Ord for AsyncPathNode {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
other
.f_score
.cmp(&self.f_score)
.then_with(|| other.g_score.cmp(&self.g_score))
}
}
impl PartialOrd for AsyncPathNode {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
const ASYNC_ALLOWED_MOVES: [(i32, i32, i32); 24] = [
(-1, 0, 0),
(1, 0, 0),
(0, -1, 0),
(0, 1, 0),
(-1, -1, 0),
(-1, 1, 0),
(1, -1, 0),
(1, 1, 0),
(-1, 0, 1),
(-1, 0, -1),
(1, 0, 1),
(1, 0, -1),
(0, -1, 1),
(0, -1, -1),
(0, 1, 1),
(0, 1, -1),
(-1, -1, 1),
(-1, -1, -1),
(-1, 1, 1),
(-1, 1, -1),
(1, -1, 1),
(1, -1, -1),
(1, 1, 1),
(1, 1, -1),
];
pub fn calculate_async_path(bit_grid: StandableBitGrid, start: IVec3, goal: IVec3) -> Vec<Vec3> {
let (sbx, sby, sbz) = match bit_grid.to_bit_coords(start) {
Some(c) => c,
None => return vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)],
};
let (gbx, gby, gbz) = match bit_grid.to_bit_coords(goal) {
Some(c) => c,
None => return vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)],
};
let mut g_scores: FxHashMap<(u32, u32, u32), i32> = FxHashMap::default();
let mut came_from: FxHashMap<(u32, u32, u32), (u32, u32, u32)> = FxHashMap::default();
let mut closed_set: FxHashSet<(u32, u32, u32)> = FxHashSet::default();
let mut open_set: BinaryHeap<AsyncPathNode> = BinaryHeap::new();
let h = octile_distance_3d_bit(sbx, sby, sbz, gbx, gby, gbz);
open_set.push(AsyncPathNode {
position: start,
f_score: h,
g_score: 0,
});
g_scores.insert((sbx, sby, sbz), 0);
let mut nodes_expanded: usize = 0;
while let Some(current_node) = open_set.pop() {
let current = current_node.position;
nodes_expanded += 1;
if nodes_expanded > PATHFINDER_MAX_NODES {
return reconstruct_path_async(&came_from, current, &bit_grid);
}
if current == goal {
return reconstruct_path_async(&came_from, current, &bit_grid);
}
let (cx, cy, cz) = match bit_grid.to_bit_coords(current) {
Some(c) => c,
None => continue,
};
closed_set.insert((cx, cy, cz));
for &(dx, dy, dz) in &ASYNC_ALLOWED_MOVES {
let nx = cx as i32 + dx;
let ny = cy as i32 + dy;
let nz = cz as i32 + dz;
if nx < 0 || ny < 0 || nz < 0 {
continue;
}
let neighbor_bx = nx as u32;
let neighbor_by = ny as u32;
let neighbor_bz = nz as u32;
if !bit_grid.is_standable_at(neighbor_bx, neighbor_by, neighbor_bz)
|| closed_set.contains(&(neighbor_bx, neighbor_by, neighbor_bz))
{
continue;
}
let movement_cost = calculate_movement_cost_bit(dx, dy, dz);
if movement_cost == 0 {
continue;
}
let neighbor_pos = IVec3::new(
current.x + dx * ITILE_SIZE,
current.y + dy * ITILE_SIZE,
current.z + dz * ITILE_SIZE,
);
let current_g = *g_scores.get(&(cx, cy, cz)).unwrap_or(&i32::MAX);
let new_g = current_g + movement_cost;
let existing_g = *g_scores
.get(&(neighbor_bx, neighbor_by, neighbor_bz))
.unwrap_or(&i32::MAX);
if new_g < existing_g {
came_from.insert((neighbor_bx, neighbor_by, neighbor_bz), (cx, cy, cz));
g_scores.insert((neighbor_bx, neighbor_by, neighbor_bz), new_g);
let h =
octile_distance_3d_bit(neighbor_bx, neighbor_by, neighbor_bz, gbx, gby, gbz);
open_set.push(AsyncPathNode {
position: neighbor_pos,
f_score: new_g + h,
g_score: new_g,
});
}
}
}
Vec::new()
}
fn octile_distance_3d_bit(ax: u32, ay: u32, az: u32, bx: u32, by: u32, bz: u32) -> i32 {
let dx = (ax as i32 - bx as i32).abs();
let dy = (ay as i32 - by as i32).abs();
let dz = (az as i32 - bz as i32).abs();
10 * dx.max(dy).max(dz) + 4 * sort_middle(dx, dy, dz) + sort_min(dx, dy, dz)
}
fn sort_middle(a: i32, b: i32, c: i32) -> i32 {
let mut arr = [a, b, c];
arr.sort_unstable();
arr[1]
}
fn sort_min(a: i32, b: i32, c: i32) -> i32 {
let mut arr = [a, b, c];
arr.sort_unstable();
arr[0]
}
fn calculate_movement_cost_bit(dx: i32, dy: i32, dz: i32) -> i32 {
match (dx.abs(), dy.abs(), dz.abs()) {
(1, 0, 0) | (0, 1, 0) => 10,
(1, 1, 0) => 14,
(1, 0, 1) | (0, 1, 1) => 42,
(1, 1, 1) => 56,
_ => 0,
}
}
fn reconstruct_path_async(
came_from: &FxHashMap<(u32, u32, u32), (u32, u32, u32)>,
mut current: IVec3,
bit_grid: &StandableBitGrid,
) -> Vec<Vec3> {
let mut path = vec![Vec3::new(
current.x as f32,
current.y as f32,
current.z as f32,
)];
while let Some((cx, cy, cz)) = bit_grid.to_bit_coords(current) {
if let Some(&(px, py, pz)) = came_from.get(&(cx, cy, cz)) {
let prev = IVec3::new(
bit_grid.origin.x + (px as i32) * ITILE_SIZE,
bit_grid.origin.y + (py as i32) * ITILE_SIZE,
bit_grid.origin.z + (pz as i32) * ITILE_SIZE,
);
path.push(Vec3::new(prev.x as f32, prev.y as f32, prev.z as f32));
current = prev;
} else {
break;
}
}
path.reverse();
path
}
pub fn poll_async_paths(
mut commands: Commands,
mut pending_query: Query<(Entity, &mut PendingAsyncPath)>,
mut completed: ResMut<CompletedPaths>,
) {
for (entity, mut pending) in pending_query.iter_mut() {
if !pending.task.is_finished() {
continue;
}
let path: Option<Vec<Vec3>> =
futures_lite::future::block_on(futures_lite::future::poll_once(&mut pending.task));
if let Some(p) = path {
if !p.is_empty() {
completed.paths.push((pending.request_id, p));
}
commands.entity(entity).remove::<PendingAsyncPath>();
}
}
}
pub fn compute_bounding_box(start: IVec3, goal: IVec3, margin_tiles: i32) -> (IVec3, UVec3) {
let margin = margin_tiles * ITILE_SIZE;
let min_x = start.x.min(goal.x) - margin;
let max_x = start.x.max(goal.x) + margin;
let min_y = start.y.min(goal.y) - margin;
let max_y = start.y.max(goal.y) + margin;
let min_z = start.z.min(goal.z) - margin;
let max_z = start.z.max(goal.z) + margin;
let origin = IVec3::new(min_x, min_y, min_z);
let size = UVec3::new(
((max_x - min_x) / ITILE_SIZE + 1) as u32,
((max_y - min_y) / ITILE_SIZE + 1) as u32,
((max_z - min_z) / ITILE_SIZE + 1) as u32,
);
(origin, size)
}
pub fn spawn_async_path_task(
tilemap: &TileMap,
start: IVec3,
goal: IVec3,
provisional_path: Vec<Vec3>,
request_id: u64,
) -> PendingAsyncPath {
let (origin, size) = compute_bounding_box(start, goal, 20);
let bit_grid = StandableBitGrid::new(origin, size, tilemap);
let pool = AsyncComputeTaskPool::get();
let task = pool.spawn(async move { calculate_async_path(bit_grid, start, goal) });
PendingAsyncPath {
request_id,
task,
goal,
provisional_path,
provisional_path_index: 0,
}
}
-1
View File
@@ -1,2 +1 @@
pub mod async_pathfinding;
pub mod pathfinding; pub mod pathfinding;
+45 -52
View File
@@ -1,15 +1,12 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::AsyncComputeTaskPool;
use rayon::prelude::*; use rayon::prelude::*;
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use rustc_hash::FxHashSet; use rustc_hash::FxHashSet;
use std::{cell::RefCell, collections::BinaryHeap, time::Instant}; use std::{cell::RefCell, collections::BinaryHeap, collections::VecDeque, time::Instant};
use crate::constants::{ use crate::constants::{
ITILE_SIZE, PATHFINDER_MAX_NODES, PATHFINDER_PROVISIONAL_NODE_LIMIT, TILE_SIZE, ITILE_SIZE, PATHFINDER_MAX_NODES, PATHFINDER_PROVISIONAL_NODE_LIMIT, TILE_SIZE,
}; };
use crate::entities::shared_components::PendingAsyncPath;
use crate::world::tiles::tilemap::StandableBitGrid;
use crate::world::tiles::TileMap; use crate::world::tiles::TileMap;
use crate::world::{chunks::ChunkMap, chunks::CHUNK_SIZE}; use crate::world::{chunks::ChunkMap, chunks::CHUNK_SIZE};
use crate::{constants::*, entities::shared_components::Ambulatory}; use crate::{constants::*, entities::shared_components::Ambulatory};
@@ -134,6 +131,13 @@ impl PathfindingBenchmark {
} }
} }
#[derive(Resource, Default)]
pub struct PathRequestQueue {
pub pending: VecDeque<(Entity, IVec3, IVec3)>,
}
const MAX_PATHS_PER_FRAME: usize = 8;
pub struct PathfindingPlugin; pub struct PathfindingPlugin;
impl Plugin for PathfindingPlugin { impl Plugin for PathfindingPlugin {
@@ -141,9 +145,7 @@ impl Plugin for PathfindingPlugin {
app.insert_resource(PathfindingBenchmark::new(100)) app.insert_resource(PathfindingBenchmark::new(100))
.insert_resource(crate::entities::shared_components::CompletedPaths::default()) .insert_resource(crate::entities::shared_components::CompletedPaths::default())
.insert_resource(crate::entities::shared_components::PathRequestCounter::default()) .insert_resource(crate::entities::shared_components::PathRequestCounter::default())
.insert_resource( .insert_resource(PathRequestQueue::default())
crate::entities::shared_systems::async_pathfinding::AsyncPathCounter::default(),
)
.add_systems( .add_systems(
FixedUpdate, FixedUpdate,
(prepare_paths, update_wandering_targets, movement).chain(), (prepare_paths, update_wandering_targets, movement).chain(),
@@ -153,8 +155,7 @@ impl Plugin for PathfindingPlugin {
( (
merge_benchmark_stats, merge_benchmark_stats,
process_completed_paths, process_completed_paths,
crate::entities::shared_systems::async_pathfinding::poll_async_paths, process_path_queue,
splice_completed_async_paths,
), ),
) )
.add_systems(Update, bench_report_system); .add_systems(Update, bench_report_system);
@@ -163,26 +164,19 @@ impl Plugin for PathfindingPlugin {
pub fn prepare_paths( pub fn prepare_paths(
mut commands: Commands, mut commands: Commands,
mut counter: ResMut<crate::entities::shared_systems::async_pathfinding::AsyncPathCounter>, mut queue: ResMut<PathRequestQueue>,
mut query: Query< mut query: Query<
( (
Entity, Entity,
&mut crate::entities::shared_components::Ambulatory, &mut crate::entities::shared_components::Ambulatory,
&Transform, &Transform,
Option<&crate::entities::shared_components::PendingAsyncPath>,
), ),
Without<crate::entities::shared_components::PendingPath>, Without<crate::entities::shared_components::PendingPath>,
>, >,
tilemap: Res<TileMap>, tilemap: Res<TileMap>,
) { ) {
for (entity, mut ambulatory, transform, pending_async) in query.iter_mut() { for (entity, mut ambulatory, transform) in query.iter_mut() {
if ambulatory.current_path.is_some() { if ambulatory.current_path.is_some() || ambulatory.target.is_none() {
continue;
}
if pending_async.is_some() {
continue;
}
if ambulatory.target.is_none() {
continue; continue;
} }
let Some(target) = ambulatory.target else { let Some(target) = ambulatory.target else {
@@ -204,19 +198,18 @@ pub fn prepare_paths(
PATHFINDER_PROVISIONAL_NODE_LIMIT, PATHFINDER_PROVISIONAL_NODE_LIMIT,
); );
if !provisional.is_empty() { if !provisional.is_empty() {
ambulatory.current_path = Some(provisional.clone()); ambulatory.current_path = Some(provisional);
ambulatory.path_index = 0; ambulatory.path_index = 0;
let request_id = counter.next(); queue.pending.push_back((entity, start, goal));
let pending = commands
crate::entities::shared_systems::async_pathfinding::spawn_async_path_task( .entity(entity)
&tilemap, .insert(crate::entities::shared_components::PendingPath {
start, start,
goal, goal,
provisional, waypoint_path: Vec::new(),
request_id, request_id: 0,
); });
commands.entity(entity).insert(pending);
} else { } else {
let path = calculate_path_benchmarked(&tilemap, start, goal); let path = calculate_path_benchmarked(&tilemap, start, goal);
ambulatory.current_path = Some(path); ambulatory.current_path = Some(path);
@@ -226,38 +219,38 @@ pub fn prepare_paths(
} }
} }
pub fn splice_completed_async_paths( pub fn process_path_queue(
mut completed: ResMut<crate::entities::shared_components::CompletedPaths>, mut commands: Commands,
mut query: Query<( mut queue: ResMut<PathRequestQueue>,
Entity, tilemap: Res<TileMap>,
&mut crate::entities::shared_components::Ambulatory, mut query: Query<
&Transform, (Entity, &mut Ambulatory, &Transform),
)>, With<crate::entities::shared_components::PendingPath>,
>,
) { ) {
if completed.paths.is_empty() { let mut processed = 0;
return; while processed < MAX_PATHS_PER_FRAME {
} if let Some((entity, _old_start, goal)) = queue.pending.pop_front() {
processed += 1;
let mut to_process = Vec::new(); if let Ok((_, mut ambulatory, transform)) = query.get_mut(entity) {
for (request_id, path) in completed.paths.drain(..) { let actual_start = transform.translation.as_ivec3();
to_process.push((request_id, path)); let full_path = calculate_path_benchmarked(&tilemap, actual_start, goal);
}
for (request_id, path) in to_process { if !full_path.is_empty() {
for (entity, mut ambulatory, _transform) in query.iter_mut() { ambulatory.current_path = Some(full_path);
if ambulatory.current_path.as_ref().is_none_or(|p| p != &path) { ambulatory.path_index = 0;
let splice_index = find_splice_point(&path, ambulatory.path_index); }
ambulatory.current_path = Some(path.clone()); commands
ambulatory.path_index = splice_index; .entity(entity)
.remove::<crate::entities::shared_components::PendingPath>();
} }
} else {
break;
} }
} }
} }
fn find_splice_point(path: &[Vec3], current_index: usize) -> usize {
current_index.min(path.len().saturating_sub(1))
}
pub fn process_completed_paths( pub fn process_completed_paths(
mut completed: ResMut<crate::entities::shared_components::CompletedPaths>, mut completed: ResMut<crate::entities::shared_components::CompletedPaths>,
mut query: Query<( mut query: Query<(
-95
View File
@@ -195,98 +195,3 @@ impl TileMap {
self.floor_tiles.get_mut(pos) 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))
}
}