From d7cbbaa04bd84a3c82baf2608a5da4a782400127 Mon Sep 17 00:00:00 2001 From: popertots Date: Fri, 20 Mar 2026 10:30:33 +0000 Subject: [PATCH] feat: implement chunk unloading infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chunk unloading system with cleanly abstracted decision layer. - ChunkMap: replace (bool, i32) timer tuple with () presence marker; remove FIFO cycle fields pending_unload/unload_cycle/unload_cursor/ unload_countdown. loaded_chunks is now HashMap. - handle_chunk_events: simplify re-entrancy check to contains_key; remove parallel Mutex dedup machinery — load detection is now trivial. - is_standable: single HashMap get; return false for unloaded chunks instead of falling back to is_standable_slow (pathfinding correctness). - remove_chunk_data: bounds-iteration tile cleanup for chunk unload. - ChunkOwner component + chunk_entity_index: static terrain entities (floor tiles, fixtures, trees) tagged at spawn and despawned by chunk. Mobile entities (dorfs, pigs, rabbits) not tracked — safe by design since they were never indexed. - unload_chunk: canonical single-chunk unload function; cleans loaded_chunks, despawns entities, removes tilemap data, marks render chunk dirty via ChunkZKey::from_world, updates connectivity, fires rebake. Full docstring with step-by-step state map, working example for dynamic unload, and "what it does NOT handle" section. - dynamic_chunk_unloading_system: no-op stub. Re-enable by writing a system that diffs wanted vs loaded chunks and calls unload_chunk. Wired out of FixedUpdate during foundation work. - render chunk fix: ChunkZKey::from_world now pub(crate) so unload can derive the correct render chunk key (spans 4x4 world chunks) matching what build_quilted_terrain_sprites uses at spawn. - FloorTilePrefab::spawn: pre-existing bug — return Entity not (). --- src/world/chunks/management.rs | 204 +++++++++++++++++++++++-------- src/world/generation/forestry.rs | 33 ++++- src/world/generation/terrain.rs | 11 +- src/world/mod.rs | 8 +- src/world/tiles/prefabs.rs | 6 +- src/world/tiles/rendering.rs | 2 +- src/world/tiles/tilemap.rs | 36 +++++- 7 files changed, 237 insertions(+), 63 deletions(-) diff --git a/src/world/chunks/management.rs b/src/world/chunks/management.rs index e8a517b..36f56a1 100644 --- a/src/world/chunks/management.rs +++ b/src/world/chunks/management.rs @@ -1,9 +1,16 @@ use bevy::prelude::*; use bevy_platform::collections::HashMap; -use bevy_platform::sync::Mutex; use std::collections::HashSet; -use crate::world::{tiles::TileMap, CurrentWorldSpriteState, TerrainSpriteState}; +use crate::world::{ + tiles::{ChunkZKey, QuiltCache, TileMap}, + CurrentWorldSpriteState, TerrainSpriteState, +}; + +/// Marks an entity as belonging to a specific chunk. Used for O(1) entity despawn +/// when the chunk is unloaded. +#[derive(Component)] +pub struct ChunkOwner(pub IVec2); pub const CHUNK_SIZE: i32 = 8; pub const CHUNK_SIZE_TILE: i32 = CHUNK_SIZE * crate::constants::ITILE_SIZE; @@ -39,15 +46,15 @@ pub const Z_BELOW: f32 = 5.0; pub const Z_ABOVE: f32 = 15.0; pub const Z_TOTAL: f32 = Z_ABOVE + Z_BELOW; -const _: () = assert!(Z_TOTAL <= 255.0); +const _: () = assert!((Z_BELOW + Z_ABOVE) as usize <= 255); #[derive(Resource)] pub struct ChunkMap { - pub loaded_chunks: HashMap, + /// All currently loaded chunk positions. The `()` value is just presence. + pub loaded_chunks: HashMap, pub chunk_connectivity: HashMap>, - /// Chunks that need connectivity updates. Only these chunks are processed - /// each frame instead of rebuilding the entire graph. pub dirty_chunks: HashSet, + pub chunk_entity_index: HashMap>, } impl Default for ChunkMap { @@ -56,6 +63,7 @@ impl Default for ChunkMap { loaded_chunks: HashMap::new(), chunk_connectivity: HashMap::new(), dirty_chunks: HashSet::new(), + chunk_entity_index: HashMap::new(), } } } @@ -104,13 +112,12 @@ pub fn handle_chunk_events( let mut any = false; for event in chunk_events.par_read() { let chunk_pos = event.0.chunk_position; - if let Some((true, _)) = chunk_map.loaded_chunks.get(&chunk_pos) { + if chunk_map.loaded_chunks.contains_key(&chunk_pos) { continue; } any = true; - let chunk_map_updates: Mutex> = Mutex::new(HashMap::new()); - // Mark chunk as loaded in our thread-safe collection - chunk_map_updates.lock().unwrap().insert(chunk_pos, true); + chunk_map.loaded_chunks.insert(chunk_pos, ()); + chunk_map.dirty_chunks.insert(chunk_pos); terrain_event_writer.write(ChunkTerrainEvent { chunk_position: chunk_pos, }); @@ -123,41 +130,12 @@ pub fn handle_chunk_events( fauna_event_writer.write(ChunkFaunaEvent { chunk_position: chunk_pos, }); - // Apply all collected updates to the actual resources - for (chunk_pos, value) in chunk_map_updates.into_inner().unwrap() { - chunk_map.loaded_chunks.insert(chunk_pos, (value, 1800)); - chunk_map.dirty_chunks.insert(chunk_pos); - } } if any { cwss.state = TerrainSpriteState::WaitingForRender; } } -pub fn chunkmap_despawn_timer_system( - mut chunk_map: ResMut, - mut cwss: ResMut, -) { - let mut newly_unloaded: Vec = Vec::new(); - - for (chunk_pos, (is_loaded, timer)) in chunk_map.loaded_chunks.iter_mut() { - if !*is_loaded { - continue; - } - if *timer > 0 { - *timer -= 1; - } else { - *is_loaded = false; - newly_unloaded.push(*chunk_pos); - cwss.state = TerrainSpriteState::WaitingForRender; - } - } - - for chunk_pos in newly_unloaded { - chunk_map.dirty_chunks.insert(chunk_pos); - } -} - pub fn update_chunk_connectivity(mut chunk_map: ResMut) { if chunk_map.dirty_chunks.is_empty() { return; @@ -166,13 +144,7 @@ pub fn update_chunk_connectivity(mut chunk_map: ResMut) { let dirty_chunks: Vec = chunk_map.dirty_chunks.drain().collect(); for chunk_pos in dirty_chunks { - let is_loaded = chunk_map - .loaded_chunks - .get(&chunk_pos) - .map(|(loaded, _)| *loaded) - .unwrap_or(false); - - if !is_loaded { + if !chunk_map.loaded_chunks.contains_key(&chunk_pos) { chunk_map.chunk_connectivity.remove(&chunk_pos); for neighbor in get_chunk_neighbors(chunk_pos) { if let Some(neighbors) = chunk_map.chunk_connectivity.get_mut(&neighbor) { @@ -184,7 +156,7 @@ pub fn update_chunk_connectivity(mut chunk_map: ResMut) { let mut connected_chunks = HashSet::new(); for neighbor in get_chunk_neighbors(chunk_pos) { - if let Some((true, _)) = chunk_map.loaded_chunks.get(&neighbor) { + if chunk_map.loaded_chunks.contains_key(&neighbor) { connected_chunks.insert(neighbor); } } @@ -193,7 +165,7 @@ pub fn update_chunk_connectivity(mut chunk_map: ResMut) { .insert(chunk_pos, connected_chunks); for neighbor in get_chunk_neighbors(chunk_pos) { - if let Some((true, _)) = chunk_map.loaded_chunks.get(&neighbor) { + if chunk_map.loaded_chunks.contains_key(&neighbor) { if let Some(neighbors) = chunk_map.chunk_connectivity.get_mut(&neighbor) { neighbors.insert(chunk_pos); } @@ -201,3 +173,139 @@ pub fn update_chunk_connectivity(mut chunk_map: ResMut) { } } } + +/// Unloads a single chunk, cleaning up all associated state. +/// +/// ## What gets cleaned up (in order) +/// +/// 1. **`loaded_chunks`** — removes the chunk from the set of loaded chunks. +/// Subsequent pathfinding and tile lookups treat it as unloaded. +/// 2. **`chunk_entity_index`** — retrieves and despawns all ECS entities registered +/// to this chunk. Only terrain entities (floor tiles, fixtures, trees) are indexed +/// here. Mobile entities (dorfs, pigs, rabbits) are not tracked by chunk and must +/// be handled separately if needed. +/// 3. **`tilemap`** — removes all floor, fixture, and item tile data for this chunk +/// from the TileMap HashMaps. `is_standable` will return false for these tiles +/// after this step. +/// 4. **`quilt_cache`** — marks all z-levels of the render chunk containing this +/// world chunk as dirty. The next `build_quilted_terrain_sprites` call will +/// despawn the old sprites and rebake the render chunk with remaining tiles. +/// A render chunk spans 4×4 world chunks (CHUNK_TILES=32 / CHUNK_SIZE=8). +/// The render chunk key is derived via `ChunkZKey::from_world`. +/// 5. **`dirty_chunks`** — marks the chunk dirty so `update_chunk_connectivity` +/// removes it from the pathfinding graph and cleans up neighbor references. +/// 6. **`cwss`** — sets state to `WaitingForRender` so the terrain sprite +/// rebake fires in the next Update schedule. +/// +/// ## Example: dynamic unload based on player/NPC distance +/// +/// ```ignore +/// pub fn dynamic_chunk_unloading_system( +/// mut commands: Commands, +/// mut tilemap: ResMut, +/// mut chunk_map: ResMut, +/// mut quilt_cache: ResMut, +/// mut cwss: ResMut, +/// camera: Query<&Transform, With>, +/// npcs: Query<(&Ambulatory, &Transform)>, +/// ) { +/// // Build the set of chunks that should be loaded +/// let mut wanted_chunks: HashSet = HashSet::new(); +/// let unload_radius = 12; +/// +/// // Player chunk +/// if let Ok(cam) = camera.get_single() { +/// let cx = (cam.translation.x / (CHUNK_SIZE * TILE_SIZE) as f32).floor() as i32; +/// let cy = (cam.translation.y / (CHUNK_SIZE * TILE_SIZE) as f32).floor() as i32; +/// for dx in -unload_radius..=unload_radius { +/// for dy in -unload_radius..=unload_radius { +/// wanted_chunks.insert(IVec2::new(cx + dx, cy + dy)); +/// } +/// } +/// } +/// +/// // NPC chunks +/// for (_, transform) in &npcs { +/// let cx = (transform.translation.x / (CHUNK_SIZE * TILE_SIZE) as f32).floor() as i32; +/// let cy = (transform.translation.y / (CHUNK_SIZE * TILE_SIZE) as f32).floor() as i32; +/// for dx in -unload_radius..=unload_radius { +/// for dy in -unload_radius..=unload_radius { +/// wanted_chunks.insert(IVec2::new(cx + dx, cy + dy)); +/// } +/// } +/// } +/// +/// // Diff: unload chunks no longer in the wanted set +/// for (&chunk_pos, ()) in chunk_map.loaded_chunks.iter() { +/// if !wanted_chunks.contains(&chunk_pos) { +/// unload_chunk( +/// commands.as_mut(), +/// tilemap.as_mut(), +/// chunk_map.as_mut(), +/// quilt_cache.as_mut(), +/// cwss.as_mut(), +/// chunk_pos, +/// ); +/// } +/// } +/// } +/// ``` +/// +/// ## What this does NOT handle +/// +/// - **Loading chunks**: emit `GenerateChunkEvent` messages to trigger chunk loading. +/// A chunk must be loaded via `handle_chunk_events` before it can be unloaded here. +/// - **Mobile entities**: dorfs/pigs/rabbits are not in `chunk_entity_index`. If you +/// need to despawn them when they enter an unloaded chunk, check their current +/// chunk via `world_to_chunk(transform.translation)` and handle them separately. +pub fn unload_chunk( + mut commands: Commands, + mut tilemap: ResMut, + mut chunk_map: ResMut, + mut quilt_cache: ResMut, + mut cwss: ResMut, + chunk_pos: IVec2, +) { + if !chunk_map.loaded_chunks.contains_key(&chunk_pos) { + return; + } + chunk_map.loaded_chunks.remove(&chunk_pos); + + if let Some(entities) = chunk_map.chunk_entity_index.remove(&chunk_pos) { + for entity in entities { + commands.entity(entity).despawn(); + } + } + + tilemap.remove_chunk_data(chunk_pos); + + let world_x = chunk_pos.x as f32 * CHUNK_SIZE_TILE as f32; + let world_y = chunk_pos.y as f32 * CHUNK_SIZE_TILE as f32; + for z in 0..=(Z_TOTAL as usize) { + quilt_cache + .dirty_keys + .insert(ChunkZKey::from_world(world_x, world_y, z)); + } + + chunk_map.dirty_chunks.insert(chunk_pos); + cwss.state = TerrainSpriteState::WaitingForRender; +} + +/// Placeholder: drives dynamic chunk unloading based on player/NPC interest radius. +/// Currently a no-op — unloads are disabled during foundation work. +/// To enable: replace the body with logic that diffs wanted chunks against +/// loaded_chunks and calls unload_chunk() for chunks that fell out of range. +pub fn dynamic_chunk_unloading_system(_: Commands) {} + +/// Stub that drains queued unloads. Kept for when dynamic_unloading_system +/// re-queues chunks — wire it back into the FixedUpdate schedule then. +#[allow(clippy::too_many_arguments)] +pub fn handle_chunk_unloading( + mut _commands: Commands, + mut _tilemap: ResMut, + mut _chunk_map: ResMut, + mut _quilt_cache: ResMut, +) { + // TODO: re-enable when dynamic_unloading_system queues chunks + // For now, dynamic_chunk_unloading_system calls unload_chunk() directly +} diff --git a/src/world/generation/forestry.rs b/src/world/generation/forestry.rs index 4decbb3..e5cb370 100644 --- a/src/world/generation/forestry.rs +++ b/src/world/generation/forestry.rs @@ -12,7 +12,8 @@ use crate::{ constants::{SEED, TILE_SIZE}, world::{ tiles::{FixtureTileData, TileMap}, - ChunkForrestryEvent, FixtureTilePrefab, TextureIDs, Textures, VisibleGameEntity, + ChunkForrestryEvent, ChunkMap, ChunkOwner, FixtureTilePrefab, TextureIDs, Textures, + VisibleGameEntity, }, }; @@ -22,11 +23,14 @@ pub fn generate_chunk_forrestry( mut tilemap: ResMut, texture_ids: Res, textures: Res, + mut chunk_map: ResMut, ) { let start = Instant::now(); let count = events.len(); let collected_tilemap_updates: Mutex> = Mutex::new(Vec::new()); + // Collect (chunk_pos, entity) pairs from parallel section for chunk_entity_index + let collected_entities: Mutex> = Mutex::new(Vec::new()); events.par_read().for_each(|event| { let floor_positions = &event.floor_tiles; @@ -71,6 +75,14 @@ pub fn generate_chunk_forrestry( let trunk_entity = FixtureTilePrefab::log(trunk_pos).spawn(&mut commands); tree_positions.push(trunk_pos); + commands + .entity(trunk_entity) + .insert(ChunkOwner(event.chunk_position)); + collected_entities + .lock() + .unwrap() + .push((event.chunk_position, trunk_entity)); + // Add sprite component to the same entity if texture exists if let Some(texture_id) = texture_ids.refs.get(&500004) { if let Some(texture) = textures.handles.get(texture_id) { @@ -142,7 +154,14 @@ pub fn generate_chunk_forrestry( ), )) .id(); - commands.entity(leaf).insert(VisibleGameEntity); + commands.entity(leaf).insert(( + VisibleGameEntity, + ChunkOwner(event.chunk_position), + )); + collected_entities + .lock() + .unwrap() + .push((event.chunk_position, leaf)); collected_tilemap_updates.lock().unwrap().push( ( ivec, @@ -169,6 +188,16 @@ pub fn generate_chunk_forrestry( for (ivec, data) in collected_updates { tilemap.insert_fixture(ivec, data); } + + // Populate chunk_entity_index from collected entities (after parallel section) + let collected = collected_entities.into_inner().unwrap(); + for (chunk_pos, entity) in collected { + chunk_map + .chunk_entity_index + .entry(chunk_pos) + .or_default() + .push(entity); + } if count > 0 { println!( "Forrestry update for {:?} chunks in {:.2?}", diff --git a/src/world/generation/terrain.rs b/src/world/generation/terrain.rs index 096ad69..05701bc 100644 --- a/src/world/generation/terrain.rs +++ b/src/world/generation/terrain.rs @@ -10,7 +10,7 @@ use crate::{ world::{ tiles::{ChunkData, FloorTileData, TileMap, TerrainSpriteState, CurrentWorldSpriteState}, ChunkForrestryEvent, ChunkTerrainEvent, FloorTilePrefab, TileOcclusionEvent, CHUNK_SIZE, - Z_ABOVE, Z_BELOW, + Z_ABOVE, Z_BELOW, ChunkMap, ChunkOwner, }, }; @@ -297,6 +297,7 @@ pub fn apply_terrain_blobs( mut commands: Commands, blob_storage: Res, mut tilemap: ResMut, + mut chunk_map: ResMut, mut forrestry_event_writer: MessageWriter, mut occlusion_event_writer: MessageWriter, ) { @@ -318,7 +319,13 @@ pub fn apply_terrain_blobs( tilemap.chunks.insert(blob.chunk_pos, blob.chunk_data); for (_position, prefab) in blob.tile_spawns { - prefab.spawn(&mut commands); + let entity = prefab.spawn(&mut commands); + commands.entity(entity).insert(ChunkOwner(blob.chunk_pos)); + chunk_map + .chunk_entity_index + .entry(blob.chunk_pos) + .or_default() + .push(entity); } for pos in new_positions { diff --git a/src/world/mod.rs b/src/world/mod.rs index a16b039..f211d81 100644 --- a/src/world/mod.rs +++ b/src/world/mod.rs @@ -37,15 +37,19 @@ impl Plugin for WorldPlugin { .add_systems( FixedUpdate, ( + // === CHUNK LOADING PIPELINE === handle_chunk_events, + update_chunk_connectivity, + + // === CHUNK GENERATION (triggered by events above) === spawn_terrain_tasks, apply_terrain_blobs, generate_chunk_weathering_and_precipitation, generate_chunk_forrestry, generate_chunk_foliage, generate_chunk_fauna, - chunkmap_despawn_timer_system, - update_chunk_connectivity, + + // === DYNAMIC UNLOAD (disabled — see dynamic_chunk_unloading_system) === ), ) .add_systems( diff --git a/src/world/tiles/prefabs.rs b/src/world/tiles/prefabs.rs index 5e3fa00..28c989f 100644 --- a/src/world/tiles/prefabs.rs +++ b/src/world/tiles/prefabs.rs @@ -107,8 +107,10 @@ impl FloorTilePrefab { } } - pub fn spawn(self, commands: &mut Commands) { - commands.spawn((self.tile, self.transform, self.tile_state, self.visibility)); + pub fn spawn(self, commands: &mut Commands) -> Entity { + commands + .spawn((self.tile, self.transform, self.tile_state, self.visibility)) + .id() } } diff --git a/src/world/tiles/rendering.rs b/src/world/tiles/rendering.rs index 6a47d6d..232298a 100644 --- a/src/world/tiles/rendering.rs +++ b/src/world/tiles/rendering.rs @@ -44,7 +44,7 @@ pub struct ChunkZKey { impl ChunkZKey { #[inline] - fn from_world(world_x: f32, world_y: f32, z_index: usize) -> Self { + pub(crate) fn from_world(world_x: f32, world_y: f32, z_index: usize) -> Self { Self { chunk_x: (world_x / (TILE_SIZE * CHUNK_TILES as f32)).floor() as i32, chunk_y: (world_y / (TILE_SIZE * CHUNK_TILES as f32)).floor() as i32, diff --git a/src/world/tiles/tilemap.rs b/src/world/tiles/tilemap.rs index c5e76df..dafc9ee 100644 --- a/src/world/tiles/tilemap.rs +++ b/src/world/tiles/tilemap.rs @@ -232,14 +232,14 @@ impl TileMap { } /// O(1) standability check using bit-packed chunk data. - /// Falls back to HashMap lookups if chunk data is not available. + /// Returns false if chunk is not loaded (unloaded chunks have no valid tiles). 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) + let Some(chunk) = self.chunks.get(&chunk_pos) else { + return false; + }; + let (local_x, local_y, z) = ChunkData::world_to_local(world_pos); + chunk.is_standable(local_x, local_y, z) } /// Fallback standability check using HashMap lookups. @@ -283,4 +283,28 @@ impl TileMap { .map(|t| t.astar_weight) .unwrap_or(100) } + + /// Remove all tile data for a specific chunk from the TileMap. + /// Iterates all positions in the chunk volume and removes from HashMaps. + /// Used during chunk unloading to clean up tile data. + pub fn remove_chunk_data(&mut self, chunk_pos: IVec2) { + use crate::constants::ITILE_SIZE; + use crate::world::chunks::{CHUNK_SIZE, Z_ABOVE, Z_BELOW}; + + for local_x in 0..CHUNK_SIZE { + for local_y in 0..CHUNK_SIZE { + for z in -Z_BELOW as i32..=Z_ABOVE as i32 { + let pos = IVec3::new( + chunk_pos.x * CHUNK_SIZE + local_x, + chunk_pos.y * CHUNK_SIZE + local_y, + z, + ) * ITILE_SIZE; + self.floor_tiles.remove(&pos); + self.fixture_tiles.remove(&pos); + self.item_tiles.remove(&pos); + } + } + } + self.chunks.remove(&chunk_pos); + } }