交互基础
Three.js 提供了多种方式实现用户交互,其中 Raycaster(射线投射器)是实现鼠标拾取和交互的核心工具。
概述
交互是 3D 应用的关键功能,通过射线检测、碰撞检测等技术,可以实现物体选择、拖拽、点击等交互操作。本文档详细介绍 Three.js 中的交互实现机制。
核心概念
射线投射原理
射线投射(Raycasting)是从一个点沿某个方向发射一条无限长的射线,检测该射线与场景中几何体的相交情况。
plaintext
相机位置 (Camera)
│
▼
┌─────────────────────────────┐
│ 屏幕空间 │
│ ┌───────────────────┐ │
│ │ 鼠标位置 (x,y) │ │
│ │ ● │ │
│ │ │ │ │
│ └─────────│─────────┘ │
│ │ │
│ ▼ │
│ 射线 (Raycaster) │
│ │ │
│ ▼ │
│ ┌───────────────────┐ │
│ │ 相交物体 (Mesh) │ │
│ └───────────────────┘ │
└─────────────────────────────┘坐标系统
| 坐标系 | 范围 | 说明 |
|---|---|---|
| 屏幕坐标 | 0 ~ width/height | 鼠标在屏幕上的像素位置 |
| 标准化设备坐标 (NDC) | -1 ~ 1 | OpenGL 标准坐标,原点在中心 |
| 世界坐标 | 无限制 | 3D 场景中的实际位置 |
坐标转换
javascript
// 屏幕坐标 → NDC
const ndcX = (screenX / window.innerWidth) * 2 - 1;
const ndcY = -(screenY / window.innerHeight) * 2 + 1;
// NDC → 屏幕坐标
const screenX = (ndcX + 1) / 2 * window.innerWidth;
const screenY = -(ndcY - 1) / 2 * window.innerHeight;Raycaster 基础
创建 Raycaster
javascript
import * as THREE from 'three';
// 创建射线投射器
const raycaster = new THREE.Raycaster();
// 设置射线参数
raycaster.set(
origin, // 起点(Vector3)
direction, // 方向(Vector3,需要归一化)
near, // 近裁剪距离(可选,默认 0)
far // 远裁剪距离(可选,默认 Infinity)
);
// 从相机和鼠标位置设置射线
raycaster.setFromCamera(mouse, camera);Raycaster 属性
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
ray | Ray | - | 射线对象,包含 origin 和 direction |
near | number | 0 | 近裁剪距离 |
far | number | Infinity | 远裁剪距离 |
camera | Camera | null | 关联的相机(用于 setFromCamera) |
layers | Layers | - | 检测的图层掩码 |
params | Object | - | 检测参数配置 |
检测参数配置
javascript
// 配置检测参数
raycaster.params = {
Mesh: {
threshold: 0.1 // 点云检测阈值
},
Line: {
threshold: 0.1 // 线段检测阈值
},
LOD: {
threshold: 0.1
},
Points: {
threshold: 0.1 // 点检测阈值
}
};鼠标交互
javascript
// 创建射线和鼠标向量
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// 监听鼠标移动
window.addEventListener('mousemove', (event) => {
// 计算鼠标在标准化设备坐标中的位置
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// 更新射线
raycaster.setFromCamera(mouse, camera);
// 检测相交对象
const intersects = raycaster.intersectObjects(scene.children);
if (intersects.length > 0) {
console.log('相交对象:', intersects[0].object);
}
});intersectObjects 方法
javascript
// 检测单个或多个对象
const intersects = raycaster.intersectObjects(objects);
// 完整参数
raycaster.intersectObjects(
objects, // Object3D[] - 要检测的对象数组
recursive, // boolean - 是否递归检测子对象(默认 false)
optionalTarget // Array - 可选的目标数组,用于存储结果
);相交结果结构
javascript
// 返回值(按距离排序)
// [
// {
// distance: 10, // 从射线起点到相交点的距离
// distanceToRay: 0, // 相交点到射线的距离(仅点云)
// point: Vector3, // 世界坐标中的相交点
// index: 0, // 几何体索引(索引几何体)
// face: Face3, // 相交面(三角形面)
// faceIndex: 0, // 面索引
// object: Object3D, // 相交的 3D 对象
// uv: Vector2, // 相交点的 UV 坐标
// uv2: Vector2, // 第二套 UV 坐标
// instanceId: 0 // 实例化对象的实例 ID
// },
// ...
// ]其他检测方法
javascript
// 检测场景中的所有对象
const intersects = raycaster.intersectObjects(scene.children, true);
// 检测单个对象
const intersects = raycaster.intersectObject(mesh);
// 检测点云中的点
const intersects = raycaster.intersectObject(points);
// intersects[0].index - 点的索引
// intersects[0].distanceToRay - 点到射线的距离鼠标拾取示例
基础拾取
javascript
import * as THREE from 'three';
// 场景设置
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 创建多个立方体
const cubes = [];
for (let i = 0; i < 10; i++) {
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
const cube = new THREE.Mesh(geometry, material);
cube.position.x = (Math.random() - 0.5) * 10;
cube.position.y = (Math.random() - 0.5) * 10;
cube.position.z = (Math.random() - 0.5) * 10;
scene.add(cube);
cubes.push(cube);
}
// 光源
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 5, 5);
scene.add(light);
const ambientLight = new THREE.AmbientLight(0x404040);
scene.add(ambientLight);
// 射线和鼠标
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let selectedObject = null;
// 鼠标移动事件
window.addEventListener('mousemove', (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// 更新射线
raycaster.setFromCamera(mouse, camera);
// 检测相交
const intersects = raycaster.intersectObjects(cubes);
// 重置之前选中的对象
if (selectedObject) {
selectedObject.material.color.setHex(0x00ff00);
}
// 高亮新选中的对象
if (intersects.length > 0) {
selectedObject = intersects[0].object;
selectedObject.material.color.setHex(0xff0000);
} else {
selectedObject = null;
}
});
// 动画循环
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();点击选择
javascript
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let selectedObjects = [];
// 点击事件
window.addEventListener('click', (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(cubes);
if (intersects.length > 0) {
const object = intersects[0].object;
// 切换选中状态
const index = selectedObjects.indexOf(object);
if (index > -1) {
// 取消选中
selectedObjects.splice(index, 1);
object.material.color.setHex(0x00ff00);
} else {
// 选中
selectedObjects.push(object);
object.material.color.setHex(0xff0000);
}
}
});
// Ctrl + 点击多选
window.addEventListener('click', (event) => {
if (!event.ctrlKey) {
// 清除之前的选择
selectedObjects.forEach(obj => obj.material.color.setHex(0x00ff00));
selectedObjects = [];
}
// ... 其余逻辑
});拖拽交互
基础拖拽
javascript
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
let isDragging = false;
let draggedObject = null;
let plane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
let intersection = new THREE.Vector3();
let offset = new THREE.Vector3();
// 鼠标按下
window.addEventListener('mousedown', (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(cubes);
if (intersects.length > 0) {
isDragging = true;
draggedObject = intersects[0].object;
// 设置拖拽平面(垂直于相机方向,经过物体位置)
plane.setFromNormalAndCoplanarPoint(
camera.getWorldDirection(new THREE.Vector3()),
draggedObject.position
);
// 计算偏移量
raycaster.ray.intersectPlane(plane, intersection);
offset.copy(intersection).sub(draggedObject.position);
// 防止穿透
renderer.domElement.style.cursor = 'grabbing';
}
});
// 鼠标移动
window.addEventListener('mousemove', (event) => {
if (!isDragging) return;
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
// 计算射线与平面的交点
if (raycaster.ray.intersectPlane(plane, intersection)) {
draggedObject.position.copy(intersection.sub(offset));
}
});
// 鼠标释放
window.addEventListener('mouseup', () => {
isDragging = false;
draggedObject = null;
renderer.domElement.style.cursor = 'default';
});平面拖拽
在特定平面上进行拖拽操作。
javascript
// XY 平面拖拽(Z 轴固定)
const xyPlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0);
// XZ 平面拖拽(Y 轴固定)
const xzPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
// YZ 平面拖拽(X 轴固定)
const yzPlane = new THREE.Plane(new THREE.Vector3(1, 0, 0), 0);
// 使用示例:在地面上拖拽物体
let dragPlane = xzPlane;
window.addEventListener('mousemove', (event) => {
if (!isDragging) return;
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersection = new THREE.Vector3();
if (raycaster.ray.intersectPlane(dragPlane, intersection)) {
draggedObject.position.x = intersection.x;
draggedObject.position.z = intersection.z;
// Y 坐标保持不变
}
});轴向拖拽
沿特定轴进行拖拽。
javascript
// 轴向拖拽辅助函数
function createAxisDragHelper(axis) {
const axisVector = {
x: new THREE.Vector3(1, 0, 0),
y: new THREE.Vector3(0, 1, 0),
z: new THREE.Vector3(0, 0, 1)
};
return {
plane: new THREE.Plane(),
axis: axisVector[axis],
updatePlane(object) {
// 创建垂直于其他两个轴的平面
const normal = new THREE.Vector3();
if (axis === 'x') normal.set(0, 1, 0);
else if (axis === 'y') normal.set(1, 0, 0);
else normal.set(1, 0, 0);
this.plane.setFromNormalAndCoplanarPoint(normal, object.position);
},
move(object, intersection) {
const dot = intersection.dot(this.axis);
object.position[axis] = dot;
}
};
}
// 使用示例
const xDragger = createAxisDragHelper('x');高级交互
轮廓高亮
使用后处理实现高质量的轮廓高亮效果。
javascript
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { OutlinePass } from 'three/addons/postprocessing/OutlinePass.js';
// 创建后处理器
const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
composer.addPass(renderPass);
// 轮廓通道
const outlinePass = new OutlinePass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
scene,
camera
);
composer.addPass(outlinePass);
// 轮廓效果参数
outlinePass.edgeStrength = 5; // 边缘强度
outlinePass.edgeGlow = 1; // 边缘发光
outlinePass.edgeThickness = 2; // 边缘厚度
outlinePass.pulsePeriod = 2; // 脉冲周期(0 表示无脉冲)
outlinePass.visibleEdgeColor.set('#ffffff'); // 可见边缘颜色
outlinePass.hiddenEdgeColor.set('#ffffff'); // 遮挡边缘颜色
// 更新选中对象
function updateOutline(objects) {
outlinePass.selectedObjects = objects;
}
// 使用
window.addEventListener('mousemove', (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(cubes);
if (intersects.length > 0) {
updateOutline([intersects[0].object]);
} else {
updateOutline([]);
}
});
// 渲染循环使用 composer
function animate() {
requestAnimationFrame(animate);
composer.render();
}悬停提示
javascript
// 创建提示元素
const tooltip = document.createElement('div');
tooltip.style.cssText = `
position: absolute;
padding: 10px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
border-radius: 5px;
pointer-events: none;
display: none;
font-size: 14px;
z-index: 1000;
`;
document.body.appendChild(tooltip);
// 显示提示
function showTooltip(object, x, y) {
tooltip.innerHTML = object.userData.name || 'Object';
tooltip.style.left = (x + 15) + 'px';
tooltip.style.top = (y + 15) + 'px';
tooltip.style.display = 'block';
}
// 隐藏提示
function hideTooltip() {
tooltip.style.display = 'none';
}
// 鼠标移动
window.addEventListener('mousemove', (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(cubes);
if (intersects.length > 0) {
showTooltip(intersects[0].object, event.clientX, event.clientY);
renderer.domElement.style.cursor = 'pointer';
} else {
hideTooltip();
renderer.domElement.style.cursor = 'default';
}
});框选
实现矩形框选多个对象。
javascript
// 框选功能
const selectionBox = document.createElement('div');
selectionBox.style.cssText = `
position: absolute;
border: 1px solid #00ff00;
background: rgba(0, 255, 0, 0.1);
pointer-events: none;
display: none;
`;
document.body.appendChild(selectionBox);
let isSelecting = false;
let startPoint = { x: 0, y: 0 };
let selectedByBox = [];
window.addEventListener('mousedown', (event) => {
if (event.button === 0 && event.shiftKey) {
isSelecting = true;
startPoint = { x: event.clientX, y: event.clientY };
selectionBox.style.display = 'block';
updateSelectionBox(startPoint, startPoint);
}
});
window.addEventListener('mousemove', (event) => {
if (!isSelecting) return;
updateSelectionBox(startPoint, { x: event.clientX, y: event.clientY });
});
window.addEventListener('mouseup', (event) => {
if (!isSelecting) return;
isSelecting = false;
selectionBox.style.display = 'none';
// 执行框选检测
performBoxSelection(
startPoint,
{ x: event.clientX, y: event.clientY }
);
});
function updateSelectionBox(start, end) {
const left = Math.min(start.x, end.x);
const top = Math.min(start.y, end.y);
const width = Math.abs(end.x - start.x);
const height = Math.abs(end.y - start.y);
selectionBox.style.left = left + 'px';
selectionBox.style.top = top + 'px';
selectionBox.style.width = width + 'px';
selectionBox.style.height = height + 'px';
}
function performBoxSelection(start, end) {
// 计算框选的 NDC 坐标
const left = Math.min(start.x, end.x) / window.innerWidth * 2 - 1;
const right = Math.max(start.x, end.x) / window.innerWidth * 2 - 1;
const top = -(Math.min(start.y, end.y) / window.innerHeight * 2 - 1);
const bottom = -(Math.max(start.y, end.y) / window.innerHeight * 2 - 1);
// 创建框选视锥体
const frustum = new THREE.Frustum();
const projMatrix = new THREE.Matrix4().makePerspective(
left, right, top, bottom,
camera.near, camera.far
);
projMatrix.multiply(camera.matrixWorldInverse);
frustum.setFromProjectionMatrix(projMatrix);
// 检测对象
selectedByBox = [];
cubes.forEach(cube => {
// 使用包围盒检测
const box = new THREE.Box3().setFromObject(cube);
if (frustum.intersectsBox(box)) {
selectedByBox.push(cube);
cube.material.color.setHex(0xff0000);
}
});
console.log(`选中 ${selectedByBox.length} 个对象`);
}性能优化
减少检测频率
使用节流减少射线检测频率。
javascript
// 节流函数
function throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
// 应用节流
const throttledIntersect = throttle((event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(objects);
// ...
}, 50); // 每 50ms 最多执行一次
window.addEventListener('mousemove', throttledIntersect);限制检测对象
只检测可交互的对象,减少检测范围。
javascript
// 使用 userData 标记可交互对象
mesh.userData.interactive = true;
// 只检测可交互对象
const interactiveObjects = [];
scene.traverse((obj) => {
if (obj.userData.interactive) {
interactiveObjects.push(obj);
}
});
// 或使用图层
const INTERACTIVE_LAYER = 1;
interactiveMesh.layers.set(INTERACTIVE_LAYER);
raycaster.layers.set(INTERACTIVE_LAYER);
const intersects = raycaster.intersectObjects(scene.children);使用包围盒检测
先进行包围盒检测,再进行精确检测。
javascript
// 快速包围盒检测
function quickSelect(mouse, camera, objects) {
raycaster.setFromCamera(mouse, camera);
// 先用包围盒筛选
const candidates = objects.filter(obj => {
const box = new THREE.Box3().setFromObject(obj);
return raycaster.ray.intersectsBox(box);
});
// 再进行精确检测
return raycaster.intersectObjects(candidates);
}八叉树加速
对于大量对象,使用八叉树空间划分加速检测。
javascript
import { Octree } from 'three/addons/math/Octree.js';
// 创建八叉树
const octree = new Octree();
octree.fromGraphNode(scene);
// 使用八叉树进行射线检测
const octreeRaycaster = new THREE.Raycaster();
const result = [];
octreeRaycaster.intersectOctree(octree, result);
console.log('检测结果:', result);按需更新
只在需要时进行检测。
javascript
let needsIntersectCheck = false;
// 鼠标移动时标记需要检测
window.addEventListener('mousemove', () => {
needsIntersectCheck = true;
});
// 在渲染循环中检测
function animate() {
requestAnimationFrame(animate);
if (needsIntersectCheck) {
performIntersection();
needsIntersectCheck = false;
}
renderer.render(scene, camera);
}API 参考
Raycaster 类
构造函数
javascript
new THREE.Raycaster(origin, direction, near, far)| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
origin | Vector3 | (0,0,0) | 射线起点 |
direction | Vector3 | (0,0,-1) | 射线方向(需归一化) |
near | number | 0 | 近裁剪距离 |
far | number | Infinity | 远裁剪距离 |
主要方法
| 方法 | 参数 | 返回值 | 说明 |
|---|---|---|---|
set(origin, direction) | Vector3, Vector3 | void | 设置射线起点和方向 |
setFromCamera(coords, camera) | Vector2, Camera | void | 从相机和 NDC 坐标设置射线 |
intersectObject(object, recursive, target) | Object3D, boolean, Array | Array | 检测单个对象 |
intersectObjects(objects, recursive, target) | Array, boolean, Array | Array | 检测多个对象 |
Ray 类属性
| 属性 | 类型 | 说明 |
|---|---|---|
origin | Vector3 | 射线起点 |
direction | Vector3 | 射线方向(归一化) |
Ray 类方法
| 方法 | 说明 |
|---|---|
at(t, target) | 获取射线上距离 t 处的点 |
lookAt(v) | 设置射线方向指向 v |
closestPointToPoint(point, target) | 射线上距离点最近的点 |
distanceToPoint(point) | 射线到点的距离 |
distanceSqToPoint(point) | 射线到点距离的平方 |
intersectsSphere(sphere) | 是否与球相交 |
intersectsBox(box) | 是否与包围盒相交 |
intersectsPlane(plane) | 是否与平面相交 |
intersectPoint(point, plane, target) | 与平面的交点 |
Plane 类
javascript
// 创建平面
const plane = new THREE.Plane(normal, constant);
// 从法线和点创建
plane.setFromNormalAndCoplanarPoint(normal, point);
// 计算射线与平面的交点
ray.intersectPlane(plane, target);常见问题
Q: 射线检测不到对象?
A: 检查以下几点:
javascript
// 1. 确认对象在场景中
console.log('对象是否在场景:', scene.children.includes(mesh));
// 2. 检查相机视锥
const frustum = new THREE.Frustum();
frustum.setFromProjectionMatrix(
new THREE.Matrix4().multiplyMatrices(
camera.projectionMatrix,
camera.matrixWorldInverse
)
);
console.log('对象是否在视锥内:', frustum.containsPoint(mesh.position));
// 3. 检查材质是否可见
console.log('材质可见:', mesh.material.visible);
// 4. 检查是否开启了递归检测
raycaster.intersectObjects(objects, true); // 递归检测子对象
// 5. 检查射线方向
console.log('射线:', raycaster.ray);Q: 拖拽时物体跳动?
A: 需要计算鼠标点击位置与物体中心之间的偏移:
javascript
let offset = new THREE.Vector3();
// mousedown 时计算偏移
raycaster.ray.intersectPlane(plane, intersection);
offset.copy(intersection).sub(draggedObject.position);
// mousemove 时应用偏移
if (raycaster.ray.intersectPlane(plane, intersection)) {
draggedObject.position.copy(intersection.sub(offset));
}Q: 检测精度不够?
A: 调整检测参数或使用包围盒:
javascript
// 对于线或点云
raycaster.params.Line.threshold = 0.1; // 增大检测阈值
raycaster.params.Points.threshold = 0.5;
// 对于细小物体,使用包围球
const sphere = new THREE.Sphere();
mesh.geometry.computeBoundingSphere();
sphere.copy(mesh.geometry.boundingSphere);
sphere.applyMatrix4(mesh.matrixWorld);
if (raycaster.ray.intersectsSphere(sphere)) {
// 检测到
}Q: 多个对象重叠时如何选择?
A: 使用距离排序或图层优先级:
javascript
// 按距离排序(默认行为)
const intersects = raycaster.intersectObjects(objects);
// intersects[0] 是最近的
// 按优先级选择
const prioritized = intersects.sort((a, b) => {
const priorityA = a.object.userData.priority || 0;
const priorityB = b.object.userData.priority || 0;
return priorityB - priorityA;
});Q: 性能问题:太多对象导致卡顿?
A: 使用空间划分或减少检测频率:
javascript
// 1. 使用八叉树
import { Octree } from 'three/addons/math/Octree.js';
const octree = new Octree();
octree.fromGraphNode(scene);
// 2. 使用图层分组
const nearObjects = objects.filter(obj => obj.position.distanceTo(camera.position) < 50);
raycaster.intersectObjects(nearObjects);
// 3. 节流检测
const throttledCheck = throttle(() => {
// 检测逻辑
}, 100);Q: 如何检测透明物体的背面?
A: 默认情况下射线会穿透透明材质:
javascript
// 方法1:设置 material.side
material.side = THREE.DoubleSide; // 双面渲染
// 方法2:检测所有相交点
const intersects = raycaster.intersectObjects(objects);
intersects.forEach((intersect, index) => {
console.log(`第${index}个相交点:`, intersect.object.name);
});最佳实践
- 合理使用节流:对于频繁触发的事件(如 mousemove),使用节流控制检测频率
- 限制检测范围:只检测可交互的对象,使用图层或 userData 标记
- 空间划分:大量对象时使用八叉树或 BVH 加速
- 包围盒预检:先用包围盒快速筛选,再进行精确检测
- 及时清理:移除对象时同时从检测数组中移除
- 用户体验:添加视觉反馈(光标变化、高亮效果)