quilted chunk optimisations

This commit is contained in:
2026-02-28 00:07:07 +00:00
parent 4ef68c4e04
commit b1de9f95c2
2 changed files with 240 additions and 140 deletions
+222 -122
View File
@@ -1,5 +1,5 @@
use bevy::{asset::RenderAssetUsages, prelude::*, render::render_resource}; use bevy::{asset::RenderAssetUsages, prelude::*, render::render_resource};
use bevy_platform::collections::HashMap; use bevy_platform::collections::{HashMap, HashSet};
use bevy_platform::sync::Mutex; use bevy_platform::sync::Mutex;
use bevy_platform::time::Instant; use bevy_platform::time::Instant;
use rayon::prelude::*; use rayon::prelude::*;
@@ -9,6 +9,11 @@ use crate::{
world::{tiles::FloorTile, TextureIDs, Textures, Z_BELOW, Z_TOTAL}, 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)] #[derive(Debug, PartialEq)]
pub enum TerrainSpriteState { pub enum TerrainSpriteState {
Inactive, Inactive,
@@ -24,20 +29,52 @@ pub struct CurrentWorldSpriteState {
#[derive(Component)] #[derive(Component)]
pub struct TerrainSprite { pub struct TerrainSprite {
pub z_index: usize, // Store the z-index this sprite belongs to (as world_Z) /// 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,
pub chunk_y: i32,
pub z_index: usize,
}
impl ChunkZKey {
#[inline]
fn from_world(world_x: f32, world_y: f32, z_index: usize) -> Self {
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,
z_index,
}
}
} }
#[derive(Resource)] #[derive(Resource)]
pub struct QuiltCache { pub struct QuiltCache {
pub dimensions: HashMap<usize, (u32, u32)>, /// Pixel dimensions (w, h) of the last-baked texture for each chunk×z.
pub dirty_indices: Vec<usize>, 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 { impl Default for QuiltCache {
fn default() -> Self { fn default() -> Self {
Self { Self {
dimensions: HashMap::new(), dimensions: HashMap::new(),
dirty_indices: Vec::new(), dirty_keys: HashSet::new(),
pixel_buffers: HashMap::new(),
} }
} }
} }
@@ -49,7 +86,7 @@ pub fn build_quilted_terrain_sprites(
mut cwss: ResMut<CurrentWorldSpriteState>, mut cwss: ResMut<CurrentWorldSpriteState>,
textures: Res<Textures>, textures: Res<Textures>,
texture_ids: Res<TextureIDs>, texture_ids: Res<TextureIDs>,
query_terrain_sprites: Query<Entity, With<TerrainSprite>>, query_terrain_sprites: Query<(Entity, &TerrainSprite)>,
mut images: ResMut<Assets<Image>>, mut images: ResMut<Assets<Image>>,
mut quilt_cache: ResMut<QuiltCache>, mut quilt_cache: ResMut<QuiltCache>,
) { ) {
@@ -59,127 +96,196 @@ pub fn build_quilted_terrain_sprites(
let now = Instant::now(); let now = Instant::now();
cwss.state = TerrainSpriteState::InProgress; cwss.state = TerrainSpriteState::InProgress;
// Despawn existing terrain sprites // --- Determine whether this is a full or partial (dirty-only) bake ---
let despawn_entities: Vec<Entity> = query_terrain_sprites.iter().collect(); //
for entity in despawn_entities { // 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| { commands.command_scope(|mut cmd| {
cmd.entity(entity).despawn(); cmd.entity(entity).despawn();
}); });
} }
// Collect tiles by z-index first
let mut tiles_by_z: HashMap<usize, Vec<(Vec2, &FloorTile)>> = HashMap::new();
// Calculate bounds for all visible tiles
for (floortile, transform) in query_tiles.iter() {
let position = Vec2::new(transform.translation.x, transform.translation.y);
for z_index in 0..=Z_TOTAL as usize {
if (floortile.visible_range[z_index / 32] & (1 << (z_index % 32) as u32)) != 0 {
tiles_by_z
.entry(z_index)
.or_default()
.push((position, floortile));
}
}
} }
// Thread-safe collections to store results // Move pixel buffers out of the cache so rayon threads can borrow from the pool.
let dimensions_mutex = Mutex::new(HashMap::new()); let buffer_pool: Mutex<HashMap<ChunkZKey, Vec<u8>>> =
let texture_handles_mutex = Mutex::new(HashMap::new()); Mutex::new(std::mem::take(&mut quilt_cache.pixel_buffers));
// Process each z-level in parallel let texture_results: Mutex<Vec<(ChunkZKey, Image, f32, f32)>> =
let z_indices: Vec<usize> = tiles_by_z.keys().cloned().collect(); 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()));
z_indices.into_par_iter().for_each(|z_index| { let tile_texture_data = &tile_texture_data;
let tiles = if let Some(tiles) = tiles_by_z.get(&z_index) {
tiles // --- Parallel bake over chunk×z keys ---
} else { keys_to_bake.into_par_iter().for_each(|key| {
return; // Skip empty z-levels let tiles = match tiles_by_chunk_z.get(&key) {
Some(t) if !t.is_empty() => t,
_ => return,
}; };
if tiles.is_empty() { // Bounds are clamped to the chunk grid so textures are always exactly
return; // CHUNK_TILES wide/tall (or smaller at world edges), keeping sizes
} // predictable and buffer reuse rates high.
let chunk_world_min_x = key.chunk_x as f32 * TILE_SIZE * CHUNK_TILES as f32;
let chunk_world_min_y = key.chunk_y as f32 * TILE_SIZE * CHUNK_TILES as f32;
// Calculate bounds // Actual tile extent within this chunk (may be smaller than full chunk
let min_x_aligned: f32 = ((tiles.iter().map(|(pos, _)| pos.x).reduce(f32::min).unwrap() // at world edges or sparse z-levels).
let min_x_aligned: f32 = ((tiles.iter().map(|(p, _)| p.x).reduce(f32::min).unwrap()
- TILE_SIZE / 2.0) - TILE_SIZE / 2.0)
/ TILE_SIZE) / TILE_SIZE)
.floor() .floor()
* TILE_SIZE; * TILE_SIZE;
let min_y_aligned: f32 = ((tiles.iter().map(|(pos, _)| pos.y).reduce(f32::min).unwrap() let min_y_aligned: f32 = ((tiles.iter().map(|(p, _)| p.y).reduce(f32::min).unwrap()
- TILE_SIZE / 2.0) - TILE_SIZE / 2.0)
/ TILE_SIZE) / TILE_SIZE)
.floor() .floor()
* TILE_SIZE; * TILE_SIZE;
let max_x_aligned: f32 = ((tiles.iter().map(|(pos, _)| pos.x).reduce(f32::max).unwrap() let max_x_aligned: f32 = ((tiles.iter().map(|(p, _)| p.x).reduce(f32::max).unwrap()
+ TILE_SIZE / 2.0) + TILE_SIZE / 2.0)
/ TILE_SIZE) / TILE_SIZE)
.ceil() .ceil()
* TILE_SIZE; * TILE_SIZE;
let max_y_aligned: f32 = ((tiles.iter().map(|(pos, _)| pos.y).reduce(f32::max).unwrap() let max_y_aligned: f32 = ((tiles.iter().map(|(p, _)| p.y).reduce(f32::max).unwrap()
+ TILE_SIZE / 2.0) + TILE_SIZE / 2.0)
/ TILE_SIZE) / TILE_SIZE)
.ceil() .ceil()
* TILE_SIZE; * TILE_SIZE;
// Calculate terrain texture dimensions in pixels
let width_tiles = ((max_x_aligned - min_x_aligned) / TILE_SIZE) as u32; 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; let height_tiles = ((max_y_aligned - min_y_aligned) / TILE_SIZE) as u32;
let width_px = width_tiles * TILE_PIXELS; let width_px = width_tiles * TILE_PIXELS;
let height_px = height_tiles * TILE_PIXELS; let height_px = height_tiles * TILE_PIXELS;
let required_len = (width_px * height_px * 4) as usize;
// Store dimensions in our thread-safe map dimensions_results
dimensions_mutex
.lock() .lock()
.unwrap() .unwrap()
.insert(z_index, (width_px, height_px)); .push((key, (width_px, height_px)));
let mut texture_data = vec![0u8; (width_px * height_px * 4) as usize]; // 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],
}
};
// Process tiles for this z-level
// We can't parallelize this inner loop without more complex locking on texture_data
for (pos, floortile) in tiles { for (pos, floortile) in tiles {
if let Some(texture_id) = texture_ids.refs.get(&floortile.id) { let Some((source, src_width, src_height)) = tile_texture_data.get(&floortile.id) else {
if let Some(texture) = textures.handles.get(texture_id) { continue;
};
let src_width = *src_width;
let src_height = *src_height;
let rel_x = pos.x - min_x_aligned; let rel_x = pos.x - min_x_aligned;
let rel_y = pos.y - min_y_aligned; let rel_y = pos.y - min_y_aligned;
let tile_x = (rel_x / TILE_SIZE).round() as u32; 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; let tile_y = (height_tiles as f32 - 1.0 - (rel_y / TILE_SIZE).round()) as u32;
let target_x = tile_x * TILE_PIXELS;
let target_y = tile_y * TILE_PIXELS;
let mut data: Vec<&[u8]> = vec![];
let mut base_texture: &Image = &Default::default();
// Use a thread-safe approach to access images
// In a full implementation, this would require a more sophisticated
// thread-safe access pattern to Assets<Image>
if let Some(_base_texture) = images.get(texture) {
if let Some(_data) = &_base_texture.data {
data.push(_data);
base_texture = _base_texture;
}
}
blit_texture_with_alpha( blit_texture_with_alpha(
data, // tile source,
&mut texture_data, // terrain &mut texture_data,
base_texture.size().x as u32, src_width,
base_texture.size().y as u32, src_height,
width_px, width_px,
height_px, height_px,
target_x, tile_x * TILE_PIXELS,
target_y, tile_y * TILE_PIXELS,
); );
} }
}
}
// Create the quilted texture
let quilted_texture = Image::new_fill( let quilted_texture = Image::new_fill(
render_resource::Extent3d { render_resource::Extent3d {
width: width_px, width: width_px,
@@ -192,29 +298,27 @@ pub fn build_quilted_terrain_sprites(
RenderAssetUsages::RENDER_WORLD, RenderAssetUsages::RENDER_WORLD,
); );
// In a real implementation, we would need thread-safe access to images
// For now, we'll collect the textures and add them after parallel processing
let center_x = min_x_aligned + (max_x_aligned - min_x_aligned) / 2.0; 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; let center_y = min_y_aligned + (max_y_aligned - min_y_aligned) / 2.0;
// Store texture and position data for later spawning // Return buffer to pool for the next bake.
texture_handles_mutex buffer_pool.lock().unwrap().insert(key, texture_data);
texture_results
.lock() .lock()
.unwrap() .unwrap()
.insert(z_index, (quilted_texture, center_x, center_y)); .push((key, quilted_texture, center_x, center_y));
}); });
// Process collected textures and spawn entities // --- Drain and store results back on the main thread ---
let collected_dimensions = dimensions_mutex.into_inner().unwrap();
let collected_textures = texture_handles_mutex.into_inner().unwrap();
// Update quilt_cache dimensions quilt_cache.pixel_buffers = buffer_pool.into_inner().unwrap();
for (z_index, dimensions) in collected_dimensions {
quilt_cache.dimensions.insert(z_index, dimensions); for (key, dimensions) in dimensions_results.into_inner().unwrap() {
quilt_cache.dimensions.insert(key, dimensions);
} }
// Add images and spawn entities with the collected data for (key, quilted_texture, center_x, center_y) in texture_results.into_inner().unwrap() {
for (z_index, (quilted_texture, center_x, center_y)) in collected_textures {
let texture_handle = images.add(quilted_texture); let texture_handle = images.add(quilted_texture);
commands.command_scope(|mut cmd| { commands.command_scope(|mut cmd| {
@@ -230,19 +334,24 @@ pub fn build_quilted_terrain_sprites(
) )
.with_scale(Vec3::splat(PIXEL_RATIO)), .with_scale(Vec3::splat(PIXEL_RATIO)),
Visibility::Hidden, Visibility::Hidden,
TerrainSprite { z_index }, TerrainSprite { key },
)); ));
}); });
} }
quilt_cache.dirty_indices.clear(); quilt_cache.dirty_keys.clear();
cwss.state = TerrainSpriteState::RenderReady; cwss.state = TerrainSpriteState::RenderReady;
println!("Terrain sprites baked in: {:.2?}", now.elapsed()); println!(
"Terrain sprites baked in: {:.2?} ({} chunks×z, {} full)",
now.elapsed(),
tiles_by_chunk_z.len(),
if full_bake { "yes" } else { "no" },
);
} }
// Helper function to blit a texture onto another texture /// Blits `source` onto `target` at (`offset_x`, `offset_y`) with per-pixel alpha compositing.
fn blit_texture_with_alpha( fn blit_texture_with_alpha(
sources: Vec<&[u8]>, source: &[u8],
target: &mut [u8], target: &mut [u8],
source_width: u32, source_width: u32,
source_height: u32, source_height: u32,
@@ -251,7 +360,6 @@ fn blit_texture_with_alpha(
offset_x: u32, offset_x: u32,
offset_y: u32, offset_y: u32,
) { ) {
for (_, source_data) in sources.iter().rev().enumerate() {
for y in 0..source_height { for y in 0..source_height {
if y + offset_y >= target_height { if y + offset_y >= target_height {
continue; continue;
@@ -261,41 +369,33 @@ fn blit_texture_with_alpha(
continue; continue;
} }
let source_pixel_idx = ((y * source_width) + x) as usize * 4; let src_idx = ((y * source_width) + x) as usize * 4;
let target_pixel_idx = let dst_idx = (((y + offset_y) * target_width) + (x + offset_x)) as usize * 4;
(((y + offset_y) * target_width) + (x + offset_x)) as usize * 4;
let src_a = source_data[source_pixel_idx + 3]; let src_a = source[src_idx + 3];
if src_a == 0 { if src_a == 0 {
continue; continue;
} }
let src_r = source_data[source_pixel_idx];
let src_g = source_data[source_pixel_idx + 1];
let src_b = source_data[source_pixel_idx + 2];
if src_a == 255 { if src_a == 255 {
target[target_pixel_idx] = src_r; target[dst_idx] = source[src_idx];
target[target_pixel_idx + 1] = src_g; target[dst_idx + 1] = source[src_idx + 1];
target[target_pixel_idx + 2] = src_b; target[dst_idx + 2] = source[src_idx + 2];
target[target_pixel_idx + 3] = 255; target[dst_idx + 3] = 255;
} else { } else {
let dst_r = target[target_pixel_idx]; let alpha = src_a as f32 / 255.0;
let dst_g = target[target_pixel_idx + 1]; let inv_alpha = 1.0 - alpha;
let dst_b = target[target_pixel_idx + 2];
let alpha_factor = src_a as f32 / 255.0; target[dst_idx] =
let inv_alpha = 1.0 - alpha_factor; (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[target_pixel_idx] = + target[dst_idx + 1] as f32 * inv_alpha)
(src_r as f32 * alpha_factor + dst_r as f32 * inv_alpha) as u8; as u8;
target[target_pixel_idx + 1] = target[dst_idx + 2] = (source[src_idx + 2] as f32 * alpha
(src_g as f32 * alpha_factor + dst_g as f32 * inv_alpha) as u8; + target[dst_idx + 2] as f32 * inv_alpha)
target[target_pixel_idx + 2] = as u8;
(src_b as f32 * alpha_factor + dst_b as f32 * inv_alpha) as u8; target[dst_idx + 3] = 255;
target[target_pixel_idx + 3] = 255;
}
} }
} }
} }
+1 -1
View File
@@ -186,7 +186,7 @@ pub fn update_tile_visibility(
let now = Instant::now(); let now = Instant::now();
for (terrain_sprite, mut visibility) in query.iter_mut() { for (terrain_sprite, mut visibility) in query.iter_mut() {
*visibility = if terrain_sprite.z_index == ((z_index.0 + Z_BELOW) as usize) { *visibility = if terrain_sprite.key.z_index == ((z_index.0 + Z_BELOW) as usize) {
Visibility::Visible Visibility::Visible
} else { } else {
Visibility::Hidden Visibility::Hidden