Portal scans: keep per-chunk section masks across ticks

The nether-portal section precheck from #661 answered "does this section's
palette hold a portal?" per section, per tick. Keep the answer per resident
chunk in `portal_sections`, keyed by a weak handle: an edit or a chunk
replacement rebuilds it, and it is dropped when a chunk unloads and cleared
when the block facts change, so a portal block always forces the sweep. The
entity sweep now finds portal blocks without the per-tick map.

This is a scan microbenchmark, not a whole-game claim: steady improves 2.4x
to 17x over the fixtures and the run parity-checks the cached and per-tick
answers. Rescanning every chunk every tick (cold, reload, edit_all) is slower
because a miss reads all 24 sections of a chunk; see
docs/portal-scan-benchmark.md.
This commit is contained in:
rdbtCVS
2026-10-17 22:29:59 +01:00
parent dc1a8a4f39
commit c4cc7954ce
7 changed files with 490 additions and 18 deletions
@@ -0,0 +1,263 @@
//! The `entity_portals` section scan only: `may_hold_portal` over synthetic
//! entity reaches, with the per-tick palette map against the resident cache.
//! Run with prepared client resources:
//! cargo run --profile fast -p horizon-client --example portal_scan_bench -- RESOURCES
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, SECTIONS, Section},
fast_hash::FxHashMap,
portal_sections::PortalSections,
};
use horizon_core::{
geometry::{Aabb, Vec3},
position::BlockPos,
};
use horizon_world::{level::Behaviours, registry::BlockStateId};
use std::{hint::black_box, path::Path, sync::Arc, time::Instant};
/// `Game::swept_portal`'s reach (`Entity.checkInsideBlocks`'s swept box).
fn reach(from: Vec3, to: Vec3, width: f32, height: f32) -> Aabb {
let half = f64::from(width) / 2.0;
let bounds = Aabb::new(
Vec3::new(to.x - half, to.y, to.z - half),
Vec3::new(to.x + half, to.y + f64::from(height), to.z + half),
)
.deflate(Vec3::new(1.0e-5, 1.0e-5, 1.0e-5));
bounds
.union_bounds(bounds.move_by(from.subtract(to)))
.inflate(Vec3::new(1.0, 1.0, 1.0))
}
fn sections(reach: Aabb) -> (i64, i32, i32, i32, i32, i32, i32) {
let (min, max) = (reach.min_position(), reach.max_position());
let (min, max) = (BlockPos::containing(min), BlockPos::containing(max));
let top = SECTIONS as i32 - 1;
let (x0, x1) = (min.x >> 4, max.x >> 4);
let (z0, z1) = (min.z >> 4, max.z >> 4);
let (y0, y1) = (
((min.y - MIN_Y) >> 4).clamp(0, top),
((max.y - MIN_Y) >> 4).clamp(0, top),
);
let count = (i64::from(x1) - i64::from(x0) + 1)
* (i64::from(y1) - i64::from(y0) + 1)
* (i64::from(z1) - i64::from(z0) + 1);
(count, x0, x1, y0, y1, z0, z1)
}
/// The original `Game::may_hold_portal`: per-tick `seen`, palette per section.
fn may_hold_portal_baseline(
reach: Aabb,
world: &FxHashMap<(i32, i32), Arc<Chunk>>,
seen: &mut FxHashMap<(i32, i32, i32), bool>,
behaviours: &Behaviours,
) -> bool {
let (count, x0, x1, y0, y1, z0, z1) = sections(reach);
if count > 64 {
return true;
}
(x0..=x1).any(|cx| {
(z0..=z1).any(|cz| {
(y0..=y1).any(|sy| {
*seen.entry((cx, sy, cz)).or_insert_with(|| {
world
.get(&(cx, cz))
.and_then(|c| c.sections.get(sy as usize))
.is_some_and(|s| {
s.blocks
.maybe_has(|v| behaviours.portal_axis(BlockStateId(v)).is_some())
})
})
})
})
})
}
/// The cached `Game::may_hold_portal`: one mask per resident chunk.
fn may_hold_portal_cached(
reach: Aabb,
world: &FxHashMap<(i32, i32), Arc<Chunk>>,
cache: &mut PortalSections,
behaviours: &Behaviours,
) -> bool {
let (count, x0, x1, y0, y1, z0, z1) = sections(reach);
if count > 64 {
return true;
}
let mut y_mask = 0u32;
for sy in y0..=y1 {
y_mask |= 1 << sy;
}
(x0..=x1).any(|cx| {
(z0..=z1).any(|cz| {
world.get(&(cx, cz)).is_some_and(|chunk| {
cache.get((cx, cz), chunk, |v| {
behaviours.portal_axis(BlockStateId(v)).is_some()
}) & y_mask
!= 0
})
})
})
}
fn main() -> horizon_client::Result<()> {
let root = std::env::args()
.nth(1)
.ok_or("expected prepared client resource directory")?;
let start = Instant::now();
let registry = horizon_world::level::data::load_registry(Path::new(&root))?;
let (facts, behaviours) = BlockFacts::load_pair(Path::new(&root), &registry)?;
println!(
"resource_load_ms={:.3}",
start.elapsed().as_secs_f64() * 1000.0
);
let len = registry.len() as u32;
let portal: Vec<u32> = (0..len)
.filter(|&id| behaviours.portal_axis(BlockStateId(id)).is_some())
.collect();
let inactive_states: Vec<u32> = (0..len)
.filter(|&id| behaviours.portal_axis(BlockStateId(id)).is_none())
.collect();
let _ = &facts;
let portal_state = *portal
.first()
.ok_or("no nether portal block state in the registry")?;
println!("portal_states={}", portal.len());
// 500 entities spread over a 17x17 chunk world. Their reaches touch 1-4
// sections, as an item or a mob standing in or stepping into a portal does.
let grid = 17;
let base = -(grid / 2) * 16;
let entities: Vec<(Vec3, Vec3, f32, f32)> = (0..500)
.map(|i| {
let x = base + (i % grid) * 16 + (i % 5);
let z = base + (i / grid % grid) * 16 + (i % 7);
let y = 64 + (i % 3) * 16;
let from = Vec3::new(f64::from(x), f64::from(y), f64::from(z));
let to = from.add_vector(Vec3::new(0.1, 0.0, 0.07));
(from, to, 0.25, 0.25)
})
.collect();
let reaches: Vec<Aabb> = entities
.iter()
.map(|&(from, to, w, h)| reach(from, to, w, h))
.collect();
for (name, palette, portals_every) in [
("sparse", 1usize, 0usize),
("dense", 128, 0),
("global", 300, 0),
("dense_portal", 128, 5),
] {
let start = Instant::now();
let mut world: FxHashMap<(i32, i32), Arc<Chunk>> = FxHashMap::default();
for czi in 0..grid {
for cxi in 0..grid {
let (cx, cz) = (-(grid / 2) + cxi, -(grid / 2) + czi);
let mut chunk = Chunk::empty(cx, cz);
for (i, section) in chunk.sections.iter_mut().enumerate() {
let values: [u32; 4096] = std::array::from_fn(|j| {
if portals_every > 0 && i % portals_every == 0 && j == 0 {
portal_state
} else {
inactive_states[(j * 7 + i) % palette.min(inactive_states.len())]
}
});
Arc::make_mut(section).blocks = Section::from_blocks(&values);
}
world.insert((cx, cz), Arc::new(chunk));
}
}
println!(
"{name} fixture_ms={:.3}",
start.elapsed().as_secs_f64() * 1000.0
);
let keys: Vec<_> = world.keys().copied().collect();
let portal_chunks: Vec<(i32, i32)> = keys
.iter()
.copied()
.filter(|&(cx, cz)| {
world[&(cx, cz)]
.sections
.iter()
.any(|s| s.blocks.maybe_has(|v| v == portal_state))
})
.collect();
println!("{name} portal_chunks={}", portal_chunks.len());
// The global palette's maybe_has walks the whole ID range, so rescanning
// every chunk each tick (cold/reload/edit_all) is intractable; those rows
// are only meaningful for the palettes whose scans are bounded.
let workloads: &[(&str, usize)] = if name == "global" {
&[("steady", 0), ("edit_1pct", 3)]
} else {
&[
("steady", 0),
("cold", 0),
("reload", 0),
("edit_1pct", 3),
("edit_all", 289),
]
};
for &(workload, changed) in workloads {
let mut baseline = Vec::new();
let mut candidate = Vec::new();
let mut cache = PortalSections::default();
let (mut base_hits, mut cand_hits) = (0u64, 0u64);
for sample in 0..8 {
for cached in if sample % 2 == 0 {
[false, true]
} else {
[true, false]
} {
let start = Instant::now();
let mut result = 0u64;
for tick in 0..300 {
if cached && workload == "cold" {
cache = PortalSections::default();
}
if cached && workload == "reload" {
cache.clear();
}
for key in keys.iter().take(changed) {
Arc::make_mut(world.get_mut(key).unwrap()).set(
0,
MIN_Y,
0,
inactive_states[tick % inactive_states.len()],
);
}
let mut seen = FxHashMap::default();
for reach in &reaches {
let hit = if cached {
may_hold_portal_cached(*reach, &world, &mut cache, &behaviours)
} else {
may_hold_portal_baseline(*reach, &world, &mut seen, &behaviours)
};
result += hit as u64;
}
}
if cached {
cache = PortalSections::default();
cand_hits += black_box(result);
} else {
base_hits += black_box(result);
}
if sample > 0 {
let us = start.elapsed().as_secs_f64() * 1e6 / 300.0;
if cached {
candidate.push(us);
} else {
baseline.push(us);
}
}
}
}
assert_eq!(
base_hits, cand_hits,
"{name} {workload}: cached may_hold_portal diverged"
);
println!("{name} {workload} baseline_us={baseline:.3?} candidate_us={candidate:.3?}");
}
}
println!("chunk_wrapper_bytes={}", std::mem::size_of::<Chunk>());
Ok(())
}
+1
View File
@@ -1815,6 +1815,7 @@ impl Game<'_> {
}
for &key in self.tick_chunks.removed() {
self.random_tick_sections.remove(&key);
self.portals.sections.remove(&key);
self.block_ticks
.remove_chunk(horizon_core::position::ChunkPos::new(key.0, key.1));
}
+20 -18
View File
@@ -65,6 +65,8 @@ pub(super) struct Portals {
arrivals: Vec<Arrival>,
loaded: bool,
dirty: bool,
/// Per-chunk portal palette masks, kept across ticks (portal_sections).
pub(super) sections: horizon_client::portal_sections::PortalSections,
}
fn rotate_y(v: Vec3, degrees: f32) -> Vec3 {
@@ -486,13 +488,14 @@ impl Game<'_> {
.filter(|m| self.mob_can_use_portal(m))
.map(|m| (m.id, m.position, m.width(), m.height()))
.collect();
let mut portal_sections = horizon_client::fast_hash::FxHashMap::default();
let mut portal_sections = std::mem::take(&mut self.portals.sections);
for (id, to, width, height) in items.into_iter().chain(mobs) {
let from = self.portals.before.get(&id).copied().unwrap_or(to);
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
inside.push((id, pos));
}
}
self.portals.sections = portal_sections;
for (id, pos) in inside {
self.portals
.entities
@@ -509,7 +512,7 @@ impl Game<'_> {
to: Vec3,
width: f32,
height: f32,
portal_sections: &mut horizon_client::fast_hash::FxHashMap<(i32, i32, i32), bool>,
portal_sections: &mut horizon_client::portal_sections::PortalSections,
) -> Option<BlockPos> {
let half = f64::from(width) / 2.0;
let bounds = horizon_core::geometry::Aabb::new(
@@ -550,12 +553,13 @@ impl Game<'_> {
found
}
/// Whether a section `reach` touches has a nether portal state in its
/// palette: without one, no block there is a portal. Answers are kept
/// per section for the tick; a box over many sections is not checked.
/// palette: without one, no block there is a portal. Palette answers are
/// kept per chunk across ticks (`portal_sections`); a box over many
/// sections is not checked.
fn may_hold_portal(
&self,
reach: horizon_core::geometry::Aabb,
seen: &mut horizon_client::fast_hash::FxHashMap<(i32, i32, i32), bool>,
sections: &mut horizon_client::portal_sections::PortalSections,
) -> bool {
use horizon_client::chunk::{MIN_Y, SECTIONS};
let (min, max) = (reach.min_position(), reach.max_position());
@@ -576,23 +580,21 @@ impl Game<'_> {
if count > 64 {
return true;
}
let mut y_mask = 0u32;
for sy in y0..=y1 {
y_mask |= 1 << sy;
}
let behaviours = &self.res.behaviours;
let chunks = &self.world.chunks;
(x0..=x1).any(|cx| {
(z0..=z1).any(|cz| {
(y0..=y1).any(|sy| {
*seen.entry((cx, sy, cz)).or_insert_with(|| {
chunks
.get(&(cx, cz))
.and_then(|c| c.sections.get(sy as usize))
.is_some_and(|s| {
s.blocks.maybe_has(|v| {
behaviours
.portal_axis(horizon_world::registry::BlockStateId(v))
.is_some()
})
})
})
chunks.get(&(cx, cz)).is_some_and(|chunk| {
sections.get((cx, cz), chunk, |v| {
behaviours
.portal_axis(horizon_world::registry::BlockStateId(v))
.is_some()
}) & y_mask
!= 0
})
})
})
+1
View File
@@ -154,6 +154,7 @@ impl Game<'_> {
};
// Everything loaded: switch over.
self.random_tick_sections.clear();
self.portals.sections.clear();
self.options.resources = root.to_path_buf();
let context = pack.context.clone();
self.pack = pack;
+1
View File
@@ -133,6 +133,7 @@ pub mod patrol;
pub mod perf_overlay;
pub mod pick_item;
pub mod poi_sections;
pub mod portal_sections;
pub mod pose;
pub mod post;
pub mod projectiles;
@@ -0,0 +1,123 @@
//! Memoized nether-portal palette eligibility per resident chunk.
//!
//! `Game::find_entity_portals` asks the same sections every tick whether their
//! palette may hold a portal state. The answer only changes when a chunk's
//! blocks change, so it is kept per chunk until the weak handle stops matching.
use crate::{chunk::Chunk, fast_hash::FxHashMap};
use std::sync::{Arc, Weak};
struct Cached {
chunk: Weak<Chunk>,
/// A bit per section, set when that section's palette may hold a portal.
sections: u32,
}
/// Resident chunks are immutable behind `Arc`; edits use `Arc::make_mut`.
/// A weak reference detects both edits and replacement, without retaining block
/// data or forcing a deep copy on the next edit. It also prevents address reuse.
/// Clear this cache whenever the gameplay facts change (including resource reload).
#[derive(Default)]
pub struct PortalSections {
chunks: FxHashMap<(i32, i32), Cached>,
}
impl PortalSections {
pub fn clear(&mut self) {
self.chunks.clear();
}
pub fn remove(&mut self, key: &(i32, i32)) {
self.chunks.remove(key);
}
/// The sections of `chunk` whose palette may hold a portal, as a bitmask in
/// ascending section order (`MIN_Y` at bit 0). `portal` is only called while
/// (re)building an entry, so an unchanged chunk scans its palette once.
pub fn get(
&mut self,
key: (i32, i32),
chunk: &Arc<Chunk>,
mut portal: impl FnMut(u32) -> bool,
) -> u32 {
const {
assert!(crate::chunk::SECTIONS <= u32::BITS as usize);
}
let cached = self.chunks.entry(key).or_insert_with(|| Cached {
chunk: Weak::new(),
sections: 0,
});
if cached.chunk.as_ptr() != Arc::as_ptr(chunk) {
let mut mask = 0;
for (i, section) in chunk.sections.iter().enumerate() {
// Preserve maybe_has, including unused entries and global palettes.
if section.blocks.maybe_has(&mut portal) {
mask |= 1 << i;
}
}
cached.sections = mask;
cached.chunk = Arc::downgrade(chunk);
}
cached.sections
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chunk::{MIN_Y, SECTIONS, Section};
#[test]
fn edits_replacements_compaction_and_reload() {
let mut cache = PortalSections::default();
let mut chunk = Arc::new(Chunk::empty(0, 0));
assert_eq!(cache.get((0, 0), &chunk, |id| id == 7), 0);
cache.get((0, 0), &chunk, |_| panic!("unchanged chunk rescanned"));
let worker = chunk.clone();
Arc::make_mut(&mut chunk).set(0, MIN_Y, 0, 7);
assert_eq!(cache.get((0, 0), &chunk, |id| id == 7), 1);
assert_eq!(worker.get(0, MIN_Y, 0), 0);
// Even after the last portal block is removed, its unused palette entry counts.
Arc::make_mut(&mut chunk).set(0, MIN_Y, 0, 0);
assert_eq!(cache.get((0, 0), &chunk, |id| id == 7), 1);
Arc::make_mut(&mut Arc::make_mut(&mut chunk).sections[0]).compact();
assert_eq!(cache.get((0, 0), &chunk, |id| id == 7), 0);
Arc::make_mut(&mut chunk).sections[SECTIONS - 1] = Arc::new(Section::default());
cache.clear(); // resource reload, with changed facts for the same state IDs
assert_eq!(
cache.get((0, 0), &chunk, |id| id == 0),
(1 << SECTIONS) - 1,
"a fresh palette is rescanned for every section"
);
cache.remove(&(0, 0));
assert!(cache.chunks.is_empty());
}
#[test]
fn replacement_at_same_coordinates_and_early_snapshot() {
let mut cache = PortalSections::default();
let mut chunk = Arc::new(Chunk::empty(-2, 3));
let before = cache.get((-2, 3), &chunk, |id| id == 1);
Arc::make_mut(&mut chunk).set(0, MIN_Y + 16, 0, 1);
assert_eq!(before, 0);
assert_eq!(cache.get((-2, 3), &chunk, |id| id == 1), 1 << 1);
let weak = Arc::downgrade(&chunk);
drop(chunk);
assert!(weak.upgrade().is_none()); // cache does not retain unloaded block data
let replacement = Arc::new(Chunk::empty(-2, 3));
assert_eq!(cache.get((-2, 3), &replacement, |id| id == 1), 0);
}
#[test]
fn global_palette_covers_the_whole_id_range() {
let mut chunk = Chunk::empty(0, 0);
for i in [0, 9, SECTIONS - 1] {
Arc::make_mut(&mut chunk.sections[i]).blocks =
Section::from_blocks(&std::array::from_fn(|j| (j % 300) as u32));
}
let chunk = Arc::new(chunk);
let mut cache = PortalSections::default();
// Global maybe_has considers the entire ID range, including absent states.
assert_eq!(
cache.get((0, 0), &chunk, |id| id == 40000),
(1 << 0) | (1 << 9) | (1 << (SECTIONS - 1))
);
}
}
+81
View File
@@ -0,0 +1,81 @@
# Nether-portal section-scan benchmark
Run with the pinned prepared client resources:
```sh
CARGO_BUILD_JOBS=6 cargo run --profile fast -p horizon-client --example portal_scan_bench -- \
.local/horizon-client/26.3
```
The example keeps the original `Game::may_hold_portal` alongside the resident
cache (`portal_sections`). Both run in the same optimized binary, alternating
measurement order. Each workload has one discarded warmup pair and seven pairs
of 300 ticks. It reports resource loading and fixture construction separately.
This measures the section scan only, not the whole `entity_portals` span, the
sweep that follows a positive answer, or the rest of the tick.
The fixture is a 17x17 chunk world (289 chunks), each with 24 sections, and 500
entity reaches spread over it (an item or mob box 0.25 wide and the small move
that produces the swept box). A reach touches 1-4 sections. Four palettes:
sparse (one non-portal state), dense (128 distinct), global (300 distinct, a
global palette whose `maybe_has` walks the whole state range) and dense_portal
(the dense palette with a portal in every fifth section). These are controlled
fixtures, not a claim about typical generated terrain.
Steady includes the initial cache fill amortized over 300 ticks. Cold recreates
the cache each tick, including allocation and destruction; reload clears it each
tick while retaining capacity. Edit workloads alternate a block value in three
or all 289 chunks before every scan. Timing includes the `Arc::make_mut` move of
the 176-byte chunk wrapper caused by weak cache handles; the baseline holds no
such handles. Both paths keep the original outer reach list.
The scan is parity-checked in the same run: the cached and per-tick answers must
agree for every reach over every sample, or the example panics.
Measured on an AMD Ryzen 5 5600X (12 logical CPUs), Linux x86-64, rustc 1.99.0
(b940084d7), using `fast` (release optimization, LTO off, 16 codegen units).
Resource loading took 705 ms; fixture construction took 66 ms sparse, 360 ms
dense, 392 ms global and 385 ms dense_portal, shared by both implementations.
Median microseconds per tick (seven paired samples):
| Palette | Workload | Original | Cached | Speedup |
| --- | --- | ---: | ---: | ---: |
| sparse | steady | 40.058 | 16.836 | 2.38x |
| sparse | cold | 40.203 | 48.619 | 0.83x |
| sparse | reload | 40.828 | 44.552 | 0.92x |
| sparse | edit_1pct | 41.282 | 52.200 | 0.79x |
| sparse | edit_all | 50.119 | 4192.396 | 0.01x |
| dense | steady | 244.610 | 21.869 | 11.19x |
| dense | cold | 292.533 | 1505.691 | 0.19x |
| dense | reload | 250.397 | 1301.608 | 0.19x |
| dense | edit_1pct | 256.522 | 69.132 | 3.71x |
| dense | edit_all | 263.829 | 5904.581 | 0.04x |
| global | steady | 6179.070 | 360.082 | 17.16x |
| global | edit_1pct | 6251.530 | 1565.249 | 3.99x |
| dense_portal | steady | 256.854 | 21.078 | 12.19x |
| dense_portal | cold | 240.498 | 1526.251 | 0.16x |
| dense_portal | reload | 347.879 | 2037.123 | 0.17x |
| dense_portal | edit_1pct | 368.684 | 118.108 | 3.12x |
| dense_portal | edit_all | 282.876 | 7194.112 | 0.04x |
The global palette's `maybe_has` walks the whole ID range, so rescanning every
chunk each tick (cold, reload, edit_all) is intractable; those rows are not
measured for it.
Keep: the steady scan improves for every palette (2.4x to 17x), which is the
case that repeats each tick in real play. Under constant edits the cache also
improves the dense and global palettes (edit_1pct 3-4x) because the original
rescans through the per-tick map. Rescanning every chunk every tick (cold,
reload, edit_all) is slower than the original because a miss scans all 24
sections of a chunk, where the original only touched the sections a reach
overlaps. No whole-game speedup is inferred from this microbenchmark; real
terrain's palette sizes and block-write frequency determine the gain. Lighting
and other chunk changes conservatively invalidate a chunk's mask.
The cache adds a weak handle and mask per visited resident chunk, retains no
unloaded block data, and is pruned when a chunk unloads (`sync_tick_chunks`) and
cleared on resource reload, like `random_tick_sections`. Editing a cached chunk
can move its 176-byte wrapper (plus Arc bookkeeping) on the first edit after it
is cached; it does not force a deep copy of the block data. The original outer
reach-list allocation remains.