From ed94331b1be1574930266ae39438d44cc7ce8e06 Mon Sep 17 00:00:00 2001 From: Martin Valigursky Date: Thu, 20 Aug 2026 11:25:47 +0200 Subject: [PATCH] Cull mesh instances by their bounding box instead of a bounding sphere 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. --- src/core/shape/frustum.js | 37 +++++++++ src/scene/mesh-instance.js | 8 +- src/scene/renderer/shadow-renderer.js | 72 ++++++++++-------- test/core/shape/frustum.test.mjs | 75 +++++++++++++++++++ .../renderer/shadow-renderer-local.test.mjs | 48 ++++++++---- 5 files changed, 189 insertions(+), 51 deletions(-) diff --git a/src/core/shape/frustum.js b/src/core/shape/frustum.js index 04c85dd6f07..5388c6157cf 100644 --- a/src/core/shape/frustum.js +++ b/src/core/shape/frustum.js @@ -3,6 +3,7 @@ import { Debug } from '../debug.js'; import { Vec3 } from '../math/vec3.js'; /** + * @import { BoundingBox } from './bounding-box.js' * @import { BoundingSphere } from './bounding-sphere.js' * @import { Mat4 } from '../math/mat4.js' */ @@ -287,6 +288,42 @@ class Frustum { return (c === 6) ? 2 : 1; } + + /** + * Tests whether an axis aligned bounding box intersects the frustum. + * + * The test is conservative in the same way the plane based sphere test is: a box lying just + * outside a frustum corner can be reported as intersecting. It is however always at least as + * tight as testing the box's bounding sphere, since the extent of a box along a plane normal + * never exceeds the radius of its bounding sphere. + * + * Unlike {@link Frustum#containsSphere}, a box completely inside the frustum is not + * distinguished from one merely intersecting it. Detecting that costs a comparison per plane + * and no caller needs it. + * + * @param {BoundingBox} aabb - The bounding box to test. + * @returns {boolean} True if the bounding box intersects or is inside the frustum, false if it + * is completely outside. + */ + containsAabb(aabb) { + const data = this.planeData; + const { center, halfExtents } = aabb; + const { x, y, z } = center; + const ex = halfExtents.x, ey = halfExtents.y, ez = halfExtents.z; + + for (let offset = 0; offset < 24; offset += 4) { + const nx = data[offset], ny = data[offset + 1], nz = data[offset + 2]; + + // the box's extent along the plane normal - the box is outside the plane when its + // signed distance is no greater than minus that extent + const extent = Math.abs(nx) * ex + Math.abs(ny) * ey + Math.abs(nz) * ez; + if (nx * x + ny * y + nz * z + data[offset + 3] <= -extent) { + return false; + } + } + + return true; + } } export { Frustum }; diff --git a/src/scene/mesh-instance.js b/src/scene/mesh-instance.js index f76a7b456a0..7524fc3152e 100644 --- a/src/scene/mesh-instance.js +++ b/src/scene/mesh-instance.js @@ -1,6 +1,5 @@ import { Debug, DebugHelper } from '../core/debug.js'; import { BoundingBox } from '../core/shape/bounding-box.js'; -import { BoundingSphere } from '../core/shape/bounding-sphere.js'; import { BindGroup } from '../platform/graphics/bind-group.js'; import { UniformBuffer } from '../platform/graphics/uniform-buffer.js'; import { VertexBuffer } from '../platform/graphics/vertex-buffer.js'; @@ -44,7 +43,6 @@ import { PickerId } from './picker-id.js'; const _tmpAabb = new BoundingBox(); const _tempBoneAabb = new BoundingBox(); -const _tempSphere = new BoundingSphere(); /** @type {Set} */ const _meshSet = new Set(); @@ -1121,10 +1119,8 @@ class MeshInstance { return this.isVisibleFunc(camera); } - _tempSphere.center = this.aabb.center; // this line evaluates aabb - _tempSphere.radius = this._aabb.halfExtents.length(); - - return camera.frustum.containsSphere(_tempSphere) > 0; + // note that reading aabb evaluates it + return camera.frustum.containsAabb(this.aabb); } return false; diff --git a/src/scene/renderer/shadow-renderer.js b/src/scene/renderer/shadow-renderer.js index 9f1899d7a6d..e1719611bc7 100644 --- a/src/scene/renderer/shadow-renderer.js +++ b/src/scene/renderer/shadow-renderer.js @@ -255,7 +255,7 @@ class ShadowRenderer { * a near and a far plane perpendicular to the face axis, plus four side planes through the * light position with the slope of the face's field of view. Testing a caster's bounding sphere * against those planes in light space is a handful of comparisons per face, and uses the same - * planes {@link Frustum#containsSphere} would, so the result is the same set of casters (up to + * planes {@link Frustum#containsAabb} would, so the result is the same set of casters (up to * the slab rejection below, which is tighter than a plane test near the frustum corners). * * @param {LayerComposition} comp - The layer composition used as a source of shadow casters, @@ -294,9 +294,6 @@ class ShadowRenderer { const far = shadowCam.farClip; const slope = Math.tan(shadowCam.fov * 0.5 * math.DEG_TO_RAD); - // side plane normals are (slope, -+1) and so need scaling to be normalized - const sideScale = Math.sqrt(1 + slope * slope); - // The union of the six face frusta is bounded by the axis aligned cube of this half side. // Note that this is larger than the light's range: each face's far plane is perpendicular to // the face axis, so the corners of the frusta stick out past the range sphere, and rejecting @@ -344,74 +341,85 @@ class ShadowRenderer { continue; } - // caster's bounding sphere in light space + // caster's bounding box in light space const center = meshInstance.aabb.center; // this line evaluates aabb - const radius = meshInstance._aabb.halfExtents.length(); + const halfExtents = meshInstance._aabb.halfExtents; + const ex = halfExtents.x; + const ey = halfExtents.y; + const ez = halfExtents.z; const x = center.x - lightX; const y = center.y - lightY; const z = center.z - lightZ; // reject casters outside the bounds of all six faces - const slab = bounds + radius; - if (x > slab || x < -slab || y > slab || y < -slab || z > slab || z < -slab) { + if (x > bounds + ex || x < -bounds - ex || + y > bounds + ey || y < -bounds - ey || + z > bounds + ez || z < -bounds - ez) { continue; } - // a sphere is outside a plane when its signed distance is <= -radius, so for each - // face: axial > near - radius, axial < far + radius, and (slope * axial -+ lateral) - // > -radius * sideScale for the two lateral axes - const nearLimit = near - radius; - const farLimit = far + radius; - const sideLimit = -radius * sideScale; + // A box is outside a plane when its signed distance is no greater than minus its + // extent along the plane normal. For a face with axial extent ea and lateral extents + // eu and ev that gives: axial + ea > near, axial - ea < far, and + // (slope * axial -+ lateral) > -(slope * ea + e_lateral). The 1 / sqrt(1 + slope^2) + // that normalizes the side plane normals cancels on both sides, so it is dropped. const slopeX = slope * x; const slopeY = slope * y; const slopeZ = slope * z; + + // side plane limits, shared by the two faces of each axis + const limXY = -(slope * ex + ey); + const limXZ = -(slope * ex + ez); + const limYX = -(slope * ey + ex); + const limYZ = -(slope * ey + ez); + const limZX = -(slope * ez + ex); + const limZY = -(slope * ez + ey); let visible = false; // +X - if (x > nearLimit && x < farLimit && - slopeX - y > sideLimit && slopeX + y > sideLimit && - slopeX - z > sideLimit && slopeX + z > sideLimit) { + if (x + ex > near && x - ex < far && + slopeX - y > limXY && slopeX + y > limXY && + slopeX - z > limXZ && slopeX + z > limXZ) { _faceLists[0].push(meshInstance); visible = true; } // -X - if (-x > nearLimit && -x < farLimit && - -slopeX - y > sideLimit && -slopeX + y > sideLimit && - -slopeX - z > sideLimit && -slopeX + z > sideLimit) { + if (-x + ex > near && -x - ex < far && + -slopeX - y > limXY && -slopeX + y > limXY && + -slopeX - z > limXZ && -slopeX + z > limXZ) { _faceLists[1].push(meshInstance); visible = true; } // +Y - if (y > nearLimit && y < farLimit && - slopeY - x > sideLimit && slopeY + x > sideLimit && - slopeY - z > sideLimit && slopeY + z > sideLimit) { + if (y + ey > near && y - ey < far && + slopeY - x > limYX && slopeY + x > limYX && + slopeY - z > limYZ && slopeY + z > limYZ) { _faceLists[2].push(meshInstance); visible = true; } // -Y - if (-y > nearLimit && -y < farLimit && - -slopeY - x > sideLimit && -slopeY + x > sideLimit && - -slopeY - z > sideLimit && -slopeY + z > sideLimit) { + if (-y + ey > near && -y - ey < far && + -slopeY - x > limYX && -slopeY + x > limYX && + -slopeY - z > limYZ && -slopeY + z > limYZ) { _faceLists[3].push(meshInstance); visible = true; } // +Z - if (z > nearLimit && z < farLimit && - slopeZ - x > sideLimit && slopeZ + x > sideLimit && - slopeZ - y > sideLimit && slopeZ + y > sideLimit) { + if (z + ez > near && z - ez < far && + slopeZ - x > limZX && slopeZ + x > limZX && + slopeZ - y > limZY && slopeZ + y > limZY) { _faceLists[4].push(meshInstance); visible = true; } // -Z - if (-z > nearLimit && -z < farLimit && - -slopeZ - x > sideLimit && -slopeZ + x > sideLimit && - -slopeZ - y > sideLimit && -slopeZ + y > sideLimit) { + if (-z + ez > near && -z - ez < far && + -slopeZ - x > limZX && -slopeZ + x > limZX && + -slopeZ - y > limZY && -slopeZ + y > limZY) { _faceLists[5].push(meshInstance); visible = true; } diff --git a/test/core/shape/frustum.test.mjs b/test/core/shape/frustum.test.mjs index 002ff7124aa..b9917413596 100644 --- a/test/core/shape/frustum.test.mjs +++ b/test/core/shape/frustum.test.mjs @@ -2,6 +2,7 @@ import { expect } from 'chai'; import { Mat4 } from '../../../src/core/math/mat4.js'; import { Vec3 } from '../../../src/core/math/vec3.js'; +import { BoundingBox } from '../../../src/core/shape/bounding-box.js'; import { BoundingSphere } from '../../../src/core/shape/bounding-sphere.js'; import { Frustum } from '../../../src/core/shape/frustum.js'; import { Plane } from '../../../src/core/shape/plane.js'; @@ -180,6 +181,80 @@ describe('Frustum', function () { }); }); + describe('#containsAabb', function () { + + it('accepts a box in the middle and rejects boxes outside', function () { + const frustum = createFrustum(); + expect(frustum.containsAabb(new BoundingBox(new Vec3(0, 0, -50), new Vec3(1, 1, 1)))).to.equal(true); + expect(frustum.containsAabb(new BoundingBox(new Vec3(0, 0, 10), new Vec3(1, 1, 1)))).to.equal(false); + expect(frustum.containsAabb(new BoundingBox(new Vec3(0, 0, -200), new Vec3(1, 1, 1)))).to.equal(false); + expect(frustum.containsAabb(new BoundingBox(new Vec3(200, 0, -50), new Vec3(1, 1, 1)))).to.equal(false); + }); + + it('accepts a box straddling a side plane', function () { + const frustum = createFrustum(); + + // the frustum reaches x = -50 at z = -50, so this box is half in and half out + expect(frustum.containsAabb(new BoundingBox(new Vec3(-50, 0, -50), new Vec3(5, 5, 5)))).to.equal(true); + }); + + it('accepts a box enclosing the whole frustum', function () { + const frustum = createFrustum(); + expect(frustum.containsAabb(new BoundingBox(new Vec3(0, 0, 0), new Vec3(500, 500, 500)))).to.equal(true); + }); + + it('agrees with containsPoint for a box of zero size', function () { + const frustum = createFrustum(); + for (const p of [new Vec3(0, 0, -50), new Vec3(0, 0, 10), new Vec3(90, 0, -50), new Vec3(-10, 5, -30)]) { + const box = new BoundingBox(p.clone(), new Vec3(0, 0, 0)); + expect(frustum.containsAabb(box), `${p.x},${p.y},${p.z}`).to.equal(frustum.containsPoint(p)); + } + }); + + it('is tighter than testing the box bounding sphere', function () { + const frustum = createFrustum(); + + // a long thin box well outside the frustum, whose bounding sphere is not - the sphere + // radius is the box diagonal, while the box only reaches one unit along the axis the + // side plane cares about + const box = new BoundingBox(new Vec3(-120, 0, -50), new Vec3(60, 1, 1)); + const sphere = new BoundingSphere(box.center.clone(), box.halfExtents.length()); + + expect(frustum.containsSphere(sphere)).to.be.greaterThan(0); + expect(frustum.containsAabb(box)).to.equal(false); + }); + + it('never reports a box visible that the bounding sphere test rejects', function () { + const frustum = createFrustum(); + const box = new BoundingBox(); + const sphere = new BoundingSphere(); + + let seed = 8675309; + const random = () => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed / 0x7fffffff; + }; + + let tighter = 0; + for (let i = 0; i < 20000; i++) { + box.center.set((random() * 2 - 1) * 150, (random() * 2 - 1) * 150, (random() * 2 - 1) * 150); + box.halfExtents.set(0.5 + random() * 20, 0.5 + random() * 4, 0.5 + random() * 4); + sphere.center.copy(box.center); + sphere.radius = box.halfExtents.length(); + + const byBox = frustum.containsAabb(box); + const bySphere = frustum.containsSphere(sphere) > 0; + expect(byBox && !bySphere, `box ${i} visible by the box test but not by the sphere test`).to.equal(false); + if (bySphere && !byBox) { + tighter++; + } + } + + // and it does reject boxes the sphere test admits + expect(tighter).to.be.greaterThan(0); + }); + }); + describe('#add', function () { it('contains points that were only inside the other frustum', function () { diff --git a/test/scene/renderer/shadow-renderer-local.test.mjs b/test/scene/renderer/shadow-renderer-local.test.mjs index 633b5e7a191..27c037245df 100644 --- a/test/scene/renderer/shadow-renderer-local.test.mjs +++ b/test/scene/renderer/shadow-renderer-local.test.mjs @@ -48,14 +48,18 @@ describe('ShadowRendererLocal', function () { /** * @param {Vec3} position - The caster position. - * @param {number} scale - The uniform caster scale. + * @param {number|Vec3} scale - The caster scale, uniform or per axis. * @returns {MeshInstance} The caster's mesh instance. */ const createCaster = (position, scale) => { const entity = new Entity(); entity.addComponent('render', { type: 'box' }); entity.setPosition(position); - entity.setLocalScale(scale, scale, scale); + if (scale instanceof Vec3) { + entity.setLocalScale(scale); + } else { + entity.setLocalScale(scale, scale, scale); + } app.root.addChild(entity); return entity.render.meshInstances[0]; }; @@ -99,15 +103,12 @@ describe('ShadowRendererLocal', function () { * @returns {Set[]} The visible casters per face. */ const referenceFaces = (light, casters) => { - const sphere = new BoundingSphere(); const faces = []; for (let face = 0; face < light.numShadowFaces; face++) { const camera = light.getRenderData(null, face).shadowCamera; const visible = new Set(); for (const caster of casters) { - sphere.center.copy(caster.aabb.center); - sphere.radius = caster._aabb.halfExtents.length(); - if (camera.frustum.containsSphere(sphere) > 0) { + if (camera.frustum.containsAabb(caster.aabb)) { visible.add(caster); } } @@ -117,8 +118,8 @@ describe('ShadowRendererLocal', function () { }; /** - * A caster is provably outside every face frustum when its bounding sphere does not reach the - * axis aligned cube the union of the six frusta is bounded by. Used to confirm the casters the + * A caster is provably outside every face frustum when its bounding box does not reach the axis + * aligned cube the union of the six frusta is bounded by. Used to confirm the casters the * single pass classification drops - but a per-plane frustum test keeps - genuinely cannot * render into any face. * @@ -130,14 +131,14 @@ describe('ShadowRendererLocal', function () { const shadowCam = light.getRenderData(null, 0).shadowCamera; const half = shadowCam.farClip * Math.tan(shadowCam.fov * 0.5 * Math.PI / 180); const center = caster.aabb.center; - const radius = caster._aabb.halfExtents.length(); + const halfExtents = caster._aabb.halfExtents; const lightPos = light._node.getPosition(); - let distance = 0; for (const axis of ['x', 'y', 'z']) { - const excess = Math.max(0, Math.abs(center[axis] - lightPos[axis]) - half); - distance += excess * excess; + if (Math.abs(center[axis] - lightPos[axis]) - halfExtents[axis] > half) { + return true; + } } - return distance > radius * radius; + return false; }; describe('#cull - omni', function () { @@ -193,6 +194,27 @@ describe('ShadowRendererLocal', function () { } }); + it('excludes an elongated caster from the faces only its bounding sphere reaches', function () { + const light = createLight(new Vec3(0, 0, 0)); + + // a long thin beam beside the light. Its bounding sphere has a radius of ~60, so a + // sphere based test reaches faces the box itself is nowhere near. + const caster = createCaster(new Vec3(70, 0, 0), new Vec3(120, 1, 1)); + + const faces = cullFaces(light, [caster]); + expect(faceIndices(faces, [caster])).to.eql([[0], [], [], [], [], []]); + + // the same caster's bounding sphere is not rejected by those other faces + const sphere = new BoundingSphere(caster.aabb.center.clone(), caster._aabb.halfExtents.length()); + let sphereFaces = 0; + for (let face = 0; face < 6; face++) { + if (light.getRenderData(null, face).shadowCamera.frustum.containsSphere(sphere) > 0) { + sphereFaces++; + } + } + expect(sphereFaces).to.be.greaterThan(1); + }); + it('places a caster straddling two faces in both of them', function () { const light = createLight(new Vec3(0, 0, 0));