【可视化学习】87-从入门到放弃WebGL(十八)
发表于:2024-09-05 |

前言

本篇文章就和大家来说一下相机轨道控制器的封装,本篇将详细展开说明,也会对前面俩篇文章进行补充说明。最近有接个外包,如果成功谈下来,之后文章更新频率就会下去,在这里和大家提前知会一下。

明确正交相机轨道控制器和透视相机轨道控制器的区别

首先我们先对之前所学的正交相机轨道和透视相机轨道做一个差异的对比:

旋转轨道

正交相机和透视相机的旋转轨道都是一样的,都是使用球坐标,让相机视点围绕目标点做的旋转。
效果图

位移轨道
  1. 正交相机的位移轨道是鼠标从canvas画布到近裁剪面,再到世界坐标系的位移量的转换,最后这个位移量会同时作用于相机的目标点和视点。
    效果图
  2. 透视相机的位移轨道是鼠标从canvas画布到目标平面,再到世界坐标系的位移量的转换,最后这个位移量会同时作用于相机的目标点和视点。
    效果图
缩放轨道
  1. 正交相机的缩放轨道是通过对其可视区域的宽高尺寸的缩放实现的。
    效果图
  2. 透视相机的缩放轨道是通过相机视点在视线上的位移实现的。
    效果图

整体的原理就是这样,接下来就可以封装轨道控制器了。

封装 OrbitControls

接下来,我们根据上俩篇文章的知识封装一个自己的 OrbitControls 控制器

导入定义一些基础配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import {
Matrix4,
Vector2,
Vector3,
Spherical,
} from "https://unpkg.com/three/build/three.module.js";

// 定义2Π,旋转时比例计算
const pi2 = Math.PI * 2;
// 投影视图矩阵
const pvMatrix = new Matrix4();
// 定义一些默认值
const defAttr = () => ({
// 相机
camera: null,
// canavs的dom节点
dom: null,
// 目标点
target: new Vector3(),
// 鼠标事件,左键旋转,右键平移,滚轮的单独用addEventListener('wheel')监听处理了。
mouseButtons: new Map([
[0, "rotate"],
[2, "pan"],
]),
// 鼠标状态,是rotate还是pan
state: "none",
// 起始点位
dragStart: new Vector2(),
// 结束点位
dragEnd: new Vector2(),
// 平移量
panOffset: new Vector3(),
// 是往哪个方向平移,true就是y方向,false就是z方向
screenSpacePanning: true,
// 缩放尺寸
zoomScale: 0.95,
// 定义一个球坐标,用于表示相机的位置和方向。
spherical: new Spherical(),
// 旋转方向,可以是xy,x,y
rotateDir: "xy",
});

封装类

接下来我们开始封装我们自己的 OrbitControls 控制器

初始化类

首先我们定义一个 class,在原型上直接将默认的一些配置项和传递进来的配置项都给 this,这样在类中,我们可以直接通过 this.xxx 获取到这些属性。

1
2
3
4
5
6
export default class OrbitControls {
constructor(attr) {
// 将默认的一些配置项和传递进来的配置项都给this
Object.assign(this, defAttr(), attr);
}
}

更新球坐标和世界坐标与本地坐标

初始化的时候,我们就需要先将世界坐标和球坐标进行更新了,这里我们需要明确一个概念,平移,缩放,旋转这些本质上都是向量的线性变化,什么意思呢,就是这一系列操作之后,都可以找到最后的向量,由这些过程依次执行得到的向量,累加起来得到。

1
2
3
4
5
6
7
8
9
10
export default class OrbitControls {
constructor(attr) {
// 将默认的一些配置项和传递进来的配置项都给this
Object.assign(this, defAttr(), attr);
// 更新球坐标
this.updateSpherical();
// 更新世界坐标和本地坐标
this.update();
}
}
更新球坐标

这个方法使用当前相机的位置和目标点来更新球坐标对象spherical。
它首先克隆相机的位置向量,
然后减去目标点向量,
得到从目标点指向相机的向量,
最后用这个向量设置球坐标

1
2
3
4
5
// 更新球坐标
updateSpherical() {
const { spherical, camera, target } = this;
spherical.setFromVector3(camera.position.clone().sub(target));
}
更新相机的位置和视图矩阵。

这个方法用于更新相机的位置和视图矩阵。
首先,根据平移偏移量panOffset平移目标点和相机的位置。想象一下,当用户进行平移操作时,相机和目标点一起在三维空间中移动。
然后,根据球坐标对象spherical计算出相机的偏移量,并将其加到目标点上,得到相机的新位置。这样可以根据球坐标来调整相机的位置,实现旋转和缩放后的位置更新。
接着,让相机看向目标点,并更新相机的世界矩阵。这样可以确保相机始终朝着目标点进行拍摄。
最后,更新球坐标对象spherical,使其与相机的新位置相对应,并重置平移偏移量为零。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
update() {
const { camera, target, spherical, panOffset } = this;
//基于平移量平移相机
target.add(panOffset);
camera.position.add(panOffset);

//基于球坐标缩放旋转相机
/**通过·setFromSpherical·将球坐标的偏移变成三维向量的偏移,
* 也就是笛卡尔坐标系的位置,
* 然后我们说过,旋转和缩放其实本质上就是向量的累加,
* 所以此时我们调整相机的位置为目标点加上旋转缩放偏移量的向量即可。
* **/
const rotateOffset = new Vector3().setFromSpherical(spherical);
camera.position.copy(target.clone().add(rotateOffset));

//更新投影视图矩阵
camera.lookAt(target);
camera.updateMatrixWorld(true);

//重置旋转量和平移量
/** 这里更新过投影视图矩阵了,
* 本质上就是根据当前的目标点和相机重新画一个球坐标,
* 参考上面的updateSpherical方法
* */
spherical.setFromVector3(camera.position.clone().sub(target));
panOffset.set(0, 0, 0);
}

定义鼠标事件

接下来我们定义鼠标事件,这里就很好理解

鼠标按下事件

根据鼠标按下事件得到是平移还是旋转,左键旋转,右键平移
dragStart设置为当前按下鼠标的点

1
2
3
4
5
6
// 鼠标按下事件
pointerdown({ clientX, clientY,button }) {
const {dragStart,mouseButtons}=this
dragStart.set(clientX, clientY)
this.state = mouseButtons.get(button)
}
鼠标移动事件

移动时一直设置最终的位置值,根据状态判断是平移还是旋转,传递偏移量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 鼠标移动事件
pointermove({ clientX, clientY }) {
const { dragStart, dragEnd, state, camera: { type } } = this
// 移动时一直设置最终的位置值
dragEnd.set(clientX, clientY)
// dragEnd.clone().sub(dragStart)得到的就是鼠标移动的距离
switch (state) {
// 如果是移动,根据不同相机type处理
case 'pan':
this[`pan${type}`] (dragEnd.clone().sub(dragStart))
break
// 如果是旋转
case 'rotate':
this.rotate(dragEnd.clone().sub(dragStart))
break
}
// 将起始位置设置为最后移动的位置
dragStart.copy(dragEnd)
}
鼠标抬起事件
1
2
3
4
// 鼠标指针抬起,重置状态
pointerup() {
this.state = 'none'
}
滚轮滚动事件

这里处理缩放,deltaY<0就是向上滚轮,也就是缩小,
比如我们之前默认值设置的是0.95,
那就是滚一段就是缩放成0.95,
如果是向下,那就是1/0.95,
这个值是大于1的,也就是放大

1
2
3
4
5
6
7
8
// 滚动事件
wheel({ deltaY }) {
const { zoomScale, camera: { type } } = this
let scale=deltaY < 0?zoomScale:1 / zoomScale
// 根据不同相机type处理
this[`dolly${type}`] (scale)
this.update()
}

定义滚动缩放事件

我们先来说最简单的缩放逻辑

正交相机的缩放

我们说过正交相机没有近大远小的效果,所有物体的大小在屏幕上都是固定的。它将相机的zoom属性乘以缩放比例dollyScale,然后调用updateProjectionMatrix方法更新相机的投影矩阵。这样可以调整相机的视野大小,从而实现缩放效果。

1
2
3
4
5
dollyOrthographicCamera(dollyScale) {
const { camera } = this;
camera.zoom *= dollyScale;
camera.updateProjectionMatrix();
}
透视相机的缩放

这个方法用于透视相机的缩放操作。它通过改变球坐标对象spherical的半径来实现缩放,将半径乘以缩放比例dollyScale。

1
2
3
dollyPerspectiveCamera(dollyScale) {
this.spherical.radius *= dollyScale;
}

接下来我们解释一下,为什么这段代码可以成立

一、球坐标与透视相机的关系

球坐标通常由半径、水平角度(方位角)和垂直角度(仰角)组成。球坐标被用来表示相机相对于目标点的位置和方向。

对于透视相机,相机到目标点的距离在很大程度上影响了相机所看到的场景的大小和透视效果。当相机靠近目标点时,场景看起来会更大,物体显得更大,透视效果更强烈;当相机远离目标点时,场景看起来会更小,物体显得更小,透视效果减弱。

二、通过改变半径实现缩放的原理

当用户进行缩放操作时,传入一个缩放比例dollyScale。
代码this.spherical.radius *= dollyScale;将球坐标的半径乘以这个缩放比例。如果dollyScale大于 1,半径增大,相机远离目标点,场景看起来会变小,实现了缩小效果;如果dollyScale小于 1,半径减小,相机靠近目标点,场景看起来会变大,实现了放大效果。

三、结合实际场景理解

想象你正在用一个放大镜观察一个物体。当你把放大镜远离物体时,你看到的物体变小了;当你把放大镜靠近物体时,你看到的物体变大了。在这个轨道控制器中,相机就像那个放大镜,目标点就是被观察的物体,而球坐标的半径就相当于放大镜与物体之间的距离。通过改变这个距离(半径),就可以实现对透视相机所观察到的场景的缩放效果。

定义左键旋转事件

这个方法用于处理相机的旋转操作。
它根据鼠标移动的距离和画布高度计算出在球坐标上的旋转角度增量deltaT和deltaP。
然后根据rotateDir的值确定是否在 x 轴和 y 轴上进行旋转,如果是,则相应地更新球坐标对象spherical的theta(水平旋转角度)和phi(垂直旋转角度)。
同时,对phi进行了限制,以防止相机翻转。最后调用update方法更新相机的位置和视图矩阵。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
rotate({ x, y }) {
const {
dom: { clientHeight },
spherical, rotateDir,
} = this;
const deltaT = pi2 * x / clientHeight;
const deltaP = pi2 * y / clientHeight;
if (rotateDir.includes('x')) {
spherical.theta -= deltaT;
}
if (rotateDir.includes('y')) {
const phi = spherical.phi - deltaP;
spherical.phi = Math.min(
Math.PI * 0.99999999,
Math.max(0.00000001, phi)
);
}
this.update();
}

效果图

定义右键平移事件

正交相机平移方法

这个方法用于正交相机的平移操作。
首先计算出相机在水平和垂直方向上的尺寸,以及鼠标移动距离与画布尺寸的比例。
然后根据这些比例计算出相机在水平和垂直方向上的位移量。
接着根据screenSpacePanning的值确定相机平移的方向向量,
最后将位移量转换为世界坐标,并将其累加到panOffset中。
最后调用update方法更新相机的位置和视图矩阵。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
panOrthographicCamera({ x, y }) {
const {
camera: { right, left, top, bottom, matrix, up },
dom: { clientWidth, clientHeight },
panOffset, screenSpacePanning
} = this;
// 计算出相机在水平和垂直方向上的尺寸
const cameraW = right - left;
const cameraH = top - bottom;
// 计算鼠标移动距离与画布尺寸的比例
const ratioX = x / clientWidth;
const ratioY = y / clientHeight;
// 根据比例计算出相机在水平和垂直方向上的位移量
const distanceLeft = ratioX * cameraW;
const distanceUp = ratioY * cameraH;
//相机本地坐标系里的x轴
const mx = new Vector3().setFromMatrixColumn(camera.matrix, 0);
//相机x轴平移量
const vx = mx.clone().multiplyScalar(-distanceLeft);
//相机z|y轴平移量
const vy = new Vector3();
if (screenSpacePanning) {
// y向
vy.setFromMatrixColumn(matrix, 1);
} else {
// -z向,向量叉积得到-z
vy.crossVectors(up, mx);
}
//相机y向或-z向的平移量
vy.multiplyScalar(distanceUp);
//整合平移量
panOffset.copy(vx.add(vy));
this.update();
}
透视相机平移方法

这个方法用于透视相机的平移操作。
首先计算出一些与相机和画布相关的参数,包括视线长度、视椎体垂直夹角的一半、目标平面的高度以及目标平面与画布的高度比。
然后根据鼠标移动的距离和这些参数计算出在目标平面上的位移量。
接着根据screenSpacePanning的值确定相机平移的方向向量,
最后将位移量转换为世界坐标,并将其累加到panOffset中。
最后调用update方法更新相机的位置和视图矩阵。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
panPerspectiveCamera({ x, y }) {
const {
camera: { matrix, position, fov, up },
dom: { clientHeight },
panOffset, screenSpacePanning, target
} = this;

//视线长度:相机视点到目标点的距离
const sightLen = position.clone().sub(target).length();
//视椎体垂直夹角的一半(弧度)
const halfFov = fov * Math.PI / 360;
//目标平面的高度
const targetHeight = sightLen * Math.tan(halfFov) * 2;
//目标平面与画布的高度比
const ratio = targetHeight / clientHeight;
//画布位移量转目标平面位移量
const distanceLeft = x * ratio;
const distanceUp = y * ratio;

//相机平移方向
//鼠标水平运动时,按照相机本地坐标的 x 轴平移相机
const mx = new Vector3().setFromMatrixColumn(matrix, 0);
//鼠标水平运动时,按照相机本地坐标的 y 轴,或者-z 轴平移相机
const myOrz = new Vector3();
if (screenSpacePanning) {
//y 轴,正交相机中默认
myOrz.setFromMatrixColumn(matrix, 1);
} else {
//-z 轴,透视相机中默认
myOrz.crossVectors(up, mx);
}
//目标平面位移量转世界坐标
const vx = mx.clone().multiplyScalar(-distanceLeft);
const vy = myOrz.clone().multiplyScalar(distanceUp);
panOffset.copy(vx.add(vy));

this.update();
}

暴露一个投影视图矩阵方法

这个方法用于获取相机的投影视图矩阵。它通过将相机的投影矩阵和世界逆矩阵相乘,得到投影视图矩阵,并返回这个矩阵。

1
2
3
4
5
6
7
getPvMatrix() {
const { camera: { projectionMatrix, matrixWorldInverse } } = this;
return pvMatrix.multiplyMatrices(
projectionMatrix,
matrixWorldInverse,
);
}

整体代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
import {
Matrix4, Vector2, Vector3,Spherical
} from 'https://unpkg.com/three/build/three.module.js';

const pi2 = Math.PI * 2
const pvMatrix=new Matrix4()

const defAttr = () => ({
camera: null,
dom: null,
target: new Vector3(),
mouseButtons:new Map([
[0, 'rotate'],
[2, 'pan'],
]),
state: 'none',
dragStart: new Vector2(),
dragEnd: new Vector2(),
panOffset: new Vector3(),
screenSpacePanning: true,
zoomScale: 0.95,
spherical:new Spherical(),
rotateDir: 'xy',
})

export default class OrbitControls{
constructor(attr){
Object.assign(this, defAttr(), attr)
this.updateSpherical()
this.update()
}
updateSpherical() {
const {spherical,camera,target}=this
spherical.setFromVector3(
camera.position.clone().sub(target)
)
}
pointerdown({ clientX, clientY,button }) {
const {dragStart,mouseButtons}=this
dragStart.set(clientX, clientY)
this.state = mouseButtons.get(button)
}
pointermove({ clientX, clientY }) {
const { dragStart, dragEnd, state, camera: { type } } = this
dragEnd.set(clientX, clientY)
switch (state) {
case 'pan':
this[`pan${type}`] (dragEnd.clone().sub(dragStart))
break
case 'rotate':
this.rotate(dragEnd.clone().sub(dragStart))
break
}
dragStart.copy(dragEnd)
}
pointerup() {
this.state = 'none'
}
wheel({ deltaY }) {
const { zoomScale, camera: { type } } = this
let scale=deltaY < 0?zoomScale:1 / zoomScale
this[`dolly${type}`] (scale)
this.update()
}
dollyPerspectiveCamera(dollyScale) {
this.spherical.radius *= dollyScale
}
dollyOrthographicCamera(dollyScale) {
const {camera}=this
camera.zoom *= dollyScale
camera.updateProjectionMatrix()
}
panPerspectiveCamera({ x, y }) {
const {
camera: { matrix, position, fov,up },
dom: { clientHeight },
panOffset,screenSpacePanning,target
} = this

//视线长度:相机视点到目标点的距离
const sightLen = position.clone().sub(target).length()
//视椎体垂直夹角的一半(弧度)
const halfFov = fov * Math.PI / 360
//目标平面的高度
const targetHeight = sightLen * Math.tan(halfFov) * 2
//目标平面与画布的高度比
const ratio = targetHeight / clientHeight
//画布位移量转目标平面位移量
const distanceLeft = x * ratio
const distanceUp = y * ratio

//相机平移方向
//鼠标水平运动时,按照相机本地坐标的x轴平移相机
const mx = new Vector3().setFromMatrixColumn(matrix, 0)
//鼠标水平运动时,按照相机本地坐标的y轴,或者-z轴平移相机
const myOrz = new Vector3()
if (screenSpacePanning) {
//y轴,正交相机中默认
myOrz.setFromMatrixColumn(matrix, 1)
} else {
//-z轴,透视相机中默认
myOrz.crossVectors(up, mx)
}
//目标平面位移量转世界坐标
const vx = mx.clone().multiplyScalar(-distanceLeft)
const vy = myOrz.clone().multiplyScalar(distanceUp)
panOffset.copy(vx.add(vy))

this.update()
}

panOrthographicCamera({ x, y }) {
const {
camera: { right, left, top, bottom, matrix, up },
dom: { clientWidth, clientHeight },
panOffset,screenSpacePanning
} = this

const cameraW = right - left
const cameraH = top - bottom
const ratioX = x / clientWidth
const ratioY = y / clientHeight
const distanceLeft = ratioX * cameraW
const distanceUp = ratioY * cameraH
const mx = new Vector3().setFromMatrixColumn(matrix, 0)
const vx = mx.clone().multiplyScalar(-distanceLeft)
const vy = new Vector3()
if (screenSpacePanning) {
vy.setFromMatrixColumn(matrix, 1)
} else {
vy.crossVectors(up, mx)
}
vy.multiplyScalar(distanceUp)
panOffset.copy(vx.add(vy))
this.update()
}


rotate({ x, y }) {
const {
dom: { clientHeight },
spherical, rotateDir,
} = this
const deltaT = pi2 * x / clientHeight
const deltaP = pi2 * y / clientHeight
if (rotateDir.includes('x')) {
spherical.theta -= deltaT
}
if (rotateDir.includes('y')) {
const phi = spherical.phi - deltaP
spherical.phi = Math.min(
Math.PI * 0.99999999,
Math.max(0.00000001, phi)
)
}
this.update()
}

update() {
const {camera,target,spherical,panOffset} = this
//基于平移量平移相机
target.add(panOffset)
camera.position.add(panOffset)

//基于球坐标缩放相机
const rotateOffset = new Vector3()
.setFromSpherical(spherical)
camera.position.copy(
target.clone().add(rotateOffset)
)

//更新投影视图矩阵
camera.lookAt(target)
camera.updateMatrixWorld(true)

//重置旋转量和平移量
spherical.setFromVector3(
camera.position.clone().sub(target)
)
panOffset.set(0, 0, 0)
}

getPvMatrix() {
const { camera: { projectionMatrix, matrixWorldInverse } } = this
return pvMatrix.multiplyMatrices(
projectionMatrix,
matrixWorldInverse,
)
}

}

实例化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
/* 透视相机 */
/*
const eye = new Vector3(0, 0.5, 1)
const target = new Vector3(0, 0, -2.5)
const up = new Vector3(0, 1, 0)
const [fov, aspect, near, far] = [
45,
canvas.width / canvas.height,
1,
20
]
const camera = new PerspectiveCamera(fov, aspect, near, far)
camera.position.copy(eye)
*/

/* 正交相机 */
const halfH = 2
const ratio = canvas.width / canvas.height
const halfW = halfH * ratio
const [left, right, top, bottom, near, far] = [
-halfW, halfW, halfH, -halfH, 1, 8
]
const eye = new Vector3(1, 1, 2)
const target = new Vector3(0, 0, -3)
const up = new Vector3(0, 1, 0)

const camera = new OrthographicCamera(
left, right, top, bottom, near, far
)
camera.position.copy(eye)

const pvMatrix = new Matrix4()

……

/* 实例化轨道控制器 */
const orbit = new OrbitControls({
camera,
target,
dom: canvas,
})
pvMatrix.copy(orbit.getPvMatrix())
render()

/* 取消右击菜单的显示 */
canvas.addEventListener('contextmenu', event => {
event.preventDefault()
})

/* 指针按下时,设置拖拽起始位,获取轨道控制器状态。 */
canvas.addEventListener('pointerdown', event => {
orbit.pointerdown(event)
})

/* 指针移动时,若控制器处于平移状态,平移相机;若控制器处于旋转状态,旋转相机。 */
canvas.addEventListener('pointermove', event => {
orbit.pointermove(event)
pvMatrix.copy(orbit.getPvMatrix())
render()
})
canvas.addEventListener('pointerup', event => {
orbit.pointerup(event)
})

//滚轮事件
canvas.addEventListener('wheel', event => {
orbit.wheel(event)
pvMatrix.copy(orbit.getPvMatrix())
render()
})

结语

本篇文章到这里就结束了,更多内容敬请期待,债见~

上一篇:
【PS学习】03-切图仔小技巧
下一篇:
帮后端同事爬数据记录