Cull omni shadow casters against all six faces in a single pass - #9194
Conversation
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.
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 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.
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#cullShadowCastersOmniclassifies 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.pointLightRotationsis 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#cullroutes omni lights to the new path; spot lights keep the existing single-frustum path.ShadowRenderer#_collectCasterLists.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:
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.