Skip to content

Cull mesh instances by their bounding box instead of a bounding sphere - #9197

Merged
mvaligursky merged 1 commit into
mainfrom
mv-aabb-cull
Aug 20, 2026
Merged

Cull mesh instances by their bounding box instead of a bounding sphere#9197
mvaligursky merged 1 commit into
mainfrom
mv-aabb-cull

Conversation

@mvaligursky

Copy link
Copy Markdown
Contributor

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 when n·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#_isVisible uses it, so camera, spot light and directional cascade culling all pick it up. The temporary BoundingSphere and the halfExtents.length() square root it needed are gone.
  • The omni shadow caster classification switches from the caster's bounding sphere radius to its per-axis extents. The 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.
  • Tests: six containsAabb cases, 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:

frustum.containsAabb(meshInstance.aabb);  // true if it intersects or is inside

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.

before after
frustum cull the whole scene, isolated 0.067 ms 0.035 ms 1.9x
render time 44.7 ms 41.9 ms −6%
forward draw calls 1041 1029 −1.2%
shadow draw calls 37554 35207 −6.3%

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 _isVisible needed, and does one comparison per plane where containsSphere does 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 culling still uses Light#getBoundingSphere, which is a genuine sphere and already tight for spot cones.
  • The gsplat cull compute shaders still do a sphere test — the same opportunity, in a separate area.
  • Against exact SAT, the omni classification now has zero false positives at any caster size, because its slab rejection supplies exactly the three box-axis separating axes a plane test lacks. containsAabb on 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.

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.
@github-actions

Copy link
Copy Markdown

Public API report

This PR changes the public API surface (+1 / −0), per the docs' rules (@ignore / @Private / undocumented are excluded).

Show API diff
+Frustum.containsAabb(aabb: BoundingBox): boolean

Informational only — this never fails the build.

@github-actions

Copy link
Copy Markdown

Build size report

This PR changes the size of the minified bundles.

Bundle Minified Gzip Brotli
playcanvas.min.js 2371.4 KB (+0.2 KB, +0.01%) 609.4 KB (+0.1 KB, +0.02%) 473.0 KB (−0.0 KB, −0.00%)
playcanvas.min.mjs 2368.8 KB (+0.2 KB, +0.01%) 608.3 KB (+0.1 KB, +0.01%) 472.6 KB (+0.3 KB, +0.06%)

@mvaligursky mvaligursky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@mvaligursky
mvaligursky merged commit aa946b9 into main Aug 20, 2026
10 checks passed
@mvaligursky
mvaligursky deleted the mv-aabb-cull branch August 20, 2026 09:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant