diff --git a/Cargo.toml b/Cargo.toml index 31d07ec..1f33281 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/assets/tileset.png b/assets/tileset.png new file mode 100644 index 0000000..6130ddc Binary files /dev/null and b/assets/tileset.png differ diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..b8352da --- /dev/null +++ b/build.rs @@ -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, Vec> = + 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"); +} diff --git a/src/main.rs b/src/main.rs index 53b5879..ca5e339 100644 --- a/src/main.rs +++ b/src/main.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::() .add_systems(PreStartup, world::textures::initialize_textures) .add_plugins( diff --git a/src/world/chunks/management.rs b/src/world/chunks/management.rs index 36f56a1..2dd32d4 100644 --- a/src/world/chunks/management.rs +++ b/src/world/chunks/management.rs @@ -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, mut foliage_event_writer: MessageWriter, mut fauna_event_writer: MessageWriter, - mut cwss: ResMut, ) { - 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) { @@ -187,15 +191,8 @@ pub fn update_chunk_connectivity(mut chunk_map: ResMut) { /// 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) { /// mut commands: Commands, /// mut tilemap: ResMut, /// mut chunk_map: ResMut, -/// mut quilt_cache: ResMut, -/// mut cwss: ResMut, /// camera: Query<&Transform, With>, /// npcs: Query<(&Ambulatory, &Transform)>, /// ) { @@ -242,8 +237,6 @@ pub fn update_chunk_connectivity(mut chunk_map: ResMut) { /// 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, mut chunk_map: ResMut, - mut quilt_cache: ResMut, - mut cwss: ResMut, 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, - mut _chunk_map: ResMut, - mut _quilt_cache: ResMut, -) { - // TODO: re-enable when dynamic_unloading_system queues chunks - // For now, dynamic_chunk_unloading_system calls unload_chunk() directly -} diff --git a/src/world/generation/terrain.rs b/src/world/generation/terrain.rs index 05701bc..ae02503 100644 --- a/src/world/generation/terrain.rs +++ b/src/world/generation/terrain.rs @@ -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, - mut cwss: ResMut, blob_storage: Res, ) { 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, mut forrestry_event_writer: MessageWriter, mut occlusion_event_writer: MessageWriter, + mut spawner: ResMut, ) { 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 }); diff --git a/src/world/mod.rs b/src/world/mod.rs index f211d81..9698168 100644 --- a/src/world/mod.rs +++ b/src/world/mod.rs @@ -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::() .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() .add_message::() .add_message::() .add_message::() @@ -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, - camera_moved: Res| { - 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, ), ); } diff --git a/src/world/textures/management.rs b/src/world/textures/management.rs index 37eb195..e8d5e68 100644 --- a/src/world/textures/management.rs +++ b/src/world/textures/management.rs @@ -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, } diff --git a/src/world/tiles/benchmark.rs b/src/world/tiles/benchmark.rs new file mode 100644 index 0000000..bac3570 --- /dev/null +++ b/src/world/tiles/benchmark.rs @@ -0,0 +1,156 @@ +//! Benchmark metrics for the tilemap rendering system. +//! +//! ## Usage +//! Add to `src/world/mod.rs`: +//! ```ignore +//! .init_resource::() +//! .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, + /// 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::() / 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, + time: Res