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
+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)
}
}