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
+239 -139
View File
@@ -1,5 +1,5 @@
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::time::Instant;
use rayon::prelude::*;
@@ -9,6 +9,11 @@ use crate::{
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,
@@ -24,20 +29,52 @@ pub struct CurrentWorldSpriteState {
#[derive(Component)]
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)]
pub struct QuiltCache {
pub dimensions: HashMap<usize, (u32, u32)>,
pub dirty_indices: Vec<usize>,
/// 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_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>,
textures: Res<Textures>,
texture_ids: Res<TextureIDs>,
query_terrain_sprites: Query<Entity, With<TerrainSprite>>,
query_terrain_sprites: Query<(Entity, &TerrainSprite)>,
mut images: ResMut<Assets<Image>>,
mut quilt_cache: ResMut<QuiltCache>,
) {
@@ -59,127 +96,196 @@ pub fn build_quilted_terrain_sprites(
let now = Instant::now();
cwss.state = TerrainSpriteState::InProgress;
// Despawn existing terrain sprites
let despawn_entities: Vec<Entity> = query_terrain_sprites.iter().collect();
for entity in despawn_entities {
commands.command_scope(|mut cmd| {
cmd.entity(entity).despawn();
});
// --- 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),
);
}
// Collect tiles by z-index first
let mut tiles_by_z: HashMap<usize, Vec<(Vec2, &FloorTile)>> = HashMap::new();
// --- 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();
// 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));
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;
}
}
}
// Thread-safe collections to store results
let dimensions_mutex = Mutex::new(HashMap::new());
let texture_handles_mutex = Mutex::new(HashMap::new());
// --- 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()
};
// Process each z-level in parallel
let z_indices: Vec<usize> = tiles_by_z.keys().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();
});
}
}
z_indices.into_par_iter().for_each(|z_index| {
let tiles = if let Some(tiles) = tiles_by_z.get(&z_index) {
tiles
} else {
return; // Skip empty z-levels
// 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,
};
if tiles.is_empty() {
return;
}
// Bounds are clamped to the chunk grid so textures are always exactly
// 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
let min_x_aligned: f32 = ((tiles.iter().map(|(pos, _)| pos.x).reduce(f32::min).unwrap()
// Actual tile extent within this chunk (may be smaller than full chunk
// 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)
.floor()
* 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)
.floor()
* 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)
.ceil()
* 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)
.ceil()
* TILE_SIZE;
// Calculate terrain texture dimensions in pixels
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 width_px = width_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_mutex
dimensions_results
.lock()
.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];
// 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 {
if let Some(texture_id) = texture_ids.refs.get(&floortile.id) {
if let Some(texture) = textures.handles.get(texture_id) {
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;
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(
data, // tile
&mut texture_data, // terrain
base_texture.size().x as u32,
base_texture.size().y as u32,
width_px,
height_px,
target_x,
target_y,
);
// 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,
tile_y * TILE_PIXELS,
);
}
// Create the quilted texture
let quilted_texture = Image::new_fill(
render_resource::Extent3d {
width: width_px,
@@ -192,29 +298,27 @@ pub fn build_quilted_terrain_sprites(
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_y = min_y_aligned + (max_y_aligned - min_y_aligned) / 2.0;
// Store texture and position data for later spawning
texture_handles_mutex
// Return buffer to pool for the next bake.
buffer_pool.lock().unwrap().insert(key, texture_data);
texture_results
.lock()
.unwrap()
.insert(z_index, (quilted_texture, center_x, center_y));
.push((key, quilted_texture, center_x, center_y));
});
// Process collected textures and spawn entities
let collected_dimensions = dimensions_mutex.into_inner().unwrap();
let collected_textures = texture_handles_mutex.into_inner().unwrap();
// --- Drain and store results back on the main thread ---
// Update quilt_cache dimensions
for (z_index, dimensions) in collected_dimensions {
quilt_cache.dimensions.insert(z_index, dimensions);
quilt_cache.pixel_buffers = buffer_pool.into_inner().unwrap();
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 (z_index, (quilted_texture, center_x, center_y)) in collected_textures {
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| {
@@ -230,19 +334,24 @@ pub fn build_quilted_terrain_sprites(
)
.with_scale(Vec3::splat(PIXEL_RATIO)),
Visibility::Hidden,
TerrainSprite { z_index },
TerrainSprite { key },
));
});
}
quilt_cache.dirty_indices.clear();
quilt_cache.dirty_keys.clear();
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(
sources: Vec<&[u8]>,
source: &[u8],
target: &mut [u8],
source_width: u32,
source_height: u32,
@@ -251,51 +360,42 @@ fn blit_texture_with_alpha(
offset_x: u32,
offset_y: u32,
) {
for (_, source_data) in sources.iter().rev().enumerate() {
for y in 0..source_height {
if y + offset_y >= target_height {
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;
}
for x in 0..source_width {
if x + offset_x >= target_width {
continue;
}
let source_pixel_idx = ((y * source_width) + x) as usize * 4;
let target_pixel_idx =
(((y + offset_y) * target_width) + (x + offset_x)) as usize * 4;
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_data[source_pixel_idx + 3];
let src_a = source[src_idx + 3];
if src_a == 0 {
continue;
}
if src_a == 0 {
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 {
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;
if src_a == 255 {
target[target_pixel_idx] = src_r;
target[target_pixel_idx + 1] = src_g;
target[target_pixel_idx + 2] = src_b;
target[target_pixel_idx + 3] = 255;
} else {
let dst_r = target[target_pixel_idx];
let dst_g = target[target_pixel_idx + 1];
let dst_b = target[target_pixel_idx + 2];
let alpha_factor = src_a as f32 / 255.0;
let inv_alpha = 1.0 - alpha_factor;
target[target_pixel_idx] =
(src_r as f32 * alpha_factor + dst_r as f32 * inv_alpha) as u8;
target[target_pixel_idx + 1] =
(src_g as f32 * alpha_factor + dst_g as f32 * inv_alpha) as u8;
target[target_pixel_idx + 2] =
(src_b as f32 * alpha_factor + dst_b as f32 * inv_alpha) as u8;
target[target_pixel_idx + 3] = 255;
}
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;
}
}
}
+1 -1
View File
@@ -186,7 +186,7 @@ pub fn update_tile_visibility(
let now = Instant::now();
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
} else {
Visibility::Hidden