From 4459f132fe481be02ed0c2a2e2e41b7666ba3dbe Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:44:09 +0100 Subject: [PATCH 1/2] Collision scans: skip the outer cells of sections no block reaches out of BlockCollisions keeps a block on the cells around the box (Cursor3D types 1 and 2) only if it has a large collision shape or is a moving piston. Most sections hold neither in their palette, so the scan now asks the provider once per section and passes those cells without reading them: a mob's box reads about a fifth of the cells it did. Same hits, same order. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/collisions.rs | 18 +++ crates/horizon-world/src/block_collision.rs | 7 ++ .../horizon-world/src/block_collision_scan.rs | 31 +++++ .../tests/block_collision_scan.rs | 107 +++++++++++++----- 4 files changed, 136 insertions(+), 27 deletions(-) diff --git a/crates/horizon-client/src/collisions.rs b/crates/horizon-client/src/collisions.rs index 3ff2cfdd6..7c32bd829 100644 --- a/crates/horizon-client/src/collisions.rs +++ b/crates/horizon-client/src/collisions.rs @@ -92,6 +92,13 @@ impl BlockCollisionWorld for EntityCollisions<'_, W> { ) -> std::result::Result { self.blocks.is_suffocating(chunk, block, p) } + fn may_reach_out( + &mut self, + chunk: Self::Chunk, + section_y: i32, + ) -> std::result::Result { + self.blocks.may_reach_out(chunk, section_y) + } fn context_shape( &mut self, block: CollisionBlock, @@ -262,6 +269,17 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> { fn collision_block(&mut self, chunk: Self::Chunk, p: BlockPos) -> io::Result { self.binding.collision_block(chunk.block(p.x, p.y, p.z)) } + /// From the section's palette, without reading its blocks; a palette + /// of the whole registry reads every cell. + fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result { + let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4)) + .ok() + .and_then(|s| chunk.sections.get(s)) + .map(|section| §ion.blocks); + Ok(blocks.is_none_or(|blocks| { + blocks.is_global() || blocks.maybe_has(|v| self.binding.reaches_out(BlockStateId(v))) + })) + } fn is_suffocating( &mut self, _: Self::Chunk, diff --git a/crates/horizon-world/src/block_collision.rs b/crates/horizon-world/src/block_collision.rs index 3770a1a0f..9e2d81b78 100644 --- a/crates/horizon-world/src/block_collision.rs +++ b/crates/horizon-world/src/block_collision.rs @@ -158,6 +158,13 @@ impl<'r> BlockCollisionRegistry<'r> { empty_shape: flags & 4 != 0, }) } + /// Whether a scan keeps `state` in the cells around its box: a large + /// collision shape or a moving piston. States outside the registry count. + pub fn reaches_out(&self, state: BlockStateId) -> bool { + self.scan_flags + .get(state.0 as usize) + .is_none_or(|flags| flags & 3 != 0) + } pub fn collision_shape( &self, world: &mut W, diff --git a/crates/horizon-world/src/block_collision_scan.rs b/crates/horizon-world/src/block_collision_scan.rs index 6c26c6631..391c3e96e 100644 --- a/crates/horizon-world/src/block_collision_scan.rs +++ b/crates/horizon-world/src/block_collision_scan.rs @@ -41,6 +41,13 @@ pub trait BlockCollisionWorld { position: BlockPos, context: &CollisionContext<'_>, ) -> Result; + /// Whether a block of the chunk's section `section_y` (block y >> 4) may + /// have a large collision shape or be a moving piston: the only blocks + /// the scan keeps outside the box's own cells. False lets the scan pass + /// that section's outer cells without reading them; true reads them all. + fn may_reach_out(&mut self, _chunk: Self::Chunk, _section_y: i32) -> Result { + Ok(true) + } } #[derive(Debug)] @@ -88,6 +95,8 @@ pub struct BlockCollisionScan<'w, 'c, W: BlockCollisionWorld> { count: usize, budget: usize, chunk: Option<(ChunkPos, W::Chunk)>, + /// `may_reach_out` of the last section an outer cell was in. + reach: Option<(ChunkPos, i32, bool)>, failed: bool, } impl<'w, 'c, W: BlockCollisionWorld> BlockCollisionScan<'w, 'c, W> { @@ -137,6 +146,7 @@ impl<'w, 'c, W: BlockCollisionWorld> BlockCollisionScan<'w, 'c, W> { count, budget: candidate_budget, chunk: None, + reach: None, failed: false, }) } @@ -184,6 +194,27 @@ impl<'w, 'c, W: BlockCollisionWorld> BlockCollisionScan<'w, 'c, W> { let Some(chunk) = chunk else { continue; }; + // An outer cell only counts for a block reaching out of its own + // cell: most sections hold none, so their outer cells go unread + // (the result is the same, with a fifth of the block reads for a + // mob's box). + if kind != 0 { + let section_y = p.y >> 4; + let reaches = match self.reach { + Some((at, y, reaches)) if at == chunk_position && y == section_y => reaches, + _ => { + let reaches = self + .world + .may_reach_out(chunk, section_y) + .map_err(CollisionScanError::World)?; + self.reach = Some((chunk_position, section_y, reaches)); + reaches + } + }; + if !reaches { + continue; + } + } let block = self .world .collision_block(chunk, p) diff --git a/crates/horizon-world/tests/block_collision_scan.rs b/crates/horizon-world/tests/block_collision_scan.rs index 32735f826..adf530069 100644 --- a/crates/horizon-world/tests/block_collision_scan.rs +++ b/crates/horizon-world/tests/block_collision_scan.rs @@ -112,6 +112,8 @@ fn invalid_or_excessive_domains_fail_before_world_queries() { /// and has an empty shape. struct Recording { cells: Vec, + /// Sections (block y >> 4) whose blocks never reach out of their cell. + contained: Vec, } impl BlockCollisionWorld for Recording { type Error = io::Error; @@ -139,6 +141,40 @@ impl BlockCollisionWorld for Recording { ) -> io::Result { unreachable!() } + fn may_reach_out(&mut self, _: (), section_y: i32) -> io::Result { + Ok(!self.contained.contains(§ion_y)) + } +} + +/// The cells the scan reads for `bounds` (Cursor3D order: x fastest, then +/// y, then z, without the 8 corners), less the outer cells of `contained`. +fn expected_cells(bounds: Aabb, contained: &[i32]) -> Vec { + let (min, max) = (bounds.min_position(), bounds.max_position()); + let start = BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7)) + .offset(-1, -1, -1); + let end = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7)) + .offset(1, 1, 1); + let sizes = [ + end.x - start.x + 1, + end.y - start.y + 1, + end.z - start.z + 1, + ]; + let mut expected = Vec::new(); + for i in 0..sizes[0] * sizes[1] * sizes[2] { + let r = [ + i % sizes[0], + i / sizes[0] % sizes[1], + i / sizes[0] / sizes[1], + ]; + let edges = (0..3) + .filter(|&a| r[a] == 0 || r[a] == sizes[a] - 1) + .count(); + let p = start.offset(r[0], r[1], r[2]); + if edges < 3 && (edges == 0 || !contained.contains(&(p.y >> 4))) { + expected.push(p); + } + } + expected } #[test] @@ -150,7 +186,10 @@ fn cells_follow_the_cursor_order_without_corners() { Aabb::new(Vec3::new(-0.3, 0.0, -0.3), Vec3::new(0.3, 0.9, 0.3)), ]; for bounds in boxes { - let mut world = Recording { cells: Vec::new() }; + let mut world = Recording { + cells: Vec::new(), + contained: Vec::new(), + }; let hits = BlockCollisionScan::new( &mut world, CollisionContext::empty(false), @@ -161,31 +200,45 @@ fn cells_follow_the_cursor_order_without_corners() { .unwrap() .count(); assert_eq!(hits, 0); - // Cursor3D: x fastest, then y, then z, skipping the 8 corners. - let (min, max) = (bounds.min_position(), bounds.max_position()); - let start = BlockPos::containing(Vec3::new(min.x - 1.0e-7, min.y - 1.0e-7, min.z - 1.0e-7)) - .offset(-1, -1, -1); - let end = BlockPos::containing(Vec3::new(max.x + 1.0e-7, max.y + 1.0e-7, max.z + 1.0e-7)) - .offset(1, 1, 1); - let sizes = [ - end.x - start.x + 1, - end.y - start.y + 1, - end.z - start.z + 1, - ]; - let mut expected = Vec::new(); - for i in 0..sizes[0] * sizes[1] * sizes[2] { - let r = [ - i % sizes[0], - i / sizes[0] % sizes[1], - i / sizes[0] / sizes[1], - ]; - let edges = (0..3) - .filter(|&a| r[a] == 0 || r[a] == sizes[a] - 1) - .count(); - if edges < 3 { - expected.push(start.offset(r[0], r[1], r[2])); - } - } - assert_eq!(world.cells, expected); + assert_eq!(world.cells, expected_cells(bounds, &[])); + } +} + +/// Outer cells are read only in sections that may hold a block reaching +/// out of its cell; the box's own cells always are. +#[test] +fn outer_cells_of_contained_sections_are_not_read() { + // A mob crossing from section 3 into section 4, and an item in section 0. + let boxes = [ + ( + Aabb::new(Vec3::new(0.2, 63.5, 0.3), Vec3::new(0.8, 65.3, 0.9)), + vec![3], + ), + ( + Aabb::new(Vec3::new(0.2, 63.5, 0.3), Vec3::new(0.8, 65.3, 0.9)), + vec![3, 4], + ), + ( + Aabb::new(Vec3::new(4.4, 2.0, -7.6), Vec3::new(4.65, 2.25, -7.35)), + vec![0], + ), + ]; + for (bounds, contained) in boxes { + let mut world = Recording { + cells: Vec::new(), + contained: contained.clone(), + }; + let hits = BlockCollisionScan::new( + &mut world, + CollisionContext::empty(false), + bounds, + false, + 1_000_000, + ) + .unwrap() + .count(); + assert_eq!(hits, 0); + assert_eq!(world.cells, expected_cells(bounds, &contained)); + assert!(world.cells.len() < expected_cells(bounds, &[]).len()); } } From a013e35a61f3d888f141364ab5e04f13fbb1a842 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:17:59 +0100 Subject: [PATCH 2/2] Collision scans: pass the rest of an outer row in a contained section at once On the rows along a box's bottom, top and sides every cell is outer, so in a section no block reaches out of, the rest of the row up to the chunk's edge is passed in one step instead of cell by cell. The candidate budget still ends the scan where it did. Co-Authored-By: Claude Opus 5.5 --- .../horizon-world/src/block_collision_scan.rs | 35 ++++++++++++++++++- .../tests/block_collision_scan.rs | 31 ++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/crates/horizon-world/src/block_collision_scan.rs b/crates/horizon-world/src/block_collision_scan.rs index 391c3e96e..172e6a79c 100644 --- a/crates/horizon-world/src/block_collision_scan.rs +++ b/crates/horizon-world/src/block_collision_scan.rs @@ -151,12 +151,31 @@ impl<'w, 'c, W: BlockCollisionWorld> BlockCollisionScan<'w, 'c, W> { }) } + /// Move the cursor `n` cells further along its row; `n` must not pass + /// the row's end. + fn skip(&mut self, n: usize) { + if n == 0 { + return; + } + let [width, height, _] = self.sizes; + self.index += n; + self.cursor[0] += n; + if self.cursor[0] == width { + self.cursor[0] = 0; + self.cursor[1] += 1; + if self.cursor[1] == height { + self.cursor[1] = 0; + self.cursor[2] += 1; + } + } + } + fn find_next(&mut self) -> Result, CollisionScanError> { while self.index < self.count { if self.index >= self.budget { return Err(CollisionScanError::VisitBudget); } - let [width, height, _] = self.sizes; + let [width, height, depth] = self.sizes; let relative = self.cursor; self.index += 1; self.cursor[0] += 1; @@ -212,6 +231,20 @@ impl<'w, 'c, W: BlockCollisionWorld> BlockCollisionScan<'w, 'c, W> { } }; if !reaches { + // On a row along the box's bottom, top or sides every + // cell is outer: the rest of it in this chunk is passed + // at once (never beyond the budget, which still ends the + // scan where it would have). + if relative[1] == 0 + || relative[1] == height - 1 + || relative[2] == 0 + || relative[2] == depth - 1 + { + let rest = (width - 1 - relative[0]) + .min(15 - (p.x & 15) as usize) + .min(self.budget.saturating_sub(self.index)); + self.skip(rest); + } continue; } } diff --git a/crates/horizon-world/tests/block_collision_scan.rs b/crates/horizon-world/tests/block_collision_scan.rs index adf530069..e3f61e1ae 100644 --- a/crates/horizon-world/tests/block_collision_scan.rs +++ b/crates/horizon-world/tests/block_collision_scan.rs @@ -222,6 +222,11 @@ fn outer_cells_of_contained_sections_are_not_read() { Aabb::new(Vec3::new(4.4, 2.0, -7.6), Vec3::new(4.65, 2.25, -7.35)), vec![0], ), + // Across a chunk's edge: the rest of a row is passed only up to it. + ( + Aabb::new(Vec3::new(14.2, 70.5, -0.4), Vec3::new(17.8, 71.5, 0.4)), + vec![4], + ), ]; for (bounds, contained) in boxes { let mut world = Recording { @@ -242,3 +247,29 @@ fn outer_cells_of_contained_sections_are_not_read() { assert!(world.cells.len() < expected_cells(bounds, &[]).len()); } } + +/// Passing outer cells at once still stops at the candidate budget, as +/// visiting them one by one does. +#[test] +fn passed_cells_count_toward_the_budget() { + let bounds = Aabb::new(Vec3::new(0.2, 63.5, 0.3), Vec3::new(0.8, 65.3, 0.9)); + for budget in 0..80 { + let scan = |contained: Vec| { + let mut world = Recording { + cells: Vec::new(), + contained, + }; + BlockCollisionScan::new( + &mut world, + CollisionContext::empty(false), + bounds, + false, + budget, + ) + .unwrap() + .map(|hit| hit.map(|_| ()).map_err(|e| e.to_string())) + .collect::>() + }; + assert_eq!(scan(vec![3, 4]), scan(Vec::new()), "budget {budget}"); + } +}