WebGL从零开始(六)—— WebGL中相机控制的原理
# 1.坐标空间
讲相机前,先了解几个空间。
# 1.局部空间
一般指模型自身的空间,模型自身中心点即为坐标原点(0,0,0)。
# 2.世界空间
一般指场景的全局坐标系,所有的模型都放在这里。
# 3.观察空间(视图空间)
以相机为原点的坐标系,相机用于固定在(0,0,0)。
这是相机构成的第一核心。通过 View Matrix(视图矩阵),我们将整个世界进行“反向移动和旋转”,让所有的世界坐标都变成相对于相机的坐标。 本质:相机并没有动,是世界在反向动。
# 4.裁剪空间
这是一个标准化的立方体空间(齐次坐标),范围是 (-1, -1, -1) 到 (1, 1, 1)。任何超出这个范围的顶点,都会被 GPU 直接“剪掉”(不渲染)。 立方体空间边界:左(left)、右(right)、下(bottom)、上(top)、近(near)、远(far)。

这是相机构成的第二核心。通过 Projection Matrix(投影矩阵),我们将观察空间中的 3D 场景“压”进这个标准化的盒子里。操作顺序是先位移再缩放。
我们在顶点着色器里写下的 gl_Position = u_ProjectionMatrix * u_ViewMatrix * u_ModelMatrix * a_Position;,其最终输出的结果,就是裁剪空间坐标。
# 5.NDC 与 屏幕空间
裁剪空间经过 GPU 自动执行的透视除法(除以 w 分量)后,变成 NDC(标准化设备坐标),最终通过视口变换(Viewport Transform)映射到 Canvas 的 2D 像素坐标上。是 GPU 硬件自动完成的,我们不需要在 GLSL 中手动干预,但它决定了相机最终在屏幕上画出的画面大小和位置。
# 2.相机
WebGL 中并没有真实的“相机”,所谓的相机其实是一组数学矩阵的模拟。
# 1.视图矩阵(View Matrix)
视图矩阵决定了相机在三维世界中的物理状态,即“相机站在哪里,朝哪里看”。通常由三个核心参数构建:
- 视点(eye):相机在世界坐标系中的三维坐标。
- 目标点(target):相机镜头正对的空间坐标。
- 上方向(up):定义相机的“头顶”朝向(通常是 Y 轴正方向),用于防止相机发生倾斜翻滚。
# 2.投影矩阵 (Projection Matrix)
如果说视图矩阵是相机的位置,那么投影矩阵就是相机的“镜头”。它负责将三维空间中的物体“压扁”到二维屏幕上,并决定画面的透视规则。投影矩阵主要分为两种类型:
正交投影 (Orthographic):没有透视畸变,远近物体大小一致。它的可视区域是一个标准的长方体。

透视投影 (Perspective):模拟人眼的“近大远小”效果。它的可视区域(视锥体)是一个被切掉顶部的金字塔(四棱台)。

所以相机也分正交相机和透视相机,后面介绍。
# 3.视口与裁剪面 (Viewport & Clipping Planes)
- 视口 (Viewport):即最终渲染到的 Canvas 画布尺寸(宽高)。它决定了投影矩阵中的宽高比(Aspect Ratio),如果视口改变而不更新相机,画面就会发生拉伸变形。
- 近/远裁剪面 (Near/Far Planes):定义了相机的有效可视距离。距离相机小于
near或大于far的物体将被直接剔除(不可见)。如果near设为 0 会导致深度冲突闪烁,far设置过大会导致远处模型穿插。
# 3.正交相机
# 1.什么是正交相机
正交相机(Orthographic Camera)是一种没有透视效果的相机模型。在正交投影下,无论物体距离相机多远,其在屏幕上显示的大小始终一致。它的可视区域(View Frustum)是一个长方体,所有的投影线都是相互平行的。
# 2.正交相机位移的原理
在 WebGL 中,并没有真正的“相机”实体。所谓的相机移动,本质上是对整个场景进行反向平移。 如果希望相机在空间中向右移动 1 个单位,实际上是给世界中的所有物体乘以一个向左平移 1 个单位的矩阵(View Matrix)。
# 3.正交相机缩放的原理
正交相机没有透视效果,不会“近大远小”,所以正交相机的缩放是通过缩放相机的近裁剪面来实现的,直接改变投影矩阵的左右(left/right)和上下(top/bottom)边界参数。
- 放大画面:缩小边界范围(例如从 -10~10 缩小到 -5~5),相当于把远处的场景拉近。
- 缩小画面:扩大边界范围,相当于把相机拉远。
# 4.正交相机旋转的原理
与位移类似,相机的旋转也是通过反向旋转整个世界来实现的。如果相机绕 Y 轴向左旋转 30 度,视图矩阵就会包含一个绕 Y 轴向右旋转 30 度的操作。
# 正交相机旋转的限制
- UI 和 2D 场景不适用:在 UI 渲染或 2D 游戏中,旋转相机会导致文字和图标发生透视或倾斜变形,这违背了 UI 设计的初衷。
- 万向节死锁 (Gimbal Lock):如果在 3D 场景中使用欧拉角(Euler Angles)控制正交相机的旋转,当俯仰角(Pitch)达到 90 度时,会导致两个旋转轴重合,丢失一个旋转自由度,引发画面剧烈抖动或失控。
# 5.WebGL+Threejs的正交相机控制实例
WebGL+Threejs正交相机控制控制的步骤分为下面几步:
# 1.创建相机(正交相机)
这里使用Threejs的OrthographicCamera来创建一个正交相机,其原理还是正交投影矩阵的效果,只不过Threejs将投影矩阵和位移旋转矩阵放入到了camera实例对象上。
OrthographicCamera 接受的参数是相机的6个边界,左右上下近远。
6个边界数据我们设边界的上下距离的一半为2,再以画布宽高比进行计算左右宽,近裁剪面设1,远裁剪面设8;
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 camera = new OrthographicCamera(left, right, top, bottom, near, far);
2
3
4
5
6
7
然后我们将相机的视点、目标点、上方向设置一下,以此更新相机的世界坐标矩阵
const eye = new Vector3(1,1,3)
const target = new Vector3(0,0,0)
const up = new Vector3(0,1,0)
camera.position.copy(eye);
camera.lookAt(target)
camera.updateMatrixWorld(true)
2
3
4
5
6
# 2.创建相机平移轨道
有了相机,我们想控制相机进行左右平移,前进后退,就需要一个相机的平移轨道。Threejs中通过鼠标右键按住,并拖拽鼠标进行平移相机,滑动滚轮实现缩放,按住左键进行旋转。我们来实现一下。 我们需要声明鼠标事件集合,这里先只声明平移的事件:
const mouseButtons = new Map([
[2, 'pan'], // 2:鼠标右键按下时的 event.button 值, 'pan' 这里用作平移的 key
])
2
3
声明轨道控制器的状态,这个状态表面当前正在对相机进行何种操作,如 state 等于 pan 时是相机平移。 默认 none 。
let state = 'none';
我们需要记录鼠标拖拽的起始位置和结束位置,这里用 Vector2,存储的是画布坐标
const dragStart = new Vector2();
const dragEnd = new Vector2();
2
还需要每次拖拽量,这个用 Vector3,存储的是WebGL的坐标,因为后面需要根据拖拽量计算相机的目标点和视点的偏移。
const panOffset = new Vector3();
然后再声明一个变量 screenSpacePanning,表示鼠标在屏幕上垂直拖拽时,是基于相机本地坐标的 y 方向还是 z 方向移动, true 是 y 方向, false 是 z 方向。
const screenSpacePanning = true;
然后是绑定画布事件,这里使用指针事件,指针事件可以包含 鼠标、触摸屏、触控笔等输入设备的事件。 我们在鼠标点击时进行一些处理:设置起始位置,获取操作类型。
// contextmenu canvas默认的右键事件,这里阻止一下,避免右键弹出菜单
canvas.addEventListener('contextmenu', (e)=>{
e.preventDefault();
})
// 增加指针事件(pointerEvent)
canvas.addEventListener('pointerdown', ({clientX, clientY, button})=>{
dragStart.set(clientX, clientY)
state = mouseButtons.get(button)
})
2
3
4
5
6
7
8
9
在鼠标点击弹起时,将操作类型设置为 none,表示不进行操作。
canvas.addEventListener('pointerup', ()=>{
state = 'none'
})
2
3
在鼠标拖拽时,计算拖拽量、重新渲染等操作
canvas.addEventListener('pointermove', (e)=>{
const {clientX, clientY} = e
dragEnd.set(clientX, clientY) // 计算结束位置
switch(state){
case 'pan':
handleMousePan(dragEnd.clone().sub(dragStart))
break
}
dragStart.copy(dragEnd) // 操作后将结束位置设置给开始位置,保持连贯
})
2
3
4
5
6
7
8
9
10
接下来的重点就是如何实现平移操作。handleMousePan核心步骤就是将鼠标的画布位移量计算为在正交相机近裁剪面投影的位移量,通过这个位移量计算在相机本地坐标轴上的偏移,然后重新计算相机的整体偏移,再更新WebGL。
function handleMousePan(delta) {
// 相机的近裁剪面尺寸
const cameraW = camera.right - camera.left;
const cameraH = camera.top - camera.bottom
// 指针位移量在画布中的比值
const ratioX = delta.x / canvas.clientWidth
const ratioY = delta.y / canvas.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 轴的偏移量
const vy = new Vector3()
if (screenSpacePanning) {
// 获取 y 轴
vy.setFromMatrixColumn(camera.matrix, 1)
} else {
// 获取 z 轴
vy.crossVectors(camera.up, mx)
}
// 相机 y方向 或者 -z 方向的位移
vy.multiplyScalar(distanceUp) // 这里没有使用负值的原因是 WebGL的y轴与画布的y轴本就相反,WebGL的 z轴是朝向屏幕前的我们,与相机的是反的。
// 整合平移量
panOffset.copy(vx.add(vy))
// 更新
update()
}
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
好了,现在还有一个更新方法需要实现,其核心逻辑就是,通过更新相机的目标点、视点的偏移,重新更新相机的世界坐标矩阵
function update() {
// 平移
target.add(panOffset)
camera.position.add(panOffset)
// 重新投影视图矩阵
camera.lookAt(target)
// 更新camera世界坐标矩阵
camera.updateMatrixWorld(true)
// 重置平移量
panOffset.set(0,0,0)
// 渲染
render()
}
2
3
4
5
6
7
8
9
10
11
12
13
渲染方法就简单多了
function render() {
gl.clear(gl.COLOR_BUFFER_BIT)
triangle1.draw(); // 图形的重新渲染
}
2
3
4
当然这里只有相机的变化,实际上还缺少相机与图形的关联:图形的投影矩阵、视图矩阵、模型矩阵中的投影视图矩阵需要设置为相机的投影视图矩阵才行:
u_ProjectionMatrix 的值是 camera.projectionMatrix.elements u_ViewMatrix 的值是相机世界坐标矩阵的逆矩阵 camera.matrixWorldInverse.elements
# 3.创建相机缩放轨道
正交相机的缩放原理之前已说过,正交投影是没有近大远小的效果,是通过对近裁剪面的缩放进行实现的。
我们使用滚轮进行缩放,需要一个缩放系数,然后通过对 camera 的 zoom 值进行改变来达到缩放目的, zoom 默认是1,大于1相当于放大,小于1相当于缩小。
// 定义滚轮每次滚动的缩放系数
const zoomScale = 0.95
// 添加滚动事件
canvas.addEventListener('wheel', handleMouseWheel)
function handleMouseWheel(evnet){
console.log('wheel')
const {deltaY} = evnet // 垂直方向上的滚动量和方向,正负号代表滚动方向,数值大小代表滚动幅度
if(deltaY<0) { // 向上滚动
dolly(1/zoomScale) // scale > 1
}else if (deltaY > 0) { // 向下滚动
dolly(zoomScale) // scale < 1
}
update()
}
function dolly(scale) {
camera.zoom *= scale
camera.updateProjectionMatrix()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
update 方法目前无需更新,因为并没有改变相机的位置,只是缩放了近裁剪面。
# 4.创建相机旋转轨道
旋转的话,有两种方式,一种是球坐标,一种是轨迹球。 我们先看球坐标方式。
# 球坐标方式
其原理就是以目标点为球心,并建立直角空间坐标系,以视点到目标点的距离为半径建立的球坐标。为什么使用球坐标?球坐标的值就是旋转角度值,可以直接通过控制更改球坐标以实现旋转的功能。 数学中的球坐标系:
WebGL中的坐标系:
假设P点就是相机,我们只需要修改 θ 和 φ 就可以控制相机的左右旋转和上下旋转。
首先先将鼠标事件集合中增加一个旋转事件
const mouseButtons = new Map([
[2, 'pan'],
[0, 'rotate']
])
2
3
4
由于球坐标的 θ 和 φ 都是弧度值,所以我们先声明一个 2π,方便后面计算:
const pi2 = Math.PI * 2
然后通过Threejs的 Spherical 创建球体坐标,通过 camera 的 position 和 target 进行相减得到新向量,用来赋值给球体,以此得到相机位置在目标点的球坐标。
const spherical = new Spherical().setFromVector3(camera.position.clone().sub(target))
我们可以声明一个变量来控制可以旋转的轴:
// 限制旋转轴
const rotateDirection = 'xy'; // 'xy'方向, x , y
2
然后在鼠标的move事件中增加旋转的操作
canvas.addEventListener('pointermove', (e)=>{
const {clientX, clientY} = e
dragEnd.set(clientX, clientY) // 计算结束位置
switch(state){
case 'pan':
handleMousePan(dragEnd.clone().sub(dragStart))
break;
case 'rotate':
handleMouseRotate(dragEnd.clone().sub(dragStart))
}
dragStart.copy(dragEnd) // 操作后将结束位置设置给开始位置,保持连贯
})
2
3
4
5
6
7
8
9
10
11
12
实现handleMouseRotate方法,其核心就是通过画布x轴位移量比上画布高度,然后以这个比值乘以 2π,得到 θ 的增量,通过画布y轴位移量比上画布高度再乘以2π,得到 φ 的增量。其中球坐标的 φ 的取值范围应是 (0, π),因为 φ 是与 y轴的极角,当 φ=0 和 φ=π 时会让相机位置落到旋转轴的“极点”上,导致方向计算失去唯一性,画面容易翻转或抖动。
function handleMouseRotate(delta) {
const {x,y} = delta
const {clientHeight} = canvas
const theta = pi2 * x / clientHeight; // 将 x 轴向的偏移量/画布高度转为角度,为什么用高度而不是宽度,是为了确保
const phi = pi2 * y / clientHeight; // 将 y 轴向的偏移量/画布高度转为角度
if (rotateDirection.includes('x')) {
spherical.theta -= theta
}
if (rotateDirection.includes('y')) {
// 由于极角定义的域是[0, π] 但不能为 0 和 π, 否则俯仰角 φ 失去唯一性,可以给一个近似值
spherical.phi = Math.min(
Math.PI * 0.99999999,
Math.max(0.00000001, spherical.phi - phi)
)
}
// 基于球坐标系的旋转轨道可以参考 threejs 的 OrbitControls 的源码
update();
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
然后需要处理一下 update 方法,增加旋转偏移的计算
function update() {
// 平移
target.add(panOffset)
camera.position.add(panOffset)
// 旋转偏移
const rotateOffset = new Vector3().setFromSpherical(spherical)
camera.position.copy(
target.clone().add(rotateOffset)
)
// 重新投影视图矩阵
camera.lookAt(target)
// 更新camera世界坐标矩阵
camera.updateMatrixWorld(true)
// 重置旋转和平移量
spherical.setFromVector3(
camera.position.clone().sub(target)
)
panOffset.set(0,0,0)
render()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 轨迹球的方式
轨迹球的方式的原理是:把鼠标在平面上的拖动,映射成虚拟球体上的旋转,再把这个旋转作用到相机上,让你感觉像是在用手指拨动一个球。轨迹球旋转这里用的是四元数方式,可以防止万向节死锁(连续旋转时出现的姿态锁定问题)。
除了声明事件、2π外,还行要声明一个四元数,这里用Threejs的Quaternion来声明:
const quaternion = new Quaternion();
然后就是修改handleMouseRotate和update方法
handleMouseRotate方法核心目的是计算旋转轴和角度得到四元数,如何计算旋转轴和角度呢?
我们先将鼠标在画布上的移动转为相机在球体上的移动,假如鼠标从A点移动到B点对应球体上的P1点到P2点,那么P1到P2就是移动方向,再根据相机的位置到目标的位置得到z轴方向,位移方向叉乘视线方向得到旋转轴的方向。
旋转角度就是OP1向量与OP2向量的夹角,O是球心;可以用在球面上的位移量*2π得到夹角角度。
function handleMouseRotate(delta) {
const { x, y } = delta
const { left, right, top, bottom, matrix, position } = camera
const { clientWidth, clientHeight } = canvas
// 相机宽高
const cameraW = right - left
const cameraH = top - bottom
// 鼠标像素位移量在画布的比例
const ratioX = x / clientWidth
const ratioY = - y / clientHeight
// 基于高度的x位置比,用于计算旋转量,当鼠标位移距离等于画布高度时,就旋转了一圈
const ratioXBaseHeight = x / clientHeight
// 位移量(归一化的弧长)
const ratioLen = new Vector2(ratioXBaseHeight, ratioY).length()
// 旋转量
const angle = ratioLen * pi2
// 在相机世界中的位移距离
const distanceLeft = ratioX * cameraW
const distanceUp = ratioY * cameraH
// 相机本地坐标系的x、y轴
const mx = new Vector3().setFromMatrixColumn(camera.matrix, 0)
const my = new Vector3().setFromMatrixColumn(camera.matrix, 1)
// 鼠标在相机世界中的位移量
let vx= new Vector3(),vy = new Vector3()
if (rotateDirection.includes("x")) {
vx = mx.clone().multiplyScalar(distanceLeft)
}
if (rotateDirection.includes("y")) {
vy = my.clone().multiplyScalar(distanceUp)
}
// 鼠标在相机世界中的位移方向-x轴
const moveDir = vx.clone().add(vy).normalize()
// 目标点到视点的单位向量-z轴
const eyeDir = camera.position.clone().sub(target).normalize()
// 根据位移方向 获取旋转轴 - 上方向 y轴
const axis = moveDir.clone().cross(eyeDir)
// 基于旋转轴和旋转量生成 四元数
quaternion.setFromAxisAngle(axis, angle)
update();
}
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
然后是update方法修改
function update() {
// 平移
target.add(panOffset)
camera.position.add(panOffset)
// 旋转偏移
const rotateOffset = camera.position.clone().sub(target).applyQuaternion(quaternion)
camera.position.copy(
target.clone().add(rotateOffset)
)
camera.up.applyQuaternion(quaternion)
// 重新投影视图矩阵
camera.lookAt(target)
// 更新camera世界坐标矩阵
camera.updateMatrixWorld(true)
// 重置旋转和平移量
quaternion.setFromRotationMatrix(new Matrix4())
panOffset.set(0, 0, 0)
render()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 4.透视相机
# 1.什么是透视相机
透视相机完美模拟了人眼或真实物理相机的成像机制。它的可视区域是一个截断的金字塔(Frustum)。其核心特征是近大远小,且空间中原本平行的线条在远处会汇聚于一个点(灭点 Vanishing Point)。
# 2.透视相机位移的原理
透视相机的位移原理与正交相机完全相同,都是通过修改 View Matrix(视图矩阵) 将世界坐标向相反方向平移。
# 3.透视相机缩放的原理
透视相机不能通过修改投影矩阵的边界来缩放(这会破坏近大远小的透视关系)。它的缩放有两种标准方式:
- 物理移动(Dolly / Zoom):直接沿着相机的观察方向(Z轴)向前或向后移动相机的位置。这是最真实的缩放方式。
- 修改视场角(FOV):改变垂直视野角(Field of View)。
- 缩小 FOV(例如从 75° 缩小到 30°):画面变窄,物体变大,类似望远镜效果。
- 增大 FOV(例如从 75° 增大到 120°):画面变广,物体变小,类似鱼眼效果。
# 4.透视相机旋转的原理
通过改变相机的 Pitch(俯仰,绕 X 轴)和 Yaw(偏航,绕 Y 轴)角度,实时重新计算 View Matrix。
在原生 WebGL 中,绝对不要使用欧拉角直接累加旋转!必须使用四元数(Quaternion)或分离旋转轴的方式:
- 分离轴:创建一个父节点控制 Yaw(左右转头),相机本身作为子节点控制 Pitch(上下点头)。
- 四元数:使用四元数记录相机的朝向,通过 Slerp(球面线性插值)实现极其平滑的旋转,彻底避免万向节死锁。
# 5.WebGL+Threejs的透视相机控制实例
透视相机的控制和正交相机一样,只不过相机使用的是Threejs的 PerspectiveCamera,缩放就是控制相机的位置来实现而不是缩放裁剪面。
# 1.创建相机
// 创建透视相机
const [fov, aspect, near, far] = [
45, canvas.width / canvas.height, 2, 20
]
const camera = new PerspectiveCamera(fov, aspect, near, far);
const eye = new Vector3(0, 0, 3)
const target = new Vector3(0, 0, 0)
const up = new Vector3(0, 1, 0)
camera.position.copy(eye);
camera.lookAt(target)
camera.updateMatrixWorld(true)
2
3
4
5
6
7
8
9
10
11
12
# 2.创建相机平移轨道
平移的实现区别正交相机就是在handleMousePan方法里面:
function handleMousePan({x,y}) {
const {matrix, position} = camera
const {clientWidth, clientHeight} = canvas
// 视点到目标点的长度
const sightLen = position.clone().sub(target).length()
// 视锥体垂直夹角的一半弧度
const halfFov = fov * pi2 / 360
// 目标平面的高度
const targetHeight = sightLen * Math.tan(halfFov) * 2
// 目标平面的高度与画布的宽高比
const ratioH = targetHeight/clientHeight
// 画布的平移量 转 目标平面的位移量
const distanceLeft = x * ratioH
const distanceUp = y * ratioH
// 相机本地坐标系的x轴
const mx = new Vector3().setFromMatrixColumn(camera.matrix, 0)
// 相机 x 轴的偏移量
const vx = mx.clone().multiplyScalar(-distanceLeft)
// 相机 z 轴的偏移量
const vy = new Vector3()
if (screenSpacePanning) {
// y 轴
vy.setFromMatrixColumn(camera.matrix, 1)
} else {
vy.crossVectors(camera.up, mx) // -z 方向
}
// 相机 y方向 或者 -z 方向的位移
vy.multiplyScalar(distanceUp)
// 整合平移量
panOffset.copy(vx.add(vy))
// 更新
update()
}
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
# 3.创建相机缩放轨道
缩放也是同正交相机一样,只是缩放方法里面不一样而已:
function dolly(scale) {
// camera.position.lerp(target, 1-scale) // 不用球坐标方式,直接更改相机的位置
spherical.radius *= scale // 球坐标方式直接修改半径
}
2
3
4
# 4.创建相机旋转轨道
旋转也是同正交相机一样。球坐标方式是一模一样。