大家好,我是程序员kenney,在我之前的一篇文章《OpenGL 3D渲染技术:坐标系及矩阵变换》中给大家初步讲解了一下模型矩阵(Model Matrix)、视图矩阵(View Matrix)、投影矩阵(Projection Matrix)的基本概念及在代码层面的用法,建议在阅读本文前先阅读一下这篇文章。
我之前在腾讯bugly技术专栏发过一篇文章《OpenGL矩阵变换的数学推导》,现在将这篇文章重新整理完善,给大家深入剖析一下这些矩阵背后的数学原理及推导过程,尝试打造一篇全网讲解OpenGL矩阵变换数学原理最详细最通俗易懂的文章。
本文将分析模型矩阵(Model Matrix)、视图矩阵(View Matrix)、投影矩阵(Projection Matrix),视口变换矩阵(Viewport Transform Matrix) 这4种矩阵,它们将影响一个最初始的坐标点到最后渲染出来究竟是什么位置,是矩阵变换最核心的内容。
前情回顾
我们先来回顾一下用矩阵变换和不用矩阵变换,在写shader的时候有什么区别。
不用矩阵变换:
#version 300 es
precision mediump float;
layout(location = 0) in vec4 a_position;
layout(location = 1) in vec2 a_textureCoordinate;
out vec2 v_textureCoordinate;
void main() {
v_textureCoordinate = a_textureCoordinate;
gl_Position = a_position;
}
使用矩阵变换:
#version 300 es
precision mediump float;
layout(location = 0) in vec4 a_position;
layout(location = 1) in vec2 a_textureCoordinate;
layout(location = 2) uniform mat4 u_mvp;
out vec2 v_textureCoordinate;
void main() {
v_textureCoordinate = a_textureCoordinate;
gl_Position = u_mvp * a_position;
}
我们能看到差别就在于是否用矩阵乘了顶点坐标a_position
,而传入的a_position
的值实际上也有语义上的差别,如果是不用矩阵变换,那相当于直接使用了NDC坐标系下的坐标,使用矩阵变换时则是世界坐标系下的坐标。
那么接下来就带大家来分析一下这个u_mvp
矩阵背后的数学原理,主要是四个矩阵:模型矩阵(Model Matrix)、视图矩阵(View Matrix)、投影矩阵(Projection Matrix),视口变换矩阵(View Transform Matrix),其中前三个是我们构造出来传进shader中的,而第四个是无需我们操心的,只需要设置对应的参数即可。
模型矩阵(Model Matrix)
模型矩阵的作用是将模型从自己局部的模型坐标系变换到OpenGL的世界坐标系,对应三种基本的变换:平移、缩放、旋转,这三个矩阵比较简单,不是本文讲解的重点,下面直接给出,如有疑问,可以给我留言。
- 平移变换:
- 缩放变换
- 旋转变换
- 绕x轴旋转
- 绕y轴旋转
- 绕z轴旋转
大家可以看到旋转变换有三个矩阵?为什么不写成一个,注意绕轴旋转的先后顺序不同,最终的结果可能是不一样的,因此有三个独立的矩阵,根据实际情况组合。另外,平移、缩放、旋转这三个矩阵的不同组合顺序也会有不同的结果,一般旋转是首先做的,因为此时模型还在原点,旋转起来比较容易理解,然后再做缩放,最后平移。
视图矩阵(View Matrix)
我们先来回顾一下生成视图矩阵的API:
Matrix.setLookAtM(
viewMatrix,
0,
cameraPositionX, cameraPositionY, cameraPositionZ,
lookAtX, lookAtY, lookAtZ,
cameraUpX, cameraUpY, cameraUpZ
)
它由Camera的位置、看向点的坐标、以及Camera的上方向向量确定,以下是Camera在世界坐标系下的示意图:
下面我们来看看怎样通过Camera的位置、看向点的坐标、以及Camera的上方向向量计算得到对应的视图矩阵,首先给Camera定一个NUV坐标系:
将Camera的坐标记为eye
,朝向的点坐标记为lookat
,上方向向量记为up
,那么:
N向量: eye - lookat
U向量:up X N并归一化
V向量:N X U并归一化
我们要把以某种姿态放在世界坐标系中的某个地方,这个放的过程就是对应Camera的旋转和平移,这里表示为TR
,其中T
表示平移变换矩阵,R
表示旋转变换矩阵。
我们虽然设置的是Camera,但最终动的是点坐标,因为Camera压根就不存在,是一个假想的东西。假设我们不动摄像机,动坐标点,那么对坐标点的变换就应该是对相机变换的逆变换T^-1R^-1
(就是对TR
整体求逆矩阵),注意,这里的T^-1R^-1
看起来貌不惊人,实际上就是我们要求的View Matrix。
根据前面的知识,我们能很容易得到T^-1
:
这个直观上也好理解,比如本来是平移Tx
,逆过来就是平移-Tx
,依此类推。
再回顾一下我们的目标T^-1R^-1
,现在还差R^-1
,现在再次回到我们假想的Camera,前面说要对它做TR
,当做完R
后,Camera会旋转至某个姿态:
这时两个坐标系的原点是重合的,XYZ
和UVN
都可以看成是一组基,根据线性代数公式可将一个点在XYZ
基下的坐标转成在UVN
基下的坐标,R就相当于是把基XYZ
变换成UVN
的变换矩阵,其中:
假设:
则有:
于是:
由于R
是正交矩阵,有性质:R^-1=R^T
(R^T
代表R的转置),为什么R
是正交矩阵?Tips:方阵A
正交的充要条件是A
的行(列) 向量组是单位正交向量组。
于是:
现在我们T^-1
和R^-1
都有了,T^-1R^-1
也就是最终的View Matrix可以很容易地计算出来了,因为OpenGL中坐标是4维的,所以这里将矩阵写成4*4的:
投影矩阵(Projection Matrix)
投影矩阵又分为正交投影矩阵和透视投影矩阵,正交投影没有近大远小的效果,透视投影则有。透视投影矩阵比较常用,我们先来看透视投影矩阵的计算。
先回顾一下生成透视投影矩阵的API:
Matrix.frustumM(
projectMatrix,
0,
nearPlaneLeft, nearPlaneRight, nearPlaneBottom, nearPlaneTop,
nearPlane,
farPlane
)
透视投影矩阵由近平面左、上、右、下坐标,近平面、远平面距离决定,下图是一个投影矩阵对应的视见体示意:
简单起见,我们不妨把Camera摆在原点,让它朝z
轴负方向来讨论问题。
h表示近平面高度
w表示近平面宽度
n表示Camera到近平面的距离
f表示Camera到远平面的距离
P代表视野中的一个点
那么接下来要求的透视投影矩阵,就是能将P点正确地投影到近平面上,设P(x0, y0, z0)
,我们从y轴正向往负向看,即看xoz平面,看到的画面是这样的:
假设投影后的x坐标为x1
,由三角形相似原理则易得:
同理有:
设l和r分别为近平面左、右边框的x坐标,则有l=-w/2,r=w/2
,投影归一化后坐标范围为-1~1
,最左边是-1
,最右边是1
,l
和r
归一化至-1~1
是线性变换,于是列一个kx+b
类型的方程组并解得k
和b
:
令xn
表示点P
的x
坐标投影归一化后的值,代入kx+b
得:
同理可得点P
的y
坐标投影归一化后的值yn
:
下面我们来构造带有未知数的透视投影矩阵然后求解它们,设待投影点为(x0,y0,z0,1)
,我们先来构造投影矩阵的第一第二行:
这里强调一个细节,透视投影矩阵仅帮我们完成投影变换,不会归一化,上面的x2、y2、z2
指的是透视投影后归一化前的值,还记得前面计算的xn
和yn
吗?我们用一个括号把其中一个部分括了起来,外面乘了一个因子(-1/z0)
,后面会说这个因子是什么东西,现在只需要知道,x2、y2
实际上就是前面括号里那堆东西,所以上面透视投影矩阵的第一行和第二行就自然能轻松地构造出来。
接下来就构造第三第四行,我们先看第四行,第四行计算的结果是透视投影后的第四维坐标,也就是w,前面提到了归一化,而OpenGL的归一化操作就是通过将坐标除以其对应的w值来完成的,再回头看我们前面计算的xn
和yn
,它们是归一化后的值。
还记得括号外面乘了一个因子(-1/z0)
吗?乘(-1/z0)
可以看成是除以-z0
,因此希望w
就是-z0
,于是构造第四行让w的计算结果为-z0
:
接下来就是最复杂的第三行,如何去构造第三行?第三行有4个值,现在都不知道是什么,我们需要构造4个未知数吗?对于解方程来说,在能解决问题的情况下,未知数能少就尽量少,不然只会徒增烦恼。
这里其实不需要4个未知数,为什么呢?那就要理解z2
这个值是什么东西,它就是投影之后未归一化的深度值,而深度和x0、y0
没有关系,这个如何理解?就是说我把一个东西放在左,上边,还是右边,不影响它的深度,要改变深度需要前后移动。
既然z2
和x0、y0
没有关系,那么x0、y0
不管是什么值,都不会影响z2
的值,因此用0去乘x0、y0
,即第三行的第一第二个元素是0。
再看第三行的第三第四个元素,我们假设第三个元素是0,会发生是什么?那么z2
就等于B,而B最后求出来放到矩阵中肯定是一个定值,这就意味着z2
也是定值,于是z2就无法表示不同的点的不同深度,这不是我们想要的结果,因此第三个元素不能是0,是一个待求的未知数。同理,我们假设第四个元素是0会发生什么?这样投影矩阵第四列全是0,根据线性代数的知识,这个矩阵行列式等0,它必定不可逆,而我们希望投影矩阵是可逆的,这样我们可以对坐标做一些逆变换来实现一些特殊的功能,因此第四个元素也不能是0,于是设它为一个未知数。
这样,我们就构造出了一个包含未知数A和B的透视投影矩阵:
下面就是求解A和B:
我们将z0
为-f
和-n
代进去,-f
就是远平面,-n
就是近平面,求归一化后的坐标,-f
最远,深度最深,归一化后是1,反之,-n
代进去后是-1,注意,深度是值越大越深,于是有:
可解得:
于是透视投影矩阵为:
然后再来看正交投影矩阵的计算,它比透视投影矩阵要简单很多,先看一下生成正交矩阵的API:
Matrix.orthoM(
orthoMatrix,
0,
nearPlaneLeft, nearPlaneRight, nearPlaneBottom, nearPlaneTop,
nearPlane,
farPlane
)
可以看到参数和透视投影矩阵是一样的,下面是一个正交投影矩阵的视见体:
假设有一个点P(x,y,x)
投影后的坐标为P'(x',y',z')
,在正交投影中,因为没有近大远小的效果,因此w=1
,即相当于正交投影之后的坐标就已经是归一化之后的坐标了,又因为投影下来是线性变换,所以有:
当x
正好在视见体右边时,投影下来是近平面的右边,所以将x=r,x'=1
带入上面的式子就可以求得b
,再整理一下得:
同理也可以求得:
整理成矩阵就是:
视口变换矩阵(View Port Transform Matrix)
视口变换是将坐标从NDC坐标系变换到屏幕坐标系,视口相关的API不像前面的那些那样得到一个变换矩阵来传入shader,而是直接通过视口的左下角和宽高指定视口的区域就可以了:
glViewport(vLeft, vBottom, vWidth, vHeight)
这里注意一下,不同设备的屏幕坐标系的y
轴朝向可能是不同的,下面以android为例,它的屏幕坐标系y
轴朝下,来看下面这张图:
假设屏幕宽高分别是sWidth
和sHeight
,NDC坐标系中有一个点P(x,y,z)
,变换到屏幕坐标系后为P'(x',y',z')
,在这里z
分量的含义就是深度了。这里是一个线性变换,(-1,-1)
映射到(vLeft,sHeight-vBottom)
,(1,1)
映射到(vLeft+vWidth,sHeight-(vBottom+vHeight))
,另外还可以通过glDepthRangef
设置深度的映射值,假设设置为近平面和近平面分别映射到ns
和fs
,由上面这些映射关系可以推出:
整理成矩阵就是:
总结
至此,我们就完成了模型矩阵(Model Matrix)、视图矩阵(View Matrix)、投影矩阵(Projection Matrix)和视口变换矩阵(Viewport Transform Matrix)的数学推导,可以看到,渲染时的各种大小变换、视角切换、投影的近大远小、不可见部分的裁剪等,全是数学上的计算所带来的结果,搞懂其中的原理对理解OpenGL渲染的核心技术非常有帮助。
谢谢阅读!如有疑问,欢迎在评论区交流~
欢迎关注我的github:www.github.com/kenneycode