Cull mesh instances by their bounding box instead of a bounding sphere - #9197
Conversation
Frustum culling built a bounding sphere from a mesh instance's world AABB using halfExtents.length() - the circumscribing sphere, the loosest bound a box has - and tested that against the frustum planes. For a cube that sphere is 2.7 times the box's volume, and for anything elongated it is far worse: a beam of half extents 60, 1, 1 gets a sphere of radius 60. Frustum#containsAabb tests the box itself, using its extent along each plane normal: r = |n.x| ex + |n.y| ey + |n.z| ez, with the box outside a plane when n.c + d <= -r. That extent never exceeds the box's bounding sphere radius, by Cauchy-Schwarz, so the test is always at least as tight as the one it replaces and can only ever remove false positives - it cannot cull something that was previously drawn. MeshInstance#_isVisible now uses it, which covers camera, spot light and directional cascade culling, and the omni shadow classification switches from the caster's bounding sphere radius to its per-axis extents. In the latter the 1 / sqrt(1 + slope^2) that normalizes the side plane normals cancels on both sides of a box test, so it drops out and that path gets cheaper as well as tighter. The method is public because reading the frustum planes to hand-roll this is the common case for application side culling.
Build size reportThis PR changes the size of the minified bundles.
|
mvaligursky
left a comment
There was a problem hiding this comment.
Automated PR review by Codex (GPT-5).
No actionable issues found.
Reviewed the change from the merge base for geometric correctness, boundary semantics, public API design, world-AABB validity, camera/spot/directional culling behavior, the specialized six-face omni path, custom visibility and culling overrides, performance, and test coverage. containsAabb uses the standard conservative plane projection test with the same outside/touching convention as containsSphere; MeshInstance#_isVisible supplies its evaluated world-space AABB; and the expanded omni equations use the correct axial/lateral extent for every face and sign.
Local verification on the exact head source: 51 focused Frustum, Camera, and local-shadow tests passed, including an additional randomized anisotropic-box stress run across all six omni faces. ESLint passed for all changed files, generated TypeScript declarations and test:types passed, and git diff --check passed. All current GitHub checks are green.
Frustum culling built a bounding sphere from a mesh instance's world AABB using
halfExtents.length()— the circumscribing sphere, which is the loosest bound a box has — and tested that against the frustum planes. For a cube that sphere is 2.7x the box's volume; for anything elongated it is far worse, a beam of half extents 60, 1, 1 getting a sphere of radius 60. This tests the box itself.Changes:
Frustum#containsAabb(aabb)tests a bounding box against the frustum, using the box's extent along each plane normal:r = |n.x|ex + |n.y|ey + |n.z|ez, with the box outside a plane whenn·c + d <= -r. Public, because reading the planes to hand-roll this is the common case for application side culling — it also covers the request in [BREAKING] Store frustum planes in a typed array #9196.MeshInstance#_isVisibleuses it, so camera, spot light and directional cascade culling all pick it up. The temporaryBoundingSphereand thehalfExtents.length()square root it needed are gone.1 / sqrt(1 + slope²)that normalizes the side plane normals cancels on both sides of a box test, so it drops out and that path gets cheaper as well as tighter.containsAabbcases, including one that checks it never reports a box visible that the sphere test rejects across 20k random boxes, and one that demonstrates it is tighter; plus the omni suite's reference moved to box semantics and a new elongated-caster case.API Changes:
Returns a boolean rather than the 0/1/2 of
containsSphere. Distinguishing "fully inside" would be cheap — a box's extent is symmetric about its centre, so the same extent serves both tests for one extra comparison per plane — but no caller needs it, and it measured 11% slower where all six planes are evaluated. Adding it later would be non-breaking.Performance:
Measured on a clustered omni shadow scene of 1542 casters and 32 shadow casting omni lights, at 1600x900, medians of 40 deterministically driven frames.
Culling got faster rather than slower, which is worth explaining: on top of the packed planes from #9196 the box test drops the square root and the temporary sphere write that
_isVisibleneeded, and does one comparison per plane wherecontainsSpheredoes two. So it is both tighter and cheaper.A hash of the framebuffer after a fixed frame sequence is identical before and after — every object no longer submitted was entirely outside the frustum it was being drawn into, contributing no pixels.
How much the draw call count improves is scene dependent, and scales with how large the casters are relative to the frustum. A synthetic sweep gives 1–12% for a camera frustum and 20–55% for the small face frusta of a short range omni light, so scenes with large floors and walls lit by short range lights gain considerably more than this one, and scenes of small compact props gain less.
Notes:
Light#getBoundingSphere, which is a genuine sphere and already tight for spot cones.containsAabbon a general frustum still leaves the usual plane-test false positives near frustum corners, from 0% for small boxes up to a few percent for boxes large relative to the frustum. Adding the frustum's own AABB as a fourth rejection would remove most of those and is actually faster for large boxes, but it needs new per-frustum state, so it is left for a follow-up.