Skip to content
概述
在构建了包含几何体、材质、纹理与光照的静态场景之后,画面仍停留在单帧渲染的结果上。要让三维场景持续更新并响应用户输入,需要通过渲染循环驱动动画,同时建立从屏幕坐标到三维空间的映射能力。
本章涵盖以下内容:
requestAnimationFrame驱动的渲染循环及其相对于setInterval的优势。- 利用时间差(delta time)实现帧率无关的运动。
- 响应窗口尺寸和设备像素比变化,保持画布清晰与比例正确。
- 使用
Raycaster将鼠标点击转换为三维射线,并检测与物体的相交。 - 集成
OrbitControls实现相机旋转、平移与缩放。
最终的完整示例会将这些机制组合在一起,形成一个可交互的三维场景。
requestAnimationFrame 的工作原理
与 setInterval 的区别
setInterval 以固定时间间隔重复执行回调,但其精度受事件循环繁忙程度影响,无法与浏览器的渲染流程同步。页面切换到后台标签页后,setInterval 依然会触发,持续占用计算资源。此外,它无法利用显示设备的垂直同步信号,容易导致画面撕裂或无效绘制。
requestAnimationFrame 的语义是在浏览器下一次重绘之前执行回调。浏览器会依据显示器的刷新率(通常 60 Hz,约每 16.7 ms 一次)自动调节调用频率,并在页面不可见时暂停,从而避免不必要的计算开销。
基本用法
javascript
function animate() {
requestAnimationFrame(animate);
// 更新场景状态
// ...
renderer.render(scene, camera);
}
animate();requestAnimationFrame 每次只触发一次回调,因此需要在回调中再次调用它以维持循环。回调会收到一个 DOMHighResTimeStamp 参数,表示页面加载后经过的毫秒数,该值可用于计算帧间隔。调用返回的整数 ID 可通过 cancelAnimationFrame(id) 取消后续执行。
若 animate 中仅重复调用 render 而不更新场景数据,渲染结果不会发生变化——场景状态的更新才是动画的本质。
时间驱动变化的问题
最简单的“动起来”是在每帧增加一个固定增量,让物体绕轴旋转:
javascript
let angle = 0;
function animate() {
angle += 0.01;
cube.rotation.y = angle;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}这里的 0.01 是每帧的旋转量。当显示器刷新率是 60 Hz 时,物体每秒旋转 0.6 rad;若换成 120 Hz 的屏幕,同样的代码会让物体每秒旋转 1.2 rad。动画速度与帧率强绑定,在高刷新率设备上运动明显加快。
帧无关动画
使用时间差(delta time)
解决方法是基于两帧之间的实际时间差来计算状态更新量,而非写死一个按帧累加的数值。Three.js 提供了 Clock 工具来简化这一过程:
javascript
const clock = new THREE.Clock();
function animate() {
const delta = clock.getDelta(); // 距离上一帧经过的秒数
cube.rotation.y += 0.6 * delta; // 每秒旋转 0.6 rad
renderer.render(scene, camera);
requestAnimationFrame(animate);
}getDelta() 首次调用返回 0,之后返回距上次调用的间隔秒数。用 speed * delta 得到的是该帧应累积的增量,从而保证无论帧率如何变化,物体在真实时间中的运动速率保持一致。
也可以不使用 Clock,手动记录时间戳:
javascript
let lastTime = 0;
function animate(time) {
const delta = (time - lastTime) / 1000;
lastTime = time;
cube.rotation.y += 0.6 * delta;
// ...
}Clock 在页面从后台恢复时能更准确地处理时间基准,总体推荐使用它。
时间差上限裁剪
当浏览器标签从后台恢复时,getDelta() 可能返回数秒甚至更长的间隔。若不处理,物体会瞬间跳跃一大段距离。实践中会对 delta 设置上限:
javascript
const delta = Math.min(clock.getDelta(), 0.1); // 上限 100 ms这样一来,即便出现异常长的时间差,单帧的更新量也不会超过既定阈值,运动可以平滑恢复。
自适应画布
响应窗口尺寸变化
用户调整浏览器窗口大小,或将窗口拖至不同分辨率的显示器时,如果画布尺寸不随之更新,可能出现两种问题:渲染内容被拉伸或模糊,以及 PerspectiveCamera 的宽高比与画面不一致导致物体比例失调。
监听 resize 事件并更新渲染器与相机可以解决:
javascript
window.addEventListener('resize', () => {
const canvas = renderer.domElement;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
});要点:
- 使用
canvas.clientWidth/clientHeight获取 canvas 元素在布局中实际占用的 CSS 像素尺寸,而非window.innerWidth/innerHeight。后者在 canvas 并非铺满视口或页面存在滚动条时会得到错误的值。 renderer.setSize(w, h, false)的第三个参数为false表示只更新 WebGL 绘制缓冲区的分辨率,不修改 canvas 的 CSS 尺寸(仍由样式表控制)。若省略该参数或设为true,setSize会直接改写style.width/style.height,可能破坏布局。- 宽高比变更后必须调用
camera.updateProjectionMatrix(),否则相机投影矩阵仍使用旧的宽高比,渲染出的物体会发生变形。
设备像素比
高分辨率屏幕(如 Retina)的一个 CSS 像素可能对应 2×2 甚至 3×3 个设备物理像素。如果 WebGL 绘制缓冲区尺寸与 CSS 像素尺寸一致,画面会出现模糊。Three.js 可以通过 setPixelRatio 在内部放大缓冲区:
javascript
renderer.setPixelRatio(window.devicePixelRatio);之后 setSize 的实际绘制缓冲区尺寸会变为 width * pixelRatio × height * pixelRatio,CSS 尺寸不变,从而达到清晰显示的效果。
不过,较高的像素比会显著增加 GPU 填充和内存开销。通常取 Math.min(window.devicePixelRatio, 2) 作为上限,在清晰度与性能之间折中。
射线拾取
从屏幕坐标到射线
在三维场景中“点击物体”是最直接的交互方式。其本质是将屏幕上的二维坐标反向映射为一条从相机出发穿过该点的射线,再检测射线穿过了哪些物体,这正是 Raycaster 的职责。
Three.js 中的拾取流程:
- 获取鼠标在 canvas 上的像素位置,并转换为归一化设备坐标(NDC),范围 -1 到 1。
- 通过
Raycaster.setFromCamera(mouse, camera),将 NDC 坐标与相机结合,生成世界空间中的射线。 - 调用
intersectObjects检测射线与物体的相交情况。
javascript
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// 计算鼠标相对于 canvas 的归一化坐标
mouse.x = (x / canvas.clientWidth) * 2 - 1;
mouse.y = -(y / canvas.clientHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);NDC 中 X 轴从左到右为 -1 到 1,Y 轴从下到上为 -1 到 1。因为屏幕坐标的 Y 轴向下,所以需要取反。setFromCamera 会利用当前相机的投影矩阵将 NDC 转化为世界空间的射线,与相机类型无关。
相交检测
Raycaster.intersectObjects(objects, recursive) 返回与射线相交的物体数组,按距射线起点的距离升序排列。数组的每个元素是 Intersection 对象,包含:
object— 被击中的物体(通常是Mesh)。point— 世界坐标中的交点。distance— 射线起点到交点的距离。face— 相交的三角面信息。faceIndex— 三角形的索引。
javascript
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
const selected = intersects[0].object;
// 对选中物体执行反馈
}intersectObjects 的第一个参数是待检测的物体数组,第二个参数 recursive 设为 true 时会递归检查所有子节点,这对于组结构场景是必须的。
鼠标交互实践
将鼠标事件与射线拾取结合,即可实现点击选取和高亮反馈。
javascript
renderer.domElement.addEventListener('click', onClick, false);
function onClick(event) {
const rect = renderer.domElement.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
mouse.x = (x / rect.width) * 2 - 1;
mouse.y = -(y / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
const obj = intersects[0].object;
obj.material.emissive?.setHex(0x333333);
}
}这里用 getBoundingClientRect 获取 canvas 在视口中的位置与尺寸,据此计算相对于 canvas 的鼠标坐标。相较于直接使用 event.offsetX / offsetY,这种方式能正确处理页面滚动和 CSS 变换,避免坐标偏移。
视觉反馈可以简单地修改材质 emissive 或 color,也可以在独立集合中集中管理高亮状态。射线拾取只需在事件触发时执行,不需要放入渲染循环中持续检测。
相机控制
引入 OrbitControls
在三维场景中,允许用户自由旋转、平移和缩放相机是普遍的交互需求。Three.js 在附加模块中提供了 OrbitControls,它将鼠标和触摸事件转换为相机变换。
javascript
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 启用惯性
controls.dampingFactor = 0.05;
controls.update(); // 初始同步OrbitControls 会监听绑定 DOM 元素上的事件,并直接修改 camera 的 position、rotation 等属性。若启用了阻尼惯性,则需在每一帧的渲染循环中调用 controls.update():
javascript
function animate() {
const delta = Math.min(clock.getDelta(), 0.1);
controls.update(); // 处理惯性
// 其他更新...
renderer.render(scene, camera);
requestAnimationFrame(animate);
}OrbitControls 与自定义相机变换存在天然冲突:若同时在代码中修改相机的位置或朝向,OrbitControls 会在下一次用户交互时覆盖这些变更,或导致相机突然跳动。保持相机由单一来源控制是推荐做法。
完整示例
以下示例将渲染循环、帧无关动画、自适应画布、射线拾取以及 OrbitControls 结合在一个场景中。
javascript
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// --- 场景初始化 ---
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x202020);
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(3, 2, 5);
camera.lookAt(0, 0, 0);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.domElement.style.display = 'block';
document.body.appendChild(renderer.domElement);
// --- 光照 ---
const ambient = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambient);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
dirLight.position.set(1, 3, 2);
scene.add(dirLight);
// --- 物体 ---
const objects = [];
const boxGeo = new THREE.BoxGeometry(1, 1, 1);
const boxMat = new THREE.MeshStandardMaterial({ color: 0xff6600 });
const box = new THREE.Mesh(boxGeo, boxMat);
scene.add(box);
objects.push(box);
const sphereGeo = new THREE.SphereGeometry(0.6, 32, 16);
const sphereMat = new THREE.MeshStandardMaterial({ color: 0x0099ff });
const sphere = new THREE.Mesh(sphereGeo, sphereMat);
sphere.position.x = -1.5;
scene.add(sphere);
objects.push(sphere);
// --- OrbitControls ---
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
// --- 自适应 ---
function resize() {
const canvas = renderer.domElement;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
resize();
// --- 拾取 ---
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
renderer.domElement.addEventListener('click', (event) => {
const rect = renderer.domElement.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
mouse.x = (x / rect.width) * 2 - 1;
mouse.y = -(y / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(objects);
objects.forEach(obj => {
obj.material.emissive?.setHex(0x000000);
});
if (intersects.length > 0) {
const picked = intersects[0].object;
picked.material.emissive?.setHex(0x333333);
}
});
// --- 动画循环 ---
const clock = new THREE.Clock();
function animate() {
const delta = Math.min(clock.getDelta(), 0.1);
box.rotation.y += 1.0 * delta; // 帧率无关旋转
controls.update();
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate();在 resize 函数中,使用 canvas.clientWidth 与 canvas.clientHeight 而非视口尺寸,setSize 的第三个参数设为 false,可以保证即使 canvas 不占满全屏,比例仍然正确。delta 的上限裁剪为 100 ms,防止后台恢复时物体剧烈跳变。
注意事项
- delta 上限裁剪:务必限制
getDelta()或手动计算的delta最大值,否则标签页从后台恢复时会产生大幅跳跃。常见上限值为 100 ms 或更小。 - resize 使用 clientWidth:
window.innerWidth不等于 canvas 实际显示宽度,尤其在 canvas 非满屏或有滚动条时。直接使用会造成比例失调。 - setPixelRatio 上限:过高的像素比(如 3x)会急剧增加渲染像素量,在移动设备上可能导致过热和丢帧。建议使用
Math.min(devicePixelRatio, 2)。 - 拾取性能:
intersectObjects会遍历物体所有三角面,对于面数极高的复杂模型,可能在点击时造成明显卡顿。可将不需要拾取的物体(如地面)排除在检测数组之外,减少检测数量。 - OrbitControls 与自定义相机变换:若要在代码中控制相机,应当避免与
OrbitControls同时操作,否则会出现相互覆盖或相机跳动。实现时令相机变换来源单一。 - 事件监听清理:在 SPA 中切换或销毁场景时,记得移除已绑定的
resize、click等事件监听器,防止内存泄漏。
