feat(optimization): implement data-oriented chunk architecture
Phase 1: Bit-packed standability - Add ChunkData struct with 4 bitsets per chunk (stand_in/on for floor/fixture) - Replace 4 HashMap lookups per standability check with O(1) bit operations - Memory: ~2KB bitsets per chunk vs ~50KB HashMap overhead Phase 2: Reactive connectivity - Add dirty_chunks HashSet to ChunkMap for incremental updates - update_chunk_connectivity now O(d) where d = dirty chunks - Early exit when no changes, preventing O(N) full rebuilds Phase 3: Async terrain baking - Move terrain generation to AsyncComputeTaskPool - spawn_terrain_tasks: non-blocking task spawn (~34µs) - apply_terrain_blobs: batched entity spawn on main thread - Eliminates main-thread stutters during world generation
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
//! Bit-packed per-chunk standability data for O(1) pathfinding queries.
|
||||
//!
|
||||
//! # Design
|
||||
//!
|
||||
//! Replaces 4 HashMap lookups per standability check with 4 bit-checks.
|
||||
//!
|
||||
//! ## Memory Layout
|
||||
//! - 4 bitsets × 40u32 = 640 bytes for standability
|
||||
//! - Total per chunk: ~2KB vs ~50KB+ HashMap overhead
|
||||
//!
|
||||
//! ## Index Calculation
|
||||
//! - Local coords: (0..CHUNK_SIZE, 0..CHUNK_SIZE, -Z_BELOW..Z_ABOVE)
|
||||
//! - Linear index: z * CHUNK_SIZE² + y * CHUNK_SIZE + x
|
||||
//! - Bit index: linear_index / 32 → word, linear_index % 32 → bit
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::constants::ITILE_SIZE;
|
||||
use crate::world::chunks::{CHUNK_SIZE, Z_ABOVE, Z_BELOW};
|
||||
|
||||
/// Number of z-levels in a chunk (Z_BELOW + Z_ABOVE + 1 for inclusive range).
|
||||
/// Terrain generation uses -Z_BELOW..=Z_ABOVE (inclusive at both ends).
|
||||
const Z_LEVELS: i32 = (Z_BELOW + Z_ABOVE) as i32 + 1; // 5 + 15 + 1 = 21
|
||||
|
||||
/// Number of tiles per z-level (CHUNK_SIZE²).
|
||||
const TILES_PER_LEVEL: usize = (CHUNK_SIZE * CHUNK_SIZE) as usize; // 64
|
||||
|
||||
/// Total tiles in a chunk.
|
||||
const TOTAL_TILES: usize = TILES_PER_LEVEL * (Z_LEVELS as usize); // 64 * 21 = 1344
|
||||
|
||||
/// Number of u32 words needed to store all tile bits.
|
||||
const BITSET_WORDS: usize = (TOTAL_TILES + 31) / 32; // ceil(1344/32) = 42
|
||||
|
||||
/// Per-chunk bit-packed data for O(1) standability queries.
|
||||
///
|
||||
/// Each bitset uses u32 words to cover all tiles in an 8×8×21 chunk volume.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ChunkData {
|
||||
pub chunk_pos: IVec2,
|
||||
|
||||
/// Standability bitsets - one bit per tile position.
|
||||
pub stand_in_floor: [u32; BITSET_WORDS],
|
||||
pub stand_on_floor: [u32; BITSET_WORDS],
|
||||
pub stand_in_fixture: [u32; BITSET_WORDS],
|
||||
pub stand_on_fixture: [u32; BITSET_WORDS],
|
||||
|
||||
/// Tile IDs for rendering.
|
||||
pub tile_ids: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ChunkData {
|
||||
pub fn new(chunk_pos: IVec2) -> Self {
|
||||
Self {
|
||||
chunk_pos,
|
||||
stand_in_floor: [0u32; BITSET_WORDS],
|
||||
stand_on_floor: [0u32; BITSET_WORDS],
|
||||
stand_in_fixture: [0u32; BITSET_WORDS],
|
||||
stand_on_fixture: [0u32; BITSET_WORDS],
|
||||
tile_ids: vec![0u8; TOTAL_TILES],
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert local tile coordinates to linear index.
|
||||
#[inline]
|
||||
pub fn pos_to_index(local_x: i32, local_y: i32, z: i32) -> usize {
|
||||
let z_normalized = (z + Z_BELOW as i32) as usize;
|
||||
let y = local_y as usize;
|
||||
let x = local_x as usize;
|
||||
z_normalized * TILES_PER_LEVEL + y * (CHUNK_SIZE as usize) + x
|
||||
}
|
||||
|
||||
/// Convert linear index back to local coordinates.
|
||||
#[inline]
|
||||
pub fn index_to_pos(index: usize) -> (i32, i32, i32) {
|
||||
let chunk_area = (CHUNK_SIZE * CHUNK_SIZE) as usize;
|
||||
let z_normalized = index / chunk_area;
|
||||
let remainder = index % chunk_area;
|
||||
let y = remainder / (CHUNK_SIZE as usize);
|
||||
let x = remainder % (CHUNK_SIZE as usize);
|
||||
|
||||
(x as i32, y as i32, (z_normalized as i32) - (Z_BELOW as i32))
|
||||
}
|
||||
|
||||
/// Check if a tile position is standable using O(1) bit checks.
|
||||
///
|
||||
/// This replaces 4 HashMap lookups with 4 bit-checks.
|
||||
///
|
||||
/// # Standability Logic
|
||||
/// An entity can stand at position (x, y, z) if:
|
||||
/// - (can_stand_in_floor OR can_stand_in_fixture) at (x, y, z)
|
||||
/// - AND (can_stand_on_floor OR can_stand_on_fixture) at (x, y, z-1)
|
||||
#[inline]
|
||||
pub fn is_standable(&self, local_x: i32, local_y: i32, z: i32) -> bool {
|
||||
// Bounds check: z must be within -Z_BELOW..Z_ABOVE
|
||||
if z < -(Z_BELOW as i32) || z > (Z_ABOVE as i32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bounds check: local coords must be within chunk
|
||||
if local_x < 0 || local_x >= CHUNK_SIZE || local_y < 0 || local_y >= CHUNK_SIZE {
|
||||
return false;
|
||||
}
|
||||
|
||||
let idx = Self::pos_to_index(local_x, local_y, z);
|
||||
let word = idx / 32;
|
||||
let bit = idx % 32;
|
||||
let mask = 1u32 << bit;
|
||||
|
||||
let in_floor = (self.stand_in_floor[word] & mask) != 0;
|
||||
let in_fixture = (self.stand_in_fixture[word] & mask) != 0;
|
||||
|
||||
// Can't stand at the very bottom of the world
|
||||
if z <= -(Z_BELOW as i32) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check tile below for "stand on"
|
||||
let below_idx = Self::pos_to_index(local_x, local_y, z - 1);
|
||||
let below_word = below_idx / 32;
|
||||
let below_bit = below_idx % 32;
|
||||
let below_mask = 1u32 << below_bit;
|
||||
|
||||
let on_floor = (self.stand_on_floor[below_word] & below_mask) != 0;
|
||||
let on_fixture = (self.stand_on_fixture[below_word] & below_mask) != 0;
|
||||
|
||||
(in_floor || in_fixture) && (on_floor || on_fixture)
|
||||
}
|
||||
|
||||
/// Set standability bits for a tile position during terrain generation.
|
||||
#[inline]
|
||||
pub fn set_tile(
|
||||
&mut self,
|
||||
local_x: i32,
|
||||
local_y: i32,
|
||||
z: i32,
|
||||
tile_id: u8,
|
||||
can_stand_in_floor: bool,
|
||||
can_stand_on_floor: bool,
|
||||
can_stand_in_fixture: bool,
|
||||
can_stand_on_fixture: bool,
|
||||
) {
|
||||
let idx = Self::pos_to_index(local_x, local_y, z);
|
||||
let word = idx / 32;
|
||||
let bit = idx % 32;
|
||||
let mask = 1u32 << bit;
|
||||
|
||||
// Set/clear floor bits
|
||||
if can_stand_in_floor {
|
||||
self.stand_in_floor[word] |= mask;
|
||||
} else {
|
||||
self.stand_in_floor[word] &= !mask;
|
||||
}
|
||||
|
||||
if can_stand_on_floor {
|
||||
self.stand_on_floor[word] |= mask;
|
||||
} else {
|
||||
self.stand_on_floor[word] &= !mask;
|
||||
}
|
||||
|
||||
// Set/clear fixture bits
|
||||
if can_stand_in_fixture {
|
||||
self.stand_in_fixture[word] |= mask;
|
||||
} else {
|
||||
self.stand_in_fixture[word] &= !mask;
|
||||
}
|
||||
|
||||
if can_stand_on_fixture {
|
||||
self.stand_on_fixture[word] |= mask;
|
||||
} else {
|
||||
self.stand_on_fixture[word] &= !mask;
|
||||
}
|
||||
|
||||
// Set tile ID
|
||||
self.tile_ids[idx] = tile_id;
|
||||
}
|
||||
|
||||
/// Set only floor standability bits (for terrain generation).
|
||||
#[inline]
|
||||
pub fn set_floor_tile(
|
||||
&mut self,
|
||||
local_x: i32,
|
||||
local_y: i32,
|
||||
z: i32,
|
||||
tile_id: u8,
|
||||
can_stand_in: bool,
|
||||
can_stand_on: bool,
|
||||
) {
|
||||
let idx = Self::pos_to_index(local_x, local_y, z);
|
||||
let word = idx / 32;
|
||||
let bit = idx % 32;
|
||||
let mask = 1u32 << bit;
|
||||
|
||||
if can_stand_in {
|
||||
self.stand_in_floor[word] |= mask;
|
||||
} else {
|
||||
self.stand_in_floor[word] &= !mask;
|
||||
}
|
||||
|
||||
if can_stand_on {
|
||||
self.stand_on_floor[word] |= mask;
|
||||
} else {
|
||||
self.stand_on_floor[word] &= !mask;
|
||||
}
|
||||
|
||||
self.tile_ids[idx] = tile_id;
|
||||
}
|
||||
|
||||
/// Set only fixture standability bits (for forestry generation).
|
||||
#[inline]
|
||||
pub fn set_fixture_tile(
|
||||
&mut self,
|
||||
local_x: i32,
|
||||
local_y: i32,
|
||||
z: i32,
|
||||
can_stand_in: bool,
|
||||
can_stand_on: bool,
|
||||
) {
|
||||
let idx = Self::pos_to_index(local_x, local_y, z);
|
||||
let word = idx / 32;
|
||||
let bit = idx % 32;
|
||||
let mask = 1u32 << bit;
|
||||
|
||||
if can_stand_in {
|
||||
self.stand_in_fixture[word] |= mask;
|
||||
} else {
|
||||
self.stand_in_fixture[word] &= !mask;
|
||||
}
|
||||
|
||||
if can_stand_on {
|
||||
self.stand_on_fixture[word] |= mask;
|
||||
} else {
|
||||
self.stand_on_fixture[word] &= !mask;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get tile ID at position.
|
||||
#[inline]
|
||||
pub fn get_tile_id(&self, local_x: i32, local_y: i32, z: i32) -> u8 {
|
||||
let idx = Self::pos_to_index(local_x, local_y, z);
|
||||
self.tile_ids[idx]
|
||||
}
|
||||
|
||||
/// Check if this chunk has any tiles populated.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
// Check if all tile IDs are zero
|
||||
self.tile_ids.iter().all(|&id| id == 0)
|
||||
}
|
||||
|
||||
/// Clear all data, resetting to empty state.
|
||||
pub fn clear(&mut self) {
|
||||
self.stand_in_floor.fill(0);
|
||||
self.stand_on_floor.fill(0);
|
||||
self.stand_in_fixture.fill(0);
|
||||
self.stand_on_fixture.fill(0);
|
||||
self.tile_ids.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper functions for coordinate conversion.
|
||||
impl ChunkData {
|
||||
/// Convert world position to chunk-local position.
|
||||
#[inline]
|
||||
pub fn world_to_local(world_pos: IVec3) -> (i32, i32, i32) {
|
||||
let local_x = ((world_pos.x / ITILE_SIZE) % CHUNK_SIZE + CHUNK_SIZE) % CHUNK_SIZE;
|
||||
let local_y = ((world_pos.y / ITILE_SIZE) % CHUNK_SIZE + CHUNK_SIZE) % CHUNK_SIZE;
|
||||
let z = world_pos.z / ITILE_SIZE;
|
||||
(local_x, local_y, z)
|
||||
}
|
||||
|
||||
/// Convert chunk position + local position back to world position.
|
||||
#[inline]
|
||||
pub fn local_to_world(chunk_pos: IVec2, local_x: i32, local_y: i32, z: i32) -> IVec3 {
|
||||
IVec3::new(
|
||||
(chunk_pos.x * CHUNK_SIZE + local_x) * ITILE_SIZE,
|
||||
(chunk_pos.y * CHUNK_SIZE + local_y) * ITILE_SIZE,
|
||||
z * ITILE_SIZE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_index_roundtrip() {
|
||||
for x in 0..CHUNK_SIZE {
|
||||
for y in 0..CHUNK_SIZE {
|
||||
for z in -(Z_BELOW as i32)..=(Z_ABOVE as i32) {
|
||||
let idx = ChunkData::pos_to_index(x, y, z);
|
||||
let (rx, ry, rz) = ChunkData::index_to_pos(idx);
|
||||
assert_eq!((x, y, z), (rx, ry, rz), "Failed for ({}, {}, {})", x, y, z);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bit_set_clear() {
|
||||
let mut chunk = ChunkData::new(IVec2::new(0, 0));
|
||||
|
||||
// Set a tile at (2, 3, 1)
|
||||
chunk.set_floor_tile(2, 3, 1, 42, true, true);
|
||||
|
||||
assert!(chunk.is_standable(2, 3, 1));
|
||||
|
||||
// Check that setting clears properly
|
||||
chunk.set_floor_tile(2, 3, 1, 0, false, false);
|
||||
|
||||
// Need fixture or floor below to stand
|
||||
assert!(!chunk.is_standable(2, 3, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_standability_logic() {
|
||||
let mut chunk = ChunkData::new(IVec2::new(0, 0));
|
||||
|
||||
// Set floor at z=1 that you can stand ON
|
||||
chunk.set_floor_tile(0, 0, 1, 1, false, true);
|
||||
|
||||
// Set floor at z=2 that you can stand IN (air)
|
||||
chunk.set_floor_tile(0, 0, 2, 0, true, false);
|
||||
|
||||
// Should be standable at z=2: in_air AND on_floor_below
|
||||
assert!(chunk.is_standable(0, 0, 2));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
pub mod chunk_data;
|
||||
pub mod components;
|
||||
pub mod prefabs;
|
||||
pub mod rendering;
|
||||
pub mod tilemap;
|
||||
pub mod visibility;
|
||||
|
||||
pub use chunk_data::*;
|
||||
pub use components::*;
|
||||
pub use prefabs::*;
|
||||
pub use rendering::*;
|
||||
|
||||
@@ -12,12 +12,18 @@
|
||||
//! ~18 bytes vs 48 bytes. Bit-packing flags (can_stand_in/on, visibly_transparent)
|
||||
//! reduces memory footprint and improves cache locality.
|
||||
//!
|
||||
//! ## ChunkData for O(1) Standability
|
||||
//! Each chunk stores bit-packed standability data. The `is_standable()` method
|
||||
//! checks chunk data first (4 bit-checks) before falling back to HashMap lookups.
|
||||
//! This replaces 4 HashMap lookups with O(1) bit operations.
|
||||
//!
|
||||
//! ## Single-Threaded Access
|
||||
//! No Arc wrapper because pathfinding runs on the main thread using thread-local
|
||||
//! scratchpads. Async pathfinding was attempted but snapshot copying overhead
|
||||
//! exceeded the benefit given current P99 (~357µs).
|
||||
//!
|
||||
//! ## Memory Layout
|
||||
//! - chunks: O(1) standability lookups via bitsets (~2KB per chunk)
|
||||
//! - floor_tiles: Primary pathfinding data (standability checks)
|
||||
//! - fixture_tiles: Secondary checks (fixtures can be standable)
|
||||
//! - item_tiles: Entity references per tile position
|
||||
@@ -25,6 +31,9 @@
|
||||
use bevy::prelude::*;
|
||||
use rustc_hash::FxHashMap;
|
||||
|
||||
use super::chunk_data::ChunkData;
|
||||
use crate::world::chunks::world_to_chunk;
|
||||
|
||||
/// Packed floor tile data for efficient storage. ~35 bytes vs 76 bytes tuple.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct FloorTileData {
|
||||
@@ -162,8 +171,13 @@ impl FixtureTileData {
|
||||
/// Tile map using FxHashMap for fast lookups. No Arc wrapper - single-threaded access.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct TileMap {
|
||||
/// O(1) standability lookups via bitsets (~2KB per chunk).
|
||||
pub chunks: FxHashMap<IVec2, ChunkData>,
|
||||
/// Primary tile storage for pathfinding (fallback for standability).
|
||||
pub floor_tiles: FxHashMap<IVec3, FloorTileData>,
|
||||
/// Secondary tile storage (fixtures like trees can be standable).
|
||||
pub fixture_tiles: FxHashMap<IVec3, FixtureTileData>,
|
||||
/// Entity references per tile position.
|
||||
pub item_tiles: FxHashMap<IVec3, Vec<u32>>,
|
||||
}
|
||||
|
||||
@@ -216,4 +230,43 @@ impl TileMap {
|
||||
pub fn get_floor_mut(&mut self, pos: &IVec3) -> Option<&mut FloorTileData> {
|
||||
self.floor_tiles.get_mut(pos)
|
||||
}
|
||||
|
||||
/// O(1) standability check using bit-packed chunk data.
|
||||
/// Falls back to HashMap lookups if chunk data is not available.
|
||||
pub fn is_standable(&self, world_pos: IVec3) -> bool {
|
||||
let chunk_pos = world_to_chunk(world_pos);
|
||||
if let Some(chunk) = self.chunks.get(&chunk_pos) {
|
||||
let (local_x, local_y, z) = ChunkData::world_to_local(world_pos);
|
||||
return chunk.is_standable(local_x, local_y, z);
|
||||
}
|
||||
self.is_standable_slow(world_pos)
|
||||
}
|
||||
|
||||
/// Fallback standability check using HashMap lookups.
|
||||
fn is_standable_slow(&self, pos: IVec3) -> bool {
|
||||
let can_stand_in_floor = self
|
||||
.floor_tiles
|
||||
.get(&pos)
|
||||
.map(|t| t.can_stand_in())
|
||||
.unwrap_or(false);
|
||||
let can_stand_in_fixture = self
|
||||
.fixture_tiles
|
||||
.get(&pos)
|
||||
.map(|t| t.can_stand_in())
|
||||
.unwrap_or(false);
|
||||
|
||||
let pos_below = IVec3::new(pos.x, pos.y, pos.z - crate::constants::ITILE_SIZE);
|
||||
let can_stand_on_floor = self
|
||||
.floor_tiles
|
||||
.get(&pos_below)
|
||||
.map(|t| t.can_stand_on())
|
||||
.unwrap_or(false);
|
||||
let can_stand_on_fixture = self
|
||||
.fixture_tiles
|
||||
.get(&pos_below)
|
||||
.map(|t| t.can_stand_on())
|
||||
.unwrap_or(false);
|
||||
|
||||
(can_stand_in_floor || can_stand_in_fixture) && (can_stand_on_floor || can_stand_on_fixture)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user