From f9a74ed432911460120bc6b2c2eff4f9d7737d61 Mon Sep 17 00:00:00 2001 From: popertots Date: Fri, 20 Mar 2026 14:02:04 +0000 Subject: [PATCH] feat: migrate quilter rendering to Bevy TilemapChunk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- Cargo.toml | 3 + assets/tileset.png | Bin 0 -> 3610 bytes build.rs | 50 ++++ src/main.rs | 4 - src/world/chunks/management.rs | 61 ++--- src/world/generation/terrain.rs | 18 +- src/world/mod.rs | 30 +-- src/world/textures/management.rs | 18 +- src/world/tiles/benchmark.rs | 156 ++++++++++++ src/world/tiles/mod.rs | 5 +- src/world/tiles/rendering.rs | 395 +------------------------------ src/world/tiles/tilemap.rs | 47 ---- src/world/tiles/tilemap_chunk.rs | 315 ++++++++++++++++++++++++ src/world/tiles/visibility.rs | 63 +++-- 14 files changed, 609 insertions(+), 556 deletions(-) create mode 100644 assets/tileset.png create mode 100644 build.rs create mode 100644 src/world/tiles/benchmark.rs create mode 100644 src/world/tiles/tilemap_chunk.rs 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 0000000000000000000000000000000000000000..6130ddc57d3bfc63964fdbf2f3e8c3ecb39d36d8 GIT binary patch literal 3610 zcmb7{dstHG_QyAf3YsIJm02c|c`NfSPNRv6<+Z$2{<2Bb$VStH<(Q}mXlPR2$_kOZ zWTVrmyj5N@5i_-m8csB6Y3gWZ4W;5GAra5sPMz~SbIw1%=V7xq>s{-+KJVJ^dY>1z z-|k%+YPxCw0BCr6kOIIv8oX+dD&V)u)MPGb|HG5yvX=(?)T&f!c;IrUWI16n`*HWL zC%g|=#SGMc{X}LJU-qglwzk1f%1$;!B~_gH;n?rh^aq+t9}i0#uFXujjSruBD$u;d z&^KG&u~(6DUB2iiTe$i^2TD09VvfD==h#VRrRbpC`@3g)HStO6%n?}qZmMJu{0YQ@ zV~a6GL!;cx({ZZJmIr@r0Q$G{J7!&?LF33E%%c0jJ*5!9to7vw^N<2i#?e`I;=Ake z^?Fx+2o=w*c_3&59VQ!Abh)79iXu2Tau1Td=a*Ok1@(gA#RH%xlo>!QtAU9e91?(5 z&;*z*dwt6b{`?a%L2g7a(F%7i;5jzj?8h*eE79iGuE*N$XoDN((K=ez zSJ(KZ9>(--PG`MGDS3jiOARVhHk3@OgwdMPq_hX{}Bo@53^g#MQ z^a!`M<+bR%GHKizgR3E0>qNOBG7y9?ACw<#-Kx-^P* zSmsS;QLPjm8_)HqvesK`!`w9dZ)V~PPmhPX_U<$3`1$v_)%$x^MZQLay?1*tkPxED z?56NTYUMrHdVP{5A}gY5<*CtOA*@oQj9Df+xj{>b3H5Tuo%$1htwOdhk2mn zD{8k~g&>lq+eiq2iy}r?`)Z{OYdHo+T;L8`*d&&AQ&y6cP9;ZGB8U;UL#hu{_fd?v zPaJY6Mhp2QQ)N#u4X_%AFX)MYo}k2oLVJ-d%+KqAJ;r66yTY{51pmG%=lBL#K?OT_ zOI=-tA!vT;0|GBz3dmy_0vTbr{Wn*O=L@^v(pRR;56OoR&a+~?OuHxI{yeo~7Z&qi z37_^VT7^ggdZFn@Ro;CG%4U{c4xrJ$)+OLr`9)U}8UySNL10{l7eJ;9G$XJZGDl>@ z&UeE(YPfbmy49tl*6(DP(e%>4ZxO|wnG=r+CUzm2|Fxg2MM_-#U|BLk8T}8#*F17mgQ+H~+Li+M*FIAUC8lW^ z?+J6W`S>x~;K7(;bkB7x=gs`D!{-1c_*tK05q9hY7TboDZhMaSjb;QT?E!9RoJm$? zVb>KZ!whP0lp3TD>z`_dwSt7!CYMW%Tg}gAMc+={j4Rj|=s~G5IC6tXG|oh`{@#d4 zlQf`_rf@{vGLCDs-}wT+>Xxn!(G?Eg+8C>F1nhc_*db8$C?9XjGUV1x2&}y@yfgzy z{i_o#JNtY0<+<3$d>Hoq7kehG_K;RX9>`~9J&j)oV8Ik{G#(P=e*v_A>VLOnzT zy28f{E3`$3KrJ@zs-}sx@$MqHn+MK??5>pelSXjiWg`tYn@FuRte-0jaIp*WD9bqC zPWj0`#pmM`we%B5Jlh0Q&gvCy_ZEmF)U2upW z)wz(@{`s>CPBBxxoVwjtt6sMF$cZ~)Y*X8#P{6Y?J6x5jlGA z)0BQVPZ#6iLYkJ75Ks>}=EYP*;sHa+x6TYVF4u;S45oWS-M3_&4|Y%IaYGw6a@)sA zEerxEm{00S;v3mKSux)$G4wL^7*&J$!HD3V0pQ`xDsd)q@+dE|5HKkRR7z)j!2Wkf zXli4AKJ>^T2e^BgVsxH+Wqa+zxGo=1-mJWL!Kqxk>id+mm%6_NZvurUIow-)$6ku< zAOJ1T{+-ym{ZXdrnK*nSnPkc90Wy(5@Ug5hkIG9gb?eWQvXLiG*36vQHH4W$M$POsR}KFHf0F5T>l9m5Qsrf3lRLH=r`2N1t-K?kMVGh#F?)NQwMZ z|CwfeU^V2csM~hvwd&U{SBnJ$rF~5kE$RSxNI({QA`Mt*SENCfRj1D3EUR~1@Z5j~ z`VPfiU{r$gDB@qoiq#|iwK0l1C>O}C|Jh*bQMvcx;8gMAHegz%8MYF!sP{vCkImKL zMA>j{(7R!z1m66iOOTbwB+F+7SmC*+afu=hW1Z5eq2wQhaaOGs3K^Mw^x6zS{W4=Q zCQQy1!)TAi&1#x+iE@na?MhS-cupXs$T*S-{-b%vxA7s$%r^&1o+&x4JPOBh0r@$G zA_#u{KO%{c25`S&N70xj9jSRYSuFZqF>+=)uP<@+j$CK>G?dqrw(Xn8iYqAkU8_XZ znFXoGU2TEDou{0tCJd@)%{~r&`8mf~Y5@G#!v@B*TR(sP14Nb%(QI(k|E3Gx^_UO> zWSBTTlI{Rf)POXJ2FT*vuLJ+_fDA&_`L)aPYJ_xFx@qIg_Ug9D$~_a()B0^+$yN{% zD-{4O2&Pcla$nZryu4*!ao0C^=niQu41in^A3*y;UBU=iwEtO1y?=X@NKz7J|0<@V z2&xfkdP1@bSecq?5~KnA+htVEOmlk0chxlbvNY$`4JQx)fpmhgxme?G?_FK;W5|>_ z`DvZ{bB%k~#;3hEHxbU0y4Y1)1ascbpE=Yooz#UBit!Suy0WlCheH2kyu80&mR%yd zPl&0MY;l&YZ42Kp<2y7R6Y_G*L1IK*KC#B4Roo);-r;zocq==4C#+6IARl9t_od8o zPD!Z>`S1>99F0jxJi}RZXAR09nvwY7 z(ajsTF$pv=CqAj{q|16OC4W-Rm-1ohJpo-rNW32;?I3?)J12_>DUX6^QVYfV5~uW| zn?NFRCl=I{S5_F9)&-o!bx`GlWPvGhcG`$4wYLz@N6}`)E#uyfSB@rKYe-qAnt_yj zq0U>M?A+v&cAC8SLGV)C(&4Qb?zS1V6fOi5=n$>x1z^>BOf&@c6-Y?FTW2CA!%{PmvYY;c(* z-RIQ03J}vEX@H_be<_YH84!Hr$!Btx%b{2KS|WCyC3x-eC_A$0paLtL>!dE<&gr#S zn#o-n=fRmUoil6#{XS=wD*s6Kb`NJ=+7fpc6*_;cVYyr#C)%S3SfKjP@Ibb~(AH z9yTN%h*M5_(?DaNS%%^RX%&5po17DUePpi&!V^!Z4wrzTkxKIG02q(bW=bDBy1diQ zE77{NFS(0zG9sBJ5?stCcs{INnj?cNX{OwfmyoN|=lNkN^LRna=RU?4>ag|*xWtao bCHgQ{`(~@yizZa?KOf-fzME9#8k+VmY@p&C literal 0 HcmV?d00001 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