1.1 前言
客户需求要能预览 3D 口腔模型,来观察上传的模型是否正确。后期可能还需要编辑,我们决定使用 three.js 开发,本文是我初学 three.js 的记录总结。
1.2 四要素
- 创建场景 Scene
- 添加物体 Mesh(形状 Geometry + 外观 Material)
- 添加摄像机 Camera,为了获得一个视角
- 创建渲染器 Renderer,把摄像机看到的内容画出来
默认情况下,所有东西都在场景的中心点(0, 0, 0),所以看起来一片黑,需要调整摄像机的位置。
1.3 对象变换
所有继承自 Object3D 的类,比如:Mesh、Camera、Group
都能使用它的属性、方法和事件,比如:Transform(变换)属性、lookAt
Transform 属性: position(位置)、scale(缩放)、rotation(旋转)、Quaternion(四元数)
position:对象位置,是 Vector3(三维向量),默认 x 轴向右,y 轴向上,z 轴向后
- set:设置向量的 x、y、z,也可以通过 position.x(/y/z)单独设置
- length:获取向量到中心点的直线距离
- distanceTo:获取两个向量之间的距离
- normalize:向量方向不变,但长度设置为 1 在空间中定位物体是比较困难的,可以使用 AxesHelper 来帮助定位 AxesHelper:轴辅助工具,在场景中显示坐标轴,X 轴为红色,Y 绿色,Z 蓝色
scale:对象缩放,也是 Vector3,默认是(1, 1, 1),和 position 用法一样
rotation:对象旋转,是 Euler(欧拉角),默认是(0, 0, 0, 'XYZ')
- 旋转首先要考虑绕哪个轴旋转,找到旋转轴,然后再考虑轴的旋转顺序
- set:设置欧拉的 x、y、z 和旋转顺序,以弧度为单位
- reorder:设置旋转顺序,如果使用这个方法,必须先设置旋转顺序,再单独设置 x、y、z
- 旋转 Math.PI 正好是半圈,以此类推:一圈 = Math.PI _ 2 = 3.14 _ 2 = 6.28 弧度
- 万向节死锁(Gimbal Lock):写的是按 x 轴旋转,实际却按照 z 轴旋转的问题,旋转轴不生效了
Quaternion:四元数,旋转的另一种表示方式,更复杂可靠,可以避免万向节死锁
- 有时候会得到四元数形式的坐标,只需要将它应用到对象上,旋转就会更新
lookAt:旋转对象使它“看向”某个 Vector3,就是 z 轴指向目标,可以修改 up 属性来调整方向
在创建复杂对象的时候,比如一个房子,里面有门、墙、窗户等,怎么对整体进行 Transform,而不是一个个设置?这时就需要 Group,创建一个组,把所有东西都 add 进去,再操作组。
1.4 动画
在 three.js 中做动画就像制作【定格动画】,移动物品,拍照,再移动,再拍照…
FPS:frames per second,每秒帧数,60FPS 表示屏幕每 1 秒经过 60 次刷新
window.requestAnimationFrame:在下一帧调用函数
const tick = () => {
console.log('tick');
// 更新对象
mesh.rotation.y += 0.01;
// 重新渲染
renderer.render(scene, camera);
// 在下一帧调用 tick 函数,然后 tick 函数执行又会调用 requestAnimationFrame,形成循环
window.requestAnimationFrame(tick);
};
tick();
帧率越高,执行次数越多,导致动画效果越快,我们希望动画效果在任何帧率下都保持一致,怎么做? 方法一:利用时间差,因为不管是什么电脑,好的坏的,每一秒经过的时间是相等的
let lastTime = Date.now();
const tick = () => {
const currentTime = Date.now();
const deltaTime = currentTime - lastTime;
lastTime = currentTime;
mesh.rotation.y += 0.001 _ deltaTime; // 0.001 弧度/毫秒 = 每秒 1 弧度
renderer.render(scene, camera);
window.requestAnimationFrame(tick);
};
tick();
方法二:利用 three 提供的 Clock
const clock = new THREE.Clock(); // 创建时钟并默认启动
const tick = () => {
const elapsedTime = clock.getElapsedTime(); // 获取时钟启动以来经过的秒数
console.log('elapsedTime:', elapsedTime);
mesh.rotation.y = elapsedTime _ (Math.PI \* 2); // 每秒转一圈
renderer.render(scene, camera);
window.requestAnimationFrame(tick);
};
tick();
用数学函数实现的简单动画效果:
// 正弦,动画效果:上下移动,像电梯,一开始在 0
mesh.position.y = Math.sin(elapsedTime);
// 余弦,动画效果:也是上下移动,一开始在 1
mesh.position.y = Math.cos(elapsedTime);
// 正弦和余弦组合,动画效果:顺时针旋转(把 x 和 y 调换就是逆时针旋转)
mesh.position.x = Math.sin(elapsedTime);
mesh.position.y = Math.cos(elapsedTime);
如果你需要实现复杂的动画,那就使用动画库,最常用的是 GSAP,它能做时间线动画
gsap.to(mesh.position, { x: 2, duration: 1, delay: 0 });
gsap.to(mesh.position, { x: 0, duration: 1, delay: 1 });
const tick = () => {
// 不要把 gsap 的执行放在这里,因为 gsap 内部会自行调用 requestAnimationFrame 更新 x 属性,我们只需要在每一帧都进行渲染即可,动画自然就有了
renderer.render(scene, camera);
window.requestAnimationFrame(tick);
};
tick();
1.5 相机和控制器
1.5.1 相机
PerspectiveCamera:最核心,最常用的相机,模拟人眼所见,透视物体近大远小。常用于:3D
- fov:垂直视野角度,决定了相机“看”出去的广度。建议值在 25-75 之间,25 的效果类似望眼镜,75 的效果类似广角镜头,能看到更多的物体,但边缘的物体会明显变形
- aspect:宽高比,渲染宽度除以渲染高度
- near:近裁剪面,任何距离小于 near 值的物体都不会被渲染,常用值 0.1
- far:远裁剪面,常用值 100,最好设置成能容纳场景中最远物体的距离
- 合理设置 near 和 far,否则可能导致 Z-fighting
- 修改属性后,必须调用 updateProjectionMatrix 以生效
OrthographicCamera:渲染出来的场景,没有透视效果,物体远近一样大。常用于:2D 游戏、固定在屏幕上的 UI 界面
- 左/右/上/下边界:保持宽高比
- 也有 near 和 far 参数
CubeCamera:从一个点向周围六个方向(上、下、左、右、前、后)拍摄场景,生成一个“环境贴图”(CubeMap),给其他物体对象使用。常用于:反射
ArrayCamera:摄像头阵列(想象成“监控墙”),用多个子相机来渲染场景,通过不同的视角渲染同一场景的不同部分。常用于:分屏多人游戏
StereoCamera:用两台相机来渲染场景,模拟一双眼睛,创造出立体感。常用于:VR 开发
1.5.2 控制器
OrbitControls:轨道控制器,最常用
- new OrbitControls(object:要控制的相机, domElement:用于事件监听的元素)
- target(Vector3):更新旋转焦点
- enableDamping: 启用阻尼(惯性)效果,注意开启后必须在动画循环中调用 update()
- update():更新控件设置,所有设置更新后,都需要重新调用以生效
TrackballControls:轨迹球控制器,类似 OrbitControls,但更自由没有限制角度(老旧)
ArcballControls:弧球控制器,比 TrackballControls 更好,用于查看一个物体的细节
MapControls:地图控制器,类似 OrbitControls,但专为俯瞰视角优化
FirstPersonControls:第一人称控制器,y 轴被限制且不能翻转(老旧)
PointerLockControls:指针锁定控制器,比 FirstPersonControls 更好,用于任何第一人称的游戏(FPS)或应用
FlyControls:飞行控制器
DragControls:拖拽控制器,用来移动场景中的物体
TransformControls:变换控制器,用于编辑物体位置、旋转和缩放
1.6 全屏渲染
始终充满视口:
.webgl {
position: fixed;
top: 0;
left: 0;
}
const sizes = {
width: window.innerWidth,
height: window.innerHeight,
};
window.addEventListener('resize', () => {
// 更新 sizes 对象中的宽高
sizes.width = window.innerWidth;
sizes.height = window.innerHeight;
// 更新相机的 aspect(宽高比),防止物体变形
camera.aspect = sizes.width / sizes.height;
camera.updateProjectionMatrix();
// 更新渲染器的尺寸,并设置像素比,防止在高分辨率屏幕上出现模糊
// 2 像素比是上限,像素比再高,肉眼也看不出来,还会增大 GPU 的渲染压力
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
});
1.7 模型预览/编辑
1.7.1 3D 模型常见格式
常见的 3D 模型格式(5 个):stl、obj、ply 以及为 web 优化的 gltf/glb
做口腔模型的软件:3Shape、EXO(exocad)
1.7.2 模型预览/编辑
如果只是需要预览模型,并且后期也没有编辑模型的需求,那可以使用 Online3DViewer,支持格式多,预览效果清晰,缺点是不好扩展
如果预览只是早期功能,后期可能需要编辑模型,那就使用 three 自己开发。可以参考 three editor 的源码,常见格式直接拖拽进去都能预览编辑
- ply 格式需要计算顶点法线、启用顶点颜色,否则不能正确预览
- 多文件的 obj、gltf 格式,需要加载全部文件才能正确预览,所以一般是拖拽一个压缩包(包含所有模型信息文件),然后解压预览
从 three editor 迁移的模型预览功能(仅做参考):3DModelViewer
如果要做 3D 模型的编辑,强烈建议:后端在源头将所有模型统一转换为 .GLB 格式。为什么?
- glb 是为 web 而生的格式,二进制、文件小、加载快,并且可以内嵌材质和动画信息
- 统一的格式能极大简化前端处理逻辑,否则不同格式需要不同的加载器,前端分别处理会很复杂麻烦
1.8 最后
学习资料:threejs-journey.com/
2025/07/19:发布文章