Vulkan的相机矩阵与投影矩阵

  • 简介

3D世界中,点是三维的,但是我们的屏幕是二维的,如何将三维的点变换成二维的是图形学中最重要的一步,也是最基础的一步。

我们的物体是在世界坐标系的,如果直接变换成屏幕坐标系,那么比较麻烦。我们需要先把点变到相机坐标系(因为相机坐标系转换到屏幕坐标系比较简单)。然后再把点变换到屏幕坐标系。
相机矩阵跟投影矩阵要配合着一起使用。


OpenGL坐标变换流程图,引用别人的哈

有关矩阵的知识的补充:

假如有一个二维点,我们想实现平移,矩阵该是什么样子的?
无论怎么找,我们都发现矩阵无法实现平移操作!
而且我们得到的结果(X,Y)中不可能存在1/Xo或者1/Yo这种结果
 |X|   =   |A11,A12| x |Xo|
 |Y|       |A21,A22|   |Yo|
于是有人一直钻研这个问题,终于想出了一个解决办法,就是上提升一个维度。
|X|    |A11,A12,Xt|     |Xo|    // Xt,Yt是平移的距离
|Y| =  |A21,A22,Yt|  X  |Yo|  
|1|    | 0 , 0 ,1 |     |1 |
并且约定最后最后一个维度为1,这样矩阵有个特点,
最后一个维度代表了这个向量的整体缩放程度。

OpenGL下的坐标变换

  • 首先我们要讲一下相机矩阵。

相机在世界坐标系的位置

物体的绘制在屏幕上的位置取决于我们从哪里看,从正面看跟从背面看得到的是不一样的形状。
不管从哪里看我们最终还是要变换到屏幕上。
一般摄像机在原点,并且朝向和头顶方向跟坐标轴平行才比较好变换到屏幕坐标系上去。

下图是OpenGL常用的相机坐标系

准备变换到屏幕坐标的相机坐标系和标准化设备坐标(不要把标准设备坐标系当作左手坐标系,其实这是个平面,只有X、Y被需要,Z只是辅助深度缓冲用的,用完即丢,而且Z不是和X、Y一样的线性变换)

我们就拿这个相机坐标系举例子。

我们需要做的是把世界的点变换到相机坐标系下,其实变换前的点跟变换后的点,都是一样的,不过在不同的坐标系下面,表示不同,体现在坐标数值上的变化(可以理解为,你是90后,对于70后来说,你是嫩草,对于10后来说,你是老牛,只不过叫法不同了,你还是那个你)。

即此,我们需要把世界坐标系的点映射到相机坐标系上面去。

在上面的相机坐标系中,相机的位置是原点,Z轴与相机朝向相反,X朝右,Y朝上。

学过线性代数的也知道,A、B两个向量 点乘 得到的是A向量在B向量的长度乘上B向量的长度(反过来也成立),那么如果A向量长度为1,那么A、B点乘得到的是B向量映射在A向量的长度,如果我们想知道世界坐标系的点在相机坐标系所对应的X,Y,Z值,我们把世界坐标系的点跟相机X、Y、Z轴在世界坐标系的单位向量相乘,就能得到世界点在相机坐标系下的X\Y\Z轴下的长度,因此就能得到对应的相机坐标。

假设,我们已经求出来X,Y,Z三个单位向量(单位向量指长度为1,也成为归一化向量),那么我们只需要把点跟这三个向量相乘就能得到新坐标系下的x\y\z值。
因此变换矩阵可以写成

|X1,X2,X3,0|  // 此矩阵为行优先
|Y1,Y2,Y3,0| // OpenGL需要传入的是列优先
|Z1,Z2,Z3,0| // 要么把矩阵转置传进去
|0 ,0 ,0 ,1| // 要么直接写列优先矩阵

但是我们相机的位置不在原点,也就是说相机坐标系跟世界坐标系的原点不重合,如果不把两个坐标系的点重合到一起,是求不出正确的结果的,我们需要先把相机坐标系的点平移到世界坐标系。你可以想象成把相机跟点一起平移了相同的位置,这样都平移了,这些点的相机坐标系下的表示都没有变,而且由于世界跟相机坐标原点重合,能够进行向量坐标映射。
于是我们要先平移后再进行上面坐标映射的矩阵相乘。下面是平移矩阵

// 设相机的位置为C
|1,0,0,-Cx|  //此矩阵为行优先
|0,1,0,-Cy|
|0,0,1,-Cz|
|0,0,0, 1 |

因此返回的矩阵为

|X1,X2,X3,0|           |1,0,0,-Cx|  // 矩阵都是左乘点于点
|Y1,Y2,Y3,0|     X     |0,1,0,-Cy|    
|Z1,Z2,Z3,0|           |0,0,1,-Cz|
|0 ,0 ,0 ,1|           |0,0,0, 1 |

下面贴代码

// 这是最近写的js代码,列优先
// OpenGL以前写过,找不到了,懒得手撸了,因为还要测试Bug
// 这里记住,向量一定要归一化,要不然得不到正确结果
// OpenGL用的是右手坐标系
//              ^ Y
//              |
//              |
//              。----------------->  X
//            /
//     Z    / 
//         V
function getMatrix_LookAt(eyePosition,lookDirection,upDirection)
{
      let result=new Matrix4();
      let Y=new Point3();
      let Z=new Point3();
      for(let i=0;i<3;i++)
      {
        Z.data[i]=-lookDirection[i];
        Y.data[i]=upDirection[i];
      }
      Z.normalize();
      let X=cross(Y,Z);
      X.normalize();
      Y=cross(Z,X);
      Y.normalize();
      let matrix_move=getMatrix_translate_xyz(-eyePosition[0],-eyePosition[1],-eyePosition[2]);
      let matrix_transform=new Matrix4();
      matrix_transform.identity();
      for(let i=0;i<3;i++)
      {
        matrix_transform.data[4*i] = X.data[i];
        matrix_transform.data[1+4*i]=Y.data[i];
        matrix_transform.data[2+4*i]=Z.data[i];
      }
      return multiply_matrix(matrix_transform,matrix_move);
}

  • OpenGL下的投影坐标系

OpenGL投影方法为两种,一个是正交一个是透视,正交投影就是把X,Y,Z老老实实平移到相应屏幕点上,在工程制图上比较常用


正交投影

透视投影就是模拟人眼远小近大的原理,投射到屏幕上。这里暂时只讲透视投影,后面哪天有时间想起来再补充正交,自己可以推导出来,很简单的。

opengl屏幕坐标系为Y朝上,X朝右。下图为视锥体的一个侧面图。


透视投影截面

X,Y,Z为[-1,1]以内的才会保留,在外面的会被裁剪掉。我们需要把在视锥体里的点变换到[-1,1]之内。

这里先提前声明一下概念:

Xe : Xeye的缩写,代表相机坐标系下的X坐标
Ye: 同上
Ze: 同上
Xp: Xprojection的缩写,代表被线性变换到近平面的X坐标 这时候还没有被缩放到[-1,1]。
Yp: 同上
Xc: Xclip的缩写,代表被变换到裁剪坐标的X坐标,Xc的产生完全是因为矩阵没有办法直接一步除-Ze的折衷办法,他在推理上没必要存在的。在推理上直接一步到Xn.
Yc: 同上
Zc: 同上
Xn: Xndc的缩写,代表标准设备坐标系的X坐标
Yn: Yndc的缩写,代表标准设备坐标系的Y坐标
Zn: Zndc的缩写,代表标准设备坐标系的Z坐标

上面的关系是:
Xc是Xp缩放到[-1,1]的坐标,投影矩阵包括了将Xe映射到近平面的Xp,然后缩放Xp到Xc的操作
Xc=投影矩阵*Xe
Xn=Xc/Wc (这一步是渲染管线自动算的,我们只需要把-Ze存到Wc分量)
由于后面推导的Xn、Yn都要除-Ze,然而矩阵有个缺点,就是不能进行一次性进行除-Ze操作,所以我们把-Ze放在W向量上,相当于X,Y,Z分量不变的情况下,扩大了W倍,到光栅前,管线会自动进行除W分量操作。

将-Ze放在W分量上

我们无法一次性变换到屏幕坐标系(但是渲染管线自动除W分量),那我们就变换到没有除W分量的裁剪坐标系。
因此我们推出最后一行,这样变换后的向量的W分量就是-Ze:
如果这里不理解不要紧,我讲的顺序有点问题,先看后面Xn、Yn的推导,再来看这里,然后再看Zn的推导。
先求出最后一行

利用相似三角形求出近平面的值

利用相似三角形:
Xp=n*Xe/-Ze
Yp=n*Ye/-Ze
Xp、Yp是在近平面上的坐标但是在[-1,1]范围外,我们需要把在近平面内的映射到[-1,1],因此
Xn=Xp/近平面的长度/2
Yn=Yp/近平面的高度/2

因此,推导出:


       2*n*Xe
Xn=  ——————————
      -Ze*(r-l)


       2*n*Ye
Yn=  ——————————
      -Ze*(t-b)

如果你的视锥体表示方式是Fovy,apect
Fovy指相机垂直方向的角度,aspect指近平面宽高比

          2*Xe
Xn=  ————————————————————
      -Ze*tan(fovy/2)*aspect


          2*Ye
Yn=  ———————————————
      -Ze*tan(fovy/2)

由于我们-Ze是单独除的,换一种说法是Clip坐标系=NDC坐标系*-Ze;
上面的公式去掉Xe/Ye和-Ze就是系数。因此我们推导出了前两行矩阵

|2n/(r-l)       0         0       0|             |Xe|
|     0      2n/(t-b)     0       0|      X      |Ye|
|     0          0        A       B|             |Ze|
|     0          0       -1       0|             |We|  //这里如果正常变换的话We应该为1

如果参数是是Fovy 和Aspect,
矩阵为:
|1/(tan(fovy/2)*aspect)      0           0       0|                |Xe|
|     0                1/tan(fovy/2)     0       0|        X       |Ye|
|     0                      0           A       B|                |Ze|
|     0                      0          -1       0|                |We|   //这里如果正常变换的话We应该为1

Z变换肯定和X、Y没有关系,因此我们把第三行前两个系数设为0,那么Z的变化就跟后面两个系数有关系,我们设他们为A、B,这是就要解方程了,

Zn=Zc/-Ze // 裁剪坐标系除-Ze才是齐次化标准坐标系
Zc=A*Ze+B // 裁剪坐标系
Zn=(A*Ze+B)/-Ze

我们要把视锥体里的Z映射到[-1,1]

因此
当Ze=-n的时候,Zn=-1;
当Ze=-f的时候,Zn=1;
带入方程,得:
-1=(A*-n+B)/n
1=(A*-f+B)/f
解方程得:
A=(f+n)/(n-f)
B=2*f*n/(n-f)

因此投影矩阵为:

|2n/(r-l)       0           0              0        |                  |Xe|
|     0      2n/(t-b)       0              0        |         X        |Ye|
|     0          0      (f+n)/(n-f)   2*f*n/(n-f)   |                  |Ze|
|     0          0         -1              0        |                  |We|  //这里如果正常变换的话We应该为1

如果参数是是Fovy 和Aspect,
矩阵为:
|1/(tan(fovy/2)*aspect)      0                0               0        |                 |Xe|
|     0                1/tan(fovy/2)          0               0        |        X        |Ye|
|     0                      0           (f+n)/(n-f)     2*f*n/(n-f)   |                 |Ze|
|     0                      0               -1               0        |                 |We|   //这里如果正常变换的话We应该为1

如果之前没有在W分量上做整体缩放操作的话的上式结果向量应该为
| Xc | 我们把点变换到这里就结束了,
| Yc | 在vertex shader里面把点变换成左边的样子,
| Zc | 然后送入光栅化阶段。
|-Ze | OpenGL会在光栅化之前,自己进行除W分量操作,
 把上面的点变换成
|Xc/-Ze|
|Yc/-Ze|
|Zc/-Ze|
|   1  |
此时,这个坐标就是我们要的齐次化设备标准坐标系
|Xn|
|Yn|
|Zn|
| 1|

注意投影矩阵的Z变换不是线性变换,引用一下别人的图


Z的非线性变换

上面函数显示,Zn在远处变化较慢,这就说明,如果两个非常远的物体深度相近,变换后Z有可能是相同的值,导致Z值冲突。

Vulkan下的坐标变换

Vulkan跟OpenGL用的都是右手坐标系,但是Vulkan的屏幕坐标系的Y轴跟OpenGL相反。

Vulkan的屏幕坐标系

Vulkan的Z值方向大小跟OpenGL一样。
X/Y范围都是[-1,1]。
前面我们说过,其实相机坐标系不是固定的,作为渲染API,它只是给你指出他裁剪的标准化屏幕坐标系范围,你只需要给他变换到对应的裁剪坐标系就好,它不关心你中间用了坐标系。
介于Vulkan的屏幕坐标系Y轴是向下的,那么我们用的相机坐标系跟他相近就好,如下图所示:


Vulkan下的相机坐标系

然后投影矩阵在上面坐标系的基础上变换到Vulkan的屏幕坐标系,这里就不推导了,直接上C++代码,满足伸手党。

// RR是行
//CC 是列
// 只有行列都为4的时候本矩阵才有效。
//  本矩阵为行优先,传入Vulkan需要在Vulkan选项或者GLSL里设置转置
// 本代码是自己写的库的其中一部分
// 完整库代码地址:
// https://github.com/kaqima/MyOwnLibrary
// 在Math目录下的Matrix.hpp头文件
        template
        static enable_if_t > getMatrix_LookAt_Vulkan(const Point& position,const  Point& direction,const Point& up)
        {
            Matrix result;
            result = Matrix::getMatrix_Translate(-position);
            Point Z = Point::normalize( direction);
            Point X = Point::multiply_cross(Z, up).normalize();
            Point Y = Point::multiply_cross(Z, X).normalize();
            Matrix left = { Point(X,0.0f), Point(Y,0.0f),Point( Z,0.0f),Point(0.0f,0.0f,0.0f,1.0f)};
            return left*result;
            
        }

        template
        static enable_if_t > getMatrix_Perspective_Vulkan(const T& fov_H,const T& aspect_WdH,const T &zNear,const T &zFar)
        {
            Matrix result;
            result.identity();
            constexpr double PI = 3.1415926;
            TT a = (fov_H / 180) * PI;
            TT H = tan(a /2.0f);
            result[0][0] = 1 / (H*aspect_WdH);
            result[1][1] = 1 / H;
            result[2][2] = zFar/(zFar-zNear);
            result[2][3] = zFar*zNear/(zNear-zFar);
            result[3][3] = 0;
            result[3][2] = 1;
            return result;
        }


你可能感兴趣的:(Vulkan的相机矩阵与投影矩阵)