Per instance bbox culling for thin instance picking

Proposal: per-instance bounding-info culling for thin-instance picking

Summary

The current thin-instance picking flow performs one whole-mesh broad-phase in InternalPick() and InternalMultiPick(), then iterates every thin instance and calls the picker with bounding-info checks skipped for each instance. That means dense source meshes can still pay the full submesh and triangle cost for many thin instances that an instance-level broad-phase would have rejected immediately.

This proposal updates the thin-instance path so that, when thinInstanceEnablePicking is true and rawBoundingInfo is available, the engine always runs a per-instance sphere-plus-box precheck against the source-only bounds before entering triangle intersection. There is no new property, no threshold, and no additional opt-in. If rawBoundingInfo is missing, the precheck is skipped and the current behavior is preserved, so there is no risk of false negatives from missing source bounds.

The implementation should use the existing local-ray approach already available through rayFunction, and it must mirror the intersectionThreshold handling used by AbstractMesh.intersects() for standard line meshes so thin-instance line meshes are not rejected incorrectly. One upstream caveat is that GreasedLineMesh now has a custom pick path, so it should not be treated as a drop-in match for the standard AbstractMesh.intersects() broad-phase without separate verification.

Current behavior

Today the thin-instance path in InternalPick() works like this:

  1. Run a whole-mesh bounding-info check once through the picker.
  2. If that broad-phase passes, iterate all thin-instance matrices.
  3. Build the combined instance world matrix from the thin-instance matrix and the mesh world matrix.
  4. Call the picker again for each instance, but with bounding-info checks bypassed so the code reaches AbstractMesh.intersects() in skip-bounds mode.
  5. Let submesh tests and triangle tests decide the hit.

That is correct, but it can do unnecessary work for thin instances that are trivially outside the ray.

Goal

Add a reject-only per-instance broad-phase before the expensive submesh and triangle walk, while preserving the existing public API, avoiding false negatives, and leaving behavior unchanged when source-only bounds are unavailable.

Updated behavior

The updated behavior is:

  • Keep existing thinInstanceEnablePicking unchanged as the only public gate.
  • When thin-instance picking is active and rawBoundingInfo is available, always run the per-instance broad-phase.
  • Do not add a new mesh property.
  • Do not add a vertex-count threshold.
  • Do not add an extra opt-in.
  • If rawBoundingInfo is missing, skip the precheck and preserve current behavior.
  • Use the instance-local ray already built through rayFunction.
  • Inflate the sphere and box checks with the same intersectionThreshold logic used by AbstractMesh.intersects() for the standard mesh and lines-mesh path.
  • Do not silently substitute getRawBoundingInfo() for rawBoundingInfo in the new helper, because that upstream helper falls back to aggregated bounds when source-only bounds are missing.

Decision rationale

  • a bounding sphere plus box test is extremely cheap,
  • rejecting even one thin instance early usually repays the full cost of the extra prechecks,
  • the downside on simple meshes is theoretical and expected to be lost in the noise compared with even a single unnecessary triangle walk,
  • adding a new property or threshold would complicate the API and documentation for an optimization that should help by default.

So the plan is to treat the precheck as a low-cost reject-only optimization, not as a feature that needs new configuration.

Recommended implementation approach

Prefer local-ray plus raw bounds over world-space AABB construction

The first implementation should not build a transformed world-space AABB from 8 corners for every thin instance.

A cheaper and cleaner path already exists in the picking pipeline:

So the recommended precheck is:

  1. Get the instance matrix exactly as today in InternalPick() and InternalMultiPick().
  2. Build the same instance-local ray that the picker would use by calling rayFunction(world, mesh.enableDistantPicking).
  3. Resolve the same intersectionThreshold that AbstractMesh.intersects() would use for that mesh.
  4. Test that local ray against the raw bounding sphere and raw bounding box.
  5. Only if that precheck passes, call the picker with skip-bounds mode and let triangle picking continue.

This reuses the existing picking math, avoids any per-instance corner-transform loop, and keeps the new broad-phase aligned with the normal mesh broad-phase. For the built-in picker path, it is worth structuring the helper so the same local ray can be reused for both the precheck and the subsequent mesh.intersects() call instead of recomputing it twice.

Where the checks should live

The broad-phase should live in packages/dev/core/src/Culling/ray.core.ts, directly in the thin-instance loops inside InternalPick() and InternalMultiPick().

Recommended structure:

  1. Keep the existing whole-mesh broad-phase exactly as it is today.
  2. Resolve mesh.rawBoundingInfo once for the mesh after the whole-mesh pass succeeds.
  3. For each thin instance:
    • apply the mesh-instance predicate,
    • build the combined matrix,
    • if rawBoundingInfo exists, build the local ray and run the raw sphere-plus-box precheck,
    • skip triangle picking if that precheck fails,
    • otherwise keep the current picker call with skip-bounds enabled.

If it helps reduce duplication, the raw-bounds precheck can be factored into a small internal helper in ray.core.ts and reused by both thin-instance loops.

Runtime condition

There is no new heuristic beyond what already exists.

Conceptually, once the code is already inside the thin-instance picking branch, the only runtime question is whether source-only bounds are available:

const rawBounds = mesh.rawBoundingInfo;
const usePerInstanceBounds = !!rawBounds;

The full decision remains:

if (mesh.hasThinInstances && mesh.thinInstanceEnablePicking) {
    // existing whole-mesh broad-phase
    // ...
    if (rawBounds) {
        // per-instance raw sphere+box precheck
    }
}

If rawBoundingInfo is absent, the engine simply falls back to the current behavior for that mesh.

Raw-bounds source and fallback behavior

The new precheck must use source-only bounds, not the aggregated thin-instance bounds.

Relevant existing behavior:

Recommended fallback rules:

  • If source raw bounds are available, use them.
  • If source raw bounds are missing, do not try to derive per-instance culling from the aggregated mesh bounds.
  • Instead, skip the new precheck and preserve current behavior for that mesh.

That avoids false negatives and avoids using overly large aggregated bounds that would provide little or no benefit.

Broad-phase details

The new per-instance precheck should mirror the existing mesh broad-phase in AbstractMesh.intersects() for the normal mesh and lines-mesh path:

  • use the same intersectionThreshold logic used there today,
  • test the local ray against the raw bounding sphere first,
  • then test against the raw bounding box,
  • continue to triangle picking only if both pass.

Conceptually:

const threshold =
    className === "InstancedLinesMesh" || className === "LinesMesh"
        ? (mesh as any).intersectionThreshold
        : 0;

if (!localRay.intersectsSphere(rawBounds.boundingSphere, threshold) || !localRay.intersectsBox(rawBounds.boundingBox, threshold)) {
    continue;
}

The important correctness detail is that LinesMesh and InstancedLinesMesh need the same threshold inflation here that they already get in AbstractMesh.intersects(). Without that, line-mesh thin instances could be rejected before their actual picking path has a chance to run.

GreasedLineMesh is not on the standard broad-phase path

GreasedLineMesh now overrides intersects() and routes picking through width-aware segment tests in findAllIntersections(). Its broad-phase behavior is therefore not the same as the standard AbstractMesh.intersects() sphere-plus-box path:

  • its onlyBoundingInfo check uses a sphere test, not the standard sphere-plus-box pair,
  • its actual segment precision scales with line width and per-segment width, not just the plain intersectionThreshold.

Because of that, the proposal should not treat GreasedLineMesh as automatically covered by the generic raw sphere-plus-box helper. The conservative implementation choices are:

  • either limit the first pass of this optimization to meshes that follow the standard AbstractMesh.intersects() broad-phase,
  • or add a GreasedLine-specific precheck only if it is proven to match the custom width-aware picking semantics without false negatives.

Behavior and compatibility requirements

Preserve current public API

This proposal intentionally introduces no new public property and no new scene-setting behavior. Existing thinInstanceEnablePicking semantics remain unchanged.

Preserve onlyBoundingInfo

The current thin-instance path returns early after the whole-mesh broad-phase when only bounding info is requested. The proposal keeps that unchanged. The new per-instance precheck is not needed in that path.

Preserve fastCheck

Once a valid pick is found, fast mode should still return immediately. The precheck only reduces the number of expensive instance tests that are attempted before that point.

Preserve predicate behavior

The mesh-instance predicate currently runs before each thin-instance pick attempt. That ordering must remain unchanged.

Preserve pick data

The following result fields must remain correct:

  • thin instance index,
  • picked point,
  • distance,
  • submesh id,
  • picked mesh reference.

Custom picker compatibility

PickingCustomization can override the picker used for meshes. The new per-instance broad-phase should still run before invoking that picker when rawBoundingInfo is available, because it is a reject-only optimization. If raw bounds are not available, the custom picker should continue to see the current behavior unchanged.

Public API and serialization

No new public API is proposed.

That means:

Validation plan

Correctness tests

Add tests that compare current behavior and the new behavior in the cases where the precheck should and should not apply.

Coverage should include:

  • thin instances translated away from the ray,
  • thin instances rotated and non-uniformly scaled,
  • negative scaling or mirrored transforms,
  • dense meshes and low-vertex meshes,
  • hit and miss parity,
  • preservation of thin-instance indices,
  • preservation of fastCheck,
  • preservation of onlyBoundingInfo,
  • fallback behavior when rawBoundingInfo is missing,
  • LinesMesh or InstancedLinesMesh with nonzero intersectionThreshold,
  • any GreasedLineMesh thin-instance case that is either explicitly excluded from the new helper or covered by a separate verified implementation.

The critical guarantee is no false negatives when the new precheck is active, including the standard line-mesh threshold case and any explicit GreasedLine handling.

Performance validation

Benchmark at least these scenarios:

  1. Dense source mesh, many thin instances, low hit ratio.
  2. Dense source mesh, many thin instances, high hit ratio.
  3. Low-vertex source mesh with a small thin-instance count.
  4. Low-vertex source mesh with many thin instances, to confirm the always-on precheck is still neutral or beneficial in practice.
  5. A case where rawBoundingInfo is absent, to confirm behavior and cost stay aligned with the current path.

Measure time spent in scene picking paths that flow through InternalPick() and InternalMultiPick().

Non-goals

This proposal does not aim to:

  • add a new per-mesh thin-instance picking property,
  • add a vertex-count threshold or heuristic gate,
  • introduce a global static switch for the optimization,
  • add per-instance cached bounding boxes or spheres,
  • redesign non-thin-instance picking in AbstractMesh.intersects(),
  • replace the current triangle picking path with a new acceleration structure.

Risks

Key risks for implementation are:

  • false negatives if the precheck accidentally uses aggregated thin-instance bounds instead of rawBoundingInfo,
  • false negatives for LinesMesh or InstancedLinesMesh if the intersectionThreshold inflation does not match AbstractMesh.intersects(),
  • false negatives for GreasedLineMesh if it is forced through the generic helper without matching its width-aware pick semantics,
  • accidental use of AbstractMesh.getRawBoundingInfo() in the helper, which would silently convert the intended rawBoundingInfo-missing fallback into an aggregated-bounds precheck,
  • stale source bounds if updates around Mesh.prototype.thinInstanceRefreshBoundingInfo() are bypassed or not synchronized with source geometry changes,
  • compatibility surprises for custom pickers wired through PickingCustomization if the reject-only helper changes ordering incorrectly,
  • small regressions in pathological all-hit or tiny-mesh scenes where the extra broad-phase prunes very little work.

Performance impact

Expected CPU impact:

  • positive for dense source meshes with many thin instances and low hit ratios, because fewer instances reach the expensive submesh and triangle tests,
  • positive or neutral for many practical scenes because the added sphere-plus-box test is very cheap and rejecting even one instance tends to pay it back,
  • theoretically slightly negative in tiny-mesh or all-hit cases, but expected to be noise compared with even a single unnecessary triangle walk,
  • unchanged when rawBoundingInfo is missing,
  • unchanged for onlyBoundingInfo, because that path already exits after the whole-mesh broad-phase.

Memory impact

Expected memory impact:

  • near-zero steady-state increase because the implementation reuses existing rawBoundingInfo,
  • no new property on Mesh,
  • no new per-instance arrays in thin-instance storage,
  • no meaningful hot-path allocation increase if the implementation reuses the existing temporary math objects already used by the picking flow in packages/dev/core/src/Culling/ray.core.ts.

Mermaid overview

flowchart TD
    A[Whole mesh bounds pass]
    B{Only bounding info}
    C[Thin instance loop]
    D{Predicate passes}
    E{Raw bounds available}
    F[Local ray plus thresholded sphere and box precheck]
    G[Skip instance]
    H[Triangle picker with skip bounds]
    I[Update picking result]

    A --> B
    B -- yes --> I
    B -- no --> C
    C --> D
    D -- no --> G
    D -- yes --> E
    E -- no --> H
    E -- yes --> F
    F -- fail --> G
    F -- pass --> H
    H --> I

Implementation handoff

Implementation mode should execute the following steps:

  1. Add a small internal helper in packages/dev/core/src/Culling/ray.core.ts that tests a thin instance against mesh.rawBoundingInfo using the instance-local ray and the same intersectionThreshold rule as AbstractMesh.intersects() for the standard mesh and lines-mesh path.
  2. Apply that helper in both InternalPick() and InternalMultiPick() after the whole-mesh broad-phase passes and after the per-instance predicate check.
  3. On a failed raw-bounds precheck, skip triangle picking for that thin instance.
  4. On a passed precheck, keep the current picker call with skip-bounds enabled.
  5. If rawBoundingInfo is missing, preserve the current behavior with no extra rejection step.
  6. Keep current behavior for onlyBoundingInfo, fastCheck, predicates, custom pickers, and thin-instance result data.
  7. Add tests for hit parity, false-negative prevention, direct-rawBoundingInfo fallback behavior, and standard line-mesh threshold inflation.
  8. Decide explicitly whether GreasedLineMesh is excluded from the first implementation or handled by a separate verified precheck.
  9. Benchmark dense and simple cases to validate the always-on choice.

Alternatives

  • Per-instance Octree, updated only on thinInstanceSetBuffer(“matrix”) or equivalent, costing extra memory usage and update cpu overhead
  • Keep it as-it, thin instance picking is already an opt-in feature, users should take their own risk

Updated this with the help of AI

Let see what @Evgeni_Popov thinks of it before you do a PR

I have implemented a variation of picking that is useful with spherical thin instances. My algorithm compares each thin instance position in screen space to pointer event screen coordinates and uses distanceSquared to pick thin instances close to the pointer event: closest to camera that is within 20 pixels.

Thanks for the detailed write-up — the analysis of the current thin-instance picking flow is spot-on.

We agree that the per-instance broad-phase is a good optimization, but we’d rather just always do it instead of gating it behind a new property. A bounding sphere + box test is extremely cheap (a handful of dot products and comparisons), and even for low-vertex meshes with a handful of thin instances, rejecting one instance early already pays for all the prechecks. The risk of measurable overhead on simple meshes is theoretical — in practice it’s noise compared to even a single unnecessary triangle walk.

So the plan would be: when thinInstanceEnablePicking is true and rawBoundingInfo is available, always run the per-instance sphere+box precheck against the source-only bounds before entering the triangle intersection. No new property, no threshold, no opt-in. If rawBoundingInfo is missing, we skip the precheck and fall back to current behavior, so there’s no risk of false negatives.

The local-ray approach you describe is exactly what we’d use — the picking loop already builds an instance-local ray via rayFunction, so testing it against the raw bounding sphere and box is nearly free and avoids any per-instance AABB corner transforms.

One detail to watch during implementation: the intersectionThreshold used for line meshes needs to inflate the sphere/box test in the precheck the same way AbstractMesh.intersects does today, otherwise line-mesh thin instances could get false rejections.

2 Likes

Main post updated, now it’s a lot simplier, hope the overhead of an extra bounding check disappear as noise

1 Like

That works perfectly for me!

And a micro bench here:

browser bench (will hang the browser for some time):


nodejs bench:

Node: v22.16.0
gc exposed: true
This measures the thin-instance inner loop only, not the full scene.pickWithRay dispatch.

=== Dense mesh, many thin instances, low hit ratio ===
mode=pick mesh=dense instances=1,024 picks/sample=6 layout=grid
baseline  median= 966.137ms  min= 962.832  max= 977.669
optimized median=   1.805ms  min=   1.782  max=   5.569
optimized vs baseline: +99.8%

=== Dense mesh, many thin instances, high hit ratio ===
mode=pick mesh=dense instances=256 picks/sample=2 layout=stack
baseline  median=  79.971ms  min=  78.735  max=  85.295
optimized median=  83.017ms  min=  81.236  max=  98.726
optimized vs baseline: -3.8%

=== Low-vertex mesh, small thin-instance count ===
mode=pick mesh=box instances=32 picks/sample=10,000 layout=grid
baseline  median= 212.228ms  min= 209.961  max= 217.751
optimized median=  47.137ms  min=  46.475  max=  53.673
optimized vs baseline: +77.8%

=== Low-vertex mesh, many thin instances ===
mode=pick mesh=box instances=5,000 picks/sample=40 layout=grid
baseline  median= 128.375ms  min= 126.366  max= 134.269
optimized median=  24.756ms  min=  24.566  max=  28.626
optimized vs baseline: +80.7%

=== rawBoundingInfo missing fallback ===
mode=pick mesh=box instances=5,000 picks/sample=40 layout=grid rawBounds=null
baseline  median= 124.680ms  min= 123.476  max= 139.855
optimized median= 124.144ms  min= 121.742  max= 132.207
optimized vs baseline: +0.4%

=== Multi-pick dense mesh, low hit ratio ===
mode=multiPick mesh=dense instances=1,024 picks/sample=4 layout=grid
baseline  median= 650.027ms  min= 642.736  max= 657.738
optimized median=   1.248ms  min=   1.228  max=   3.361
optimized vs baseline: +99.8%

=== Multi-pick dense mesh, high hit ratio ===
mode=multiPick mesh=dense instances=256 picks/sample=1 layout=stack
baseline  median=  42.185ms  min=  41.512  max=  43.875
optimized median=  42.514ms  min=  40.898  max=  44.200
optimized vs baseline: -0.8%
nodejs bench code
// Microbenchmark: thin-instance picking inner loop with vs without per-instance raw-bounds precheck.
//
// Run with:
//   node --expose-gc scripts/benchThinInstancePickingRawBounds.mjs
//
// This intentionally isolates the thin-instance inner loop after the whole-mesh bounds pass has succeeded.
// It compares:
//   1. baseline: local ray + triangle picking with skip-bounds enabled for every thin instance
//   2. optimized: same loop, but with rawBoundingInfo sphere+box rejection before triangle picking
//
// The goal is to quantify the proposal's effect across low-hit, high-hit, low-vertex, and rawBoundingInfo-missing scenarios.

import { createRequire } from "node:module";

const require = createRequire(import.meta.url);
globalThis.self = globalThis;
require("./../packages/public/umd/babylonjs/babylon.max.js");

const BABYLON = globalThis.BABYLON;
if (!BABYLON) {
    throw new Error("BABYLON global was not installed");
}

BABYLON.Logger.LogLevels = 0;

const { Matrix, Ray, Vector3, Scene, NullEngine, MeshBuilder, Quaternion } = BABYLON;

function median(values) {
    const sorted = values.slice().sort((a, b) => a - b);
    return sorted[(sorted.length / 2) | 0];
}

function formatMs(ms) {
    return ms.toFixed(3).padStart(8, " ");
}

function tryGc() {
    if (typeof globalThis.gc === "function") {
        globalThis.gc();
    }
}

function benchmark(fn, { warmups = 3, samples = 9 } = {}) {
    const times = [];
    let checksum = 0;

    for (let i = 0; i < warmups; i++) {
        checksum += fn();
    }

    tryGc();

    for (let i = 0; i < samples; i++) {
        const start = performance.now();
        checksum += fn();
        times.push(performance.now() - start);
    }

    return {
        medianMs: median(times),
        minMs: Math.min(...times),
        maxMs: Math.max(...times),
        checksum,
    };
}

function createEngine() {
    const engine = new NullEngine({
        renderHeight: 256,
        renderWidth: 256,
        textureSize: 256,
        deterministicLockstep: false,
        lockstepMaxSteps: 1,
    });
    engine.getCaps().instancedArrays = true;
    return engine;
}

function createSourceMesh(scene, meshKind) {
    switch (meshKind) {
        case "dense":
            return MeshBuilder.CreateSphere(
                "dense",
                {
                    segments: 32,
                    diameter: 2,
                },
                scene
            );
        case "box":
            return MeshBuilder.CreateBox(
                "box",
                {
                    size: 1,
                },
                scene
            );
        default:
            throw new Error(`Unknown mesh kind: ${meshKind}`);
    }
}

function fillGridMatrices(data, instanceCount, spacing, y = 0) {
    const side = Math.ceil(Math.sqrt(instanceCount));
    for (let i = 0; i < instanceCount; i++) {
        const col = i % side;
        const row = (i / side) | 0;
        const m = Matrix.Compose(Vector3.One(), Quaternion.Identity(), new Vector3(col * spacing, y, row * spacing));
        m.copyToArray(data, i * 16);
    }
}

function fillStackMatrices(data, instanceCount, spacing) {
    for (let i = 0; i < instanceCount; i++) {
        const m = Matrix.Compose(Vector3.One(), Quaternion.Identity(), new Vector3(0, 0, i * spacing));
        m.copyToArray(data, i * 16);
    }
}

function makeScenarioContext({ meshKind, instanceCount, layout, rawBounds = true }) {
    const engine = createEngine();
    const scene = new Scene(engine);
    const mesh = createSourceMesh(scene, meshKind);
    const matrixData = new Float32Array(instanceCount * 16);

    if (layout === "grid") {
        fillGridMatrices(matrixData, instanceCount, 4);
    } else if (layout === "stack") {
        fillStackMatrices(matrixData, instanceCount, 3);
    } else {
        throw new Error(`Unknown layout: ${layout}`);
    }

    mesh.thinInstanceSetBuffer("matrix", matrixData, 16, true);
    mesh.thinInstanceEnablePicking = true;
    mesh.thinInstanceRefreshBoundingInfo(true);
    if (!rawBounds) {
        mesh.rawBoundingInfo = null;
    }

    return {
        engine,
        scene,
        mesh,
    };
}

function getScenarioRay(layout) {
    if (layout === "grid") {
        return new Ray(new Vector3(0, 10, 0), new Vector3(0, -1, 0));
    }

    if (layout === "stack") {
        return new Ray(new Vector3(0, 0, -10), new Vector3(0, 0, 1));
    }

    throw new Error(`Unknown layout: ${layout}`);
}

function createHarness(mesh, ray) {
    const meshWorld = mesh.computeWorldMatrix(false);
    const matrixData = mesh._thinInstanceDataStorage.matrixData;
    const instancesCount = Math.min(mesh.thinInstanceCount, matrixData.length >> 4);
    const rawBounds = mesh.rawBoundingInfo;
    const threshold = 0;

    const thinMatrix = Matrix.Identity();
    const combinedWorld = Matrix.Identity();
    const inverseWorld = Matrix.Identity();
    const localRay = Ray.Zero();

    function onePick(useRawBoundsPrecheck) {
        let bestIndex = -1;
        let bestDistance = Number.POSITIVE_INFINITY;

        for (let index = 0; index < instancesCount; index++) {
            Matrix.FromArrayToRef(matrixData, index << 4, thinMatrix);
            thinMatrix.multiplyToRef(meshWorld, combinedWorld);

            combinedWorld.invertToRef(inverseWorld);
            Ray.TransformToRef(ray, inverseWorld, localRay);

            if (useRawBoundsPrecheck && rawBounds) {
                if (!localRay.intersectsSphere(rawBounds.boundingSphere, threshold) || !localRay.intersectsBox(rawBounds.boundingBox, threshold)) {
                    continue;
                }
            }

            const info = mesh.intersects(localRay, false, undefined, false, combinedWorld, true);
            if (!info || !info.hit) {
                continue;
            }

            if (info.distance < bestDistance) {
                bestDistance = info.distance;
                bestIndex = index;
            }
        }

        return bestIndex === -1 ? -1 : bestIndex * 1_000_000 + Math.round(bestDistance * 1_000);
    }

    function oneMultiPick(useRawBoundsPrecheck) {
        let hitCount = 0;
        let hitIndexSum = 0;

        for (let index = 0; index < instancesCount; index++) {
            Matrix.FromArrayToRef(matrixData, index << 4, thinMatrix);
            thinMatrix.multiplyToRef(meshWorld, combinedWorld);

            combinedWorld.invertToRef(inverseWorld);
            Ray.TransformToRef(ray, inverseWorld, localRay);

            if (useRawBoundsPrecheck && rawBounds) {
                if (!localRay.intersectsSphere(rawBounds.boundingSphere, threshold) || !localRay.intersectsBox(rawBounds.boundingBox, threshold)) {
                    continue;
                }
            }

            const info = mesh.intersects(localRay, false, undefined, false, combinedWorld, true);
            if (!info || !info.hit) {
                continue;
            }

            hitCount++;
            hitIndexSum += index;
        }

        return hitCount * 1_000_000 + hitIndexSum;
    }

    return {
        onePick,
        oneMultiPick,
    };
}

function runScenario({ label, meshKind, instanceCount, layout, mode, picksPerSample }) {
    const ctx = makeScenarioContext({ meshKind, instanceCount, layout, rawBounds: true });
    const ray = getScenarioRay(layout);
    const harness = createHarness(ctx.mesh, ray);

    const baselineOnce = mode === "pick" ? harness.onePick(false) : harness.oneMultiPick(false);
    const optimizedOnce = mode === "pick" ? harness.onePick(true) : harness.oneMultiPick(true);
    if (baselineOnce !== optimizedOnce) {
        throw new Error(`${label}: result mismatch baseline=${baselineOnce} optimized=${optimizedOnce}`);
    }

    const baseline = benchmark(() => {
        let acc = 0;
        for (let i = 0; i < picksPerSample; i++) {
            acc += mode === "pick" ? harness.onePick(false) : harness.oneMultiPick(false);
        }
        return acc;
    });

    const optimized = benchmark(() => {
        let acc = 0;
        for (let i = 0; i < picksPerSample; i++) {
            acc += mode === "pick" ? harness.onePick(true) : harness.oneMultiPick(true);
        }
        return acc;
    });

    ctx.engine.dispose();

    const speedup = ((baseline.medianMs - optimized.medianMs) / baseline.medianMs) * 100;

    console.log(`\n=== ${label} ===`);
    console.log(`mode=${mode} mesh=${meshKind} instances=${instanceCount.toLocaleString()} picks/sample=${picksPerSample.toLocaleString()} layout=${layout}`);
    console.log(`baseline  median=${formatMs(baseline.medianMs)}ms  min=${formatMs(baseline.minMs)}  max=${formatMs(baseline.maxMs)}`);
    console.log(`optimized median=${formatMs(optimized.medianMs)}ms  min=${formatMs(optimized.minMs)}  max=${formatMs(optimized.maxMs)}`);
    console.log(`optimized vs baseline: ${speedup >= 0 ? "+" : ""}${speedup.toFixed(1)}%`);
}

function runRawBoundsMissingScenario({ label, meshKind, instanceCount, layout, mode, picksPerSample }) {
    const ctx = makeScenarioContext({ meshKind, instanceCount, layout, rawBounds: false });
    const ray = getScenarioRay(layout);
    const harness = createHarness(ctx.mesh, ray);

    const baselineOnce = mode === "pick" ? harness.onePick(false) : harness.oneMultiPick(false);
    const optimizedOnce = mode === "pick" ? harness.onePick(true) : harness.oneMultiPick(true);
    if (baselineOnce !== optimizedOnce) {
        throw new Error(`${label}: result mismatch baseline=${baselineOnce} optimized=${optimizedOnce}`);
    }

    const baseline = benchmark(() => {
        let acc = 0;
        for (let i = 0; i < picksPerSample; i++) {
            acc += mode === "pick" ? harness.onePick(false) : harness.oneMultiPick(false);
        }
        return acc;
    });

    const optimized = benchmark(() => {
        let acc = 0;
        for (let i = 0; i < picksPerSample; i++) {
            acc += mode === "pick" ? harness.onePick(true) : harness.oneMultiPick(true);
        }
        return acc;
    });

    ctx.engine.dispose();

    const speedup = ((baseline.medianMs - optimized.medianMs) / baseline.medianMs) * 100;

    console.log(`\n=== ${label} ===`);
    console.log(`mode=${mode} mesh=${meshKind} instances=${instanceCount.toLocaleString()} picks/sample=${picksPerSample.toLocaleString()} layout=${layout} rawBounds=null`);
    console.log(`baseline  median=${formatMs(baseline.medianMs)}ms  min=${formatMs(baseline.minMs)}  max=${formatMs(baseline.maxMs)}`);
    console.log(`optimized median=${formatMs(optimized.medianMs)}ms  min=${formatMs(optimized.minMs)}  max=${formatMs(optimized.maxMs)}`);
    console.log(`optimized vs baseline: ${speedup >= 0 ? "+" : ""}${speedup.toFixed(1)}%`);
}

console.log("Thin-instance raw-bounds precheck microbench");
console.log("Node:", process.version);
console.log("gc exposed:", typeof globalThis.gc === "function");
console.log("This measures the thin-instance inner loop only, not the full scene.pickWithRay dispatch.");

runScenario({
    label: "Dense mesh, many thin instances, low hit ratio",
    meshKind: "dense",
    instanceCount: 1_024,
    layout: "grid",
    mode: "pick",
    picksPerSample: 6,
});

runScenario({
    label: "Dense mesh, many thin instances, high hit ratio",
    meshKind: "dense",
    instanceCount: 256,
    layout: "stack",
    mode: "pick",
    picksPerSample: 2,
});

runScenario({
    label: "Low-vertex mesh, small thin-instance count",
    meshKind: "box",
    instanceCount: 32,
    layout: "grid",
    mode: "pick",
    picksPerSample: 10_000,
});

runScenario({
    label: "Low-vertex mesh, many thin instances",
    meshKind: "box",
    instanceCount: 5_000,
    layout: "grid",
    mode: "pick",
    picksPerSample: 40,
});

runRawBoundsMissingScenario({
    label: "rawBoundingInfo missing fallback",
    meshKind: "box",
    instanceCount: 5_000,
    layout: "grid",
    mode: "pick",
    picksPerSample: 40,
});

runScenario({
    label: "Multi-pick dense mesh, low hit ratio",
    meshKind: "dense",
    instanceCount: 1_024,
    layout: "grid",
    mode: "multiPick",
    picksPerSample: 4,
});

runScenario({
    label: "Multi-pick dense mesh, high hit ratio",
    meshKind: "dense",
    instanceCount: 256,
    layout: "stack",
    mode: "multiPick",
    picksPerSample: 1,
});

And link a pr here: