From c902cff908251f901b856aa7ff88692fdd13e36e Mon Sep 17 00:00:00 2001 From: popertots Date: Wed, 18 Mar 2026 16:35:39 +0000 Subject: [PATCH] feat: async pathfinding with provisional paths and bit-grid snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Cargo.lock | 1 + Cargo.toml | 1 + last_24h_diff.patch | 2380 ----------------- src/constants.rs | 3 +- src/entities/shared_components/ambulatory.rs | 10 + .../shared_systems/async_pathfinding.rs | 296 ++ src/entities/shared_systems/mod.rs | 1 + src/entities/shared_systems/pathfinding.rs | 254 +- src/world/tiles/tilemap.rs | 97 + 9 files changed, 626 insertions(+), 2417 deletions(-) delete mode 100644 last_24h_diff.patch create mode 100644 src/entities/shared_systems/async_pathfinding.rs diff --git a/Cargo.lock b/Cargo.lock index 58598e8..440fc3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2335,6 +2335,7 @@ dependencies = [ "bevy", "bevy_platform", "bevy_rand", + "futures-lite", "image", "nohash-hasher", "noise", diff --git a/Cargo.toml b/Cargo.toml index 998fdd6..8522f33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ rayon = "1.11.0" rustc-hash = "2.1.1" ahash = "0.8.12" nohash-hasher = "0.2.0" +futures-lite = "2.6" # Enable max optimizations for dependencies, but not for our code: [profile.dev.package."*"] diff --git a/last_24h_diff.patch b/last_24h_diff.patch deleted file mode 100644 index 285c625..0000000 --- a/last_24h_diff.patch +++ /dev/null @@ -1,2380 +0,0 @@ -diff --git a/Cargo.lock b/Cargo.lock -index 38f3aec..58598e8 100644 ---- a/Cargo.lock -+++ b/Cargo.lock -@@ -400,9 +400,9 @@ dependencies = [ - - [[package]] - name = "avif-serialize" --version = "0.8.3" -+version = "0.8.8" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "98922d6a4cfbcb08820c69d8eeccc05bb1f29bfa06b4f5b1dbfe9a868bd7608e" -+checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" - dependencies = [ - "arrayvec", - ] -@@ -415,9 +415,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - - [[package]] - name = "bevy" --version = "0.18.0" -+version = "0.18.1" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "ec689b5a79452b6f777b889bbff22d3216b82a8d2ab7814d4a0eb571e9938d97" -+checksum = "1fd310426290cec560221f9750c2f4484be4a8eeea7de3483c423329b465c40e" - dependencies = [ - "bevy_internal", - ] -@@ -1175,9 +1175,9 @@ dependencies = [ - - [[package]] - name = "bevy_platform" --version = "0.18.0" -+version = "0.18.1" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "9b29ea749a8e85f98186ab662f607b885b97c804bb14cdb0cdf838164496d474" -+checksum = "ec6b36504169b644acd26a5469fd8d371aa6f1d73ee5c01b1b1181ae1cefbf9b" - dependencies = [ - "critical-section", - "foldhash 0.2.0", -@@ -1249,9 +1249,9 @@ checksum = "4f98cbc6d34bbdb58240b72ed1731931b4991a893b3a3238bb7c42ae054aa676" - - [[package]] - name = "bevy_rand" --version = "0.14.0" -+version = "0.14.2" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "41e6e263026d08a4ca27c83257ed582939162534e6658ead164886d610df9b13" -+checksum = "526ba09324b96fb64f275a0cb4f6112d36b6c916ce2f14e38d5a8739dd2ac7f9" - dependencies = [ - "bevy_app", - "bevy_ecs", -@@ -2331,13 +2331,16 @@ dependencies = [ - name = "dorf" - version = "0.1.0" - dependencies = [ -+ "ahash", - "bevy", - "bevy_platform", - "bevy_rand", - "image", -+ "nohash-hasher", - "noise", - "rand 0.10.0", - "rayon", -+ "rustc-hash 2.1.1", - ] - - [[package]] -@@ -3023,9 +3026,9 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - - [[package]] - name = "image" --version = "0.25.9" -+version = "0.25.10" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" -+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" - dependencies = [ - "bytemuck", - "byteorder-lite", -@@ -3041,8 +3044,8 @@ dependencies = [ - "rayon", - "rgb", - "tiff", -- "zune-core 0.5.1", -- "zune-jpeg 0.5.12", -+ "zune-core", -+ "zune-jpeg", - ] - - [[package]] -@@ -3057,9 +3060,9 @@ dependencies = [ - - [[package]] - name = "imgref" --version = "1.11.0" -+version = "1.12.0" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" -+checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" - - [[package]] - name = "indexmap" -@@ -3431,9 +3434,9 @@ dependencies = [ - - [[package]] - name = "moxcms" --version = "0.7.5" -+version = "0.8.1" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "ddd32fa8935aeadb8a8a6b6b351e40225570a37c43de67690383d87ef170cd08" -+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" - dependencies = [ - "num-traits", - "pxfm", -@@ -3566,6 +3569,12 @@ dependencies = [ - "libc", - ] - -+[[package]] -+name = "nohash-hasher" -+version = "0.2.0" -+source = "registry+https://github.com/rust-lang/crates.io-index" -+checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" -+ - [[package]] - name = "noise" - version = "0.9.0" -@@ -4415,9 +4424,9 @@ dependencies = [ - - [[package]] - name = "ravif" --version = "0.12.0" -+version = "0.13.0" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" -+checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" - dependencies = [ - "avif-serialize", - "imgref", -@@ -4536,9 +4545,9 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" - - [[package]] - name = "rgb" --version = "0.8.50" -+version = "0.8.53" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" -+checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" - - [[package]] - name = "rodio" -@@ -4993,16 +5002,16 @@ dependencies = [ - - [[package]] - name = "tiff" --version = "0.10.3" -+version = "0.11.3" - source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" -+checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" - dependencies = [ - "fax", - "flate2", - "half", - "quick-error", - "weezl", -- "zune-jpeg 0.4.21", -+ "zune-jpeg", - ] - - [[package]] -@@ -6468,12 +6477,6 @@ dependencies = [ - "syn", - ] - --[[package]] --name = "zune-core" --version = "0.4.12" --source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" -- - [[package]] - name = "zune-core" - version = "0.5.1" -@@ -6489,20 +6492,11 @@ dependencies = [ - "simd-adler32", - ] - --[[package]] --name = "zune-jpeg" --version = "0.4.21" --source = "registry+https://github.com/rust-lang/crates.io-index" --checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" --dependencies = [ -- "zune-core 0.4.12", --] -- - [[package]] - name = "zune-jpeg" - version = "0.5.12" - source = "registry+https://github.com/rust-lang/crates.io-index" - checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" - dependencies = [ -- "zune-core 0.5.1", -+ "zune-core", - ] -diff --git a/Cargo.toml b/Cargo.toml -index 254d4bb..998fdd6 100644 ---- a/Cargo.toml -+++ b/Cargo.toml -@@ -4,13 +4,16 @@ version = "0.1.0" - edition = "2021" - - [dependencies] --bevy = { version = "0.18.0", features = ["wayland"] } -+bevy = { version = "0.18.1", features = ["wayland"] } - noise = "0.9.0" - rand = "0.10.0" --bevy_rand = { version = "0.14.0", features = ["wyrand"] } --image = "0.25.9" --bevy_platform = "0.18.0" -+bevy_rand = { version = "0.14.2", features = ["wyrand"] } -+image = "0.25.10" -+bevy_platform = "0.18.1" - rayon = "1.11.0" -+rustc-hash = "2.1.1" -+ahash = "0.8.12" -+nohash-hasher = "0.2.0" - - # Enable max optimizations for dependencies, but not for our code: - [profile.dev.package."*"] -diff --git a/src/constants.rs b/src/constants.rs -index a0c34b2..853a18f 100644 ---- a/src/constants.rs -+++ b/src/constants.rs -@@ -3,3 +3,13 @@ pub const TILE_PIXELS: u32 = 16; - pub const TILE_SIZE: f32 = TILE_PIXELS as f32 * PIXEL_RATIO; - pub const ITILE_SIZE: i32 = TILE_SIZE as i32; - pub const SEED: u32 = 420; -+ -+// Pathfinding tier thresholds (in tiles) -+pub const PATHFINDER_TIER0_MAX_TILES: i32 = 10; // Very short paths: Vec-based -+pub const PATHFINDER_TIER1_MAX_TILES: i32 = 30; // Short paths: AHashMap scratchpad -+pub const PATHFINDER_TIER2_MAX_TILES: i32 = 100; // Medium paths: AHashMap scratchpad -+ // TIER3: > 100 tiles → Chunk waypoints + async -+ -+// Pathfinding configuration -+pub const PATHFINDER_MAX_NODES: usize = 5000; // Max nodes before partial path -+pub const PATHFINDER_ASYNC_THRESHOLD_TILES: i32 = 150; // Start async for very long paths -diff --git a/src/entities/item/prefabs/misc/misc_prefabs.rs b/src/entities/item/prefabs/misc/misc_prefabs.rs -index 39d9e16..f5d9706 100644 ---- a/src/entities/item/prefabs/misc/misc_prefabs.rs -+++ b/src/entities/item/prefabs/misc/misc_prefabs.rs -@@ -41,15 +41,7 @@ pub fn spawn_prefab( - }, - }) - .id(); -- let mut items = tilemap -- .item_tiles -- .get(&position.as_ivec3()) -- .unwrap_or(&Vec::new()) -- .clone(); -- items.push(meat.index_u32()); -- tilemap -- .item_tiles -- .insert(position.as_ivec3(), items.clone()); -+ tilemap.insert_item(position.as_ivec3(), meat.index_u32()); - return commands - .entity(meat) - .insert(VisibleGameEntity) -@@ -76,15 +68,7 @@ pub fn spawn_prefab( - }, - }) - .id(); -- let mut items = tilemap -- .item_tiles -- .get(&position.as_ivec3()) -- .unwrap_or(&Vec::new()) -- .clone(); -- items.push(coin.index_u32()); -- tilemap -- .item_tiles -- .insert(position.as_ivec3(), items.clone()); -+ tilemap.insert_item(position.as_ivec3(), coin.index_u32()); - commands - .entity(coin) - .insert(VisibleGameEntity) -diff --git a/src/entities/item/systems.rs b/src/entities/item/systems.rs -index 5bc0eef..fc3f536 100644 ---- a/src/entities/item/systems.rs -+++ b/src/entities/item/systems.rs -@@ -116,13 +116,13 @@ pub fn item_tile_management_system( - - for position in positions { - let items = match tilemap.item_tiles.get(&position) { -- Some(i) => i, -+ Some(i) => i.clone(), - None => continue, - }; - - // Clean up empty tiles - if items.is_empty() { -- tilemap.item_tiles.remove(&position); -+ tilemap.remove_item(&position); - rotation_timer.current_indices.remove(&position); - continue; - } -@@ -146,8 +146,8 @@ pub fn item_tile_management_system( - .copied() - .unwrap_or(0); - -- for &entity_id in items { -- if let Some(entity) = Entity::from_raw_u32(entity_id) { -+ for entity_id in &items { -+ if let Some(entity) = Entity::from_raw_u32(*entity_id) { - if let Ok(mut rotation_state) = item_query.get_mut(entity) { - rotation_state.should_be_visible = false; - } -diff --git a/src/entities/shared_components/ambulatory.rs b/src/entities/shared_components/ambulatory.rs -index 8c7ae65..5b41470 100644 ---- a/src/entities/shared_components/ambulatory.rs -+++ b/src/entities/shared_components/ambulatory.rs -@@ -9,3 +9,21 @@ pub struct Ambulatory { - pub target: Option, - pub step_recovery: u32, - } -+ -+#[derive(Component)] -+pub struct PendingPath { -+ pub start: IVec3, -+ pub goal: IVec3, -+ pub waypoint_path: Vec, -+ pub request_id: u64, -+} -+ -+#[derive(Resource, Default)] -+pub struct PathRequestCounter { -+ pub next_id: u64, -+} -+ -+#[derive(Resource, Default)] -+pub struct CompletedPaths { -+ pub paths: Vec<(u64, Vec)>, -+} -diff --git a/src/entities/shared_systems/pathfinding.rs b/src/entities/shared_systems/pathfinding.rs -index ee8c897..11f22c6 100644 ---- a/src/entities/shared_systems/pathfinding.rs -+++ b/src/entities/shared_systems/pathfinding.rs -@@ -1,15 +1,81 @@ --use crate::constants::TILE_SIZE; -+use ahash::AHashMap; -+use ahash::AHashSet; -+use bevy::prelude::*; -+use rayon::prelude::*; -+use rustc_hash::FxHashMap; -+use rustc_hash::FxHashSet as HashSet; -+use std::{cell::RefCell, collections::BinaryHeap, time::Instant}; -+ -+// Thread-local storage for collecting metrics during parallel execution -+thread_local! { -+ static LOCAL_PATH_TIMES: RefCell> = const{ RefCell::new(Vec::new()) } ; -+ static LOCAL_PATH_LENGTHS: RefCell> = const{ RefCell::new(Vec::new()) }; -+ static LOCAL_NODES_EXPANDED: RefCell> = const{ RefCell::new(Vec::new()) }; -+ static LOCAL_FAILED_PATHS: RefCell = const{ RefCell::new(0) }; -+} -+ -+// Thread-local scratchpad for memory reuse (eliminates allocation overhead) -+// Cleared before each use but retains allocated capacity -+thread_local! { -+ static SCRATCH_G_SCORES_FX: RefCell> = RefCell::new(FxHashMap::default()); -+ static SCRATCH_CAME_FROM_FX: RefCell> = RefCell::new(FxHashMap::default()); -+ static SCRATCH_CLOSED_SET_FX: RefCell> = RefCell::new(HashSet::default()); -+ static SCRATCH_IN_OPEN_FX: RefCell> = RefCell::new(HashSet::default()); -+ static SCRATCH_G_SCORES_AH: RefCell> = RefCell::new(AHashMap::default()); -+ static SCRATCH_CAME_FROM_AH: RefCell> = RefCell::new(AHashMap::default()); -+ static SCRATCH_CLOSED_SET_AH: RefCell> = RefCell::new(AHashSet::default()); -+ static SCRATCH_IN_OPEN_AH: RefCell> = RefCell::new(AHashSet::default()); -+ static SCRATCH_TIER0_G_POS: RefCell> = RefCell::new(Vec::with_capacity(256)); -+ static SCRATCH_TIER0_G_VAL: RefCell> = RefCell::new(Vec::with_capacity(256)); -+ static SCRATCH_TIER0_CF_POS: RefCell> = RefCell::new(Vec::with_capacity(256)); -+ static SCRATCH_TIER0_CF_PREV: RefCell> = RefCell::new(Vec::with_capacity(256)); -+ static SCRATCH_TIER0_CLOSED: RefCell> = RefCell::new(Vec::with_capacity(256)); -+} -+ -+use crate::constants::{ -+ ITILE_SIZE, PATHFINDER_ASYNC_THRESHOLD_TILES, PATHFINDER_MAX_NODES, PATHFINDER_TIER0_MAX_TILES, -+ PATHFINDER_TIER1_MAX_TILES, PATHFINDER_TIER2_MAX_TILES, TILE_SIZE, -+}; - use crate::world::tiles::TileMap; - use crate::world::{chunks::ChunkMap, chunks::CHUNK_SIZE}; - use crate::{constants::*, entities::shared_components::Ambulatory}; - use bevy::{math::ivec3, prelude::*}; - use bevy_rand::prelude::*; - use rand::RngExt; --use std::{ -- collections::{BinaryHeap, HashMap, HashSet}, -- process::exit, --}; - -+/// Maximum nodes to expand before giving up (prevents runaway searches) -+const MAX_NODES: usize = 10000; -+ -+const ALLOWED_MOVES: [IVec3; 24] = [ -+ //Orthogonal moves -+ IVec3::new(-ITILE_SIZE, 0, 0), -+ IVec3::new(ITILE_SIZE, 0, 0), -+ IVec3::new(0, -ITILE_SIZE, 0), -+ IVec3::new(0, ITILE_SIZE, 0), -+ // Diagonal moves -+ IVec3::new(-ITILE_SIZE, -ITILE_SIZE, 0), -+ IVec3::new(-ITILE_SIZE, ITILE_SIZE, 0), -+ IVec3::new(ITILE_SIZE, -ITILE_SIZE, 0), -+ IVec3::new(ITILE_SIZE, ITILE_SIZE, 0), -+ // Diagonal with vertical moves -+ IVec3::new(-ITILE_SIZE, 0, ITILE_SIZE), -+ IVec3::new(-ITILE_SIZE, 0, -ITILE_SIZE), -+ IVec3::new(ITILE_SIZE, 0, ITILE_SIZE), -+ IVec3::new(ITILE_SIZE, 0, -ITILE_SIZE), -+ IVec3::new(0, -ITILE_SIZE, ITILE_SIZE), -+ IVec3::new(0, -ITILE_SIZE, -ITILE_SIZE), -+ IVec3::new(0, ITILE_SIZE, ITILE_SIZE), -+ IVec3::new(0, ITILE_SIZE, -ITILE_SIZE), -+ // Full 3D diagonal moves -+ IVec3::new(-ITILE_SIZE, -ITILE_SIZE, ITILE_SIZE), -+ IVec3::new(-ITILE_SIZE, -ITILE_SIZE, -ITILE_SIZE), -+ IVec3::new(-ITILE_SIZE, ITILE_SIZE, ITILE_SIZE), -+ IVec3::new(-ITILE_SIZE, ITILE_SIZE, -ITILE_SIZE), -+ IVec3::new(ITILE_SIZE, -ITILE_SIZE, ITILE_SIZE), -+ IVec3::new(ITILE_SIZE, -ITILE_SIZE, -ITILE_SIZE), -+ IVec3::new(ITILE_SIZE, ITILE_SIZE, ITILE_SIZE), -+ IVec3::new(ITILE_SIZE, ITILE_SIZE, -ITILE_SIZE), -+]; - #[derive(Clone, Eq, PartialEq, Debug)] - struct PathNode { - position: IVec3, -@@ -28,15 +94,122 @@ impl PartialOrd for PathNode { - Some(self.cmp(other)) - } - } -+/// Benchmark statistics for pathfinding performance analysis. -+/// Results are written to CSV on F8 keypress for comparison. -+#[derive(Resource, Default)] -+pub struct PathfindingBenchmark { -+ // Per-path metrics (collected via thread-local, merged at end) -+ pub path_calc_times_us: Vec, -+ pub path_lengths: Vec, -+ pub nodes_expanded: Vec, -+ -+ // System-level metrics -+ pub movement_system_times_us: Vec, -+ pub wander_system_times_us: Vec, -+ -+ // Counters -+ pub total_paths_calculated: u64, -+ pub total_failed_paths: u64, -+ -+ // Reporting config -+ pub report_every_n: u32, -+ pub sample_count: u32, -+} -+ -+impl PathfindingBenchmark { -+ pub fn new(report_every_n: u32) -> Self { -+ Self { -+ report_every_n, -+ ..Default::default() -+ } -+ } -+} -+ -+/// Individual path calculation metrics (returned from benchmarked function) -+#[derive(Clone, Debug)] -+pub struct PathMetrics { -+ pub duration_us: u128, -+ pub path_length: usize, -+ pub nodes_expanded: usize, -+ pub success: bool, -+} -+ -+/// CSV output row for benchmark comparison -+#[derive(Debug, Clone)] -+pub struct BenchmarkRow { -+ pub timestamp_ms: u128, -+ pub path_duration_us: u128, -+ pub path_length: usize, -+ pub nodes_expanded: usize, -+ pub success: bool, -+} - - pub struct PathfindingPlugin; - - impl Plugin for PathfindingPlugin { - fn build(&self, app: &mut App) { -- app.add_systems(FixedUpdate, (update_wandering_targets, movement).chain()); -+ 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()) -+ .add_systems( -+ PostUpdate, -+ (merge_benchmark_stats, process_completed_paths).chain(), -+ ) -+ .add_systems(Update, bench_report_system); -+ } -+} -+ -+pub fn process_completed_paths( -+ mut completed: ResMut, -+ mut query: Query<( -+ Entity, -+ &mut crate::entities::shared_components::Ambulatory, -+ &mut crate::entities::shared_components::PendingPath, -+ )>, -+ mut commands: Commands, -+) { -+ for (request_id, path) in completed.paths.drain(..) { -+ for (entity, mut ambulatory, pending) in query.iter_mut() { -+ if pending.request_id == request_id { -+ ambulatory.current_path = Some(path.clone()); -+ commands -+ .entity(entity) -+ .remove::(); -+ } -+ } - } - } - -+/// Merge thread-local benchmark stats into the global resource. -+/// This must run after all parallel work is complete. -+pub fn merge_benchmark_stats(mut bench: ResMut) { -+ LOCAL_PATH_TIMES.with(|t| { -+ let mut times = t.borrow_mut(); -+ bench.path_calc_times_us.extend(times.iter()); -+ bench.total_paths_calculated += times.len() as u64; -+ times.clear(); -+ }); -+ -+ LOCAL_PATH_LENGTHS.with(|l| { -+ let mut lengths = l.borrow_mut(); -+ bench.path_lengths.extend(lengths.iter()); -+ lengths.clear(); -+ }); -+ -+ LOCAL_NODES_EXPANDED.with(|n| { -+ let mut nodes = n.borrow_mut(); -+ bench.nodes_expanded.extend(nodes.iter()); -+ nodes.clear(); -+ }); -+ -+ LOCAL_FAILED_PATHS.with(|f| { -+ let mut failed = f.borrow_mut(); -+ bench.total_failed_paths += *failed; -+ *failed = 0; -+ }); -+} -+ - pub fn update_wandering_targets( - mut query: Query<(&mut Ambulatory, &Transform)>, // add a 'with' here when behaviours are implemented - tilemap: Res, -@@ -104,7 +277,7 @@ pub fn movement(mut query: Query<(&mut Ambulatory, &mut Transform)>, tilemap: Re - if let Some(target) = ambulatory.target { - // Calculate path if needed - if ambulatory.current_path.is_none() { -- ambulatory.current_path = Some(calculate_path( -+ ambulatory.current_path = Some(calculate_path_benchmarked( - &tilemap, - transform.translation.as_ivec3(), - target.as_ivec3() - ivec3(0, 0, 1), -@@ -147,53 +320,49 @@ pub fn movement(mut query: Query<(&mut Ambulatory, &mut Transform)>, tilemap: Re - } - }); - } -- - fn is_standable_tile(tilemap: &TileMap, pos: IVec3) -> bool { - let mut can_i_stand_in_tile: bool = false; - let mut can_i_stand_on_tile_bellow: bool = false; - let mut can_i_stand_in_fixture: bool = false; - let mut can_i_stand_on_fixture_bellow: bool = false; - -- // Check if current position has a blocking floor tile - if let Some(current_floor_tile) = tilemap.floor_tiles.get(&pos) { -- can_i_stand_in_tile = current_floor_tile.1; -+ can_i_stand_in_tile = current_floor_tile.can_stand_in(); - } -- // Check if current position has a solid fixture tile (e.g., log) - if let Some(current_fixture_tile) = tilemap.fixture_tiles.get(&pos) { -- can_i_stand_in_fixture = current_fixture_tile.1; -+ can_i_stand_in_fixture = current_fixture_tile.can_stand_in(); - } - -- // Check if there's solid ground below (fixture or floor) - let pos_below = pos - IVec3::new(0, 0, ITILE_SIZE); - - if let Some(below_floor_tile) = tilemap.floor_tiles.get(&pos_below) { -- can_i_stand_on_tile_bellow = below_floor_tile.2; -+ can_i_stand_on_tile_bellow = below_floor_tile.can_stand_on(); - } - - if let Some(below_fixture_tile) = tilemap.fixture_tiles.get(&pos_below) { -- can_i_stand_on_fixture_bellow = below_fixture_tile.2; -+ can_i_stand_on_fixture_bellow = below_fixture_tile.can_stand_on(); - } -- return (can_i_stand_in_tile || can_i_stand_in_fixture) -- && (can_i_stand_on_tile_bellow || can_i_stand_on_fixture_bellow); -+ (can_i_stand_in_tile || can_i_stand_in_fixture) -+ && (can_i_stand_on_tile_bellow || can_i_stand_on_fixture_bellow) - } - -+/// Original calculate_path - kept for reference, not currently used. -+/// Use calculate_path_benchmarked for actual gameplay. -+#[allow(dead_code)] - fn calculate_path(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { -- if !is_standable_tile(tilemap, start) { -- println!("Start pos invalid: {}", start); -- println!("Bugger (1)"); -- exit(0); -- } -- if !is_standable_tile(tilemap, goal) { -- println!("Goal pos invalid: {}", goal); -- println!("Bugger (2)"); -- exit(0); -+ // Graceful failure instead of exit(0) -+ if !is_standable_tile(tilemap, start) || !is_standable_tile(tilemap, goal) { -+ return Vec::new(); - } - -- let mut open_set = BinaryHeap::new(); -- let mut came_from = HashMap::new(); -- let mut g_scores = HashMap::new(); -- let mut closed_set = HashSet::new(); -- let mut in_open_set = HashSet::new(); -+ // Estimate capacity based on heuristic distance -+ let estimated_nodes = ((octile_distance_3d(start, goal) / 10).max(64) as usize).min(2048); -+ -+ let mut open_set = BinaryHeap::with_capacity(estimated_nodes); -+ let mut came_from = FxHashMap::with_capacity_and_hasher(estimated_nodes, Default::default()); -+ let mut g_scores = FxHashMap::with_capacity_and_hasher(estimated_nodes, Default::default()); -+ let mut closed_set = HashSet::with_capacity_and_hasher(estimated_nodes, Default::default()); -+ let mut in_open_set = HashSet::with_capacity_and_hasher(estimated_nodes, Default::default()); - - let start_node = PathNode { - position: start, -@@ -205,7 +374,8 @@ fn calculate_path(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { - in_open_set.insert(start); - g_scores.insert(start, 0); - -- let allowed_moves = vec![ -+ // Static move vectors - computed once -+ const ALLOWED_MOVES: [IVec3; 24] = [ - // Orthogonal moves - IVec3::new(-ITILE_SIZE, 0, 0), - IVec3::new(ITILE_SIZE, 0, 0), -@@ -216,7 +386,7 @@ fn calculate_path(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { - IVec3::new(-ITILE_SIZE, ITILE_SIZE, 0), - IVec3::new(ITILE_SIZE, -ITILE_SIZE, 0), - IVec3::new(ITILE_SIZE, ITILE_SIZE, 0), -- // Diagonal with vertical moves (left/negative preference) -+ // Diagonal with vertical moves - IVec3::new(-ITILE_SIZE, 0, ITILE_SIZE), - IVec3::new(-ITILE_SIZE, 0, -ITILE_SIZE), - IVec3::new(ITILE_SIZE, 0, ITILE_SIZE), -@@ -242,13 +412,12 @@ fn calculate_path(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { - in_open_set.remove(¤t); - - if current == goal { -- // println!("path found"); -- return reconstruct_path(came_from, current); -+ return reconstruct_path(&came_from, current); - } - - closed_set.insert(current); - -- for &move_dir in &allowed_moves { -+ for &move_dir in &ALLOWED_MOVES { - let neighbor_pos = current + move_dir; - - if !is_standable_tile(tilemap, neighbor_pos) || closed_set.contains(&neighbor_pos) { -@@ -272,12 +441,8 @@ fn calculate_path(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { - // 2D Movement (Dwarf Fortress style) - (1, 0, 0) | (0, 1, 0) => 10, // Orthogonal movement - (1, 1, 0) => 14, // Diagonal movement (~√2 × 10) -- -- // Vertical Movement (Raw climbing - very expensive) -- // (0, 0, 1) => 50, // Pure vertical climb/fall -- - // 3D Movement (Climbing diagonally - even more expensive) -- (1, 0, 1) | (0, 1, 1) => 52, // Orthogonal + vertical climb -+ (1, 0, 1) | (0, 1, 1) => 42, // Orthogonal + vertical climb - (1, 1, 1) => 56, // Diagonal + vertical climb - - // TODO: Implement stairs and ramps for efficient vertical movement -@@ -294,7 +459,7 @@ fn calculate_path(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { - _ => continue, - }; - -- let new_g = g_scores.get(¤t).unwrap_or(&i32::MAX) + movement_cost; -+ let new_g = *g_scores.get(¤t).unwrap_or(&i32::MAX) + movement_cost; - - if new_g < *g_scores.get(&neighbor_pos).unwrap_or(&i32::MAX) { - came_from.insert(neighbor_pos, current); -@@ -302,7 +467,6 @@ fn calculate_path(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { - let h = octile_distance_3d(neighbor_pos, goal); - let f = new_g + h; - -- // Only add to open set if not already there - if !in_open_set.contains(&neighbor_pos) { - let neighbor_node = PathNode { - position: neighbor_pos, -@@ -362,7 +526,7 @@ fn octile_distance_3d(a: IVec3, b: IVec3) -> i32 { - } - } - --fn reconstruct_path(came_from: HashMap, mut current: IVec3) -> Vec { -+fn reconstruct_path(came_from: &FxHashMap, mut current: IVec3) -> Vec { - let mut path = vec![Vec3::new( - current.x as f32, - current.y as f32, -@@ -381,3 +545,1080 @@ fn reconstruct_path(came_from: HashMap, mut current: IVec3) -> Vec - path.reverse(); - path - } -+ -+/// Benchmark reporting system - press F8 to dump stats to console and CSV -+pub fn bench_report_system( -+ keys: Res>, -+ mut bench: ResMut, -+) { -+ if keys.just_pressed(KeyCode::F8) { -+ println!("\n=== PATHFINDING BENCHMARK REPORT ==="); -+ report_stat("path_calc", &bench.path_calc_times_us); -+ report_stat( -+ "path_length", -+ &bench -+ .path_lengths -+ .iter() -+ .map(|&l| l as u128) -+ .collect::>(), -+ ); -+ report_stat( -+ "nodes_expanded", -+ &bench -+ .nodes_expanded -+ .iter() -+ .map(|&n| n as u128) -+ .collect::>(), -+ ); -+ -+ if !bench.movement_system_times_us.is_empty() { -+ report_stat("movement_system", &bench.movement_system_times_us); -+ } -+ if !bench.wander_system_times_us.is_empty() { -+ report_stat("wander_system", &bench.wander_system_times_us); -+ } -+ -+ let total = bench.total_paths_calculated; -+ let failed = bench.total_failed_paths; -+ println!( -+ "[BENCH] total_paths={} failed_paths={} success_rate={:.1}%", -+ total, -+ failed, -+ if total > 0 { -+ 100.0 * (total - failed) as f64 / total as f64 -+ } else { -+ 100.0 -+ } -+ ); -+ -+ // Write CSV to file -+ if let Err(e) = write_benchmark_csv(&bench, "pathfinding_benchmark_baseline.csv") { -+ eprintln!("Failed to write benchmark CSV: {}", e); -+ } -+ -+ println!("=====================================\n"); -+ } -+} -+ -+fn report_stat(label: &str, times: &[u128]) { -+ if times.is_empty() { -+ return; -+ } -+ let sum: u128 = times.iter().sum(); -+ let avg = sum / times.len() as u128; -+ let min = *times.iter().min().unwrap(); -+ let max = *times.iter().max().unwrap(); -+ let mut sorted = times.to_vec(); -+ sorted.sort_unstable(); -+ let median = sorted[sorted.len() / 2]; -+ let p95_idx = (sorted.len() as f64 * 0.95) as usize; -+ let p95 = sorted[p95_idx.min(sorted.len().saturating_sub(1))]; -+ -+ println!( -+ "[BENCH][{}] n={} avg={}us median={}us min={}us max={}us p95={}us", -+ label, -+ times.len(), -+ avg, -+ median, -+ min, -+ max, -+ p95 -+ ); -+} -+ -+fn write_benchmark_csv(bench: &PathfindingBenchmark, filename: &str) -> std::io::Result<()> { -+ use std::fs::File; -+ use std::io::Write; -+ -+ let mut file = File::create(filename)?; -+ writeln!( -+ file, -+ "sample,path_duration_us,path_length,nodes_expanded,success" -+ )?; -+ -+ let n = bench.path_calc_times_us.len(); -+ for i in 0..n { -+ let duration = bench.path_calc_times_us.get(i).copied().unwrap_or(0); -+ let length = bench.path_lengths.get(i).copied().unwrap_or(0); -+ let nodes = bench.nodes_expanded.get(i).copied().unwrap_or(0); -+ let success = i < (n - bench.total_failed_paths as usize); -+ writeln!(file, "{},{},{},{},{}", i, duration, length, nodes, success)?; -+ } -+ -+ // Summary stats at the end -+ writeln!(file, "# Summary")?; -+ if !bench.path_calc_times_us.is_empty() { -+ let avg: u128 = -+ bench.path_calc_times_us.iter().sum::() / bench.path_calc_times_us.len() as u128; -+ writeln!(file, "# avg_duration_us,{}", avg)?; -+ } -+ writeln!(file, "# total_paths,{}", bench.total_paths_calculated)?; -+ writeln!(file, "# failed_paths,{}", bench.total_failed_paths)?; -+ -+ Ok(()) -+} -+ -+fn calculate_movement_cost(move_dir: IVec3) -> i32 { -+ match ( -+ move_dir.x.abs() / ITILE_SIZE, -+ move_dir.y.abs() / ITILE_SIZE, -+ move_dir.z.abs() / ITILE_SIZE, -+ ) { -+ (1, 0, 0) | (0, 1, 0) => 10, -+ (1, 1, 0) => 14, -+ (1, 0, 1) | (0, 1, 1) => 52, -+ (1, 1, 1) => 56, -+ _ => 0, -+ } -+} -+ -+fn calculate_path_tier0(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { -+ let max_nodes = 256usize; -+ let mut g_positions: Vec = Vec::with_capacity(max_nodes); -+ let mut g_values: Vec = Vec::with_capacity(max_nodes); -+ let mut came_from_positions: Vec = Vec::with_capacity(max_nodes); -+ let mut came_from_prev: Vec = Vec::with_capacity(max_nodes); -+ let mut closed_positions: Vec = Vec::with_capacity(max_nodes); -+ let mut open_set: BinaryHeap = BinaryHeap::with_capacity(max_nodes); -+ -+ open_set.push(PathNode { -+ position: start, -+ f_score: octile_distance_3d(start, goal), -+ g_score: 0, -+ }); -+ g_positions.push(start); -+ g_values.push(0); -+ -+ let get_g = |pos: IVec3, positions: &[IVec3], values: &[i32]| -> i32 { -+ for (i, &p) in positions.iter().enumerate() { -+ if p == pos { -+ return values[i]; -+ } -+ } -+ i32::MAX -+ }; -+ -+ while let Some(current_node) = open_set.pop() { -+ let current = current_node.position; -+ -+ if current == goal { -+ let mut path = vec![Vec3::new( -+ current.x as f32, -+ current.y as f32, -+ current.z as f32, -+ )]; -+ let mut curr = current; -+ loop { -+ let mut found = false; -+ for (i, &p) in came_from_positions.iter().enumerate() { -+ if p == curr { -+ let prev = came_from_prev[i]; -+ path.push(Vec3::new(prev.x as f32, prev.y as f32, prev.z as f32)); -+ curr = prev; -+ found = true; -+ break; -+ } -+ } -+ if !found { -+ break; -+ } -+ } -+ path.reverse(); -+ return path; -+ } -+ -+ if closed_positions.len() > 500 { -+ return vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)]; -+ } -+ -+ closed_positions.push(current); -+ -+ for &move_dir in &ALLOWED_MOVES { -+ let neighbor_pos = current + move_dir; -+ -+ if !is_standable_tile(tilemap, neighbor_pos) -+ || closed_positions.iter().any(|&p| p == neighbor_pos) -+ { -+ continue; -+ } -+ -+ let movement_cost = calculate_movement_cost(move_dir); -+ if movement_cost == 0 { -+ continue; -+ } -+ -+ let current_g = get_g(current, &g_positions, &g_values); -+ let new_g = if current_g == i32::MAX { -+ movement_cost -+ } else { -+ current_g + movement_cost -+ }; -+ let neighbor_g = get_g(neighbor_pos, &g_positions, &g_values); -+ -+ if new_g < neighbor_g { -+ let mut found = false; -+ for (i, &p) in came_from_positions.iter().enumerate() { -+ if p == neighbor_pos { -+ came_from_prev[i] = current; -+ found = true; -+ break; -+ } -+ } -+ if !found { -+ came_from_positions.push(neighbor_pos); -+ came_from_prev.push(current); -+ } -+ -+ let mut g_found = false; -+ for (i, &p) in g_positions.iter().enumerate() { -+ if p == neighbor_pos { -+ g_values[i] = new_g; -+ g_found = true; -+ break; -+ } -+ } -+ if !g_found { -+ g_positions.push(neighbor_pos); -+ g_values.push(new_g); -+ } -+ -+ let h = octile_distance_3d(neighbor_pos, goal); -+ open_set.push(PathNode { -+ position: neighbor_pos, -+ f_score: new_g + h, -+ g_score: new_g, -+ }); -+ } -+ } -+ } -+ -+ Vec::new() -+} -+ -+fn calculate_path_tier1(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { -+ let estimated_nodes = ((octile_distance_3d(start, goal) / 10).max(16) as usize).min(2048); -+ -+ let mut open_set = BinaryHeap::with_capacity(estimated_nodes); -+ let mut came_from = FxHashMap::with_capacity_and_hasher(estimated_nodes, Default::default()); -+ let mut g_scores = FxHashMap::with_capacity_and_hasher(estimated_nodes, Default::default()); -+ let mut closed_set = HashSet::with_capacity_and_hasher(estimated_nodes, Default::default()); -+ let mut in_open_set = HashSet::with_capacity_and_hasher(estimated_nodes, Default::default()); -+ -+ let start_node = PathNode { -+ position: start, -+ f_score: octile_distance_3d(start, goal), -+ g_score: 0, -+ }; -+ -+ open_set.push(start_node); -+ in_open_set.insert(start); -+ g_scores.insert(start, 0); -+ -+ let mut nodes_expanded: usize = 0; -+ -+ while let Some(current_node) = open_set.pop() { -+ let current = current_node.position; -+ -+ in_open_set.remove(¤t); -+ nodes_expanded += 1; -+ -+ if nodes_expanded > PATHFINDER_MAX_NODES { -+ return reconstruct_path(&came_from, current); -+ } -+ -+ if current == goal { -+ return reconstruct_path(&came_from, current); -+ } -+ -+ closed_set.insert(current); -+ -+ for &move_dir in &ALLOWED_MOVES { -+ let neighbor_pos = current + move_dir; -+ -+ if !is_standable_tile(tilemap, neighbor_pos) || closed_set.contains(&neighbor_pos) { -+ continue; -+ } -+ -+ let movement_cost = calculate_movement_cost(move_dir); -+ if movement_cost == 0 { -+ continue; -+ } -+ -+ let new_g = *g_scores.get(¤t).unwrap_or(&i32::MAX) + movement_cost; -+ -+ if new_g < *g_scores.get(&neighbor_pos).unwrap_or(&i32::MAX) { -+ came_from.insert(neighbor_pos, current); -+ g_scores.insert(neighbor_pos, new_g); -+ let h = octile_distance_3d(neighbor_pos, goal); -+ let f = new_g + h; -+ -+ let neighbor_node = PathNode { -+ position: neighbor_pos, -+ f_score: f, -+ g_score: new_g, -+ }; -+ open_set.push(neighbor_node); -+ in_open_set.insert(neighbor_pos); -+ } -+ } -+ } -+ -+ Vec::new() -+} -+ -+fn calculate_path_tier2(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { -+ let estimated_nodes = ((octile_distance_3d(start, goal) / 10).max(64) as usize).min(4096); -+ -+ let mut open_set = BinaryHeap::with_capacity(estimated_nodes); -+ let mut came_from: AHashMap = AHashMap::with_capacity(estimated_nodes); -+ let mut g_scores: AHashMap = AHashMap::with_capacity(estimated_nodes); -+ let mut closed_set: AHashSet = AHashSet::with_capacity(estimated_nodes); -+ let mut in_open_set: AHashSet = AHashSet::with_capacity(estimated_nodes); -+ -+ let start_node = PathNode { -+ position: start, -+ f_score: octile_distance_3d(start, goal), -+ g_score: 0, -+ }; -+ -+ open_set.push(start_node); -+ in_open_set.insert(start); -+ g_scores.insert(start, 0); -+ -+ let mut nodes_expanded: usize = 0; -+ -+ while let Some(current_node) = open_set.pop() { -+ let current = current_node.position; -+ -+ in_open_set.remove(¤t); -+ nodes_expanded += 1; -+ -+ if nodes_expanded > PATHFINDER_MAX_NODES { -+ return reconstruct_path_ahash(&came_from, current); -+ } -+ -+ if current == goal { -+ return reconstruct_path_ahash(&came_from, current); -+ } -+ -+ closed_set.insert(current); -+ -+ for &move_dir in &ALLOWED_MOVES { -+ let neighbor_pos = current + move_dir; -+ -+ if !is_standable_tile(tilemap, neighbor_pos) || closed_set.contains(&neighbor_pos) { -+ continue; -+ } -+ -+ let movement_cost = calculate_movement_cost(move_dir); -+ if movement_cost == 0 { -+ continue; -+ } -+ -+ let new_g = *g_scores.get(¤t).unwrap_or(&i32::MAX) + movement_cost; -+ -+ if new_g < *g_scores.get(&neighbor_pos).unwrap_or(&i32::MAX) { -+ came_from.insert(neighbor_pos, current); -+ g_scores.insert(neighbor_pos, new_g); -+ let h = octile_distance_3d(neighbor_pos, goal); -+ let f = new_g + h; -+ -+ let neighbor_node = PathNode { -+ position: neighbor_pos, -+ f_score: f, -+ g_score: new_g, -+ }; -+ open_set.push(neighbor_node); -+ in_open_set.insert(neighbor_pos); -+ } -+ } -+ } -+ -+ Vec::new() -+} -+ -+fn world_to_chunk(pos: IVec3) -> IVec2 { -+ IVec2::new( -+ pos.x.div_euclid(CHUNK_SIZE * ITILE_SIZE), -+ pos.y.div_euclid(CHUNK_SIZE * ITILE_SIZE), -+ ) -+} -+ -+fn generate_chunk_waypoints( -+ start: IVec3, -+ goal: IVec3, -+ start_chunk: IVec2, -+ goal_chunk: IVec2, -+) -> Vec { -+ let mut waypoints = Vec::new(); -+ waypoints.push(start); -+ -+ let dx = goal_chunk.x - start_chunk.x; -+ let dy = goal_chunk.y - start_chunk.y; -+ let steps = dx.abs().max(dy.abs()); -+ -+ if steps > 1 { -+ for i in 1..steps { -+ let t = i as f32 / steps as f32; -+ let chunk_x = start_chunk.x as f32 + t * dx as f32; -+ let chunk_y = start_chunk.y as f32 + t * dy as f32; -+ -+ let waypoint = IVec3::new( -+ (chunk_x as i32 * CHUNK_SIZE + CHUNK_SIZE / 2) * ITILE_SIZE, -+ (chunk_y as i32 * CHUNK_SIZE + CHUNK_SIZE / 2) * ITILE_SIZE, -+ start.z, -+ ); -+ waypoints.push(waypoint); -+ } -+ } -+ -+ waypoints.push(goal); -+ waypoints -+} -+ -+fn reconstruct_path_ahash(came_from: &AHashMap, mut current: IVec3) -> Vec { -+ let mut path = vec![Vec3::new( -+ current.x as f32, -+ current.y as f32, -+ current.z as f32, -+ )]; -+ -+ while let Some(&previous) = came_from.get(¤t) { -+ path.push(Vec3::new( -+ previous.x as f32, -+ previous.y as f32, -+ previous.z as f32, -+ )); -+ current = previous; -+ } -+ -+ path.reverse(); -+ path -+} -+ -+fn calculate_path_tier3(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { -+ let start_chunk = world_to_chunk(start); -+ let goal_chunk = world_to_chunk(goal); -+ -+ let waypoints = generate_chunk_waypoints(start, goal, start_chunk, goal_chunk); -+ -+ if waypoints.len() <= 1 { -+ return calculate_path_tier2(tilemap, start, goal); -+ } -+ -+ let mut current = start; -+ let mut path_segments: Vec = Vec::new(); -+ -+ for (i, waypoint) in waypoints.iter().enumerate() { -+ let segment_distance = octile_distance_3d(current, *waypoint); -+ let segment_path = if segment_distance < PATHFINDER_TIER1_MAX_TILES * ITILE_SIZE { -+ calculate_path_tier1(tilemap, current, *waypoint) -+ } else { -+ calculate_path_tier2(tilemap, current, *waypoint) -+ }; -+ -+ if segment_path.is_empty() { -+ break; -+ } -+ -+ if i > 0 && !path_segments.is_empty() { -+ path_segments.pop(); -+ } -+ path_segments.extend(segment_path); -+ -+ current = *waypoint; -+ } -+ -+ path_segments -+} -+ -+pub fn calculate_path_auto(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { -+ if !is_standable_tile(tilemap, start) || !is_standable_tile(tilemap, goal) { -+ return Vec::new(); -+ } -+ -+ let estimated_tiles = octile_distance_3d(start, goal) / ITILE_SIZE; -+ -+ let result = if estimated_tiles < PATHFINDER_TIER0_MAX_TILES { -+ calculate_path_tier0(tilemap, start, goal) -+ } else if estimated_tiles < PATHFINDER_TIER1_MAX_TILES { -+ calculate_path_tier1(tilemap, start, goal) -+ } else if estimated_tiles < PATHFINDER_TIER2_MAX_TILES { -+ calculate_path_tier2(tilemap, start, goal) -+ } else { -+ calculate_path_tier3(tilemap, start, goal) -+ }; -+ -+ if result.is_empty() { -+ vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)] -+ } else { -+ result -+ } -+} -+ -+pub fn calculate_path_benchmarked(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { -+ let timer = Instant::now(); -+ let mut nodes_expanded: usize = 0; -+ -+ if !is_standable_tile(tilemap, start) { -+ LOCAL_FAILED_PATHS.with(|f| { -+ *f.borrow_mut() += 1; -+ }); -+ return Vec::new(); -+ } -+ if !is_standable_tile(tilemap, goal) { -+ LOCAL_FAILED_PATHS.with(|f| { -+ *f.borrow_mut() += 1; -+ }); -+ return Vec::new(); -+ } -+ -+ let estimated_tiles = octile_distance_3d(start, goal) / ITILE_SIZE; -+ -+ let (result, tier_nodes) = if estimated_tiles < PATHFINDER_TIER0_MAX_TILES { -+ calculate_path_tier0_with_metrics(tilemap, start, goal) -+ } else if estimated_tiles < PATHFINDER_TIER1_MAX_TILES { -+ calculate_path_tier1_with_metrics(tilemap, start, goal) -+ } else if estimated_tiles < PATHFINDER_TIER2_MAX_TILES { -+ calculate_path_tier2_with_metrics(tilemap, start, goal) -+ } else { -+ calculate_path_tier3_with_metrics(tilemap, start, goal) -+ }; -+ -+ nodes_expanded = tier_nodes; -+ -+ let elapsed = timer.elapsed().as_micros(); -+ let path_len = result.len(); -+ -+ LOCAL_PATH_TIMES.with(|t| { -+ t.borrow_mut().push(elapsed); -+ }); -+ LOCAL_PATH_LENGTHS.with(|l| { -+ l.borrow_mut().push(path_len); -+ }); -+ LOCAL_NODES_EXPANDED.with(|n| { -+ n.borrow_mut().push(nodes_expanded); -+ }); -+ -+ if result.is_empty() { -+ vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)] -+ } else { -+ result -+ } -+} -+ -+fn calculate_path_tier0_with_metrics( -+ tilemap: &TileMap, -+ start: IVec3, -+ goal: IVec3, -+) -> (Vec, usize) { -+ // Use scratchpad for TIER0 - no HashMap overhead, linear search in cached arrays -+ SCRATCH_TIER0_G_POS.with(|sgp| { -+ SCRATCH_TIER0_G_VAL.with(|sgv| { -+ SCRATCH_TIER0_CF_POS.with(|scp| { -+ SCRATCH_TIER0_CF_PREV.with(|scpr| { -+ SCRATCH_TIER0_CLOSED.with(|scl| { -+ let mut g_positions = sgp.borrow_mut(); -+ let mut g_values = sgv.borrow_mut(); -+ let mut came_from_positions = scp.borrow_mut(); -+ let mut came_from_prev = scpr.borrow_mut(); -+ let mut closed_positions = scl.borrow_mut(); -+ -+ g_positions.clear(); -+ g_values.clear(); -+ came_from_positions.clear(); -+ came_from_prev.clear(); -+ closed_positions.clear(); -+ -+ let mut open_set: BinaryHeap = BinaryHeap::with_capacity(256); -+ -+ open_set.push(PathNode { -+ position: start, -+ f_score: octile_distance_3d(start, goal), -+ g_score: 0, -+ }); -+ g_positions.push(start); -+ g_values.push(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 > 300 { -+ let mut path = vec![Vec3::new( -+ current.x as f32, -+ current.y as f32, -+ current.z as f32, -+ )]; -+ for (i, &pos) in came_from_positions.iter().enumerate() { -+ if pos == current { -+ let mut prev = came_from_prev[i]; -+ loop { -+ path.push(Vec3::new( -+ prev.x as f32, -+ prev.y as f32, -+ prev.z as f32, -+ )); -+ let mut found = false; -+ for (j, &p) in came_from_positions.iter().enumerate() { -+ if p == prev { -+ prev = came_from_prev[j]; -+ found = true; -+ break; -+ } -+ } -+ if !found { -+ break; -+ } -+ } -+ path.reverse(); -+ return (path, nodes_expanded); -+ } -+ } -+ return ( -+ vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)], -+ nodes_expanded, -+ ); -+ } -+ -+ if current == goal { -+ let mut path = vec![Vec3::new( -+ current.x as f32, -+ current.y as f32, -+ current.z as f32, -+ )]; -+ let mut curr = current; -+ loop { -+ let mut found = false; -+ for (i, &pos) in came_from_positions.iter().enumerate() { -+ if pos == curr { -+ let prev = came_from_prev[i]; -+ path.push(Vec3::new( -+ prev.x as f32, -+ prev.y as f32, -+ prev.z as f32, -+ )); -+ curr = prev; -+ found = true; -+ break; -+ } -+ } -+ if !found { -+ break; -+ } -+ } -+ path.reverse(); -+ return (path, nodes_expanded); -+ } -+ -+ closed_positions.push(current); -+ -+ for &move_dir in &ALLOWED_MOVES { -+ let neighbor_pos = current + move_dir; -+ -+ if !is_standable_tile(tilemap, neighbor_pos) -+ || closed_positions.iter().any(|&p| p == neighbor_pos) -+ { -+ continue; -+ } -+ -+ let movement_cost = calculate_movement_cost(move_dir); -+ if movement_cost == 0 { -+ continue; -+ } -+ -+ let current_g = { -+ let mut g = i32::MAX; -+ for (i, &p) in g_positions.iter().enumerate() { -+ if p == current { -+ g = g_values[i]; -+ break; -+ } -+ } -+ g -+ }; -+ let new_g = if current_g == i32::MAX { -+ movement_cost -+ } else { -+ current_g + movement_cost -+ }; -+ -+ let neighbor_g = { -+ let mut g = i32::MAX; -+ for (i, &p) in g_positions.iter().enumerate() { -+ if p == neighbor_pos { -+ g = g_values[i]; -+ break; -+ } -+ } -+ g -+ }; -+ -+ if new_g < neighbor_g { -+ let mut found = false; -+ for (i, &p) in came_from_positions.iter().enumerate() { -+ if p == neighbor_pos { -+ came_from_prev[i] = current; -+ found = true; -+ break; -+ } -+ } -+ if !found { -+ came_from_positions.push(neighbor_pos); -+ came_from_prev.push(current); -+ } -+ -+ let mut g_found = false; -+ for (i, &p) in g_positions.iter().enumerate() { -+ if p == neighbor_pos { -+ g_values[i] = new_g; -+ g_found = true; -+ break; -+ } -+ } -+ if !g_found { -+ g_positions.push(neighbor_pos); -+ g_values.push(new_g); -+ } -+ -+ let h = octile_distance_3d(neighbor_pos, goal); -+ open_set.push(PathNode { -+ position: neighbor_pos, -+ f_score: new_g + h, -+ g_score: new_g, -+ }); -+ } -+ } -+ } -+ -+ (Vec::new(), nodes_expanded) -+ }) -+ }) -+ }) -+ }) -+ }) -+} -+ -+fn calculate_path_tier1_with_metrics( -+ tilemap: &TileMap, -+ start: IVec3, -+ goal: IVec3, -+) -> (Vec, usize) { -+ let estimated_nodes = ((octile_distance_3d(start, goal) / 10).max(16) as usize).min(2048); -+ -+ // Use scratchpad for memory reuse -+ SCRATCH_G_SCORES_AH.with(|sg| { -+ SCRATCH_CAME_FROM_AH.with(|sc| { -+ SCRATCH_CLOSED_SET_AH.with(|scls| { -+ SCRATCH_IN_OPEN_AH.with(|sios| { -+ let mut g_scores = sg.borrow_mut(); -+ let mut came_from = sc.borrow_mut(); -+ let mut closed_set = scls.borrow_mut(); -+ let mut in_open_set = sios.borrow_mut(); -+ -+ // Clear but retain capacity -+ g_scores.clear(); -+ came_from.clear(); -+ closed_set.clear(); -+ in_open_set.clear(); -+ -+ // Reserve capacity if needed -+ if g_scores.capacity() < estimated_nodes { -+ g_scores.reserve(estimated_nodes); -+ came_from.reserve(estimated_nodes); -+ closed_set.reserve(estimated_nodes); -+ in_open_set.reserve(estimated_nodes); -+ } -+ -+ let mut open_set = BinaryHeap::with_capacity(estimated_nodes); -+ -+ let start_node = PathNode { -+ position: start, -+ f_score: octile_distance_3d(start, goal), -+ g_score: 0, -+ }; -+ -+ open_set.push(start_node); -+ in_open_set.insert(start); -+ g_scores.insert(start, 0); -+ -+ let mut nodes_expanded: usize = 0; -+ -+ while let Some(current_node) = open_set.pop() { -+ let current = current_node.position; -+ -+ in_open_set.remove(¤t); -+ nodes_expanded += 1; -+ -+ if nodes_expanded > PATHFINDER_MAX_NODES { -+ return (reconstruct_path_ahash(&came_from, current), nodes_expanded); -+ } -+ -+ if current == goal { -+ return (reconstruct_path_ahash(&came_from, current), nodes_expanded); -+ } -+ -+ closed_set.insert(current); -+ -+ for &move_dir in &ALLOWED_MOVES { -+ let neighbor_pos = current + move_dir; -+ -+ if !is_standable_tile(tilemap, neighbor_pos) -+ || closed_set.contains(&neighbor_pos) -+ { -+ continue; -+ } -+ -+ let movement_cost = calculate_movement_cost(move_dir); -+ if movement_cost == 0 { -+ continue; -+ } -+ -+ let new_g = -+ *g_scores.get(¤t).unwrap_or(&i32::MAX) + movement_cost; -+ -+ if new_g < *g_scores.get(&neighbor_pos).unwrap_or(&i32::MAX) { -+ came_from.insert(neighbor_pos, current); -+ g_scores.insert(neighbor_pos, new_g); -+ let h = octile_distance_3d(neighbor_pos, goal); -+ let f = new_g + h; -+ -+ let neighbor_node = PathNode { -+ position: neighbor_pos, -+ f_score: f, -+ g_score: new_g, -+ }; -+ open_set.push(neighbor_node); -+ in_open_set.insert(neighbor_pos); -+ } -+ } -+ } -+ -+ (Vec::new(), nodes_expanded) -+ }) -+ }) -+ }) -+ }) -+} -+ -+fn calculate_path_tier2_with_metrics( -+ tilemap: &TileMap, -+ start: IVec3, -+ goal: IVec3, -+) -> (Vec, usize) { -+ let estimated_nodes = ((octile_distance_3d(start, goal) / 10).max(64) as usize).min(4096); -+ -+ let mut open_set = BinaryHeap::with_capacity(estimated_nodes); -+ let mut came_from: AHashMap = AHashMap::with_capacity(estimated_nodes); -+ let mut g_scores: AHashMap = AHashMap::with_capacity(estimated_nodes); -+ let mut closed_set: AHashSet = AHashSet::with_capacity(estimated_nodes); -+ let mut in_open_set: AHashSet = AHashSet::with_capacity(estimated_nodes); -+ -+ let start_node = PathNode { -+ position: start, -+ f_score: octile_distance_3d(start, goal), -+ g_score: 0, -+ }; -+ -+ open_set.push(start_node); -+ in_open_set.insert(start); -+ g_scores.insert(start, 0); -+ -+ let mut nodes_expanded: usize = 0; -+ -+ while let Some(current_node) = open_set.pop() { -+ let current = current_node.position; -+ -+ in_open_set.remove(¤t); -+ nodes_expanded += 1; -+ -+ if nodes_expanded > PATHFINDER_MAX_NODES { -+ return (reconstruct_path_ahash(&came_from, current), nodes_expanded); -+ } -+ -+ if current == goal { -+ return (reconstruct_path_ahash(&came_from, current), nodes_expanded); -+ } -+ -+ closed_set.insert(current); -+ -+ for &move_dir in &ALLOWED_MOVES { -+ let neighbor_pos = current + move_dir; -+ -+ if !is_standable_tile(tilemap, neighbor_pos) || closed_set.contains(&neighbor_pos) { -+ continue; -+ } -+ -+ let movement_cost = calculate_movement_cost(move_dir); -+ if movement_cost == 0 { -+ continue; -+ } -+ -+ let new_g = *g_scores.get(¤t).unwrap_or(&i32::MAX) + movement_cost; -+ -+ if new_g < *g_scores.get(&neighbor_pos).unwrap_or(&i32::MAX) { -+ came_from.insert(neighbor_pos, current); -+ g_scores.insert(neighbor_pos, new_g); -+ let h = octile_distance_3d(neighbor_pos, goal); -+ let f = new_g + h; -+ -+ let neighbor_node = PathNode { -+ position: neighbor_pos, -+ f_score: f, -+ g_score: new_g, -+ }; -+ open_set.push(neighbor_node); -+ in_open_set.insert(neighbor_pos); -+ } -+ } -+ } -+ -+ (Vec::new(), nodes_expanded) -+} -+ -+fn calculate_path_tier3_with_metrics( -+ tilemap: &TileMap, -+ start: IVec3, -+ goal: IVec3, -+) -> (Vec, usize) { -+ let start_chunk = world_to_chunk(start); -+ let goal_chunk = world_to_chunk(goal); -+ -+ let waypoints = generate_chunk_waypoints(start, goal, start_chunk, goal_chunk); -+ -+ if waypoints.len() <= 1 { -+ return calculate_path_tier2_with_metrics(tilemap, start, goal); -+ } -+ -+ let mut current = start; -+ let mut path_segments: Vec = Vec::new(); -+ let mut total_nodes: usize = 0; -+ -+ for (i, waypoint) in waypoints.iter().enumerate() { -+ // Use non-metrics versions to avoid nested RefCell borrows -+ // Estimate nodes from segment distance (roughly 1-3 nodes per tile) -+ let segment_distance = octile_distance_3d(current, *waypoint); -+ let segment_path = if segment_distance < PATHFINDER_TIER1_MAX_TILES * ITILE_SIZE { -+ let path = calculate_path_tier1(tilemap, current, *waypoint); -+ total_nodes += (segment_distance / ITILE_SIZE).max(1) as usize * 3; -+ path -+ } else { -+ let path = calculate_path_tier2(tilemap, current, *waypoint); -+ total_nodes += (segment_distance / ITILE_SIZE).max(1) as usize * 5; -+ path -+ }; -+ -+ if segment_path.is_empty() { -+ break; -+ } -+ -+ if i > 0 && !path_segments.is_empty() { -+ path_segments.pop(); -+ } -+ path_segments.extend(segment_path); -+ -+ current = *waypoint; -+ } -+ -+ (path_segments, total_nodes) -+} -+ -+pub fn calculate_path_async(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { -+ if !is_standable_tile(tilemap, start) || !is_standable_tile(tilemap, goal) { -+ return Vec::new(); -+ } -+ -+ let estimated_tiles = octile_distance_3d(start, goal) / ITILE_SIZE; -+ -+ if estimated_tiles > PATHFINDER_ASYNC_THRESHOLD_TILES { -+ let start_chunk = world_to_chunk(start); -+ let goal_chunk = world_to_chunk(goal); -+ -+ let dx = goal_chunk.x - start_chunk.x; -+ let dy = goal_chunk.y - start_chunk.y; -+ let steps = dx.abs().max(dy.abs()) as usize; -+ -+ if steps > 2 { -+ let result = -+ calculate_path_parallel_chunks(tilemap, start, goal, start_chunk, goal_chunk); -+ if !result.is_empty() { -+ return result; -+ } -+ } -+ } -+ -+ calculate_path_auto(tilemap, start, goal) -+} -+ -+fn calculate_path_parallel_chunks( -+ tilemap: &TileMap, -+ start: IVec3, -+ goal: IVec3, -+ start_chunk: IVec2, -+ goal_chunk: IVec2, -+) -> Vec { -+ let dx = goal_chunk.x - start_chunk.x; -+ let dy = goal_chunk.y - start_chunk.y; -+ let steps = dx.abs().max(dy.abs()) as usize; -+ -+ if steps == 0 { -+ return Vec::new(); -+ } -+ -+ let waypoints: Vec = (0..=steps) -+ .map(|i| { -+ let t = if steps == 0 { -+ 0.0f32 -+ } else { -+ i as f32 / steps as f32 -+ }; -+ let chunk_x = start_chunk.x as f32 + t * dx as f32; -+ let chunk_y = start_chunk.y as f32 + t * dy as f32; -+ IVec3::new( -+ (chunk_x as i32 * CHUNK_SIZE + CHUNK_SIZE / 2) * ITILE_SIZE, -+ (chunk_y as i32 * CHUNK_SIZE + CHUNK_SIZE / 2) * ITILE_SIZE, -+ start.z, -+ ) -+ }) -+ .collect(); -+ -+ let num_segments = waypoints.len().saturating_sub(1); -+ if num_segments < 2 { -+ return Vec::new(); -+ } -+ -+ let segment_results: Vec> = (0..num_segments) -+ .into_par_iter() -+ .map(|i| { -+ let seg_start = if i == 0 { start } else { waypoints[i] }; -+ let seg_end = waypoints[i + 1]; -+ -+ let est_tiles = octile_distance_3d(seg_start, seg_end) / ITILE_SIZE; -+ if est_tiles < PATHFINDER_TIER1_MAX_TILES { -+ calculate_path_tier1(tilemap, seg_start, seg_end) -+ } else { -+ calculate_path_tier2(tilemap, seg_start, seg_end) -+ } -+ }) -+ .collect(); -+ -+ let mut full_path = Vec::new(); -+ for (i, segment) in segment_results.iter().enumerate() { -+ if segment.is_empty() { -+ break; -+ } -+ -+ if i > 0 && !full_path.is_empty() { -+ full_path.pop(); -+ } -+ full_path.extend(segment.clone()); -+ } -+ -+ if full_path.is_empty() { -+ Vec::new() -+ } else { -+ full_path -+ } -+} -diff --git a/src/world/chunks/management.rs b/src/world/chunks/management.rs -index f066456..300c4c4 100644 ---- a/src/world/chunks/management.rs -+++ b/src/world/chunks/management.rs -@@ -6,7 +6,7 @@ use crate::world::{tiles::TileMap, CurrentWorldSpriteState, TerrainSpriteState}; - - pub const CHUNK_SIZE: i32 = 8; - --pub const Z_BELOW: f32 = 45.0; -+pub const Z_BELOW: f32 = 5.0; - pub const Z_ABOVE: f32 = 15.0; - pub const Z_TOTAL: f32 = Z_ABOVE + Z_BELOW; - -diff --git a/src/world/generation/forestry.rs b/src/world/generation/forestry.rs -index 9fc597c..4decbb3 100644 ---- a/src/world/generation/forestry.rs -+++ b/src/world/generation/forestry.rs -@@ -11,8 +11,8 @@ use std::hash::{Hash, Hasher}; - use crate::{ - constants::{SEED, TILE_SIZE}, - world::{ -- tiles::TileMap, ChunkForrestryEvent, FixtureTilePrefab, TextureIDs, Textures, -- VisibleGameEntity, -+ tiles::{FixtureTileData, TileMap}, -+ ChunkForrestryEvent, FixtureTilePrefab, TextureIDs, Textures, VisibleGameEntity, - }, - }; - -@@ -26,8 +26,7 @@ pub fn generate_chunk_forrestry( - let start = Instant::now(); - let count = events.len(); - -- let collected_tilemap_updates: Mutex> = -- Mutex::new(Vec::<(IVec3, (i32, bool, bool, [u32; 8]))>::new()); -+ let collected_tilemap_updates: Mutex> = Mutex::new(Vec::new()); - - events.par_read().for_each(|event| { - let floor_positions = &event.floor_tiles; -@@ -87,10 +86,10 @@ pub fn generate_chunk_forrestry( - } - } - -- collected_tilemap_updates -- .lock() -- .unwrap() -- .push((trunk_ivec, (1, false, true, [0; 8]))); -+ collected_tilemap_updates.lock().unwrap().push(( -+ trunk_ivec, -+ FixtureTileData::new(1, false, true, [0; 8]), -+ )); - - log_positions.insert(trunk_ivec); - } -@@ -144,10 +143,14 @@ pub fn generate_chunk_forrestry( - )) - .id(); - commands.entity(leaf).insert(VisibleGameEntity); -- collected_tilemap_updates -- .lock() -- .unwrap() -- .push((ivec, (5, false, true, [0; 8]))); -+ collected_tilemap_updates.lock().unwrap().push( -+ ( -+ ivec, -+ FixtureTileData::new( -+ 5, false, true, [0; 8], -+ ), -+ ), -+ ); - } - } - } -@@ -164,7 +167,7 @@ pub fn generate_chunk_forrestry( - - let collected_updates = collected_tilemap_updates.into_inner().unwrap(); - for (ivec, data) in collected_updates { -- tilemap.fixture_tiles.insert(ivec, data); -+ tilemap.insert_fixture(ivec, data); - } - if count > 0 { - println!( -diff --git a/src/world/generation/terrain.rs b/src/world/generation/terrain.rs -index fb46146..673ee7e 100644 ---- a/src/world/generation/terrain.rs -+++ b/src/world/generation/terrain.rs -@@ -7,8 +7,9 @@ use noise::{NoiseFn, Perlin}; - use crate::{ - constants::{SEED, TILE_SIZE}, - world::{ -- tiles::TileMap, ChunkForrestryEvent, ChunkTerrainEvent, FloorTilePrefab, -- TileOcclusionEvent, CHUNK_SIZE, Z_ABOVE, Z_BELOW, -+ tiles::{FloorTileData, TileMap}, -+ ChunkForrestryEvent, ChunkTerrainEvent, FloorTilePrefab, TileOcclusionEvent, CHUNK_SIZE, -+ Z_ABOVE, Z_BELOW, - }, - }; - -@@ -50,8 +51,7 @@ pub fn generate_chunk_terrain( - let start_y = chunk_pos.y * CHUNK_SIZE; - - let mut surface_positions: Vec<(Vec3, String)> = Vec::new(); -- let mut local_tilemap_updates: HashMap = -- HashMap::new(); -+ let mut local_tilemap_updates: HashMap = HashMap::new(); - - // Generate tiles for this chunk - for local_y in 0..CHUNK_SIZE { -@@ -84,23 +84,26 @@ pub fn generate_chunk_terrain( - commands.command_scope(|mut cmd| { - FloorTilePrefab::air(position).spawn(&mut cmd); - }); -- local_tilemap_updates -- .insert(pos_ivec, (0, true, false, true, 0, [0; 8])); -- // Air tile -+ local_tilemap_updates.insert( -+ pos_ivec, -+ FloorTileData::new(0, true, false, true, 0, [0; 8]), -+ ); - } else if cave_value < 0.8 { - commands.command_scope(|mut cmd| { - FloorTilePrefab::rock(position).spawn(&mut cmd); - }); -- local_tilemap_updates -- .insert(pos_ivec, (2, false, true, false, 50, [0; 8])); -- // Rock tile -+ local_tilemap_updates.insert( -+ pos_ivec, -+ FloorTileData::new(2, false, true, false, 50, [0; 8]), -+ ); - } else { - commands.command_scope(|mut cmd| { - FloorTilePrefab::dirt(position).spawn(&mut cmd); - }); -- local_tilemap_updates -- .insert(pos_ivec, (1, false, true, false, 85, [0; 8])); -- // Dirt tile -+ local_tilemap_updates.insert( -+ pos_ivec, -+ FloorTileData::new(1, false, true, false, 85, [0; 8]), -+ ); - } - } else if noise_position.z > position.z { - if (generate_surface_terrain(world_x, world_y) * TILE_SIZE).round() -@@ -109,23 +112,28 @@ pub fn generate_chunk_terrain( - commands.command_scope(|mut cmd| { - FloorTilePrefab::grass(position).spawn(&mut cmd); - }); -- local_tilemap_updates -- .insert(pos_ivec, (1, false, true, false, 100, [0; 8])); // Dirt tile (grass) -- surface_positions.push((position, ("grass").to_string())); -+ local_tilemap_updates.insert( -+ pos_ivec, -+ FloorTileData::new(1, false, true, false, 100, [0; 8]), -+ ); -+ surface_positions.push((position, "grass".to_string())); - } else { - commands.command_scope(|mut cmd| { - FloorTilePrefab::dirt(position).spawn(&mut cmd); - }); -- local_tilemap_updates -- .insert(pos_ivec, (1, false, true, false, 85, [0; 8])); -- // Dirt tile -+ local_tilemap_updates.insert( -+ pos_ivec, -+ FloorTileData::new(1, false, true, false, 85, [0; 8]), -+ ); - } - } else { - commands.command_scope(|mut cmd| { - FloorTilePrefab::air(position).spawn(&mut cmd); - }); -- local_tilemap_updates.insert(pos_ivec, (0, true, false, true, 0, [0; 8])); -- // Air tile -+ local_tilemap_updates.insert( -+ pos_ivec, -+ FloorTileData::new(0, true, false, true, 0, [0; 8]), -+ ); - } - } - } -@@ -151,7 +159,7 @@ pub fn generate_chunk_terrain( - .unwrap() - .into_iter() - .map(|(pos, data)| { -- tilemap.floor_tiles.insert(pos, data); -+ tilemap.insert_floor(pos, data); - pos - }) - .collect(); -diff --git a/src/world/mod.rs b/src/world/mod.rs -index a0cb751..d94211f 100644 ---- a/src/world/mod.rs -+++ b/src/world/mod.rs -@@ -69,8 +69,8 @@ impl Plugin for WorldPlugin { - } - - fn setup_initial_chunks(mut event_writer: MessageWriter) { -- for x in -5..=5 { -- for y in -5..=5 { -+ for x in -15..=15 { -+ for y in -15..=15 { - event_writer.write(GenerateChunkEvent { - chunk_position: IVec2::new(x, y), - }); -diff --git a/src/world/tiles/tilemap.rs b/src/world/tiles/tilemap.rs -index 8b3d811..f3fd9ef 100644 ---- a/src/world/tiles/tilemap.rs -+++ b/src/world/tiles/tilemap.rs -@@ -1,9 +1,240 @@ -+use ahash::AHashMap; - use bevy::prelude::*; --use bevy_platform::collections::hash_map::HashMap; -+use std::sync::Arc; - --#[derive(Resource, Default, Clone)] -+/// Packed floor tile data for efficient storage. ~35 bytes vs 76 bytes tuple. -+#[derive(Clone, Copy, Debug)] -+pub struct FloorTileData { -+ pub id: u8, -+ /// bit0=can_stand_in, bit1=can_stand_on, bit2=visibly_transparent -+ pub flags: u8, -+ pub astar_weight: u8, -+ pub visible_range: [u32; 8], -+} -+ -+impl Default for FloorTileData { -+ fn default() -> Self { -+ Self { -+ id: 0, -+ flags: 0b001, -+ astar_weight: 0, -+ visible_range: [0; 8], -+ } -+ } -+} -+ -+impl FloorTileData { -+ pub fn new( -+ id: u8, -+ can_stand_in: bool, -+ can_stand_on: bool, -+ visibly_transparent: bool, -+ astar_weight: u8, -+ visible_range: [u32; 8], -+ ) -> Self { -+ let mut flags = 0u8; -+ if can_stand_in { -+ flags |= 0b001; -+ } -+ if can_stand_on { -+ flags |= 0b010; -+ } -+ if visibly_transparent { -+ flags |= 0b100; -+ } -+ Self { -+ id, -+ flags, -+ astar_weight, -+ visible_range, -+ } -+ } -+ -+ #[inline] -+ pub fn can_stand_in(&self) -> bool { -+ self.flags & 0b001 != 0 -+ } -+ #[inline] -+ pub fn can_stand_on(&self) -> bool { -+ self.flags & 0b010 != 0 -+ } -+ #[inline] -+ pub fn visibly_transparent(&self) -> bool { -+ self.flags & 0b100 != 0 -+ } -+ -+ #[inline] -+ pub fn set_can_stand_in(&mut self, value: bool) { -+ if value { -+ self.flags |= 0b001; -+ } else { -+ self.flags &= !0b001; -+ } -+ } -+ -+ #[inline] -+ pub fn set_can_stand_on(&mut self, value: bool) { -+ if value { -+ self.flags |= 0b010; -+ } else { -+ self.flags &= !0b010; -+ } -+ } -+ -+ #[inline] -+ pub fn set_visibly_transparent(&mut self, value: bool) { -+ if value { -+ self.flags |= 0b100; -+ } else { -+ self.flags &= !0b100; -+ } -+ } -+ -+ pub fn from_tuple(tuple: (i32, bool, bool, bool, i32, [u32; 8])) -> Self { -+ Self::new( -+ tuple.0 as u8, -+ tuple.1, -+ tuple.2, -+ tuple.3, -+ tuple.4 as u8, -+ tuple.5, -+ ) -+ } -+ -+ pub fn to_tuple(&self) -> (i32, bool, bool, bool, i32, [u32; 8]) { -+ ( -+ self.id as i32, -+ self.can_stand_in(), -+ self.can_stand_on(), -+ self.visibly_transparent(), -+ self.astar_weight as i32, -+ self.visible_range, -+ ) -+ } -+} -+ -+/// Packed fixture tile data. ~18 bytes vs 48 bytes tuple. -+#[derive(Clone, Copy, Debug)] -+pub struct FixtureTileData { -+ pub id: u8, -+ /// bit0=can_stand_in, bit1=can_stand_on -+ pub flags: u8, -+ pub visible_range: [u32; 8], -+} -+ -+impl Default for FixtureTileData { -+ fn default() -> Self { -+ Self { -+ id: 0, -+ flags: 0, -+ visible_range: [0; 8], -+ } -+ } -+} -+ -+impl FixtureTileData { -+ pub fn new(id: u8, can_stand_in: bool, can_stand_on: bool, visible_range: [u32; 8]) -> Self { -+ let mut flags = 0u8; -+ if can_stand_in { -+ flags |= 0b001; -+ } -+ if can_stand_on { -+ flags |= 0b010; -+ } -+ Self { -+ id, -+ flags, -+ visible_range, -+ } -+ } -+ -+ #[inline] -+ pub fn can_stand_in(&self) -> bool { -+ self.flags & 0b001 != 0 -+ } -+ #[inline] -+ pub fn can_stand_on(&self) -> bool { -+ self.flags & 0b010 != 0 -+ } -+ -+ pub fn from_tuple(tuple: (i32, bool, bool, [u32; 8])) -> Self { -+ Self::new(tuple.0 as u8, tuple.1, tuple.2, tuple.3) -+ } -+ -+ pub fn to_tuple(&self) -> (i32, bool, bool, [u32; 8]) { -+ ( -+ self.id as i32, -+ self.can_stand_in(), -+ self.can_stand_on(), -+ self.visible_range, -+ ) -+ } -+} -+ -+/// Tile map with Arc-wrapped HashMaps for async pathfinding access. -+/// Uses copy-on-write: Arc::make_mut clones only if other Arcs exist. -+#[derive(Resource, Clone, Default)] - pub struct TileMap { -- pub floor_tiles: HashMap, //id, canStandIn, canStandOn, visiblyTransparent, astar_weight, visible_range -- pub fixture_tiles: HashMap, // id, canStandIn, canStandOn, visible_range -- pub item_tiles: HashMap>, // Entity.id's of items on this tile -+ pub floor_tiles: Arc>, -+ pub fixture_tiles: Arc>, -+ pub item_tiles: Arc>>, -+} -+ -+impl TileMap { -+ pub fn new() -> Self { -+ Self::default() -+ } -+ -+ #[inline] -+ pub fn get_floor(&self, pos: &IVec3) -> Option<&FloorTileData> { -+ self.floor_tiles.get(pos) -+ } -+ -+ #[inline] -+ pub fn get_fixture(&self, pos: &IVec3) -> Option<&FixtureTileData> { -+ self.fixture_tiles.get(pos) -+ } -+ -+ #[inline] -+ pub fn has_floor(&self, pos: &IVec3) -> bool { -+ self.floor_tiles.contains_key(pos) -+ } -+ -+ #[inline] -+ pub fn has_fixture(&self, pos: &IVec3) -> bool { -+ self.fixture_tiles.contains_key(pos) -+ } -+ -+ #[inline] -+ pub fn insert_floor(&mut self, pos: IVec3, tile: FloorTileData) { -+ Arc::make_mut(&mut self.floor_tiles).insert(pos, tile); -+ } -+ -+ #[inline] -+ pub fn insert_fixture(&mut self, pos: IVec3, tile: FixtureTileData) { -+ Arc::make_mut(&mut self.fixture_tiles).insert(pos, tile); -+ } -+ -+ #[inline] -+ pub fn insert_item(&mut self, pos: IVec3, entity_id: u32) { -+ Arc::make_mut(&mut self.item_tiles) -+ .entry(pos) -+ .or_default() -+ .push(entity_id); -+ } -+ -+ #[inline] -+ pub fn remove_item(&mut self, pos: &IVec3) -> Option> { -+ Arc::make_mut(&mut self.item_tiles).remove(pos) -+ } -+ -+ #[inline] -+ pub fn get_floor_mut(&mut self, pos: &IVec3) -> Option<&mut FloorTileData> { -+ Arc::make_mut(&mut self.floor_tiles).get_mut(pos) -+ } -+ -+ #[inline] -+ pub fn get_fixture_mut(&mut self, pos: &IVec3) -> Option<&mut FixtureTileData> { -+ Arc::make_mut(&mut self.fixture_tiles).get_mut(pos) -+ } - } -diff --git a/src/world/tiles/visibility.rs b/src/world/tiles/visibility.rs -index c742685..c56ce8b 100644 ---- a/src/world/tiles/visibility.rs -+++ b/src/world/tiles/visibility.rs -@@ -36,8 +36,8 @@ pub fn compute_visibility_of_game_entities( - return; - } - } -- -- let entity_z = (transform.translation.z / TILE_SIZE) - 1.0; -+ let scaled_z = transform.translation.z / TILE_SIZE; -+ let entity_z = scaled_z - 1.0; - // if same z-level, always visible - if z_index.0 == entity_z { - sprite.color = Color::WHITE; -@@ -59,11 +59,11 @@ pub fn compute_visibility_of_game_entities( - - // if visible, calculate saturation - *visibility = Visibility::Visible; -- let saturation = -- ((z_index.0 - (transform.translation.z / TILE_SIZE) + 1.) / 8.0).clamp(0.0, 1.0); -+ let saturation = ((z_index.0 - scaled_z + 1.) / 8.0).clamp(0.0, 1.0); - -- sprite.color = Color::hsv(194.7, saturation, 1.0 - (saturation / 2.0)); -- sprite.color.set_alpha(1.0 - saturation); -+ let mut c = Color::hsv(194.7, saturation, 1.0 - (saturation / 2.0)); -+ c.set_alpha(1.0 - saturation); -+ sprite.color = c; - }); - } - -@@ -100,8 +100,8 @@ pub fn handle_tile_occlusion_updates( - for (_, mut tile, pos) in floor_tiles.iter_mut() { - if let Some(visibility) = update_map.get(&pos.translation.as_ivec3()) { - tile.visible_range = *visibility; -- if let Some(tile_data) = tilemap.floor_tiles.get_mut(&pos.translation.as_ivec3()) { -- tile_data.5 = *visibility; -+ if let Some(tile_data) = tilemap.get_floor_mut(&pos.translation.as_ivec3()) { -+ tile_data.visible_range = *visibility; - } - } - } -@@ -115,8 +115,11 @@ pub fn handle_tile_occlusion_updates( - } - } - -+#[inline(always)] - pub fn calculate_visibility(pos: IVec3, tilemap: &TileMap) -> [u32; 8] { - let mut visible_range = [0u32; 8]; -+ let z_total = Z_TOTAL as i32; -+ let z_below = Z_BELOW as i32; - - let touches_air = 'neighbor_check: { - for x_offset in -1..=1 { -@@ -131,7 +134,7 @@ pub fn calculate_visibility(pos: IVec3, tilemap: &TileMap) -> [u32; 8] { - pos.z + z_offset * ITILE_SIZE, - ); - match tilemap.floor_tiles.get(&neighbor_pos) { -- Some(&(id, _, _, _, _, _)) if id == 0 => break 'neighbor_check true, -+ Some(tile) if tile.id == 0 => break 'neighbor_check true, - None => {} - _ => {} - } -@@ -145,45 +148,42 @@ pub fn calculate_visibility(pos: IVec3, tilemap: &TileMap) -> [u32; 8] { - return visible_range; - } - -- let base_camera_index = (pos.z / ITILE_SIZE) + Z_BELOW as i32; -+ let base_camera_index = (pos.z / ITILE_SIZE) + z_below; - -- if base_camera_index >= 0 && base_camera_index <= Z_TOTAL as i32 { -- if touches_air { -- let z2 = base_camera_index as usize; -- visible_range[z2 / 32] |= 1 << (z2 % 32); -- } -+ if base_camera_index >= 0 && base_camera_index <= z_total { -+ let z2 = base_camera_index as usize; -+ visible_range[z2 / 32] |= 1 << (z2 % 32); - } - - let mut occluded = false; -+ let mut check_pos = pos; - - for z_offset in 1..=(Z_TOTAL as i32 + Z_BELOW as i32 + 1) { -- let check_z = pos.z + z_offset * ITILE_SIZE; -- let camera_z_index = (check_z / ITILE_SIZE) + Z_BELOW as i32; -+ check_pos.z = pos.z + z_offset * ITILE_SIZE; -+ let camera_z_index = (check_pos.z / ITILE_SIZE) + z_below; - - if camera_z_index < 0 { -- match tilemap.floor_tiles.get(&IVec3::new(pos.x, pos.y, check_z)) { -- Some(&(_, _, _, visibly_transparent, _, _)) => occluded = !visibly_transparent, -- None => occluded = false, -+ if let Some(tile) = tilemap.floor_tiles.get(&check_pos) { -+ occluded = !tile.visibly_transparent(); -+ } else { -+ occluded = false; - } - continue; - } - -- if camera_z_index > Z_TOTAL as i32 { -+ if camera_z_index > z_total { - break; - } - - if !occluded { - let z2 = camera_z_index as usize; -- visible_range[z2 / 32] |= 1 << ((z2 % 32) as u32); -+ visible_range[z2 / 32] |= 1 << (z2 % 32); - } - -- let tile = tilemap.floor_tiles.get(&IVec3::new(pos.x, pos.y, check_z)); -- -- match tile { -- Some(&(_, _, _, visibly_transparent, _, _)) => { -- occluded = !visibly_transparent; -- } -- None => occluded = false, -+ if let Some(tile) = tilemap.floor_tiles.get(&check_pos) { -+ occluded = !tile.visibly_transparent(); -+ } else { -+ occluded = false; - } - } - diff --git a/src/constants.rs b/src/constants.rs index 3bae652..7a22b57 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -4,6 +4,7 @@ pub const TILE_SIZE: f32 = TILE_PIXELS as f32 * PIXEL_RATIO; pub const ITILE_SIZE: i32 = TILE_SIZE as i32; pub const SEED: u32 = 420; -pub const PATHFINDER_SHORT_PATH_MAX_TILES: i32 = 100; +pub const PATHFINDER_SHORT_PATH_MAX_TILES: i32 = 64; pub const PATHFINDER_MAX_NODES: usize = 5000; pub const PATHFINDER_WAYPOINT_THRESHOLD_TILES: i32 = 100; +pub const PATHFINDER_PROVISIONAL_NODE_LIMIT: usize = 64; diff --git a/src/entities/shared_components/ambulatory.rs b/src/entities/shared_components/ambulatory.rs index 5b41470..c02a637 100644 --- a/src/entities/shared_components/ambulatory.rs +++ b/src/entities/shared_components/ambulatory.rs @@ -1,4 +1,5 @@ use bevy::prelude::*; +use bevy::tasks::Task; #[derive(Component)] pub struct Ambulatory { @@ -18,6 +19,15 @@ pub struct PendingPath { pub request_id: u64, } +#[derive(Component)] +pub struct PendingAsyncPath { + pub request_id: u64, + pub task: Task>, + pub goal: IVec3, + pub provisional_path: Vec, + pub provisional_path_index: usize, +} + #[derive(Resource, Default)] pub struct PathRequestCounter { pub next_id: u64, diff --git a/src/entities/shared_systems/async_pathfinding.rs b/src/entities/shared_systems/async_pathfinding.rs new file mode 100644 index 0000000..67b0521 --- /dev/null +++ b/src/entities/shared_systems/async_pathfinding.rs @@ -0,0 +1,296 @@ +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 { + 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 { + 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 = 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 { + 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, +) { + for (entity, mut pending) in pending_query.iter_mut() { + if !pending.task.is_finished() { + continue; + } + let path: Option> = + 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::(); + } + } +} + +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, + 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, + } +} diff --git a/src/entities/shared_systems/mod.rs b/src/entities/shared_systems/mod.rs index 1bc27c5..e2529e9 100644 --- a/src/entities/shared_systems/mod.rs +++ b/src/entities/shared_systems/mod.rs @@ -1 +1,2 @@ +pub mod async_pathfinding; pub mod pathfinding; diff --git a/src/entities/shared_systems/pathfinding.rs b/src/entities/shared_systems/pathfinding.rs index 67baec4..6601906 100644 --- a/src/entities/shared_systems/pathfinding.rs +++ b/src/entities/shared_systems/pathfinding.rs @@ -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, + mut query: Query< + ( + Entity, + &mut crate::entities::shared_components::Ambulatory, + &Transform, + Option<&crate::entities::shared_components::PendingAsyncPath>, + ), + Without, + >, + tilemap: Res, +) { + 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, + 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, 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 { + 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>, mut bench: ResMut, diff --git a/src/world/tiles/tilemap.rs b/src/world/tiles/tilemap.rs index fa1f4b3..38806b1 100644 --- a/src/world/tiles/tilemap.rs +++ b/src/world/tiles/tilemap.rs @@ -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, +} + +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)) + } +}