怎么在 Three.js 中实现鼠标点击选中 3D 物体

作者:像素猫时间:2026-11-05 23:08:11浏览:265

系统讲解在 Three.js 中通过光线投射器 (Raycaster) 实现鼠标点击精确选中 3D 物体的完整方案。涵盖屏幕坐标归一化换算、空间射线拾取、层级复合模型回溯、材质高亮反馈及拖拽冲突排查。

分类:WebGL / Three.js 核心交互 阅读时长:约 10 分钟 技术要点:Raycaster · 坐标归一化 · 层级遍历 · 高亮反馈
在 Three.js 中实现鼠标点击选中 3D 物体的核心方案是光线投射法 (Raycasting)。浏览器中 Canvas 渲染的是三维世界投影后的二维画面,原生 DOM 无法直接获知鼠标点击命中了哪个几何模型。标准做法是:先获取鼠标点击相对 Canvas 的屏幕坐标,将其换算为取值范围在 [-1, 1] 之间的归一化设备坐标 (NDC);然后由相机位置穿过该点向三维空间发射一条虚拟射线;最后利用 THREE.Raycaster 计算该射线与场景网格体的相交列表,以距离最近的交点对象作为选中目标,并为其更新材质或状态。
怎么在 Three.js 中实现鼠标点击选中 3D 物体
图 0:Three.js 鼠标点击与光线投射拾取机制架构示意

一、核心原理:从二维屏幕像素到三维空间射线的映射

初次接触 Three.js 的开发者往往直觉地认为可以像操作常规 HTML 按钮那样,直接给 mesh.addEventListener('click', handler) 绑定事件。这种直觉很自然,但实际上 3D 网格体只是内存中顶点和材质的数据结构,所有模型最终都会被 WebGL 统一光栅化输出到单块 <canvas> 画布上。浏览器在物理层面只能感知到点击了画布的某一行像素,无法区分深层的 3D 几何结构。

为了解决这一空间跨度问题,计算机图形学通常采用射线拾取技术。整个过程可清晰拆解为三个几何阶段:

  • 屏幕像素空间:浏览器提供的事件参数(如 clientX、clientY)以屏幕左上角为原点,数值单位是物理像素,且 Y 轴向下递增。
  • 归一化设备坐标 (NDC):Three.js 相机计算要求将任意视口尺寸统一投影到以中心为原点 (0, 0)、取值范围在 [-1, 1] 的标准化空间,且垂直 Y 轴向上递增。
  • 世界射线发射:利用当前相机视锥体投影矩阵的逆变换,从相机原点穿过近裁剪面上的 NDC 点生成射线向量 Ray(origin, direction),与空间中的三角形面进行碰撞相交测试。
怎么在 Three.js 中实现鼠标点击选中 3D 物体
图 1:屏幕像素坐标经 NDC 映射至三维视锥体射线投射的几何变换原理

二、关键步骤一:将鼠标物理坐标换算为归一化设备坐标 (NDC)

这是最容易产生坐标偏移的一步。在很多简化示例中,常能见到直接使用 window.innerWidth 和 window.innerHeight 的写法。这种写法在整个页面恰好完全被全屏 Canvas 铺满时能够正常工作,但只要 Canvas 处于局部容器中、有内边距、或者页面产生了垂直滚动,拾取位置就会发生严重偏移。

可靠的做法是针对 renderer.domElement 调用 getBoundingClientRect(),精准扣除 Canvas 元素相对视口的 left 和 top 偏移量,再除以实际渲染的宽度与高度:

import * as THREE from 'three';

// 声明二维归一化坐标向量与光线投射器
const pointer = new THREE.Vector2();
const raycaster = new THREE.Raycaster();

function updatePointerCoords(event, canvasElement) {
  // 获取画布当前在视口中的精确盒模型位置与尺寸
  const rect = canvasElement.getBoundingClientRect();

  // 1. 计算相对画布内部的像素坐标
  const clientX = event.clientX - rect.left;
  const clientY = event.clientY - rect.top;

  // 2. 映射换算到 [-1, 1] 的 NDC 空间
  pointer.x = (clientX / rect.width) * 2 - 1;
  pointer.y = -(clientY / rect.height) * 2 + 1; // 注意:Y 轴取负号反转方向
}
为什么 Y 轴公式前面必须有一个负号?

在标准浏览器 DOM 坐标系中,顶部为 0,向下延伸为正数;而在 WebGL 的剪裁坐标与 NDC 空间中,中心为 0,向上才是正方向。因此,当鼠标向屏幕下方移动时,其对应的三维归一化坐标应该向负方向延伸,必须通过 -(clientY / height) * 2 + 1 实现数值的反转。

三、关键步骤二:使用 Raycaster 执行空间相交拾取

得到 NDC 坐标后,通过 raycaster.setFromCamera(pointer, camera) 让射线根据当前相机的参数与视线方向自动校准。随后调用 raycaster.intersectObjects() 进行求交。

相交检测方法返回一个按与相机距离从小到大升序排序的数组。数组中的每一个元素都封装了丰富的碰撞数据:

// 针对待检测物体列表进行光线拾取
// 第二个参数 recursive: true 表示深入检测子级网格(对 Group 和外部导入模型极为关键)
const intersects = raycaster.intersectObjects(selectableObjects, true);

if (intersects.length > 0) {
  // 距离相机最近的命中项排在首位
  const hit = intersects[0];
  
  console.log('命中的 3D 网格:', hit.object);
  console.log('射线击中的三维交点坐标:', hit.point);
  console.log('击中点与相机的距离:', hit.distance);
  console.log('命中的网格三角面序号:', hit.faceIndex);
}
怎么在 Three.js 中实现鼠标点击选中 3D 物体
图 2:鼠标点击选中 3D 物体的标准执行流水线(5 个有序阶段)

四、关键步骤三:选中状态管理与高亮视觉反馈

在实际项目中,选中物体通常伴随视觉反馈(例如模型高亮、改变颜色、显示边框线或轮廓光)。为了保证可以随时“取消选中”并恢复原有外观,必须妥善缓存物体的原始状态,避免直接覆盖导致材质原貌丢失。

1. 基于 emissive 自发光颜色的非破坏式高亮(推荐)

如果物体使用的是 MeshStandardMaterial 或 MeshLambertMaterial 等具备光照响应的材质,最稳妥的方式是通过改变材质的 emissive(自发光色),这种方式不会破坏物体的原本漫反射贴图:

let currentSelectedObject = null;
const originalEmissive = new Map(); // 缓存原始发光颜色

function selectObject(targetObject) {
  // 如果再次点击同一物体,无需重复处理
  if (currentSelectedObject === targetObject) return;

  // 1. 恢复上一次选中物体的材质外观
  if (currentSelectedObject) {
    resetHighlight(currentSelectedObject);
    currentSelectedObject = null;
  }

  // 2. 如果点击了空白处(targetObject 为 null),直接退出
  if (!targetObject) return;

  // 3. 应用当前选中高亮
  highlightObject(targetObject);
  currentSelectedObject = targetObject;
}

function highlightObject(obj) {
  obj.traverse((child) => {
    if (child.isMesh && child.material) {
      // 记录原发光色
      if (!originalEmissive.has(child)) {
        originalEmissive.set(child, child.material.emissive.getHex());
      }
      // 赋予选中强调色 (如灰橄榄偏亮发光)
      child.material.emissive.setHex(0x3a4235);
    }
  });
}

function resetHighlight(obj) {
  obj.traverse((child) => {
    if (child.isMesh && child.material && originalEmissive.has(child)) {
      child.material.emissive.setHex(originalEmissive.get(child));
    }
  });
}

五、深入细节与常见易错辨析

实现基础的光线投射只需要几行代码,但在复杂应用或导入外部 glTF/FBX 模型时,开发者常常会遇到各种预期之外的现象。以下总结针对实际开发中最常遇到的难点进行辨析:

1. 点击选中了零件,但业务上需要选中整个模型 (Group 递归回溯)

从 Blender 或外部导入的机械、车辆、建筑模型,通常是由多个 Mesh 组合在 THREE.Group 下构成的。物理层面上,射线碰撞计算只能击中具体的面(即具体的子级 Mesh,如车轮上的螺丝),因此 intersects[0].object 返回的永远是最底层的网格。

如果业务要求“选中整台车”,就需要从被命中的子网格向上递归查找带有业务标识的父级节点:

// 为模型根组标记可选中属性
carModelGroup.userData.isSelectable = true;

// 向上寻找业务根节点的辅助函数
function getSelectableRoot(hitMesh, rootScene) {
  let curr = hitMesh;
  while (curr && curr.parent && curr.parent !== rootScene) {
    if (curr.userData && curr.userData.isSelectable) {
      return curr; // 找到了整车 Group
    }
    curr = curr.parent;
  }
  return hitMesh; // 如果未标记特定 Group,退回原始 Mesh
}
怎么在 Three.js 中实现鼠标点击选中 3D 物体
图 3:复杂模型层级架构下的叶子节点相交与业务父级 Group 回溯原理

2. 视角旋转与点击选中的冲突:拖拽防误触过滤

项目中常配合 OrbitControls 控制相机视角。如果直接给 Canvas 监听 click 事件,当用户长按鼠标拖拽旋转场景并在松开时,浏览器依然会派发一次原生的 click 事件,导致原本只想看背面的操作意外改变了选中物体。

解决该问题的标准策略是记录按下与松开的屏幕位移。只有当位移小于微小阈值(如 3 像素)时,才判定为纯粹的点击:

let startX = 0;
let startY = 0;

canvas.addEventListener('pointerdown', (e) => {
  startX = e.clientX;
  startY = e.clientY;
});

canvas.addEventListener('pointerup', (e) => {
  const deltaX = Math.abs(e.clientX - startX);
  const deltaY = Math.abs(e.clientY - startY);

  // 若移动距离超过 3 像素,判定为拖拽或旋转场景,直接忽略选中逻辑
  if (deltaX > 3 || deltaY > 3) return;

  handleRaycastSelection(e);
});

3. 避免将 scene.children 全盘传入检测

有些教程为了简便写成 raycaster.intersectObjects(scene.children, true)。这在实际生产环境中非常低效且容易引入 bug。原因在于 scene.children 往往包含了辅助网格 (GridHelper)、坐标轴 (AxesHelper)、天空盒以及无需交互的静态背景,会导致射线误选中辅助元素。

建议专门维护一个独立的 selectableObjects 数组,仅将参与交互的几何体或模型根组推入其中进行检测。

六、完整实战代码示例

下面是一个可以直接在现代前端项目或浏览器中运行的完整模块化实现,包含了场景初始化、坐标精准校准、防拖拽判定与高亮状态切换:

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

// 1. 基础环境初始化
const canvas = document.querySelector('#webgl-canvas');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));

const scene = new THREE.Scene();
scene.background = new THREE.Color('#F7F4ED');

const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 3, 6);

const controls = new OrbitControls(camera, canvas);
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, 10, 7);
scene.add(dirLight);

// 2. 创建用于演示的可交互 3D 物体
const interactiveList = [];
const geometries = [
  new THREE.BoxGeometry(1, 1, 1),
  new THREE.SphereGeometry(0.7, 32, 16),
  new THREE.CylinderGeometry(0.5, 0.5, 1.2, 32)
];

geometries.forEach((geo, index) => {
  const mat = new THREE.MeshStandardMaterial({
    color: 0x464B48,
    roughness: 0.4,
    metalness: 0.1
  });
  const mesh = new THREE.Mesh(geo, mat);
  mesh.position.x = (index - 1) * 2.2;
  mesh.userData.id = `object_${index + 1}`;
  scene.add(mesh);
  interactiveList.push(mesh);
});

// 3. 射线拾取与选中状态管理
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let selectedMesh = null;
let pointerDownPos = { x: 0, y: 0 };

function performSelection(event) {
  const rect = canvas.getBoundingClientRect();
  pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
  pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;

  raycaster.setFromCamera(pointer, camera);
  const intersects = raycaster.intersectObjects(interactiveList, false);

  // 恢复之前选中的物体
  if (selectedMesh) {
    selectedMesh.material.emissive.setHex(0x000000);
    selectedMesh = null;
  }

  // 应用新选中
  if (intersects.length > 0) {
    selectedMesh = intersects[0].object;
    // 设置灰橄榄色高亮自发光
    selectedMesh.material.emissive.setHex(0x8A9478);
    console.log('当前选中物体 ID:', selectedMesh.userData.id);
  }
}

// 4. 事件监听与拖拽过滤
canvas.addEventListener('pointerdown', (e) => {
  pointerDownPos = { x: e.clientX, y: e.clientY };
});

canvas.addEventListener('pointerup', (e) => {
  const dist = Math.hypot(e.clientX - pointerDownPos.x, e.clientY - pointerDownPos.y);
  if (dist < 3) {
    performSelection(e);
  }
});

// 5. 动画循环
function animate() {
  requestAnimationFrame(animate);
  controls.update();
  renderer.render(scene, camera);
}
animate();

七、故障排查与优化对照指南

当在项目中遇到“点击没有反应”或者“拾取不准确”时,可以按照下述矩阵中的四个核心维度进行排查:

异常表现 排查要点与可能根因 标准修复方案
点击无响应 / 完全无法命中 物体的 visible 为 false;或射线检测的目标数组为空;或材质配置了特殊双面剔除。 确认目标物体已被添加到候选数组中;检查相机近远裁剪面参数是否正常。
点击错位 / 越靠近边缘偏差越大 直接使用了全屏 window.innerWidth,未扣除页面滚动条或局部容器的 left/top 偏移。 改用 canvas.getBoundingClientRect(),精准计算相对视口偏移量。
旋转视角松开鼠标时意外触发选中 仅监听了 click 事件,未能识别 OrbitControls 带来的拖拽位移。 记录 pointerdown 和 pointerup 的坐标差,位移大于 3px 视为视角旋转并忽略。
性能掉帧 / 悬停拾取卡顿 待检测模型面数过高(数十万三角形),或者在 mousemove 中无节制每帧触发全量相交计算。 为高模绑定简单的 Box/Sphere 隐式碰撞体代理;对移动事件增加防抖节流。
怎么在 Three.js 中实现鼠标点击选中 3D 物体
图 4:Three.js 鼠标点击拾取常见故障与技术调优四维排查决策指南
作者最新文章
相关文章
更多