第 6 篇:相机与 OrbitControls
本文是"使用 Three.js 构建转炉数字孪生系统"系列的第 6 篇。
本篇将系统介绍透视相机、正交相机、相机位置与朝向,以及工业三维场景中最常用的浏览控制器
OrbitControls。
在 Three.js 中,场景负责保存三维对象,渲染器负责生成画面,而相机负责决定:
text
用户站在哪里
用户看向哪里
用户能够看到多大范围
哪些远近范围会被绘制
物体以透视方式还是正交方式投影
在转炉数字孪生系统中,相机不仅用于"看见模型",还决定用户如何观察设备:
text
厂房总览
转炉正视图
转炉侧视图
炉口观察视角
氧枪局部视角
钢包运输视角
设备检修视角
顶部平面视角
工业三维场景通常还需要配合 OrbitControls,让用户通过鼠标完成:
- 围绕设备旋转观察;
- 拉近和拉远;
- 平移观察中心;
- 切换预设视角;
- 限制相机进入模型内部;
- 限制用户翻转到地下或无效区域。
一、本文要解决的问题
完成本文后,应当能够回答:
- Three.js 中常用的相机有哪些;
- 透视相机与正交相机有什么区别;
fov、aspect、near、far分别控制什么;- 正交相机的
left、right、top、bottom如何计算; - 相机的
position与观察目标是什么关系; lookAt()实际改变了什么;- 为什么使用 OrbitControls 后不能只修改相机位置;
controls.target为什么非常重要;- 如何限制旋转、缩放和平移;
- 如何创建转炉正视图、侧视图、俯视图和炉口视角;
- 如何在浏览器尺寸变化时同时适配透视相机和正交相机;
- 页面销毁时为什么需要调用
controls.dispose()。
二、Camera 是什么
1. 相机不会直接生成画面
Three.js 中的相机只负责描述观察方式。
真正产生画面的仍然是:
javascript
renderer.render(scene, camera);
这里的含义是:
使用指定的
camera观察scene,并将结果绘制到渲染器的 Canvas 中。
只有相机而没有场景和渲染器,不会显示任何内容。
2. Camera 是抽象基类
Three.js 中的 Camera 是相机的抽象基础类。
实际项目中通常不会直接创建:
javascript
new THREE.Camera();
而是创建具体相机,例如:
javascript
THREE.PerspectiveCamera
THREE.OrthographicCamera
其中:
text
PerspectiveCamera
→ 透视相机
OrthographicCamera
→ 正交相机
3. 相机也继承自 Object3D
相机同样继承自 Object3D,因此拥有:
javascript
camera.position
camera.rotation
camera.quaternion
camera.scale
camera.parent
camera.children
camera.lookAt()
可以将相机加入场景树,也可以把相机挂在移动设备上。
例如,把检修相机挂在天车上:
javascript
craneRoot.add(inspectionCamera);
当天车移动时,相机会跟随父节点移动。
不过,相机不一定必须加入 Scene 才能用于渲染:
javascript
renderer.render(scene, camera);
只要渲染器持有相机引用,就可以使用它。
三、透视相机 PerspectiveCamera
4. 什么是透视投影
透视相机模拟人眼和普通摄影机的观察效果:
text
近处的物体看起来更大
远处的物体看起来更小
平行线在远处产生汇聚感
这符合人类对真实三维空间的视觉习惯。
因此,透视相机是 Three.js 中最常见的三维场景相机,也最适合:
- 厂房漫游;
- 设备总览;
- 转炉近距离观察;
- 第一人称或第三人称视角;
- 大多数工业数字孪生页面。
5. 创建透视相机
javascript
const camera = new THREE.PerspectiveCamera(
60,
width / height,
0.1,
500
);
完整构造形式:
javascript
new THREE.PerspectiveCamera(
fov,
aspect,
near,
far
);
四个参数分别表示:
text
fov:垂直视野角度
aspect:宽高比
near:近裁剪面
far:远裁剪面
6. fov:视野角度
fov 是垂直方向的视野角度,单位为度。
javascript
const camera = new THREE.PerspectiveCamera(
60,
aspect,
0.1,
500
);
较小的 fov:
javascript
camera.fov = 30;
通常会产生:
- 观察范围较窄;
- 远近压缩感较强;
- 类似长焦镜头;
- 画面边缘变形较小。
较大的 fov:
javascript
camera.fov = 90;
通常会产生:
- 观察范围更广;
- 透视感更强;
- 近大远小更明显;
- 画面边缘可能出现明显拉伸。
工业三维场景可以先从下面的范围开始:
text
45° 到 65°
例如:
javascript
camera.fov = 55;
camera.updateProjectionMatrix();
修改 fov 后必须调用:
javascript
camera.updateProjectionMatrix();
7. aspect:宽高比
javascript
const aspect = width / height;
相机宽高比应与 Canvas 显示区域的宽高比一致。
窗口尺寸变化时:
javascript
camera.aspect = width / height;
camera.updateProjectionMatrix();
如果宽高比不正确,可能出现:
- 圆形变成椭圆;
- 设备横向拉伸;
- 设备纵向压缩;
- 鼠标选择与视觉位置不一致。
8. near 和 far:观察深度范围
javascript
const camera = new THREE.PerspectiveCamera(
60,
aspect,
0.1,
500
);
表示只绘制距离相机位于以下范围内的内容:
text
0.1 到 500
距离小于 near 的内容会被裁掉。
距离大于 far 的内容也会被裁掉。
透视相机的 near 必须大于 0。
错误:
javascript
camera.near = 0;
建议:
javascript
camera.near = 0.1;
near 不要过小,far 也不要无意义地过大。
例如下面的范围可能造成深度精度问题:
javascript
camera.near = 0.00001;
camera.far = 100000000;
这可能导致:
- Z-fighting;
- 两个相近表面闪烁;
- 阴影异常;
- 深度判断不稳定。
对于以米为单位的单个转炉车间,可以先尝试:
javascript
camera.near = 0.1;
camera.far = 500;
camera.updateProjectionMatrix();
对于大型厂区,可根据实际尺寸增加 far,但仍应尽量保持合理范围。
9. 透视相机的观察范围是视锥体
透视相机可见区域可以理解为一个切掉尖端的金字塔:
text
far
┌────────────────┐
/ \
/ \
/ \
└────── near ───────────┘
camera
只有位于视锥体内部的对象才可能被绘制。
可以使用 CameraHelper 查看视锥体:
javascript
const cameraHelper =
new THREE.CameraHelper(camera);
scene.add(cameraHelper);
修改相机参数后:
javascript
camera.updateProjectionMatrix();
cameraHelper.update();
需要使用另一台相机观察 CameraHelper,否则主相机通常无法直接看到自己的完整视锥体。
四、正交相机 OrthographicCamera
10. 什么是正交投影
正交相机不产生近大远小的透视变化。
一个物体无论距离相机近还是远,只要尺寸不变,它在画面中的显示大小就基本保持不变。
可以理解为:
text
透视相机
→ 更接近人眼观察
正交相机
→ 更接近工程图、CAD 视图和二维平面图
正交相机适合:
- 设备正视图;
- 设备侧视图;
- 顶部平面图;
- 工艺布置图;
- 需要减少透视误差的尺寸对比;
- 二维标注和小地图;
- CAD 风格展示。
11. 创建正交相机
javascript
const viewSize = 20;
const aspect = width / height;
const camera = new THREE.OrthographicCamera(
-viewSize * aspect / 2,
viewSize * aspect / 2,
viewSize / 2,
-viewSize / 2,
0.1,
500
);
完整构造形式:
javascript
new THREE.OrthographicCamera(
left,
right,
top,
bottom,
near,
far
);
这些参数定义一个长方体观察区域:
text
left:左边界
right:右边界
top:上边界
bottom:下边界
near:近裁剪面
far:远裁剪面
与透视相机不同,正交相机的 near 可以取 0。
不过,工业场景中仍可以使用:
javascript
near = 0.1;
以保持统一习惯。
12. 正交相机没有 fov
正交相机不通过 fov 决定范围。
它通过:
text
left
right
top
bottom
确定画面能够显示多少世界单位。
例如:
javascript
const camera = new THREE.OrthographicCamera(
-10,
10,
10,
-10,
0.1,
500
);
表示观察区域宽度为:
text
20 个世界单位
高度也为:
text
20 个世界单位
13. 正交相机的 zoom
正交相机常使用:
javascript
camera.zoom
控制显示范围。
例如:
javascript
camera.zoom = 2;
camera.updateProjectionMatrix();
zoom 越大,画面看起来越接近物体。
zoom 越小,显示范围越大。
OrbitControls 操作正交相机时,鼠标滚轮主要改变的就是 camera.zoom,而不是像透视相机那样改变相机与目标点之间的距离。
14. 正交相机的窗口适配
创建函数:
javascript
function createOrthographicCamera(
width,
height,
viewSize = 20
) {
const aspect = width / height;
const camera =
new THREE.OrthographicCamera(
-viewSize * aspect / 2,
viewSize * aspect / 2,
viewSize / 2,
-viewSize / 2,
0.1,
500
);
return camera;
}
窗口变化时:
javascript
function updateOrthographicCamera(
camera,
width,
height,
viewSize
) {
const aspect = width / height;
camera.left =
-viewSize * aspect / 2;
camera.right =
viewSize * aspect / 2;
camera.top =
viewSize / 2;
camera.bottom =
-viewSize / 2;
camera.updateProjectionMatrix();
}
正交相机没有 aspect 属性用于直接完成窗口适配,因此需要重新计算四个边界。
五、透视相机和正交相机如何选择
15. 对比
| 对比项 | PerspectiveCamera | OrthographicCamera |
|---|---|---|
| 投影效果 | 近大远小 | 远近大小基本不变 |
| 真实感 | 更强 | 更像工程图 |
| 常见用途 | 漫游、总览、设备观察 | 正视、侧视、俯视、平面图 |
| 观察范围 | fov、aspect、near、far | left、right、top、bottom、near、far |
| 缩放方式 | 改变相机与目标距离 | 主要改变 zoom |
| 工业三维主视角 | 常用 | 辅助视角常用 |
16. 转炉数字孪生中的推荐组合
可以采用:
text
主三维视图
→ PerspectiveCamera
设备正视图
→ OrthographicCamera
设备侧视图
→ OrthographicCamera
车间俯视图
→ OrthographicCamera
炉口近距离观察
→ PerspectiveCamera
不必强制整个系统只使用一种相机。
可以根据业务视图切换相机,也可以在同一个页面中使用多个视口。
六、相机位置
17. 设置相机位置
javascript
camera.position.set(
18,
12,
20
);
相机位置同样是 Vector3:
javascript
console.log(camera.position);
可以分别设置:
javascript
camera.position.x = 18;
camera.position.y = 12;
camera.position.z = 20;
工业项目中建议使用明确的 Vector3 预设:
javascript
const overviewPosition =
new THREE.Vector3(
18,
12,
20
);
camera.position.copy(
overviewPosition
);
18. 位置不等于朝向
下面的代码只移动了相机:
javascript
camera.position.set(
18,
12,
20
);
但没有保证相机看向转炉。
相机默认沿自身局部 -Z 方向观察。
因此,移动相机后通常还需要设置朝向:
javascript
camera.lookAt(
0,
5,
0
);
19. 相机可以挂在父节点下
例如创建转炉检修相机锚点:
javascript
const inspectionCameraAnchor =
new THREE.Object3D();
inspectionCameraAnchor.position.set(
4,
3,
6
);
converterRoot.add(
inspectionCameraAnchor
);
inspectionCameraAnchor.add(
inspectionCamera
);
相机将继承父节点的变换。
这适合:
- 天车随行相机;
- 设备检修视角;
- 车辆驾驶视角;
- 模型内部摄像机;
- 预先在模型中定义的 Camera 节点。
使用 OrbitControls 的主浏览相机通常不建议挂在会持续运动或非均匀缩放的设备节点下,否则控制逻辑会变得复杂。
七、相机朝向和 lookAt()
20. lookAt() 的作用
javascript
camera.lookAt(
x,
y,
z
);
或者:
javascript
const target =
new THREE.Vector3(
0,
5,
0
);
camera.lookAt(target);
lookAt() 会调整相机旋转,使相机朝向世界空间中的目标点。
它不会移动相机位置。
21. 相机看向局部 -Z 方向
普通 Object3D 的方向理解经常容易混淆。
对于相机,可以记住:
Three.js 相机默认沿自身局部负 Z 轴方向观察。
可以获取世界观察方向:
javascript
const direction =
new THREE.Vector3();
camera.getWorldDirection(
direction
);
console.log(
direction.toArray()
);
22. camera.up
相机的上方向默认是:
javascript
camera.up.set(0, 1, 0);
也就是 Y 轴向上。
lookAt() 会结合 camera.up 决定相机的滚转方向。
如果项目采用 Z 轴向上,可以修改:
javascript
camera.up.set(0, 0, 1);
但整个项目应统一坐标约定。
转炉数字孪生教程中仍建议:
text
Y 轴向上
23. lookAt() 与父节点缩放
lookAt() 接收的是世界坐标目标点。
Three.js 官方文档提醒,它不支持对象处于非均匀缩放父节点下的情况。
因此,不建议把主相机放入:
javascript
parent.scale.set(
2,
1,
0.5
);
这样的父节点中再依赖 lookAt()。
相机父节点尽量保持:
javascript
scale.set(1, 1, 1);
八、OrbitControls
24. OrbitControls 是什么
OrbitControls 是 Three.js addons 中的相机控制器。
它允许相机围绕一个目标点进行:
text
Orbit:旋转观察
Dolly / Zoom:拉近和拉远
Pan:平移目标区域
它保持相机的上方向,默认使用 Y 轴向上。
OrbitControls 不是 Three.js 核心命名空间中的直接成员,必须单独导入。
25. 导入 OrbitControls
javascript
import * as THREE from "three";
import {
OrbitControls
} from "three/addons/controls/OrbitControls.js";
不需要额外执行:
bash
npm install OrbitControls
因为它已经包含在 three 软件包的 addons 中。
26. 创建 OrbitControls
javascript
const controls =
new OrbitControls(
camera,
renderer.domElement
);
两个参数分别是:
text
camera
→ 需要控制的相机
renderer.domElement
→ 接收鼠标和触摸事件的 DOM 元素
通常应把事件绑定到 Three.js Canvas:
javascript
renderer.domElement
而不是整个 document。
这样可以避免用户操作页面菜单或参数面板时,三维相机也发生移动。
九、OrbitControls 的 target
27. target 是什么
javascript
controls.target
表示相机围绕旋转和观察的中心点。
默认目标通常位于:
text
(0, 0, 0)
但转炉主体可能位于:
text
(0, 6, 0)
因此应该设置:
javascript
controls.target.set(
0,
6,
0
);
controls.update();
28. 相机位置和 target 必须一起理解
OrbitControls 的观察状态由至少两部分决定:
text
camera.position
controls.target
例如:
javascript
camera.position.set(
18,
12,
20
);
controls.target.set(
0,
5,
0
);
controls.update();
相机位于:
text
(18, 12, 20)
并围绕:
text
(0, 5, 0)
观察。
只修改相机位置,不更新目标点,可能仍然看向旧区域。
只修改目标点,不调用 controls.update(),控制器状态可能没有立即同步。
29. OrbitControls 与 lookAt()
没有 OrbitControls 时,可以直接使用:
javascript
camera.lookAt(target);
使用 OrbitControls 后,更推荐设置:
javascript
camera.position.copy(
cameraPosition
);
controls.target.copy(
targetPosition
);
controls.update();
原因是 OrbitControls 会根据 camera.position 和 controls.target 重新计算相机朝向。
如果在控制器运行期间只调用:
javascript
camera.lookAt(newTarget);
下一次 controls.update() 可能再次让相机看向 controls.target。
因此,使用 OrbitControls 时,观察目标应由:
javascript
controls.target
统一管理。
十、旋转、缩放和平移
30. 默认鼠标操作
OrbitControls 常见默认操作为:
text
鼠标左键拖动
→ 围绕 target 旋转
鼠标滚轮或中键
→ 拉近和拉远
鼠标右键拖动
→ 平移
触摸设备通常使用:
text
单指移动
→ 旋转
双指缩放
→ 缩放
双指移动
→ 平移
具体操作也可以通过控制器配置修改。
31. 开启或关闭操作
旋转:
javascript
controls.enableRotate = true;
缩放:
javascript
controls.enableZoom = true;
平移:
javascript
controls.enablePan = true;
例如,设备固定正视图不允许旋转:
javascript
controls.enableRotate = false;
controls.enablePan = true;
controls.enableZoom = true;
例如,只允许查看,不允许用户改变相机:
javascript
controls.enabled = false;
32. 操作速度
旋转速度:
javascript
controls.rotateSpeed = 0.8;
缩放速度:
javascript
controls.zoomSpeed = 1.0;
平移速度:
javascript
controls.panSpeed = 0.8;
工业大型场景中,不建议设置过高,否则轻微滚轮或鼠标移动就可能让用户失去设备位置。
33. 平移方式
javascript
controls.screenSpacePanning = true;
表示平移更接近屏幕空间的上下左右。
设置为:
javascript
controls.screenSpacePanning = false;
时,平移会更多地受到相机上方向和世界空间平面的影响。
对于普通数字孪生浏览,可以先使用默认值,再根据用户体验调整。
十一、阻尼和平滑操作
34. enableDamping
开启阻尼:
javascript
controls.enableDamping = true;
controls.dampingFactor = 0.08;
阻尼会让相机操作具有惯性,停止拖动后逐渐减速,而不是立即停止。
这通常能改善工业三维场景的浏览体验。
35. 开启阻尼后必须每帧 update()
javascript
function animate() {
controls.update();
renderer.render(
scene,
camera
);
}
renderer.setAnimationLoop(
animate
);
如果启用了:
javascript
controls.enableDamping = true;
却没有在动画循环中调用:
javascript
controls.update();
阻尼效果不会正常工作。
自动旋转同样需要持续调用 update()。
36. deltaTime
OrbitControls 当前支持向 update() 传递秒为单位的时间增量,用于让自动旋转等效果减少对刷新率的依赖。
javascript
const clock = new THREE.Clock();
function animate() {
const deltaTime =
clock.getDelta();
controls.update(
deltaTime
);
renderer.render(
scene,
camera
);
}
只使用阻尼而不依赖自动旋转时,直接调用:
javascript
controls.update();
通常已经足够。
十二、相机控制限制
37. 限制透视相机距离
透视相机使用:
javascript
controls.minDistance = 5;
controls.maxDistance = 80;
表示相机与 target 之间允许的最小和最大距离。
这可以防止:
- 相机进入转炉内部;
- 用户无限拉远导致设备消失;
- 相机靠近表面时裁剪异常;
- 用户失去当前车间位置。
38. 限制正交相机缩放
正交相机使用:
javascript
controls.minZoom = 0.5;
controls.maxZoom = 6;
这限制的是:
javascript
camera.zoom
而不是 minDistance 和 maxDistance。
修改或由 OrbitControls 调整正交相机 zoom 后,控制器会维护相机投影更新。
手动修改时仍应调用:
javascript
camera.updateProjectionMatrix();
39. 限制垂直旋转
OrbitControls 使用极角控制上下旋转范围:
javascript
controls.minPolarAngle =
THREE.MathUtils.degToRad(15);
controls.maxPolarAngle =
THREE.MathUtils.degToRad(85);
极角范围为:
text
0 到 π
可以理解为:
text
0
→ 位于目标正上方
π / 2
→ 位于目标水平侧面
π
→ 位于目标正下方
限制 maxPolarAngle 小于 90° 可以防止相机进入地面以下。
例如:
javascript
controls.maxPolarAngle =
THREE.MathUtils.degToRad(88);
40. 限制水平旋转
javascript
controls.minAzimuthAngle =
THREE.MathUtils.degToRad(-120);
controls.maxAzimuthAngle =
THREE.MathUtils.degToRad(120);
方位角用于限制相机围绕目标点的水平旋转范围。
适合:
- 只允许观察设备正面和两侧;
- 防止用户进入墙体后方;
- 避免看到尚未建模的区域;
- 固定操作台观察范围。
如果不需要限制,保持默认无限范围即可。
41. 限制目标点移动范围
OrbitControls 当前还提供:
javascript
controls.minTargetRadius
controls.maxTargetRadius
controls.cursor
可以限制 target 相对于 cursor 的移动距离。
例如:
javascript
controls.cursor.set(
0,
5,
0
);
controls.minTargetRadius = 0;
controls.maxTargetRadius = 25;
这可以防止用户通过平移把观察中心拖到厂房之外。
对于不需要复杂限制的初学项目,也可以先关闭平移:
javascript
controls.enablePan = false;
或者在 change 事件中自行限制 controls.target。
42. 手动限制 target 高度
例如不允许目标点进入地下:
javascript
controls.addEventListener(
"change",
() => {
controls.target.y =
Math.max(
controls.target.y,
0
);
}
);
限制在车间区域:
javascript
controls.addEventListener(
"change",
() => {
controls.target.x =
THREE.MathUtils.clamp(
controls.target.x,
-30,
30
);
controls.target.y =
THREE.MathUtils.clamp(
controls.target.y,
0,
25
);
controls.target.z =
THREE.MathUtils.clamp(
controls.target.z,
-30,
30
);
}
);
注意不要在 change 回调中再次无条件制造新的控制器变化,否则可能形成多余更新。
十三、预设视角
43. 为什么需要预设视角
数字孪生系统不能只让用户手动拖动相机。
常用设备和工位应提供一键视角:
text
车间总览
转炉正视
转炉侧视
转炉顶部
炉口观察
氧枪观察
钢包工位
恢复默认
一个预设视角至少包含:
text
相机位置
OrbitControls 目标点
透视相机还可以包含:
text
fov
正交相机还可以包含:
text
zoom
44. 定义视角对象
javascript
const cameraPresets = {
overview: {
position:
new THREE.Vector3(
22,
16,
24
),
target:
new THREE.Vector3(
0,
5,
0
)
},
front: {
position:
new THREE.Vector3(
0,
7,
22
),
target:
new THREE.Vector3(
0,
6,
0
)
},
side: {
position:
new THREE.Vector3(
22,
7,
0
),
target:
new THREE.Vector3(
0,
6,
0
)
},
top: {
position:
new THREE.Vector3(
0,
30,
0.001
),
target:
new THREE.Vector3(
0,
0,
0
)
},
mouth: {
position:
new THREE.Vector3(
0,
9,
9
),
target:
new THREE.Vector3(
0,
7,
0
)
}
};
顶部视角中的 Z 不直接使用 0,而是使用一个非常小的值:
javascript
0.001
这样可以减少相机观察方向与 camera.up 完全共线时产生的朝向不稳定。
45. 立即切换视角
javascript
function applyCameraPreset(
presetName
) {
const preset =
cameraPresets[presetName];
if (!preset) {
throw new Error(
`未知相机视角:${presetName}`
);
}
camera.position.copy(
preset.position
);
controls.target.copy(
preset.target
);
controls.update();
}
调用:
javascript
applyCameraPreset("front");
46. 使用 saveState() 和 reset()
OrbitControls 可以保存和恢复状态:
javascript
controls.saveState();
用户操作后恢复:
javascript
controls.reset();
需要注意:
text
reset()
恢复的是调用 saveState() 时保存的状态,或者控制器创建时的初始状态。
如果初始化后修改了相机位置和目标点,应在正确状态下调用:
javascript
controls.update();
controls.saveState();
47. 平滑切换视角
立即跳转虽然简单,但工业展示中通常希望相机平滑移动。
可以保存起点和终点:
javascript
const cameraTransition = {
active: false,
progress: 0,
duration: 1.2,
startPosition:
new THREE.Vector3(),
endPosition:
new THREE.Vector3(),
startTarget:
new THREE.Vector3(),
endTarget:
new THREE.Vector3()
};
开始切换:
javascript
function startCameraTransition(
presetName
) {
const preset =
cameraPresets[presetName];
if (!preset) {
return;
}
cameraTransition.active = true;
cameraTransition.progress = 0;
cameraTransition
.startPosition
.copy(camera.position);
cameraTransition
.endPosition
.copy(preset.position);
cameraTransition
.startTarget
.copy(controls.target);
cameraTransition
.endTarget
.copy(preset.target);
}
每帧更新:
javascript
function updateCameraTransition(
deltaTime
) {
if (!cameraTransition.active) {
return;
}
cameraTransition.progress +=
deltaTime /
cameraTransition.duration;
const t =
THREE.MathUtils.clamp(
cameraTransition.progress,
0,
1
);
const smoothT =
t * t * (3 - 2 * t);
camera.position.lerpVectors(
cameraTransition.startPosition,
cameraTransition.endPosition,
smoothT
);
controls.target.lerpVectors(
cameraTransition.startTarget,
cameraTransition.endTarget,
smoothT
);
controls.update();
if (t >= 1) {
cameraTransition.active = false;
}
}
动画循环:
javascript
const clock = new THREE.Clock();
function animate() {
const deltaTime =
clock.getDelta();
updateCameraTransition(
deltaTime
);
controls.update(
deltaTime
);
renderer.render(
scene,
camera
);
}
相机自动切换期间,可以暂时禁止用户控制:
javascript
controls.enabled = false;
切换结束后恢复:
javascript
controls.enabled = true;
也可以允许用户随时打断动画,具体取决于产品设计。
十四、将相机聚焦到设备
48. 使用包围盒计算目标点
加载真实设备模型后,不应为每个设备手工猜测目标中心。
可以通过包围盒计算:
javascript
const box =
new THREE.Box3()
.setFromObject(model);
const center =
new THREE.Vector3();
box.getCenter(center);
设置控制目标:
javascript
controls.target.copy(center);
49. 根据模型尺寸计算相机距离
javascript
const size =
new THREE.Vector3();
box.getSize(size);
const maxSize = Math.max(
size.x,
size.y,
size.z
);
透视相机中,可以根据 fov 估算距离:
d = s / 2 tan ( f o v / 2 ) d=\frac{s/2}{\tan(\mathrm{fov}/2)} d=tan(fov/2)s/2
代码:
javascript
const fovInRadians =
THREE.MathUtils.degToRad(
camera.fov
);
const distance =
maxSize /
(2 * Math.tan(
fovInRadians / 2
));
增加安全系数:
javascript
const safeDistance =
distance * 1.5;
沿当前观察方向放置相机:
javascript
const direction =
new THREE.Vector3()
.subVectors(
camera.position,
controls.target
)
.normalize();
camera.position.copy(center)
.addScaledVector(
direction,
safeDistance
);
controls.target.copy(center);
controls.update();
这个计算只是基础估算。
真实项目还需要考虑:
- Canvas 宽高比;
- 模型横向和纵向尺寸;
- 预留 UI 空间;
- 相机 near 和 far;
- 设备观察方向。
十五、切换透视相机和正交相机
50. 创建两台相机
javascript
const perspectiveCamera =
new THREE.PerspectiveCamera(
55,
width / height,
0.1,
500
);
perspectiveCamera.position.set(
20,
14,
22
);
const viewSize = 24;
const aspect = width / height;
const orthographicCamera =
new THREE.OrthographicCamera(
-viewSize * aspect / 2,
viewSize * aspect / 2,
viewSize / 2,
-viewSize / 2,
0.1,
500
);
orthographicCamera.position.set(
0,
10,
30
);
51. 管理当前相机
javascript
let activeCamera =
perspectiveCamera;
渲染:
javascript
renderer.render(
scene,
activeCamera
);
OrbitControls 需要控制当前相机。
简单而稳定的方式是在切换时销毁旧控制器并创建新控制器:
javascript
let controls =
createControls(
activeCamera
);
function createControls(camera) {
const nextControls =
new OrbitControls(
camera,
renderer.domElement
);
nextControls.enableDamping = true;
nextControls.target.set(
0,
6,
0
);
nextControls.update();
return nextControls;
}
切换:
javascript
function switchCamera(
nextCamera
) {
const currentTarget =
controls.target.clone();
controls.dispose();
activeCamera = nextCamera;
controls =
createControls(
activeCamera
);
controls.target.copy(
currentTarget
);
controls.update();
}
这样可以避免旧控制器继续监听 Canvas 事件。
十六、窗口尺寸适配
52. 透视相机适配
javascript
function resizePerspectiveCamera(
camera,
width,
height
) {
camera.aspect =
width / height;
camera.updateProjectionMatrix();
}
53. 正交相机适配
javascript
function resizeOrthographicCamera(
camera,
width,
height,
viewSize
) {
const aspect =
width / height;
camera.left =
-viewSize * aspect / 2;
camera.right =
viewSize * aspect / 2;
camera.top =
viewSize / 2;
camera.bottom =
-viewSize / 2;
camera.updateProjectionMatrix();
}
54. 同时适配两台相机
javascript
function handleResize() {
const width =
app.clientWidth;
const height =
app.clientHeight;
if (
width === 0 ||
height === 0
) {
return;
}
resizePerspectiveCamera(
perspectiveCamera,
width,
height
);
resizeOrthographicCamera(
orthographicCamera,
width,
height,
viewSize
);
renderer.setSize(
width,
height,
false
);
}
十七、完整转炉相机示例
55. 示例功能
下面的示例包含:
- 透视相机;
- 正交相机;
- OrbitControls;
- 阻尼;
- 旋转限制;
- 缩放限制;
- 平移范围限制;
- 总览、正视、侧视、俯视和炉口视角;
- 透视与正交切换;
- 窗口尺寸适配;
- 页面销毁函数。
56. index.html
html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Three.js 相机与 OrbitControls</title>
</head>
<body>
<div id="app">
<div class="toolbar">
<button data-view="overview">总览</button>
<button data-view="front">正视</button>
<button data-view="side">侧视</button>
<button data-view="top">俯视</button>
<button data-view="mouth">炉口</button>
<button id="perspective-button">透视</button>
<button id="orthographic-button">正交</button>
</div>
</div>
<script
type="module"
src="/src/main.js"
></script>
</body>
</html>
57. style.css
css
* {
box-sizing: border-box;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
}
body {
overflow: hidden;
font-family:
Inter,
"Microsoft YaHei",
sans-serif;
background: #0f172a;
}
#app {
position: relative;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
.toolbar {
position: absolute;
z-index: 10;
top: 16px;
left: 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.toolbar button {
padding: 8px 12px;
border: 1px solid #475569;
border-radius: 6px;
color: #e2e8f0;
background: rgba(15, 23, 42, 0.86);
cursor: pointer;
}
.toolbar button:hover {
background: rgba(51, 65, 85, 0.95);
}
58. main.js
javascript
import * as THREE from "three";
import {
OrbitControls
} from "three/addons/controls/OrbitControls.js";
import "./style.css";
const app =
document.querySelector("#app");
if (!app) {
throw new Error(
"未找到 #app 容器"
);
}
// 场景
const scene =
new THREE.Scene();
scene.background =
new THREE.Color(0x0f172a);
// 渲染器
const renderer =
new THREE.WebGLRenderer({
antialias: true
});
renderer.setPixelRatio(
Math.min(
window.devicePixelRatio,
2
)
);
app.appendChild(
renderer.domElement
);
// 初始尺寸
const initialWidth =
app.clientWidth;
const initialHeight =
app.clientHeight;
// 透视相机
const perspectiveCamera =
new THREE.PerspectiveCamera(
55,
initialWidth /
initialHeight,
0.1,
500
);
perspectiveCamera.position.set(
22,
16,
24
);
// 正交相机
const orthographicViewSize = 24;
const initialAspect =
initialWidth /
initialHeight;
const orthographicCamera =
new THREE.OrthographicCamera(
-orthographicViewSize *
initialAspect / 2,
orthographicViewSize *
initialAspect / 2,
orthographicViewSize / 2,
-orthographicViewSize / 2,
0.1,
500
);
orthographicCamera.position.set(
0,
10,
30
);
let activeCamera =
perspectiveCamera;
function createControls(camera) {
const nextControls =
new OrbitControls(
camera,
renderer.domElement
);
nextControls.target.set(
0,
6,
0
);
nextControls.enableDamping = true;
nextControls.dampingFactor = 0.08;
nextControls.rotateSpeed = 0.7;
nextControls.zoomSpeed = 0.9;
nextControls.panSpeed = 0.7;
nextControls.minPolarAngle =
THREE.MathUtils.degToRad(10);
nextControls.maxPolarAngle =
THREE.MathUtils.degToRad(88);
nextControls.minDistance = 5;
nextControls.maxDistance = 90;
nextControls.minZoom = 0.5;
nextControls.maxZoom = 6;
nextControls.cursor.set(
0,
6,
0
);
nextControls.maxTargetRadius = 30;
nextControls.update();
return nextControls;
}
let controls =
createControls(activeCamera);
// 灯光
const ambientLight =
new THREE.AmbientLight(
0xffffff,
1.3
);
scene.add(ambientLight);
const directionalLight =
new THREE.DirectionalLight(
0xffffff,
3
);
directionalLight.position.set(
12,
20,
10
);
scene.add(directionalLight);
// 地面
const grid =
new THREE.GridHelper(
50,
50,
0x64748b,
0x334155
);
scene.add(grid);
// 简化转炉结构
const converterRoot =
new THREE.Group();
converterRoot.position.set(
0,
0,
0
);
scene.add(converterRoot);
const converterPivot =
new THREE.Group();
converterPivot.position.set(
0,
6,
0
);
converterRoot.add(
converterPivot
);
const converterModel =
new THREE.Mesh(
new THREE.CylinderGeometry(
2.5,
3,
5,
32
),
new THREE.MeshStandardMaterial({
color: 0x64748b,
roughness: 0.65,
metalness: 0.45
})
);
converterModel.position.y = -1.8;
converterPivot.add(
converterModel
);
// 操作平台占位模型
const platform =
new THREE.Mesh(
new THREE.BoxGeometry(
14,
0.5,
9
),
new THREE.MeshStandardMaterial({
color: 0x334155,
roughness: 0.8
})
);
platform.position.y = 3.2;
scene.add(platform);
// 视角预设
const cameraPresets = {
overview: {
position:
new THREE.Vector3(
22,
16,
24
),
target:
new THREE.Vector3(
0,
5,
0
)
},
front: {
position:
new THREE.Vector3(
0,
7,
24
),
target:
new THREE.Vector3(
0,
6,
0
)
},
side: {
position:
new THREE.Vector3(
24,
7,
0
),
target:
new THREE.Vector3(
0,
6,
0
)
},
top: {
position:
new THREE.Vector3(
0,
32,
0.001
),
target:
new THREE.Vector3(
0,
0,
0
)
},
mouth: {
position:
new THREE.Vector3(
0,
10,
10
),
target:
new THREE.Vector3(
0,
7,
0
)
}
};
function applyCameraPreset(name) {
const preset =
cameraPresets[name];
if (!preset) {
return;
}
activeCamera.position.copy(
preset.position
);
controls.target.copy(
preset.target
);
controls.update();
}
for (
const button of
document.querySelectorAll(
"[data-view]"
)
) {
button.addEventListener(
"click",
() => {
applyCameraPreset(
button.dataset.view
);
}
);
}
function switchCamera(nextCamera) {
if (activeCamera === nextCamera) {
return;
}
const oldPosition =
activeCamera.position.clone();
const oldTarget =
controls.target.clone();
controls.dispose();
activeCamera = nextCamera;
activeCamera.position.copy(
oldPosition
);
controls =
createControls(
activeCamera
);
controls.target.copy(
oldTarget
);
controls.update();
}
document
.querySelector(
"#perspective-button"
)
?.addEventListener(
"click",
() => {
switchCamera(
perspectiveCamera
);
}
);
document
.querySelector(
"#orthographic-button"
)
?.addEventListener(
"click",
() => {
switchCamera(
orthographicCamera
);
}
);
function handleResize() {
const width =
app.clientWidth;
const height =
app.clientHeight;
if (
width === 0 ||
height === 0
) {
return;
}
perspectiveCamera.aspect =
width / height;
perspectiveCamera
.updateProjectionMatrix();
const aspect = width / height;
orthographicCamera.left =
-orthographicViewSize *
aspect / 2;
orthographicCamera.right =
orthographicViewSize *
aspect / 2;
orthographicCamera.top =
orthographicViewSize / 2;
orthographicCamera.bottom =
-orthographicViewSize / 2;
orthographicCamera
.updateProjectionMatrix();
renderer.setSize(
width,
height,
false
);
}
const resizeObserver =
new ResizeObserver(
handleResize
);
resizeObserver.observe(app);
handleResize();
const clock =
new THREE.Clock();
function animate() {
const deltaTime =
clock.getDelta();
converterPivot.rotation.z =
THREE.MathUtils.degToRad(
-20
);
controls.update(
deltaTime
);
renderer.render(
scene,
activeCamera
);
}
renderer.setAnimationLoop(
animate
);
function dispose() {
renderer.setAnimationLoop(null);
resizeObserver.disconnect();
controls.dispose();
renderer.dispose();
renderer.domElement.remove();
}
window.addEventListener(
"beforeunload",
dispose,
{ once: true }
);
十八、OrbitControls 事件
59. change 事件
相机或目标发生变化时:
javascript
controls.addEventListener(
"change",
() => {
console.log(
camera.position,
controls.target
);
}
);
如果项目采用"按需渲染"而不是持续动画循环,也可以在 change 事件中触发渲染。
但启用阻尼时通常仍需要持续调用 controls.update()。
60. start 和 end 事件
用户开始操作:
javascript
controls.addEventListener(
"start",
() => {
console.log(
"开始操作相机"
);
}
);
用户结束操作:
javascript
controls.addEventListener(
"end",
() => {
console.log(
"结束操作相机"
);
}
);
可以用于:
- 暂停自动巡航;
- 记录用户视角;
- 用户操作时降低某些特效质量;
- 操作结束后保存相机状态;
- 区分自动相机动画和人工操作。
十九、相机状态保存
61. 保存到普通对象
javascript
function getCameraState() {
return {
position:
camera.position.toArray(),
target:
controls.target.toArray(),
zoom:
camera.zoom,
fov:
camera.isPerspectiveCamera
? camera.fov
: undefined
};
}
恢复:
javascript
function applyCameraState(
state
) {
camera.position.fromArray(
state.position
);
controls.target.fromArray(
state.target
);
if (
typeof state.zoom ===
"number"
) {
camera.zoom = state.zoom;
}
if (
camera.isPerspectiveCamera &&
typeof state.fov ===
"number"
) {
camera.fov = state.fov;
}
camera.updateProjectionMatrix();
controls.update();
}
这可以用于:
- 保存用户上次视角;
- 分享设备观察位置;
- 历史回放恢复视角;
- 不同岗位使用不同默认视图。
二十、常见错误
62. 只设置 camera.position,不设置 target
错误:
javascript
camera.position.set(
0,
10,
20
);
使用 OrbitControls 时,还应设置:
javascript
controls.target.set(
0,
6,
0
);
controls.update();
63. 使用 OrbitControls 后只调用 lookAt()
错误:
javascript
camera.lookAt(newTarget);
下一次 controls.update() 后,相机可能重新看向旧的 controls.target。
正确:
javascript
controls.target.copy(
newTarget
);
controls.update();
64. 忘记调用 controls.update()
修改相机位置后:
javascript
camera.position.set(
20,
10,
20
);
controls.update();
启用阻尼或自动旋转时,还需要每帧调用。
65. 将 OrbitControls 绑定到错误元素
不推荐:
javascript
new OrbitControls(
camera,
document.body
);
如果页面还有按钮、菜单和图表,用户操作这些区域时也可能影响相机。
推荐:
javascript
new OrbitControls(
camera,
renderer.domElement
);
66. near 太小、far 太大
错误:
javascript
near = 0.000001
far = 100000000
可能导致深度精度下降。
应根据场景真实范围选择。
67. 正交相机窗口变化后比例错误
正交相机不能只更新:
javascript
camera.aspect
因为它没有像透视相机那样使用 aspect 属性。
必须重新计算:
text
left
right
top
bottom
然后调用:
javascript
camera.updateProjectionMatrix();
68. 限制参数用错相机类型
透视相机距离限制:
javascript
controls.minDistance
controls.maxDistance
正交相机缩放限制:
javascript
controls.minZoom
controls.maxZoom
不要认为两组参数完全等价。
69. 相机可以钻进设备内部
设置:
javascript
controls.minDistance = 5;
但需要注意:
OrbitControls 的距离限制是相对于 controls.target,不是与所有模型表面的碰撞距离。
如果需要真正防止相机穿墙或进入设备,需要额外实现:
- 射线检测;
- 碰撞体;
- 相机约束区域;
- 导航网格;
- 专门的第一人称控制系统。
OrbitControls 本身不是完整的碰撞系统。
70. 忘记 dispose()
页面重复进入和退出时,如果不释放控制器:
javascript
controls.dispose();
旧事件监听可能继续存在,导致:
- 一次鼠标操作触发多个控制器;
- 内存增长;
- 页面切换后行为异常;
- 重复创建 Canvas 和监听器。
Vue、React 或其他单页应用中尤其需要在组件卸载时处理。
71. 顶视图发生翻转
当相机位置、目标点和 camera.up 形成退化关系时,相机可能出现不稳定滚转。
例如:
javascript
camera.position.set(
0,
30,
0
);
controls.target.set(
0,
0,
0
);
相机观察方向与 Y 轴完全平行,而默认 up 也是 Y 轴。
可以采用:
javascript
camera.position.set(
0,
30,
0.001
);
或者为顶视相机设置合适的 up:
javascript
camera.up.set(
0,
0,
-1
);
不同视角切换时,要统一处理 camera.up,否则恢复普通视角后可能出现倾斜。
二十一、适合数字孪生的相机管理模块
72. 推荐目录
text
src/
└─ three/
└─ camera/
├─ CameraManager.js
├─ CameraPresets.js
├─ CameraTransition.js
└─ createOrbitControls.js
73. CameraPresets.js
javascript
import * as THREE from "three";
export const CAMERA_PRESETS = {
overview: {
position:
new THREE.Vector3(
22,
16,
24
),
target:
new THREE.Vector3(
0,
5,
0
)
},
converterFront: {
position:
new THREE.Vector3(
0,
7,
24
),
target:
new THREE.Vector3(
0,
6,
0
)
}
};
74. createOrbitControls.js
javascript
import {
OrbitControls
} from "three/addons/controls/OrbitControls.js";
import * as THREE from "three";
export function createOrbitControls(
camera,
domElement
) {
const controls =
new OrbitControls(
camera,
domElement
);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minPolarAngle =
THREE.MathUtils.degToRad(10);
controls.maxPolarAngle =
THREE.MathUtils.degToRad(88);
controls.minDistance = 5;
controls.maxDistance = 100;
controls.minZoom = 0.5;
controls.maxZoom = 6;
controls.target.set(
0,
5,
0
);
controls.update();
return controls;
}
75. CameraManager 的职责
一个相机管理器可以负责:
text
创建透视相机
创建正交相机
维护当前相机
创建和销毁 OrbitControls
更新窗口尺寸
应用预设视角
执行平滑过渡
保存和恢复相机状态
聚焦指定设备
限制观察区域
页面组件只需要调用:
javascript
cameraManager.focusEquipment(
converterRoot
);
cameraManager.applyPreset(
"converterFront"
);
而不需要了解相机内部实现。
二十二、本篇必须掌握的代码
76. 创建透视相机
javascript
const camera =
new THREE.PerspectiveCamera(
55,
width / height,
0.1,
500
);
77. 创建正交相机
javascript
const camera =
new THREE.OrthographicCamera(
left,
right,
top,
bottom,
near,
far
);
78. 设置位置和朝向
javascript
camera.position.set(
20,
12,
20
);
camera.lookAt(
0,
5,
0
);
79. 创建 OrbitControls
javascript
const controls =
new OrbitControls(
camera,
renderer.domElement
);
80. 设置观察目标
javascript
controls.target.set(
0,
5,
0
);
controls.update();
81. 开启阻尼
javascript
controls.enableDamping = true;
controls.dampingFactor = 0.08;
动画循环:
javascript
controls.update();
82. 限制透视相机距离
javascript
controls.minDistance = 5;
controls.maxDistance = 80;
83. 限制正交相机缩放
javascript
controls.minZoom = 0.5;
controls.maxZoom = 6;
84. 限制垂直旋转
javascript
controls.minPolarAngle =
THREE.MathUtils.degToRad(10);
controls.maxPolarAngle =
THREE.MathUtils.degToRad(88);
85. 销毁控制器
javascript
controls.dispose();
二十三、本文总结
Three.js 中最常用的三维相机是:
text
PerspectiveCamera
它使用透视投影,适合大多数工业三维浏览场景。
正交相机:
text
OrthographicCamera
不产生近大远小,适合工程正视图、侧视图、俯视图和平面布置图。
相机位置
javascript
camera.position
决定观察者位于哪里。
相机朝向
javascript
camera.lookAt(target)
可以让相机看向世界空间目标点。
使用 OrbitControls 后,应优先通过:
javascript
controls.target
管理观察目标。
OrbitControls
它主要提供:
text
旋转
缩放
平移
并通过:
javascript
controls.update();
同步相机状态。
相机限制
透视相机常用:
javascript
minDistance
maxDistance
正交相机常用:
javascript
minZoom
maxZoom
旋转范围常用:
javascript
minPolarAngle
maxPolarAngle
minAzimuthAngle
maxAzimuthAngle
预设视角
一个完整视角至少应保存:
text
camera.position
controls.target
根据相机类型,还可以保存:
text
fov
zoom
up
最终可以用一句话概括:
相机位置决定用户站在哪里,观察目标决定用户看向哪里,投影参数决定用户能看到什么,而 OrbitControls 则负责把鼠标和触摸操作转换成相机与目标点的变化。
对于转炉数字孪生系统,可以采用:
text
透视相机
→ 车间总览和自由浏览
正交相机
→ 转炉正视、侧视和俯视
OrbitControls
→ 旋转、缩放和平移
相机预设
→ 快速定位关键设备和工位
控制限制
→ 防止进入地下、设备内部和无效区域
二十四、下一篇学习内容
下一篇建议继续介绍:
第 7 篇:Geometry、Material 与 Mesh
主要内容可以包括:
BufferGeometry;- 内置几何体;
- 顶点、索引、法线和 UV;
Mesh;MeshBasicMaterial;MeshStandardMaterial;- 颜色、透明度和双面显示;
- Geometry 与 Material 复用;
dispose();- 使用基础几何体搭建转炉、氧枪和钢包占位模型。