射线检测 Raycaster
本教程共 40 篇 · 第 23 篇 · 更新于 2026-08-14 · 约 9 分钟阅读
本节目标:学会用 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 对象
每个交点是一个对象,常用字段:
| 字段 | 类型 | 含义 |
|---|---|---|
| distance | number | 交点到射线起点的距离 |
| point | Vector3 | 交点世界坐标 |
| object | Object3D | 被命中的物体 |
| face | object | 命中的三角面信息 |
| faceIndex | number | 面的索引 |
| uv | Vector2 | 交点处的纹理坐标 |
| normal | Vector3 | 交点处的法线 |
| instanceId | number | InstancedMesh 中被命中的实例序号 |
实际开发里,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; // 第几个实例被点中
}
几个容易踩的坑
- 只检测正面。材质默认 side 为 FrontSide,射线从背面穿过检测不到。想两面都能点中,设置
material.side = THREE.DoubleSide。 - 物体移动后检测不到。raycast 使用物体的世界矩阵,如果场景没有渲染循环,移动物体后要手动调
object.updateMatrixWorld()。 - 误把多个交点都当命中。intersects 已按距离排序,通常只取
intersects[0]。 - 点击和拖拽冲突。OrbitControls 下,拖拽结束时也会触发 click。比较按下和松开的位置,位移超过几个像素就当作拖拽,不处理点击。
- 事件对象选错。用 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 工具。