射线检测与 MeshBVH
射线检测在 CPU 上计算一条射线与网格三角形的交点,用于鼠标拾取、在模型表面放置物体、视线遮挡判断、贴地等。引擎提供两个入口,它们共用同一套求交逻辑,并由三角形层次包围盒 MeshBVH 加速:
| 入口 | 适用场景 |
|---|---|
| Raycaster | 任意射线(不限于从相机出发),对任意 MeshRenderer 求交,无需碰撞组件;可返回射线上的所有交点 |
拾取事件 bound 模式 + MeshColliderShape | 鼠标悬停、点击等事件驱动的交互 |
与 帧缓冲拾取 相比,射线检测不依赖渲染结果,可以从任意位置、沿任意方向发射,能拿到射线穿过的每一个交点,并提供三角形索引、重心坐标、UV 等信息。
Raycaster
基本用法
import { Raycaster } from '@orillusion/core';
const raycaster = new Raycaster();
// 从相机经过画布上的某个像素构造射线
raycaster.setFromCamera(view.camera, input.mouseX, input.mouseY);
// 与场景中的所有网格求交(默认递归子节点),结果按距离由近到远排序
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 会遍历给定节点下所有启用的 MeshRenderer 并逐个求交,不需要添加 ColliderComponent。天空球(SkyRenderer、AtmosphericComponent、EarthSkyRenderer)默认会被跳过,对整个 scene 求交时不会在远处命中天空,需要时可以通过 includeSky 打开。
构造射线
| 方法 | 说明 |
|---|---|
setFromCamera(camera, x, y) | 从相机经过画布像素 (x, y) 构造射线。坐标为相对画布左上角的 CSS 像素,与 InputSystem.mouseX/Y、PointerEvent3D.mouseX/Y 一致 |
setFromCameraNDC(camera, x, y) | 从相机经过标准化设备坐标 (x, y) 构造射线,x、y 取值 -1 ~ 1,y 向上 |
set(origin, direction) | 直接指定世界空间的起点与方向(方向会被归一化),也可在构造函数中传入 |
// 从角色头顶向下发射,检测脚下的地面
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;求交方法
| 方法 | 说明 |
|---|---|
intersectObject(object, recursive = true, out = []) | 与一个节点(及其子节点)求交,返回所有交点,按距离排序 |
intersectObjects(objects, recursive = true, out = []) | 同上,传入多个根节点 |
intersectFirst(objects, recursive = true) | 只返回最近的一个交点,没有则为 null。每个网格只会搜索到当前最近距离为止,比 intersectObjects(...)[0] 更快 |
选项
| 属性 | 默认值 | 说明 |
|---|---|---|
near / far | 0 / Infinity | 只保留距离在此范围内的交点(世界单位) |
layerMask | VisibleLayer.All | 只检测 visibleLayer 与之有交集的渲染器 |
filter | null | 逐个渲染器的过滤函数,返回 false 跳过 |
includeSky | false | 是否检测天空球(SkyRenderer、AtmosphericComponent、EarthSkyRenderer) |
side | 'material' | 可命中的面:'material' 跟随每个子网格材质的 cullMode(即画面上看得到的面),'front' / 'back' 只命中正面 / 背面,'double' 双面 |
firstHitOnly | false | 每个网格只报告最近的交点,而不是射线穿过的所有交点 |
useBVH | 'auto' | 何时使用 MeshBVH,见 MeshBVH 加速 |
deformation | 'cpu' | 蒙皮与形变动画网格的处理方式,见 蒙皮与形变动画 |
// 忽略标记物体,只检测正面
raycaster.filter = (renderer) => renderer.object3D !== marker;
raycaster.side = 'front';已禁用的物体、渲染器和材质,以及非 triangle-list 拓扑(线、点)的子网格不会被检测;被 InstanceDrawComponent 合批的渲染器虽然自身被禁用,但仍然可以被检测到。
命中结果
每个交点是一个新的 RaycastHit 对象,可以放心保存:
| 字段 | 说明 |
|---|---|
distance | 射线起点到交点的世界空间距离 |
point / localPoint | 交点的世界坐标 / 网格本地坐标 |
normal | 世界空间法线:按重心坐标插值的顶点法线,几何体没有法线时使用 faceNormal。命中背面时不会翻转 |
faceNormal | 命中三角形的几何法线(世界空间,由三角形绕序决定) |
faceIndex | 三角形在索引缓冲中的序号,顶点为 indices[faceIndex * 3 .. + 2] |
barycentric | 三个顶点的重心坐标权重 (w0, w1, w2) |
frontFace | 是否命中三角形的正面(逆时针,与渲染一致) |
uv / uv1 | 插值后的 TEXCOORD_0 / TEXCOORD_1,几何体没有时为 null |
subGeometryIndex | 三角形所属的子几何体,对应 renderer.materials[subGeometryIndex] |
object3D / renderer | 被命中的物体与渲染器 |
MeshBVH 加速
不使用加速结构时,每条射线都要测试网格的每一个三角形,耗时与三角形数量成正比。MeshBVH 为几何体的三角形建立一棵层次包围盒树,查询时只需访问射线经过的少数节点,耗时近似为对数级。以一个约 32 万个三角形的网格为例,建树约需 140 ms,之后每条射线约 13 µs,而逐个三角形测试约需 4.4 ms,快了约 300 倍。
何时使用 BVH
Raycaster.useBVH 与 MeshColliderShape.useBVH 决定是否使用 BVH:
| 取值 | 说明 |
|---|---|
'auto' | 几何体已建好 BVH 时使用,否则逐个三角形测试。Raycaster 的默认值 |
'always' | 首次查询时自动建树并缓存。MeshColliderShape 的默认值 |
'never' | 始终逐个三角形测试 |
对于需要频繁检测的高面数模型,建议提前建树,避免首次检测时卡顿:
import { MeshBVH } from '@orillusion/core';
// 建树并缓存,之后 'auto' 模式的 Raycaster 会自动使用它
const bvh = MeshBVH.build(meshRenderer.geometry);
console.log(bvh.triCount, bvh.nodeCount, bvh.depth);缓存与更新
BVH 按几何体缓存,多个渲染器共用同一个几何体时也共用同一棵树:
| 方法 | 说明 |
|---|---|
MeshBVH.build(geometry, maxLeafTris = 8) | 建树(或重建)并缓存,maxLeafTris 为每个叶节点最多包含的三角形数 |
MeshBVH.get(geometry) | 获取缓存的树,没有则建树 |
MeshBVH.has(geometry) | 是否已有缓存的树 |
MeshBVH.invalidate(geometry) | 丢弃缓存,下次 get 时重建 |
bvh.refit() | 保持树结构,按当前顶点位置重新计算节点包围盒 |
几何体的顶点变化后,缓存的树会在下次查询时自动更新:在原数组上修改顶点并重新上传(如再次调用 geometry.setAttribute)时执行 refit();替换为新的顶点或索引数组时完整重建。如果只是在原数组上修改了顶点而没有重新上传,需要手动调用 bvh.refit() 或 MeshBVH.invalidate()。
TIP
refit() 开销很小(与三角形数量成线性关系),但只更新包围盒、不调整树结构。顶点变形幅度很大时查询结果依然正确,只是会变慢,此时可以用 MeshBVH.build() 重建。
BVH 只包含每个子几何体 LOD 0 的三角形,支持带索引与不带索引的几何体,以及预制体(compose_bin)的交错顶点格式。它描述的是几何体本身的顶点,不包含蒙皮和形变动画。
直接查询 MeshBVH
需要在几何体的本地空间做自定义计算时,也可以直接使用 MeshBVH 查询。注意它的输入与结果都在几何体本地空间,距离 t 也以本地单位计:
import { MeshBVH, MeshRenderer, Vector3 } from '@orillusion/core';
const renderer = obj.getComponent(MeshRenderer);
const bvh = MeshBVH.get(renderer.geometry);
// 把世界空间的射线变换到网格本地空间
const inv = renderer.transform.worldMatrix.clone().invert();
const origin = inv.transformPoint(worldOrigin.clone());
const direction = inv.transformVector(worldDirection.clone()).normalize();
// 最近的交点,只检测正面,最远 100 个本地单位
const hit = bvh.raycast(origin, direction, { cull: 'back', far: 100 });
if (hit) console.log(hit.t, hit.triIndex, hit.point, hit.faceNormal);| 方法 | 说明 |
|---|---|
raycast(origin, direction, options?, target?) | 最近的交点,没有则为 null;可传入 target 复用结果对象 |
raycastAll(origin, direction, options?, out?) | 所有交点,按距离排序;穿过共享边或顶点时只报告一次 |
intersectsRay(origin, direction, options?) | 是否与任意三角形相交,找到第一个即返回,适合视线遮挡判断 |
MeshBVH.raycastGeometry(geometry, ...) | 不建树,逐个三角形求最近交点,适合一次性查询或很小的网格 |
MeshBVH.raycastGeometryAll(geometry, ...) | 不建树,返回所有交点 |
查询选项 options:
| 字段 | 默认值 | 说明 |
|---|---|---|
cull | 'none' | 忽略的面:'none'、'back'(跳过背面,与 cullMode: 'back' 的材质看到的一致)、'front';true 等同于 'back' |
near / far | 0 / Infinity | 只保留距离在此范围内的交点 |
结果 MeshBVHHit 包含 t(沿射线的距离)、triIndex、重心坐标 u / v(v0 的权重为 1 - u - v)、point、faceNormal、frontFace 与 subGeometryIndex。
蒙皮与形变动画
骨骼动画与 Morph 形变动画在 GPU 的顶点着色器中完成,几何体中保存的仍是绑定姿态,直接对它求交会命中错误的位置。Raycaster.deformation 决定如何处理这类网格:
| 取值 | 说明 |
|---|---|
'cpu' | 默认。在 CPU 上按与顶点着色器相同的方式计算变形后的顶点,再对结果求交,交点与画面一致 |
'bindPose' | 对未变形的绑定姿态求交 |
'skip' | 忽略这类网格 |
CPU 变形由 MeshDeformer 完成,先应用 Morph 形变再应用蒙皮,支持 glTF 的 SkinnedMeshRenderer2 + AnimatorComponent 以及预制体的 SkinnedMeshRenderer + SkeletonAnimationComponent。变形结果按渲染器缓存,只有骨骼矩阵或形变权重变化时才重新计算,并通过 refit() 更新 BVH。
MeshColliderShape 也有同名的 deformation 属性,ColliderComponent 会自动关联同一物体上的渲染器,因此 bound 模式的拾取事件同样能跟随动画。
下面的示例每帧从鼠标位置发射射线:红点是按动画变形后的命中位置,黄点沿法线方向,蓝点是同一条射线命中绑定姿态的位置。可以在面板中切换动画、调节狮子的形变权重,或切换 deformation 模式对比效果。
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();与拾取事件配合
在 拾取事件 的 bound 模式下,MeshColliderShape 使用同样的求交逻辑,默认首次拾取时自动建立 BVH,即使是几十万面的模型也能逐帧精确拾取:
import { ColliderComponent, MeshColliderShape } from '@orillusion/core';
// 不设置 shape.mesh 时,自动使用同一物体上 MeshRenderer 的几何体
const collider = obj.addComponent(ColliderComponent);
collider.shape = new MeshColliderShape();MeshColliderShape 属性 | 默认值 | 说明 |
|---|---|---|
mesh | 自动 | 用于拾取的几何体,未设置时使用同一物体上 MeshRenderer 的几何体 |
useBVH | 'always' | 何时使用 BVH |
backfaceCulling | false | 是否跳过背面 |
maxDistance | Infinity | 忽略超过该距离(世界单位)的交点 |
deformation | 'cpu' | 蒙皮与形变动画网格的处理方式 |
bound 模式的拾取事件数据 e.data 中包含以下字段,所有坐标与法线均为世界空间:
| 字段 | 说明 |
|---|---|
worldPos / localPos | 交点的世界坐标 / 碰撞体本地坐标 |
worldNormal | 交点处的法线 |
distance | 射线起点到交点的距离 |
faceNormal、faceIndex、barycentric、uv、uv1、subGeometryIndex、frontFace | 仅 MeshColliderShape,含义同 命中结果 |
hits | 射线穿过的所有碰撞体的交点,由近到远排序 |
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 与 view.pickFire.filter 可以排除不参与拾取的碰撞体,用法与 Raycaster 相同。
下面的示例中,约 32 万面的球体、4 万面的圆环和几个缩放过的立方体都使用 MeshColliderShape。移动鼠标时逐帧精确拾取并在交点处放置标记;点击时用 Raycaster 对整个场景求交,列出射线穿过的所有交点;Benchmark 面板可以对比 BVH 与逐个三角形测试的耗时。
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();
