Three.js 实现鼠标交互:使用 Raycaster 进行点击选中与拖拽
详细讲解在 Three.js 中通过 Raycaster 光线投射器实现鼠标点击选中与三维空间平滑拖拽的核心原理、坐标转换公式、虚拟平面投影算法及完整可执行代码示例。
一、为什么 2D 鼠标能选中 3D 物体:Raycaster 工作机制
网页上的 WebGL 画布本质上是一个 2D 像素区域,用户在屏幕上移动或点击鼠标时,系统捕获的事件参数(如 e.clientX、e.clientY)都是基于 DOM 视口的二维像素值。而在场景内部,几何体处于由模型矩阵、视图矩阵和投影矩阵定义的三维坐标系中。
要在 2D 屏幕与 3D 空间之间建立联系,Three.js 提供了 THREE.Raycaster。它的工作过程分为三个阶段:
- 坐标规格化:将 DOM 像素坐标转换为取值在
[-1, 1]之间的标准设备坐标(Normalized Device Coordinates,简称 NDC)。 - 射线发射:以当前渲染相机的视点作为原点,朝向 NDC 对应投影方向发射一条无限延伸的 3D 几何射线(Ray)。
- 碰撞检测:计算该射线与场景中指定三维网格(Mesh)三角形面的几何交点,并按距离从近到远返回相交结果数组。
二、核心前置步骤:屏幕坐标转标准设备坐标 (NDC)
在调用 Raycaster 之前,必须将事件回调中的 2D 像素位置转换为相机投影裁剪空间所识别的 NDC 坐标。
屏幕坐标与 NDC 坐标存在两个根本区别:
- 原点位置与数值范围不同:屏幕像素坐标原点在画布左上角,水平轴向右为正(范围
0 ~ width),垂直轴向下为正(范围0 ~ height);而 NDC 坐标原点在视口中心,X 与 Y 的取值范围均严格为[-1, 1]。 - Y 轴方向相反:屏幕像素坐标的 Y 轴朝下递增,而 WebGL 和 Three.js 的三维空间在投影后,NDC 坐标系的 Y 轴正方向朝上。因此计算 Y 坐标时必须进行符号反转。
坐标转换的标准计算封装
如果 WebGL 画布(renderer.domElement)不是铺满全屏,或者页面存在滚动条与外部边距,必须使用 getBoundingClientRect() 消除外层偏移,确保计算的精确性:
// 维护一个全局复用的二维向量,避免频繁创建对象引发 GC
const pointer = new THREE.Vector2();
function updatePointerCoords(event, canvas) {
const rect = canvas.getBoundingClientRect();
// 转换为 [-1, 1] 的 NDC 坐标
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}
三、实现鼠标点击选中与材质高亮
点击选中的核心逻辑是:当鼠标按下(pointerdown 或 click)时更新 NDC 坐标,通过 raycaster.setFromCamera() 定向射线,再通过 raycaster.intersectObjects() 检测目标列表。
1. 相交结果解析
调用 raycaster.intersectObjects(targetList, recursive) 后,会返回一个包含相交信息的对象数组。若没有物体被射线穿透,则返回空数组 [];若击中多个重叠物体,数组中的元素会自动按照距离相机的远近从小到大升序排序。
通常情况下,排在首位的 intersects[0] 就是离用户视线最近、最先被看到的有效命中目标。每个交点对象包含以下关键属性:
point:射线击中网格表面的确切 3D 世界坐标(THREE.Vector3)。distance:相机原点到命中交点的实际三维欧氏距离。object:被击中的THREE.Mesh实例。face:被击中的几何体三角形面信息及法向量。
2. 点击选中的代码实现
const raycaster = new THREE.Raycaster();
let selectedObject = null;
const originalEmissive = new THREE.Color();
window.addEventListener('pointerdown', (event) => {
// 1. 获取 NDC 坐标
updatePointerCoords(event, renderer.domElement);
// 2. 根据当前相机配置射线
raycaster.setFromCamera(pointer, camera);
// 3. 计算相交(仅对可交互的目标列表检测)
const intersects = raycaster.intersectObjects(interactiveObjects, false);
if (intersects.length > 0) {
const hitMesh = intersects[0].object;
// 如果点击了新物体,先恢复上一个物体的颜色
if (selectedObject && selectedObject !== hitMesh) {
selectedObject.material.emissive.copy(originalEmissive);
}
// 选中新物体
if (selectedObject !== hitMesh) {
selectedObject = hitMesh;
originalEmissive.copy(selectedObject.material.emissive);
// 设置高亮自发光颜色(如金黄色)
selectedObject.material.emissive.setHex(0xF59E0B);
}
} else {
// 点击了空白区域,取消选中并恢复材质
if (selectedObject) {
selectedObject.material.emissive.copy(originalEmissive);
selectedObject = null;
}
}
});
emissive(自发光颜色)或切换线框轮廓(Outline),尽量避免直接替换整个材质或几何体,以免引起 GPU 重新编译着色器管线产生掉帧。
四、三维空间鼠标拖拽原理与实现
许多初学者在实现 3D 拖拽时,容易误将 2D 鼠标的位移增量直接加在 3D 物体的坐标上。这会导致严重的穿模、视角变形或远近位置不可控,因为三维空间具有深度(Z轴),缺乏平面约束的投影无法映射到确切的 3D 点。
1. 虚拟相交平面(Drag Plane)机制
解决 3D 拖拽的标准做法是在三维空间中动态构造一个看不见的虚拟几何平面(THREE.Plane):
- 确定平面的法向量:最常用的方式是将平面的法向量设置为面向相机的视线反方向(
camera.getWorldDirection()取反),这样无论相机从什么角度观察,拖拽平面始终正对相机屏幕,移动感受最为自然;或者将平面固定为水平地面(法线为(0, 1, 0)),实现沿地表的平移。 - 确定平面上的共面点:将虚拟平面穿过被点击物体的当前世界坐标中心。
- 记录抓取偏移量(Offset):鼠标点击点通常位于模型的表面边缘,而不是模型的中心点。在按下瞬间,计算
初始交点与模型中心点之间的向量差offset = object.position - hitPoint。在拖拽移动过程中,始终将该 offset 加回到新交点上,保证模型不会突然以中心点“瞬移”对齐到光标。
2. 拖拽全生命周期实现逻辑
拖拽分为三个阶段:按下(初始化平面与偏移)、移动(射线与平面求交并更新模型位置)、抬起(注销状态并恢复场景控制)。完整实现如下:
// 拖拽辅助对象
const dragPlane = new THREE.Plane();
const planeIntersectPoint = new THREE.Vector3();
const dragOffset = new THREE.Vector3();
let isDragging = false;
// 1. 鼠标按下:判定是否启动拖拽
window.addEventListener('pointerdown', (event) => {
updatePointerCoords(event, renderer.domElement);
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(interactiveObjects, false);
if (intersects.length > 0) {
const hit = intersects[0];
selectedObject = hit.object;
isDragging = true;
// 禁用轨道控制器,防止拖拽模型时视角发生旋转
if (controls) controls.enabled = false;
// 构造对齐相机朝向、且穿过物体当前位置的虚拟平面
const normal = new THREE.Vector3();
camera.getWorldDirection(normal).negate(); // 法线指向相机
dragPlane.setFromNormalAndCoplanarPoint(normal, selectedObject.position);
// 计算鼠标射线与虚拟平面的交点
raycaster.ray.intersectPlane(dragPlane, planeIntersectPoint);
// 计算物体中心到点击交点的相对偏移向量
dragOffset.subVectors(selectedObject.position, planeIntersectPoint);
document.body.style.cursor = 'grabbing';
}
});
// 2. 鼠标移动:计算新交点并移动物体
window.addEventListener('pointermove', (event) => {
updatePointerCoords(event, renderer.domElement);
if (isDragging && selectedObject) {
raycaster.setFromCamera(pointer, camera);
// 计算当前射线与虚拟拖拽平面的新交点
if (raycaster.ray.intersectPlane(dragPlane, planeIntersectPoint)) {
// 将物体位置更新为:新交点 + 初始抓取偏移量
selectedObject.position.addVectors(planeIntersectPoint, dragOffset);
}
} else {
// 悬停反馈:鼠标指针移动到可点击模型上方时改变光标状态
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObjects(interactiveObjects, false);
document.body.style.cursor = intersects.length > 0 ? 'pointer' : 'default';
}
});
// 3. 鼠标释放:结束拖拽
window.addEventListener('pointerup', () => {
if (isDragging) {
isDragging = false;
document.body.style.cursor = 'default';
// 恢复相机轨道控制器的交互功能
if (controls) controls.enabled = true;
}
});
五、工程架构、状态管理与性能优化
在实际生产项目中,随着三维场景复杂度的提升,如果不加节制地执行 Raycaster 检测,极易导致帧率骤降。同时,多状态交互与相机控制器之间也需要合理的生命周期管理。
关键优化策略
| 优化方向 | 问题根源 | 工程解决方案 |
|---|---|---|
| 目标筛选隔离 | 直接对 scene.children 进行投射会遍历灯光、辅助线和大量静态装饰物,消耗海量 CPU 算力。 |
维护专用的 interactiveObjects 数组,仅将需要点击响应的网格加入检测列表,避免深度递归。 |
| 高面数碰撞体代理 | 十万级多边形模型在进行精确三角形求交时会导致主线程卡死。 | 为高模绑定一个不可见(visible = false)的简易几何体(如立方体或胶囊体),射线只对代理体求交。 |
| 轨道控制器冲突 | OrbitControls 默认接管了全画布的指针事件,拖拽物体时会导致视角同步旋转错位。 |
在 pointerdown 命中物体后立即执行 controls.enabled = false,在 pointerup 后重新开启。 |
| 对象池与垃圾回收 | 在频繁触发的 pointermove 中调用 new THREE.Vector2() 会引发频繁 GC 产生微卡顿。 |
全局初始化单例向量(如 pointer、offset、plane),在回调中使用 .copy() 或 .set() 复用。 |
六、完整可运行示例代码
下面是一个可以直接运行的完整原生 HTML/JS 演示实例,包含了基础三维场景搭建、若干网格模型、点击变色与在面向相机的平面上平滑拖拽的功能:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Three.js 点击选中与拖拽演示</title>
<style>
body { margin: 0; overflow: hidden; }
#canvas-box { width: 100vw; height: 100vh; }
</style>
</head>
<body>
<div id="canvas-box"></div>
<!-- 引入 Three.js 与 OrbitControls -->
<script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
<script src="https://unpkg.com/three@0.160.0/examples/js/controls/OrbitControls.js"></script>
<script>
// 1. 初始化场景、相机与渲染器
const container = document.getElementById('canvas-box');
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xF1F5F9);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 5, 12);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
// 灯光配置
const ambientLight = new THREE.AmbientLight(0xffffff, 0.8);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight.position.set(5, 12, 8);
scene.add(dirLight);
// 参考网格地标
const gridHelper = new THREE.GridHelper(20, 20, 0x94A3B8, 0xCBD5E1);
scene.add(gridHelper);
// 2. 创建可交互的测试立方体
const interactiveObjects = [];
const geometry = new THREE.BoxGeometry(1.6, 1.6, 1.6);
const colors = [0x456581, 0x438A80, 0xD97706, 0x6366F1];
for (let i = 0; i < 4; i++) {
const material = new THREE.MeshStandardMaterial({
color: colors[i],
roughness: 0.3,
metalness: 0.1
});
const box = new THREE.Mesh(geometry, material);
box.position.set((i - 1.5) * 2.8, 1, 0);
scene.add(box);
interactiveObjects.push(box);
}
// 3. 交互逻辑与变量声明
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
const dragPlane = new THREE.Plane();
const planePoint = new THREE.Vector3();
const dragOffset = new THREE.Vector3();
let selectedObject = null;
let isDragging = false;
const originalEmissive = new THREE.Color();
function getNDC(event) {
const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
}
// 指针按下事件
window.addEventListener('pointerdown', (e) => {
getNDC(e);
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(interactiveObjects, false);
if (hits.length > 0) {
const hitObj = hits[0].object;
// 重置旧物体的自发光
if (selectedObject && selectedObject !== hitObj) {
selectedObject.material.emissive.copy(originalEmissive);
}
// 绑定新选中物体
if (selectedObject !== hitObj) {
selectedObject = hitObj;
originalEmissive.copy(selectedObject.material.emissive);
selectedObject.material.emissive.setHex(0x3B82F6); // 蓝色高亮
}
isDragging = true;
controls.enabled = false; // 锁定相机视角
// 构造正对相机且穿过物体中心的约束平面
const normal = new THREE.Vector3();
camera.getWorldDirection(normal).negate();
dragPlane.setFromNormalAndCoplanarPoint(normal, selectedObject.position);
// 计算初始抓取偏移向量
raycaster.ray.intersectPlane(dragPlane, planePoint);
dragOffset.subVectors(selectedObject.position, planePoint);
document.body.style.cursor = 'grabbing';
} else {
// 点击空白处取消选中
if (selectedObject) {
selectedObject.material.emissive.copy(originalEmissive);
selectedObject = null;
}
}
});
// 指针移动事件
window.addEventListener('pointermove', (e) => {
getNDC(e);
raycaster.setFromCamera(pointer, camera);
if (isDragging && selectedObject) {
if (raycaster.ray.intersectPlane(dragPlane, planePoint)) {
selectedObject.position.addVectors(planePoint, dragOffset);
}
} else {
const hits = raycaster.intersectObjects(interactiveObjects, false);
document.body.style.cursor = hits.length > 0 ? 'pointer' : 'default';
}
});
// 指针抬起事件
window.addEventListener('pointerup', () => {
if (isDragging) {
isDragging = false;
controls.enabled = true;
document.body.style.cursor = 'default';
}
});
// 窗口尺寸自适应
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// 4. 循环渲染帧
function animate() {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>
七、常见排查与避坑要点
- 物体不能被点击选中:
- 检查
updateProjectionMatrix()是否在相机参数改变或窗口缩放后调用。若投影矩阵未更新,射线方向与屏幕像素对应关系会出现偏移。 - 检查模型的
visible属性。默认情况下,Raycaster 不会检测visible = false的物体。 - 如果物体是层级复杂的
THREE.Group,intersectObjects的第二个参数recursive需设为true,且获取到的intersects[0].object为具体的子网格,而不是外层父 Group。
- 检查
- 拖拽时物体瞬间瞬移或发生剧烈跳动:
- 必须记录并应用
dragOffset偏移量。如果直接将物体坐标设置为planePoint,物体中心会在抓取的瞬间跳跃对齐到鼠标击中点。 - 必须在拖拽时暂时设置
controls.enabled = false,否则鼠标移动会同时驱动 OrbitControls 旋转视角,导致虚拟平面和射线方向剧烈变化。
- 必须记录并应用
- 物体在屏幕边缘时拾取不准确:
- 确保没有将
window.innerWidth / window.innerHeight与容器元素的宽高混淆。非全屏场景务必根据canvas.getBoundingClientRect()动态提取尺寸。
- 确保没有将
JavaScript将图片转换为Base64编码的两种途径
2026-11-05 16:43:05
怎么在 Vue 或 React 项目中正确集成 animate.js
2026-11-05 10:23:38
JS注册事件:addEventListener基础语法与参数详解
2026-11-05 08:44:56
JSP和JS的区别:运行环境与服务端/客户端职责解析
2026-11-04 23:34:36
pdf.js 官方下载地址:GitHub Release 获取预编译包
2026-11-04 23:07:27
相关标签:








