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:
2026-03-19 17:01:51 +00:00
parent 15c0d1c7a2
commit 50788af3c7
7 changed files with 630 additions and 188 deletions
+327
View File
@@ -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));
}
}
+2
View File
@@ -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::*;
+53
View File
@@ -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)
}
}