perf(entity-culling): reduce occlusion query overhead

Skip doomed async work while the camera moves, reduce synchronous voxel-plane lookups, and cull dynamic block entities before generating their geometry. Reuse chest state and remove dye-prefix sorting from the block-entity hot path.

Co-Authored-By: OpenAI Codex <[email protected]>
This commit is contained in:
ElyTheCatandOpenAI Codex committed 2026-11-05 03:23:26 +03:30
1 parent 3ec390a6b8
commit d87992bdf3
10 files changed
+483 -110

No files matched your search

@@ -271,10 +271,10 @@ pub const DYES: [(&str, u32); 16] = [
("black", 0x1D1D21),
];
fn dye_prefix(name: &str) -> Option<(usize, &str)> {
// Longest names first so "light_gray" wins over "gray".
let mut order: Vec<usize> = (0..16).collect();
order.sort_by_key(|&i| std::cmp::Reverse(DYES[i].0.len()));
order.into_iter().find_map(|i| {
// Longest names first; this is the same stable order as sorting DYES by
// descending prefix length, without a Vec allocation or comparison sort.
const ORDER: [usize; 16] = [3, 8, 2, 1, 4, 10, 0, 12, 13, 15, 5, 6, 7, 9, 11, 14];
ORDER.into_iter().find_map(|i| {
name.strip_prefix(DYES[i].0)
.and_then(|rest| rest.strip_prefix('_'))
.map(|rest| (i, rest))
@@ -833,6 +833,9 @@ pub struct Scene<'a> {
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
/// text laid out under another value is laid out again.
pub glyphs: u64,
/// Per-block visibility for dynamic, animated, or uncached models. Bounds
/// are world-space and conservatively include model animation extents.
pub dynamic_visible: Option<&'a dyn Fn([i32; 3], u32, DVec3, DVec3) -> bool>,
}
/// Empty by default (a dedicated server): no state has a block entity model.
@@ -1402,6 +1405,167 @@ impl BlockEntityRenderer {
fn kind(&self, state: u32) -> &Kind {
self.kinds.get(state as usize).unwrap_or(&Kind::None)
}
fn dynamic_bounds(
&self,
scene: &Scene,
pos: [i32; 3],
kind: &Kind,
chest_openness: Option<(f32, Option<[i32; 3]>)>,
) -> (DVec3, DVec3) {
let [x, y, z] = pos.map(f64::from);
let mut lo = DVec3::new(x, y, z);
let mut hi = DVec3::new(x + 1.0, y + 1.0, z + 1.0);
match kind {
Kind::Chest { double_with, .. } => {
if let Some(offset) = double_with {
lo.x += f64::from(offset[0].min(0));
lo.z += f64::from(offset[2].min(0));
hi.x += f64::from(offset[0].max(0));
hi.z += f64::from(offset[2].max(0));
}
let is_open = chest_openness.map_or_else(
|| {
(scene.lid)(pos) != 0.0
|| double_with.is_some_and(|offset| {
let other = [
pos[0].saturating_add(offset[0]),
pos[1].saturating_add(offset[1]),
pos[2].saturating_add(offset[2]),
];
(scene.lid)(other) != 0.0
})
},
|(open, _)| open != 0.0,
);
if is_open {
// The lid pivots up from the rear edge. Its longest corner
// rises by less than one block in the fully open pose.
hi.y += 0.8;
}
}
Kind::Shulker { facing, .. } => {
let offset = step(facing);
lo.x += f64::from(offset[0].min(0)) * 0.52;
lo.y += f64::from(offset[1].min(0)) * 0.52;
lo.z += f64::from(offset[2].min(0)) * 0.52;
hi.x += f64::from(offset[0].max(0)) * 0.52;
hi.y += f64::from(offset[1].max(0)) * 0.52;
hi.z += f64::from(offset[2].max(0)) * 0.52;
}
Kind::Banner { wall: false, .. } => {
hi.y += 1.0;
lo.x -= 0.05;
lo.z -= 0.05;
hi.x += 0.05;
hi.z += 0.05;
}
Kind::Banner { wall: true, .. } => {
lo.y -= 1.0;
lo.x -= 0.05;
lo.z -= 0.05;
hi.x += 0.05;
hi.z += 0.05;
}
Kind::Sign { hanging: true, .. } => {
lo.y -= 1.0;
lo.x -= 0.15;
lo.z -= 0.15;
hi.x += 0.15;
hi.z += 0.15;
}
Kind::Sign { hanging: false, .. } => {
hi.y += 0.3;
lo.x -= 0.02;
lo.z -= 0.02;
hi.x += 0.02;
hi.z += 0.02;
}
Kind::EnchantingTable | Kind::Lectern { .. } => {
// The book and statue models rise above the block's collision
// cube; include their full rendered height and rotated corners.
hi.y += 1.0;
lo.x -= 0.25;
lo.z -= 0.25;
hi.x += 0.25;
hi.z += 0.25;
}
Kind::CopperGolem { .. } => {
// The statue pose rotates around the root, so some variants
// extend below the block as well as above it.
lo.y -= 1.0;
hi.y += 1.0;
lo.x -= 0.25;
lo.z -= 0.25;
hi.x += 0.25;
hi.z += 0.25;
}
Kind::Conduit => {
// Its animated cage is centered above the conduit block and
// the wind model spans multiple cells. The caller bypasses
// voxel occlusion for envelopes this tall, but retains frustum
// culling with this conservative volume.
lo.x -= 1.0;
lo.y -= 1.0;
lo.z -= 1.0;
hi.x += 1.0;
hi.y += 1.0;
hi.z += 1.0;
}
Kind::Bell => {
// The bell's pivoted swing moves its corners slightly outside
// the block even though the model starts centered in it.
lo.x -= 0.1;
lo.y -= 0.1;
lo.z -= 0.1;
hi.x += 0.1;
hi.y += 0.1;
hi.z += 0.1;
}
Kind::Skull { .. } => {
// Wall-mounted and dragon heads can protrude from their block.
lo.x -= 0.25;
lo.y -= 0.25;
lo.z -= 0.25;
hi.x += 0.25;
hi.y += 0.25;
hi.z += 0.25;
}
Kind::EndGateway { .. } => {
let anim = (scene.anim)(pos);
let beam = gateway_beam(anim.gateway, scene.partial, scene.max_y, scene.game_time);
let height = f64::from(beam.height.max(0));
lo.y -= height;
hi.y += height;
}
_ => {}
}
(lo, hi)
}
fn is_dynamic_visible(
&self,
scene: &Scene,
shapes: &Arc<BlockEntityShapes>,
pos: [i32; 3],
state: u32,
kind: &Kind,
chest_openness: Option<(f32, Option<[i32; 3]>)>,
) -> bool {
let Some(visible) = scene.dynamic_visible else {
return true;
};
if matches!(kind, Kind::EndGateway { .. }) {
return true;
}
// A pack model may extend outside the vanilla envelope. Keep that
// geometry on the uncullable path unless its model bounds are known.
if !Arc::ptr_eq(shapes, &self.vanilla) {
return true;
}
let (lo, hi) = self.dynamic_bounds(scene, pos, kind, chest_openness);
visible(pos, state, lo, hi)
}
fn cached_entry_is_current(
&self,
scene: &Scene,
@@ -1411,6 +1575,7 @@ impl BlockEntityRenderer {
options: CacheOptions,
entry: &StaticEntry,
section_revision: StaticSectionRevision,
chest_openness: Option<(f32, Option<[i32; 3]>)>,
) -> bool {
if entry.key.state != state
|| entry.key.nether != scene.nether
@@ -1422,15 +1587,24 @@ impl BlockEntityRenderer {
}
match kind {
Kind::Chest { .. } => {
let (open, other) = self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
);
// Double chests depend on the neighboring half's light too;
// let those uncommon models take the full key path.
open == 0.0 && other.is_none()
let (open, other) = chest_openness.unwrap_or_else(|| {
self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
)
});
if open != 0.0 {
return false;
}
let mut light = scene.world.light(pos[0], pos[1], pos[2]);
if let Some(other) = other {
let other_light = scene.world.light(other[0], other[1], other[2]);
light = (light >> 4).max(other_light >> 4) << 4
| (light & 15).max(other_light & 15);
}
entry.key.light == light
}
Kind::Shulker { .. } => {
let (progress, old) = (scene.anim)(pos).shulker;
@@ -1693,6 +1867,17 @@ impl BlockEntityRenderer {
}
let local = static_local_index(pos, section_min_y);
let current_section_revision = active_section_revision.unwrap_or_default();
let chest_openness = (cache.is_some()
&& options.chests
&& matches!(kind, Kind::Chest { .. }))
.then(|| {
self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
)
});
if cache_only && section_revision.is_some() && active_section_revision.is_none() {
break 'draw;
}
@@ -1708,6 +1893,7 @@ impl BlockEntityRenderer {
options,
&active_entries[index].1,
current_section_revision,
chest_openness,
)
{
let entry = &mut active_entries[index].1;
@@ -1727,7 +1913,9 @@ impl BlockEntityRenderer {
..*vertex
}));
}
if matches!(kind, Kind::Banner { .. }) {
if matches!(kind, Kind::Banner { .. })
&& self.is_dynamic_visible(scene, &shapes, pos, state, kind, None)
{
let packed = scene.world.light(pos[0], pos[1], pos[2]);
let origin =
(DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
@@ -1748,11 +1936,25 @@ impl BlockEntityRenderer {
}
break 'draw;
}
if cache_only && chest_openness.is_some_and(|(open, _)| open != 0.0) {
if let Some(cache) = &mut cache
&& let Ok(index) =
active_entries.binary_search_by_key(&local, |(index, _)| *index)
{
active_entries.remove(index);
cache.len -= 1;
cache.mark_section_dirty(section);
}
break 'draw;
}
let origin = (DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]))
- scene.camera)
.as_vec3();
let mut packed = scene.world.light(pos[0], pos[1], pos[2]);
if matches!(kind, Kind::Sign { .. }) && scene.texts.is_none() {
if !self.is_dynamic_visible(scene, &shapes, pos, state, kind, None) {
break 'draw;
}
if options.signs {
self.sign_text(scene, pos, state, origin, packed, out);
} else {
@@ -1771,8 +1973,15 @@ impl BlockEntityRenderer {
break 'draw;
}
if let Some(cache) = &mut cache
&& let Some(key) =
self.static_key(scene, pos, state, kind, options, &mut packed)
&& let Some(key) = self.static_key(
scene,
pos,
state,
kind,
options,
&mut packed,
chest_openness,
)
{
let slot = active_entries.binary_search_by_key(&local, |(index, _)| *index);
let entry = match slot {
@@ -1857,7 +2066,9 @@ impl BlockEntityRenderer {
..*vertex
}));
}
if matches!(kind, Kind::Banner { .. }) {
if matches!(kind, Kind::Banner { .. })
&& self.is_dynamic_visible(scene, &shapes, pos, state, kind, None)
{
self.render_banner(
&shapes,
scene,
@@ -1884,6 +2095,9 @@ impl BlockEntityRenderer {
if cache_only {
break 'draw;
}
if !self.is_dynamic_visible(scene, &shapes, pos, state, kind, chest_openness) {
break 'draw;
}
let base = Mat4::from_translation(origin);
self.render_one(scene, pos, kind, base, packed, lights, false, true, out);
}
@@ -1950,6 +2164,7 @@ impl BlockEntityRenderer {
kind: &Kind,
options: CacheOptions,
packed: &mut u8,
chest_openness: Option<(f32, Option<[i32; 3]>)>,
) -> Option<StaticKey> {
let mut sprite_rects = [None; 5];
let mut sprite_count = 0;
@@ -1967,12 +2182,14 @@ impl BlockEntityRenderer {
part,
..
} if options.chests => {
let (open, other) = self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
);
let (open, other) = chest_openness.unwrap_or_else(|| {
self.chest_openness(
pos,
kind,
&|p| scene.world.block(p[0], p[1], p[2]).0,
scene.lid,
)
});
if open != 0.0 {
return None;
}
@@ -3354,6 +3571,7 @@ mod tests {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
// Compare the cached world path with the original model renderer,
// including atlas replacement, current light and animated fallback.
@@ -3648,6 +3866,7 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
let mut call = TextCall {
matrix: Mat4::IDENTITY,
@@ -3720,6 +3939,7 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
let call = TextCall {
matrix: Mat4::IDENTITY,
@@ -3835,6 +4055,7 @@ mod tests {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
let check = |scene: &Scene, pos: [i32; 3], state: u32| {
let mut cached = Vec::new();
@@ -3975,6 +4196,7 @@ mod tests {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let mut verts = Vec::new();
renderer.render_block(&scene, [0; 3], state.0, Mat4::IDENTITY, 0xff, &mut verts);
@@ -4060,6 +4282,7 @@ mod tests {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
let draw = |renderer: &BlockEntityRenderer, block: &str, texture: &'static str| {
@@ -358,6 +358,7 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
// Reverse local-position order to check that cache indexing does not
// change the order of emitted entities.
@@ -446,6 +447,7 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let options = CacheOptions {
shulker_boxes: true,
@@ -500,6 +502,7 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([100, 70, -32], 0)];
let other_block = [([116, 70, -32], 0)];
@@ -686,6 +689,7 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
skull: &|_| None,
glyph: &glyph,
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let enabled = CacheOptions {
@@ -739,6 +743,7 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -840,6 +845,7 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let options = CacheOptions {
shulker_boxes: true,
@@ -894,6 +900,7 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -955,6 +962,7 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -1050,6 +1058,7 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let pos = [0, 64, 0];
let options = CacheOptions {
@@ -1138,6 +1147,7 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
let options = CacheOptions {
@@ -1213,6 +1223,7 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -1291,6 +1302,7 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let blocks = [([0, 64, 0], 0)];
let options = CacheOptions {
@@ -1357,6 +1369,7 @@ fn cached_wall_banner_poles_use_the_wall_model() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let pos = [0, 64, 0];
let options = CacheOptions {
@@ -1404,6 +1417,7 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let options = CacheOptions {
copper_golem_statues: true,
@@ -1509,6 +1523,7 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
skull: &|_| None,
glyph: &|_| Glyph::default(),
glyphs: 0,
dynamic_visible: None,
};
let options = CacheOptions {
copper_golem_statues: true,
@@ -373,6 +373,7 @@ pub fn main() -> Result<()> {
skull: &|_| None,
glyph: &|c| atlas.glyph_cell(c),
glyphs: atlas.font.epoch(),
dynamic_visible: None,
};
let mut verts = Vec::new();
renderer.render_block(&scene, pos, state, Mat4::IDENTITY, packed, &mut verts);
+57 -26
View File
@@ -377,6 +377,7 @@ use horizon_client::{
first_person::{Hand, SwingAnimation},
furnace::FurnaceCooking,
gfx::{Frame, Renderer},
graphics::EntityCullingMode,
hud::{HudAtlas, HudBatch, rgba},
hud_layout, hud_overlays,
items::{ItemStack, Items},
@@ -11198,32 +11199,6 @@ impl Game<'_> {
laps.lap("eg_shadows");
// Block entities (BlockEntityRenderers): chests, shulker boxes, banners, skulls...
if let Some(r) = &self.renderer {
let lid = |p: [i32; 3]| self.chest_lid(p, alpha as f32);
let anim = |p: [i32; 3]| self.block_anim(p, alpha as f32);
let scene = BlockEntityScene {
camera,
orientation: self.camera_orientation(),
anim: &anim,
texts: None,
partial: alpha as f32,
game_time: self.world.game_time,
max_y: self.world.shape.max_y() - 1,
world: &self.world,
sprites: &self.gui.atlas.sprites,
emissive: &self.gui.atlas.emissive,
translucent_sprites: &self.gui.atlas.translucent,
atlas_revision: self.gui.atlas.revision,
nether: self.dim == Dim::Nether,
lid: &lid,
sign: &|p| match self.world.block_entities.get(&(p[0], p[1], p[2])) {
Some(BlockEntity::Sign(sign)) => Some(sign),
_ => None,
},
banner: &|p| self.banner_layers_at(p),
skull: &|p| self.skull_skin_at(p),
glyph: &|c| self.gui.atlas.glyph_cell(c),
glyphs: self.gui.atlas.font.epoch(),
};
// Block entities outside the view frustum are skipped (with a
// block of margin for models larger than their block).
let view = self.view_frustum;
@@ -11250,6 +11225,62 @@ impl Game<'_> {
v.intersects(o - glam::Vec3::ONE, o + glam::Vec3::splat(2.0))
})
};
let dynamic_visible = |_: [i32; 3], state: u32, lo: glam::DVec3, hi: glam::DVec3| {
if off_screen(state) {
return true;
}
if let Some(view) = view
&& !view.intersects(
(lo - camera - glam::DVec3::splat(0.5)).as_vec3(),
(hi - camera + glam::DVec3::splat(0.5)).as_vec3(),
)
{
return false;
}
let name = &*self.res.facts().get(BlockStateId(state)).name;
if self.settings.horizon.entity_culling == EntityCullingMode::Off
|| self.pack_shadow_casters.0
|| name == "minecraft:end_portal"
|| name == "minecraft:end_gateway"
|| hi.y - lo.y > 2.0
{
return true;
}
// Culler::aabb_bounds adds 0.05 on each side. Keep its final
// query just inside the conservative rendered-model envelope.
let bounds = Aabb::new(
Vec3::new(lo.x + 0.06, lo.y + 0.06, lo.z + 0.06),
Vec3::new(hi.x - 0.06, hi.y - 0.06, hi.z - 0.06),
);
!self.block_entity_occluded(bounds)
};
let lid = |p: [i32; 3]| self.chest_lid(p, alpha as f32);
let anim = |p: [i32; 3]| self.block_anim(p, alpha as f32);
let scene = BlockEntityScene {
camera,
orientation: self.camera_orientation(),
anim: &anim,
texts: None,
partial: alpha as f32,
game_time: self.world.game_time,
max_y: self.world.shape.max_y() - 1,
world: &self.world,
sprites: &self.gui.atlas.sprites,
emissive: &self.gui.atlas.emissive,
translucent_sprites: &self.gui.atlas.translucent,
atlas_revision: self.gui.atlas.revision,
nether: self.dim == Dim::Nether,
lid: &lid,
sign: &|p| match self.world.block_entities.get(&(p[0], p[1], p[2])) {
Some(BlockEntity::Sign(sign)) => Some(sign),
_ => None,
},
banner: &|p| self.banner_layers_at(p),
skull: &|p| self.skull_skin_at(p),
glyph: &|c| self.gui.atlas.glyph_cell(c),
glyphs: self.gui.atlas.font.epoch(),
dynamic_visible: Some(&dynamic_visible),
};
let visible = r
.visible_block_entities_in_frustum(
self.settings.chunk_fade,
+4 -1
View File
@@ -6917,7 +6917,10 @@ impl Game<'_> {
// 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
.async_occlusion_culler
.as_ref()
.is_some_and(|culler| culler.camera_is_stationary())
&& self.settings.horizon.entity_culling_precheck != 0
&& self.mobs.len() >= self.settings.horizon.entity_culling_precheck as usize
&& !self.pack_shadow_casters.0
@@ -68,6 +68,9 @@ impl Game<'_> {
EntityCullingMode::Off => false,
EntityCullingMode::Asynchronous => {
if let Some(culler) = &self.async_occlusion_culler {
if !culler.camera_is_stationary() {
return false;
}
let Some(query) = culler.query_camera_bounds(lo, hi) else {
return false;
};
@@ -90,23 +93,31 @@ impl Game<'_> {
}
}
} else {
let corners = [lo, hi].map(|pos| Vertex {
pos: pos.to_array(),
..bytemuck::Zeroable::zeroed()
});
self.entity_hidden_sync(Some(&corners), 0, None)
self.entity_hidden_sync_camera_bounds(lo, hi)
}
}
EntityCullingMode::Synchronous => {
let corners = [lo, hi].map(|pos| Vertex {
pos: pos.to_array(),
..bytemuck::Zeroable::zeroed()
});
self.entity_hidden_sync(Some(&corners), 0, None)
}
EntityCullingMode::Synchronous => self.entity_hidden_sync_camera_bounds(lo, hi),
}
}
fn entity_hidden_sync_camera_bounds(&self, lo: glam::Vec3, hi: glam::Vec3) -> bool {
self.sync_occlusion_culler.as_ref().is_some_and(|culler| {
let mut occluder = SyncOccluder::new(
&self.world,
self.res.facts(),
self.renderer.as_ref(),
&self.streaming,
&self.pending_uploads,
);
culler.hidden_camera_bounds(lo.to_array(), hi.to_array(), |pos| occluder.ready(pos))
})
}
/// Pre-model visibility hook for dynamic block-entity geometry.
pub(super) fn block_entity_occluded(&self, bounds: Aabb) -> bool {
self.occlusion_hidden(None, 0, Some(bounds))
}
/// Cull from the shared entity AABB before generating its model vertices.
pub(super) fn entity_cull_decision<T: RenderEntityBounds>(
&self,
@@ -151,6 +162,9 @@ impl Game<'_> {
EntityCullDecision::CheckVertices
};
};
if !culler.camera_is_stationary() {
return EntityCullDecision::CheckVertices;
}
let Some(query) = culler.query_entity_bounds(bounds) else {
return EntityCullDecision::CheckVertices;
};
@@ -203,6 +217,9 @@ impl Game<'_> {
EntityCullingMode::Synchronous => self.entity_hidden_sync(vertices, start, bounds),
EntityCullingMode::Asynchronous => {
if let Some(culler) = &self.async_occlusion_culler {
if !culler.camera_is_stationary() {
return false;
}
let Some(query) = (match bounds {
Some(bounds) => culler.query_entity_bounds(bounds),
None => vertices.and_then(|vertices| culler.query(vertices, start)),
@@ -2718,6 +2718,7 @@ impl Game<'_> {
skull: &|_| None,
glyph: &|c| self.gui.atlas.glyph_cell(c),
glyphs: self.gui.atlas.font.epoch(),
dynamic_visible: None,
};
self.res
.block_entity_render()
@@ -745,6 +745,8 @@ pub(super) struct AsyncOcclusionCuller {
query_cache_stamp: Option<SnapshotKey>,
query_cache_camera_cell: Option<[i32; 3]>,
camera: [f64; 3],
previous_camera_key: Option<[u64; 3]>,
camera_stationary: bool,
stamp: SnapshotKey,
pending: bool,
frame: u64,
@@ -856,6 +858,8 @@ impl AsyncOcclusionCuller {
query_cache_stamp: None,
query_cache_camera_cell: None,
camera: [0.0; 3],
previous_camera_key: None,
camera_stationary: true,
stamp: SnapshotKey {
world_revision: 0,
chunks_revision: 0,
@@ -901,6 +905,10 @@ impl AsyncOcclusionCuller {
stats.previous_chunks_revision = Some(self.stamp.chunks_revision);
}
let camera_key = self.camera.map(f64::to_bits);
self.camera_stationary = self
.previous_camera_key
.is_none_or(|previous| previous == camera_key);
self.previous_camera_key = Some(camera_key);
let current_camera_cell = camera_cell(self.camera);
let layout_changed = self
.query_cache_stamp
@@ -946,13 +954,7 @@ impl AsyncOcclusionCuller {
let mut stats = self.stats.borrow_mut();
stats.stale_camera_jobs = stats.stale_camera_jobs.saturating_add(1);
}
if results.camera == camera_key {
self.cache_query_results(&mut results);
} else {
// A stale-camera visible answer is fail-open. Never carry a
// hidden proof across viewpoints.
self.cache_visible_results(&mut results);
}
self.cache_results_for_camera(&mut results, camera_key);
let scratch = std::mem::take(&mut results.scratch_queries);
if scratch.capacity() > self.queries.get_mut().capacity() {
*self.queries.get_mut() = scratch;
@@ -981,6 +983,11 @@ impl AsyncOcclusionCuller {
self.queries.get_mut().clear();
}
#[inline]
pub(super) fn camera_is_stationary(&self) -> bool {
self.camera_stationary
}
pub(super) fn query(&self, vertices: &[Vertex], start: usize) -> Option<CullKey> {
let scan_started = self.measure_bounds_timing.get().then(Instant::now);
let bounds = OcclusionCuller::bounds(self.camera, vertices, start);
@@ -1229,10 +1236,17 @@ impl AsyncOcclusionCuller {
self.cache_results(results, true, camera_cell);
}
fn cache_visible_results(&mut self, results: &mut CullResults) {
// Visible results fail open, so they may follow the current camera
// sub-cell without ever hiding geometry.
self.cache_results(results, false, self.query_cache_camera_cell);
#[inline]
fn cache_results_for_camera(&mut self, results: &mut CullResults, camera: [u64; 3]) {
if results.camera == camera {
self.cache_query_results(results);
} else {
// Even visible answers suppress a fresh query while their cache
// entry is live. Drop stale batches instead of sorting and merging
// results that cannot help this viewpoint.
results.visible.clear();
results.hidden.clear();
}
}
fn cache_results(
@@ -1955,8 +1969,8 @@ pub(super) struct SyncOccluder<'a> {
streaming: &'a Streaming,
pending_uploads: &'a [((i32, i32, i32), SectionMesh)],
bottom_y: i32,
last_chunk: Option<(ChunkKey, Option<&'a Chunk>)>,
last_section: Option<(SectionKey, bool)>,
chunk_slots: [Option<(ChunkKey, Option<&'a Chunk>)>; 4],
section_slots: [Option<(SectionKey, bool)>; 4],
}
impl<'a> SyncOccluder<'a> {
@@ -1974,19 +1988,20 @@ impl<'a> SyncOccluder<'a> {
streaming,
pending_uploads,
bottom_y: world.bottom_y(),
last_chunk: None,
last_section: None,
chunk_slots: [None; 4],
section_slots: [None; 4],
}
}
#[inline]
pub(super) fn ready(&mut self, pos: [i32; 3]) -> bool {
let chunk_key = (pos[0] >> 4, pos[2] >> 4);
let chunk = match self.last_chunk {
let chunk_index = (((chunk_key.0 as usize) & 1) << 1) | ((chunk_key.1 as usize) & 1);
let chunk = match self.chunk_slots[chunk_index] {
Some((key, cached)) if key == chunk_key => cached,
_ => {
let found = self.world.chunks.get(&chunk_key).map(Arc::as_ref);
self.last_chunk = Some((chunk_key, found));
self.chunk_slots[chunk_index] = Some((chunk_key, found));
found
}
};
@@ -2001,14 +2016,15 @@ impl<'a> SyncOccluder<'a> {
return false;
};
let section = (chunk_key.0, y >> 4, chunk_key.1);
match self.last_section {
let section_index = (chunk_index ^ (section.1 as usize)) & 3;
match self.section_slots[section_index] {
Some((key, ready)) if key == section => ready,
_ => {
let ready = self.renderer.is_some_and(|r| r.has_section(section))
&& !self.world.dirty_sections.contains(&section)
&& !self.streaming.is_meshing(section)
&& !self.pending_uploads.iter().any(|(key, _)| *key == section);
self.last_section = Some((section, ready));
self.section_slots[section_index] = Some((section, ready));
ready
}
}
@@ -244,7 +244,7 @@ fn stale_world_results_cannot_replace_a_current_proof_in_the_same_camera_cell()
}
#[test]
fn stale_visible_results_are_reusable_in_the_current_camera_cell() {
fn stale_camera_visible_results_are_discarded_for_a_fresh_query() {
let mut culler = AsyncOcclusionCuller::new().unwrap();
culler.stamp = SnapshotKey {
world_revision: 2,
@@ -275,11 +275,14 @@ fn stale_visible_results_are_reusable_in_the_current_camera_cell() {
visible_entities: 1,
deferred_entities: 0,
};
culler.cache_visible_results(&mut results);
assert!(matches!(
culler.query_result(&current_key, QuerySource::EntityAabb),
Some((false, None))
));
culler.cache_results_for_camera(&mut results, current_key.camera);
assert!(results.visible.is_empty());
assert!(results.hidden.is_empty());
assert!(
culler
.query_result(&current_key, QuerySource::EntityAabb)
.is_none()
);
}
#[test]
+86 -23
View File
@@ -67,6 +67,29 @@ impl Culler {
self.occluded(min, max, opaque)
}
/// Whether camera-relative model bounds are hidden. This avoids building
/// synthetic vertices when a renderer already has a conservative AABB.
pub fn hidden_camera_bounds(
&self,
lo: [f32; 3],
hi: [f32; 3],
opaque: impl FnMut([i32; 3]) -> bool,
) -> bool {
if self.remaining.get() == 0
|| self.camera.into_iter().any(|value| !value.is_finite())
|| lo.into_iter().chain(hi).any(|value| !value.is_finite())
{
return false;
}
let min = std::array::from_fn(|axis| {
f64::from(lo[axis].min(hi[axis])) + self.camera[axis] - 0.05
});
let max = std::array::from_fn(|axis| {
f64::from(lo[axis].max(hi[axis])) + self.camera[axis] + 0.05
});
self.occluded(min, max, opaque)
}
/// World-space bounds expanded for floating-point and rasterization edges.
pub fn aabb_bounds(bounds: Aabb) -> Option<([f64; 3], [f64; 3])> {
let lo = bounds.min_position();
@@ -171,29 +194,45 @@ impl Culler {
};
cell = next;
}
// These two reciprocals are invariant for every plane along the ray.
let denominator_min = (min[axis] - camera[axis]) * sign;
let denominator_max = (max[axis] - camera[axis]) * sign;
if !denominator_min.is_finite()
|| !denominator_max.is_finite()
|| denominator_min.abs() <= EPSILON
|| denominator_max.abs() <= EPSILON
{
return false;
}
let reciprocal_min = 1.0 / denominator_min;
let reciprocal_max = 1.0 / denominator_max;
let transverse_min = [
min[across[0]] - camera[across[0]],
min[across[1]] - camera[across[1]],
];
let transverse_max = [
max[across[0]] - camera[across[0]],
max[across[1]] - camera[across[1]],
];
loop {
let depth = (f64::from(cell) + 0.5 - camera[axis]) * sign;
if depth >= gap - EPSILON || *remaining == 0 {
return false;
}
let t_min = depth * reciprocal_min;
let t_max = depth * reciprocal_max;
let mut lo = [f64::INFINITY; 2];
let mut hi = [f64::NEG_INFINITY; 2];
for corner in 0..8 {
let point: [f64; 3] = std::array::from_fn(|a| {
if corner & (1 << a) == 0 {
min[a]
} else {
max[a]
}
});
let t = depth / ((point[axis] - camera[axis]) * sign);
for (i, &a) in across.iter().enumerate() {
let p = camera[a] + t * (point[a] - camera[a]);
if !p.is_finite() {
for i in 0..2 {
let camera_axis = camera[across[i]];
for t in [t_min, t_max] {
let p_min = camera_axis + t * transverse_min[i];
let p_max = camera_axis + t * transverse_max[i];
if !p_min.is_finite() || !p_max.is_finite() {
return false;
}
lo[i] = lo[i].min(p);
hi[i] = hi[i].max(p);
lo[i] = lo[i].min(p_min);
hi[i] = hi[i].max(p_max);
}
}
let bounds = [
@@ -205,27 +244,51 @@ impl Culler {
let [Some(x0), Some(x1), Some(y0), Some(y1)] = bounds else {
return false;
};
let center_x = x0 + ((i64::from(x1) - i64::from(x0)) / 2) as i32;
let center_y = y0 + ((i64::from(y1) - i64::from(y0)) / 2) as i32;
let mut pos = [0; 3];
pos[axis] = cell;
pos[across[0]] = x0 + ((i64::from(x1) - i64::from(x0)) / 2) as i32;
pos[across[1]] = y0 + ((i64::from(y1) - i64::from(y0)) / 2) as i32;
pos[across[0]] = center_x;
pos[across[1]] = center_y;
*remaining -= 1;
if opaque(pos) {
let area = (i64::from(x1) - i64::from(x0) + 1)
.checked_mul(i64::from(y1) - i64::from(y0) + 1)
.and_then(|n| usize::try_from(n).ok());
let Some(area) = area.filter(|&n| n <= *remaining) else {
let Some(additional) = area.and_then(|n| n.checked_sub(1)) else {
return false;
};
// Reserve the worst-case lookups before walking the rectangle.
*remaining -= area;
let covered = (x0..=x1).all(|x| {
if additional > *remaining {
return false;
}
// Keep Y in the inner loop for X-facing planes. This keeps
// consecutive probes in one chunk column. Charge only probes
// actually visited when a gap short-circuits the rectangle.
let covered = if across[0] == 1 {
(y0..=y1).all(|y| {
pos[across[0]] = x;
pos[across[1]] = y;
opaque(pos)
(x0..=x1).all(|x| {
if x == center_x && y == center_y {
return true;
}
*remaining -= 1;
pos[across[0]] = x;
opaque(pos)
})
})
});
} else {
(x0..=x1).all(|x| {
pos[across[0]] = x;
(y0..=y1).all(|y| {
if x == center_x && y == center_y {
return true;
}
*remaining -= 1;
pos[across[1]] = y;
opaque(pos)
})
})
};
if covered {
return true;
}