View矩阵的说明和计算

1,433 阅读11分钟

本文较原文有变动,使用THREE.js进行说明

本文讲尝试去说明:

  • 如何正确的构建view矩阵,
  • 如何使用view矩阵将模型的顶点坐标映射到裁剪空间中
  • 如何根据view矩阵去计算相机在世界坐标系中的位置

简介

理解view矩阵是如何在三维空间中工作,在3D编程中是一件非常重要的事情。世界坐标系中的变换矩阵(transformation matrix),决定了三维空间中的一个物体的位置和朝向。view矩阵用于将模型的顶点坐标从世界空间转换到相机空间。这两者并不是一回事。
想想你手中拿着一个录影机,对一辆汽车进行录影。通过移动你的相机,你可以看到不同视角下的这个车。这样一来,当你看到汽车的图像时,就像是整个场景在你的取景器中移动一样。
事实上,在计算机程序中,相机并不会进行移动,当你预期相机做某种移动和变化时,实际上是世界空间在朝着相反的方向和朝向进行移动。
需要明确的两件事就是:

  1. 相机的变换矩阵(Transformation): 变换矩阵用于将相机放置到世界空间中正确的位置和朝向上。
  2. View矩阵:view矩阵用于将顶点坐标从世界坐标系转换到视口坐标系,它实际上是相机变换矩阵(Transformation)的逆矩阵。

image.png 上图中,相机在世界空间中的变化如图左侧,相机观察到的内容如图右侧。

转化

一个4X4的齐次矩阵(行主序表示,线性代数中的习惯,但是在计算机中,以列主序进行读写),第一行表示X分量(right),第二行表示Y分量(up),第三行表示的是Z分量(forward),最后一列表示的是变化矩阵(Transformation)所表示的平移(或位置)。

[rightxrightyrightzpositionxupxupyupzpositionyforwardxforwardyforwardzpositionz0001]\left[ \begin{matrix} right_{x} & right_{y} & right_{z} & position_{x} \\ up_{x} & up_{y} & up_{z} & position_{y} \\ forward_{x} & forward_{y} & forward_{z} & position_{z} \\ 0 & 0 & 0 & 1 \\ \end{matrix} \right]

使用上述的变换矩阵时,我们需要左乘列向量。也就是说,如果我们需要使用矩阵MM转化一个矩阵vv,我们就需要使用MM矩阵在左侧乘以列向量vv

v=Mv[xyzw]=[m0,0m0,1m0,2m0,3m1,0m1,1m1,2m1,3m2,0m2,1m2,2m2,3m3,0m3,1m3,2m3,3][xyzw]v' = Mv \\ \left[ \begin{matrix} x' \\ y' \\ z' \\ w' \end{matrix} \right] = \left[ \begin{matrix} m_{0,0} & m_{0,1} & m_{0,2} & m_{0,3} \\ m_{1,0} & m_{1,1} & m_{1,2} & m_{1,3} \\ m_{2,0} & m_{2,1} & m_{2,2} & m_{2,3} \\ m_{3,0} & m_{3,1} & m_{3,2} & m_{3,3} \\ \end{matrix} \right] \left[ \begin{matrix} x \\ y \\ z \\ w \end{matrix} \right]

MM矩阵中的元素使用mi,jm_{i,j}表示,其中mi,jm_{i,j}表示第ii行,第jj列元素。

一系列的仿射变化(例如平移TT、缩放SS、旋转RR)组合顺序如下:

v=(T(R(Sv)))v' = (T(R(Sv)))

上述的变化可以说是“先缩放、再旋转、最后平移”,SSRRTT三个矩阵可以合并为一个MM矩阵:

M=TRSv=MvM = TRS \\ v' = Mv

在使用一个场景的变换矩阵去变换内部的子元素的时候,需要使用父场景的变换矩阵左乘元素自身的变换矩阵:

Childworld=ParentworldChildlocalChild_{world} = Parent_{world} * Child_{local}

当子元素不存在父场景时,该元素的世界坐标系下的变换矩阵和它相对于父场景的变换矩阵(ChildlocalChild_{local})是一样的:

Childworld=ChildlocalChild_{world} = Child_{local}

存储列主序矩阵的方式

在计算机内存中,该矩阵的读写使用的是列主序,形式如下:

M=[ m0,0, m1,0, m2,0  m3,3 ]M = [\ m_{0,0},\ m_{1,0},\ m_{2,0}\ ···\ m_{3,3}\ ]

在THREE.js中定义一个矩阵如下

// 线性代数中使用行主序
const matrix = new THREE.Matrix4().set(
    1, 0, 0, 1, // X
    0, 1, 0, 2, // Y
    0, 0, 1, 3, // Z
    0, 0, 0, 1
)

// 在WebGL中使用列主序读写 
// [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 1, 2, 3, 1]

转化

在3D空间渲染一个场景时,针对3D几何体通常会有三种变换:

  1. World Transform:World Transform可以理解为空间中物体的变换,也就是model matrix。用以变换一个模型的顶点坐标,将模型以正确的位置和朝向放置在世界空间中。

  2. View Transform:世界坐标空间中的顶点需要根据相机的视角转换到相机的视野空间中,也叫做"view space"(有时称作"camera space"),这个矩阵也是本文要研究的矩阵。

  3. Projection Transform:顶点在转换到view space后,需要根据投影方式变换到一个裁剪空间中("clip space")。这个空间就是图形程序最后需要关注的空间了。本文所讨论的内容不涉及投影矩阵这部分。

如果把相机也想象成一个三维几何体的话,我们可以认为相机也是具有一个变换矩阵(transformation matrix)的,该矩阵可以控制相机在世界坐标中的朝向和位置,为了和view transform进行区分,在本文中,将该矩阵暂时命名为camera transform。由于我们需要根据相机的视角去渲染一个场景,我们需要找到一个变换矩阵,用以将相机转换到view space。换句话说,我们需要一个矩阵,让相机的坐标轴和世界的坐标轴重合。
因此,我们实际上是在寻找一个满足下面等式的矩阵

I=VMI = VM

其中MM表示的是相机的transform矩阵(或世界坐标系下的变换矩阵),VV表示的是我们要去寻找的view矩阵,该矩阵可以让相机的变化矩阵最后编程一个单位矩阵II
因此view矩阵就是MM的逆矩阵:

V=M1V = M^{-1}

相机变换

相机变换矩阵,可以控制场景中相机的位置和朝向。如果你想在场景中表示多个相机,并且想可视化的表示出每一个相机在世界坐标系中的位置,那么,该变换矩阵就可以将表示相机的物体从物体坐标系转换到世界坐标系之中。这一点和场景中任何模型的变换矩阵是一样的。这个矩阵和view矩阵是不一样的。他不能直接用来将世界坐标系的定点转换到view空间中去。
计算相机的变换矩阵和计算场景中的任意一个物体的变换矩阵是没有区别的。
RR表示相机的在世界坐标系的朝向,用TT表示相机在世界坐标系的位移,那么相机的变换矩阵MM可以用以上两个矩阵的乘积来表示:

M=TRM = TR

请注意,我们在处理变换时是先旋转,后平移。

View Matrix

view matrix用以将世界坐标系下的顶点转换到view空间中。他通常和物体的世界坐标的变换矩阵和投影矩阵一起使用。一起作用之后的顶点就会进入到裁剪空间中去。
MM表示物体的变换操作,用VV代表view矩阵,用PP表示投影矩阵,那么MVPMVP变换就是三者的一个乘积:

MVP=PVMMVP = P * V * M

一个顶点坐标乘以MVPMVP矩阵后,可以直接转换到裁剪空间中去:

v=MVPvv' = MVP * v

那么view矩阵是怎样被计算出来的呢?有几种方式去计算一个view矩阵,不同的计算方式取决于你要怎么去使用他。 常用的计算view矩阵的方式就是通过给定一个矩阵(Look-at)。需要设定一个相机在世界坐标系中的位置,以及一个相机正方向(up),通常是[0 1 0]T[ 0 \ 1\ 0 ]^{T},最后给定一个世界坐标系下观察目标点的坐标。 如果你要开发一个FPS的游戏,你就不会使用Look-at的方法。在FPS的场景中,更方便的做法是使用世界坐标系中的位置信息、一个俯仰角Pitch(绕X轴旋转)以及偏航角Yaw(绕Y轴旋转)。
如果你想要使用一个相机去环绕一个一个物体,这种方式时就是一个轨迹球相机。 接下来就会讨论这三种相机。

Look At Camera

使用这种方式时,我们可以根据世界坐标系的相机位置,正方向和目标点,直接计算出view矩阵。 一个经典的实现如下:

function lookAt(eye:THREE.Vector3, target:THREE.Vector3, up:THREE.Vector3):THREE.Matrix4 {
    const zAxis = eye.clone().sub(target).normalize()
    const xAxis = up.clone().cross(zAxis).normalize()
    const yAxis = zAxis.clone().cross(xAxis).normalize()

    const orientation = new THREE.Matrix4().set(
      xAxis.x, xAxis.y, xAxis.z, 0,
      yAxis.x, yAxis.y, yAxis.z, 0,
      zAxis.x, zAxis.y, zAxis.z, 0,
      0, 0, 0, 1,
    )

    const translation = new THREE.Matrix4().set(
      1, 0, 0, -eye.x,
      0, 1, 0, -eye.y,
      0, 0, 1, -eye.z,
      0, 0, 0, 1
    )

    return orientation.multiply(translation)
  }

在THREE.js中矩阵的设定,是按照行主序的,也就是在线性代数中使用的矩阵格式,在数据的存储中,使用列主序,列主序也是WebGL中的矩阵读取顺序

上述方程表示的意思是,先将世界坐标系的原点移动到相机在世界坐标系中的位置,然后将世界坐标系的各个轴和相机坐标系的各个轴对齐,进而得到当前相机视角的一个view矩阵。

FPS Camera

如果要实现一个FPS相机,我们就需要一系列的欧拉角(俯仰角Pitch和航向角Yaw)以及一个世界坐标系的位置坐标。FPS相机,首先需要绕着X轴旋转俯仰角(Pitch),之后再绕着Y轴旋转航向角(Yaw),最后根据世界坐标系的位置,做一个平移变换。犹豫我们需要的是view矩阵,因此,我们需要计算的是上述变换的一个逆矩阵:

V=(T(RyRx))1V = (T(R_{y}R_{x}))^{-1}

image.png 具体实现如下:

// 俯仰角Pitch的范围在[-90°,90°]
// 航向角Yaw的范围在[0°,360°]
  function FPSView(eye:THREE.Vector3, pitch:Number, yaw:Number):THREE.Matrix4 {
    const sinPitch = Math.sin(pitch)
    const cosPitch = Math.cos(pitch)
    const sinYaw = Math.sin(yaw)
    const cosYaw = Math.cos(yaw)

    const matrixPitch = new THREE.Matrix4().set(
      1, 0, 0, 0,
      0, cosPitch, sinPitch, 0,
      0, -sinPitch, cosPitch, 0,
      0, 0, 0, 1
    )

    const matrixYaw = new THREE.Matrix4().set(
      cosYaw, 0, sinYaw, 0,
      0, 1, 0, 0,
      -sinYaw, 0, cosYaw, 0,
      0, 0, 0, 1
    )

    const matrixTranslation = new THREE.Matrix4().set(
      1, 0, 0, eye.x,
      0, 1, 0, eye.y,
      0, 0, 1, eye.z,
      0, 0, 0, 1
    )

    return matrixTranslation.multiply(matrixYaw.multiply(matrixPitch)).invert()

  }

Arcball Orbit Camera

轨迹球相机是一个围绕着空间中的某一个物体,绕着某一个轨道进行转动的相机。被围绕的物体不一定是需要放置在坐标原点的。轨迹球相机通常不会像FPS相机一样限制旋转的角度。在轨迹球相机中我们需要关注万向节锁的问题。

image.png

Gimbal_Lock_Plane.gif

万向节锁的问题可以使用四元数的方式去避免,但万向节锁并不是使用欧拉角去表示该相机的旋转会导致的唯一的问题。如果相机绕着X轴旋转超过90°(顺时针或逆时针),相机就会变得上下颠倒,Y轴的左右方向就会变得相反。这是一个很难修复的问题,因此,大部分的应用不允许相机绕X轴旋转超过90°。
如果你使用鼠标,你或许想要通过点击或拖拽将相机绕着某一个物体旋转。为了确定旋转,鼠标在屏幕上的点击位置P0P_{0}会被投射到一个覆盖屏幕的单位球体上。当鼠标移动时,相机会移动到单位球面上的P1P_{1}点。就会得到一个四元数,用以表示从P0P_{0}P1P_{1}的旋转。
为了构建轨迹球的view矩阵,我们将使用两个平移和一个旋转表示view矩阵。第一个平移t0t_{0}将相机移动到距离物体某一特定的距离上,以方便视野中可以看到该物体。之后一个四元数的旋转rr控制相机围绕着物体进行一个旋转。物体并非被放置在世界坐标系的原点,之后我们需要将相机的基准点从原点移动到物体的位置,这时我们就需要使用一个平移t1t_{1}。因此,一个完整的变换如下:

M=T1RT0V=M1M = T_{1}RT_{0} \\ V = M^{-1}

具体的实现如下:

function arcBallCamera (trans0:THREE.Vector3, rotate:THREE.Quaternion, trans1:THREE.Vector3):THREE.Matrix4 {
    const translation0 = new THREE.Matrix4().translate(trans0.x, trans0.y, trans0.z)
    const rotate = new THREE.Matrix4().setRotationFromQuaternion(rotate)
    const translation1 = new THREE.Matrix4().translate(trans1.x, trans1.y, trans1.z)
    rotate.multiply(translation0)
    return translation1.multiply(rotate).invert()
}

相机变换矩阵和view矩阵的转换关系

如果你计算出了相机的变换矩阵MM,你可以求变换矩阵的逆矩阵,就可以得到view矩阵,用以将世界空间转换到相机空间中:

V=M1V = M^{-1}

如果你已经得到了view矩阵,并且你需要去设置一个虚拟的物体去表示相机在世界坐标系下的位置,那么就可以去求解view矩阵的逆矩阵,去获取相机的变换矩阵MM

M=V1M = V^{-1}

该方法主要在shader中进行使用,常用的就是需要知道相机在世界坐标系下的位置。在这个场景下,其实MM矩阵的第四列就是表示的是相机在世界坐标系下的位置的:

M=V1eyeworld=[M0,3, M1,3, M2,3]M = V^{-1} \\ eye_{world} = [M_{0,3}, \ M_{1,3}, \ M_{2,3}]

结论

通过上文的描述,希望你能够清楚相机的变换矩阵和view矩阵的区别,以及两者之间如何进行转换。你需要清楚的意识到自己在使用的是哪种矩阵,以方便你根据该矩阵,去获取相机在世界坐标系下的位置。当使用相机变换矩阵时,矩阵的第四列坐标就是表示的相机在世界坐标系下的位置的,在使用view矩阵时,你需要先求解该矩阵的逆矩阵,才能获取到相机在世界坐标系下的位置的。

原文