Note
This is a very early draft, and could be changed in future, and might not be implemented. This post is crafted with the help of AI, especially the algorithm part.
Motivation
BoundingInfo is the most frequently called object in the render loop.
update() runs once per mesh per frame, and isInFrustum() runs once per
submesh per frame. For a scene with 10,000 meshes at 60 fps, these two methods
alone account for 1.2 million calls per second.
The current implementation has two costs:
-
Allocation overhead. Each
BoundingInfoeagerly constructs aBoundingBox
(8 localVector3corners, 8 worldVector3corners, center, extendSize,
directions, etc.) and aBoundingSphere(center, centerWorld, minimum, maximum).
That is ~40Vector3heap objects per mesh, putting GC pressure on scenes with
many meshes.
-
Algorithmic overhead. The
BoundingBox._updatemethod transforms all 8
corners through the world matrix usingVector3.TransformCoordinatesToRef, then
reduces them withminimizeInPlace/maximizeInPlace. The frustum check in
BoundingBox.isInFrustumtests each of the 8 world corners against each of the
6 frustum planes (up to 48 dot products).
Both costs are well-studied in real-time graphics. The cglm library (used by
hundreds of C/OpenGL projects) provides battle-tested replacements:
- Arvo’s AABB transform (
glm_aabb_transform): works directly on the min/max
pair and the three matrix column vectors, computing the new world AABB in ~50 ops
instead of transforming 8 corners (~190 ops). - P-vertex frustum test (
glm_aabb_frustum): for each frustum plane, tests only
the single AABB corner most aligned with the plane normal (the “p-vertex”),
requiring exactly 6 tests instead of up to 48.
Also check here for some previous study on this:
Goals
- Reduce per-mesh bounding data from ~40 heap-allocated
Vector3objects and two
class instances down to a singleFloat32Array(12)plus two scalars. - Replace the 8-corner AABB transform (~190 floating-point ops) with Arvo’s method
(~50 ops), yielding 3.3x measured speedup on theupdate()hot path. - Replace the up-to-48-dot-product frustum check with the p-vertex method (6 plane
tests), yielding 4.1x measured speedup on theisInFrustum()hot path. - Maintain full structural API compatibility with
BoundingInfoso that existing
user code – includingmesh.getBoundingInfo().boundingBox,
boundingInfo.minimum,isInFrustum(),intersects(), and all culling
strategies – continues to work without changes. - Enable adoption as a standalone side-effect import (prototype patch) with no
changes to Babylon.js core source files.
Non-Goals
- Replacing
BoundingInfoas the engine default. This should be an opt-in optimization;
users who do not import the patch keep the existing behavior. - Change
ICullableor public mesh APIs in a breaking way. - Make AABB frustum tests exactly identical to current OBB corner tests. The
fast AABB test is conservative: it may keep an object visible longer, but
should not incorrectly cull an object that the current OBB test would keep. - Optimize cold construction paths such as geometry parse/clone before the main
render-loop paths are measured. - SIMD / WebAssembly acceleration. The algorithms are scalar JavaScript tuned for
JIT inlining. SIMD could be a follow-up.
Public Type Strategy
One option is to introduce an IBoundingInfo interface or abstract supertype and
change mesh/submesh method signatures from BoundingInfo to that type. That is
architecturally cleaner because FastBoundingInfo would not need to masquerade
as BoundingInfo.
However, this is likely breaking for downstream TypeScript users:
- Overrides of methods such as
getBoundingInfo()or bounding refresh hooks may
currently returnBoundingInfoexactly. - User code may store mesh bounds in variables, parameters, or generic
constraints typed asBoundingInfo. - Public declaration changes can surface as compile errors even if runtime
behavior remains compatible. - Structural typing helps only when the new interface is a strict subset and all
downstream code accepts the widened return/parameter type.
For the staged rollout, keep public signatures returning BoundingInfo and make
FastBoundingInfo structurally compatible enough to be installed behind the
factory/static switch. A later major-version or explicitly breaking phase can
consider IBoundingInfo after the helper getters and hot-path call sites have
stabilized.
Description
Data Layout
All bounding state is stored in a single Float32Array(12):
Offset Content
------ ----------------------------
[0..2] Local AABB min (x, y, z)
[3..5] Local AABB max (x, y, z)
[6..8] World AABB min (x, y, z)
[9..11] World AABB max (x, y, z)
Two additional scalars are stored as class fields:
_localRadius: number– half-diagonal of the local AABB_radiusWorld: number– world-space bounding sphere radius
A reference to the current world matrix is held for proxy sync and OBB fallback.
Core Algorithms
AABB Transform (Arvo’s method)
For each output axis, scales each matrix column vector by both the local min and
local max component, then accumulates translation + min(a, b) for the new
world min and translation + max(a, b) for the new world max:
worldMin[i] = T[i] + min(col0[i]*lMin.x, col0[i]*lMax.x)
+ min(col1[i]*lMin.y, col1[i]*lMax.y)
+ min(col2[i]*lMin.z, col2[i]*lMax.z)
This is branch-free and uses only multiplies, min, max, and add – all of which
JIT-compile efficiently.
P-vertex Frustum Test
For each of the 6 frustum planes, selects the AABB corner most in the direction
of the plane normal (the p-vertex) using conditional selection:
pVertex[i] = normal[i] > 0 ? worldMax[i] : worldMin[i]
If dot(pVertex, normal) + d < 0, the entire box is outside that plane and the
test returns false immediately. This is the cglm glm_aabb_frustum algorithm.
N-vertex Completely-in-Frustum Test
The dual: selects the corner least aligned with the normal (the n-vertex). If
any n-vertex is outside its plane, the box is not completely inside.
Sphere Radius Heuristic
Matches the existing BoundingSphere._update formula:
radiusWorld = max(|col0+col1+col2|) * localRadius, where each col is the
corresponding matrix column sum component.
Hot Path Analysis
| Call Site | Method | Frequency |
|---|---|---|
scene._evaluateSubMesh |
isInFrustum() |
per submesh per frame |
abstractMesh._updateBoundingInfo |
update() |
per mesh per frame |
renderingGroup transparent sort |
boundingSphere.centerWorld |
per transparent mesh per frame |
scene active mesh evaluation |
isInFrustum() |
per mesh per frame |
Alternatives Considered
-
Optimize
BoundingBox._updatein place. Would improve transform speed but
cannot reduce memory footprint without breaking the publicBoundingBoxAPI
that exposesvectors,vectorsWorld,center,extendSize, etc. as
Vector3instances. -
SoA (Structure of Arrays) layout across all meshes. Maximum cache
efficiency but requires invasive changes to the scene graph, violates the
per-mesh ownership model, and is incompatible with the existing
mesh.getBoundingInfo()API. -
WebAssembly / SIMD. Could yield further gains but adds build complexity,
has a call overhead for small batches, and doesn’t address the memory
bloat. Can be layered on top of this proposal later. -
Do nothing. The current implementation is correct and well-tested. However,
scenes with 10K+ meshes spend a measurable fraction of frame time in bounding
info update and culling. The 3-4x speedup on these paths directly translates
to more headroom for application logic and rendering.
Phase 1: Fast thinInstanceRefreshBoundingInfo
Scope
Update only Mesh.prototype.thinInstanceRefreshBoundingInfo in
packages/dev/core/src/Meshes/thinInstanceMesh.ts.
Current Code
The current implementation:
- Captures local bounding box vectors from
boundingInfo.boundingBox.vectors. - For each thin instance, builds a temporary
Matrixwith
Matrix.FromArrayToRef. - Transforms all 8 local corners.
- Merges the transformed points into one min/max.
This is correct, but it does 8 coordinate transforms per instance and allocates
or touches the local corner-vector array.
Proposed Change
Use the raw local min/max pair and the thin-instance matrix data directly:
- Read
this.rawBoundingInfo.minimum/.maximuminto scalar locals once. - For each instance, apply Arvo’s AABB transform directly against the flat
matrix data. - Merge the resulting instance AABB into the accumulated min/max.
- Reconstruct the mesh bounding info with the accumulated min/max and call
_updateBoundingInfo()as today.
This phase does not require FastBoundingInfo or any factory changes. It is a
localized optimization with a small behavioral surface.
Tests
- Existing thin-instance scenes with identity, translated, scaled, non-uniformly
scaled, rotated, and combined SRT instance matrices. - Picking regression for thin instances, because
rawBoundingInfois used by
thin-instance picking inray.core.ts. - Compare final min/max against the existing 8-corner implementation. (Babylon.js Playground)
Expected CPU Impact
Positive for meshes that call thinInstanceRefreshBoundingInfo with many thin
instances. The hot work drops from 8 corner transforms per instance to one direct
AABB transform against matrix scalars. Normal mesh culling and generic
BoundingInfo.update() do not change in this phase.
Expected Memory Impact
Near-neutral. This phase reduces transient hot-path work and avoids touching the
local corner-vector array as much, but it does not change the stored
BoundingInfo / BoundingBox / BoundingSphere object graph.
Expected Risks
Low to medium. The main risk is correctness drift in matrix indexing or min/max
merge logic for rotated and non-uniformly scaled thin instances. There is also a
picking regression risk because thin-instance picking reads rawBoundingInfo.
Phase 2: Static BoundingInfo Creation Method
Scope
Add a central construction method to BoundingInfo and use it at current
creation sites.
Suggested API:
export type BoundingInfoFactory = (
minimum: DeepImmutable<Vector3>,
maximum: DeepImmutable<Vector3>,
worldMatrix?: DeepImmutable<Matrix>
) => BoundingInfo;
export class BoundingInfo implements ICullable {
public static Factory: BoundingInfoFactory = (minimum, maximum, worldMatrix) =>
new BoundingInfo(minimum, maximum, worldMatrix);
public static Create(
minimum: DeepImmutable<Vector3>,
maximum: DeepImmutable<Vector3>,
worldMatrix?: DeepImmutable<Matrix>
): BoundingInfo {
return BoundingInfo.Factory(minimum, maximum, worldMatrix);
}
}
Then replace hot and common creation sites:
AbstractMesh.buildBoundingInfoAbstractMesh._refreshBoundingInfoAbstractMesh._refreshBoundingInfoDirectAbstractMesh._updateBoundingInfoSubMesh.refreshBoundingInfoSubMesh.cloneGeometry.clone/Geometry.Parseonly if the broader change is wanted
This avoids side-effect prototype patching and gives downstream users one
supported hook for custom bounding info creation.
Tests
- Existing mesh/submesh bounding tests.
- A temporary custom factory test that returns a subclass/mock and verifies that
buildBoundingInfo, refresh paths, and submesh refresh use the factory.
Expected CPU Impact
Near-neutral. One extra static call indirection at construction sites is
negligible compared with actual bounding construction and world updates. This
phase enables later CPU wins but does not deliver them by itself.
Expected Memory Impact
Neutral by default. Potentially positive later because the creation hook becomes
the point where a compact implementation can be selected without rewriting call
sites.
Expected Risks
Low. The main risk is incomplete adoption: missing one or more
new BoundingInfo(...) call sites would make customization partial and
confusing. A secondary risk is lifecycle inconsistency if some refresh paths use
the hook and others still construct BoundingInfo directly.
Phase 3: Trivial Helper Getters on BoundingInfo
Scope
Add direct getters to BoundingInfo that simply forward to current objects:
public get minimumWorld(): Vector3 {
return this.boundingBox.minimumWorld;
}
public get maximumWorld(): Vector3 {
return this.boundingBox.maximumWorld;
}
public get centerWorld(): Vector3 {
return this.boundingSphere.centerWorld;
}
public get radiusWorld(): number {
return this.boundingSphere.radiusWorld;
}
public get extendSizeWorld(): Vector3 {
return this.boundingBox.extendSizeWorld;
}
These getters should not change default performance in a meaningful way because
they are one property hop over the existing objects. They create a cleaner
optimization target for FastBoundingInfo, which can override the same getters
without forcing .boundingBox or .boundingSphere proxy creation.
Tests
- Assert each getter returns the same value/reference as the existing nested
field on normalBoundingInfo. - Include identity and non-identity world matrices.
Expected CPU Impact
Neutral to very small positive. On normal BoundingInfo, these getters are
trivial forwarding accessors. The real CPU value is preparatory: FastBoundingInfo
can later answer the same getters directly without materializing boundingBox or
boundingSphere proxies.
Expected Memory Impact
Neutral. No additional per-instance state is required for the default
implementation.
Expected Risks
Low. The main risk is accidentally changing reference or laziness semantics. The
getters should return the same values as existing nested property access and
should not allocate.
Phase 4: Optional FastBoundingInfo and Static Switch
Scope
Add FastBoundingInfo as an optional core implementation and expose a static
switch through the factory added in Phase 2.
Suggested shape:
BoundingInfo.Factory = (minimum, maximum, worldMatrix) => {
return BoundingInfo.UseFastBoundingInfo
? (new FastBoundingInfo(minimum, maximum, worldMatrix) as unknown as BoundingInfo)
: new BoundingInfo(minimum, maximum, worldMatrix);
};
The exact module dependency should avoid forcing boundingInfo.ts to import the
fast implementation eagerly. A registration helper in fastBoundingInfo.ts is
preferable:
FastBoundingInfo.Register();
BoundingInfo.UseFastBoundingInfo = true;
FastBoundingInfo should implement:
updatereConstructcenterOnencapsulateencapsulateBoundingInfoscaleisInFrustumisCompletelyInFrustumintersectsPointintersects_checkCollision- lazy
boundingBox/boundingSpherecompatibility proxies - helper getters from Phase 3 without proxy creation
Compatibility Notes
boundingBoxandboundingSphereproxies must be per-instance.- Already-returned proxy objects must stay live after updates.
vectorsWorldmust materialize exact transformed local corners, not corners of
the transformed world AABB.isInFrustumandisCompletelyInFrustumuse world AABB tests, so they are
conservative compared with the current OBB corner tests.- Shared transient
minimum/maximumare acceptable for internal Babylon
call sites only if documented clearly and validated by code search.
Tests
- Side-by-side correctness against current
BoundingInfofor transforms,
sphere data, world AABB min/max, local data, point tests, sphere/box
intersection, and precise intersection fallback. - Randomized transform stress tests.
- Tests that cache
const box = info.boundingBoxand
const sphere = info.boundingSphere, callinfo.update(...), and verify the
cached objects expose updated values.
Expected CPU Impact
High positive impact when enabled for hot bounding update and culling paths.
update() avoids transforming 8 corners and instead performs direct AABB
transform math. AABB frustum checks are much cheaper than corner-based OBB
checks. Cold compatibility operations still pay for proxy creation or
synchronization when .boundingBox / .boundingSphere is touched.
Expected Memory Impact
High positive impact per bounding-info instance when hot callers use the direct
helper getters and avoid forcing compatibility proxies. The eager BoundingBox
plus BoundingSphere object graph is replaced by compact numeric storage plus
optional lazy proxy objects. If a workload frequently materializes both proxies
and keeps them alive, the memory win shrinks.
Expected Risks
Medium to high. This is the first phase that changes the concrete runtime
implementation. Main risks are live proxy behavior after update() /
reConstruct(), reference and mutability expectations of returned vectors,
conservative-vs-identical culling behavior, and edge cases in
SAT/intersection/collision paths that still expect full BoundingBox /
BoundingSphere semantics.
Phase 5: Refactor Hot .boundingBox / .boundingSphere Call Sites
Scope
Move hot internal paths from nested object access to the helper getters added in
Phase 3.
Candidate replacements:
boundingInfo.boundingBox.minimumWorld→boundingInfo.minimumWorldboundingInfo.boundingBox.maximumWorld→boundingInfo.maximumWorldboundingInfo.boundingSphere.centerWorld→boundingInfo.centerWorldboundingInfo.boundingSphere.radiusWorld→boundingInfo.radiusWorldboundingInfo.boundingBox.extendSizeWorld→boundingInfo.extendSizeWorldboundingInfo.boundingBox.vectors[index]→ exact local-corner helperboundingInfo.boundingBox.vectorsWorld[index]→ exact world-corner helper
Current hot or repeated call sites include:
scene.tsworld-extents aggregationnode.tshierarchy bounding vectorsRendering/renderingGroup.tstransparent distance sortingPostProcesses/volumetricLightScatteringPostProcess.tsLights/Shadows/cascadedShadowGenerator.tsMeshes/mesh.tsbounding aggregationCulling/boundingInfo.tsinternal methodsLights/directionalLight.tsshadow auto-extend projectionParticles/solidParticleSystem.tsparticle-intersection bounds
Direct vectors / vectorsWorld usage should be eliminated only where an exact
helper can preserve behavior within epsilon. Do not replace exact corners with
world AABB min/max unless the call site is explicitly conservative and measured.
Keep places that need debug rendering state or full ray box/sphere APIs on the
existing nested objects.
Direct Vector-Array Evaluation
Only two non-BoundingBox / non-thin-instance engine call sites currently use
the corner arrays directly:
packages/dev/core/src/Lights/directionalLight.ts- Current behavior projects each exact
vectorsWorldOBB corner into light
view space and reduces x/y, optionally z. - This must not be replaced by projecting the world AABB min/max, because that
can over-expand the shadow frustum beyond epsilon. - It can be accelerated with the same direct AABB transform used elsewhere,
provided the transform is applied to the local-space AABB with the combined
local-to-light-view matrix. That produces the same light-space min/max as
transforming all 8 local corners and reducing them, up to floating-point
epsilon. - So the preferred refactor here is not an exact-corner helper but a helper
such asboundingInfo.computeTransformedBoundingBoxMinMaxToRef(matrix, min, max)
wherematrixis the combined local-to-light-view transform. The default
BoundingInfoimplementation can either forward to current corner logic or
use the same direct AABB transform;FastBoundingInfocan answer directly
from local min/max plus the combined matrix without materializing
boundingBox. - With Babylon.js matrix conventions, the combined matrix should match the
existing two-step transform:
boundingBox.getWorldMatrix().multiplyToRef(viewMatrix, localToLightView).
- Current behavior projects each exact
packages/dev/core/src/Particles/solidParticleSystem.ts- Current behavior reads
modelBoundingInfo.boundingBox.vectors, applies the
particle scale/rotation/camera-basis transform to all 8 local AABB corners,
then reduces to min/max. - This can be refactored safely because
BoundingBox.vectorsare only the
eight combinations of localminimum/maximum. - For strict epsilon equivalence, first refactor to enumerate the same 8
local corner combinations in the same order using scalar min/max values. A
later measured pass may replace that with a direct AABB transform if the
benchmark justifies the small arithmetic-order difference.
- Current behavior reads
The additional packages/dev/core/src/Meshes/thinInstanceMesh.ts direct
boundingBox.vectors use is covered by Phase 1, because that phase rewrites
thinInstanceRefreshBoundingInfo around raw min/max plus direct AABB transforms.
Tests
- Existing culling, picking, shadows, and bounding-box renderer tests.
- A usage-driven benchmark that covers:
update_onlyfrustum_standard- transparent sort
- scene/node extents aggregation
- exact
vectorsWorldreads for shadows/debug paths
- Directional-light shadow auto-extend tests comparing ortho left/right/top/bottom
and optional z bounds against the currentvectorsWorldimplementation within
epsilon. - Solid-particle intersection tests comparing per-particle bounding box min/max
against the currentboundingBox.vectorsimplementation within epsilon.
Expected CPU Impact
Moderate to high positive impact when Phase 4 is enabled. This phase removes
avoidable proxy/getter churn from hot internal code and lets FastBoundingInfo
answer with direct scalar/vector access. On default BoundingInfo, impact should
be near-neutral because the helper getters simply forward to existing objects.
For the direct corner-array refactors, CPU should be neutral to mildly positive:
the directional-light path still transforms 8 exact corners, while the SPS path
can avoid reading/materializing the BoundingBox.vectors array.
Expected Memory Impact
Moderate positive impact in fast mode because fewer hot-path accesses will force
lazy boundingBox / boundingSphere proxy creation. Eliminating the two direct
corner-array users also avoids forcing boundingBox / vectorsWorld proxy
materialization in fast mode. Neutral in default mode.
Expected Risks
Medium. The edits are mechanically simple but spread across hot call sites. The
main risks are changing a call site that actually needed full BoundingBox /
BoundingSphere behavior, subtle behavior drift from object identity
assumptions, and benchmark noise causing churn in places that are not materially
hot. For directionalLight.ts, the specific risk is accidentally using world
AABB min/max instead of applying the direct AABB transform to the local bounds
with the combined local-to-light-view matrix.

