512 chunks/frame in debug, 1024 in release. In release, recolor runs
~2x faster so 1024 still costs ~1ms but clears the dirty queue twice
as fast, closing the p99 gap during rapid z-scrolling.
Negative world z (below ground) was excluded by the clamp, making the
visible window asymmetrically large. Dirty count now proportional to
actual scroll distance. Frame time dropped from ~13ms to ~8.3ms avg.
Boundary: mark all z-levels where z_diff changed, not just 4 boundary
indices. For multi-step scrolls, every z-level between
min(old,new)-8 and max(old,new) inclusive needs recoloring.
Also fixes last_z_change_dirty_count to use actual new_keys.len().
Recolor: update tileset_index alongside color so sky tile switch at
t>=0.98 actually renders the sky texture, not sky-coloured grass.
Cleanup: removed solid_bits (was unused after recolor fix).
Three optimizations for z-scroll performance:
Option 1 — Fast in-place recolor (no HashMap lookups):
- TilemapChunkStates stores solid_bits + underground_bits per chunk
- recolor_chunk_for_depth iterates flat Vec<Option<TileData>> — pure cache-friendly sequential reads
- tile_fade_color computes color from tileset_idx + z_diff without any HashMap access
- vs repopulate_chunk_tiles: 32K Vec reads vs 32K HashMap lookups per frame
Option 2 — Targeted boundary dirty (from 9×chunks to 2×chunks):
- on_camera_z_changed now only marks the 4 boundary z-levels:
old_entered, old_exited, new_entered, new_exited
- Registry.z_change_keys tracks z-changed keys separately from occlusion dirty_keys
- populate_tilemap_chunk_data uses fast recolor for z_change_keys,
full repopulate for dirty_keys (occlusion — rare)
Option 3 — Separate budgets:
- z_change_keys processed with MAX_POPULATE_PER_FRAME budget (fast recolor)
- dirty_keys processed with MAX_POPULATE_PER_FRAME budget (full repopulate)
- Both pipelines tracked separately in benchmark
Also:
- populate_chunk_tiles now returns underground_bits for state storage
- compute_solid_bits helper extracts solid tiles from TileData Vec
- spawn_tilemap_chunks computes and stores both bitmasks on spawn
- despawn_tilemap_chunks clears all state for despawned chunks
on_camera_z_changed: only dirty z-levels whose z_diff changed
(both old and new camera positions, within 0..=8 window).
Previously dirtied all 20k entities on every z-change.
populate_tilemap_chunk_data: process up to MAX_POPULATE_PER_FRAME
(512) keys per frame, sorted by z-distance from current camera.
Spreads z-scroll cost across multiple frames instead of one spike.
TilemapBenchmark: added z-change spike instrumentation
(last_z_change_dirty_ms, last_z_change_dirty_count,
last_z_change_populate_ms, z_change_history_ms).
F9 report now shows z-scroll metrics.
PreviousZIndex resource tracks prior camera z for targeted dirty calc.
- tile_for_depth: alpha always 1.0, lerp RGB from white toward sky RGB
instead of fading tile out via alpha. Tiles stay opaque.
- Expose LAYER_ALPHA as pub const so it can be tuned at runtime
- LAYER_ALPHA changed from 41/255 to 50/255 for steeper visible fade
- Invisible tiles: explicit solid sky colour, not tile_for_depth(0, 999)
Replace df_fade_color (multiplicative tint that preserves texture hue)
with tile_for_depth which cross-fades between real tile and sky tile:
- z_diff <= 0: real tile at full opacity (no fade)
- z_diff in (0, threshold): real tile fades out via alpha
- z_diff past threshold: sky tile (index 0) fades in
- invisible tiles: solid sky colour
Also fix tile_id_to_tileset_index to include sky (id=0) as index 0,
so sky tiles render correctly as the fade target.
Chunk unloading system with cleanly abstracted decision layer.
- ChunkMap: replace (bool, i32) timer tuple with () presence marker;
remove FIFO cycle fields pending_unload/unload_cycle/unload_cursor/
unload_countdown. loaded_chunks is now HashMap<IVec2, ()>.
- handle_chunk_events: simplify re-entrancy check to contains_key;
remove parallel Mutex dedup machinery — load detection is now trivial.
- is_standable: single HashMap get; return false for unloaded chunks
instead of falling back to is_standable_slow (pathfinding correctness).
- remove_chunk_data: bounds-iteration tile cleanup for chunk unload.
- ChunkOwner component + chunk_entity_index: static terrain entities
(floor tiles, fixtures, trees) tagged at spawn and despawned by
chunk. Mobile entities (dorfs, pigs, rabbits) not tracked — safe
by design since they were never indexed.
- unload_chunk: canonical single-chunk unload function; cleans
loaded_chunks, despawns entities, removes tilemap data, marks render
chunk dirty via ChunkZKey::from_world, updates connectivity, fires
rebake. Full docstring with step-by-step state map, working example
for dynamic unload, and "what it does NOT handle" section.
- dynamic_chunk_unloading_system: no-op stub. Re-enable by writing
a system that diffs wanted vs loaded chunks and calls unload_chunk.
Wired out of FixedUpdate during foundation work.
- render chunk fix: ChunkZKey::from_world now pub(crate) so unload
can derive the correct render chunk key (spans 4x4 world chunks)
matching what build_quilted_terrain_sprites uses at spawn.
- FloorTilePrefab::spawn: pre-existing bug — return Entity not ().
- Fix PathNode and ChunkPathNode Ord impl to use correct min-heap ordering
(self.f_score.cmp vs other.f_score.cmp) - was causing zig-zag paths
- Fix movement threshold formula to use tile_weight directly
(walk_speed * tile_weight / 50) instead of inverted multiplier
- Update comments to reflect 'higher = slower to traverse' semantics
Phase 1: Bit-packed standability
- Add ChunkData struct with 4 bitsets per chunk (stand_in/on for floor/fixture)
- Replace 4 HashMap lookups per standability check with O(1) bit operations
- Memory: ~2KB bitsets per chunk vs ~50KB HashMap overhead
Phase 2: Reactive connectivity
- Add dirty_chunks HashSet to ChunkMap for incremental updates
- update_chunk_connectivity now O(d) where d = dirty chunks
- Early exit when no changes, preventing O(N) full rebuilds
Phase 3: Async terrain baking
- Move terrain generation to AsyncComputeTaskPool
- spawn_terrain_tasks: non-blocking task spawn (~34µs)
- apply_terrain_blobs: batched entity spawn on main thread
- Eliminates main-thread stutters during world generation
- Add validation_cooldown field to Ambulatory component
- Implement validate_next_steps to check walkability of next 3 path nodes
- Integrate validation into movement system (every 10 frames)
- Trigger re-path when validation fails (path blocked or invalid)
- Entities now detect and recover from invalid paths automatically
1. CSV Overflow Panic: Use saturating_sub to prevent underflow when total_failed_paths > n
2. Heuristic Scale Mismatch: octile_distance_3d now divides by ITILE_SIZE to match g-score units
3. Goal Z Offset: Correct target.as_ivec3() - ivec3(0,0,1) not ITILE_SIZE (target already has +1.0)
4. Gravity Yo-Yo: Clear current_path and target when entity falls to prevent teleportation loop
The async pathfinding with StandableBitGrid caused massive lag due to synchronous
bounding box calculation in the main thread (millions of hashmap lookups for long paths).
Additionally, the splicing logic had a catastrophic bug where it applied a single
finished path to ALL entities unconditionally.
Solution:
- Revert to thread-local synchronous A*
- Implement PathRequestQueue to process max 8 paths per frame
- Keep provisional paths for immediate movement
- Since entity walks provisional path, full path calculates from current position,
eliminating the need for complex splicing logic.
The is_standable_tile(goal) check was causing failures when goal position
wasn't directly standable (common case). A* doesn't require goal to be
standable - it finds the best path it can. Removed the check and return
fallback single-point path when start isn't standable.
Provisional paths were not being recorded to benchmark stats, causing
total_paths=0 while failed_paths accumulated. Now records:
- path_calc_times_us (timing)
- path_lengths (path length)
- nodes_expanded (A* nodes visited)
- failed_paths (when start/goal not standable)
The condition 'if ambulatory.current_path.is_none() || pending_async.is_some()' was backwards.
It should skip entities that ALREADY have a path, not entities WITHOUT a path.
Fixed to properly skip entities with existing paths or pending async tasks.
- Restored original target selection logic that validates is_standable_tile
- Fixed target position z-level checking (check floor tile then standable)
- Re-added check for completed paths (path_index >= path.len)
- This was the cause of 50,297 failed paths - entities targeting invalid positions
Key fixes based on benchmark analysis:
1. Remove TIER0 Vec-based pathfinding - O(N) linear scan was slower than FxHashMap for typical path lengths
2. Consolidate scratchpads into single AStarScratchpad struct - eliminates nested RefCell borrow overhead
3. Remove Arc wrapper from TileMap - eliminated Copy-on-Write trap causing 63ms stutters
4. Replace AHashMap with FxHashMap - FxHash is faster for small integer keys like IVec3
5. Simplify tier logic - single pathfinding function with scratchpad reuse
Benchmark analysis showed:
- Original: 1.95 µs/node, P99 1.1ms, Max 5ms
- TIER0/TIER1 regression: 2.89 µs/node (+48%), P99 5ms (+348%)
- Root causes: Vec linear scan in TIER0, nested RefCell borrows, Arc::make_mut CoW
This should restore and improve performance by using simple FxHashMap scratchpad for all paths.
- Add Arc<AHashMap> wrapper around floor_tiles, fixture_tiles, item_tiles
- Copy-on-write semantics: Arc::make_mut clones only if other Arcs exist
- Add insert_floor, insert_fixture, insert_item, remove_item methods
- Add get_floor_mut, get_fixture_mut for visibility updates
- Update all mutation sites to use new TileMap methods
- Enables cheap Arc::clone for async pathfinding workers
- Single-threaded pathfinding: no clone, direct access
- Multi-threaded pathfinding: clone Arc, read without locks
- Replace std HashMap (SipHash) with ahash::AHashMap for fast non-crypto hashing
- Pack tile data from tuples to structs: FloorTileData (~35 bytes) and FixtureTileData (~18 bytes)
- FloorTileData: pack 3 bools into single flags byte, use u8 for id/weight
- FixtureTileData: pack 2 bools into single flags byte
- Update all accessors: is_standable_tile, visibility, terrain generation, forestry
- Preparation for async pathfinding (Arc wrapping to come in follow-up)
Memory reduction: ~54% for floor tiles (76→35 bytes), ~62% for fixtures (48→18 bytes)
Hash performance: AHashMap uses fxhash, faster than SipHash for game data