Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/core/shape/frustum.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
*/
Expand Down Expand Up @@ -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 };
8 changes: 2 additions & 6 deletions src/scene/mesh-instance.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -44,7 +43,6 @@ import { PickerId } from './picker-id.js';

const _tmpAabb = new BoundingBox();
const _tempBoneAabb = new BoundingBox();
const _tempSphere = new BoundingSphere();

/** @type {Set<Mesh>} */
const _meshSet = new Set();
Expand Down Expand Up @@ -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;
Expand Down
72 changes: 40 additions & 32 deletions src/scene/renderer/shadow-renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
75 changes: 75 additions & 0 deletions test/core/shape/frustum.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 () {
Expand Down
48 changes: 35 additions & 13 deletions test/scene/renderer/shadow-renderer-local.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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];
};
Expand Down Expand Up @@ -99,15 +103,12 @@ describe('ShadowRendererLocal', function () {
* @returns {Set<MeshInstance>[]} 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);
}
}
Expand All @@ -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.
*
Expand All @@ -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 () {
Expand Down Expand Up @@ -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));

Expand Down