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:
@@ -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(¤t).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>,
|
||||
|
||||
Reference in New Issue
Block a user