Draft: Fast BoundingInfo

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:

  1. Allocation overhead. Each BoundingInfo eagerly constructs a BoundingBox
    (8 local Vector3 corners, 8 world Vector3 corners, center, extendSize,
    directions, etc.) and a BoundingSphere (center, centerWorld, minimum, maximum).
    That is ~40 Vector3 heap objects per mesh, putting GC pressure on scenes with
    many meshes.

  2. Algorithmic overhead. The BoundingBox._update method transforms all 8
    corners through the world matrix using Vector3.TransformCoordinatesToRef, then
    reduces them with minimizeInPlace / maximizeInPlace. The frustum check in
    BoundingBox.isInFrustum tests 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 Vector3 objects and two
    class instances down to a single Float32Array(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 the update() 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 the isInFrustum() hot path.
  • Maintain full structural API compatibility with BoundingInfo so that existing
    user code – including mesh.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 BoundingInfo as the engine default. This should be an opt-in optimization;
    users who do not import the patch keep the existing behavior.
  • Change ICullable or 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 return BoundingInfo exactly.
  • User code may store mesh bounds in variables, parameters, or generic
    constraints typed as BoundingInfo.
  • 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

  1. Optimize BoundingBox._update in place. Would improve transform speed but
    cannot reduce memory footprint without breaking the public BoundingBox API
    that exposes vectors, vectorsWorld, center, extendSize, etc. as
    Vector3 instances.

  2. 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.

  3. 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.

  4. 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:

  1. Captures local bounding box vectors from boundingInfo.boundingBox.vectors.
  2. For each thin instance, builds a temporary Matrix with
    Matrix.FromArrayToRef.
  3. Transforms all 8 local corners.
  4. 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 / .maximum into 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 rawBoundingInfo is used by
    thin-instance picking in ray.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.buildBoundingInfo
  • AbstractMesh._refreshBoundingInfo
  • AbstractMesh._refreshBoundingInfoDirect
  • AbstractMesh._updateBoundingInfo
  • SubMesh.refreshBoundingInfo
  • SubMesh.clone
  • Geometry.clone / Geometry.Parse only 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 normal BoundingInfo.
  • 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:

  • update
  • reConstruct
  • centerOn
  • encapsulate
  • encapsulateBoundingInfo
  • scale
  • isInFrustum
  • isCompletelyInFrustum
  • intersectsPoint
  • intersects
  • _checkCollision
  • lazy boundingBox / boundingSphere compatibility proxies
  • helper getters from Phase 3 without proxy creation

Compatibility Notes

  • boundingBox and boundingSphere proxies must be per-instance.
  • Already-returned proxy objects must stay live after updates.
  • vectorsWorld must materialize exact transformed local corners, not corners of
    the transformed world AABB.
  • isInFrustum and isCompletelyInFrustum use world AABB tests, so they are
    conservative compared with the current OBB corner tests.
  • Shared transient minimum / maximum are acceptable for internal Babylon
    call sites only if documented clearly and validated by code search.

Tests

  • Side-by-side correctness against current BoundingInfo for 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.boundingBox and
    const sphere = info.boundingSphere, call info.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.minimumWorldboundingInfo.minimumWorld
  • boundingInfo.boundingBox.maximumWorldboundingInfo.maximumWorld
  • boundingInfo.boundingSphere.centerWorldboundingInfo.centerWorld
  • boundingInfo.boundingSphere.radiusWorldboundingInfo.radiusWorld
  • boundingInfo.boundingBox.extendSizeWorldboundingInfo.extendSizeWorld
  • boundingInfo.boundingBox.vectors[index] → exact local-corner helper
  • boundingInfo.boundingBox.vectorsWorld[index] → exact world-corner helper

Current hot or repeated call sites include:

  • scene.ts world-extents aggregation
  • node.ts hierarchy bounding vectors
  • Rendering/renderingGroup.ts transparent distance sorting
  • PostProcesses/volumetricLightScatteringPostProcess.ts
  • Lights/Shadows/cascadedShadowGenerator.ts
  • Meshes/mesh.ts bounding aggregation
  • Culling/boundingInfo.ts internal methods
  • Lights/directionalLight.ts shadow auto-extend projection
  • Particles/solidParticleSystem.ts particle-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 vectorsWorld OBB 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 as boundingInfo.computeTransformedBoundingBoxMinMaxToRef(matrix, min, max)
      where matrix is the combined local-to-light-view transform. The default
      BoundingInfo implementation can either forward to current corner logic or
      use the same direct AABB transform; FastBoundingInfo can 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).
  • 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.vectors are only the
      eight combinations of local minimum / 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.

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_only
    • frustum_standard
    • transparent sort
    • scene/node extents aggregation
    • exact vectorsWorld reads for shadows/debug paths
  • Directional-light shadow auto-extend tests comparing ortho left/right/top/bottom
    and optional z bounds against the current vectorsWorld implementation within
    epsilon.
  • Solid-particle intersection tests comparing per-particle bounding box min/max
    against the current boundingBox.vectors implementation 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.

5 Likes

The (AI?) text mentions “measured speedup”. So does that mean you have working code? If so, can you put it into some benchmark playgrounds? I think this would strongly strengthen your case for a PR.

You refer to this textual, non-code post, here, right? The actual code is/will be written entirely be you or taken from known human sources**?

**FYI: this is for copyright reasons. If AI used copyrighted code (e.g. proprietary, GPL), but this is not disclosed by AI, and this code ends up in the Babylon repo, there would be a copyright violation. Worse, AFAIK, we users would “inherit” this violation.

Tested with nodejs for the algorithm, code is generated by AI.
Node.js 22.16.0 (x64)

Test: Arvo vs 8-corner (identity) - OK
Test: Arvo vs 8-corner (translation) - OK
Test: Arvo vs 8-corner (scaling) - OK
Test: Arvo vs 8-corner (90° rotation) - OK
Test: Arvo vs 8-corner (SRT) - OK
Test: frustum check (inside) - OK
Test: frustum check (outside) - OK
Test: frustum check (partial) - OK
Test: random stress (10000 transforms) - OK

Benchmark (1000000 transforms):
  8-corner: 412.30 ms
  Arvo:     103.93 ms
  Speedup:  3.97x

Benchmark (1000000 frustum checks):
  8-corner: 399.25 ms
  p-vertex: 41.55 ms
  Speedup:  9.61x

34 passed, 0 failed

test-fast-bounding-info-standalone.zip (2.9 KB)

3 Likes

I explictly asked AI to reference code of cglm to make this test, which is MIT licensed.

Nice idea. I really like the plan so far

Just for reference, three.js uses 8-corner AABB transform with P-vertex frustum test

Another thing to consider that there is so many code accessed BoundingInfo.boundingBox for only minimumWorld and maximumWorld, and BoundingInfo.boundingSphere for only centerWorld and center, this proposal would have more performance gain after these moved to direct getters on BoundingInfo

1 Like

You are proposing some drastic gains, I hope this is valid and can land.

Given the purposed speed increase this should be given some consideration because it’s impact would be massive.

Wanna do a PR?

I’m kind of hesitate on this, adding getters makes the code using BoundingInfo less dependent on underlying boundingBox/ boundingSphere, allowing more opportunity for future optimizations like this draft, but adding getters adds more performance cost before optimizations being done, and could make existing code slower because of that. Modern jit can inline these small functions but it’s not stable, deoptimizations happen all the time, no #[inline(always)] or @ForceInline instruction can ensure it, so potential performance loss would be expected.

1 Like

Now I’ve splitted it into 5 phases, so maybe this could be started from phase 1, thinInstanceRefreshBoundingInfo

1 Like