diff --git a/Cargo.lock b/Cargo.lock index d58b8d3..440fc3a 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.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c76e5db2c274081fdff32a525609436bd7ac2c24b91c1a5d3bc04e43d7558ddf" +checksum = "526ba09324b96fb64f275a0cb4f6112d36b6c916ce2f14e38d5a8739dd2ac7f9" dependencies = [ "bevy_app", "bevy_ecs", @@ -2331,13 +2331,17 @@ dependencies = [ name = "dorf" version = "0.1.0" dependencies = [ + "ahash", "bevy", "bevy_platform", "bevy_rand", + "futures-lite", "image", + "nohash-hasher", "noise", "rand 0.10.0", "rayon", + "rustc-hash 2.1.1", ] [[package]] @@ -3023,9 +3027,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 +3045,8 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.12", + "zune-core", + "zune-jpeg", ] [[package]] @@ -3057,9 +3061,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 +3435,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 +3570,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 +4425,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 +4546,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 +5003,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 +6478,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 +6493,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 ae71471..8522f33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,13 +4,17 @@ 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.1", 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" +futures-lite = "2.6" # 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..7a22b57 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -3,3 +3,8 @@ 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; + +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/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..36437a0 100644 --- a/src/entities/shared_systems/pathfinding.rs +++ b/src/entities/shared_systems/pathfinding.rs @@ -1,14 +1,91 @@ -use crate::constants::TILE_SIZE; +use bevy::prelude::*; +use rayon::prelude::*; +use rustc_hash::FxHashMap; +use rustc_hash::FxHashSet; +use std::{cell::RefCell, collections::BinaryHeap, collections::VecDeque, time::Instant}; + +use crate::constants::{ + ITILE_SIZE, PATHFINDER_MAX_NODES, PATHFINDER_PROVISIONAL_NODE_LIMIT, 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::math::ivec3; use bevy_rand::prelude::*; use rand::RngExt; -use std::{ - collections::{BinaryHeap, HashMap, HashSet}, - process::exit, -}; + +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) }; +} + +/// Single consolidated scratchpad for A* pathfinding. +/// One RefCell borrow instead of multiple nested borrows. +struct AStarScratchpad { + g_scores: FxHashMap, + came_from: FxHashMap, + closed_set: FxHashSet, + open_set: BinaryHeap, +} + +impl Default for AStarScratchpad { + fn default() -> Self { + Self { + g_scores: FxHashMap::default(), + came_from: FxHashMap::default(), + closed_set: FxHashSet::default(), + open_set: BinaryHeap::new(), + } + } +} + +impl AStarScratchpad { + fn clear_and_reserve(&mut self, capacity: usize) { + self.g_scores.clear(); + self.came_from.clear(); + self.closed_set.clear(); + self.open_set.clear(); + + if self.g_scores.capacity() < capacity { + self.g_scores.reserve(capacity); + self.came_from.reserve(capacity); + self.closed_set.reserve(capacity); + } + } +} + +thread_local! { + static SCRATCHPAD: RefCell = RefCell::new(AStarScratchpad::default()); +} + +const ALLOWED_MOVES: [IVec3; 24] = [ + IVec3::new(-ITILE_SIZE, 0, 0), + IVec3::new(ITILE_SIZE, 0, 0), + IVec3::new(0, -ITILE_SIZE, 0), + IVec3::new(0, ITILE_SIZE, 0), + 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), + 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), + 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 { @@ -19,7 +96,10 @@ struct PathNode { impl Ord for PathNode { fn cmp(&self, other: &Self) -> std::cmp::Ordering { - other.f_score.cmp(&self.f_score) + other + .f_score + .cmp(&self.f_score) + .then_with(|| other.g_score.cmp(&self.g_score)) } } @@ -29,58 +109,233 @@ impl PartialOrd for PathNode { } } +#[derive(Resource, Default)] +pub struct PathfindingBenchmark { + pub path_calc_times_us: Vec, + pub path_lengths: Vec, + pub nodes_expanded: Vec, + pub movement_system_times_us: Vec, + pub wander_system_times_us: Vec, + pub total_paths_calculated: u64, + pub total_failed_paths: u64, + 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() + } + } +} + +#[derive(Resource, Default)] +pub struct PathRequestQueue { + pub pending: VecDeque<(Entity, IVec3, IVec3)>, +} + +const MAX_PATHS_PER_FRAME: usize = 8; + 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()) + .insert_resource(PathRequestQueue::default()) + .add_systems( + FixedUpdate, + (prepare_paths, update_wandering_targets, movement).chain(), + ) + .add_systems( + PostUpdate, + ( + merge_benchmark_stats, + process_completed_paths, + process_path_queue, + ), + ) + .add_systems(Update, bench_report_system); + } +} + +pub fn prepare_paths( + mut commands: Commands, + mut queue: ResMut, + mut query: Query< + ( + Entity, + &mut crate::entities::shared_components::Ambulatory, + &Transform, + ), + Without, + >, + tilemap: Res, +) { + for (entity, mut ambulatory, transform) in query.iter_mut() { + if ambulatory.current_path.is_some() || ambulatory.target.is_none() { + continue; + } + let Some(target) = ambulatory.target else { + 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); + ambulatory.path_index = 0; + + queue.pending.push_back((entity, start, goal)); + commands + .entity(entity) + .insert(crate::entities::shared_components::PendingPath { + start, + goal, + waypoint_path: Vec::new(), + request_id: 0, + }); + } else { + let path = calculate_path_benchmarked(&tilemap, start, goal); + ambulatory.current_path = Some(path); + ambulatory.path_index = 0; + } + } + } +} + +pub fn process_path_queue( + mut commands: Commands, + mut queue: ResMut, + tilemap: Res, + mut query: Query< + (Entity, &mut Ambulatory, &Transform), + With, + >, +) { + let mut processed = 0; + while processed < MAX_PATHS_PER_FRAME { + if let Some((entity, _old_start, goal)) = queue.pending.pop_front() { + processed += 1; + + if let Ok((_, mut ambulatory, transform)) = query.get_mut(entity) { + let actual_start = transform.translation.as_ivec3(); + let full_path = calculate_path_benchmarked(&tilemap, actual_start, goal); + + if !full_path.is_empty() { + ambulatory.current_path = Some(full_path); + ambulatory.path_index = 0; + } + commands + .entity(entity) + .remove::(); + } + } else { + break; + } + } +} + +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::(); + } + } } } +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 + mut query: Query<(&mut Ambulatory, &Transform)>, tilemap: Res, chunk_map: Res, mut rng_q: Query<&mut WyRand, With>, ) { - if let Ok(mut rng) = rng_q.single_mut() { - for (mut ambulatory, _) in query.iter_mut() { - if ambulatory.target.is_none() - || (ambulatory.current_path.is_some() - && ambulatory.path_index >= ambulatory.current_path.as_ref().unwrap().len()) - { - // Find a random loaded chunk - let loaded_chunks: Vec<&IVec2> = chunk_map.loaded_chunks.keys().collect(); - if !loaded_chunks.is_empty() { - let random_index = rng.random_range(0..loaded_chunks.len()); - if let Some(&chunk_pos) = loaded_chunks.get(random_index) { - // Generate random position within chunk - let chunk_x = chunk_pos.x * CHUNK_SIZE; - let chunk_y = chunk_pos.y * CHUNK_SIZE; - - let target_x = chunk_x + rng.random_range(0..CHUNK_SIZE); - let target_y = chunk_y + rng.random_range(0..CHUNK_SIZE); - - // Get height at position - let surface_height = 0; - - // Find a valid z-level near the surface - for z in (surface_height - 3)..=(surface_height + 4) { - let mut target_pos = IVec3::new(target_x, target_y, z) * ITILE_SIZE; - if let Some(_) = tilemap.floor_tiles.get(&target_pos) { - target_pos.z += ITILE_SIZE; - if let Some(_base_texture) = tilemap.floor_tiles.get(&target_pos) { - if is_standable_tile(&tilemap, target_pos) { - ambulatory.target = Some(Vec3::new( - target_pos.x as f32, - target_pos.y as f32, - target_pos.z as f32 + 1.0, - )); - ambulatory.current_path = None; - ambulatory.path_index = 0; - break; - } - } + let Ok(mut rng) = rng_q.single_mut() else { + return; + }; + + for (mut ambulatory, _) in query.iter_mut() { + if ambulatory.target.is_none() + || (ambulatory.current_path.is_some() + && ambulatory.path_index >= ambulatory.current_path.as_ref().unwrap().len()) + { + let loaded_chunks: Vec<&IVec2> = chunk_map.loaded_chunks.keys().collect(); + if !loaded_chunks.is_empty() { + let random_index = rng.random_range(0..loaded_chunks.len()); + if let Some(&chunk_pos) = loaded_chunks.get(random_index) { + let chunk_x = chunk_pos.x * CHUNK_SIZE; + let chunk_y = chunk_pos.y * CHUNK_SIZE; + + let target_x = chunk_x + rng.random_range(0..CHUNK_SIZE); + let target_y = chunk_y + rng.random_range(0..CHUNK_SIZE); + + for z in -3..=4 { + let mut target_pos = IVec3::new(target_x, target_y, z) * ITILE_SIZE; + if tilemap.floor_tiles.get(&target_pos).is_some() { + target_pos.z += ITILE_SIZE; + if tilemap.floor_tiles.get(&target_pos).is_some() + && is_standable_tile(&tilemap, target_pos) + { + ambulatory.target = Some(Vec3::new( + target_pos.x as f32, + target_pos.y as f32, + target_pos.z as f32 + 1.0, + )); + ambulatory.current_path = None; + ambulatory.path_index = 0; + break; } } } @@ -95,289 +350,435 @@ pub fn movement(mut query: Query<(&mut Ambulatory, &mut Transform)>, tilemap: Re .par_iter_mut() .for_each(|(mut ambulatory, mut transform)| { let current_pos = transform.translation; - // Apply gravity if in air if !is_standable_tile(&tilemap, current_pos.as_ivec3()) { transform.translation.z -= TILE_SIZE; return; } - if let Some(target) = ambulatory.target { - // Calculate path if needed - if ambulatory.current_path.is_none() { - ambulatory.current_path = Some(calculate_path( - &tilemap, - transform.translation.as_ivec3(), - target.as_ivec3() - ivec3(0, 0, 1), - )); - ambulatory.path_index = 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 ambulatory.current_path.is_none() { + return; + } - // Follow the current path - if let Some(path) = &ambulatory.current_path { - if ambulatory.path_index < path.len() { - let next_point = path[ambulatory.path_index]; + if ambulatory.walk_speed > 0. { + if ambulatory.step_recovery <= ambulatory.walk_speed as u32 { + ambulatory.step_recovery += 1; + return; + } else { + ambulatory.step_recovery = 0; + } + } - let direction = (next_point - transform.translation).normalize(); - transform.translation = next_point; + 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; - // Update sprite direction (only for x movement) - if direction.x > 0.0 { - transform.scale.x = PIXEL_RATIO; - } else if direction.x < 0.0 { - transform.scale.x = -PIXEL_RATIO; - } + if direction.x > 0.0 { + transform.scale.x = PIXEL_RATIO; + } else if direction.x < 0.0 { + transform.scale.x = -PIXEL_RATIO; + } - // Check if we've reached the next point - 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; } } }); } 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; - } - // 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; - } - - // Check if there's solid ground below (fixture or floor) + 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) +} - if let Some(below_floor_tile) = tilemap.floor_tiles.get(&pos_below) { - can_i_stand_on_tile_bellow = below_floor_tile.2; +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) => 42, + (1, 1, 1) => 56, + _ => 0, } +} - if let Some(below_fixture_tile) = tilemap.fixture_tiles.get(&pos_below) { - can_i_stand_on_fixture_bellow = below_fixture_tile.2; - } - return (can_i_stand_in_tile || can_i_stand_in_fixture) - && (can_i_stand_on_tile_bellow || can_i_stand_on_fixture_bellow); +fn octile_distance_3d(a: IVec3, b: IVec3) -> i32 { + let dx = (a.x - b.x).abs(); + let dy = (a.y - b.y).abs(); + let dz = (a.z - b.z).abs(); + let (dmax, dmid, dmin) = sorted_desc(dx, dy, dz); + 10 * dmax + 4 * dmid + dmin } -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); +fn sorted_desc(a: i32, b: i32, c: i32) -> (i32, i32, i32) { + let mut arr = [a, b, c]; + arr.sort_unstable_by(|x, y| y.cmp(x)); + (arr[0], arr[1], arr[2]) +} + +fn reconstruct_path(came_from: &FxHashMap, 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(&prev) = came_from.get(¤t) { + path.push(Vec3::new(prev.x as f32, prev.y as f32, prev.z as f32)); + current = prev; } - if !is_standable_tile(tilemap, goal) { - println!("Goal pos invalid: {}", goal); - println!("Bugger (2)"); - exit(0); + path.reverse(); + path +} + +pub fn calculate_path_benchmarked(tilemap: &TileMap, start: IVec3, goal: IVec3) -> Vec { + let timer = Instant::now(); + + if !is_standable_tile(tilemap, start) || !is_standable_tile(tilemap, goal) { + LOCAL_FAILED_PATHS.with(|f| { + *f.borrow_mut() += 1; + }); + 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(); + let estimated_tiles = octile_distance_3d(start, goal) / ITILE_SIZE; + let (result, nodes_expanded) = + calculate_path_with_scratchpad(tilemap, start, goal, estimated_tiles); + + let elapsed = timer.elapsed().as_micros(); + LOCAL_PATH_TIMES.with(|t| { + t.borrow_mut().push(elapsed); + }); + LOCAL_PATH_LENGTHS.with(|l| { + l.borrow_mut().push(result.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 + } +} - let start_node = PathNode { - position: start, - f_score: octile_distance_3d(start, goal), - g_score: 0, - }; +fn calculate_path_with_scratchpad( + tilemap: &TileMap, + start: IVec3, + goal: IVec3, + estimated_tiles: i32, +) -> (Vec, usize) { + 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 h = octile_distance_3d(start, goal); + scratch.open_set.push(PathNode { + position: start, + f_score: h, + g_score: 0, + }); + scratch.g_scores.insert(start, 0); - open_set.push(start_node); - in_open_set.insert(start); - g_scores.insert(start, 0); - - let allowed_moves = vec![ - // 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 (left/negative preference) - 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), - ]; - - while let Some(current_node) = open_set.pop() { - let current = current_node.position; - - in_open_set.remove(¤t); - - if current == goal { - // println!("path found"); - return reconstruct_path(came_from, current); - } + let mut nodes_expanded: usize = 0; - closed_set.insert(current); + while let Some(current_node) = scratch.open_set.pop() { + let current = current_node.position; + nodes_expanded += 1; - for &move_dir in &allowed_moves { - let neighbor_pos = current + move_dir; + if nodes_expanded > PATHFINDER_MAX_NODES { + return ( + reconstruct_path(&scratch.came_from, current), + nodes_expanded, + ); + } - if !is_standable_tile(tilemap, neighbor_pos) || closed_set.contains(&neighbor_pos) { - continue; + if current == goal { + return ( + reconstruct_path(&scratch.came_from, current), + nodes_expanded, + ); } - // TODO: Add terrain-based cost modifiers - // movement_cost = apply_terrain_modifier(movement_cost, neighbor_pos, tilemap); - // Examples: - // - Mud/sand: +50% cost - // - Ice: +100% cost - // - Designated high-traffic areas: -25% cost - // - Designated restricted areas: +500% cost - // - Etc - - let movement_cost = match ( - move_dir.x.abs() / ITILE_SIZE, - move_dir.y.abs() / ITILE_SIZE, - move_dir.z.abs() / ITILE_SIZE, - ) { - // 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, 1, 1) => 56, // Diagonal + vertical climb - - // TODO: Implement stairs and ramps for efficient vertical movement - // Stairs would reduce vertical costs significantly: - // (0, 0, 1) => 20 if has_stairs(current, neighbor_pos), // Stairs: 2× horizontal cost - // (1, 0, 1) | (0, 1, 1) => 24 if has_stairs(current, neighbor_pos), // Stairs + horizontal - // (1, 1, 1) => 28 if has_stairs(current, neighbor_pos), // Stairs + diagonal - - // TODO: Implement ramps for even smoother vertical movement - // Ramps would be cheaper than stairs: - // (0, 0, 1) => 15 if has_ramp(current, neighbor_pos), // Ramps: 1.5× horizontal cost - // (1, 0, 1) | (0, 1, 1) => 18 if has_ramp(current, neighbor_pos), // Ramps + horizontal - // (1, 1, 1) => 21 if has_ramp(current, neighbor_pos), // Ramps + diagonal - _ => 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; - - // Only add to open set if not already there - if !in_open_set.contains(&neighbor_pos) { - let neighbor_node = PathNode { + 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, - }; - open_set.push(neighbor_node); - in_open_set.insert(neighbor_pos); - } else { - let neighbor_node = PathNode { + }); + } + } + } + + (Vec::new(), nodes_expanded) + }) +} + +pub fn calculate_provisional_path( + tilemap: &TileMap, + start: IVec3, + goal: IVec3, + node_limit: usize, +) -> Vec { + let timer = Instant::now(); + + if !is_standable_tile(tilemap, start) { + LOCAL_FAILED_PATHS.with(|f| { + *f.borrow_mut() += 1; + }); + return vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)]; + } + + let estimated_tiles = octile_distance_3d(start, goal) / ITILE_SIZE; + + let result = 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), + nodes_expanded, + ); + } + + if nodes_expanded >= node_limit { + return ( + reconstruct_path(&scratch.came_from, best_node), + nodes_expanded, + ); + } + + 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, - }; - open_set.push(neighbor_node); + }); } } } - } - Vec::new() + ( + reconstruct_path(&scratch.came_from, best_node), + nodes_expanded, + ) + }); + + let elapsed = timer.elapsed().as_micros(); + LOCAL_PATH_TIMES.with(|t| { + t.borrow_mut().push(elapsed); + }); + LOCAL_PATH_LENGTHS.with(|l| { + l.borrow_mut().push(result.0.len()); + }); + LOCAL_NODES_EXPANDED.with(|n| { + n.borrow_mut().push(result.1); + }); + + result.0 } -fn octile_distance_3d(a: IVec3, b: IVec3) -> i32 { - let dx = (a.x - b.x).abs(); - let dy = (a.y - b.y).abs(); - let dz = (a.z - b.z).abs(); +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); + } - // Dwarf Fortress style costs - let cost_orthogonal = 10; // Horizontal orthogonal - let cost_diagonal = 14; // Horizontal diagonal (~√2 × 10) - let cost_climb = 50; // Raw vertical movement (climbing) - - let mut diffs = [dx, dy, dz]; - diffs.sort_unstable(); - let dmin = diffs[0]; - let dmax = diffs[2]; - - if dz == 0 { - // Pure 2D movement - let diagonal_moves = dmin / ITILE_SIZE; - let orthogonal_moves = (dmax - dmin) / ITILE_SIZE; - cost_diagonal * diagonal_moves + cost_orthogonal * orthogonal_moves - } else { - // Movement involves Z - assume raw climbing for now - // TODO: Modify this when stairs/ramps are implemented - let z_moves = dz / ITILE_SIZE; - let xy_distance = ((dx * dx + dy * dy) as f32).sqrt() as i32; - let remaining_2d_diagonal = (xy_distance.min(dz)) / ITILE_SIZE; - let remaining_2d_orthogonal = - (xy_distance - remaining_2d_diagonal * ITILE_SIZE) / ITILE_SIZE; - - // Raw climbing cost + remaining 2D movement - cost_climb * z_moves - + cost_diagonal * remaining_2d_diagonal - + cost_orthogonal * remaining_2d_orthogonal + 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 + } + ); + + if let Err(e) = write_benchmark_csv(&bench, "pathfinding_benchmark_current.csv") { + eprintln!("Failed to write benchmark CSV: {}", e); + } + println!("=====================================\n"); } } -fn reconstruct_path(came_from: HashMap, mut current: IVec3) -> Vec { - let mut path = vec![Vec3::new( - current.x as f32, - current.y as f32, - current.z as f32, - )]; +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={}µs median={}µs min={}µs max={}µs p95={}µs", + label, + times.len(), + avg, + median, + min, + max, + p95 + ); +} - 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; +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)?; } - path.reverse(); - path + 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(()) } 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..028c005 100644 --- a/src/world/tiles/tilemap.rs +++ b/src/world/tiles/tilemap.rs @@ -1,9 +1,197 @@ use bevy::prelude::*; -use bevy_platform::collections::hash_map::HashMap; +use rustc_hash::FxHashMap; -#[derive(Resource, Default, Clone)] +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 { + 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; + } + } +} + +/// 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 + } +} + +/// Tile map using FxHashMap for fast lookups. No Arc wrapper - single-threaded access. +#[derive(Resource, 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: FxHashMap, + pub fixture_tiles: FxHashMap, + pub item_tiles: FxHashMap>, +} + +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) { + self.floor_tiles.insert(pos, tile); + } + + #[inline] + pub fn insert_fixture(&mut self, pos: IVec3, tile: FixtureTileData) { + self.fixture_tiles.insert(pos, tile); + } + + #[inline] + pub fn insert_item(&mut self, pos: IVec3, entity_id: u32) { + self.item_tiles.entry(pos).or_default().push(entity_id); + } + + #[inline] + pub fn remove_item(&mut self, pos: &IVec3) -> Option> { + self.item_tiles.remove(pos) + } + + #[inline] + pub fn get_floor_mut(&mut self, pos: &IVec3) -> Option<&mut FloorTileData> { + self.floor_tiles.get_mut(pos) + } } diff --git a/src/world/tiles/visibility.rs b/src/world/tiles/visibility.rs index 5c9a041..c56ce8b 100644 --- a/src/world/tiles/visibility.rs +++ b/src/world/tiles/visibility.rs @@ -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; } } } @@ -134,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 => {} _ => {} } @@ -163,8 +163,8 @@ pub fn calculate_visibility(pos: IVec3, tilemap: &TileMap) -> [u32; 8] { let camera_z_index = (check_pos.z / ITILE_SIZE) + z_below; if camera_z_index < 0 { - if let Some(&(_, _, _, vt, _, _)) = tilemap.floor_tiles.get(&check_pos) { - occluded = !vt; + if let Some(tile) = tilemap.floor_tiles.get(&check_pos) { + occluded = !tile.visibly_transparent(); } else { occluded = false; } @@ -180,8 +180,8 @@ pub fn calculate_visibility(pos: IVec3, tilemap: &TileMap) -> [u32; 8] { visible_range[z2 / 32] |= 1 << (z2 % 32); } - if let Some(&(_, _, _, vt, _, _)) = tilemap.floor_tiles.get(&check_pos) { - occluded = !vt; + if let Some(tile) = tilemap.floor_tiles.get(&check_pos) { + occluded = !tile.visibly_transparent(); } else { occluded = false; } diff --git a/verify_bench.py b/verify_bench.py new file mode 100644 index 0000000..fea2e64 --- /dev/null +++ b/verify_bench.py @@ -0,0 +1,36 @@ +import csv +import sys + +def analyze(filename): + durations = [] + failed = 0 + total = 0 + stutters = 0 + try: + with open(filename, 'r') as f: + reader = csv.reader(f) + next(reader) # skip header + for row in reader: + if not row or row[0].startswith('#'): continue + total += 1 + try: + d = int(row[1]) + durations.append(d) + if d > 1000: stutters += 1 + if row[4] == 'false': failed += 1 + except: + pass + + durations.sort() + if not durations: return + p50 = durations[len(durations)//2] + p90 = durations[int(len(durations)*0.90)] + p99 = durations[int(len(durations)*0.99)] + max_d = durations[-1] + print(f"[{filename}]") + print(f"P50={p50} P90={p90} P99={p99} Max={max_d} Stutters={stutters} Failed={failed}/{total}") + except Exception as e: + print(f"Error reading {filename}: {e}") + +analyze("/home/popertots/bench/pathfinding_benchmark_baseline_release.csv") +analyze("pathfinding_benchmark_current.csv")