diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 027c631ef6..469ff79d7e 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -2845,7 +2845,7 @@ struct Game<'a> { /// This frame's view frustum, for culling entities and block entities. view_frustum: Option, sync_occlusion_culler: Option, - async_occlusion_culler: Option, + async_occlusion_culler: Option>, 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. diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index f714497524..5d68114d54 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -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) { + // 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::(camera, alpha, out); + } else { + self.mob_geometry_inner::(camera, alpha, out); + } + } + + fn mob_geometry_inner( + &self, + camera: glam::DVec3, + alpha: f64, + out: &mut Vec, + ) { 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. diff --git a/crates/horizon-client/src/game/entity.rs b/crates/horizon-client/src/game/entity.rs index 90bcc9cd72..8e38db0dbe 100644 --- a/crates/horizon-client/src/game/entity.rs +++ b/crates/horizon-client/src/game/entity.rs @@ -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 { diff --git a/crates/horizon-client/src/game/entity_culling.rs b/crates/horizon-client/src/game/entity_culling.rs index a3853c8372..3ecbb2707a 100644 --- a/crates/horizon-client/src/game/entity_culling.rs +++ b/crates/horizon-client/src/game/entity_culling.rs @@ -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, 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); } diff --git a/crates/horizon-client/src/game/occlusion_culling.rs b/crates/horizon-client/src/game/occlusion_culling.rs index d8283b9115..484f1b406e 100644 --- a/crates/horizon-client/src/game/occlusion_culling.rs +++ b/crates/horizon-client/src/game/occlusion_culling.rs @@ -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, + last_used_frame: Cell, } #[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, } @@ -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::(), }); 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; diff --git a/crates/horizon-client/src/game/occlusion_culling_precheck_tests.rs b/crates/horizon-client/src/game/occlusion_culling_precheck_tests.rs new file mode 100644 index 0000000000..fd754abbe0 --- /dev/null +++ b/crates/horizon-client/src/game/occlusion_culling_precheck_tests.rs @@ -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, _)) + )); +} diff --git a/docs/native-client.md b/docs/native-client.md index 4dcdc86884..e6d6dd81cd 100644 --- a/docs/native-client.md +++ b/docs/native-client.md @@ -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).