Skip to content

Raycasting and MeshBVH ​

Raycasting computes, on the CPU, where a ray meets the triangles of a mesh: mouse picking, placing objects on a model's surface, line-of-sight checks, snapping to the ground and so on. The engine offers two entry points that share the same intersection code, accelerated by a triangle bounding volume hierarchy, MeshBVH:

Entry pointUse it for
RaycasterAny ray (not only from the camera) against any MeshRenderer, no collider needed; can return every hit along the ray
Pick events in bound mode + MeshColliderShapeEvent-driven interaction such as hover and click

Compared with frame buffer picking, raycasting does not depend on the rendered image: rays can start anywhere and point in any direction, every hit along the ray is available, and each hit carries the triangle index, barycentric coordinates, UVs and more.

Raycaster ​

Basic Usage ​

ts
import { Raycaster } from '@orillusion/core';

const raycaster = new Raycaster();

// A ray from the camera through a pixel of the canvas
raycaster.setFromCamera(view.camera, input.mouseX, input.mouseY);

// Intersect every mesh in the scene (children included by default); hits are sorted nearest first
const hits = raycaster.intersectObject(view.scene);
if (hits.length > 0) {
    const hit = hits[0];
    console.log(hit.object3D.name, hit.point, hit.normal, hit.uv);
}

Raycaster walks every enabled MeshRenderer under the given objects and intersects them one by one; no ColliderComponent is needed. Sky spheres (SkyRenderer, AtmosphericComponent, EarthSkyRenderer) are skipped by default, so intersecting the whole scene does not hit the sky far away; turn on includeSky if you need them.

Building the Ray ​

MethodDescription
setFromCamera(camera, x, y)Ray from the camera through canvas pixel (x, y), in CSS pixels relative to the canvas' top-left corner, as in InputSystem.mouseX/Y and PointerEvent3D.mouseX/Y
setFromCameraNDC(camera, x, y)Ray from the camera through normalized device coordinates (x, y), each in -1 ~ 1, y up
set(origin, direction)World-space origin and direction (the direction is normalized); can also be passed to the constructor
ts
// Cast down from above a character to find the ground under it
const down = new Raycaster(new Vector3(x, 100, z), new Vector3(0, -1, 0));
const ground = down.intersectFirst(terrain);
if (ground) role.y = ground.point.y;

Intersection Methods ​

MethodDescription
intersectObject(object, recursive = true, out = [])Intersect an object (and its children); returns every hit, sorted by distance
intersectObjects(objects, recursive = true, out = [])Same, for several root objects
intersectFirst(objects, recursive = true)Only the closest hit, or null. Each mesh is only searched up to the closest distance found so far, so it is faster than intersectObjects(...)[0]

Options ​

PropertyDefaultDescription
near / far0 / InfinityOnly keep hits within this distance range (world units)
layerMaskVisibleLayer.AllOnly test renderers whose visibleLayer shares a bit with it
filternullPer-renderer filter; return false to skip a renderer
includeSkyfalseWhether to test sky spheres (SkyRenderer, AtmosphericComponent, EarthSkyRenderer)
side'material'Faces that can be hit: 'material' follows each sub-mesh material's cullMode (the faces you see on screen), 'front' / 'back' only front / back faces, 'double' both
firstHitOnlyfalseReport only the closest hit per mesh instead of every crossing
useBVH'auto'When to use MeshBVH, see MeshBVH acceleration
deformation'cpu'How skinned and morph-target meshes are handled, see Skinned and morph-target meshes
ts
// Ignore the marker object and only hit front faces
raycaster.filter = (renderer) => renderer.object3D !== marker;
raycaster.side = 'front';

Disabled objects, renderers and materials are skipped, as are sub-meshes with a topology other than triangle-list (lines, points). Renderers batched by an InstanceDrawComponent are disabled individually but still tested.

Hit Results ​

Every hit is a new RaycastHit object that you can keep:

FieldDescription
distanceWorld-space distance from the ray origin to the hit
point / localPointHit point in world space / in the mesh's local space
normalWorld-space normal: the vertex normals interpolated with the barycentric coordinates, or faceNormal when the geometry has no normals. Not flipped for back-face hits
faceNormalGeometric normal of the hit triangle (world space, from the triangle winding)
faceIndexTriangle index into the index buffer; its vertices are indices[faceIndex * 3 .. + 2]
barycentricBarycentric weights (w0, w1, w2) of the triangle's three vertices
frontFaceWhether the ray hit the front (counter-clockwise, as rendered) side of the triangle
uv / uv1Interpolated TEXCOORD_0 / TEXCOORD_1, or null when the geometry has none
subGeometryIndexSub-geometry of the triangle; drawn with renderer.materials[subGeometryIndex]
object3D / rendererThe object and renderer that were hit

MeshBVH Acceleration ​

Without an acceleration structure, every ray has to test every triangle of the mesh, so the cost grows with the triangle count. MeshBVH builds a bounding volume hierarchy over the triangles of a geometry; a query only visits the few nodes along the ray, which is roughly logarithmic. For a mesh of about 320,000 triangles, building the tree takes about 140 ms; after that a ray costs about 13 µs, against about 4.4 ms when testing every triangle, roughly 300 times faster.

When to Use the BVH ​

Raycaster.useBVH and MeshColliderShape.useBVH decide whether the BVH is used:

ValueDescription
'auto'Use the BVH when one has been built for the geometry, otherwise test every triangle. Default for Raycaster
'always'Build and cache a tree on the first query. Default for MeshColliderShape
'never'Always test every triangle

For high-poly models that are tested often, build the tree ahead of time to avoid a hitch on the first test:

ts
import { MeshBVH } from '@orillusion/core';

// Build and cache the tree; an 'auto' Raycaster then uses it
const bvh = MeshBVH.build(meshRenderer.geometry);
console.log(bvh.triCount, bvh.nodeCount, bvh.depth);

Caching and Updates ​

The BVH is cached per geometry; renderers sharing a geometry share its tree:

MethodDescription
MeshBVH.build(geometry, maxLeafTris = 8)Build (or rebuild) and cache the tree; maxLeafTris is the largest number of triangles per leaf
MeshBVH.get(geometry)The cached tree, building it if needed
MeshBVH.has(geometry)Whether a tree is cached
MeshBVH.invalidate(geometry)Drop the cached tree; the next get rebuilds it
bvh.refit()Keep the tree structure and recompute the node bounds from the current vertex positions

When the geometry's vertices change, the cached tree is updated on the next query: it is refit when positions are modified in place and re-uploaded (for example by calling geometry.setAttribute again), and rebuilt when the vertex or index arrays are replaced. If you only edited the positions in place without re-uploading them, call bvh.refit() or MeshBVH.invalidate() yourself.

TIP

refit() is cheap (linear in the triangle count) but only updates the bounds, not the tree structure. After large deformations queries stay correct but get slower; rebuild with MeshBVH.build() then.

The BVH contains the LOD 0 triangles of every sub-geometry, supports indexed and non-indexed geometry as well as the interleaved vertex format of prefabs (compose_bin). It describes the geometry's own vertices, without skinning or morph targets.

Querying MeshBVH Directly ​

For custom work in a geometry's local space you can also query MeshBVH directly. Note that its inputs and results are in the geometry's local space, and the distance t is in local units too:

ts
import { MeshBVH, MeshRenderer, Vector3 } from '@orillusion/core';

const renderer = obj.getComponent(MeshRenderer);
const bvh = MeshBVH.get(renderer.geometry);

// Transform the world-space ray into the mesh's local space
const inv = renderer.transform.worldMatrix.clone().invert();
const origin = inv.transformPoint(worldOrigin.clone());
const direction = inv.transformVector(worldDirection.clone()).normalize();

// Closest hit, front faces only, at most 100 local units away
const hit = bvh.raycast(origin, direction, { cull: 'back', far: 100 });
if (hit) console.log(hit.t, hit.triIndex, hit.point, hit.faceNormal);
MethodDescription
raycast(origin, direction, options?, target?)Closest hit, or null; pass target to reuse a result object
raycastAll(origin, direction, options?, out?)Every hit, sorted by distance; a crossing through a shared edge or vertex is reported once
intersectsRay(origin, direction, options?)Whether the ray hits any triangle; returns at the first one found, for line-of-sight checks
MeshBVH.raycastGeometry(geometry, ...)Closest hit without building a tree, testing every triangle; for one-off queries or tiny meshes
MeshBVH.raycastGeometryAll(geometry, ...)Every hit without building a tree

Query options:

FieldDefaultDescription
cull'none'Faces to ignore: 'none', 'back' (skip back faces, as a cullMode: 'back' material shows them) or 'front'; true means 'back'
near / far0 / InfinityOnly keep hits within this distance range

The result MeshBVHHit holds t (distance along the ray), triIndex, barycentric coordinates u / v (v0 gets 1 - u - v), point, faceNormal, frontFace and subGeometryIndex.

Skinned and Morph-target Meshes ​

Skeletal animation and morph targets are applied by the GPU in the vertex shader; the geometry still holds the bind pose, so intersecting it directly hits the wrong place. Raycaster.deformation decides how such meshes are handled:

ValueDescription
'cpu'Default. Deform the vertices on the CPU the same way the vertex shader does, then intersect the result, so hits match the image
'bindPose'Intersect the undeformed bind pose
'skip'Ignore such meshes

CPU deformation is done by MeshDeformer: morph targets first, then skinning. It supports glTF's SkinnedMeshRenderer2 + AnimatorComponent and prefabs' SkinnedMeshRenderer + SkeletonAnimationComponent. The deformed result is cached per renderer and only recomputed when the bone matrices or morph weights change, with its BVH updated through refit().

MeshColliderShape has a deformation property of its own, and ColliderComponent links it to the renderer on the same object, so bound-mode pick events follow the animation as well.

The example below casts a ray from the mouse position every frame: the red dot is the hit on the animated surface, the yellow dot points along the normal, and the blue dot is where the same ray hits the bind pose. Use the panel to switch animations, adjust the lion's blend shapes, or change deformation to compare.

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
import { AnimatorComponent, AtmosphericComponent, CameraUtil, Color, ComponentBase, DirectLight, Engine3D, HoverCameraController, KelvinUtil, MeshDeformer, MeshRenderer, Object3D, Object3DUtil, Raycaster, RaycastHit, Scene3D, SphereGeometry, UnLitMaterial, Vector3, View3D } from '@orillusion/core';
import dat from 'dat.gui';

/**
 * Picking skinned and morph-target meshes.
 *
 * Two animated soldiers (glTF skin) and a lion with blend shapes. Every frame a Raycaster is cast
 * through the mouse position: the GPU deforms these meshes in the vertex shader, so the raycaster
 * replays the deformation on the CPU and the red marker sticks to the surface as drawn, even while
 * it animates under a still mouse. The blue marker is the same ray against the bind pose
 * (deformation = 'bindPose'). The yellow dot points along the hit normal.
 */
class Sample_SkinnedMeshPick {
    private scene: Scene3D;
    private view: View3D;
    private characters: Object3D;
    private animators: AnimatorComponent[] = [];
    private lionAnimator: AnimatorComponent;
    private hitMarker: Object3D;
    private normalMarker: Object3D;
    private bindMarker: Object3D;
    private readonly raycaster = new Raycaster();
    private readonly bindRaycaster = new Raycaster();
    private readonly info = {
        animate: true,
        clip: 'Run',
        showBindPose: true,
        lastHit: '-',
        faceIndex: -1,
        distance: 0,
        bindPoseOffset: '-',
        deformedVertices: 0,
        pickMs: 0
    };
    private readonly morph: { [key: string]: number } = {};

    async run() {
        const engine = await Engine3D.init();

        this.scene = new Scene3D();
        this.scene.addComponent(AtmosphericComponent);

        const camera = CameraUtil.createCamera3DObject(this.scene);
        camera.perspective(45, engine.aspect, 0.1, 1000);
        camera.object3D.addComponent(HoverCameraController).setCamera(0, -12, 150, new Vector3(20, 18, 0));

        const light = new Object3D();
        light.rotationX = 35;
        light.rotationY = 160;
        const dl = light.addComponent(DirectLight);
        dl.lightColor = KelvinUtil.color_temperature_to_rgb(5355);
        dl.intensity = 2;
        dl.castShadow = true;
        dl.enableCSM = true;
        this.scene.addChild(light);

        this.view = new View3D();
        this.view.scene = this.scene;
        this.view.camera = camera;

        this.scene.addChild(Object3DUtil.GetSingleCube(400, 1, 400, 0.45, 0.45, 0.48));
        this.characters = new Object3D();
        this.scene.addChild(this.characters);
        await this.loadSoldiers(engine);
        await this.loadLion(engine);
        this.hitMarker = this.createDot(1.1, new Color(1, 0.15, 0.1));
        this.normalMarker = this.createDot(0.6, new Color(1, 0.95, 0.2));
        this.bindMarker = this.createDot(0.9, new Color(0.2, 0.5, 1));
        // The comparison ray ignores the animation and hits the undeformed mesh
        this.bindRaycaster.deformation = 'bindPose';

        engine.startRenderView(this.view);
        this.initGUI();
        // Pick every frame: the characters move under a still mouse
        this.scene.addComponent(PickEveryFrame).sample = this;
    }

    private async loadSoldiers(engine: Engine3D) {
        const soldier = await engine.res.loadGltf('https://cdn.orillusion.com/gltfs/glb/Soldier.glb');
        soldier.scaleX = soldier.scaleY = soldier.scaleZ = 20;
        soldier.rotationY = 200;
        soldier.x = -20;
        this.characters.addChild(soldier);
        const walker = soldier.clone();
        walker.x = 15;
        walker.rotationY = 160;
        this.characters.addChild(walker);
        this.animators = [soldier, walker].map((s) => s.getComponentsInChild(AnimatorComponent)[0]);
        this.animators[0].playAnim('Run');
        this.animators[1].playAnim('Walk');
    }

    private async loadLion(engine: Engine3D) {
        const lion = await engine.res.loadGltf('https://cdn.orillusion.com/gltfs/glb/lion.glb');
        lion.scaleX = lion.scaleY = lion.scaleZ = 0.4;
        this.characters.addChild(lion);
        // Stand it on the ground at x = 55 using its bounds
        let minY = Infinity,
            cx = 0,
            n = 0;
        for (const r of lion.getComponentsInChild(MeshRenderer)) {
            const b = r.geometry.bounds,
                m = r.transform.worldMatrix;
            for (const p of [b.min, b.max]) {
                const w = m.transformPoint(p.clone());
                minY = Math.min(minY, w.y);
                cx += w.x;
                n++;
            }
        }
        lion.y -= minY;
        lion.x += 55 - cx / n;
        this.lionAnimator = lion.addComponent(AnimatorComponent);
        for (const key in this.lionAnimator.cloneMorphRenderers()) this.morph[key] = 0;
    }

    private createDot(radius: number, color: Color): Object3D {
        const obj = new Object3D();
        const mr = obj.addComponent(MeshRenderer);
        mr.geometry = new SphereGeometry(radius, 16, 16);
        const mat = new UnLitMaterial();
        mat.baseColor = color;
        mr.material = mat;
        mr.castShadow = false;
        obj.transform.enable = false;
        this.scene.addChild(obj);
        return obj;
    }

    /** Called every frame by PickEveryFrame. */
    public pick() {
        const input = this.view.engine3D.inputSystem;
        const camera = this.view.camera;
        this.raycaster.setFromCamera(camera, input.mouseX, input.mouseY);
        const t0 = performance.now();
        const hit = this.raycaster.intersectFirst(this.characters);
        this.info.pickMs = +(performance.now() - t0).toFixed(2);
        this.place(hit);

        let bind: RaycastHit | null = null;
        if (this.info.showBindPose) {
            this.bindRaycaster.setFromCamera(camera, input.mouseX, input.mouseY);
            bind = this.bindRaycaster.intersectFirst(this.characters);
        }
        this.bindMarker.transform.enable = !!bind;
        if (bind) this.bindMarker.localPosition = bind.point;
        this.info.bindPoseOffset = hit && bind ? Vector3.distance(hit.point, bind.point).toFixed(2) : '-';
    }

    private place(hit: RaycastHit | null) {
        this.hitMarker.transform.enable = this.normalMarker.transform.enable = !!hit;
        if (!hit) {
            this.info.lastHit = '-';
            return;
        }
        this.hitMarker.localPosition = hit.point;
        const n = hit.normal;
        this.normalMarker.localPosition = new Vector3(hit.point.x + n.x * 3, hit.point.y + n.y * 3, hit.point.z + n.z * 3);
        this.info.lastHit = hit.object3D.name || '(unnamed)';
        this.info.faceIndex = hit.faceIndex;
        this.info.distance = +hit.distance.toFixed(2);
        // The CPU-deformed copy the raycaster tested (cached per renderer)
        const deformed = MeshDeformer.deform(hit.renderer);
        this.info.deformedVertices = deformed ? deformed.geometry.getAttribute('position').data.length / 3 : 0;
    }

    private initGUI() {
        const gui = new dat.GUI();
        const a = gui.addFolder('Animation');
        a.add(this.info, 'animate').onChange((v: boolean) => this.animators.forEach((an) => (an.timeScale = v ? 1 : 0)));
        a.add(this.info, 'clip', ['Idle', 'Walk', 'Run', 'TPose']).onChange((v: string) => this.animators[0].playAnim(v));
        const m = gui.addFolder('Lion blend shapes');
        for (const key in this.morph) {
            m.add(this.morph, key, 0, 1, 0.01).onChange((v: number) => {
                this.lionAnimator.updateBlendShape(['blendShape', 'blendShape', key], 'blendShape.blendShape.' + key, v);
            });
        }
        const p = gui.addFolder('Pick (mouse)');
        p.add(this.raycaster, 'deformation', ['cpu', 'bindPose', 'skip']);
        p.add(this.info, 'showBindPose');
        p.add(this.info, 'lastHit').listen();
        p.add(this.info, 'faceIndex').listen();
        p.add(this.info, 'distance').listen();
        p.add(this.info, 'bindPoseOffset').listen();
        p.add(this.info, 'deformedVertices').listen();
        p.add(this.info, 'pickMs').listen();
        p.open();
        // Start collapsed so the panel does not cover the scene
        gui.close();
    }
}

class PickEveryFrame extends ComponentBase {
    public sample: Sample_SkinnedMeshPick;
    onUpdate() {
        this.sample?.pick();
    }
}

new Sample_SkinnedMeshPick().run();

Working with Pick Events ​

In bound mode of the pick events, MeshColliderShape uses the same intersection code and builds a BVH on the first pick by default, so even models with hundreds of thousands of triangles can be picked accurately every frame:

ts
import { ColliderComponent, MeshColliderShape } from '@orillusion/core';

// Without shape.mesh, the geometry of the MeshRenderer on the same object is used
const collider = obj.addComponent(ColliderComponent);
collider.shape = new MeshColliderShape();
MeshColliderShape propertyDefaultDescription
meshautomaticGeometry to pick against; when unset, the geometry of the MeshRenderer on the same object
useBVH'always'When to use the BVH
backfaceCullingfalseWhether to skip back faces
maxDistanceInfinityIgnore hits farther than this (world units)
deformation'cpu'How skinned and morph-target meshes are handled

In bound mode the pick event data e.data contains the following fields; all positions and normals are in world space:

FieldDescription
worldPos / localPosHit point in world space / in the collider's local space
worldNormalNormal at the hit point
distanceDistance from the ray origin to the hit
faceNormal, faceIndex, barycentric, uv, uv1, subGeometryIndex, frontFaceMeshColliderShape only; same meaning as in Hit Results
hitsHits on every collider the ray crosses, nearest first
ts
view.pickFire.addEventListener(PointerEvent3D.PICK_MOVE, (e: PointerEvent3D) => {
    const d = e.data;
    marker.localPosition = d.worldPos;
    console.log(e.target.name, d.faceIndex, d.uv, d.hits.length);
}, this);

view.pickFire.layerMask and view.pickFire.filter exclude colliders from picking, the same way as on Raycaster.

In the example below, a sphere of about 320,000 triangles, a torus of 40,000 triangles and a few scaled boxes all use MeshColliderShape. Moving the mouse picks the exact triangles every frame and places a marker at the hit; clicking runs a Raycaster over the whole scene and lists every hit along the ray; the Benchmark panel compares the BVH with testing every triangle.

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
import { AtmosphericComponent, BoxGeometry, CameraUtil, ColliderComponent, Color, DirectLight, Engine3D, GeometryBase, HoverCameraController, KelvinUtil, LitMaterial, MeshBVH, MeshColliderShape, MeshRenderer, Object3D, PointerEvent3D, Raycaster, Scene3D, SphereGeometry, TorusGeometry, UnLitMaterial, Vector3, View3D } from '@orillusion/core';
import dat from 'dat.gui';

/**
 * MeshBVH picking.
 *
 * A 320k-triangle sphere, a 40k-triangle torus and a few scaled boxes all carry a MeshColliderShape.
 * Moving the mouse picks the exact triangles every frame (bound-mode PickFire, accelerated by MeshBVH)
 * and drops a red marker at the hit point, with a yellow one along the interpolated normal.
 *
 * Clicking runs a Raycaster over the whole scene (no colliders needed) and lists every hit along the ray.
 * The benchmark fires random rays at the big sphere through the BVH and through a brute-force scan.
 */
class Sample_MeshBVH {
    private scene: Scene3D;
    private view: View3D;
    private marker: Object3D;
    private normalMarker: Object3D;
    private bigSphere: MeshRenderer;
    private readonly raycaster = new Raycaster();
    private readonly info = {
        triangles: 0,
        bvhNodes: 0,
        bvhDepth: 0,
        buildMs: 0,
        lastHit: '-',
        distance: 0,
        faceIndex: -1,
        uv: '-',
        collidersOnRay: 0,
        useBVH: 'auto',
        side: 'material',
        firstHitOnly: false,
        rayHits: '-',
        rayMs: 0,
        benchRays: 2000,
        bvhUsPerRay: 0,
        bruteUsPerRay: 0,
        speedup: 0
    };

    async run() {
        // PickFire in bound mode: picks ColliderComponents on the CPU
        const engine = await Engine3D.init({ setting: { pick: { enable: true, mode: 'bound' } } });

        this.scene = new Scene3D();
        this.scene.addComponent(AtmosphericComponent);

        const camera = CameraUtil.createCamera3DObject(this.scene);
        camera.perspective(45, engine.aspect, 0.1, 1000);
        camera.object3D.addComponent(HoverCameraController).setCamera(-30, -20, 60, new Vector3(0, 6, 0));

        const light = new Object3D();
        light.rotationX = 35;
        light.rotationY = 160;
        const dl = light.addComponent(DirectLight);
        dl.lightColor = KelvinUtil.color_temperature_to_rgb(5355);
        dl.intensity = 2;
        dl.castShadow = true;
        dl.enableCSM = true;
        this.scene.addChild(light);

        this.view = new View3D();
        this.view.scene = this.scene;
        this.view.camera = camera;

        this.buildScene();
        engine.startRenderView(this.view);
        this.initGUI();

        this.view.pickFire.addEventListener(PointerEvent3D.PICK_MOVE, this.onPickMove, this);
        this.view.pickFire.addEventListener(PointerEvent3D.PICK_CLICK, this.onPickClick, this);
    }

    private addPickable(geometry: GeometryBase, color: Color, pos: Vector3, scale = 1, name = ''): MeshRenderer {
        const obj = new Object3D();
        obj.name = name;
        obj.localPosition = pos;
        obj.localScale = new Vector3(scale, scale, scale);
        const mr = obj.addComponent(MeshRenderer);
        mr.geometry = geometry;
        const mat = new LitMaterial();
        mat.baseColor = color;
        mat.roughness = 0.5;
        mr.material = mat;
        mr.castShadow = true;
        // Without shape.mesh, the collider picks the geometry of the MeshRenderer on the same object
        obj.addComponent(ColliderComponent).shape = new MeshColliderShape();
        this.scene.addChild(obj);
        return mr;
    }

    private buildScene() {
        // 400 x 400 segments: about 320k triangles
        const t0 = performance.now();
        const sphereGeo = new SphereGeometry(10, 400, 400);
        this.bigSphere = this.addPickable(sphereGeo, new Color(0.3, 0.6, 0.9), new Vector3(0, 10, 0), 1, 'sphere-320k');
        // Prebuild the tree so the first hover does not pay for it; Raycaster's 'auto' mode also uses it
        const bvh = MeshBVH.build(sphereGeo);
        this.info.buildMs = +(performance.now() - t0).toFixed(1);
        this.info.triangles = bvh.triCount;
        this.info.bvhNodes = bvh.nodeCount;
        this.info.bvhDepth = bvh.depth;

        const torus = this.addPickable(new TorusGeometry(6, 2, 128, 160), new Color(0.9, 0.6, 0.2), new Vector3(-24, 6, 0), 1, 'torus');
        torus.object3D.rotationX = 60;

        // Scaled boxes: hits are reported in world space under any transform
        const box = new BoxGeometry(1, 1, 1);
        for (let i = 0; i < 4; i++) {
            const mr = this.addPickable(box, new Color(0.5, 0.85, 0.4), new Vector3(20 + i * 7, 3, (i % 2) * 6 - 3), 6, 'box-' + i);
            mr.object3D.rotationY = i * 25;
        }

        const floor = this.addPickable(new BoxGeometry(200, 1, 200), new Color(0.6, 0.6, 0.62), new Vector3(0, -0.5, 0), 1, 'floor');
        floor.castShadow = false;

        this.marker = this.createDot(0.35, new Color(1, 0.1, 0.1));
        this.normalMarker = this.createDot(0.22, new Color(1, 1, 0.2));
    }

    private createDot(radius: number, color: Color): Object3D {
        const obj = new Object3D();
        const mr = obj.addComponent(MeshRenderer);
        mr.geometry = new SphereGeometry(radius, 12, 12);
        const mat = new UnLitMaterial();
        mat.baseColor = color;
        mr.material = mat;
        mr.castShadow = false;
        obj.transform.enable = false;
        this.scene.addChild(obj);
        return obj;
    }

    private onPickMove(e: PointerEvent3D) {
        const d = e.data;
        if (!d || !e.target) return;
        this.marker.transform.enable = this.normalMarker.transform.enable = true;
        this.marker.localPosition = d.worldPos;
        const n: Vector3 = d.worldNormal;
        this.normalMarker.localPosition = new Vector3(d.worldPos.x + n.x * 1.5, d.worldPos.y + n.y * 1.5, d.worldPos.z + n.z * 1.5);
        this.info.lastHit = e.target.name;
        this.info.distance = +(d.distance ?? 0).toFixed(2);
        this.info.faceIndex = d.faceIndex ?? -1;
        this.info.uv = d.uv ? `${d.uv.x.toFixed(3)}, ${d.uv.y.toFixed(3)}` : '-';
        // Every collider along the ray, nearest first
        this.info.collidersOnRay = d.hits?.length ?? 0;
    }

    private onPickClick(e: PointerEvent3D) {
        // The same click through a Raycaster: every triangle hit along the ray, scene-wide
        const rc = this.raycaster;
        rc.useBVH = this.info.useBVH as any;
        rc.side = this.info.side as any;
        rc.firstHitOnly = this.info.firstHitOnly;
        // Skip the hit markers (sky spheres are skipped by default, see includeSky)
        rc.filter = (r) => r.object3D !== this.marker && r.object3D !== this.normalMarker;
        rc.setFromCamera(this.view.camera, e.mouseX, e.mouseY);
        const t0 = performance.now();
        const hits = rc.intersectObject(this.scene);
        this.info.rayMs = +(performance.now() - t0).toFixed(2);
        this.info.rayHits = hits.length ? hits.map((h) => `${h.object3D.name}#${h.faceIndex}@${h.distance.toFixed(1)}${h.frontFace ? '' : '(back)'}`).join('  ') : '-';
    }

    private benchmark() {
        const bvh = MeshBVH.get(this.bigSphere.geometry);
        const n = this.info.benchRays;
        // Random rays aimed at the sphere from a shell around it, in the geometry's local space
        const origins: Vector3[] = [],
            dirs: Vector3[] = [];
        for (let i = 0; i < n; i++) {
            const o = new Vector3(Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5).normalize().multiplyScalar(30);
            const d = new Vector3(-o.x + Math.random() * 6 - 3, -o.y + Math.random() * 6 - 3, -o.z + Math.random() * 6 - 3).normalize();
            origins.push(o);
            dirs.push(d);
        }
        let t0 = performance.now();
        for (let i = 0; i < n; i++) bvh.raycast(origins[i], dirs[i]);
        const bvhMs = performance.now() - t0;
        t0 = performance.now();
        for (let i = 0; i < n; i++) bvh.raycastBruteForce(origins[i], dirs[i]);
        const bruteMs = performance.now() - t0;
        this.info.bvhUsPerRay = +((bvhMs / n) * 1000).toFixed(1);
        this.info.bruteUsPerRay = +((bruteMs / n) * 1000).toFixed(1);
        this.info.speedup = +(bruteMs / Math.max(bvhMs, 1e-6)).toFixed(1);
    }

    private initGUI() {
        const gui = new dat.GUI();
        const f = gui.addFolder('MeshBVH (hover)');
        f.add(this.info, 'triangles').listen();
        f.add(this.info, 'bvhNodes').listen();
        f.add(this.info, 'bvhDepth').listen();
        f.add(this.info, 'buildMs').listen();
        f.add(this.info, 'lastHit').listen();
        f.add(this.info, 'distance').listen();
        f.add(this.info, 'faceIndex').listen();
        f.add(this.info, 'uv').listen();
        f.add(this.info, 'collidersOnRay').listen();
        f.open();
        const r = gui.addFolder('Raycaster (click)');
        r.add(this.info, 'useBVH', ['auto', 'always', 'never']);
        r.add(this.info, 'side', ['material', 'front', 'back', 'double']);
        r.add(this.info, 'firstHitOnly');
        r.add(this.info, 'rayHits').listen();
        r.add(this.info, 'rayMs').listen();
        const b = gui.addFolder('Benchmark');
        b.add(this.info, 'benchRays', 100, 20000, 100);
        b.add({ run: () => this.benchmark() }, 'run');
        b.add(this.info, 'bvhUsPerRay').listen();
        b.add(this.info, 'bruteUsPerRay').listen();
        b.add(this.info, 'speedup').listen();
        // Start collapsed so the panel does not cover the scene
        gui.close();
    }
}

new Sample_MeshBVH().run();

Released under the MIT License