本文最后更新于 2024年8月31日 晚上
翻译自OpenGL Projection Matrix (songho.ca)
概览
电脑屏幕是一个二维平面,因此,由OpenGL渲染的三维场景需要作为一个2D图片被“投射”到电脑屏幕上。GL_PROJECTION
矩阵是一个OpenGL内建的用于投影变换的矩阵。首先,它将所有顶点数据从观察空间变换到裁剪空间,随后,这些裁剪坐标经过齐次除法后被变换为NDC坐标。
因此,我们必须记住,GL_PROJECTION
矩阵包含了两次变换:视锥体剔除以及NDC坐标变换。下面的章节讲述了如何利用left
,right
,bottom
,top
,near
和far
六个参数构建投影矩阵。
视锥体剔除发生于齐次除法之前。在剔除时,需要确定裁剪坐标xc,yc与zc的范围。如果这三者在[−wc, wc]范围内,则保留,否则剔除。完成剔除后,OpenGL会重新构建多边形的边(通过增加顶点的方式)。
透视投影
在透视投影(Perspective Projection)中,位于平截头体(Truncated Pyramid Frustum)内部的顶点(观察空间下)会被映射到一个正立方体中。映射完毕后的三维坐标中,x分量从[l,r]映射到[-1,1],y分量从[b,t]映射到[-1,1],z分量从[-n,-f]映射到[-1,1]。
OpenGL中,观察空间是基于右手坐标系定义的,而NDC是基于左手坐标系的(直观表面为,垂直屏幕越往里,z分量越大),这意味着,在观察空间中,相机是看向-Z轴的(这意味着在观察空间中,near和far都是负值),而在NDC中相机是看向+Z轴的。
glFrustum()
函数用于接收六个参数以构建平截头体。但参数中的near
和far
参数都必须为正,所以我们必须在构建投影矩阵的过程中把这两个参数取负值。
OpenGL中,观察空间下平截头体内部的顶点都将被投影到near
平面上,该平面也被称为投影平面。下图展示了一个观察空间下的点是如何被投影到near
平面上的:


左图展示了平截头体的顶部视角,右图展示了平截头体的右侧视角。
从顶部视角,顶点的观察坐标的x分量从xe映射到xp=−zen⋅xe。
从右侧视角,顶点的观察坐标y分量从ye映射到yp=−zen⋅ye。
不难看出,xp和yp都与ze相关,它们与−ze成反比。也就是说,它们都除以了−ze。
观察坐标通过GL_PROJECTION
变换为裁剪坐标后,这个坐标仍然是一个齐次坐标(用n+1维表示的n维向量)。它最终通过将各分量除以w分量变换为NDC坐标。如图:
因此,wc的值设置为−ze。对于投影矩阵,它的第四行就应当是(0,0,-1,0)。如图:
随后,我们把顶点投影坐标映射到NDC上,也就是:[l, r] ⇒ [-1, 1] and [b, t] ⇒ [-1, 1]

类似地,可以求得yn=t−b2yp−t−bt+b。
该如何理解映射过程?
我们知道,将法线贴图的RGB值([0,1])映射到[-1,1]时,我们采用的方法是*0.5+0.5。扩展一下,想要把[a,b]映射到[c,d],所需要的公式如下:
y=d−cb−a⋅x+α (称为公式①)
而当x=b时,y=d,代入公式①可得:
d=d−cb(b−a)+α
解得α=d−d−cb(b−a)
代入公式①即可得到映射函数:
y=d−cb−a⋅x+d−cb(b−a)
得到xn和yn后,我们将xp、yp同xe、ye的关系式代入:
xn=r−l2xp−r−lr+l=r−l2⋅−zen⋅xe−r−lr+l=(r−l2n⋅xe+r−lr+l⋅ze)/−ze
yn=t−b2yp−t−bt+b=t−b2⋅−yen⋅ye−t−bt+b=(t−b2n⋅ye+t−bt+b⋅ze)/−ze
其中,最后结果中,括号内部的多项式分别代表裁剪坐标xc和yc。
根据这些线性方程组,我们可以推导出投影矩阵的前两行:
现在,我们求出投影矩阵的第三行就可以得到完整的投影矩阵了。
对于平截头体内的顶点,其观察空间下的z坐标在near平面下始终被投影至-near
的值。但我们需要一个独特的z值用于裁剪和深度测试。此外,在一些后续处理中,我们有时会需要对裁剪坐标进行反变换,重新恢复到其他坐标。为了解决这一问题,我们对z分量做一些不同的处理:
首先我们知道,NDC坐标的z值zn由裁剪坐标的z值zc除以w值得到,即zn=zc/wc。前面我们提到,wc=−ze,而z分量又是与x、y分量线性无关的,所以zc只与ze线性相关,可得:zc=Aze+B。代入得:
zn=−zeAze+B=−A−zeB
已知zn的范围是[-1,1],ze的范围是[-n,-f],将上下限代入可得:
n−An+B=−1;fAf+B=1
联立解得A=−f−nf+n,B=−f−n2fn
将A、B代入Zn与A、B的线性方程,解得Zn=−ze−f−nf+nze−f−n2fn
你可以注意到,Zn和Ze呈现非线性的关系。这意味着,在近平面附近,投影有着很高的精度,而在远平面处投影的精度就很低。当范围[-n,-f]覆盖的范围变大时,最终z值的精度就会丢失的越多(远平面附近物体z值的精细变化经过投影后可能失效)。因此,n和f应当尽可能缩小,以此来提高深度缓冲的精度。
由此可得完整的投影矩阵为:
上面的矩阵适用于一般的平截头体。当视口对称时,则有r=−l,t=−b。此时投影矩阵可以简化为:
使用FOV的透视投影矩阵
给定near、far值和窗口大小时,很难确定top、bottom、left和right的值。但是我们可以从FOV的高宽比和窗口的宽高比推导出这四个参数。需要注意,高宽比和t,b,l,r之间的转换只能在对称透视投影矩阵的情况下进行。
当垂直FOV的值为θ,而屏幕宽高比未知时,我们可以通过直角三角形的特性计算出l,r,t,b四个参数。首先,使用1/2垂直FOV角度(θ/2)的正切构建公式①:
tan(2θ)=t/n
随后,借助屏幕宽高比计算屏幕宽度的一半,即r:
r=t⋅hw
踩坑
1 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 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151
|
int TransProj(VM_D_XYZ camPos,VM_D_HPR camHpr,double camFovHoriz,double camFovVerti,int tgtNum,const VM_D_XYZ tgtXYZs[],VM_D_UVD tgtUVDs[]) { VM_D_XYZ camDir = GetDirectionFromHPR(camHpr); camDir.normalize(); VM_D_MAT4 viewMat = LookAt(camPos,plus(camPos,camDir),vec3(0,0,1)); #ifdef DEBUG std::cout << "[DEBUG] ViewMat:" << std::endl; for(const auto & row : viewMat.rows) { std::cout << "[DEBUG] "<<row.to_string() << std::endl; } #endif double near = 0.1; double far = 100.0f; double top = near * tan(camFovVerti/2); double right = near * tan(camFovHoriz/2); std::vector<VM_D_VEC4> rows; rows.push_back(vec4(near/right,0,0,0)); rows.push_back(vec4(0,(far+near)/(far-near),0,-2*far*near/(far-near))); rows.push_back(vec4(0,0,near/top,0)); rows.push_back(vec4(0,1 ,0,0)); VM_D_MAT4 projMat = mat4(rows); #ifdef DEBUG std::cout << "[DEBUG] "<<"ProjMat:" << std::endl; for(const auto & row : projMat.rows) { std::cout << "[DEBUG] "<< row.to_string() << std::endl; } #endif int res = 0; for(int i=0;i<tgtNum;i++) { VM_D_VEC4 tgtVec = vec4(tgtXYZs[i],1.0); VM_D_VEC4 viewVec = Mul(viewMat,tgtVec); VM_D_MAT4 rollMat = GetRollMatrix(camHpr.r); viewVec = Mul(rollMat,viewVec); VM_D_VEC4 projVec = Mul(projMat,viewVec); VM_D_VEC4 NDCVec = vec4(projVec.x/projVec.w,projVec.y/projVec.w,projVec.z/projVec.w,1.0); VM_D_VEC4 UVVec = vec4((NDCVec.x+1)/2, (NDCVec.y+1)/2, (NDCVec.z+1)/2, 1.0); tgtUVDs[i].u = UVVec.x; tgtUVDs[i].v = UVVec.z; tgtUVDs[i].depth = viewVec.y; #ifdef DEBUG std::cout << "[DEBUG] "<< "------Transform Info: Point "<<i<<" ------" << std::endl; std::cout<< "[DEBUG] "<<"Target:"<<tgtXYZs[i].x<<","<<tgtXYZs[i].y<<","<<tgtXYZs[i].z<<std::endl; std::cout<< "[DEBUG] "<<"NDC Y component:"<<NDCVec.y<<std::endl; std::cout<< "[DEBUG] "<<"View Pos:"<<viewVec.x<<","<<viewVec.y<<","<<viewVec.z<<","<<viewVec.w<<std::endl; std::cout<< "[DEBUG] "<<"Projection Pos:"<<projVec.x<<","<<projVec.y<<","<<projVec.z<<","<<projVec.w<<std::endl; #endif #ifdef UVD std::cout<< "[UVDOutput] "<<"UV:"<<UVVec.x<<","<<UVVec.z<<std::endl; #endif if(UVVec.x<0 || UVVec.x>1 || UVVec.z<0 || UVVec.z>1 || tgtUVDs[i].depth<=0) { tgtUVDs[i].depth = -100.0; #ifdef DEBUG std::cout<< "[DEBUG] "<<"Out of View."<<std::endl; #endif } else { #ifdef DEBUG std::cout<< "[DEBUG] "<<"In View."<<std::endl; #endif } #ifdef UVD std::cout<< "[UVDOutput] "<<"Depth:"<<tgtUVDs[i].depth<<std::endl; #endif res++; } return res; }
VM_D_MAT4 LookAt(VM_D_XYZ cameraPos, VM_D_XYZ targetPos, VM_D_XYZ worldUp) { VM_D_XYZ cameraDir = minus(targetPos, cameraPos); cameraDir.normalize(); VM_D_XYZ cameraRight = cross(cameraDir,worldUp); cameraRight.normalize(); VM_D_XYZ cameraUp = cross( cameraRight,cameraDir); cameraUp.normalize(); VM_D_MAT4 mat1 = mat4(1.0); mat1.rows[0] = vec4(cameraRight.x, cameraRight.y, cameraRight.z, 0.0); mat1.rows[1] = vec4(cameraDir.x, cameraDir.y, cameraDir.z, 0.0); mat1.rows[2] = vec4(cameraUp.x, cameraUp.y, cameraUp.z, 0.0); mat1.rows[3] = vec4(0.0, 0.0, 0.0, 1.0); VM_D_MAT4 mat2 = mat4(1.0); mat2.rows[0].w = -cameraPos.x; mat2.rows[1].w = -cameraPos.y; mat2.rows[2].w = -cameraPos.z; mat2.rows[3].w = 1.0f; #ifdef DEBUG std::cout<<"[DEBUG] "<<"CameraRight:"<<cameraRight.x<<","<<cameraRight.y<<","<<cameraRight.z<<std::endl; std::cout<<"[DEBUG] "<<"CameraDir:"<<cameraDir.x<<","<<cameraDir.y<<","<<cameraDir.z<<std::endl; std::cout<<"[DEBUG] "<<"CameraUp:"<<cameraUp.x<<","<<cameraUp.y<<","<<cameraUp.z<<std::endl; #endif return Mul(mat1, mat2); }
VM_D_XYZ GetDirectionFromHPR(VM_D_HPR hpr) { VM_D_XYZ res; res.x = cos(hpr.h) * cos(hpr.p); res.z = sin(hpr.p); res.y = -sin(hpr.h) * cos(hpr.p); #ifdef DEBUG std::cout<<"[DEBUG] "<<"DirectionFromHPR:"<<res.x<<","<<res.y<<","<<res.z<<std::endl; #endif return res; }
VM_D_MAT4 GetRollMatrix(double roll) { VM_D_MAT4 res = mat4(1.0); res.rows[0].x = cos(roll); res.rows[0].z = sin(roll); res.rows[2].x = -sin(roll); res.rows[2].z = cos(roll); #ifdef DEBUG std::cout<<"[DEBUG] "<<"RollMatrix:"<<std::endl; for(const auto & row : res.rows) { std::cout<<"[DEBUG] "<<row.to_string()<<std::endl; } #endif return res; }
|
- 教程所给的投影矩阵,默认正y轴为世界空间上向量,并且深度越大,z轴越小。
- LookAt矩阵中,相机的朝向、上轴和右轴指的是相机本身的坐标系。例如,相机默认朝向正Y轴时,矩阵的第二行就应当是相机朝向向量的三个分量。