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:
2026-03-20 14:02:04 +00:00
parent 0eb2fcfbb3
commit f9a74ed432
14 changed files with 609 additions and 556 deletions
+15 -46
View File
@@ -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
}
+4 -14
View File
@@ -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
View File
@@ -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,
),
);
}
+17 -1
View File
@@ -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>,
}
+156
View File
@@ -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());
}
}
+4 -1
View File
@@ -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::*;
+7 -388
View File
@@ -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;
-47
View File
@@ -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 {
+315
View File
@@ -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);
}
}
}
+27 -36
View File
@@ -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());
}