这两天正在重新实现maptalks.js的三维变换逻辑, 需要从底层重新实现一遍三维投影转换的算法。 好在三维投影算法已有很成熟的实现范式, 我选择了THREE.js作为参考对象, 这篇文章也是我对THREE.js中矩阵转换关系的总结。
本文面向有一定webgl开发基础的读者,THREE的版本为写作时的最新版本r88
三维投影算法
什么是三维投影转换? 简而言之,就是将三维空间中的物体,投射在相机视平面的转换算法, 如图:
(图片截取自webglfundamentals):
在这个场景中,如图摆放5个F字母和相机
相机看到的景象如下:
根据实际生活经验,相机看到的景象和以下因素有关,任何改变都会让相机眼中的世界发生变化:
- 相机投影类型(正射投影还是透视投影), 透视投影最符合现实世界, 也是最常用的投影方式
- 相机的位置和方向
- 物体的位置和形变(旋转/缩放/平移)
三维投影算法就是将上诉因素抽象为数学算法,用来计算三维物体在相机视平面上的位置。
实际应用中我们是通过矩阵计算来实现的。简而言之,我们将相机的位置方向, 相机的类型, 物体的位置和形变能转换为 矩阵, 将这些矩阵进行一系列计算后, 最终得到三维投影矩阵:
基于它, 任意给定三维坐标[x, y, z], 我们都能算出相机视平面上的位置:
当然,实际应用中情况会更复杂一些,例如三维图形引擎为了简化计算,一般将三维物体组织为层级结构,通过物体的本地位置和对上层的相对位置来计算出其在世界中的绝对位置,但归根到底,我们需要的只是最终的位置矩阵。
THREE中的矩阵
让我们回到THREE.js,来看看THREE是怎么组织定义组织投影矩阵的。
我们知道,THREE定义了场景(Scene)和相机(Camera), Scene用来添加管理三维物体, Camera用来控制相机的位置, 角度等,代码大概如下:
const scene = new THREE.Scene();
const mesh = new THREE.Mesh(new THREE.Cube());
scene.add(mesh);
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);
scene.add(camera);
renderer.render(scene, camera);复制代码
我们根据上面的总结按图索骥,THREE中定义了下述三个矩阵:
- 相机投影类型:投影矩阵(
ProjectMatrix
) - 相机的位置和方向: 视图矩阵 (
CameraMatrixWorldInverse
或ViewMatrix
) - 物体的位置和形变: 物体位置矩阵(
ObjectWorldMatrix
)
三维投影矩阵(u_matrix)计算公式
三维投影矩阵计算公式如下:
const uMatrix = ProjectMatrix * CameraMatrixWorldInverse* ObjectMatrixWorld复制代码
是不是很简单?
如果你有兴趣,可以写一段最简单的THREE程序,跟踪一下THREE的绘制逻辑,看看THREE是怎么生成和运用这些矩阵的。
接下来我们来解释一下怎么在THREE中得到上述三个矩阵:
相机投影矩阵(ProjectMatrix)
相机投影矩阵决定了相机是透视投影相机还是正射投影相机,现实世界都是透视投影,所以透视投影也是最常用的。
在THREE中,通过用不同的相机类实例化,得到不同类型的相机,例如定义一个透视投影相机:
const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);复制代码
- 获得
ProjectionMatrix
camera.projectMatrix复制代码
相机视图矩阵(CameraMatrixWorldInverse)
有的三维引擎或教程,会把视图矩阵称为ViewMatrix
(例如webglfundamentals)
视图矩阵的含义是,固定其他因素,我们改变了相机的位置和角度后,它眼中的世界也会发生变化,这种变化就是视图矩阵。
前面提到,相机在三维空间中的位置是camera.matrixWorld
,而它的视图矩阵是相机位置矩阵的逆矩阵CameraMatrixWorldInverse
,它也符合了我们的生活经验:
- 固定相机,人向左移动
- 固定人,向右移动相机
这两种情况在相机眼中是一样的。
在THREE中,我们一般通过设置camera
的position和up,调用lookAt
来改变相机的视图矩阵
camera.position.set(x, y, z);
camera.up.set(x, y, z);
camera.lookAt(x, y, z);复制代码
- 获得
CameraMatrixInverse
camera.matrixWorldInverse复制代码
我们知道,最终的投影是在GLSL顶点着色器中计算的。在一次绘制中,
ProjectionMatrix
和CameraMatrixWorldInverse
一般不会发生变化,而ObjectMatrixWorld
每个物体都可能不同, 所以为了减少顶点着色器中的计算量,有些三维引擎会在javascript程序中提前计算出ProjectionMatrix * CameraMatrixWorldInverse
的值传递给顶点着色器,这个矩阵一般称为ViewProjectionMatrix
物体位置矩阵(ObjectWorldMatrix)
ObjectWorldMatrix描述了物体在三维场景中的位置。
- 获得
ObjectWorldMatrix
object.matrixWorld复制代码
前面提到,THREE中的物体是有层级关系的,所以THREE中物体的matrixWorld
是通过local matrix(object.matrix
)与父亲的matrixWorld
递归相乘得到的, 其中的原理可以查阅webglfundamentals中的这篇教程
一些应用
获取屏幕二维坐标
给定三维坐标[x, y, z],怎么获取它在屏幕上的二维坐标呢?计算公式如下:
const [x, y] = ProjectionMatrix * CameraWorldMatrixInverse * [x, y, z]复制代码
THREE在Vector3上封装了方法:
const v = new THREE.Vector3(x, y, z);
const xy = v.project(camera);复制代码
源代码如下:
project: function () {
var matrix = new Matrix4();
return function project(camera) {
matrix.multiplyMatrices(camera.projectionMatrix, matrix.getInverse(camera.matrixWorld));
return this.applyMatrix4(matrix);
};
}(),复制代码
屏幕坐标转化为三维坐标
给定屏幕二维坐标[x, y],怎么获取它在三维空间中三维坐标呢?计算公式如下:
const [x, y, z] = CameraWorldMatrix * ProjectionMatrixInverse * [x, y, z]复制代码
THREE在Vector3上封装了方法:
const v = new THREE.Vector3(x, y, z);
const xyz = v.unproject(camera);复制代码
源代码如下:
unproject: function () {
var matrix = new Matrix4();
return function unproject(camera) {
matrix.multiplyMatrices(camera.matrixWorld, matrix.getInverse(camera.projectionMatrix));
return this.applyMatrix4(matrix);
};
}(),复制代码
不过屏幕坐标转化为三维坐标不是这么简单,因为屏幕上的二维坐标在三维空间中其实对应的是一条射线,其可以对应了无限个三维坐标点,更深入的原理可以阅读这篇stackoverflow上的问题, THREE的作者mroob和一位网友给了精彩的回答。