From b1de9f95c24bb7ebbc952043af1f7f17f62f97a1 Mon Sep 17 00:00:00 2001 From: popertots Date: Sat, 28 Feb 2026 00:07:07 +0000 Subject: [PATCH] quilted chunk optimisations --- src/world/tiles/rendering.rs | 378 +++++++++++++++++++++------------- src/world/tiles/visibility.rs | 2 +- 2 files changed, 240 insertions(+), 140 deletions(-) diff --git a/src/world/tiles/rendering.rs b/src/world/tiles/rendering.rs index a00db20..aef39a0 100644 --- a/src/world/tiles/rendering.rs +++ b/src/world/tiles/rendering.rs @@ -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, - pub dirty_indices: Vec, + /// Pixel dimensions (w, h) of the last-baked texture for each chunk×z. + pub dimensions: HashMap, + + /// 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, + + /// 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>, } 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, textures: Res, texture_ids: Res, - query_terrain_sprites: Query>, + query_terrain_sprites: Query<(Entity, &TerrainSprite)>, mut images: ResMut>, mut quilt_cache: ResMut, ) { @@ -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 = 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 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, 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> = 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> = 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 = 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 = 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>> = + Mutex::new(std::mem::take(&mut quilt_cache.pixel_buffers)); + + let texture_results: Mutex> = + Mutex::new(Vec::with_capacity(keys_to_bake.len())); + let dimensions_results: Mutex> = + 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 - 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; } } } diff --git a/src/world/tiles/visibility.rs b/src/world/tiles/visibility.rs index 5d6739d..64e5459 100644 --- a/src/world/tiles/visibility.rs +++ b/src/world/tiles/visibility.rs @@ -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