Optimize async entity occlusion hot paths

Co-Authored-By: OpenAI Codex <[email protected]>
This commit is contained in:
ElyTheCatandOpenAI Codex committed 2026-11-02 22:34:15 +03:30
1 parent 4e069b1e21
commit 37380065b7
7 files changed
+314 -13

No files matched your search

+1 -1
View File
@@ -2845,7 +2845,7 @@ struct Game<'a> {
/// This frame's view frustum, for culling entities and block entities.
view_frustum: Option<horizon_client::gfx::Frustum>,
sync_occlusion_culler: Option<horizon_client::entity_culling::Culler>,
async_occlusion_culler: Option<occlusion_culling::AsyncOcclusionCuller>,
async_occlusion_culler: Option<Box<occlusion_culling::AsyncOcclusionCuller>>,
async_occlusion_culling_failed: bool,
/// `Renderer::pack_shadow_casters` for this frame's entities: (keep what the culler hides,
/// build the player's model in first person); (false, false) without a shader pack.
+48 -2
View File
@@ -3,8 +3,8 @@
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
use super::{
Dim, Game, Hand, InteractTarget, dispenser, native_mob_draw::NativePlayerRender,
native_projectiles::Impact, view_vector,
Dim, Game, Hand, InteractTarget, dispenser, entity_culling::EntityCullDecision,
native_mob_draw::NativePlayerRender, native_projectiles::Impact, view_vector,
};
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
@@ -12,6 +12,7 @@ use horizon_client::{
arrows::EntityHit,
blocks::Fluid,
collisions::SceneCollisions,
graphics::EntityCullingMode,
health::{HurtSource, Hurting, Knock, knockback_canceled},
items::ItemStack,
mesher::Vertex,
@@ -159,6 +160,7 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
/// `ProjectileUtil.getEntityHitResult` can strike it (`ThrownEnderpearl.onHit`).
/// `WIND_CHARGE_IDS + j` is `projectiles[j]` during `tick_projectiles`, below
/// the dragon's reserved ids.
const ASYNC_MOB_AABB_PRECHECK_MIN: usize = 512;
const WIND_CHARGE_IDS: u32 = super::dragon::SPECIAL - 0x10000;
/// The index of the wind charge a `wind_charge_hitboxes` id stands for.
@@ -6367,6 +6369,26 @@ impl Game<'_> {
}
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
// The coarse mob bounds pay off only in dense async scenes; otherwise,
// keep the existing exact mesh-culling path.
if self.settings.horizon.entity_culling == EntityCullingMode::Asynchronous
&& self.async_occlusion_culler.is_some()
&& self.mobs.len() >= ASYNC_MOB_AABB_PRECHECK_MIN
&& !self.pack_shadow_casters.0
&& horizon_client::mod_api::mods().is_empty()
{
self.mob_geometry_inner::<true>(camera, alpha, out);
} else {
self.mob_geometry_inner::<false>(camera, alpha, out);
}
}
fn mob_geometry_inner<const PRECHECK: bool>(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
@@ -6437,6 +6459,29 @@ impl Game<'_> {
continue;
}
}
let culling = if PRECHECK
&& outline.is_none()
&& !is_player
&& mob.kind.id.starts_with("minecraft:")
&& [
&mob.extra.head,
&mob.extra.chest,
&mob.extra.legs,
&mob.extra.feet,
&mob.extra.main_hand,
&mob.extra.off_hand,
]
.iter()
.all(|item| item.is_none())
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
{
self.entity_cull_decision(mob, alpha)
} else {
EntityCullDecision::CheckVertices
};
if culling.hides_entity() {
continue;
}
self.mark_entity(entity_start, &mob.kind.id);
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
let light = self.world.light(
@@ -6621,6 +6666,7 @@ impl Game<'_> {
// Glowing outlines and a guardian's beam remain visible independently
// of whether the body is hidden behind terrain.
if outline.is_none()
&& culling.checks_vertices()
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
{
// What the GPU draws of the mob counts by its bounds' corners.
+23
View File
@@ -245,6 +245,29 @@ pub(super) trait Entity: EntityRules {
pub(super) trait RenderEntityBounds {
fn render_id(&self) -> u32;
fn render_bounds(&self, alpha: f64) -> Aabb;
fn render_glowing(&self, game: &Game<'_>) -> bool {
game.entity_glowing(self.render_id())
}
}
impl RenderEntityBounds for Mob {
fn render_id(&self) -> u32 {
self.id
}
fn render_bounds(&self, alpha: f64) -> Aabb {
// A loose rotation-independent envelope for vanilla model animations.
// Standing dimensions keep sleeping poses from shrinking it; equipment
// and custom rendering bypass this pre-check at the call site.
let (width, height, _) = self.awake_dims();
let radius = width.max(height) * 2.5 + 3.0;
let at = self.previous.lerp(self.position, f64::from(alpha as f32));
Aabb::of_size(at, Vec3::new(radius * 2.0, radius * 2.0, radius * 2.0))
}
fn render_glowing(&self, _game: &Game<'_>) -> bool {
self.is_currently_glowing()
}
}
macro_rules! moving_render_entity_bounds {
@@ -58,7 +58,7 @@ impl Game<'_> {
) -> EntityCullDecision {
if self.settings.horizon.entity_culling == EntityCullingMode::Off
|| self.pack_shadow_casters.0
|| self.entity_glowing(entity.render_id())
|| entity.render_glowing(self)
{
return EntityCullDecision::Bypass;
}
@@ -116,9 +116,7 @@ impl Game<'_> {
vertices: &mut Vec<Vertex>,
start: usize,
) {
if decision.checks_vertices()
&& !self.pack_shadow_casters.0
&& !self.entity_glowing(entity.render_id())
if decision.checks_vertices() && !self.pack_shadow_casters.0 && !entity.render_glowing(self)
{
self.cull_entity_vertices(vertices, start);
}
@@ -27,6 +27,7 @@ type ChunkKey = (i32, i32);
type SectionKey = (i32, i32, i32);
const MAX_SNAPSHOT_CHUNKS: i64 = 4_096;
const MAX_OCCLUSION_RESULT_CACHE_ENTRIES: usize = 16_384;
const RESULT_CACHE_IDLE_FRAMES: u64 = 2;
const OCCLUSION_LOOKUP_BUDGET: usize = 8_192;
const OPACITY_GRID_SIZE: usize = 1_024;
const MAX_OCCLUSION_RAY_STEPS: usize = 512;
@@ -475,6 +476,7 @@ struct CachedQueryResult {
stamp: SnapshotKey,
camera_cell: Option<[i32; 3]>,
proof: Option<OcclusionProof>,
last_used_frame: Cell<u64>,
}
#[derive(Default)]
@@ -489,6 +491,7 @@ struct AsyncCullingStats {
entity_mesh_cache_misses: u64,
result_cache_evictions: u64,
result_cache_capacity_evictions: u64,
result_cache_idle_evictions: u64,
result_cache_invalidation_replacements: u64,
cache_miss_empty_set: u64,
cache_miss_key_mismatch: u64,
@@ -563,6 +566,7 @@ pub(super) struct AsyncOcclusionCuller {
camera: [f64; 3],
stamp: SnapshotKey,
pending: bool,
frame: u64,
proof_validation_sample_counter: Cell<u64>,
}
@@ -640,7 +644,9 @@ impl AsyncOcclusionCuller {
cache_merge: Vec::new(),
stats: RefCell::new(AsyncCullingStats::default()),
proof_validation_cache: RefCell::new(FrameProofValidationCache::default()),
measure_bounds_timing: Cell::new(false),
measure_bounds_timing: Cell::new(
std::env::var("HORIZON_OCCLUSION_PROFILE").is_ok_and(|v| v == "1"),
),
query_cache_stamp: None,
query_cache_camera_cell: None,
camera: [0.0; 3],
@@ -650,6 +656,7 @@ impl AsyncOcclusionCuller {
bottom_y: 0,
},
pending: false,
frame: 0,
proof_validation_sample_counter: Cell::new(0),
})
}
@@ -661,6 +668,7 @@ impl AsyncOcclusionCuller {
}
fn begin_frame_inner(&mut self, camera: glam::DVec3, world: &World) {
self.frame = self.frame.saturating_add(1);
self.proof_validation_cache.get_mut().clear();
self.camera = camera.to_array();
self.stamp = SnapshotKey {
@@ -826,7 +834,10 @@ impl AsyncOcclusionCuller {
&& self
.query_cache_camera_cell
.is_some_and(|cell| entry.camera_cell == Some(cell));
(exact || reusable_visible).then(|| (entry.proof.is_some(), entry.proof.as_ref()))
(exact || reusable_visible).then(|| {
entry.last_used_frame.set(self.frame);
(entry.proof.is_some(), entry.proof.as_ref())
})
});
let miss_reason = result.is_none().then(|| {
cache_miss_reason(
@@ -987,6 +998,7 @@ impl AsyncOcclusionCuller {
stamp: results.stamp,
camera_cell: result_camera_cell,
proof: None,
last_used_frame: Cell::new(self.frame),
}));
} else {
results.visible.clear();
@@ -1017,6 +1029,7 @@ impl AsyncOcclusionCuller {
stamp: results.stamp,
camera_cell: result_camera_cell,
proof: Some(proof),
last_used_frame: Cell::new(self.frame),
});
}
} else {
@@ -1045,6 +1058,7 @@ impl AsyncOcclusionCuller {
let mut updates_iter = updates.drain(..).peekable();
let mut invalidation_replacements = 0_u64;
let mut capacity_evictions = 0_u64;
let mut idle_evictions = 0_u64;
while existing_iter.peek().is_some() || updates_iter.peek().is_some() {
let entry = match (existing_iter.peek(), updates_iter.peek()) {
(Some(old), Some(new)) => {
@@ -1068,7 +1082,12 @@ impl AsyncOcclusionCuller {
(None, Some(_)) => updates_iter.next().unwrap(),
(None, None) => unreachable!(),
};
if merged.len() < MAX_OCCLUSION_RESULT_CACHE_ENTRIES {
// Animated mesh extrema often never repeat. Drop their unused
// answers during this existing merge, while frequently queried
// AABBs and mesh bounds retain their entries without extra scans.
if self.frame.saturating_sub(entry.last_used_frame.get()) > RESULT_CACHE_IDLE_FRAMES {
idle_evictions = idle_evictions.saturating_add(1);
} else if merged.len() < MAX_OCCLUSION_RESULT_CACHE_ENTRIES {
merged.push(entry);
} else {
capacity_evictions = capacity_evictions.saturating_add(1);
@@ -1080,7 +1099,11 @@ impl AsyncOcclusionCuller {
let mut stats = self.stats.borrow_mut();
stats.result_cache_evictions = stats
.result_cache_evictions
.saturating_add(capacity_evictions);
.saturating_add(capacity_evictions)
.saturating_add(idle_evictions);
stats.result_cache_idle_evictions = stats
.result_cache_idle_evictions
.saturating_add(idle_evictions);
stats.result_cache_capacity_evictions = stats
.result_cache_capacity_evictions
.saturating_add(capacity_evictions);
@@ -1279,6 +1302,8 @@ impl AsyncOcclusionCuller {
"entity_budget_exhausted_jobs": stats.entity_budget_exhausted_jobs,
"result_cache_slots": MAX_OCCLUSION_RESULT_CACHE_ENTRIES,
"result_cache_entries": self.query_results.len(),
"profiling_enabled": self.measure_bounds_timing.get(),
"state_bytes": std::mem::size_of::<Self>(),
});
value["entity_aabb_cache_hits"] = serde_json::json!(stats.entity_aabb_cache_hits);
value["entity_aabb_cache_misses"] = serde_json::json!(stats.entity_aabb_cache_misses);
@@ -1286,6 +1311,7 @@ impl AsyncOcclusionCuller {
value["entity_mesh_cache_misses"] = serde_json::json!(stats.entity_mesh_cache_misses);
value["result_cache_capacity_evictions"] =
serde_json::json!(stats.result_cache_capacity_evictions);
value["result_cache_idle_evictions"] = serde_json::json!(stats.result_cache_idle_evictions);
value["result_cache_invalidation_replacements"] =
serde_json::json!(stats.result_cache_invalidation_replacements);
value["cache_miss_empty_set"] = serde_json::json!(stats.cache_miss_empty_set);
@@ -1322,7 +1348,6 @@ impl AsyncOcclusionCuller {
pub(super) fn reset_stats(&self) {
*self.stats.borrow_mut() = AsyncCullingStats::default();
self.measure_bounds_timing.set(true);
self.proof_validation_sample_counter.set(0);
}
}
@@ -1606,7 +1631,7 @@ impl Game<'_> {
if wants_async {
if self.async_occlusion_culler.is_none() && !self.async_occlusion_culling_failed {
match AsyncOcclusionCuller::new() {
Ok(culler) => self.async_occlusion_culler = Some(culler),
Ok(culler) => self.async_occlusion_culler = Some(Box::new(culler)),
Err(error) => {
log::warn!("Horizon: cannot start world-occlusion-culling worker: {error}");
self.async_occlusion_culling_failed = true;
@@ -1771,3 +1796,7 @@ mod tests {
drop(live);
}
}
#[cfg(test)]
#[path = "occlusion_culling_precheck_tests.rs"]
mod precheck_tests;
@@ -0,0 +1,201 @@
use super::*;
use crate::game::entity::RenderEntityBounds;
use horizon_client::mobs::{Mob, MobData, MobTypes, Species, render};
use horizon_core::geometry::Vec3;
use std::path::Path;
#[test]
fn vanilla_models_and_animations_stay_inside_the_precheck_envelope() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
eprintln!("no prepared resources: skipped model coverage");
return;
}
let types = MobTypes::load(&root).unwrap();
let data = MobData {
states: Vec::new(),
placements: Default::default(),
heightmaps: Default::default(),
animations: MobData::load_animations(&root),
villager_hats: Default::default(),
cem: Default::default(),
random_textures: Default::default(),
emissive: Default::default(),
blinks: Default::default(),
biome_tags: Default::default(),
entity_tags: Default::default(),
pois: Default::default(),
species_placements: Default::default(),
};
let camera = glam::DVec3::new(3.0, 62.0, -4.0);
let mut checked = 0;
for kind in types.types.values() {
for sample in 0..12 {
let mut mob = Mob::new(1, Arc::new(kind.clone()), Vec3::new(1.5, 64.0, 3.25), 37.0);
mob.previous = Vec3::new(1.25, 64.25, 3.0);
mob.age = sample * 37;
mob.limb_speed = 0.8;
mob.limb_position = sample as f32 * 1.7;
mob.head_pitch = if sample % 2 == 0 { 85.0 } else { -85.0 };
mob.extra.age = if sample >= 6 { -100 } else { 0 };
mob.death_time = if sample % 3 == 0 { 10 } else { 0 };
mob.extra.flag = sample % 2 == 0;
mob.extra.size = match mob.species() {
Species::Slime | Species::MagmaCube | Species::SulfurCube | Species::Phantom => {
if sample % 2 == 0 {
1
} else {
4
}
}
_ => mob.extra.size,
};
if sample % 3 == 1 {
mob.custom_name = Some("Dinnerbone".into());
}
for partial in [0.0, 0.4, 1.0] {
let bounds = mob.render_bounds(f64::from(partial));
let lo = bounds.min_position();
let hi = bounds.max_position();
let lo = [lo.x, lo.y, lo.z];
let hi = [hi.x, hi.y, hi.z];
let mut vertices = Vec::new();
render::render_with(
&mob,
partial,
camera,
&|_| Some([0.0, 0.0, 1.0, 1.0]),
0xF0F0,
&data,
&[],
&mut vertices,
);
for vertex in &vertices {
for axis in 0..3 {
let world = f64::from(vertex.pos[axis]) + camera[axis];
assert!(
world >= lo[axis] - 1.0e-4 && world <= hi[axis] + 1.0e-4,
"{} sample {sample} partial {partial}: {world} outside {lo:?}..{hi:?}",
kind.id,
);
}
}
checked += usize::from(!vertices.is_empty());
}
}
}
assert!(
checked > 1_000,
"insufficient real model coverage: {checked}"
);
}
#[test]
fn a_hole_inside_a_coarse_envelope_remains_visible() {
let camera = [0.0; 3];
let min = [7.95, -3.0, -3.0];
let max = [10.05, 3.0, 3.0];
assert!(OcclusionCuller::new(camera, 8192).occluded(min, max, |p| p[0] == 4));
assert!(
!OcclusionCuller::new(camera, 8192).occluded(min, max, |p| p[0] == 4 && p != [4, 1, 0])
);
assert!(!OcclusionCuller::new(camera, 0).occluded(min, max, |p| p[0] == 4));
}
#[test]
fn hidden_cache_entries_require_the_same_camera_and_bounds() {
let mut culler = AsyncOcclusionCuller::new().unwrap();
let bounds = Aabb::new(Vec3::new(8.0, -1.0, -1.0), Vec3::new(10.0, 1.0, 1.0));
let original = culler.query_entity_bounds(bounds).unwrap();
culler.query_results.push(CachedQueryResult {
key: original,
stamp: culler.stamp,
camera_cell: Some([0; 3]),
proof: Some(OcclusionProof {
sections: Vec::new(),
chunks: Vec::new(),
}),
last_used_frame: Cell::new(0),
});
assert!(matches!(
culler.query_result(&original, QuerySource::EntityAabb),
Some((true, _))
));
let moved = culler
.query_entity_bounds(bounds.move_by(Vec3::new(0.01, 0.0, 0.0)))
.unwrap();
assert!(
culler
.query_result(&moved, QuerySource::EntityAabb)
.is_none()
);
culler.camera[0] = 0.01;
let moved_camera = culler.query_entity_bounds(bounds).unwrap();
assert!(
culler
.query_result(&moved_camera, QuerySource::EntityAabb)
.is_none()
);
}
#[test]
fn resetting_benchmark_counters_preserves_the_profiling_choice() {
let culler = AsyncOcclusionCuller::new().unwrap();
for enabled in [false, true] {
culler.measure_bounds_timing.set(enabled);
culler.reset_stats();
assert_eq!(culler.stats_value()["profiling_enabled"], enabled);
}
}
#[test]
fn inactive_answers_expire_while_frequently_used_proofs_stay_cached() {
let mut culler = AsyncOcclusionCuller::new().unwrap();
let bounds = |x| Aabb::new(Vec3::new(x, -1.0, -1.0), Vec3::new(x + 1.0, 1.0, 1.0));
let hot = culler.query_entity_bounds(bounds(8.0)).unwrap();
let cold = culler.query_entity_bounds(bounds(12.0)).unwrap();
let fresh = culler.query_entity_bounds(bounds(16.0)).unwrap();
for key in [hot, cold] {
culler.query_results.push(CachedQueryResult {
key,
stamp: culler.stamp,
camera_cell: Some([0; 3]),
proof: Some(OcclusionProof {
sections: Vec::new(),
chunks: Vec::new(),
}),
last_used_frame: Cell::new(0),
});
}
culler.frame = 10;
assert!(matches!(
culler.query_result(&hot, QuerySource::EntityAabb),
Some((true, _))
));
culler.cache_query_results(&mut CullResults {
stamp: culler.stamp,
camera: culler.camera.map(f64::to_bits),
generation: 0,
cancelled: false,
hidden: Vec::new(),
visible: vec![fresh],
scratch_queries: Vec::new(),
worker_ns: 0,
hidden_entities: 0,
visible_entities: 1,
deferred_entities: 0,
});
assert!(matches!(
culler.query_result(&hot, QuerySource::EntityAabb),
Some((true, _))
));
assert!(
culler
.query_result(&cold, QuerySource::EntityAabb)
.is_none()
);
assert!(matches!(
culler.query_result(&fresh, QuerySource::EntityAabb),
Some((false, _))
));
}
+4
View File
@@ -74,6 +74,10 @@ cargo run --release -p horizon-client -- --render-distance 12
Development builds that link quickly: `cargo build --profile fast -p horizon-client`.
`HORIZON_OCCLUSION_PROFILE=1` enables detailed entity-occlusion phase and bounds-scan timings
in `horizon-ctl stats` and `bench`. The normal benchmark keeps these CPU-clock reads disabled;
query, cache-hit and cull counters remain available without profiling.
Optional Windows allocator build features are documented in
[Windows allocator experiments](windows-allocators.md).