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..172e6a79c 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,16 +146,36 @@ impl<'w, 'c, W: BlockCollisionWorld> BlockCollisionScan<'w, 'c, W> { count, budget: candidate_budget, chunk: None, + reach: None, failed: false, }) } + /// 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; @@ -184,6 +213,41 @@ 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 { + // 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; + } + } 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..e3f61e1ae 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,76 @@ 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], + ), + // 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 { + 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()); + } +} + +/// 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}"); } }