ThreeJs 矩阵关系

本文主要叙述两个问题:

  1. 世界三维坐标与屏幕二维坐标的转换(MVP 矩阵)
  2. 模型矩阵的位置形态变换顺序(TRS 矩阵)

基本概念:MVP 矩阵

MVP 矩阵是模型(Model)、观察(View)、投影(Projection)三个矩阵的合称。1

这三个矩阵代表了物体顶点坐标从局部空间转换到裁剪空间,最后以屏幕坐标的形式结束。

  • M 模型矩阵表示顶点坐标从物体自身局部空间(Local Space)转换到世界空间(World Space)
  • V 观察矩阵表示从世界空间到观察空间(View Space)
  • P 投影矩阵表示从观察空间到裁剪空间(Clip Space)

需要注意的是:投影矩阵并不代表这一步矩阵乘法过程中包含了投影,而是在下一步通过透视除法将 xyz 分量除以 w 才会发生投影。

所以为什么不直接使用视锥体定义的空间进行裁剪,而需要用 P 矩阵将其转到裁剪空间再进行裁剪呢?

答:因为 P 矩阵投影到一个裁剪空间上,再使用相机对该空间进行投影处理,我的理解是解耦裁剪平面和相机,这时可以替换相机为透视相机还是正交相机。

物体位置矩阵(ObjectWorldMatrix)

ObjectWorldMatrix 描述了物体在三维场景中的位置。2

获得 ObjectWorldMatrix

object.matrixWorld

THREE 中的物体是有层级关系的,所以 THREE 中物体的 matrixWorld 是通过 local matrix(object.matrix)与父亲的 matrixWorld 递归相乘得到的, 其中的原理可以查阅 webglfundamentals 中的 这篇教程

相机视图矩阵(CameraMatrixWorldInverse)

有的三维引擎或教程,会把视图矩阵称为ViewMatrix(例如 webglfundamentals)视图矩阵的含义是,固定其他因素,我们改变了相机的位置和角度后,它眼中的世界也会发生变化,这种变化就是视图矩阵。 前面提到,相机在三维空间中的位置是 camera.matrixWorld,而它的视图矩阵是相机位置矩阵的逆矩阵 CameraMatrixWorldInverse,它也符合了我们的生活经验:

  • 固定相机,人向左移动
  • 固定人,向右移动相机

这两种情况在相机眼中是一样的。 在 THREE 中,我们一般通过设置 camerapositionup,调用 lookAt() 来改变相机的视图矩阵

camera.position.set(x, y, z);
camera.up.set(x, y, z);
camera.lookAt(x, y, z);

获得 CameraMatrixInverse

camera.matrixWorldInverse

我们知道,最终的投影是在 GLSL 顶点着色器中计算的。在一次绘制中,ProjectionMatrixCameraMatrixWorldInverse 一般不会发生变化,而 ObjectMatrixWorld 每个物体都可能不同, 所以为了减少顶点着色器中的计算量,有些三维引擎会在 javascript 程序中提前计算出 ProjectionMatrix \* CameraMatrixWorldInverse 的值传递给顶点着色器,这个矩阵一般称为 ViewProjectionMatrix

相机投影矩阵(ProjectMatrix)

相机投影矩阵决定了相机是透视投影相机还是正射投影相机,现实世界都是透视投影,所以透视投影也是最常用的。在 THREE 中,通过用不同的相机类实例化,得到不同类型的相机,例如定义一个透视投影相机:

const camera = new THREE.PerspectiveCamera(fov, aspect, near, far);

获得 ProjectionMatrix

camera.projectMatrix

三维投影矩阵(u_matrix)计算公式

三维投影矩阵计算公式如下:

const uMatrix = ProjectMatrix * CameraMatrixWorldInverse* ObjectMatrixWorld

MVP 计算顺序3

MVP 到底是按什么排列相乘呢? 只能是 M * V * P 或者 P * V * M。绝对不可能是其他的乘法顺序。

采用行矩阵读法: M * V * P,m3dMatrixMultiply44方法 就是 b * a 后放入 product 里的。 最后结合物体坐标 vlocal(行向量) 也就是 M * V * P * vlocal,最后得到裁剪坐标 vclip(行向量)

采用列矩阵读法: P * V * M ,m3dMatrixMultiply44 方法 就是 a * b 后放入 product 里的。 最后结合物体坐标 vlocal(列向量) 也就是 vlocal * P * V * M,最后得到裁剪坐标 vclip(列向量)

THREE 矩阵采用列主序,所以乘法顺序为 PVM

典型应用

三维世界坐标 -> 屏幕二维坐标

给定三维坐标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 () {

    const matrix = new Matrix4();

    return function project(camera) {
        matrix.multiplyMatrices(camera.projectionMatrix, matrix.getInverse(camera.matrixWorld));
        return this.applyMatrix4(matrix);
    };
}()

屏幕二维坐标 -> 三维世界坐标2

给定屏幕二维坐标x, y,怎么获取它在三维空间中三维坐标呢?计算公式如下:

const [x, y, z] = CameraWorldMatrix * ProjectionMatrixInverse * [x, y, z]

// THREE在Vector3上封装了方法:
const v = new THREE.Vector3(x, y, z); // 此处z可能为0或为camera.far
const projectionV = v.unproject(camera); // 这里unproject已经包含对相机matrixWorld的处理。

源代码如下:

unproject: function () {

    const matrix = new Matrix4();

    return function unproject(camera) {
        matrix.multiplyMatrices(camera.matrixWorld, matrix.getInverse(camera.projectionMatrix));
        return this.applyMatrix4(matrix);
    };
}()

实例:获取视口坐标

/**
* @function 将屏幕坐标转换为摄像机投影平面坐标,实现相对摄像机静止的效果。即获取视口坐标
* @param {THREE.Vector3} position 传入屏幕坐标
* @param {Camera} camera
* @returns Vector3
*/
function getViewPlanePosition(position, camera) {
   const viewPlanePos = new THREE.Vector3(position.x, position.y, camera.far);
   viewPlanePos.unproject(camera); // 转换为相机投影坐标
   viewPlanePos.applyMatrix4(camera.matrixWorldInverse) // 乘相机世界逆矩阵
   return viewPlanePos
}

TRS 矩阵:顶点坐标转换到模型坐标4

TRS 矩阵,也就是由 Translate、Rotate、Scale 组合而成的矩阵,并且矩阵的变换顺序为先 Scale,然后 Rotate,最后再进行 Translate 的操作,这样的执行顺序是为了避免由于位移与旋转的影响而产生缩放的形变问题,所以先执行缩放的操作。5

TRS 的执行顺序可以理解为:

T(R(Sp))

缩放—旋转—平移的操作是不能交换顺序的

“我们必须首先旋转,然后再进行平移吗?”这个问题的答案基本上是“是的”。虽然再旋转之前平移似乎更加自然,但先旋转一般来说会更容易,这就是原因。当我们首先旋转对象时,旋转的中心就是原点。围绕原点旋转和平移是我们可以使用的两种原始工具,每种工具都很简单。 如果在平移之后旋转,那么旋转将发生在一个不是原点的点上。围绕原点的旋转是线性变换,而围绕任何其他点的旋转则是仿射(Affine)变换。为了执行仿射变换,需要构建一系列原始运算。对于围绕任意点的旋转,需要先将旋转中心变换为原点,围绕原点旋转,然后再平移回来。换句话说,如果想要通过先平移再旋转将机器人移动到位,那么可能会经历以下过程:

(1)平移。将世界空间平移为直立空间(ps:直立空间是本书自己的定义,其代表对象空间和世界空间的”过渡“,原点和世界空间重合,并且基矢量和对象空间平行) (2)旋转。因为我们正在围绕一个不是原点的点旋转,所以需要以下 3 个子过程:

  1. 将旋转中心平移到原点。这个步骤刚好和(1)相反。
  2. 执行围绕原点的旋转。
  3. 平移,将旋转中心放在适当的位置。

请注意,由于步骤(1)和步骤(2)中的 1 可以相互抵消,所以只剩下两个步骤:先旋转,然后平移。

以上出自《3D 数学基础:图形和游戏开发(第 2 版)》

至于缩放为什么一定要最先做,是因为如果我们先旋转或平移会导致坐标轴的轴向改变或坐标原点改变,这时候进行缩放将会得到我们不想要得到的结果。1

补充资料

WebGL - 场景图

MVP 矩阵运算 - 知乎

参考出处

Footnotes

  1. 技术美术百人计划笔记(MVP 矩阵运算) - 知乎 2
  2. 一篇文章弄懂 THREE.js 中的各种矩阵关系 - 掘金 2
  3. OpenGL mvp 矩阵使用及其相乘的顺序思考 - 简书
  4. 渲染管线中 MVP 矩阵的推导 - 知乎
  5. Unity 空间坐标转换的矩阵应用 - 知乎