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 point | Use it for |
|---|---|
| Raycaster | Any ray (not only from the camera) against any MeshRenderer, no collider needed; can return every hit along the ray |
Pick events in bound mode + MeshColliderShape | Event-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
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
| Method | Description |
|---|---|
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 |
// 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
| Method | Description |
|---|---|
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
| Property | Default | Description |
|---|---|---|
near / far | 0 / Infinity | Only keep hits within this distance range (world units) |
layerMask | VisibleLayer.All | Only test renderers whose visibleLayer shares a bit with it |
filter | null | Per-renderer filter; return false to skip a renderer |
includeSky | false | Whether 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 |
firstHitOnly | false | Report 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 |
// 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:
| Field | Description |
|---|---|
distance | World-space distance from the ray origin to the hit |
point / localPoint | Hit point in world space / in the mesh's local space |
normal | World-space normal: the vertex normals interpolated with the barycentric coordinates, or faceNormal when the geometry has no normals. Not flipped for back-face hits |
faceNormal | Geometric normal of the hit triangle (world space, from the triangle winding) |
faceIndex | Triangle index into the index buffer; its vertices are indices[faceIndex * 3 .. + 2] |
barycentric | Barycentric weights (w0, w1, w2) of the triangle's three vertices |
frontFace | Whether the ray hit the front (counter-clockwise, as rendered) side of the triangle |
uv / uv1 | Interpolated TEXCOORD_0 / TEXCOORD_1, or null when the geometry has none |
subGeometryIndex | Sub-geometry of the triangle; drawn with renderer.materials[subGeometryIndex] |
object3D / renderer | The 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:
| Value | Description |
|---|---|
'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:
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:
| Method | Description |
|---|---|
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:
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);| Method | Description |
|---|---|
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:
| Field | Default | Description |
|---|---|---|
cull | 'none' | Faces to ignore: 'none', 'back' (skip back faces, as a cullMode: 'back' material shows them) or 'front'; true means 'back' |
near / far | 0 / Infinity | Only 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:
| Value | Description |
|---|---|
'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.
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:
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 property | Default | Description |
|---|---|---|
mesh | automatic | Geometry to pick against; when unset, the geometry of the MeshRenderer on the same object |
useBVH | 'always' | When to use the BVH |
backfaceCulling | false | Whether to skip back faces |
maxDistance | Infinity | Ignore 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:
| Field | Description |
|---|---|
worldPos / localPos | Hit point in world space / in the collider's local space |
worldNormal | Normal at the hit point |
distance | Distance from the ray origin to the hit |
faceNormal, faceIndex, barycentric, uv, uv1, subGeometryIndex, frontFace | MeshColliderShape only; same meaning as in Hit Results |
hits | Hits on every collider the ray crosses, nearest first |
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.
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();
