首页 / Three.js 入门教程 / 射线检测 Raycaster

Three.js 入门教程

射线检测 Raycaster

本教程共 40 篇 · 第 23 篇 · 更新于 2026-08-14 · 约 9 分钟阅读

Three.jsRaycaster射线检测鼠标拾取交互点击

本节目标:学会用 Raycaster 判断鼠标点中了哪个 3D 物体,并读取交点信息。学完你能实现点击选中、悬停高亮这类最常见的 3D 交互。

什么是射线检测

“鼠标点中了哪个物体”是 3D 交互的基本问题。屏幕是 2D 的,场景是 3D 的,两者怎么对应?

思路是这样的:从相机位置,朝鼠标所指的方向发射一条看不见的射线,看它穿过哪些物体。谁离得近,谁就先被碰到。这个过程叫射线检测(raycasting),也叫拾取(picking)。

射线和你想象的一样,是一条直线:有起点,有方向。检测时逐个检查场景里的物体,算出射线和物体三角面的交点。Raycaster 就是封装了这套计算的类。它属于 three.js 核心,不需要引 addons。

核心概念

一条射线由两个要素决定:

  • origin:起点(世界坐标)
  • direction:方向向量(需归一化)

Raycaster 内部维护着这样一条射线。构造函数可选四个参数:

const raycaster = new THREE.Raycaster(origin, direction, near, far);
  • near:忽略比它近的交点,默认 0
  • far:忽略比它远的交点,默认 Infinity

平时我们不会手动指定方向,而是用 setFromCamera:给定屏幕坐标和相机,自动算出从相机出发的射线:

raycaster.setFromCamera(ndc, camera);

ndc 是归一化设备坐标,范围 -1 到 1。屏幕像素坐标要先换算成它。

第一步:屏幕坐标转 NDC

鼠标事件里的 clientX / clientY 是像素坐标。换算公式很简单:

const ndc = new THREE.Vector2();

function onPointerMove(event) {
  ndc.x = (event.clientX / window.innerWidth) * 2 - 1;
  ndc.y = -(event.clientY / window.innerHeight) * 2 + 1;
}

x 方向:0 到窗口宽,映射到 -1 到 1。y 要取反,因为屏幕坐标 y 向下,NDC 里 y 向上。

为什么非要 NDC?因为 setFromCamera 内部要把这个二维坐标”反投影”回三维世界:从相机出发,穿过屏幕上该点的那条视线,就是我们要的射线。NDC 是相机投影计算的标准输入格式。

Note

换算用窗口的 innerWidth / innerHeight。如果画布不是全屏,用 renderer.domElement.getBoundingClientRect() 拿画布的实际尺寸和偏移再换算,公式一样。

第二步:发射射线并求交

raycaster.setFromCamera(ndc, camera);
const intersects = raycaster.intersectObjects(objects);

intersectObjects 接收一个物体数组,返回交点数组。结果按距离从近到远排序,取第一个就是最近被点中的物体:

if (intersects.length > 0) {
  const hit = intersects[0];
  console.log(hit.object); // 被点中的 3D 物体
}

第二个参数 recursive 控制是否检测子物体,默认 true。场景层级深时保持默认;只想检测顶层物体时传 false:

raycaster.intersectObjects(scene.children, false); // 只看直接子物体

单物体版本是 intersectObject(object, recursive),用法一样。

Intersection 对象

每个交点是一个对象,常用字段:

字段类型含义
distancenumber交点到射线起点的距离
pointVector3交点世界坐标
objectObject3D被命中的物体
faceobject命中的三角面信息
faceIndexnumber面的索引
uvVector2交点处的纹理坐标
normalVector3交点处的法线
instanceIdnumberInstancedMesh 中被命中的实例序号

实际开发里,distance 用来判断”哪个最近”,point 用来放特效或标记,object 用来定位是哪个物体。

完整示例:点击变色 + 悬停高亮

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>Raycaster 拾取</title>
  <style>
    * { margin: 0; padding: 0; }
    html, body { height: 100%; }
    canvas { display: block; }
  </style>
  <script type="importmap">
  {
    "imports": {
      "three": "https://cdn.jsdelivr.net/npm/three@0.185.0/build/three.module.js",
      "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.185.0/examples/jsm/"
    }
  }
  </script>
</head>
<body>
  <script type="module">
    import * as THREE from 'three';
    import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x1a1a2e);

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

    const renderer = new THREE.WebGLRenderer();
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    document.body.appendChild(renderer.domElement);

    const controls = new OrbitControls(camera, renderer.domElement);
    controls.enableDamping = true;

    // 放 5 个彩色方块
    const boxes = [];
    const colors = [0xff4d4d, 0xffa64d, 0xffff4d, 0x4dff4d, 0x4da6ff];
    colors.forEach((color, i) => {
      const mesh = new THREE.Mesh(
        new THREE.BoxGeometry(0.8, 0.8, 0.8),
        new THREE.MeshStandardMaterial({ color })
      );
      mesh.position.x = (i - 2) * 1.5;
      scene.add(mesh);
      boxes.push(mesh);
    });

    const light = new THREE.DirectionalLight(0xffffff, 3);
    light.position.set(5, 10, 5);
    scene.add(light);

    // 射线检测相关
    const raycaster = new THREE.Raycaster();
    const ndc = new THREE.Vector2();
    let hovered = null; // 当前悬停的方块

    // 悬停高亮
    renderer.domElement.addEventListener('pointermove', (event) => {
      ndc.x = (event.clientX / window.innerWidth) * 2 - 1;
      ndc.y = -(event.clientY / window.innerHeight) * 2 + 1;

      raycaster.setFromCamera(ndc, camera);
      const intersects = raycaster.intersectObjects(boxes);

      if (hovered) {
        hovered.material.emissive.set(0x000000);
        hovered = null;
      }
      if (intersects.length > 0) {
        hovered = intersects[0].object;
        hovered.material.emissive.set(0x333333);
      }
    });

    // 点击改变颜色
    renderer.domElement.addEventListener('click', (event) => {
      ndc.x = (event.clientX / window.innerWidth) * 2 - 1;
      ndc.y = -(event.clientY / window.innerHeight) * 2 + 1;

      raycaster.setFromCamera(ndc, camera);
      const intersects = raycaster.intersectObjects(boxes);

      if (intersects.length > 0) {
        intersects[0].object.material.color.set(Math.random() * 0xffffff);
      }
    });

    function animate() {
      requestAnimationFrame(animate);
      controls.update();
      renderer.render(scene, camera);
    }
    animate();
  </script>
</body>
</html>

运行后:鼠标悬停的方块微微发亮,点击任意方块会随机换色。核心只有三行:

raycaster.setFromCamera(ndc, camera);         // 1. 生成射线
const intersects = raycaster.intersectObjects(boxes); // 2. 求交
const hit = intersects[0];                    // 3. 取最近的

拿到交点之后

intersects[0].point 是世界坐标,很多玩法从这里展开。比如”点击地面放置标记”:

const marker = new THREE.Mesh(
  new THREE.SphereGeometry(0.15, 16, 16),
  new THREE.MeshBasicMaterial({ color: 0xffff00 })
);
marker.visible = false;
scene.add(marker);

// 点击事件里
if (intersects.length > 0) {
  marker.position.copy(intersects[0].point); // 标记放到点击处
  marker.visible = true;
}

常见的应用都能归到这套流程:点击选中(高亮物体)、悬停提示(鼠标经过时弹信息)、射击判定(射线代替子弹)、地形拾取(点击地面放建筑)、视线检测(角色能不能看到目标)。

不依赖相机的射线

鼠标拾取是最常见的用法,但射线不止这一个来源。手动指定起点和方向,可以模拟”视线""子弹”:

// AI 视野:从角色位置向前看 10 个单位
const ray = new THREE.Raycaster(
  player.position.clone(),
  new THREE.Vector3(0, 0, -1) // 已经归一化
);
ray.far = 10;
const sees = ray.intersectObjects(enemies);

构造时传入的 direction 必须是归一化向量,长度正好是 1,否则距离计算会失真。这类射线常配合 far 限制检测范围。

拾取复杂模型

加载进来的 glTF 模型通常是一个 Group,里面套着多层子网格。intersectObjects 默认 recursive 为 true,会一层层查到底,所以直接传模型根节点也能命中内部子网格:

const hits = raycaster.intersectObjects([model], true);
// hits[0].object 可能是模型内部的某个子网格

想拿到”整个模型”而不是内部小零件,沿父链往上找:hit.object.parent 一直走到根节点。InstancedMesh 的命中结果里,instanceId 是实例序号,能精确到”第几个”:

const hits = raycaster.intersectObjects([instancedMesh]);
if (hits.length > 0) {
  const id = hits[0].instanceId; // 第几个实例被点中
}

几个容易踩的坑

  1. 只检测正面。材质默认 side 为 FrontSide,射线从背面穿过检测不到。想两面都能点中,设置 material.side = THREE.DoubleSide
  2. 物体移动后检测不到。raycast 使用物体的世界矩阵,如果场景没有渲染循环,移动物体后要手动调 object.updateMatrixWorld()
  3. 误把多个交点都当命中。intersects 已按距离排序,通常只取 intersects[0]
  4. 点击和拖拽冲突。OrbitControls 下,拖拽结束时也会触发 click。比较按下和松开的位置,位移超过几个像素就当作拖拽,不处理点击。
  5. 事件对象选错。用 pointermove / pointerdown 这类 Pointer 事件,比 mouse 事件更统一,触屏也能用,本节的示例就是 Pointer 事件。

layers:只检测部分物体

场景物体很多、只想检测其中一类时,用 layers 过滤:

raycaster.layers.set(1); // 射线只检测第 1 层
object.layers.enable(1); // 把这个物体加入第 1 层

默认所有物体都在第 0 层,射线也在第 0 层,所以默认行为不变。按层分组比维护数组更灵活。

阈值与性能

检测 Points 和 Line 时,射线几乎不可能”正中”一个点。params 里可以设阈值,单位是世界单位:

raycaster.params.Points.threshold = 0.1; // 命中点的半径
raycaster.params.Line.threshold = 1;

数值越大越容易命中。渲染粒子系统时想支持点击,这个参数几乎必调。

性能上记住两点:

  • Raycaster 内部会先用包围球做粗筛,不在射线附近的物体直接跳过,再对可能命中的物体做逐三角面精算
  • 不要每帧对全场景几千个物体求交。按需检测(点击时)、用 layers 分组、或缩小物体数组,都能明显省性能

小结

  • 屏幕坐标 → NDC(-1 到 1):x 乘 2 减 1,y 取反后乘 2 减 1
  • setFromCamera(ndc, camera) 生成从相机出发的射线
  • intersectObjects(objects, recursive) 求交,结果按距离排序
  • Intersection 常用字段:distance / point / object
  • 手动构造射线可模拟视线、子弹;DoubleSide、layers、params.threshold 按需配置

下一章进入调试环节:Stats.js、lil-gui 和各类 Helper 工具。