feat: migrate quilter rendering to Bevy TilemapChunk
Replaces the CPU pixel-baking quilter system (QuiltCache, TerrainSprite, pixel_buffers) with Bevy's native TilemapChunk GPU-index-lookup API. Architecture: - TilemapChunk entities replace TerrainSprite entities per (chunk, z, layer) - Tileset PNG stacked vertically as texture_2d_array (11 rows: 6 floor + 5 fixture) - populate_chunk_tiles reads TileMap HashMap directly; no ECS FloorTile entities - Chunk spawn deferred to apply_terrain_blobs (after TileMap data exists) - Dirty key system triggers repopulate on occlusion/camera-z changes Bug fixes: - populate_chunk_tiles: add chunk_pos offset to world tile lookups (was always (0,0)) - spawn_tilemap_chunks: offset Transform by -TILE_SIZE/2 (tile-centre vs bottom-left) - spawn_tilemap_chunks: AlphaMode2d::Blend (was Opaque, blocking DF fade) - update_tilemap_chunk_visibility: actually mutate Visibility (was read-only) - handle_tile_occlusion_updates: mark dirty keys inline (was double-reading events) - TilemapChunkSpawner: deduplicate pending queue and guard stale registry Performance: - ~115K FloorTile ECS entities eliminated - GPU memory: O(tile_data) instead of O(CHUNK_TILES² × z_levels × pixel_bytes) - Z-scroll: only TilemapChunkTileData repopulates (no entity re-spawn) - Benchmarks via TilemapBenchmark resource (F9 to report)
This commit is contained in:
@@ -18,6 +18,9 @@ ahash = "0.8.12"
|
|||||||
nohash-hasher = "0.2.0"
|
nohash-hasher = "0.2.0"
|
||||||
futures-lite = "2.6.1"
|
futures-lite = "2.6.1"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
image = "0.25.10"
|
||||||
|
|
||||||
# Enable max optimizations for dependencies, but not for our code:
|
# Enable max optimizations for dependencies, but not for our code:
|
||||||
[profile.dev.package."*"]
|
[profile.dev.package."*"]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,50 @@
|
|||||||
|
use image::{GenericImageView, ImageBuffer, Rgba};
|
||||||
|
|
||||||
|
const TILE_PX: u32 = 16;
|
||||||
|
|
||||||
|
const ROWS: &[(&str, u8)] = &[
|
||||||
|
("assets/sky.png", 0), // air (transparent)
|
||||||
|
("assets/grass_floor.png", 1), // grass
|
||||||
|
("assets/dirt_floor.png", 2), // dirt
|
||||||
|
("assets/rock_floor.png", 3), // rock
|
||||||
|
("assets/bedrock_floor.png", 4), // bedrock
|
||||||
|
("assets/sky.png", 5), // sky (transparent)
|
||||||
|
("assets/dirt_wall.png", 6), // dirt_wall fixture
|
||||||
|
("assets/rock_wall.png", 7), // rock_wall fixture
|
||||||
|
("assets/bedrock_wall.png", 8), // bedrock_wall fixture
|
||||||
|
("assets/log.png", 9), // log fixture
|
||||||
|
("assets/leaves.png", 10), // leaves fixture
|
||||||
|
];
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let rows = ROWS.len() as u32;
|
||||||
|
let mut combined: ImageBuffer<Rgba<u8>, Vec<u8>> =
|
||||||
|
ImageBuffer::from_pixel(TILE_PX, TILE_PX * rows, Rgba([0, 0, 0, 0]));
|
||||||
|
|
||||||
|
for (i, (path, _id)) in ROWS.iter().enumerate() {
|
||||||
|
let y = i as u32 * TILE_PX;
|
||||||
|
match image::open(path) {
|
||||||
|
Ok(img) => {
|
||||||
|
image::imageops::overlay(&mut combined, &img, 0, y as i64);
|
||||||
|
println!("cargo:rerun-if-changed={}", path);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"Warning: could not open {} — leaving row {} blank ({})",
|
||||||
|
path, i, e
|
||||||
|
);
|
||||||
|
let mut row_pixels = combined.rows_mut();
|
||||||
|
if let Some(row) = row_pixels.nth(i) {
|
||||||
|
for px in row {
|
||||||
|
*px = Rgba([255, 0, 255, 255]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
combined
|
||||||
|
.save("assets/tileset.png")
|
||||||
|
.expect("Failed to write assets/tileset.png");
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
}
|
||||||
@@ -21,11 +21,7 @@ fn main() {
|
|||||||
App::new()
|
App::new()
|
||||||
.insert_resource(game_config)
|
.insert_resource(game_config)
|
||||||
.insert_resource(game::ZIndex(0.0))
|
.insert_resource(game::ZIndex(0.0))
|
||||||
.insert_resource(world::tiles::CurrentWorldSpriteState {
|
|
||||||
state: world::tiles::TerrainSpriteState::Inactive,
|
|
||||||
})
|
|
||||||
.insert_resource(camera::CameraMoved(false))
|
.insert_resource(camera::CameraMoved(false))
|
||||||
.insert_resource(world::tiles::QuiltCache::default())
|
|
||||||
.init_resource::<SpawnDelay>()
|
.init_resource::<SpawnDelay>()
|
||||||
.add_systems(PreStartup, world::textures::initialize_textures)
|
.add_systems(PreStartup, world::textures::initialize_textures)
|
||||||
.add_plugins(
|
.add_plugins(
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ use bevy::prelude::*;
|
|||||||
use bevy_platform::collections::HashMap;
|
use bevy_platform::collections::HashMap;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use crate::world::{
|
use crate::world::tiles::TileMap;
|
||||||
tiles::{ChunkZKey, QuiltCache, TileMap},
|
|
||||||
CurrentWorldSpriteState, TerrainSpriteState,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Marks an entity as belonging to a specific chunk. Used for O(1) entity despawn
|
/// Marks an entity as belonging to a specific chunk. Used for O(1) entity despawn
|
||||||
/// when the chunk is unloaded.
|
/// when the chunk is unloaded.
|
||||||
@@ -48,6 +45,19 @@ pub const Z_TOTAL: f32 = Z_ABOVE + Z_BELOW;
|
|||||||
|
|
||||||
const _: () = assert!((Z_BELOW + Z_ABOVE) as usize <= 255);
|
const _: () = assert!((Z_BELOW + Z_ABOVE) as usize <= 255);
|
||||||
|
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
pub struct CurrentWorldSpriteState {
|
||||||
|
pub state: TerrainSpriteState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Resource, Default, PartialEq, Eq)]
|
||||||
|
pub enum TerrainSpriteState {
|
||||||
|
#[default]
|
||||||
|
Inactive,
|
||||||
|
WaitingForRender,
|
||||||
|
RenderReady,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Resource)]
|
#[derive(Resource)]
|
||||||
pub struct ChunkMap {
|
pub struct ChunkMap {
|
||||||
/// All currently loaded chunk positions. The `()` value is just presence.
|
/// All currently loaded chunk positions. The `()` value is just presence.
|
||||||
@@ -107,15 +117,12 @@ pub fn handle_chunk_events(
|
|||||||
mut weathering_event_writer: MessageWriter<ChunkWeatheringAndPrecipitationEvent>,
|
mut weathering_event_writer: MessageWriter<ChunkWeatheringAndPrecipitationEvent>,
|
||||||
mut foliage_event_writer: MessageWriter<ChunkFoliageEvent>,
|
mut foliage_event_writer: MessageWriter<ChunkFoliageEvent>,
|
||||||
mut fauna_event_writer: MessageWriter<ChunkFaunaEvent>,
|
mut fauna_event_writer: MessageWriter<ChunkFaunaEvent>,
|
||||||
mut cwss: ResMut<CurrentWorldSpriteState>,
|
|
||||||
) {
|
) {
|
||||||
let mut any = false;
|
|
||||||
for event in chunk_events.par_read() {
|
for event in chunk_events.par_read() {
|
||||||
let chunk_pos = event.0.chunk_position;
|
let chunk_pos = event.0.chunk_position;
|
||||||
if chunk_map.loaded_chunks.contains_key(&chunk_pos) {
|
if chunk_map.loaded_chunks.contains_key(&chunk_pos) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
any = true;
|
|
||||||
chunk_map.loaded_chunks.insert(chunk_pos, ());
|
chunk_map.loaded_chunks.insert(chunk_pos, ());
|
||||||
chunk_map.dirty_chunks.insert(chunk_pos);
|
chunk_map.dirty_chunks.insert(chunk_pos);
|
||||||
terrain_event_writer.write(ChunkTerrainEvent {
|
terrain_event_writer.write(ChunkTerrainEvent {
|
||||||
@@ -131,9 +138,6 @@ pub fn handle_chunk_events(
|
|||||||
chunk_position: chunk_pos,
|
chunk_position: chunk_pos,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if any {
|
|
||||||
cwss.state = TerrainSpriteState::WaitingForRender;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_chunk_connectivity(mut chunk_map: ResMut<ChunkMap>) {
|
pub fn update_chunk_connectivity(mut chunk_map: ResMut<ChunkMap>) {
|
||||||
@@ -187,15 +191,8 @@ pub fn update_chunk_connectivity(mut chunk_map: ResMut<ChunkMap>) {
|
|||||||
/// 3. **`tilemap`** — removes all floor, fixture, and item tile data for this chunk
|
/// 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
|
/// from the TileMap HashMaps. `is_standable` will return false for these tiles
|
||||||
/// after this step.
|
/// after this step.
|
||||||
/// 4. **`quilt_cache`** — marks all z-levels of the render chunk containing this
|
/// 4. **`dirty_chunks`** — marks the chunk dirty so `update_chunk_connectivity`
|
||||||
/// 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.
|
/// 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
|
/// ## Example: dynamic unload based on player/NPC distance
|
||||||
///
|
///
|
||||||
@@ -204,8 +201,6 @@ pub fn update_chunk_connectivity(mut chunk_map: ResMut<ChunkMap>) {
|
|||||||
/// mut commands: Commands,
|
/// mut commands: Commands,
|
||||||
/// mut tilemap: ResMut<TileMap>,
|
/// mut tilemap: ResMut<TileMap>,
|
||||||
/// mut chunk_map: ResMut<ChunkMap>,
|
/// mut chunk_map: ResMut<ChunkMap>,
|
||||||
/// mut quilt_cache: ResMut<QuiltCache>,
|
|
||||||
/// mut cwss: ResMut<CurrentWorldSpriteState>,
|
|
||||||
/// camera: Query<&Transform, With<Camera>>,
|
/// camera: Query<&Transform, With<Camera>>,
|
||||||
/// npcs: Query<(&Ambulatory, &Transform)>,
|
/// npcs: Query<(&Ambulatory, &Transform)>,
|
||||||
/// ) {
|
/// ) {
|
||||||
@@ -242,8 +237,6 @@ pub fn update_chunk_connectivity(mut chunk_map: ResMut<ChunkMap>) {
|
|||||||
/// commands.as_mut(),
|
/// commands.as_mut(),
|
||||||
/// tilemap.as_mut(),
|
/// tilemap.as_mut(),
|
||||||
/// chunk_map.as_mut(),
|
/// chunk_map.as_mut(),
|
||||||
/// quilt_cache.as_mut(),
|
|
||||||
/// cwss.as_mut(),
|
|
||||||
/// chunk_pos,
|
/// chunk_pos,
|
||||||
/// );
|
/// );
|
||||||
/// }
|
/// }
|
||||||
@@ -262,8 +255,6 @@ pub fn unload_chunk(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut tilemap: ResMut<TileMap>,
|
mut tilemap: ResMut<TileMap>,
|
||||||
mut chunk_map: ResMut<ChunkMap>,
|
mut chunk_map: ResMut<ChunkMap>,
|
||||||
mut quilt_cache: ResMut<QuiltCache>,
|
|
||||||
mut cwss: ResMut<CurrentWorldSpriteState>,
|
|
||||||
chunk_pos: IVec2,
|
chunk_pos: IVec2,
|
||||||
) {
|
) {
|
||||||
if !chunk_map.loaded_chunks.contains_key(&chunk_pos) {
|
if !chunk_map.loaded_chunks.contains_key(&chunk_pos) {
|
||||||
@@ -279,16 +270,7 @@ pub fn unload_chunk(
|
|||||||
|
|
||||||
tilemap.remove_chunk_data(chunk_pos);
|
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);
|
chunk_map.dirty_chunks.insert(chunk_pos);
|
||||||
cwss.state = TerrainSpriteState::WaitingForRender;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Placeholder: drives dynamic chunk unloading based on player/NPC interest radius.
|
/// Placeholder: drives dynamic chunk unloading based on player/NPC interest radius.
|
||||||
@@ -296,16 +278,3 @@ pub fn unload_chunk(
|
|||||||
/// To enable: replace the body with logic that diffs wanted chunks against
|
/// 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.
|
/// loaded_chunks and calls unload_chunk() for chunks that fell out of range.
|
||||||
pub fn dynamic_chunk_unloading_system(_: Commands) {}
|
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<TileMap>,
|
|
||||||
mut _chunk_map: ResMut<ChunkMap>,
|
|
||||||
mut _quilt_cache: ResMut<QuiltCache>,
|
|
||||||
) {
|
|
||||||
// TODO: re-enable when dynamic_unloading_system queues chunks
|
|
||||||
// For now, dynamic_chunk_unloading_system calls unload_chunk() directly
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ use crate::{
|
|||||||
config::TileRegistry,
|
config::TileRegistry,
|
||||||
constants::{SEED, TILE_SIZE},
|
constants::{SEED, TILE_SIZE},
|
||||||
world::{
|
world::{
|
||||||
tiles::{ChunkData, FloorTileData, TileMap, TerrainSpriteState, CurrentWorldSpriteState},
|
tiles::{ChunkData, FloorTileData, TileMap},
|
||||||
ChunkForrestryEvent, ChunkTerrainEvent, FloorTilePrefab, TileOcclusionEvent, CHUNK_SIZE,
|
ChunkForrestryEvent, ChunkTerrainEvent, FloorTilePrefab, TileOcclusionEvent, CHUNK_SIZE,
|
||||||
Z_ABOVE, Z_BELOW, ChunkMap, ChunkOwner,
|
Z_ABOVE, Z_BELOW, ChunkMap,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -265,7 +265,6 @@ fn generate_terrain_blob(chunk_pos: IVec2) -> TerrainBlob {
|
|||||||
/// Fast - just reads events and spawns tasks.
|
/// Fast - just reads events and spawns tasks.
|
||||||
pub fn spawn_terrain_tasks(
|
pub fn spawn_terrain_tasks(
|
||||||
mut events: MessageReader<ChunkTerrainEvent>,
|
mut events: MessageReader<ChunkTerrainEvent>,
|
||||||
mut cwss: ResMut<CurrentWorldSpriteState>,
|
|
||||||
blob_storage: Res<TerrainBlobStorage>,
|
blob_storage: Res<TerrainBlobStorage>,
|
||||||
) {
|
) {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
@@ -287,7 +286,6 @@ pub fn spawn_terrain_tasks(
|
|||||||
}).detach();
|
}).detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
cwss.state = TerrainSpriteState::WaitingForRender;
|
|
||||||
println!("{} terrain tasks spawned in {:.2?}", count, start.elapsed());
|
println!("{} terrain tasks spawned in {:.2?}", count, start.elapsed());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +298,7 @@ pub fn apply_terrain_blobs(
|
|||||||
mut chunk_map: ResMut<ChunkMap>,
|
mut chunk_map: ResMut<ChunkMap>,
|
||||||
mut forrestry_event_writer: MessageWriter<ChunkForrestryEvent>,
|
mut forrestry_event_writer: MessageWriter<ChunkForrestryEvent>,
|
||||||
mut occlusion_event_writer: MessageWriter<TileOcclusionEvent>,
|
mut occlusion_event_writer: MessageWriter<TileOcclusionEvent>,
|
||||||
|
mut spawner: ResMut<crate::world::tiles::TilemapChunkSpawner>,
|
||||||
) {
|
) {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let mut applied_count = 0;
|
let mut applied_count = 0;
|
||||||
@@ -317,16 +316,7 @@ pub fn apply_terrain_blobs(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
tilemap.chunks.insert(blob.chunk_pos, blob.chunk_data);
|
tilemap.chunks.insert(blob.chunk_pos, blob.chunk_data);
|
||||||
|
spawner.queue_chunk(blob.chunk_pos);
|
||||||
for (_position, prefab) in blob.tile_spawns {
|
|
||||||
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 {
|
for pos in new_positions {
|
||||||
occlusion_event_writer.write(TileOcclusionEvent { tile_position: pos });
|
occlusion_event_writer.write(TileOcclusionEvent { tile_position: pos });
|
||||||
|
|||||||
+10
-18
@@ -14,18 +14,19 @@ pub mod generation;
|
|||||||
pub mod textures;
|
pub mod textures;
|
||||||
pub mod tiles;
|
pub mod tiles;
|
||||||
|
|
||||||
// Re-export commonly used items
|
|
||||||
pub use chunks::management::*;
|
pub use chunks::management::*;
|
||||||
pub use textures::management::*;
|
pub use textures::management::*;
|
||||||
pub use tiles::{prefabs::*, rendering::*, visibility::*};
|
pub use tiles::{prefabs::*, rendering::*, visibility::*};
|
||||||
|
|
||||||
// Plugin
|
|
||||||
pub struct WorldPlugin;
|
pub struct WorldPlugin;
|
||||||
|
|
||||||
impl Plugin for WorldPlugin {
|
impl Plugin for WorldPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<ChunkMap>()
|
app.init_resource::<ChunkMap>()
|
||||||
.init_resource::<TerrainBlobStorage>()
|
.init_resource::<TerrainBlobStorage>()
|
||||||
|
.init_resource::<tiles::TilemapBenchmark>()
|
||||||
|
.init_resource::<tiles::TilemapChunkRegistry>()
|
||||||
|
.init_resource::<tiles::TilemapChunkSpawner>()
|
||||||
.add_message::<GenerateChunkEvent>()
|
.add_message::<GenerateChunkEvent>()
|
||||||
.add_message::<ChunkTerrainEvent>()
|
.add_message::<ChunkTerrainEvent>()
|
||||||
.add_message::<ChunkWeatheringAndPrecipitationEvent>()
|
.add_message::<ChunkWeatheringAndPrecipitationEvent>()
|
||||||
@@ -37,19 +38,15 @@ impl Plugin for WorldPlugin {
|
|||||||
.add_systems(
|
.add_systems(
|
||||||
FixedUpdate,
|
FixedUpdate,
|
||||||
(
|
(
|
||||||
// === CHUNK LOADING PIPELINE ===
|
|
||||||
handle_chunk_events,
|
handle_chunk_events,
|
||||||
update_chunk_connectivity,
|
update_chunk_connectivity,
|
||||||
|
tiles::despawn_tilemap_chunks,
|
||||||
// === CHUNK GENERATION (triggered by events above) ===
|
|
||||||
spawn_terrain_tasks,
|
spawn_terrain_tasks,
|
||||||
apply_terrain_blobs,
|
apply_terrain_blobs,
|
||||||
generate_chunk_weathering_and_precipitation,
|
generate_chunk_weathering_and_precipitation,
|
||||||
generate_chunk_forrestry,
|
generate_chunk_forrestry,
|
||||||
generate_chunk_foliage,
|
generate_chunk_foliage,
|
||||||
generate_chunk_fauna,
|
generate_chunk_fauna,
|
||||||
|
|
||||||
// === DYNAMIC UNLOAD (disabled — see dynamic_chunk_unloading_system) ===
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
@@ -57,18 +54,13 @@ impl Plugin for WorldPlugin {
|
|||||||
(
|
(
|
||||||
compute_visibility_of_game_entities
|
compute_visibility_of_game_entities
|
||||||
.after(crate::entities::item::item_tile_management_system),
|
.after(crate::entities::item::item_tile_management_system),
|
||||||
(
|
tiles::track_benchmark,
|
||||||
|
tiles::render_bench_report_system,
|
||||||
handle_tile_occlusion_updates,
|
handle_tile_occlusion_updates,
|
||||||
build_quilted_terrain_sprites,
|
tiles::spawn_tilemap_chunks,
|
||||||
update_tile_visibility.run_if(
|
tiles::on_camera_z_changed,
|
||||||
|cwss: Res<tiles::CurrentWorldSpriteState>,
|
tiles::update_tilemap_chunk_visibility,
|
||||||
camera_moved: Res<camera::CameraMoved>| {
|
tiles::populate_tilemap_chunk_data,
|
||||||
cwss.state == tiles::TerrainSpriteState::RenderReady
|
|
||||||
|| camera_moved.0
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.chain(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::{
|
||||||
|
image::{ImageArrayLayout, ImageLoaderSettings},
|
||||||
|
prelude::*,
|
||||||
|
};
|
||||||
use bevy_platform::collections::HashMap;
|
use bevy_platform::collections::HashMap;
|
||||||
use bevy_platform::time::Instant;
|
use bevy_platform::time::Instant;
|
||||||
|
|
||||||
@@ -82,4 +85,17 @@ pub fn initialize_textures(mut commands: Commands, asset_server: Res<AssetServer
|
|||||||
commands.insert_resource(Textures { handles: textures });
|
commands.insert_resource(Textures { handles: textures });
|
||||||
commands.insert_resource(TextureIDs { refs: texture_ids });
|
commands.insert_resource(TextureIDs { refs: texture_ids });
|
||||||
println!("Textures initialized in {:.2?}", start.elapsed());
|
println!("Textures initialized in {:.2?}", start.elapsed());
|
||||||
|
|
||||||
|
let tileset_handle =
|
||||||
|
asset_server.load_with_settings("tileset.png", |settings: &mut ImageLoaderSettings| {
|
||||||
|
settings.array_layout = Some(ImageArrayLayout::RowCount { rows: 11 });
|
||||||
|
});
|
||||||
|
commands.insert_resource(TilemapTileset {
|
||||||
|
handle: tileset_handle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct TilemapTileset {
|
||||||
|
pub handle: Handle<Image>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
//! Benchmark metrics for the tilemap rendering system.
|
||||||
|
//!
|
||||||
|
//! ## Usage
|
||||||
|
//! Add to `src/world/mod.rs`:
|
||||||
|
//! ```ignore
|
||||||
|
//! .init_resource::<TilemapBenchmark>()
|
||||||
|
//! .add_systems(Update, tilemap_benchmark::track_benchmark)
|
||||||
|
//! .add_systems(Update, tilemap_benchmark::render_bench_report_system)
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Tracks all rendering and simulation metrics.
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct TilemapBenchmark {
|
||||||
|
/// Rolling frame time samples.
|
||||||
|
pub frame_times: Vec<Duration>,
|
||||||
|
/// Total frames since startup.
|
||||||
|
pub frame_count: u64,
|
||||||
|
/// ECS entity count at last sample.
|
||||||
|
pub entity_count: u32,
|
||||||
|
/// Number of FloorTile ECS entities.
|
||||||
|
pub floor_tile_count: u32,
|
||||||
|
|
||||||
|
/// Number of TilemapChunk entities spawned.
|
||||||
|
pub tilemap_chunk_count: u32,
|
||||||
|
/// Number of TilemapChunk entities currently Visible.
|
||||||
|
pub tilemap_visible_count: u32,
|
||||||
|
/// Most recent `populate_tilemap_chunk_data` duration in ms.
|
||||||
|
pub last_populate_ms: f64,
|
||||||
|
/// Cumulative populate time in ms.
|
||||||
|
pub populate_ms: f64,
|
||||||
|
/// Number of dirty keys processed last frame.
|
||||||
|
pub dirty_keys_last: usize,
|
||||||
|
/// Total dirty keys processed over lifetime.
|
||||||
|
pub dirty_keys_total: usize,
|
||||||
|
/// CPU memory estimate for tile data in MB.
|
||||||
|
pub tile_data_mb: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TilemapBenchmark {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
frame_times: Vec::with_capacity(3600),
|
||||||
|
frame_count: 0,
|
||||||
|
entity_count: 0,
|
||||||
|
floor_tile_count: 0,
|
||||||
|
tilemap_chunk_count: 0,
|
||||||
|
tilemap_visible_count: 0,
|
||||||
|
last_populate_ms: 0.0,
|
||||||
|
populate_ms: 0.0,
|
||||||
|
dirty_keys_last: 0,
|
||||||
|
dirty_keys_total: 0,
|
||||||
|
tile_data_mb: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TilemapBenchmark {
|
||||||
|
/// Print a full benchmark report to stdout (triggered by F9).
|
||||||
|
pub fn report(&self, startup_time: Duration) {
|
||||||
|
let avg_frame = if !self.frame_times.is_empty() {
|
||||||
|
self.frame_times.iter().sum::<Duration>() / self.frame_times.len() as u32
|
||||||
|
} else {
|
||||||
|
Duration::ZERO
|
||||||
|
};
|
||||||
|
|
||||||
|
let p50_idx = (self.frame_times.len() as f32 * 0.50) as usize;
|
||||||
|
let p99_idx = (self.frame_times.len() as f32 * 0.99) as usize;
|
||||||
|
let mut sorted = self.frame_times.clone();
|
||||||
|
sorted.sort();
|
||||||
|
let p50 = sorted
|
||||||
|
.get(p50_idx.min(sorted.len().saturating_sub(1)))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(Duration::ZERO);
|
||||||
|
let p99 = sorted
|
||||||
|
.get(p99_idx.min(sorted.len().saturating_sub(1)))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(Duration::ZERO);
|
||||||
|
|
||||||
|
println!();
|
||||||
|
println!("╔══════════════════════════════════════════════════════════════╗");
|
||||||
|
println!("║ TILEMAP BENCHMARK REPORT ║");
|
||||||
|
println!("╠══════════════════════════════════════════════════════════════╣");
|
||||||
|
println!("║ Startup time: {:.2}s", startup_time.as_secs_f64());
|
||||||
|
println!("║ Frames: {}", self.frame_count);
|
||||||
|
println!(
|
||||||
|
"║ ECS entities: {} (floor_tiles={})",
|
||||||
|
self.entity_count, self.floor_tile_count
|
||||||
|
);
|
||||||
|
println!("╠══════════════════════════════════════════════════════════════╣");
|
||||||
|
println!("║ Frame timing:");
|
||||||
|
println!("║ Avg: {:.2}ms", avg_frame.as_secs_f64() * 1000.0);
|
||||||
|
println!("║ p50: {:.2}ms", p50.as_secs_f64() * 1000.0);
|
||||||
|
println!("║ p99: {:.2}ms", p99.as_secs_f64() * 1000.0);
|
||||||
|
println!("╠══════════════════════════════════════════════════════════════╣");
|
||||||
|
println!("║ TilemapChunk system:");
|
||||||
|
println!(
|
||||||
|
"║ TilemapChunk entities: {} (visible={})",
|
||||||
|
self.tilemap_chunk_count, self.tilemap_visible_count
|
||||||
|
);
|
||||||
|
println!("║ Last populate: {:.2}ms", self.last_populate_ms);
|
||||||
|
println!(
|
||||||
|
"║ Dirty keys: {} (total={})",
|
||||||
|
self.dirty_keys_last, self.dirty_keys_total
|
||||||
|
);
|
||||||
|
println!("║ Tile data mem: {:.2} MB", self.tile_data_mb);
|
||||||
|
println!("║ Cumulative: {:.2}s", self.populate_ms / 1000.0);
|
||||||
|
println!("╚══════════════════════════════════════════════════════════════╝");
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs every frame — samples entity counts and frame timing.
|
||||||
|
pub fn track_benchmark(
|
||||||
|
mut bench: ResMut<TilemapBenchmark>,
|
||||||
|
time: Res<Time>,
|
||||||
|
floor_tiles: Query<(), With<super::FloorTile>>,
|
||||||
|
) {
|
||||||
|
bench.frame_count += 1;
|
||||||
|
bench.frame_times.push(time.delta());
|
||||||
|
bench.floor_tile_count = floor_tiles.iter().count() as u32;
|
||||||
|
|
||||||
|
// Trim rolling window to last 300 frames for p99 calculation
|
||||||
|
if bench.frame_times.len() > 300 {
|
||||||
|
bench.frame_times.remove(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-print every 300 frames
|
||||||
|
if bench.frame_count % 300 == 0 && bench.frame_count > 0 {
|
||||||
|
let avg = bench.frame_times.iter().sum::<Duration>() / bench.frame_times.len() as u32;
|
||||||
|
let p99_idx = (bench.frame_times.len() as f32 * 0.99) as usize;
|
||||||
|
let mut sorted = bench.frame_times.clone();
|
||||||
|
sorted.sort();
|
||||||
|
let p99 = sorted[p99_idx.min(sorted.len().saturating_sub(1))];
|
||||||
|
println!("[Benchmark @ frame {:>6}] avg={:>6.2}ms p99={:>6.2}ms entities={:>6} floor_tiles={:>6}",
|
||||||
|
bench.frame_count,
|
||||||
|
avg.as_secs_f64() * 1000.0,
|
||||||
|
p99.as_secs_f64() * 1000.0,
|
||||||
|
bench.entity_count,
|
||||||
|
bench.floor_tile_count,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// F9 key report — prints full benchmark to stdout.
|
||||||
|
pub fn render_bench_report_system(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
bench: Res<TilemapBenchmark>,
|
||||||
|
time: Res<Time>,
|
||||||
|
) {
|
||||||
|
if keys.just_pressed(KeyCode::F9) {
|
||||||
|
bench.report(time.elapsed());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
|
pub mod benchmark;
|
||||||
pub mod chunk_data;
|
pub mod chunk_data;
|
||||||
pub mod components;
|
pub mod components;
|
||||||
pub mod prefabs;
|
pub mod prefabs;
|
||||||
pub mod rendering;
|
pub mod rendering;
|
||||||
pub mod tilemap;
|
pub mod tilemap;
|
||||||
|
pub mod tilemap_chunk;
|
||||||
pub mod visibility;
|
pub mod visibility;
|
||||||
|
|
||||||
|
pub use benchmark::*;
|
||||||
pub use chunk_data::*;
|
pub use chunk_data::*;
|
||||||
pub use components::*;
|
pub use components::*;
|
||||||
pub use prefabs::*;
|
pub use prefabs::*;
|
||||||
pub use rendering::*;
|
|
||||||
pub use tilemap::*;
|
pub use tilemap::*;
|
||||||
|
pub use tilemap_chunk::*;
|
||||||
pub use visibility::*;
|
pub use visibility::*;
|
||||||
|
|||||||
@@ -1,40 +1,6 @@
|
|||||||
use bevy::{asset::RenderAssetUsages, prelude::*, render::render_resource};
|
// rendering.rs — DEPRECATED MODULE
|
||||||
use bevy_platform::collections::{HashMap, HashSet};
|
// TilemapChunk replaced the quilter. Only ChunkZKey retained for reference.
|
||||||
use bevy_platform::sync::Mutex;
|
|
||||||
use bevy_platform::time::Instant;
|
|
||||||
use rayon::prelude::*;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
constants::{PIXEL_RATIO, TILE_PIXELS, TILE_SIZE},
|
|
||||||
world::{tiles::FloorTile, TextureIDs, Textures, Z_BELOW, Z_TOTAL},
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Side length of a spatial chunk in tiles.
|
|
||||||
/// 32 tiles × TILE_PIXELS px = 512 px texture (GPU-friendly power-of-two).
|
|
||||||
/// Tune this constant to balance draw calls vs. rebake granularity.
|
|
||||||
pub const CHUNK_TILES: i32 = 32;
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
|
||||||
pub enum TerrainSpriteState {
|
|
||||||
Inactive,
|
|
||||||
WaitingForRender,
|
|
||||||
InProgress,
|
|
||||||
RenderReady,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Resource)]
|
|
||||||
pub struct CurrentWorldSpriteState {
|
|
||||||
pub state: TerrainSpriteState,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Component)]
|
|
||||||
pub struct TerrainSprite {
|
|
||||||
/// Chunk grid coordinate and z-level this sprite covers.
|
|
||||||
pub key: ChunkZKey,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Uniquely identifies one spatial chunk at one z-level.
|
|
||||||
/// Used as a HashMap / HashSet key throughout the bake pipeline.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
pub struct ChunkZKey {
|
pub struct ChunkZKey {
|
||||||
pub chunk_x: i32,
|
pub chunk_x: i32,
|
||||||
@@ -45,361 +11,14 @@ pub struct ChunkZKey {
|
|||||||
impl ChunkZKey {
|
impl ChunkZKey {
|
||||||
#[inline]
|
#[inline]
|
||||||
pub(crate) 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 {
|
||||||
|
use crate::constants::TILE_SIZE;
|
||||||
|
const RENDER_CHUNK_TILES: i32 = 32;
|
||||||
Self {
|
Self {
|
||||||
chunk_x: (world_x / (TILE_SIZE * CHUNK_TILES as f32)).floor() as i32,
|
chunk_x: (world_x / (TILE_SIZE * RENDER_CHUNK_TILES as f32)).floor() as i32,
|
||||||
chunk_y: (world_y / (TILE_SIZE * CHUNK_TILES as f32)).floor() as i32,
|
chunk_y: (world_y / (TILE_SIZE * RENDER_CHUNK_TILES as f32)).floor() as i32,
|
||||||
z_index,
|
z_index,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Resource)]
|
pub const CHUNK_TILES_DEPRECATED: i32 = 32;
|
||||||
pub struct QuiltCache {
|
|
||||||
/// Pixel dimensions (w, h) of the last-baked texture for each chunk×z.
|
|
||||||
pub dimensions: HashMap<ChunkZKey, (u32, u32)>,
|
|
||||||
|
|
||||||
/// Which chunk×z combinations need to be rebaked on the next bake pass.
|
|
||||||
/// Insert here whenever a tile edit occurs.
|
|
||||||
/// Cleared after every full bake; ignored during partial (dirty-only) bakes.
|
|
||||||
pub dirty_keys: HashSet<ChunkZKey>,
|
|
||||||
|
|
||||||
/// Reusable pixel buffers keyed by ChunkZKey.
|
|
||||||
/// Zeroed and reused when dimensions are unchanged; reallocated on resize.
|
|
||||||
/// Buffers for chunks with no remaining tiles are dropped automatically.
|
|
||||||
pub pixel_buffers: HashMap<ChunkZKey, Vec<u8>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for QuiltCache {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
dimensions: HashMap::new(),
|
|
||||||
dirty_keys: HashSet::new(),
|
|
||||||
pixel_buffers: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// here be dragons :(
|
|
||||||
pub fn build_quilted_terrain_sprites(
|
|
||||||
query_tiles: Query<(&FloorTile, &Transform)>,
|
|
||||||
commands: ParallelCommands<'_, '_>,
|
|
||||||
mut cwss: ResMut<CurrentWorldSpriteState>,
|
|
||||||
textures: Res<Textures>,
|
|
||||||
texture_ids: Res<TextureIDs>,
|
|
||||||
query_terrain_sprites: Query<(Entity, &TerrainSprite)>,
|
|
||||||
mut images: ResMut<Assets<Image>>,
|
|
||||||
mut quilt_cache: ResMut<QuiltCache>,
|
|
||||||
) {
|
|
||||||
if cwss.state != TerrainSpriteState::WaitingForRender {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let now = Instant::now();
|
|
||||||
cwss.state = TerrainSpriteState::InProgress;
|
|
||||||
|
|
||||||
// --- Determine whether this is a full or partial (dirty-only) bake ---
|
|
||||||
//
|
|
||||||
// A full bake (dirty_keys is empty) rebuilds every chunk×z from scratch —
|
|
||||||
// used on initial load or after a world reload.
|
|
||||||
// A partial bake only processes the chunk×z entries listed in dirty_keys,
|
|
||||||
// leaving all other sprites untouched. This makes incremental tile edits
|
|
||||||
// proportional to the number of changed chunks, not total world size.
|
|
||||||
let full_bake = quilt_cache.dirty_keys.is_empty();
|
|
||||||
|
|
||||||
// --- Pre-extract texture data on the main thread ---
|
|
||||||
//
|
|
||||||
// Reads Assets<Image> once here, safely, before the rayon parallel section.
|
|
||||||
// Deduplicates lookups (many tiles share textures) and produces an owned
|
|
||||||
// HashMap that is freely shareable across threads without any locking.
|
|
||||||
let mut tile_texture_data: HashMap<u32, (Vec<u8>, u32, u32)> = HashMap::new();
|
|
||||||
for (floortile, _) in query_tiles.iter() {
|
|
||||||
if tile_texture_data.contains_key(&floortile.id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(texture_id) = texture_ids.refs.get(&floortile.id) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(texture) = textures.handles.get(texture_id) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(img) = images.get(texture) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let Some(data) = &img.data else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
tile_texture_data.insert(
|
|
||||||
floortile.id,
|
|
||||||
(data.clone(), img.size().x as u32, img.size().y as u32),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Bucket tiles into (chunk_x, chunk_y, z_index) keys ---
|
|
||||||
//
|
|
||||||
// Set-bit iteration means each tile only touches as many buckets as z-levels
|
|
||||||
// it is actually visible at — not Z_TOTAL iterations every time.
|
|
||||||
let mut tiles_by_chunk_z: HashMap<ChunkZKey, Vec<(Vec2, &FloorTile)>> = HashMap::new();
|
|
||||||
|
|
||||||
for (floortile, transform) in query_tiles.iter() {
|
|
||||||
let position = Vec2::new(transform.translation.x, transform.translation.y);
|
|
||||||
|
|
||||||
for (word_idx, &word) in floortile.visible_range.iter().enumerate() {
|
|
||||||
let mut bits = word;
|
|
||||||
while bits != 0 {
|
|
||||||
let bit_pos = bits.trailing_zeros() as usize;
|
|
||||||
let z_index = word_idx * 32 + bit_pos;
|
|
||||||
|
|
||||||
if z_index <= Z_TOTAL as usize {
|
|
||||||
let key = ChunkZKey::from_world(position.x, position.y, z_index);
|
|
||||||
tiles_by_chunk_z
|
|
||||||
.entry(key)
|
|
||||||
.or_default()
|
|
||||||
.push((position, floortile));
|
|
||||||
}
|
|
||||||
|
|
||||||
bits &= bits - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Decide which keys to actually bake this frame ---
|
|
||||||
//
|
|
||||||
// Full bake → all keys that have tiles.
|
|
||||||
// Dirty bake → only the intersection of dirty_keys and keys that have tiles.
|
|
||||||
// Keys in dirty_keys that have no tiles are stale deletions;
|
|
||||||
// their sprites will be despawned below.
|
|
||||||
let keys_to_bake: Vec<ChunkZKey> = if full_bake {
|
|
||||||
tiles_by_chunk_z.keys().cloned().collect()
|
|
||||||
} else {
|
|
||||||
tiles_by_chunk_z
|
|
||||||
.keys()
|
|
||||||
.filter(|k| quilt_cache.dirty_keys.contains(*k))
|
|
||||||
.cloned()
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Despawn sprites that need rebuilding ---
|
|
||||||
//
|
|
||||||
// On a full bake, despawn everything.
|
|
||||||
// On a partial bake, despawn only the sprites for dirty chunk×z keys so the
|
|
||||||
// rest of the world remains visible without flickering.
|
|
||||||
for (entity, terrain_sprite) in query_terrain_sprites.iter() {
|
|
||||||
let should_despawn = full_bake || quilt_cache.dirty_keys.contains(&terrain_sprite.key);
|
|
||||||
if should_despawn {
|
|
||||||
commands.command_scope(|mut cmd| {
|
|
||||||
cmd.entity(entity).despawn();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move pixel buffers out of the cache so rayon threads can borrow from the pool.
|
|
||||||
let buffer_pool: Mutex<HashMap<ChunkZKey, Vec<u8>>> =
|
|
||||||
Mutex::new(std::mem::take(&mut quilt_cache.pixel_buffers));
|
|
||||||
|
|
||||||
let texture_results: Mutex<Vec<(ChunkZKey, Image, f32, f32)>> =
|
|
||||||
Mutex::new(Vec::with_capacity(keys_to_bake.len()));
|
|
||||||
let dimensions_results: Mutex<Vec<(ChunkZKey, (u32, u32))>> =
|
|
||||||
Mutex::new(Vec::with_capacity(keys_to_bake.len()));
|
|
||||||
|
|
||||||
let tile_texture_data = &tile_texture_data;
|
|
||||||
|
|
||||||
// --- Parallel bake over chunk×z keys ---
|
|
||||||
keys_to_bake.into_par_iter().for_each(|key| {
|
|
||||||
let tiles = match tiles_by_chunk_z.get(&key) {
|
|
||||||
Some(t) if !t.is_empty() => t,
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Actual tile extent within this chunk, padded by one tile on each side
|
|
||||||
// so the rendered quad always shows one tile of context beyond the edge
|
|
||||||
// (prevents the hard black cutoff at chunk/world boundaries).
|
|
||||||
let min_x_aligned: f32 = ((tiles.iter().map(|(p, _)| p.x).reduce(f32::min).unwrap()
|
|
||||||
- TILE_SIZE / 2.0)
|
|
||||||
/ TILE_SIZE)
|
|
||||||
.floor()
|
|
||||||
* TILE_SIZE
|
|
||||||
- TILE_SIZE;
|
|
||||||
let min_y_aligned: f32 = ((tiles.iter().map(|(p, _)| p.y).reduce(f32::min).unwrap()
|
|
||||||
- TILE_SIZE / 2.0)
|
|
||||||
/ TILE_SIZE)
|
|
||||||
.floor()
|
|
||||||
* TILE_SIZE
|
|
||||||
- TILE_SIZE;
|
|
||||||
let max_x_aligned: f32 = ((tiles.iter().map(|(p, _)| p.x).reduce(f32::max).unwrap()
|
|
||||||
+ TILE_SIZE / 2.0)
|
|
||||||
/ TILE_SIZE)
|
|
||||||
.ceil()
|
|
||||||
* TILE_SIZE
|
|
||||||
+ TILE_SIZE;
|
|
||||||
let max_y_aligned: f32 = ((tiles.iter().map(|(p, _)| p.y).reduce(f32::max).unwrap()
|
|
||||||
+ TILE_SIZE / 2.0)
|
|
||||||
/ TILE_SIZE)
|
|
||||||
.ceil()
|
|
||||||
* TILE_SIZE
|
|
||||||
+ TILE_SIZE;
|
|
||||||
|
|
||||||
let width_tiles = ((max_x_aligned - min_x_aligned) / TILE_SIZE) as u32;
|
|
||||||
let height_tiles = ((max_y_aligned - min_y_aligned) / TILE_SIZE) as u32;
|
|
||||||
|
|
||||||
// Add 2px bleed (1px each side) so adjacent chunk quads overlap by 1px
|
|
||||||
// at any zoom level. Without this, sub-pixel gaps appear between chunks
|
|
||||||
// at fractional orthographic scales.
|
|
||||||
let width_px = width_tiles * TILE_PIXELS + 2;
|
|
||||||
let height_px = height_tiles * TILE_PIXELS + 2;
|
|
||||||
let required_len = (width_px * height_px * 4) as usize;
|
|
||||||
|
|
||||||
dimensions_results
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.push((key, (width_px, height_px)));
|
|
||||||
|
|
||||||
// Reuse buffer if dimensions match; reallocate only on resize.
|
|
||||||
let mut texture_data = {
|
|
||||||
let mut pool = buffer_pool.lock().unwrap();
|
|
||||||
match pool.remove(&key) {
|
|
||||||
Some(mut buf) if buf.len() == required_len => {
|
|
||||||
buf.fill(0);
|
|
||||||
buf
|
|
||||||
}
|
|
||||||
_ => vec![0u8; required_len],
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (pos, floortile) in tiles {
|
|
||||||
let Some((source, src_width, src_height)) = tile_texture_data.get(&floortile.id) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let src_width = *src_width;
|
|
||||||
let src_height = *src_height;
|
|
||||||
|
|
||||||
let rel_x = pos.x - min_x_aligned;
|
|
||||||
let rel_y = pos.y - min_y_aligned;
|
|
||||||
|
|
||||||
let tile_x = (rel_x / TILE_SIZE).round() as u32;
|
|
||||||
let tile_y = (height_tiles as f32 - 1.0 - (rel_y / TILE_SIZE).round()) as u32;
|
|
||||||
|
|
||||||
blit_texture_with_alpha(
|
|
||||||
source,
|
|
||||||
&mut texture_data,
|
|
||||||
src_width,
|
|
||||||
src_height,
|
|
||||||
width_px,
|
|
||||||
height_px,
|
|
||||||
tile_x * TILE_PIXELS + 1, // +1 to account for 1px bleed border
|
|
||||||
tile_y * TILE_PIXELS + 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let quilted_texture = Image::new_fill(
|
|
||||||
render_resource::Extent3d {
|
|
||||||
width: width_px,
|
|
||||||
height: height_px,
|
|
||||||
depth_or_array_layers: 1,
|
|
||||||
},
|
|
||||||
render_resource::TextureDimension::D2,
|
|
||||||
&texture_data,
|
|
||||||
render_resource::TextureFormat::Rgba8UnormSrgb,
|
|
||||||
RenderAssetUsages::RENDER_WORLD,
|
|
||||||
);
|
|
||||||
|
|
||||||
let center_x = min_x_aligned + (max_x_aligned - min_x_aligned) / 2.0;
|
|
||||||
let center_y = min_y_aligned + (max_y_aligned - min_y_aligned) / 2.0;
|
|
||||||
|
|
||||||
// Return buffer to pool for the next bake.
|
|
||||||
buffer_pool.lock().unwrap().insert(key, texture_data);
|
|
||||||
|
|
||||||
texture_results
|
|
||||||
.lock()
|
|
||||||
.unwrap()
|
|
||||||
.push((key, quilted_texture, center_x, center_y));
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Drain and store results back on the main thread ---
|
|
||||||
|
|
||||||
quilt_cache.pixel_buffers = buffer_pool.into_inner().unwrap();
|
|
||||||
|
|
||||||
for (key, dimensions) in dimensions_results.into_inner().unwrap() {
|
|
||||||
quilt_cache.dimensions.insert(key, dimensions);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (key, quilted_texture, center_x, center_y) in texture_results.into_inner().unwrap() {
|
|
||||||
let texture_handle = images.add(quilted_texture);
|
|
||||||
|
|
||||||
commands.command_scope(|mut cmd| {
|
|
||||||
cmd.spawn((
|
|
||||||
Sprite {
|
|
||||||
image: texture_handle,
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
Transform::from_xyz(
|
|
||||||
center_x - TILE_SIZE / 2.0 - PIXEL_RATIO,
|
|
||||||
center_y - TILE_SIZE / 2.0 + PIXEL_RATIO,
|
|
||||||
-Z_BELOW * TILE_SIZE,
|
|
||||||
)
|
|
||||||
.with_scale(Vec3::splat(PIXEL_RATIO)),
|
|
||||||
Visibility::Hidden,
|
|
||||||
TerrainSprite { key }, // access z_index via .key.z_index
|
|
||||||
));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
quilt_cache.dirty_keys.clear();
|
|
||||||
cwss.state = TerrainSpriteState::RenderReady;
|
|
||||||
println!(
|
|
||||||
"Terrain sprites baked in: {:.2?} ({} chunks×z, {} full)",
|
|
||||||
now.elapsed(),
|
|
||||||
tiles_by_chunk_z.len(),
|
|
||||||
if full_bake { "yes" } else { "no" },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Blits `source` onto `target` at (`offset_x`, `offset_y`) with per-pixel alpha compositing.
|
|
||||||
fn blit_texture_with_alpha(
|
|
||||||
source: &[u8],
|
|
||||||
target: &mut [u8],
|
|
||||||
source_width: u32,
|
|
||||||
source_height: u32,
|
|
||||||
target_width: u32,
|
|
||||||
target_height: u32,
|
|
||||||
offset_x: u32,
|
|
||||||
offset_y: u32,
|
|
||||||
) {
|
|
||||||
for y in 0..source_height {
|
|
||||||
if y + offset_y >= target_height {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for x in 0..source_width {
|
|
||||||
if x + offset_x >= target_width {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let src_idx = ((y * source_width) + x) as usize * 4;
|
|
||||||
let dst_idx = (((y + offset_y) * target_width) + (x + offset_x)) as usize * 4;
|
|
||||||
|
|
||||||
let src_a = source[src_idx + 3];
|
|
||||||
|
|
||||||
if src_a == 0 {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if src_a == 255 {
|
|
||||||
target[dst_idx] = source[src_idx];
|
|
||||||
target[dst_idx + 1] = source[src_idx + 1];
|
|
||||||
target[dst_idx + 2] = source[src_idx + 2];
|
|
||||||
target[dst_idx + 3] = 255;
|
|
||||||
} else {
|
|
||||||
let alpha = src_a as f32 / 255.0;
|
|
||||||
let inv_alpha = 1.0 - alpha;
|
|
||||||
|
|
||||||
target[dst_idx] =
|
|
||||||
(source[src_idx] as f32 * alpha + target[dst_idx] as f32 * inv_alpha) as u8;
|
|
||||||
target[dst_idx + 1] = (source[src_idx + 1] as f32 * alpha
|
|
||||||
+ target[dst_idx + 1] as f32 * inv_alpha)
|
|
||||||
as u8;
|
|
||||||
target[dst_idx + 2] = (source[src_idx + 2] as f32 * alpha
|
|
||||||
+ target[dst_idx + 2] as f32 * inv_alpha)
|
|
||||||
as u8;
|
|
||||||
target[dst_idx + 3] = 255;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -182,30 +182,11 @@ pub struct TileMap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TileMap {
|
impl TileMap {
|
||||||
pub fn new() -> Self {
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn get_floor(&self, pos: &IVec3) -> Option<&FloorTileData> {
|
pub fn get_floor(&self, pos: &IVec3) -> Option<&FloorTileData> {
|
||||||
self.floor_tiles.get(pos)
|
self.floor_tiles.get(pos)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn get_fixture(&self, pos: &IVec3) -> Option<&FixtureTileData> {
|
|
||||||
self.fixture_tiles.get(pos)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn has_floor(&self, pos: &IVec3) -> bool {
|
|
||||||
self.floor_tiles.contains_key(pos)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
|
||||||
pub fn has_fixture(&self, pos: &IVec3) -> bool {
|
|
||||||
self.fixture_tiles.contains_key(pos)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
pub fn insert_floor(&mut self, pos: IVec3, tile: FloorTileData) {
|
pub fn insert_floor(&mut self, pos: IVec3, tile: FloorTileData) {
|
||||||
self.floor_tiles.insert(pos, tile);
|
self.floor_tiles.insert(pos, tile);
|
||||||
@@ -242,34 +223,6 @@ impl TileMap {
|
|||||||
chunk.is_standable(local_x, local_y, z)
|
chunk.is_standable(local_x, local_y, z)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get A* pathfinding weight for a tile position.
|
/// Get A* pathfinding weight for a tile position.
|
||||||
/// Returns 100 (default) if tile not found. Lower is better.
|
/// Returns 100 (default) if tile not found. Lower is better.
|
||||||
pub fn get_astar_weight(&self, world_pos: IVec3) -> u8 {
|
pub fn get_astar_weight(&self, world_pos: IVec3) -> u8 {
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
use bevy::{
|
||||||
|
prelude::*,
|
||||||
|
sprite_render::{AlphaMode2d, TileData, TilemapChunk, TilemapChunkTileData},
|
||||||
|
};
|
||||||
|
use bevy_platform::collections::{HashMap, HashSet};
|
||||||
|
use bevy_platform::time::Instant;
|
||||||
|
|
||||||
|
use crate::constants::{ITILE_SIZE, TILE_PIXELS, TILE_SIZE};
|
||||||
|
use crate::game::ZIndex;
|
||||||
|
use crate::world::chunks::{CHUNK_SIZE, CHUNK_SIZE_TILE, Z_BELOW, Z_TOTAL};
|
||||||
|
use crate::world::textures::TilemapTileset;
|
||||||
|
use crate::world::tiles::{FloorTileData, TileMap};
|
||||||
|
|
||||||
|
pub const LAYER_FLOOR: u8 = 0;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Component)]
|
||||||
|
pub struct ChunkLayerKey {
|
||||||
|
pub chunk_pos: IVec2,
|
||||||
|
pub z_index: usize,
|
||||||
|
pub layer: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct TilemapChunkRegistry {
|
||||||
|
pub entities: HashMap<ChunkLayerKey, Entity>,
|
||||||
|
pub dirty_keys: HashSet<ChunkLayerKey>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TilemapChunkRegistry {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
entities: Default::default(),
|
||||||
|
dirty_keys: Default::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct TilemapChunkSpawner {
|
||||||
|
pub pending: Vec<ChunkLayerKey>,
|
||||||
|
pub started: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TilemapChunkSpawner {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
pending: Vec::new(),
|
||||||
|
started: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TilemapChunkSpawner {
|
||||||
|
pub fn queue_chunk(&mut self, chunk_pos: IVec2) {
|
||||||
|
for z in 0..=(Z_TOTAL as usize) {
|
||||||
|
let key = ChunkLayerKey {
|
||||||
|
chunk_pos,
|
||||||
|
z_index: z,
|
||||||
|
layer: LAYER_FLOOR,
|
||||||
|
};
|
||||||
|
if !self.pending.iter().any(|k| k == &key) {
|
||||||
|
self.pending.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const FIXTURE_ROW_OFFSET: u16 = 6;
|
||||||
|
|
||||||
|
fn tile_id_to_tileset_index(tile_id: u32) -> Option<u16> {
|
||||||
|
if tile_id == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if tile_id <= 5 {
|
||||||
|
return Some(tile_id as u16);
|
||||||
|
}
|
||||||
|
let fixture_id = tile_id.saturating_sub(crate::world::textures::FIXTURE_ID_OFFSET);
|
||||||
|
if fixture_id >= 1 && fixture_id <= 5 {
|
||||||
|
Some(FIXTURE_ROW_OFFSET + fixture_id as u16 - 1)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn df_fade_color(z_diff: i32) -> Color {
|
||||||
|
if z_diff <= 0 {
|
||||||
|
return Color::WHITE;
|
||||||
|
}
|
||||||
|
let saturation = (z_diff as f32 / 8.0).clamp(0.0, 1.0);
|
||||||
|
Color::hsv(194.7, saturation, 1.0 - saturation / 2.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_tile_visible_at_z(tile_data: &FloorTileData, z_index: usize) -> bool {
|
||||||
|
if z_index >= 256 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let word = z_index / 32;
|
||||||
|
let bit = z_index % 32;
|
||||||
|
(tile_data.visible_range[word] & (1 << bit)) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn camera_z_for_z_index(z_index: usize) -> i32 {
|
||||||
|
z_index as i32 - Z_BELOW as i32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn populate_chunk_tiles(
|
||||||
|
tilemap: &TileMap,
|
||||||
|
chunk_pos: IVec2,
|
||||||
|
z_index: usize,
|
||||||
|
camera_z: i32,
|
||||||
|
) -> Vec<Option<TileData>> {
|
||||||
|
let z_diff = i32::max(camera_z - camera_z_for_z_index(z_index), 0);
|
||||||
|
if z_diff > 8 {
|
||||||
|
return vec![None; (CHUNK_SIZE * CHUNK_SIZE) as usize];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tiles = Vec::with_capacity((CHUNK_SIZE * CHUNK_SIZE) as usize);
|
||||||
|
for local_y in 0..CHUNK_SIZE {
|
||||||
|
for local_x in 0..CHUNK_SIZE {
|
||||||
|
let world_pos = IVec3::new(
|
||||||
|
chunk_pos.x * CHUNK_SIZE_TILE + local_x * ITILE_SIZE,
|
||||||
|
chunk_pos.y * CHUNK_SIZE_TILE + local_y * ITILE_SIZE,
|
||||||
|
camera_z_for_z_index(z_index) * ITILE_SIZE,
|
||||||
|
);
|
||||||
|
let tile = tilemap.get_floor(&world_pos);
|
||||||
|
match tile {
|
||||||
|
Some(t) if tile_id_to_tileset_index(t.id as u32).is_some() => {
|
||||||
|
let visible = is_tile_visible_at_z(t, z_index);
|
||||||
|
let color = if visible {
|
||||||
|
df_fade_color(z_diff)
|
||||||
|
} else {
|
||||||
|
Color::BLACK
|
||||||
|
};
|
||||||
|
let tileset_idx = tile_id_to_tileset_index(t.id as u32).unwrap();
|
||||||
|
tiles.push(Some(TileData {
|
||||||
|
tileset_index: tileset_idx,
|
||||||
|
color,
|
||||||
|
visible,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
_ => tiles.push(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tiles
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_tilemap_chunks(
|
||||||
|
mut commands: Commands,
|
||||||
|
tilemap: Res<TileMap>,
|
||||||
|
tileset: Res<TilemapTileset>,
|
||||||
|
mut registry: ResMut<TilemapChunkRegistry>,
|
||||||
|
mut spawner: ResMut<TilemapChunkSpawner>,
|
||||||
|
z_index: Res<ZIndex>,
|
||||||
|
mut bench: ResMut<super::TilemapBenchmark>,
|
||||||
|
) {
|
||||||
|
if spawner.pending.is_empty() {
|
||||||
|
if !spawner.started && !registry.entities.is_empty() {
|
||||||
|
registry.entities.clear();
|
||||||
|
registry.dirty_keys.clear();
|
||||||
|
spawner.started = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
spawner.started = true;
|
||||||
|
let pending_count = spawner.pending.len();
|
||||||
|
let now = Instant::now();
|
||||||
|
let camera_z = z_index.0 as i32;
|
||||||
|
let mut spawned_this_call = 0usize;
|
||||||
|
|
||||||
|
registry.entities.reserve(pending_count);
|
||||||
|
|
||||||
|
for key in spawner.pending.drain(..) {
|
||||||
|
let z_diff = i32::max(camera_z - camera_z_for_z_index(key.z_index), 0);
|
||||||
|
|
||||||
|
let tile_data = if key.layer == LAYER_FLOOR {
|
||||||
|
populate_chunk_tiles(&tilemap, key.chunk_pos, key.z_index, camera_z)
|
||||||
|
} else {
|
||||||
|
vec![None; (CHUNK_SIZE * CHUNK_SIZE) as usize]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fixed
|
||||||
|
let half_chunk = (CHUNK_SIZE_TILE as f32) / 2.0 - TILE_SIZE / 2.0;
|
||||||
|
let world_x = (key.chunk_pos.x as f32) * (CHUNK_SIZE_TILE as f32) + half_chunk;
|
||||||
|
let world_y = (key.chunk_pos.y as f32) * (CHUNK_SIZE_TILE as f32) + half_chunk;
|
||||||
|
let z_depth = -(Z_BELOW - key.z_index as f32) * TILE_SIZE;
|
||||||
|
|
||||||
|
let visible = z_diff >= 0 && z_diff <= 8;
|
||||||
|
|
||||||
|
let entity = commands
|
||||||
|
.spawn((
|
||||||
|
key,
|
||||||
|
TilemapChunk {
|
||||||
|
chunk_size: UVec2::splat(CHUNK_SIZE as u32),
|
||||||
|
tile_display_size: UVec2::splat(TILE_PIXELS),
|
||||||
|
tileset: tileset.handle.clone(),
|
||||||
|
alpha_mode: AlphaMode2d::Blend,
|
||||||
|
},
|
||||||
|
TilemapChunkTileData(tile_data),
|
||||||
|
Transform::from_xyz(world_x, world_y, z_depth),
|
||||||
|
if visible {
|
||||||
|
Visibility::Visible
|
||||||
|
} else {
|
||||||
|
Visibility::Hidden
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
|
||||||
|
registry.entities.insert(key, entity);
|
||||||
|
bench.tilemap_chunk_count += 1;
|
||||||
|
spawned_this_call += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"[spawn_tilemap_chunks] spawned={} (skipped={}) total={}",
|
||||||
|
spawned_this_call,
|
||||||
|
pending_count - spawned_this_call,
|
||||||
|
registry.entities.len()
|
||||||
|
);
|
||||||
|
bench.populate_ms += now.elapsed().as_secs_f64() * 1000.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_tilemap_chunk_visibility(
|
||||||
|
z_index: Res<ZIndex>,
|
||||||
|
mut query: Query<(&ChunkLayerKey, &mut Visibility)>,
|
||||||
|
mut bench: ResMut<super::TilemapBenchmark>,
|
||||||
|
) {
|
||||||
|
let camera_z = z_index.0 as i32;
|
||||||
|
let mut visible_count = 0u32;
|
||||||
|
|
||||||
|
for (key, mut visibility) in query.iter_mut() {
|
||||||
|
let z_diff = camera_z - camera_z_for_z_index(key.z_index);
|
||||||
|
let should_show = z_diff >= 0 && z_diff <= 8;
|
||||||
|
*visibility = if should_show {
|
||||||
|
visible_count += 1;
|
||||||
|
Visibility::Visible
|
||||||
|
} else {
|
||||||
|
Visibility::Hidden
|
||||||
|
};
|
||||||
|
}
|
||||||
|
bench.tilemap_visible_count = visible_count;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn populate_tilemap_chunk_data(
|
||||||
|
mut registry: ResMut<TilemapChunkRegistry>,
|
||||||
|
tilemap: Res<TileMap>,
|
||||||
|
z_index: Res<ZIndex>,
|
||||||
|
mut chunk_data_query: Query<(&mut TilemapChunkTileData, &ChunkLayerKey)>,
|
||||||
|
mut bench: ResMut<super::TilemapBenchmark>,
|
||||||
|
) {
|
||||||
|
if registry.dirty_keys.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let now = Instant::now();
|
||||||
|
let camera_z = z_index.0 as i32;
|
||||||
|
let current_z = (camera_z as f32 + Z_BELOW) as usize;
|
||||||
|
|
||||||
|
let dirty: Vec<ChunkLayerKey> = registry.dirty_keys.drain().collect();
|
||||||
|
let mut processed = 0usize;
|
||||||
|
|
||||||
|
for key in dirty {
|
||||||
|
let Some(&entity) = registry.entities.get(&key) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok((mut tile_data, chunk_key)) = chunk_data_query.get_mut(entity) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if key.layer != LAYER_FLOOR {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let z_diff = i32::max(camera_z - camera_z_for_z_index(key.z_index), 0);
|
||||||
|
if z_diff > 8 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_tiles = populate_chunk_tiles(&tilemap, key.chunk_pos, key.z_index, camera_z);
|
||||||
|
tile_data.0 = new_tiles;
|
||||||
|
processed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let elapsed = now.elapsed().as_secs_f64() * 1000.0;
|
||||||
|
bench.last_populate_ms = elapsed;
|
||||||
|
bench.populate_ms += elapsed;
|
||||||
|
bench.dirty_keys_last = processed;
|
||||||
|
bench.dirty_keys_total += processed;
|
||||||
|
bench.tile_data_mb = (registry.entities.len() as f64 * (CHUNK_SIZE * CHUNK_SIZE) as f64 * 4.0)
|
||||||
|
/ (1024.0 * 1024.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn on_camera_z_changed(z_index: Res<ZIndex>, mut registry: ResMut<TilemapChunkRegistry>) {
|
||||||
|
if z_index.is_changed() {
|
||||||
|
let keys: Vec<ChunkLayerKey> = registry.entities.keys().cloned().collect();
|
||||||
|
for key in keys {
|
||||||
|
registry.dirty_keys.insert(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn despawn_tilemap_chunks(
|
||||||
|
mut commands: Commands,
|
||||||
|
chunk_map: Res<super::super::chunks::ChunkMap>,
|
||||||
|
mut registry: ResMut<TilemapChunkRegistry>,
|
||||||
|
query: Query<(Entity, &ChunkLayerKey)>,
|
||||||
|
) {
|
||||||
|
let loaded: HashSet<IVec2> = chunk_map.loaded_chunks.keys().cloned().collect();
|
||||||
|
|
||||||
|
for (entity, key) in query.iter() {
|
||||||
|
if !loaded.contains(&key.chunk_pos) {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
registry.entities.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@ use crate::{
|
|||||||
entities::item::ItemRotationState,
|
entities::item::ItemRotationState,
|
||||||
game,
|
game,
|
||||||
world::{
|
world::{
|
||||||
chunks::{Z_ABOVE, Z_BELOW, Z_TOTAL},
|
chunks::{Z_BELOW, Z_TOTAL},
|
||||||
tiles::{CurrentWorldSpriteState, FloorTile, TerrainSprite, TerrainSpriteState, TileMap},
|
tiles::{ChunkLayerKey, TileMap, TilemapChunkRegistry, LAYER_FLOOR},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
@@ -74,13 +74,15 @@ pub struct TileOcclusionEvent {
|
|||||||
|
|
||||||
pub fn handle_tile_occlusion_updates(
|
pub fn handle_tile_occlusion_updates(
|
||||||
mut tilemap: ResMut<TileMap>,
|
mut tilemap: ResMut<TileMap>,
|
||||||
mut floor_tiles: Query<(Entity, &mut FloorTile, &Transform)>,
|
|
||||||
mut cwss: ResMut<CurrentWorldSpriteState>,
|
|
||||||
mut events: MessageReader<TileOcclusionEvent>,
|
mut events: MessageReader<TileOcclusionEvent>,
|
||||||
|
mut registry: ResMut<TilemapChunkRegistry>,
|
||||||
) {
|
) {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
||||||
let count = events.len();
|
let count = events.len();
|
||||||
|
if count == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Process events in parallel
|
// Process events in parallel
|
||||||
let updates: Vec<(IVec3, [u32; 8])> = events
|
let updates: Vec<(IVec3, [u32; 8])> = events
|
||||||
@@ -96,22 +98,29 @@ pub fn handle_tile_occlusion_updates(
|
|||||||
// Map updates for efficient lookup
|
// Map updates for efficient lookup
|
||||||
let update_map: HashMap<IVec3, [u32; 8]> = updates.into_iter().collect();
|
let update_map: HashMap<IVec3, [u32; 8]> = updates.into_iter().collect();
|
||||||
|
|
||||||
// Update only the relevant FloorTile components
|
for (pos, vis) in &update_map {
|
||||||
for (_, mut tile, pos) in floor_tiles.iter_mut() {
|
if let Some(tile_data) = tilemap.get_floor_mut(pos) {
|
||||||
if let Some(visibility) = update_map.get(&pos.translation.as_ivec3()) {
|
tile_data.visible_range = *vis;
|
||||||
tile.visible_range = *visibility;
|
|
||||||
if let Some(tile_data) = tilemap.get_floor_mut(&pos.translation.as_ivec3()) {
|
|
||||||
tile_data.visible_range = *visibility;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
cwss.state = TerrainSpriteState::WaitingForRender;
|
|
||||||
println!(
|
println!(
|
||||||
"Tile occlusion calculated for {} tiles in {:.2?}",
|
"Tile occlusion calculated for {} tiles in {:.2?}",
|
||||||
count,
|
count,
|
||||||
start.elapsed()
|
start.elapsed()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for pos in update_map.keys() {
|
||||||
|
let chunk_x = pos.x.div_euclid(crate::world::chunks::CHUNK_SIZE_TILE);
|
||||||
|
let chunk_y = pos.y.div_euclid(crate::world::chunks::CHUNK_SIZE_TILE);
|
||||||
|
let chunk_pos = IVec2::new(chunk_x, chunk_y);
|
||||||
|
for z in 0..=(Z_TOTAL as usize) {
|
||||||
|
registry.dirty_keys.insert(ChunkLayerKey {
|
||||||
|
chunk_pos,
|
||||||
|
z_index: z,
|
||||||
|
layer: LAYER_FLOOR,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,21 +198,3 @@ pub fn calculate_visibility(pos: IVec3, tilemap: &TileMap) -> [u32; 8] {
|
|||||||
|
|
||||||
visible_range
|
visible_range
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_tile_visibility(
|
|
||||||
z_index: Res<game::ZIndex>,
|
|
||||||
mut query: Query<(&TerrainSprite, &mut Visibility)>,
|
|
||||||
mut cwss: ResMut<CurrentWorldSpriteState>,
|
|
||||||
) {
|
|
||||||
let now = Instant::now();
|
|
||||||
|
|
||||||
for (terrain_sprite, mut visibility) in query.iter_mut() {
|
|
||||||
*visibility = if terrain_sprite.key.z_index == ((z_index.0 + Z_BELOW) as usize) {
|
|
||||||
Visibility::Visible
|
|
||||||
} else {
|
|
||||||
Visibility::Hidden
|
|
||||||
};
|
|
||||||
}
|
|
||||||
cwss.state = TerrainSpriteState::Inactive;
|
|
||||||
println!("Visibility update: {:.2?}", now.elapsed());
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user