perf: targeted z-scroll dirty marking, per-frame populate budget

on_camera_z_changed: only dirty z-levels whose z_diff changed
(both old and new camera positions, within 0..=8 window).
Previously dirtied all 20k entities on every z-change.

populate_tilemap_chunk_data: process up to MAX_POPULATE_PER_FRAME
(512) keys per frame, sorted by z-distance from current camera.
Spreads z-scroll cost across multiple frames instead of one spike.

TilemapBenchmark: added z-change spike instrumentation
(last_z_change_dirty_ms, last_z_change_dirty_count,
last_z_change_populate_ms, z_change_history_ms).
F9 report now shows z-scroll metrics.

PreviousZIndex resource tracks prior camera z for targeted dirty calc.
This commit is contained in:
2026-03-20 14:50:13 +00:00
parent b48459fceb
commit ac0e8bc1c7
3 changed files with 103 additions and 11 deletions
+63 -10
View File
@@ -50,6 +50,17 @@ impl Default for TilemapChunkSpawner {
}
}
pub const MAX_POPULATE_PER_FRAME: usize = 512;
#[derive(Resource)]
pub struct PreviousZIndex(pub i32);
impl Default for PreviousZIndex {
fn default() -> Self {
Self(0)
}
}
impl TilemapChunkSpawner {
pub fn queue_chunk(&mut self, chunk_pos: IVec2) {
for z in 0..=(Z_TOTAL as usize) {
@@ -284,18 +295,27 @@ pub fn populate_tilemap_chunk_data(
if registry.dirty_keys.is_empty() {
return;
}
let was_z_change = bench.last_z_change_dirty_count > 0;
let now = Instant::now();
let camera_z = z_index.0 as i32;
let current_z = (camera_z as f32 + Z_BELOW) as usize;
let current_z_index = (camera_z as f32 + Z_BELOW) as usize;
let mut to_process: Vec<ChunkLayerKey> = registry.dirty_keys.iter().cloned().collect();
to_process.sort_by_key(|k| (k.z_index as i32 - current_z_index as i32).unsigned_abs());
to_process.truncate(MAX_POPULATE_PER_FRAME);
for key in &to_process {
registry.dirty_keys.remove(key);
}
let dirty: Vec<ChunkLayerKey> = registry.dirty_keys.drain().collect();
let mut processed = 0usize;
for key in dirty {
for key in to_process {
let Some(&entity) = registry.entities.get(&key) else {
continue;
};
let Ok((mut tile_data, chunk_key)) = chunk_data_query.get_mut(entity) else {
let Ok((mut tile_data, _)) = chunk_data_query.get_mut(entity) else {
continue;
};
@@ -320,17 +340,50 @@ pub fn populate_tilemap_chunk_data(
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);
if was_z_change {
bench.last_z_change_populate_ms = elapsed;
bench.z_change_history_ms.push(elapsed);
if bench.z_change_history_ms.len() > 100 {
bench.z_change_history_ms.remove(0);
}
}
}
pub fn on_camera_z_changed(
z_index: Res<ZIndex>,
mut registry: ResMut<TilemapChunkRegistry>,
mut prev_z: ResMut<PreviousZIndex>,
mut bench: ResMut<super::TilemapBenchmark>,
) {
if !z_index.is_changed() {
return;
}
let old_camera_z = prev_z.0;
let new_camera_z = z_index.0 as i32;
prev_z.0 = new_camera_z;
let now = Instant::now();
let mut count = 0;
let keys: Vec<ChunkLayerKey> = registry.entities.keys().cloned().collect();
for key in keys {
let tile_z = camera_z_for_z_index(key.z_index);
let old_diff = old_camera_z - tile_z;
let new_diff = new_camera_z - tile_z;
let was_in_range = old_diff >= 0 && old_diff <= 8;
let is_in_range = new_diff >= 0 && new_diff <= 8;
if was_in_range || is_in_range {
registry.dirty_keys.insert(key);
count += 1;
}
}
bench.last_z_change_dirty_ms = now.elapsed().as_secs_f64() * 1000.0;
bench.last_z_change_dirty_count = count;
}
pub fn despawn_tilemap_chunks(
mut commands: Commands,
chunk_map: Res<super::super::chunks::ChunkMap>,