Skip to content

33. 射线与点击选中 3D 场景物体

Published:

写 2D 网页的时候,我们经常 click click 点点点,到了 3D 不能点击的话,还有点不习惯。

这节我们就来实现点击选中 3D 场景中的物体的功能。

3D 场景中点击的实现是基于射线 Ray。

比如之前我们用 ArrowHelper 画的这个箭头:

image.png

它就是一条射线,也就是确定起点 origin 和方向 direction 就可以形成一条射线。

那这条射线穿过了网格模型的某个三角形,是不是就是射中了这个 Mesh 呢?

没错,三维场景中的点击选中就是基于射线来做的。

我们来写下射线的代码:

npx create-vite ray-caster

image.png

用 create-vite 创建 vite 项目。

安装下 three:

npm install
npm install --save three
npm install --save-dev @types/three

然后改下 src/main.js

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

const scene = new THREE.Scene();

scene.add(mesh);

const axesHelper = new THREE.AxesHelper(500);
scene.add(axesHelper);

const directionalLight = new THREE.DirectionalLight(0xffffff);
directionalLight.position.set(500, 400, 300);
scene.add(directionalLight);

const ambientLight = new THREE.AmbientLight(0xffffff);
scene.add(ambientLight);

const width = window.innerWidth;
const height = window.innerHeight;

const camera = new THREE.PerspectiveCamera(60, width / height, 1, 10000);
camera.position.set(500, 500, 500);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer();
renderer.setSize(width, height)

function render() {
    renderer.render(scene, camera);
    requestAnimationFrame(render);
}

render();

document.body.append(renderer.domElement);

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

创建场景 Scene,添加平行光 DirectionalLight、环境光 AmbientLight,相机 PespectiveCamera、渲染器 WebGLRenderer。

然后改下 style.css

body {
  margin: 0;
}

写下 src/mesh.js

import * as THREE from 'three';

const geometry = new THREE.BufferGeometry();

const point1 = new THREE.Vector3(0, 0, 0);
const point2 = new THREE.Vector3(300, 0, 0);
const point3 = new THREE.Vector3(0, 300, 0);

geometry.setFromPoints([point1, point2, point3]);

const material = new THREE.MeshBasicMaterial({
    color: new THREE.Color('orange')
});

const mesh = new THREE.Mesh(geometry, material);

export default mesh;

我们用 BufferGeometry 创建自定义几何体,3 个顶点构成了一个三角形。

跑一下:

npm run dev

image.png

image.png

就是这样一个三角形。

然后我们创建一条射线穿过它:

image.png

const ray = new THREE.Ray();
ray.origin.set(50, 50, 100);
ray.direction = new THREE.Vector3(0, 0, -1);

const arrowHelper = new THREE.ArrowHelper(ray.direction, ray.origin, 1000, new THREE.Color('pink'));
mesh.add(arrowHelper);

const point = new THREE.Vector3();
ray.intersectTriangle(point1, point2, point3, false, point);
console.log(point);

我们创建一条射线 Ray,设置 origin 和 direction

然后用 ArrowHelper 把这条射线可视化画出来。

调用 intersectTriangle 来穿过一个三角形,打印交叉点。

image.png

可以看到,交叉点是 50,50,0 的坐标。

穿过了这个网格模型的某个三角形,也就是穿过了这个网格模型。

但平时写代码不会直接和三角形打交道,而是和某个网格模型打交道,判断是否穿过了一个网格模型。

所以我们会用更上层的 api:Raycaster。

创建 src/mesh2.js

import * as THREE from 'three';

const group = new THREE.Group();

function generateBox(colorStr, x, y, z) {
    const geometry = new THREE.BoxGeometry(100, 100, 100);
    const material = new THREE.MeshLambertMaterial({
        color: new THREE.Color(colorStr)
    });
    const box = new THREE.Mesh(geometry, material);
    box.position.set(x, y, z)
    return box;
}

const box = generateBox('blue', 0, 0, 0);
const box2 = generateBox('green', 0, 0, 300);
const box3 =generateBox('red', 300, 0, 0);
group.add(box, box2, box3);

export default group;

创建 3 个立方体。

引入看下:

image.png

image.png

然后创建一条射线穿过它们:

image.png

setTimeout(() => {
    const rayCaster = new THREE.Raycaster();
    rayCaster.ray.origin.set(-100, 30, 0);
    rayCaster.ray.direction.set(1, 0, 0);
    
    const arrowHelper = new THREE.ArrowHelper(
        rayCaster.ray.direction,
        rayCaster.ray.origin,
        600
    );
    group.add(arrowHelper);
    
    const intersections = rayCaster.intersectObjects([box, box2, box3]);
    console.log(intersections);
    
    intersections.forEach(item => {
        item.object.material.color = new THREE.Color('pink')
    })
}, 0);

前面用过 Ray 判断射线和三角形是否相交,判断和网格模型相交的话,用 Raycaster。

这里用 ArrowHelper 把射线画出来。

相交的立方体改成粉色。

这里为啥要加一个 setTimeout 呢?

因为代码执行到这里的时候,renderer 还没渲染呢,要等渲染完之后再判断相交,所以这里加个异步。

看下效果:

image.png

可以看到,有两个相交的对象,并且还可以拿到每个对象距离射线起点的距离。

接下来,如何在点击网页的时候,转化为一条射线来判断和哪些物体相交呢?

我们知道有了相机 camera,就知道从哪个角度来看 3D 场景了。

然后再知道点了哪个屏幕坐标,就能从 3D 场景的这个位置生成一条射线。

屏幕坐标要求转换为这样 -1 到 1 的范围:

image.png

画布中间是 0,0

点击的网页的时候,距离 canvas 元素左上角的距离是 offsetX、offsetY

比如 offsetX 除以 canvas 的宽度,那就是从 0 到 1 的范围。

然后 * 2 就是从 0 到 2 的范围,再减去 1 就是 -1 到 1 的范围了。

这样就可以实现点击生成射线。

我们来写一下:

把之前这条射线去掉:

image.png

在 main.js 给 canvas 元素绑定点击事件:

image.png

renderer.domElement.addEventListener('click', (e) => {
  const y = -((e.offsetY / height) * 2 - 1);
  const x = (e.offsetX / width) * 2 - 1;

  const rayCaster = new THREE.Raycaster();
  rayCaster.setFromCamera(new THREE.Vector2(x, y), camera);

  const intersections = rayCaster.intersectObjects(mesh.children);

  intersections.forEach(item => {
    item.object.material.color = new THREE.Color('orange');
  });
});

这里要注意下,网页的坐标系 y 轴的方向和标准屏幕坐标系的 y 轴是相反的,所以算出来后要取反:

image.png

image.png

然后用 setFromCamera 方法从 camera 的位置,根据点击处的标准屏幕坐标生成一条射线。

射线射中的物体,变为橙色。

看下效果:

2025-04-01 17.49.07.gif

没啥问题。

我们把射线可视化一下:

image.png

const arrowHelper = new THREE.ArrowHelper(rayCaster.ray.direction, rayCaster.ray.origin, 3000);
scene.add(arrowHelper);

2025-04-01 17.53.08.gif

可以看到,从 camera 的位置到你点击位置对应的三维空间的位置生成一条射线,射线穿过的物体就是被点击的。

这就是点击屏幕,选中 3D 场景中物体的原理。

案例代码上传了小册仓库

总结

这节我们学了射线的概念。

确定了起点 origin 和方向 direction 就可以生成一条射线 Ray,用它的 intersectTriangle 方法可以判断是否和三角形相交。

一般我们会用 Raycaster 来生成射线,用 intersectObjects 方法判断是否和网格模型相交。

点击的实现原理就是基于射线,把 offsetX、offsetY 的网页坐标转换为 -1 到 1 的标准屏幕坐标,然后用传入 camera,用 Raycaster 的 setFromCamera 方法生成一条射线。

这样就会从相机的位置到你点击位置对应的三维空间的位置生成一条射线,射线穿过的物体就是被点击的。

这就是点击选中三维场景的物体的原理。

评论