Files
dorf/src/world/tiles/tilemap.rs
T
popertots f9a74ed432 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)
2026-03-20 14:02:04 +00:00

264 lines
7.7 KiB
Rust

//! Tile map storage for pathfinding and rendering.
//!
//! # Design Choices
//!
//! ## FxHashMap over HashMap
//! Uses `rustc_hash::FxHashMap` instead of std HashMap. FxHash is 30-50% faster
//! for integer keys (IVec3) because it uses a simpler hash function optimized
//! for hashable-by-bit patterns. ~1.3M tiles are stored, so lookup speed matters.
//!
//! ## Packed Tile Data
//! FloorTileData is ~35 bytes vs 76 bytes for a naive tuple. FixtureTileData is
//! ~18 bytes vs 48 bytes. Bit-packing flags (can_stand_in/on, visibly_transparent)
//! reduces memory footprint and improves cache locality.
//!
//! ## ChunkData for O(1) Standability
//! Each chunk stores bit-packed standability data. The `is_standable()` method
//! checks chunk data first (4 bit-checks) before falling back to HashMap lookups.
//! This replaces 4 HashMap lookups with O(1) bit operations.
//!
//! ## Single-Threaded Access
//! No Arc wrapper because pathfinding runs on the main thread using thread-local
//! scratchpads. Async pathfinding was attempted but snapshot copying overhead
//! exceeded the benefit given current P99 (~357µs).
//!
//! ## Memory Layout
//! - chunks: O(1) standability lookups via bitsets (~2KB per chunk)
//! - floor_tiles: Primary pathfinding data (standability checks)
//! - fixture_tiles: Secondary checks (fixtures can be standable)
//! - item_tiles: Entity references per tile position
use bevy::prelude::*;
use rustc_hash::FxHashMap;
use super::chunk_data::ChunkData;
use crate::world::chunks::world_to_chunk;
/// Packed floor tile data for efficient storage. ~35 bytes vs 76 bytes tuple.
#[derive(Clone, Copy, Debug)]
pub struct FloorTileData {
pub id: u8,
/// bit0=can_stand_in, bit1=can_stand_on, bit2=visibly_transparent
pub flags: u8,
pub astar_weight: u8,
pub visible_range: [u32; 8],
}
impl Default for FloorTileData {
fn default() -> Self {
Self {
id: 0,
flags: 0b001,
astar_weight: 0,
visible_range: [0; 8],
}
}
}
impl FloorTileData {
pub fn new(
id: u8,
can_stand_in: bool,
can_stand_on: bool,
visibly_transparent: bool,
astar_weight: u8,
visible_range: [u32; 8],
) -> Self {
let mut flags = 0u8;
if can_stand_in {
flags |= 0b001;
}
if can_stand_on {
flags |= 0b010;
}
if visibly_transparent {
flags |= 0b100;
}
Self {
id,
flags,
astar_weight,
visible_range,
}
}
#[inline]
pub fn can_stand_in(&self) -> bool {
self.flags & 0b001 != 0
}
#[inline]
pub fn can_stand_on(&self) -> bool {
self.flags & 0b010 != 0
}
#[inline]
pub fn visibly_transparent(&self) -> bool {
self.flags & 0b100 != 0
}
#[inline]
pub fn set_can_stand_in(&mut self, value: bool) {
if value {
self.flags |= 0b001;
} else {
self.flags &= !0b001;
}
}
#[inline]
pub fn set_can_stand_on(&mut self, value: bool) {
if value {
self.flags |= 0b010;
} else {
self.flags &= !0b010;
}
}
#[inline]
pub fn set_visibly_transparent(&mut self, value: bool) {
if value {
self.flags |= 0b100;
} else {
self.flags &= !0b100;
}
}
}
/// Packed fixture tile data. ~18 bytes vs 48 bytes tuple.
#[derive(Clone, Copy, Debug)]
pub struct FixtureTileData {
pub id: u8,
/// bit0=can_stand_in, bit1=can_stand_on
pub flags: u8,
pub visible_range: [u32; 8],
}
impl Default for FixtureTileData {
fn default() -> Self {
Self {
id: 0,
flags: 0,
visible_range: [0; 8],
}
}
}
impl FixtureTileData {
pub fn new(id: u8, can_stand_in: bool, can_stand_on: bool, visible_range: [u32; 8]) -> Self {
let mut flags = 0u8;
if can_stand_in {
flags |= 0b001;
}
if can_stand_on {
flags |= 0b010;
}
Self {
id,
flags,
visible_range,
}
}
#[inline]
pub fn can_stand_in(&self) -> bool {
self.flags & 0b001 != 0
}
#[inline]
pub fn can_stand_on(&self) -> bool {
self.flags & 0b010 != 0
}
}
/// Tile map using FxHashMap for fast lookups. No Arc wrapper - single-threaded access.
#[derive(Resource, Default)]
pub struct TileMap {
/// O(1) standability lookups via bitsets (~2KB per chunk).
pub chunks: FxHashMap<IVec2, ChunkData>,
/// Primary tile storage for pathfinding (fallback for standability).
pub floor_tiles: FxHashMap<IVec3, FloorTileData>,
/// Secondary tile storage (fixtures like trees can be standable).
pub fixture_tiles: FxHashMap<IVec3, FixtureTileData>,
/// Entity references per tile position.
pub item_tiles: FxHashMap<IVec3, Vec<u32>>,
}
impl TileMap {
#[inline]
pub fn get_floor(&self, pos: &IVec3) -> Option<&FloorTileData> {
self.floor_tiles.get(pos)
}
#[inline]
pub fn insert_floor(&mut self, pos: IVec3, tile: FloorTileData) {
self.floor_tiles.insert(pos, tile);
}
#[inline]
pub fn insert_fixture(&mut self, pos: IVec3, tile: FixtureTileData) {
self.fixture_tiles.insert(pos, tile);
}
#[inline]
pub fn insert_item(&mut self, pos: IVec3, entity_id: u32) {
self.item_tiles.entry(pos).or_default().push(entity_id);
}
#[inline]
pub fn remove_item(&mut self, pos: &IVec3) -> Option<Vec<u32>> {
self.item_tiles.remove(pos)
}
#[inline]
pub fn get_floor_mut(&mut self, pos: &IVec3) -> Option<&mut FloorTileData> {
self.floor_tiles.get_mut(pos)
}
/// O(1) standability check using bit-packed chunk data.
/// Returns false if chunk is not loaded (unloaded chunks have no valid tiles).
pub fn is_standable(&self, world_pos: IVec3) -> bool {
let chunk_pos = world_to_chunk(world_pos);
let Some(chunk) = self.chunks.get(&chunk_pos) else {
return false;
};
let (local_x, local_y, z) = ChunkData::world_to_local(world_pos);
chunk.is_standable(local_x, local_y, z)
}
/// 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 {
let chunk_pos = world_to_chunk(world_pos);
if let Some(chunk) = self.chunks.get(&chunk_pos) {
let (local_x, local_y, z) = ChunkData::world_to_local(world_pos);
return chunk.get_astar_weight(local_x, local_y, z);
}
self.floor_tiles
.get(&world_pos)
.map(|t| t.astar_weight)
.unwrap_or(100)
}
/// Remove all tile data for a specific chunk from the TileMap.
/// Iterates all positions in the chunk volume and removes from HashMaps.
/// Used during chunk unloading to clean up tile data.
pub fn remove_chunk_data(&mut self, chunk_pos: IVec2) {
use crate::constants::ITILE_SIZE;
use crate::world::chunks::{CHUNK_SIZE, Z_ABOVE, Z_BELOW};
for local_x in 0..CHUNK_SIZE {
for local_y in 0..CHUNK_SIZE {
for z in -Z_BELOW as i32..=Z_ABOVE as i32 {
let pos = IVec3::new(
chunk_pos.x * CHUNK_SIZE + local_x,
chunk_pos.y * CHUNK_SIZE + local_y,
z,
) * ITILE_SIZE;
self.floor_tiles.remove(&pos);
self.fixture_tiles.remove(&pos);
self.item_tiles.remove(&pos);
}
}
}
self.chunks.remove(&chunk_pos);
}
}