概念
鼠标拾趣,也称为射线拾取(raycasting),是 Three.js 中实现用户与 3D 场景交互的关键技术。它通过模拟从用户鼠标位置发出的射线,检测这条射线与 3D 场景中物体的交点,从而实现物体的选择、高亮或其他交互效果。
在 Three.js 中,鼠标拾趣的实现依赖于Raycaster
类。Raycaster
可以创建一条从相机出发,通过鼠标位置的射线,并与场景中的物体进行相交测试。这一过程涉及到屏幕坐标到设备坐标的转换,以及射线与物体几何体的相交计算。
鼠标拾趣的核心步骤包括:
- 获取鼠标在 Canvas 上的位置。
- 将鼠标位置从屏幕坐标转换为设备坐标(标准化设备坐标,NDC),其值域为[-1, 1]。
- 使用
Raycaster
设置射线的起点和方向。 - 执行射线与场景中物体的相交测试。
- 处理相交结果,如改变物体材质或触发事件。
应用场景
鼠标拾趣在 Three.js 中有多种应用场景,以下是一些常见的用途:
物体选择与高亮
在 3D 场景中,用户可以通过鼠标点击来选择物体,实现物体的高亮显示或其他视觉反馈。例如,在一款 3D 设计软件中,用户可以点击不同的模型组件进行编辑。
交互式应用
在交互式应用中,如游戏或模拟环境中,鼠标拾趣可以用来实现物体的拾取、移动、旋转等操作。用户可以通过鼠标与 3D 场景中的对象进行自然直观的交互。
数据可视化
在数据可视化领域,鼠标拾趣可以帮助用户理解复杂的 3D 数据。用户可以通过鼠标悬停或点击来获取数据点的详细信息用来增强数据的可读性和互动性。
增强现实(AR)与虚拟现实(VR)
在 AR 和 VR 应用中,鼠标拾趣可以作为用户输入的一种补充,尤其是在 VR 环境中,用户可能需要使用控制器来模拟鼠标的点击和移动操作。
教育与培训
在教育和培训领域,鼠标拾趣可以用于创建互动式的学习环境。例如,在解剖学课程中,学生可以通过鼠标点击来探索 3D 模型的不同部分,获取相关知识点。
通过这些应用场景,我们可以看到鼠标拾趣在 Three.js 中的重要性和多样性。它不仅丰富了用户的交互体验,也为开发者提供了实现复杂交互功能的工具。
基础知识
鼠标事件监听
在 Three.js 中实现鼠标拾趣的第一步是监听鼠标事件。这通常涉及到监听mousemove
、mousedown
和click
等事件。通过这些事件,我们可以获取鼠标的位置,并将其用于射线的计算。
- 事件监听设置:在 Three.js 中,我们可以通过
addEventListener
方法来监听鼠标事件。例如,要监听鼠标移动事件,可以设置如下:
canvas.addEventListener("mousemove", onMouseMove, false);
- 事件处理函数:在事件处理函数中,我们需要获取鼠标在 Canvas 上的位置,并将其转换为设备坐标。以下是
onMouseMove
函数的一个示例:
function onMouseMove(event) {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
}
- 数据应用:通过监听鼠标事件,我们可以收集用户的交互数据,如鼠标移动的速度、方向和点击频率。这些数据可以用于分析用户的行为模式,优化交互设计,提高用户体验。
坐标系统理解
在 Three.js 中,理解不同的坐标系统对于实现鼠标拾趣至关重要。以下是两种主要的坐标系统:
屏幕坐标(Screen Coordinates):这是用户直接与界面交互的坐标系统。在这种坐标系统中,原点(0,0)位于屏幕的左上角,x 轴向右延伸,y 轴向下延伸。
设备坐标(Device Coordinates, NDC):这是一种标准化的坐标系统,其值域为[-1, 1]。在这种坐标系统中,原点位于视口的中心,x 轴从-1 到 1,y 轴从 1 到-1。将屏幕坐标转换为设备坐标是实现鼠标拾趣的关键步骤。
- 转换公式:以下是一个常用的转换公式,用于将屏幕坐标转换为设备坐标:
mouse.x = (event.clientX / renderer.domElement.offsetWidth) * 2 - 1; mouse.y = -(event.clientY / renderer.domElement.offsetHeight) * 2 + 1;
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
这行代码计算鼠标在水平方向上的标准化设备坐标。event.clientX
是鼠标相对于视口(viewport)的 X 坐标,window.innerWidth
是视口的宽度。将clientX
除以window.innerWidth
可以得到一个0
到1
之间的值,表示鼠标在视口中的水平位置。乘以2
并减去1
后,这个值的范围就从0
到2
变为了-1
到1
,这是因为在 WebGL 中,坐标系的原点在屏幕的中心,X 轴向右为正方向。mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
这行代码计算鼠标在垂直方向上的标准化设备坐标。event.clientY
是鼠标相对于视口的 Y 坐标,window.innerHeight
是视口的高度。与水平坐标类似,将clientY
除以window.innerHeight
可以得到一个0
到1
之间的值,然后乘以2
并减去1
将其转换为-1
到1
的范围。不同之处在于,Y 坐标的计算前面有一个负号,这是因为在 WebGL 中,Y 轴向下为正方向,所以需要将值翻转。
- 应用实例:通过这种转换,我们可以将鼠标在 Canvas 上的位置转换为
Raycaster
所需的设备坐标,从而实现射线的精确投射。
通过深入理解这些坐标系统及其转换,开发者可以更准确地控制射线的起点和方向,实现更精确的鼠标拾趣功能。这对于提高 3D 场景的交互性和用户体验至关重要。
Raycaster 原理与应用
Raycaster 基础
Raycaster
是 Three.js 中用于实现鼠标拾趣的核心类,它通过模拟从相机发出的射线来检测与 3D 物体的交点。理解 Raycaster
的工作原理对于开发交互式的 3D 应用至关重要。
- Raycaster 初始化:创建
Raycaster
实例时,可以指定射线的起点、方向、近裁剪面和远裁剪面。这些参数定义了射线的基本属性,如下所示:
const raycaster = new THREE.Raycaster(origin, direction, near, far);
- 射线与几何体的相交测试:
Raycaster
提供了两个主要方法来执行相交测试:intersectObject()
和intersectObjects()
。intersectObject()
用于检测射线与单个物体的交点,而intersectObjects()
可以同时检测射线与多个物体的交点。
const intersects = raycaster.intersectObjects([targetObject]);
- 相交结果处理:
intersectObjects()
方法返回一个包含相交信息的数组。每个元素包含相交点、相交物体、距离等信息,这些数据可以用于后续的逻辑处理,如物体高亮或事件触发。所以,我们可以通过 intersects 时数组还是空数组来判断是否有物体被选中。
intersects.forEach((intersect) => {
console.log(
`Object: ${intersect.object.name}, Distance: ${intersect.distance}`
);
});
- 性能优化:在处理大型场景时,合理使用
Raycaster
可以提高性能。例如,可以通过减少检测的物体数量或使用场景分层来减少相交测试的计算量。
举个例子
这个案例展示了如何使用 Three.js 创建一个基本的 3D 场景,添加一个立方体,并实现鼠标交互功能,当鼠标悬停在立方体上时改变其颜色。
1. 导入 Three.js 库
import * as THREE from "three";
2. 创建场景
创建一个 Three.js 场景对象,这是所有 3D 对象、灯光和相机的容器。
const scene = new THREE.Scene();
3. 设置相机
创建一个透视相机对象,参数分别是:
- 视野角度:75 度
- 宽高比:
window.innerWidth / window.innerHeight
,确保相机视角在不同屏幕尺寸下保持一致 - 近裁剪面:0.1
- 远裁剪面:1000
设置相机的位置,使其位于 z 轴上的 5 单位位置,从而能够观察到场景中的物体。
const camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.set(0, 0, 5);
4. 配置渲染器
- 创建一个 WebGL 渲染器对象,用于将 3D 场景渲染到 2D 画布上。
- 设置渲染器的尺寸,使其填充整个浏览器窗口。
- 将渲染器的 DOM 元素添加到 HTML 文档的
body
中,这样我们就可以在网页上看到渲染的 3D 场景。
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
5. 添加环境光
向场景中添加一个环境光对象,颜色为白色(0xffffff),增强场景的整体亮度。
const ambientLight = new THREE.AmbientLight(0xffffff); // 增强环境光强度
scene.add(ambientLight);
6. 创建并添加立方体
创建一个立方体几何体对象,颜色设置为绿色(0x00ff00),并将其位置设置在原点(0, 0, 0)。然后将立方体添加到场景中。
const geometry = new THREE.BoxGeometry();
const originalColor = 0x00ff00;
const material = new THREE.MeshBasicMaterial({ color: originalColor });
const cube = new THREE.Mesh(geometry, material);
cube.position.set(0, 0, 0);
scene.add(cube);
到此为止,我们界面就有一个立方体了。
增加临时代码
renderer.render(scene, camera);
7. 设置射线投射器
创建一个射线投射器对象raycaster
和一个二维向量mouse
,用于存储鼠标位置。
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
8. 鼠标移动事件处理
定义一个函数onMouseMove
,用于处理鼠标移动事件。将鼠标在画布上的位置转换为归一化设备坐标(NDC),范围在-1 到 1 之间。
function onMouseMove(event) {
mouse.x = (event.clientX / renderer.domElement.offsetWidth) * 2 - 1;
mouse.y = -(event.clientY / renderer.domElement.offsetHeight) * 2 + 1;
}
9. 动画循环和渲染
定义一个animate
函数,用于执行动画循环。在这个循环中:
- 使用
raycaster.setFromCamera
方法,根据相机和鼠标位置设置射线投射器。 - 使用
raycaster.intersectObjects
方法检测射线与场景中物体的交点。 - 如果之前有物体被选中,但现在鼠标移开了,则恢复其原始颜色。
- 如果有新的物体被选中,则改变其颜色(例如,设置为红色)。
- 使用
renderer.render
方法渲染场景和相机。
我们先给出完整的代码,后面我在进行讲解
let previousIntersected = null;
function animate() {
requestAnimationFrame(animate);
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
if (
previousIntersected &&
(!intersects.length || previousIntersected !== intersects[0].object)
) {
previousIntersected.material.color.setHex(originalColor);
previousIntersected = null;
}
if (intersects.length > 0) {
const intersectedObject = intersects[0].object;
if (previousIntersected !== intersectedObject) {
intersectedObject.material.color.setHex(0xff0000);
previousIntersected = intersectedObject;
}
}
renderer.render(scene, camera);
}
animate();
renderer.domElement.addEventListener("mousemove", onMouseMove, false);
调用animate
函数开始动画循环,并为渲染器的 DOM 元素添加鼠标移动事件监听器。
到此为止,我们鼠标移动上去就能看到颜色变化了
动画讲解
对于上面的 antimate 函数,我在讲解下
请求下一帧动画
requestAnimationFrame(animate);
requestAnimationFrame
是浏览器提供的 API,用于告诉浏览器希望执行动画,并请求浏览器在下一次重绘之前调用指定的函数(在这个例子中是animate
函数)。这有助于实现平滑的动画效果,因为它允许浏览器在合适的时机执行动画更新,以匹配显示器的刷新率。
射线投射和物体相交
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
raycaster.setFromCamera(mouse, camera);
:这行代码使用Raycaster
对象来设置一条从相机出发,通过鼠标位置的射线。mouse
是鼠标位置的归一化设备坐标,camera
是场景中的相机对象。别忘了,我们在 onMouseMove 函数中获取了鼠标位置,并且将其转换为归一化设备坐标,范围是[-1, 1]。const intersects = raycaster.intersectObjects(scene.children, true);
:这行代码检测射线与场景中所有子物体的交点。intersectObjects
方法返回一个包含交点信息的数组。如果射线与任何物体相交,这些信息将被用来处理后续的逻辑。
处理相交结果
在解释这部代码之前,我们先输出一下 previousIntersected 和 intersects
增加临时代码
console.log(previousIntersected, intersects);
当我鼠标没有移动到立方体上时,previousIntersected 是 null,intersects 是一个空数组
当我鼠标移到立方体上时,previousIntersected 是立方体,intersects 是一个数组,里面有一个元素,这个元素就是立方体
这里的逻辑是:首先要记住 animate 函数是一个循环函数,它在每一帧都会执行,所以我们需要一个变量来记录上一次相交的物体,这样我们就可以在下一帧中知道上一次相交的物体是什么,然后根据这个物体来做一些操作。
所以我们定义了一个变量 previousIntersected 来记录上一次相交的物体,初始值为 null。
这部分代码处理鼠标悬停和点击时的物体颜色变化:
- 如果之前有物体被选中(
previousIntersected
不为null
),但现在没有物体与射线相交或者相交的物体不是之前选中的物体,那么将之前选中的物体的颜色恢复为原始颜色。并且将previousIntersected
设置为null
,表示没有物体被选中。
if (
previousIntersected &&
(!intersects.length || previousIntersected !== intersects[0].object)
) {
previousIntersected.material.color.setHex(originalColor);
previousIntersected = null;
}
- 如果有新的物体被选中(
intersects.length > 0
),表示有物体被选中了,那么将选中的物体的颜色设置为红色(0xff0000),并将previousIntersected
设置为选中的物体,以便在下一帧中处理。
if (intersects.length > 0) {
const intersectedObject = intersects[0].object;
if (previousIntersected !== intersectedObject) {
intersectedObject.material.color.setHex(0xff0000);
previousIntersected = intersectedObject;
}
}
总而言之一句话,通过 intersects.length > 0 来判断是否有物体被选中,记住这个核心概念,就比较好理解了。