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"
|
||||
futures-lite = "2.6.1"
|
||||
|
||||
[build-dependencies]
|
||||
image = "0.25.10"
|
||||
|
||||
# Enable max optimizations for dependencies, but not for our code:
|
||||
[profile.dev.package."*"]
|
||||
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()
|
||||
.insert_resource(game_config)
|
||||
.insert_resource(game::ZIndex(0.0))
|
||||
.insert_resource(world::tiles::CurrentWorldSpriteState {
|
||||
state: world::tiles::TerrainSpriteState::Inactive,
|
||||
})
|
||||
.insert_resource(camera::CameraMoved(false))
|
||||
.insert_resource(world::tiles::QuiltCache::default())
|
||||
.init_resource::<SpawnDelay>()
|
||||
.add_systems(PreStartup, world::textures::initialize_textures)
|
||||
.add_plugins(
|
||||
|
||||
@@ -2,10 +2,7 @@ use bevy::prelude::*;
|
||||
use bevy_platform::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::world::{
|
||||
tiles::{ChunkZKey, QuiltCache, TileMap},
|
||||
CurrentWorldSpriteState, TerrainSpriteState,
|
||||
};
|
||||
use crate::world::tiles::TileMap;
|
||||
|
||||
/// Marks an entity as belonging to a specific chunk. Used for O(1) entity despawn
|
||||
/// 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);
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct CurrentWorldSpriteState {
|
||||
pub state: TerrainSpriteState,
|
||||
}
|
||||
|
||||
#[derive(Resource, Default, PartialEq, Eq)]
|
||||
pub enum TerrainSpriteState {
|
||||
#[default]
|
||||
Inactive,
|
||||
WaitingForRender,
|
||||
RenderReady,
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
pub struct ChunkMap {
|
||||
/// 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 foliage_event_writer: MessageWriter<ChunkFoliageEvent>,
|
||||
mut fauna_event_writer: MessageWriter<ChunkFaunaEvent>,
|
||||
mut cwss: ResMut<CurrentWorldSpriteState>,
|
||||
) {
|
||||
let mut any = false;
|
||||
for event in chunk_events.par_read() {
|
||||
let chunk_pos = event.0.chunk_position;
|
||||
if chunk_map.loaded_chunks.contains_key(&chunk_pos) {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
chunk_map.loaded_chunks.insert(chunk_pos, ());
|
||||
chunk_map.dirty_chunks.insert(chunk_pos);
|
||||
terrain_event_writer.write(ChunkTerrainEvent {
|
||||
@@ -131,9 +138,6 @@ pub fn handle_chunk_events(
|
||||
chunk_position: chunk_pos,
|
||||
});
|
||||
}
|
||||
if any {
|
||||
cwss.state = TerrainSpriteState::WaitingForRender;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
/// 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`
|
||||
/// 4. **`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
|
||||
///
|
||||
@@ -204,8 +201,6 @@ pub fn update_chunk_connectivity(mut chunk_map: ResMut<ChunkMap>) {
|
||||
/// mut commands: Commands,
|
||||
/// mut tilemap: ResMut<TileMap>,
|
||||
/// mut chunk_map: ResMut<ChunkMap>,
|
||||
/// mut quilt_cache: ResMut<QuiltCache>,
|
||||
/// mut cwss: ResMut<CurrentWorldSpriteState>,
|
||||
/// camera: Query<&Transform, With<Camera>>,
|
||||
/// npcs: Query<(&Ambulatory, &Transform)>,
|
||||
/// ) {
|
||||
@@ -242,8 +237,6 @@ pub fn update_chunk_connectivity(mut chunk_map: ResMut<ChunkMap>) {
|
||||
/// commands.as_mut(),
|
||||
/// tilemap.as_mut(),
|
||||
/// chunk_map.as_mut(),
|
||||
/// quilt_cache.as_mut(),
|
||||
/// cwss.as_mut(),
|
||||
/// chunk_pos,
|
||||
/// );
|
||||
/// }
|
||||
@@ -262,8 +255,6 @@ pub fn unload_chunk(
|
||||
mut commands: Commands,
|
||||
mut tilemap: ResMut<TileMap>,
|
||||
mut chunk_map: ResMut<ChunkMap>,
|
||||
mut quilt_cache: ResMut<QuiltCache>,
|
||||
mut cwss: ResMut<CurrentWorldSpriteState>,
|
||||
chunk_pos: IVec2,
|
||||
) {
|
||||
if !chunk_map.loaded_chunks.contains_key(&chunk_pos) {
|
||||
@@ -279,16 +270,7 @@ pub fn unload_chunk(
|
||||
|
||||
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.
|
||||
@@ -296,16 +278,3 @@ pub fn unload_chunk(
|
||||
/// 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<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,
|
||||
constants::{SEED, TILE_SIZE},
|
||||
world::{
|
||||
tiles::{ChunkData, FloorTileData, TileMap, TerrainSpriteState, CurrentWorldSpriteState},
|
||||
tiles::{ChunkData, FloorTileData, TileMap},
|
||||
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.
|
||||
pub fn spawn_terrain_tasks(
|
||||
mut events: MessageReader<ChunkTerrainEvent>,
|
||||
mut cwss: ResMut<CurrentWorldSpriteState>,
|
||||
blob_storage: Res<TerrainBlobStorage>,
|
||||
) {
|
||||
let start = Instant::now();
|
||||
@@ -287,7 +286,6 @@ pub fn spawn_terrain_tasks(
|
||||
}).detach();
|
||||
}
|
||||
|
||||
cwss.state = TerrainSpriteState::WaitingForRender;
|
||||
println!("{} terrain tasks spawned in {:.2?}", count, start.elapsed());
|
||||
}
|
||||
|
||||
@@ -300,6 +298,7 @@ pub fn apply_terrain_blobs(
|
||||
mut chunk_map: ResMut<ChunkMap>,
|
||||
mut forrestry_event_writer: MessageWriter<ChunkForrestryEvent>,
|
||||
mut occlusion_event_writer: MessageWriter<TileOcclusionEvent>,
|
||||
mut spawner: ResMut<crate::world::tiles::TilemapChunkSpawner>,
|
||||
) {
|
||||
let start = Instant::now();
|
||||
let mut applied_count = 0;
|
||||
@@ -317,16 +316,7 @@ pub fn apply_terrain_blobs(
|
||||
.collect();
|
||||
|
||||
tilemap.chunks.insert(blob.chunk_pos, blob.chunk_data);
|
||||
|
||||
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);
|
||||
}
|
||||
spawner.queue_chunk(blob.chunk_pos);
|
||||
|
||||
for pos in new_positions {
|
||||
occlusion_event_writer.write(TileOcclusionEvent { tile_position: pos });
|
||||
|
||||
+11
-19
@@ -14,18 +14,19 @@ pub mod generation;
|
||||
pub mod textures;
|
||||
pub mod tiles;
|
||||
|
||||
// Re-export commonly used items
|
||||
pub use chunks::management::*;
|
||||
pub use textures::management::*;
|
||||
pub use tiles::{prefabs::*, rendering::*, visibility::*};
|
||||
|
||||
// Plugin
|
||||
pub struct WorldPlugin;
|
||||
|
||||
impl Plugin for WorldPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<ChunkMap>()
|
||||
.init_resource::<TerrainBlobStorage>()
|
||||
.init_resource::<tiles::TilemapBenchmark>()
|
||||
.init_resource::<tiles::TilemapChunkRegistry>()
|
||||
.init_resource::<tiles::TilemapChunkSpawner>()
|
||||
.add_message::<GenerateChunkEvent>()
|
||||
.add_message::<ChunkTerrainEvent>()
|
||||
.add_message::<ChunkWeatheringAndPrecipitationEvent>()
|
||||
@@ -37,19 +38,15 @@ impl Plugin for WorldPlugin {
|
||||
.add_systems(
|
||||
FixedUpdate,
|
||||
(
|
||||
// === CHUNK LOADING PIPELINE ===
|
||||
handle_chunk_events,
|
||||
update_chunk_connectivity,
|
||||
|
||||
// === CHUNK GENERATION (triggered by events above) ===
|
||||
tiles::despawn_tilemap_chunks,
|
||||
spawn_terrain_tasks,
|
||||
apply_terrain_blobs,
|
||||
generate_chunk_weathering_and_precipitation,
|
||||
generate_chunk_forrestry,
|
||||
generate_chunk_foliage,
|
||||
generate_chunk_fauna,
|
||||
|
||||
// === DYNAMIC UNLOAD (disabled — see dynamic_chunk_unloading_system) ===
|
||||
),
|
||||
)
|
||||
.add_systems(
|
||||
@@ -57,18 +54,13 @@ impl Plugin for WorldPlugin {
|
||||
(
|
||||
compute_visibility_of_game_entities
|
||||
.after(crate::entities::item::item_tile_management_system),
|
||||
(
|
||||
handle_tile_occlusion_updates,
|
||||
build_quilted_terrain_sprites,
|
||||
update_tile_visibility.run_if(
|
||||
|cwss: Res<tiles::CurrentWorldSpriteState>,
|
||||
camera_moved: Res<camera::CameraMoved>| {
|
||||
cwss.state == tiles::TerrainSpriteState::RenderReady
|
||||
|| camera_moved.0
|
||||
},
|
||||
),
|
||||
)
|
||||
.chain(),
|
||||
tiles::track_benchmark,
|
||||
tiles::render_bench_report_system,
|
||||
handle_tile_occlusion_updates,
|
||||
tiles::spawn_tilemap_chunks,
|
||||
tiles::on_camera_z_changed,
|
||||
tiles::update_tilemap_chunk_visibility,
|
||||
tiles::populate_tilemap_chunk_data,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use bevy::prelude::*;
|
||||
use bevy::{
|
||||
image::{ImageArrayLayout, ImageLoaderSettings},
|
||||
prelude::*,
|
||||
};
|
||||
use bevy_platform::collections::HashMap;
|
||||
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(TextureIDs { refs: texture_ids });
|
||||
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 components;
|
||||
pub mod prefabs;
|
||||
pub mod rendering;
|
||||
pub mod tilemap;
|
||||
pub mod tilemap_chunk;
|
||||
pub mod visibility;
|
||||
|
||||
pub use benchmark::*;
|
||||
pub use chunk_data::*;
|
||||
pub use components::*;
|
||||
pub use prefabs::*;
|
||||
pub use rendering::*;
|
||||
pub use tilemap::*;
|
||||
pub use tilemap_chunk::*;
|
||||
pub use visibility::*;
|
||||
|
||||
@@ -1,40 +1,6 @@
|
||||
use bevy::{asset::RenderAssetUsages, prelude::*, render::render_resource};
|
||||
use bevy_platform::collections::{HashMap, HashSet};
|
||||
use bevy_platform::sync::Mutex;
|
||||
use bevy_platform::time::Instant;
|
||||
use rayon::prelude::*;
|
||||
// rendering.rs — DEPRECATED MODULE
|
||||
// TilemapChunk replaced the quilter. Only ChunkZKey retained for reference.
|
||||
|
||||
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)]
|
||||
pub struct ChunkZKey {
|
||||
pub chunk_x: i32,
|
||||
@@ -45,361 +11,14 @@ pub struct ChunkZKey {
|
||||
impl ChunkZKey {
|
||||
#[inline]
|
||||
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 {
|
||||
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,
|
||||
chunk_x: (world_x / (TILE_SIZE * RENDER_CHUNK_TILES as f32)).floor() as i32,
|
||||
chunk_y: (world_y / (TILE_SIZE * RENDER_CHUNK_TILES as f32)).floor() as i32,
|
||||
z_index,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub const CHUNK_TILES_DEPRECATED: i32 = 32;
|
||||
|
||||
@@ -182,30 +182,11 @@ pub struct TileMap {
|
||||
}
|
||||
|
||||
impl TileMap {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn get_floor(&self, pos: &IVec3) -> Option<&FloorTileData> {
|
||||
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]
|
||||
pub fn insert_floor(&mut self, pos: IVec3, tile: FloorTileData) {
|
||||
self.floor_tiles.insert(pos, tile);
|
||||
@@ -242,34 +223,6 @@ impl TileMap {
|
||||
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.
|
||||
/// Returns 100 (default) if tile not found. Lower is better.
|
||||
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,
|
||||
game,
|
||||
world::{
|
||||
chunks::{Z_ABOVE, Z_BELOW, Z_TOTAL},
|
||||
tiles::{CurrentWorldSpriteState, FloorTile, TerrainSprite, TerrainSpriteState, TileMap},
|
||||
chunks::{Z_BELOW, Z_TOTAL},
|
||||
tiles::{ChunkLayerKey, TileMap, TilemapChunkRegistry, LAYER_FLOOR},
|
||||
},
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
@@ -74,13 +74,15 @@ pub struct TileOcclusionEvent {
|
||||
|
||||
pub fn handle_tile_occlusion_updates(
|
||||
mut tilemap: ResMut<TileMap>,
|
||||
mut floor_tiles: Query<(Entity, &mut FloorTile, &Transform)>,
|
||||
mut cwss: ResMut<CurrentWorldSpriteState>,
|
||||
mut events: MessageReader<TileOcclusionEvent>,
|
||||
mut registry: ResMut<TilemapChunkRegistry>,
|
||||
) {
|
||||
let start = Instant::now();
|
||||
|
||||
let count = events.len();
|
||||
if count == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Process events in parallel
|
||||
let updates: Vec<(IVec3, [u32; 8])> = events
|
||||
@@ -96,22 +98,29 @@ pub fn handle_tile_occlusion_updates(
|
||||
// Map updates for efficient lookup
|
||||
let update_map: HashMap<IVec3, [u32; 8]> = updates.into_iter().collect();
|
||||
|
||||
// Update only the relevant FloorTile components
|
||||
for (_, mut tile, pos) in floor_tiles.iter_mut() {
|
||||
if let Some(visibility) = update_map.get(&pos.translation.as_ivec3()) {
|
||||
tile.visible_range = *visibility;
|
||||
if let Some(tile_data) = tilemap.get_floor_mut(&pos.translation.as_ivec3()) {
|
||||
tile_data.visible_range = *visibility;
|
||||
}
|
||||
for (pos, vis) in &update_map {
|
||||
if let Some(tile_data) = tilemap.get_floor_mut(pos) {
|
||||
tile_data.visible_range = *vis;
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
cwss.state = TerrainSpriteState::WaitingForRender;
|
||||
println!(
|
||||
"Tile occlusion calculated for {} tiles in {:.2?}",
|
||||
count,
|
||||
start.elapsed()
|
||||
);
|
||||
|
||||
println!(
|
||||
"Tile occlusion calculated for {} tiles in {:.2?}",
|
||||
count,
|
||||
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
|
||||
}
|
||||
|
||||
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