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
+218 -36
View File
@@ -1,10 +1,15 @@
use bevy::prelude::*;
use bevy::tasks::AsyncComputeTaskPool;
use rayon::prelude::*;
use rustc_hash::FxHashMap;
use rustc_hash::FxHashSet;
use std::{cell::RefCell, collections::BinaryHeap, time::Instant};
use crate::constants::{ITILE_SIZE, PATHFINDER_MAX_NODES, TILE_SIZE};
use crate::constants::{
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::{chunks::ChunkMap, chunks::CHUNK_SIZE};
use crate::{constants::*, entities::shared_components::Ambulatory};
@@ -136,15 +141,116 @@ impl Plugin for PathfindingPlugin {
app.insert_resource(PathfindingBenchmark::new(100))
.insert_resource(crate::entities::shared_components::CompletedPaths::default())
.insert_resource(crate::entities::shared_components::PathRequestCounter::default())
.add_systems(FixedUpdate, (update_wandering_targets, movement).chain())
.insert_resource(
crate::entities::shared_systems::async_pathfinding::AsyncPathCounter::default(),
)
.add_systems(
FixedUpdate,
(prepare_paths, update_wandering_targets, movement).chain(),
)
.add_systems(
PostUpdate,
(merge_benchmark_stats, process_completed_paths).chain(),
(
merge_benchmark_stats,
process_completed_paths,
crate::entities::shared_systems::async_pathfinding::poll_async_paths,
splice_completed_async_paths,
),
)
.add_systems(Update, bench_report_system);
}
}
pub fn prepare_paths(
mut commands: Commands,
mut counter: ResMut<crate::entities::shared_systems::async_pathfinding::AsyncPathCounter>,
mut query: Query<
(
Entity,
&mut crate::entities::shared_components::Ambulatory,
&Transform,
Option<&crate::entities::shared_components::PendingAsyncPath>,
),
Without<crate::entities::shared_components::PendingPath>,
>,
tilemap: Res<TileMap>,
) {
for (entity, mut ambulatory, transform, pending_async) in query.iter_mut() {
if let Some(target) = ambulatory.target {
if ambulatory.current_path.is_none() || pending_async.is_some() {
continue;
}
let start = transform.translation.as_ivec3();
let goal = target.as_ivec3() - ivec3(0, 0, ITILE_SIZE);
let distance = octile_distance_3d(start, goal) / ITILE_SIZE;
if distance <= PATHFINDER_SHORT_PATH_MAX_TILES {
let path = calculate_path_benchmarked(&tilemap, start, goal);
ambulatory.current_path = Some(path);
ambulatory.path_index = 0;
} else {
let provisional = calculate_provisional_path(
&tilemap,
start,
goal,
PATHFINDER_PROVISIONAL_NODE_LIMIT,
);
if !provisional.is_empty() {
ambulatory.current_path = Some(provisional.clone());
ambulatory.path_index = 0;
let request_id = counter.next();
let pending =
crate::entities::shared_systems::async_pathfinding::spawn_async_path_task(
&tilemap,
start,
goal,
provisional,
request_id,
);
commands.entity(entity).insert(pending);
} else {
let path = calculate_path_benchmarked(&tilemap, start, goal);
ambulatory.current_path = Some(path);
ambulatory.path_index = 0;
}
}
}
}
}
pub fn splice_completed_async_paths(
mut completed: ResMut<crate::entities::shared_components::CompletedPaths>,
mut query: Query<(
Entity,
&mut crate::entities::shared_components::Ambulatory,
&Transform,
)>,
) {
if completed.paths.is_empty() {
return;
}
let mut to_process = Vec::new();
for (request_id, path) in completed.paths.drain(..) {
to_process.push((request_id, path));
}
for (request_id, path) in to_process {
for (entity, mut ambulatory, _transform) in query.iter_mut() {
if ambulatory.current_path.as_ref().is_none_or(|p| p != &path) {
let splice_index = find_splice_point(&path, ambulatory.path_index);
ambulatory.current_path = Some(path.clone());
ambulatory.path_index = splice_index;
}
}
}
}
fn find_splice_point(path: &[Vec3], current_index: usize) -> usize {
current_index.min(path.len().saturating_sub(1))
}
pub fn process_completed_paths(
mut completed: ResMut<crate::entities::shared_components::CompletedPaths>,
mut query: Query<(
@@ -249,43 +355,37 @@ pub fn movement(mut query: Query<(&mut Ambulatory, &mut Transform)>, tilemap: Re
return;
}
if let Some(target) = ambulatory.target {
if ambulatory.current_path.is_none() {
ambulatory.current_path = Some(calculate_path_benchmarked(
&tilemap,
transform.translation.as_ivec3(),
target.as_ivec3() - ivec3(0, 0, 1),
));
ambulatory.path_index = 0;
if ambulatory.current_path.is_none() {
return;
}
if ambulatory.walk_speed > 0. {
if ambulatory.step_recovery <= ambulatory.walk_speed as u32 {
ambulatory.step_recovery += 1;
return;
} else {
ambulatory.step_recovery = 0;
}
if ambulatory.walk_speed > 0. {
if ambulatory.step_recovery <= ambulatory.walk_speed as u32 {
ambulatory.step_recovery += 1;
return;
} else {
ambulatory.step_recovery = 0;
}
if let Some(path) = &ambulatory.current_path {
if ambulatory.path_index < path.len() {
let next_point = path[ambulatory.path_index];
let direction = (next_point - transform.translation).normalize();
transform.translation = next_point;
if direction.x > 0.0 {
transform.scale.x = PIXEL_RATIO;
} else if direction.x < 0.0 {
transform.scale.x = -PIXEL_RATIO;
}
}
if let Some(path) = &ambulatory.current_path {
if ambulatory.path_index < path.len() {
let next_point = path[ambulatory.path_index];
let direction = (next_point - transform.translation).normalize();
transform.translation = next_point;
if direction.x > 0.0 {
transform.scale.x = PIXEL_RATIO;
} else if direction.x < 0.0 {
transform.scale.x = -PIXEL_RATIO;
}
if transform.translation.distance(next_point) < TILE_SIZE {
ambulatory.path_index += 1;
}
} else {
ambulatory.current_path = None;
ambulatory.target = None;
if transform.translation.distance(next_point) < TILE_SIZE {
ambulatory.path_index += 1;
}
} else {
ambulatory.current_path = None;
ambulatory.target = None;
}
}
});
@@ -466,6 +566,88 @@ fn calculate_path_with_scratchpad(
})
}
pub fn calculate_provisional_path(
tilemap: &TileMap,
start: IVec3,
goal: IVec3,
node_limit: usize,
) -> Vec<Vec3> {
if !is_standable_tile(tilemap, start) {
return Vec::new();
}
let estimated_tiles = octile_distance_3d(start, goal) / ITILE_SIZE;
SCRATCHPAD.with(|s| {
let mut scratch = s.borrow_mut();
let capacity = ((estimated_tiles as usize).max(64)).min(4096);
scratch.clear_and_reserve(capacity);
let initial_h = octile_distance_3d(start, goal);
scratch.open_set.push(PathNode {
position: start,
f_score: initial_h,
g_score: 0,
});
scratch.g_scores.insert(start, 0);
let mut nodes_expanded: usize = 0;
let mut best_node = start;
let mut best_h = initial_h;
while let Some(current_node) = scratch.open_set.pop() {
let current = current_node.position;
nodes_expanded += 1;
let h = octile_distance_3d(current, goal);
if h < best_h {
best_h = h;
best_node = current;
}
if current == goal {
return reconstruct_path(&scratch.came_from, current);
}
if nodes_expanded >= node_limit {
return reconstruct_path(&scratch.came_from, best_node);
}
scratch.closed_set.insert(current);
for &move_dir in &ALLOWED_MOVES {
let neighbor_pos = current + move_dir;
if !is_standable_tile(tilemap, neighbor_pos)
|| scratch.closed_set.contains(&neighbor_pos)
{
continue;
}
let movement_cost = calculate_movement_cost(move_dir);
if movement_cost == 0 {
continue;
}
let new_g = *scratch.g_scores.get(&current).unwrap_or(&i32::MAX) + movement_cost;
if new_g < *scratch.g_scores.get(&neighbor_pos).unwrap_or(&i32::MAX) {
scratch.came_from.insert(neighbor_pos, current);
scratch.g_scores.insert(neighbor_pos, new_g);
let f = new_g + octile_distance_3d(neighbor_pos, goal);
scratch.open_set.push(PathNode {
position: neighbor_pos,
f_score: f,
g_score: new_g,
});
}
}
}
reconstruct_path(&scratch.came_from, best_node)
})
}
pub fn bench_report_system(
keys: Res<ButtonInput<KeyCode>>,
mut bench: ResMut<PathfindingBenchmark>,