feat: async pathfinding with provisional paths and bit-grid snapshots

- Add StandableBitGrid: O(1) bit-packed snapshot (~6KB per 50k tiles vs HashMap overhead)
- Implement two-tier pathfinding: sync for short paths (<64 tiles), provisional+async for long paths
- calculate_provisional_path: capped A* returning path to best heuristic node
- calculate_async_path: A* using bit-grid (Send+Sync, no thread_local)
- prepare_paths system: dispatches provisional paths immediately, spawns async for full paths
- poll_async_paths + splice_completed_async_paths: seamless path transition when async completes
- Entities start walking immediately on provisional path while full path computes in background

Architecture:
  FixedUpdate: prepare_paths → update_wandering_targets → movement
  PostUpdate: poll_async_paths → splice_completed_async_paths

Priority: DF-like pathing (immediate movement) > performance > memory
This commit is contained in:
2026-03-18 16:35:39 +00:00
parent 79a386afa6
commit c902cff908
9 changed files with 626 additions and 2417 deletions
Generated
+1
View File
@@ -2335,6 +2335,7 @@ dependencies = [
"bevy",
"bevy_platform",
"bevy_rand",
"futures-lite",
"image",
"nohash-hasher",
"noise",
+1
View File
@@ -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."*"]
-2380
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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;
@@ -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<Vec<Vec3>>,
pub goal: IVec3,
pub provisional_path: Vec<Vec3>,
pub provisional_path_index: usize,
}
#[derive(Resource, Default)]
pub struct PathRequestCounter {
pub next_id: u64,
@@ -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<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
const ASYNC_ALLOWED_MOVES: [(i32, i32, i32); 24] = [
(-1, 0, 0),
(1, 0, 0),
(0, -1, 0),
(0, 1, 0),
(-1, -1, 0),
(-1, 1, 0),
(1, -1, 0),
(1, 1, 0),
(-1, 0, 1),
(-1, 0, -1),
(1, 0, 1),
(1, 0, -1),
(0, -1, 1),
(0, -1, -1),
(0, 1, 1),
(0, 1, -1),
(-1, -1, 1),
(-1, -1, -1),
(-1, 1, 1),
(-1, 1, -1),
(1, -1, 1),
(1, -1, -1),
(1, 1, 1),
(1, 1, -1),
];
pub fn calculate_async_path(bit_grid: StandableBitGrid, start: IVec3, goal: IVec3) -> Vec<Vec3> {
let (sbx, sby, sbz) = match bit_grid.to_bit_coords(start) {
Some(c) => c,
None => return vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)],
};
let (gbx, gby, gbz) = match bit_grid.to_bit_coords(goal) {
Some(c) => c,
None => return vec![Vec3::new(start.x as f32, start.y as f32, start.z as f32)],
};
let mut g_scores: FxHashMap<(u32, u32, u32), i32> = FxHashMap::default();
let mut came_from: FxHashMap<(u32, u32, u32), (u32, u32, u32)> = FxHashMap::default();
let mut closed_set: FxHashSet<(u32, u32, u32)> = FxHashSet::default();
let mut open_set: BinaryHeap<AsyncPathNode> = BinaryHeap::new();
let h = octile_distance_3d_bit(sbx, sby, sbz, gbx, gby, gbz);
open_set.push(AsyncPathNode {
position: start,
f_score: h,
g_score: 0,
});
g_scores.insert((sbx, sby, sbz), 0);
let mut nodes_expanded: usize = 0;
while let Some(current_node) = open_set.pop() {
let current = current_node.position;
nodes_expanded += 1;
if nodes_expanded > PATHFINDER_MAX_NODES {
return reconstruct_path_async(&came_from, current, &bit_grid);
}
if current == goal {
return reconstruct_path_async(&came_from, current, &bit_grid);
}
let (cx, cy, cz) = match bit_grid.to_bit_coords(current) {
Some(c) => c,
None => continue,
};
closed_set.insert((cx, cy, cz));
for &(dx, dy, dz) in &ASYNC_ALLOWED_MOVES {
let nx = cx as i32 + dx;
let ny = cy as i32 + dy;
let nz = cz as i32 + dz;
if nx < 0 || ny < 0 || nz < 0 {
continue;
}
let neighbor_bx = nx as u32;
let neighbor_by = ny as u32;
let neighbor_bz = nz as u32;
if !bit_grid.is_standable_at(neighbor_bx, neighbor_by, neighbor_bz)
|| closed_set.contains(&(neighbor_bx, neighbor_by, neighbor_bz))
{
continue;
}
let movement_cost = calculate_movement_cost_bit(dx, dy, dz);
if movement_cost == 0 {
continue;
}
let neighbor_pos = IVec3::new(
current.x + dx * ITILE_SIZE,
current.y + dy * ITILE_SIZE,
current.z + dz * ITILE_SIZE,
);
let current_g = *g_scores.get(&(cx, cy, cz)).unwrap_or(&i32::MAX);
let new_g = current_g + movement_cost;
let existing_g = *g_scores
.get(&(neighbor_bx, neighbor_by, neighbor_bz))
.unwrap_or(&i32::MAX);
if new_g < existing_g {
came_from.insert((neighbor_bx, neighbor_by, neighbor_bz), (cx, cy, cz));
g_scores.insert((neighbor_bx, neighbor_by, neighbor_bz), new_g);
let h =
octile_distance_3d_bit(neighbor_bx, neighbor_by, neighbor_bz, gbx, gby, gbz);
open_set.push(AsyncPathNode {
position: neighbor_pos,
f_score: new_g + h,
g_score: new_g,
});
}
}
}
Vec::new()
}
fn octile_distance_3d_bit(ax: u32, ay: u32, az: u32, bx: u32, by: u32, bz: u32) -> i32 {
let dx = (ax as i32 - bx as i32).abs();
let dy = (ay as i32 - by as i32).abs();
let dz = (az as i32 - bz as i32).abs();
10 * dx.max(dy).max(dz) + 4 * sort_middle(dx, dy, dz) + sort_min(dx, dy, dz)
}
fn sort_middle(a: i32, b: i32, c: i32) -> i32 {
let mut arr = [a, b, c];
arr.sort_unstable();
arr[1]
}
fn sort_min(a: i32, b: i32, c: i32) -> i32 {
let mut arr = [a, b, c];
arr.sort_unstable();
arr[0]
}
fn calculate_movement_cost_bit(dx: i32, dy: i32, dz: i32) -> i32 {
match (dx.abs(), dy.abs(), dz.abs()) {
(1, 0, 0) | (0, 1, 0) => 10,
(1, 1, 0) => 14,
(1, 0, 1) | (0, 1, 1) => 42,
(1, 1, 1) => 56,
_ => 0,
}
}
fn reconstruct_path_async(
came_from: &FxHashMap<(u32, u32, u32), (u32, u32, u32)>,
mut current: IVec3,
bit_grid: &StandableBitGrid,
) -> Vec<Vec3> {
let mut path = vec![Vec3::new(
current.x as f32,
current.y as f32,
current.z as f32,
)];
while let Some((cx, cy, cz)) = bit_grid.to_bit_coords(current) {
if let Some(&(px, py, pz)) = came_from.get(&(cx, cy, cz)) {
let prev = IVec3::new(
bit_grid.origin.x + (px as i32) * ITILE_SIZE,
bit_grid.origin.y + (py as i32) * ITILE_SIZE,
bit_grid.origin.z + (pz as i32) * ITILE_SIZE,
);
path.push(Vec3::new(prev.x as f32, prev.y as f32, prev.z as f32));
current = prev;
} else {
break;
}
}
path.reverse();
path
}
pub fn poll_async_paths(
mut commands: Commands,
mut pending_query: Query<(Entity, &mut PendingAsyncPath)>,
mut completed: ResMut<CompletedPaths>,
) {
for (entity, mut pending) in pending_query.iter_mut() {
if !pending.task.is_finished() {
continue;
}
let path: Option<Vec<Vec3>> =
futures_lite::future::block_on(futures_lite::future::poll_once(&mut pending.task));
if let Some(p) = path {
if !p.is_empty() {
completed.paths.push((pending.request_id, p));
}
commands.entity(entity).remove::<PendingAsyncPath>();
}
}
}
pub fn compute_bounding_box(start: IVec3, goal: IVec3, margin_tiles: i32) -> (IVec3, UVec3) {
let margin = margin_tiles * ITILE_SIZE;
let min_x = start.x.min(goal.x) - margin;
let max_x = start.x.max(goal.x) + margin;
let min_y = start.y.min(goal.y) - margin;
let max_y = start.y.max(goal.y) + margin;
let min_z = start.z.min(goal.z) - margin;
let max_z = start.z.max(goal.z) + margin;
let origin = IVec3::new(min_x, min_y, min_z);
let size = UVec3::new(
((max_x - min_x) / ITILE_SIZE + 1) as u32,
((max_y - min_y) / ITILE_SIZE + 1) as u32,
((max_z - min_z) / ITILE_SIZE + 1) as u32,
);
(origin, size)
}
pub fn spawn_async_path_task(
tilemap: &TileMap,
start: IVec3,
goal: IVec3,
provisional_path: Vec<Vec3>,
request_id: u64,
) -> PendingAsyncPath {
let (origin, size) = compute_bounding_box(start, goal, 20);
let bit_grid = StandableBitGrid::new(origin, size, tilemap);
let pool = AsyncComputeTaskPool::get();
let task = pool.spawn(async move { calculate_async_path(bit_grid, start, goal) });
PendingAsyncPath {
request_id,
task,
goal,
provisional_path,
provisional_path_index: 0,
}
}
+1
View File
@@ -1 +1,2 @@
pub mod async_pathfinding;
pub mod pathfinding;
+218 -36
View File
@@ -1,10 +1,15 @@
use bevy::prelude::*;
use bevy::tasks::AsyncComputeTaskPool;
use rayon::prelude::*;
use rustc_hash::FxHashMap;
use rustc_hash::FxHashSet;
use std::{cell::RefCell, collections::BinaryHeap, time::Instant};
use crate::constants::{ITILE_SIZE, PATHFINDER_MAX_NODES, TILE_SIZE};
use crate::constants::{
ITILE_SIZE, PATHFINDER_MAX_NODES, PATHFINDER_PROVISIONAL_NODE_LIMIT, TILE_SIZE,
};
use crate::entities::shared_components::PendingAsyncPath;
use crate::world::tiles::tilemap::StandableBitGrid;
use crate::world::tiles::TileMap;
use crate::world::{chunks::ChunkMap, chunks::CHUNK_SIZE};
use crate::{constants::*, entities::shared_components::Ambulatory};
@@ -136,15 +141,116 @@ impl Plugin for PathfindingPlugin {
app.insert_resource(PathfindingBenchmark::new(100))
.insert_resource(crate::entities::shared_components::CompletedPaths::default())
.insert_resource(crate::entities::shared_components::PathRequestCounter::default())
.add_systems(FixedUpdate, (update_wandering_targets, movement).chain())
.insert_resource(
crate::entities::shared_systems::async_pathfinding::AsyncPathCounter::default(),
)
.add_systems(
FixedUpdate,
(prepare_paths, update_wandering_targets, movement).chain(),
)
.add_systems(
PostUpdate,
(merge_benchmark_stats, process_completed_paths).chain(),
(
merge_benchmark_stats,
process_completed_paths,
crate::entities::shared_systems::async_pathfinding::poll_async_paths,
splice_completed_async_paths,
),
)
.add_systems(Update, bench_report_system);
}
}
pub fn prepare_paths(
mut commands: Commands,
mut counter: ResMut<crate::entities::shared_systems::async_pathfinding::AsyncPathCounter>,
mut query: Query<
(
Entity,
&mut crate::entities::shared_components::Ambulatory,
&Transform,
Option<&crate::entities::shared_components::PendingAsyncPath>,
),
Without<crate::entities::shared_components::PendingPath>,
>,
tilemap: Res<TileMap>,
) {
for (entity, mut ambulatory, transform, pending_async) in query.iter_mut() {
if let Some(target) = ambulatory.target {
if ambulatory.current_path.is_none() || pending_async.is_some() {
continue;
}
let start = transform.translation.as_ivec3();
let goal = target.as_ivec3() - ivec3(0, 0, ITILE_SIZE);
let distance = octile_distance_3d(start, goal) / ITILE_SIZE;
if distance <= PATHFINDER_SHORT_PATH_MAX_TILES {
let path = calculate_path_benchmarked(&tilemap, start, goal);
ambulatory.current_path = Some(path);
ambulatory.path_index = 0;
} else {
let provisional = calculate_provisional_path(
&tilemap,
start,
goal,
PATHFINDER_PROVISIONAL_NODE_LIMIT,
);
if !provisional.is_empty() {
ambulatory.current_path = Some(provisional.clone());
ambulatory.path_index = 0;
let request_id = counter.next();
let pending =
crate::entities::shared_systems::async_pathfinding::spawn_async_path_task(
&tilemap,
start,
goal,
provisional,
request_id,
);
commands.entity(entity).insert(pending);
} else {
let path = calculate_path_benchmarked(&tilemap, start, goal);
ambulatory.current_path = Some(path);
ambulatory.path_index = 0;
}
}
}
}
}
pub fn splice_completed_async_paths(
mut completed: ResMut<crate::entities::shared_components::CompletedPaths>,
mut query: Query<(
Entity,
&mut crate::entities::shared_components::Ambulatory,
&Transform,
)>,
) {
if completed.paths.is_empty() {
return;
}
let mut to_process = Vec::new();
for (request_id, path) in completed.paths.drain(..) {
to_process.push((request_id, path));
}
for (request_id, path) in to_process {
for (entity, mut ambulatory, _transform) in query.iter_mut() {
if ambulatory.current_path.as_ref().is_none_or(|p| p != &path) {
let splice_index = find_splice_point(&path, ambulatory.path_index);
ambulatory.current_path = Some(path.clone());
ambulatory.path_index = splice_index;
}
}
}
}
fn find_splice_point(path: &[Vec3], current_index: usize) -> usize {
current_index.min(path.len().saturating_sub(1))
}
pub fn process_completed_paths(
mut completed: ResMut<crate::entities::shared_components::CompletedPaths>,
mut query: Query<(
@@ -249,43 +355,37 @@ pub fn movement(mut query: Query<(&mut Ambulatory, &mut Transform)>, tilemap: Re
return;
}
if let Some(target) = ambulatory.target {
if ambulatory.current_path.is_none() {
ambulatory.current_path = Some(calculate_path_benchmarked(
&tilemap,
transform.translation.as_ivec3(),
target.as_ivec3() - ivec3(0, 0, 1),
));
ambulatory.path_index = 0;
if ambulatory.current_path.is_none() {
return;
}
if ambulatory.walk_speed > 0. {
if ambulatory.step_recovery <= ambulatory.walk_speed as u32 {
ambulatory.step_recovery += 1;
return;
} else {
ambulatory.step_recovery = 0;
}
if ambulatory.walk_speed > 0. {
if ambulatory.step_recovery <= ambulatory.walk_speed as u32 {
ambulatory.step_recovery += 1;
return;
} else {
ambulatory.step_recovery = 0;
}
if let Some(path) = &ambulatory.current_path {
if ambulatory.path_index < path.len() {
let next_point = path[ambulatory.path_index];
let direction = (next_point - transform.translation).normalize();
transform.translation = next_point;
if direction.x > 0.0 {
transform.scale.x = PIXEL_RATIO;
} else if direction.x < 0.0 {
transform.scale.x = -PIXEL_RATIO;
}
}
if let Some(path) = &ambulatory.current_path {
if ambulatory.path_index < path.len() {
let next_point = path[ambulatory.path_index];
let direction = (next_point - transform.translation).normalize();
transform.translation = next_point;
if direction.x > 0.0 {
transform.scale.x = PIXEL_RATIO;
} else if direction.x < 0.0 {
transform.scale.x = -PIXEL_RATIO;
}
if transform.translation.distance(next_point) < TILE_SIZE {
ambulatory.path_index += 1;
}
} else {
ambulatory.current_path = None;
ambulatory.target = None;
if transform.translation.distance(next_point) < TILE_SIZE {
ambulatory.path_index += 1;
}
} else {
ambulatory.current_path = None;
ambulatory.target = None;
}
}
});
@@ -466,6 +566,88 @@ fn calculate_path_with_scratchpad(
})
}
pub fn calculate_provisional_path(
tilemap: &TileMap,
start: IVec3,
goal: IVec3,
node_limit: usize,
) -> Vec<Vec3> {
if !is_standable_tile(tilemap, start) {
return Vec::new();
}
let estimated_tiles = octile_distance_3d(start, goal) / ITILE_SIZE;
SCRATCHPAD.with(|s| {
let mut scratch = s.borrow_mut();
let capacity = ((estimated_tiles as usize).max(64)).min(4096);
scratch.clear_and_reserve(capacity);
let initial_h = octile_distance_3d(start, goal);
scratch.open_set.push(PathNode {
position: start,
f_score: initial_h,
g_score: 0,
});
scratch.g_scores.insert(start, 0);
let mut nodes_expanded: usize = 0;
let mut best_node = start;
let mut best_h = initial_h;
while let Some(current_node) = scratch.open_set.pop() {
let current = current_node.position;
nodes_expanded += 1;
let h = octile_distance_3d(current, goal);
if h < best_h {
best_h = h;
best_node = current;
}
if current == goal {
return reconstruct_path(&scratch.came_from, current);
}
if nodes_expanded >= node_limit {
return reconstruct_path(&scratch.came_from, best_node);
}
scratch.closed_set.insert(current);
for &move_dir in &ALLOWED_MOVES {
let neighbor_pos = current + move_dir;
if !is_standable_tile(tilemap, neighbor_pos)
|| scratch.closed_set.contains(&neighbor_pos)
{
continue;
}
let movement_cost = calculate_movement_cost(move_dir);
if movement_cost == 0 {
continue;
}
let new_g = *scratch.g_scores.get(&current).unwrap_or(&i32::MAX) + movement_cost;
if new_g < *scratch.g_scores.get(&neighbor_pos).unwrap_or(&i32::MAX) {
scratch.came_from.insert(neighbor_pos, current);
scratch.g_scores.insert(neighbor_pos, new_g);
let f = new_g + octile_distance_3d(neighbor_pos, goal);
scratch.open_set.push(PathNode {
position: neighbor_pos,
f_score: f,
g_score: new_g,
});
}
}
}
reconstruct_path(&scratch.came_from, best_node)
})
}
pub fn bench_report_system(
keys: Res<ButtonInput<KeyCode>>,
mut bench: ResMut<PathfindingBenchmark>,
+97
View File
@@ -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<u64>,
}
impl StandableBitGrid {
/// Create a bit-grid snapshot of all standable tiles within bounding box.
/// origin: min corner (inclusive), snapped to ITILE_SIZE
/// size: dimensions in tiles (not pixels)
pub fn new(origin: IVec3, size: UVec3, tilemap: &TileMap) -> Self {
let total_bits = (size.x * size.y * size.z) as usize;
let words = (total_bits + 63) / 64;
let mut bits = vec![0u64; words];
for bz in 0..size.z {
for by in 0..size.y {
for bx in 0..size.x {
let pos = IVec3::new(
origin.x + (bx as i32) * ITILE_SIZE,
origin.y + (by as i32) * ITILE_SIZE,
origin.z + (bz as i32) * ITILE_SIZE,
);
if Self::tile_is_standable(tilemap, pos) {
let idx = ((bz * size.y * size.x) + (by * size.x) + bx) as usize;
bits[idx / 64] |= 1u64 << (idx % 64);
}
}
}
}
Self { origin, size, bits }
}
/// Standable check using TileMap (mirrors pathfinding.rs::is_standable_tile)
#[inline]
fn tile_is_standable(tilemap: &TileMap, pos: IVec3) -> bool {
let can_stand_in_tile = tilemap
.floor_tiles
.get(&pos)
.map(|t| t.can_stand_in())
.unwrap_or(false);
let can_stand_in_fixture = tilemap
.fixture_tiles
.get(&pos)
.map(|t| t.can_stand_in())
.unwrap_or(false);
let pos_below = pos - IVec3::new(0, 0, ITILE_SIZE);
let can_stand_on_tile_below = tilemap
.floor_tiles
.get(&pos_below)
.map(|t| t.can_stand_on())
.unwrap_or(false);
let can_stand_on_fixture_below = tilemap
.fixture_tiles
.get(&pos_below)
.map(|t| t.can_stand_on())
.unwrap_or(false);
(can_stand_in_tile || can_stand_in_fixture)
&& (can_stand_on_tile_below || can_stand_on_fixture_below)
}
/// O(1) standable check using bit-grid coordinates.
#[inline]
pub fn is_standable_at(&self, bx: u32, by: u32, bz: u32) -> bool {
if bx >= self.size.x || by >= self.size.y || bz >= self.size.z {
return false;
}
let idx = ((bz * self.size.y * self.size.x) + (by * self.size.x) + bx) as usize;
self.bits[idx / 64] & (1u64 << (idx % 64)) != 0
}
/// Convert IVec3 world position to bit-grid coordinates.
/// Returns None if position is outside the grid bounds.
#[inline]
pub fn to_bit_coords(&self, pos: IVec3) -> Option<(u32, u32, u32)> {
let local = pos - self.origin;
if local.x < 0 || local.y < 0 || local.z < 0 {
return None;
}
let bx = (local.x / ITILE_SIZE) as u32;
let by = (local.y / ITILE_SIZE) as u32;
let bz = (local.z / ITILE_SIZE) as u32;
if bx >= self.size.x || by >= self.size.y || bz >= self.size.z {
return None;
}
Some((bx, by, bz))
}
}