Skip to content

Cull omni shadow casters against all six faces in a single pass - #9194

Merged
mvaligursky merged 1 commit into
mainfrom
mv-omni-shadow-cull
Aug 19, 2026
Merged

Cull omni shadow casters against all six faces in a single pass#9194
mvaligursky merged 1 commit into
mainfrom
mv-omni-shadow-cull

Conversation

@mvaligursky

@mvaligursky mvaligursky commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Local shadow caster culling walked the entire caster list once per cube map face, so an omni light cost six full passes over every shadow caster of every layer it is part of - each pass evaluating the caster's world AABB and testing its bounding sphere against that face's frustum.

Changes:

  • ShadowRenderer#cullShadowCastersOmni classifies each caster into all six face lists in a single pass. The six shadow cameras of an omni light are axis aligned in world space (LightCamera.pointLightRotations is a constant set and the omni cull only ever sets the camera position), so light space is world space translated by the light position, and each face is bounded by the same six planes its frustum has: a near and a far plane perpendicular to the face axis, plus four side planes with the slope of the face's field of view. Testing the caster's bounding sphere against those is a few comparisons per face.
  • ShadowRendererLocal#cull routes omni lights to the new path; spot lights keep the existing single-frustum path.
  • The layer walk and de-duplication shared by both paths is extracted into ShadowRenderer#_collectCasterLists.
  • Added test/scene/renderer/shadow-renderer-local.test.mjs, covering the face classification, the range cube corner case noted below, casters with culling disabled, custom visibility functions, and a cross-check of 300 casters against a per-face frustum test.

Worth noting for anyone touching this later: the union of the six face frusta is bounded by the axis aligned cube of half side range * tan(fov / 2), not by the light's range sphere. Each face's far plane is perpendicular to its face axis, so the corners of the frusta stick out past the sphere, and a range-sphere rejection silently drops casters there. The test covers this.

Casters with culling disabled, and those with a custom visibility function - which the gsplat renderers use to opt out of shadow casting - continue to take the per-face path.

Directional cascades have the same shape of redundancy (one full pass per cascade, per camera) and are left for a follow-up.

Performance:

Measured on a clustered omni shadow scene of 1542 casters and 32 shadow casting omni lights, at 1600x900, driving frames deterministically:

before after
cull time, median 10.8 ms 2.6 ms
frame time 60.9 ms 48.7 ms
shadow draw calls 37624 37603
Screenshot 2026-08-19 at 11 49 22

Backing out camera mesh culling, which shares the same timer, the shadow culling itself goes from ~9.8 ms to ~1.6 ms. The rendered output is unchanged - a hash of the framebuffer after an identical frame sequence matches exactly before and after. The marginally lower shadow draw call count is casters near a frustum corner that the sphere-vs-plane test admits but which cannot rasterize into that face.

Local shadow caster culling walked the entire caster list once per cube map
face, so an omni light cost six full passes over every shadow caster of every
layer it is part of, each evaluating the caster's world AABB and testing its
bounding sphere against the face frustum.

ShadowRenderer#cullShadowCastersOmni now classifies each caster into all six
face lists in one pass. The six shadow cameras of an omni light are axis
aligned in world space, so light space is world space translated by the light
position, and each face is bounded by the same six planes its frustum has - a
near and a far plane perpendicular to the face axis, and four side planes with
the slope of the face's field of view. Testing the caster's bounding sphere
against those in light space is a few comparisons per face.

Note that the union of the six face frusta is bounded by the axis aligned cube
of half side range * tan(fov / 2), not by the light's range sphere: each far
plane is perpendicular to its face axis, so the corners of the frusta stick out
past the sphere, and rejecting against a sphere would wrongly drop casters
there.

Casters with culling disabled, and those with a custom visibility function
(which the gsplat renderers use to opt out of shadow casting), keep taking the
per-face path.

In a scene of 1542 casters and 32 shadowed omni lights, culling drops from
10.8ms to 2.6ms per frame and the frame from 60.9ms to 48.7ms, with identical
rendered output. Spot lights and directional cascades are unchanged.
@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 2370.7 KB (+1.5 KB, +0.06%) 609.2 KB (+0.5 KB, +0.08%) 473.0 KB (+0.4 KB, +0.08%)
playcanvas.min.mjs 2368.1 KB (+1.5 KB, +0.06%) 608.1 KB (+0.5 KB, +0.08%) 472.4 KB (+0.2 KB, +0.05%)

@mvaligursky mvaligursky added performance Relating to load times or frame rate area: graphics Graphics related issue labels Aug 19, 2026

@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 findings. I reviewed the manual sphere/plane equations and face ordering against the existing shadow-camera frusta, near/far and atlas-FOV handling, the conservative union-cube rejection, AABB evaluation, disabled culling and custom visibility callbacks, layer collection/de-duplication, sort and visibleThisFrame semantics, spot-light isolation, scratch-state reuse, allocations, and the public/API surface. The straight-line six-face classification is justified in this hot path, and the tighter caster count only removes conservative per-plane corner false positives that cannot intersect the union of the cube faces.

Validation performed: all current CI checks are green; git diff --check passes; the focused shadow-renderer suite passes 11/11; ESLint passes for all changed files; and an additional >90-degree atlas-FOV case matches the corresponding shadow-camera frustum. The PR is clean from the reviewed correctness, compatibility, performance, and maintainability angles.

@mvaligursky
mvaligursky merged commit 25e5434 into main Aug 19, 2026
10 checks passed
@mvaligursky
mvaligursky deleted the mv-omni-shadow-cull branch August 19, 2026 10:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: graphics Graphics related issue performance Relating to load times or frame rate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant