@ltlovezh
2020-04-06T11:33:06.000000Z
字数 15922
阅读 5443
OpenGL
众所周知,OpenGL是一个3D图形库,在终端设备上广泛使用。但是我们的显示设备都是2D平面,那么OpenGL怎么把3D图形映射到2D屏幕那?这就是OpenGL坐标变换所要完成的工作。
一般情况下,我们总是通过一个2D屏幕,观察3D世界。因此,我们实际看到的是3D世界在2D屏幕上的一个投影。通过OpenGL坐标变换,我们可以在一个给定的观察视角下,把3D物体投影到2D屏幕上,再经过后面的光栅化和片元着色,整个3D物体就映射成了2D屏幕上的像素。
OpenGL的坐标变换流程如下所示:
第一行和第二行的模型变换、视变换和投影变换是顶点着色器负责完成的,它决定了一个图元在3D空间中的位置。
第三行的透视除法和视口变换是图元装配阶段完成的,它决定了一个图元在屏幕上的位置。
我们先简单看下整个流程:
1. 首先,输入顶点一般是以本地坐标表示的3D模型。本地坐标是为了研究孤立的3D模型,坐标原点一般都是模型的中心。每个3D模型都有自己的本地坐标系(Local Coordinate),互相之间没有关联。
2. 当我们需要同时渲染多个3D物体时,需要把不同的3D模型,变换到一个统一的坐标系,这就是世界坐标系(World Coordinate)。把物体从本地坐标系变换到世界坐标系,是通过一个Model
矩阵完成的。模型矩阵可以实现多种变换:平移(translation)、缩放(scale)、旋转(rotation)、镜像(reflection)、错切(shear)等。例如:通过平移操作,我们可以在世界坐标系的不同位置绘制同一个3D模型;
3. 世界坐标系中的多个物体共同构成了一个3D场景。从不同的角度观察这个3D场景,我们可以看到不同的屏幕投影。OpenGL提出了摄像头坐标系的概念,即从摄像头位置来观察整个3D场景。把物体从世界坐标系变换到摄像头坐标系,是通过一个View
矩阵完成的。视图矩阵定义了摄像头的位置、方向向量和上向量等构成摄像头坐标系的基础信息。View
矩阵左乘世界坐标系中顶点A的坐标,就把顶点A变换到了摄像头坐标系。同一个3D物体,在世界坐标系中,拥有一个世界坐标;在摄像头坐标系中,拥有一个摄像头坐标,View
变换就是负责把物体的坐标从世界坐标系变换到摄像头坐标系。
4. 因为我们是从一个2D屏幕观察3D场景,而屏幕本身不是无限大的。所以当从摄像头的角度观察3D场景时,可能无法看到整个场景,这时候就需要把看不到的场景裁减掉。投影变换就是负责裁剪工作,投影矩阵指定了一个视见体(View Frustum),在视见体内部的物体会出现在投影平面上,而在视见体之外的物体会被裁减掉。投影包括很多类型,OpenGL中主要考虑透视投影(Perspective Projection)和正交投影(Orthographic Projection),两者的区别在后面会详细介绍。除此之外,通过Projection
矩阵,可以把物体从摄像头坐标系变换到裁剪坐标系。在裁剪坐标下,X、Y、Z各个坐标轴上会指定一个可见范围,超过可见范围的顶点(vertex)都会被裁剪掉。
5. 每个裁剪坐标系指定的可见范围可能是不同的,为了得到一个统一的坐标系,需要对裁剪坐标进行透视除法(Perspective Division),得到NDC坐标(Normalized Device Coordinates - 标准化设备坐标系)。透视除法就是将裁剪坐标除以齐次分量W
,得到NDC坐标:
glViewport
指定绘制区域的坐标和宽高,系统会帮我们自动完成视口变换。经过视口变换,我们就得到了2D屏幕上的屏幕坐标。需要注意的是:屏幕坐标与屏幕的像素位置是不一样的,屏幕坐标是屏幕上任意一个顶点的精确位置,可以是任意小数。但是像素位置只能是整数(具体的某个像素)。这里的视口变换是从NDC坐标变换到屏幕坐标,还没有生成最终的像素位置。从屏幕坐标映射到对应的像素位置,是后面光栅化完成的。在OpenGL中,本地坐标系、世界坐标系和摄像头坐标系都属于右手坐标系,而最终的裁剪坐标系和标准化设备坐标系属于左手坐标系。
左右手坐标系的示意图如下所示,其中大拇指、食指、其余手指分别指向x,y,z轴的正方向。
下面我们分别来看下模型变换、视图变换、投影变换和视口变换的推导和使用。
模型变换通过对3D模型执行平移、缩放、旋转、镜像、错切等操作,来调整模型在世界坐标系中的位置。模型变换是通过模型矩阵来完成的,我们看下每种模型矩阵的推导过程。
平移就是将一个顶点A = (x,y,z),移动到另一个位置 =(,,),移动距离D = - A = ( - x , - y , - z) = ( , , ),所以可以用顶点A来表示:
glm::mat4 model; // 定义单位矩阵
model = glm::translate(model, glm::vec3(1.0f, 1.0f, 1.0f));
上述代码定义了平移模型矩阵,表示在X、Y、Z轴上同时位移1。
可以在X、Y和Z轴上对物体进行缩放,3个坐标轴相互独立。对于以原点为中心的缩放,假设顶点A(x,y,z)在X、Y和Z轴上分别放大、、倍,那么可以得到放大后的顶点 =( * x , * y , * z),通过缩放矩阵来表示如下所示:
glm::mat4 model; // 定义单位矩阵
model = glm::scale(model, glm::vec3(2.0f, 0.5f, 1.0f);
上述代码定义了缩放模型矩阵,表示在X轴上放大2倍,Y轴上缩小0.5倍、Z轴上保持不变。
在3D空间中,旋转需要定义一个旋转轴和一个角度。物体会沿着给定的旋转轴旋转指定角度。
我们首先看下,沿着Z轴旋转的旋转矩阵是怎样的?
假设有一个顶点P,原始坐标为 ( , , ),离原点的距离是,沿着Z轴顺时针旋转度,新的坐标为( , , ),因为旋转前后,z坐标不变,所以暂时忽略,那么可以得到:
在OpenGL中,我们可以通过GLM库来实现旋转变换:
glm::mat4 model; // 定义单位矩阵
model = glm::rotate(model, glm::radians(-45.0f), glm::vec3(0.4f, 0.6f, 0.8f));
上述代码表示:围绕向量(0.4f, 0.6f, 0.8f),顺时针旋转45度。
在进行旋转操作时,经常有一个困惑:顺时针是正方向,还是逆时针是正方向?
其实,存在一个左手规则和右手规则,可以用于判断物体绕轴旋转时的正方向。
在OpenGl中,我们使用右手规则,大拇指指向旋转轴的正方向,其余手指的弯曲方向即为旋转正方向。所以上面的-45度是顺时针旋转。
因为矩阵不满足交换律,所以平移、旋转和缩放的顺序十分重要,
一般是先缩放、再旋转、最后平移。当然最终还是要考虑实际情况。
还有一点需要注意,GLM操作矩阵的顺序和实际效果是相反的。如下所示,虽然书写顺序是:平移、旋转和缩放,但是实际最终的模型矩阵是:先缩放、再旋转、最后平移。
glm::mat4 model; // 定义单位矩阵
model = glm::translate(model, glm::vec3(1.0f, 1.0f, 1.0f));
model = glm::rotate(model, glm::radians(-45.0f), glm::vec3(0.4f, 0.6f, 0.8f));
model = glm::scale(model, glm::vec3(2.0f, 0.5f, 1.0f);
经过模型变换,都有的坐标都处于世界坐标系中,本节就是以摄像头的角度观察整个世界空间。首先需要定义一个摄像头坐标系。
一般情况下,定义一个坐标系需要以下参数:
1. 指定坐标系的维度:2D、3D、4D等。
2. 定义坐标空间的轴向量,例如:X轴、Y轴、Z轴,这些向量称为基向量
,基向量一般都是正交的。坐标系中的所有顶点都是通过基向量表示的。
3. 坐标系的原点O,原点是坐标系中所有其他点的参考点。
简单来说,坐标系=(基向量,原点O)
同一个顶点,在不同的坐标系中拥有不同的坐标,那怎么才能把世界坐标系中的顶点坐标,变换到摄像头坐标系那?
要实现不同坐标系之间的坐标转换,需要计算一个变换矩阵。这个矩阵就是坐标系A中的原点和基向量在另一个坐标系B下的坐标表示。假设存在A坐标系和B坐标系以及顶点V,那么顶点V在A和B坐标系下的坐标变换公式如下所示:
简单解释一下:
顶点V在A坐标系的坐标 = B坐标系的基向量和原点在A坐标系下的坐标表示构成的变换矩阵 * 顶点V在B坐标系的坐标;
顶点V在B坐标系的坐标 = A坐标系的基向量和原点在B坐标系下的坐标表示构成的变换矩阵 * 顶点V在A坐标系的坐标
其中,和互为逆矩阵。所以坐标系之间的切换,关键就是求出坐标系之间互相表示的变换矩阵。那么矩阵应该怎么计算那?假设坐标系A的三个基向量和原点在B坐标空间的单位坐标向量分别是、、和,那么矩阵如下所示:
下面我们看下OpenGL的视图变换矩阵是怎么计算出来的?
现在存在两个坐标系:世界坐标系W
和摄像头坐标系E
,还有一个顶点V,并且知道顶点V在世界坐标系的坐标 = (,,),那么顶点V在摄像头坐标系下的坐标是多少那?根据上面的公式可知,我们首先需要计算出矩阵。
众所周知,世界坐标系的原点O = (0,0,0),三个基向量分别是,X轴:(1,0,0)、Y轴:(0,1,0)、Z轴:(0,0,1)。
理论上,定义一个摄像头坐标系,需要4个参数:
1. 摄像头在世界坐标系中的位置(摄像头坐标系的原点)
2. 摄像头的观察方向(摄像头坐标系的Z基向量)
3. 一个指向摄像头右侧的向量(摄像头坐标系的X基向量)
4. 一个指向摄像头上方的向量(摄像头坐标系的Y基向量)。
通过上述4个参数,我们实际上创建了一个三个单位轴相互垂直的,以摄像机位置为原点的坐标系。
在使用过程中,我们只需要指定3个参数:
1. 摄像机位置向量()
2. 摄像机指向的目标位置向量()
3. 指向摄像头上方的向量()
接下来是根据上面3个参数,推导出摄像头坐标系单位基向量的步骤:
1. 首先计算摄像头的方向向量(方向向量是摄像头坐标系的Z轴正方向,和实际的观察方向是相反的)。
这样,就确定了摄像头坐标系的三个单位基向量:、和以及摄像头的位置向量。这四个参数一起确定了摄像头坐标系:摄像头位置是坐标原点,单位右向量指向正X轴,单位上向量指向正Y轴,单位方向向量指向正Z轴。
现在我们已经定义了一个摄像头坐标系,下一步就是把世界坐标系中的顶点V = (,,),变换到这个摄像头坐标系。根据上文可知,顶点V在摄像头坐标系E
的坐标计算过程如下所示:
dot
函数表示向量的点积,是一个标量。最终,顶点V在摄像头坐标系下的坐标如下所示: 上面的矩阵就是View
变换矩阵。
下面看一个案例:假设摄像头的坐标是(0, 0, 3),摄像头的观察方向是世界坐标系的原点(0,0,0),上向量是(0,1,0),顶点V在世界坐标系的坐标为(1,1,0),那么可以计算出摄像头坐标系的基向量和原点如下所示:
1. =
2. =
3. =
4. =
所以对应的View
变换矩阵就是:
最后,顶点V在摄像头坐标系的坐标就是:
虽然上述流程很复杂,但在OpenGL中,我们可以通过GLM库定义View
矩阵。针对上述案例,通过lookAt
函数就可以得到View
矩阵。
glm::mat4 view;
view = glm::lookAt(glm::vec3(0.0f, 0.0f, 3.0f),glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
经过验证,通过lookAt
函数得到View
矩阵为:
很显然,通过lookAt
函数得到View
矩阵和上面我们推导的View
矩阵是一致的。
前面经过模型变换和视图变换后,3D模型已经处于摄像头坐标系中。本节的投影变换将物体从摄像头坐标系变换到裁剪坐标系,为下一步的视口变换做好准备。
投影变换通过指定视见体来决定场景中哪些物体可以呈现在屏幕上。在视见体中的物体会出现在投影平面上,而在视见体之外的物体不会出现在投影平面上。在OpenGL中,我们主要考虑透视投影和正交投影,两者的区别如下所示:
上图中,红色和黄色球在视见体内,因而呈现在投影平面上;绿色球在视见体外,所以没有投影到近平面上。除此之外,透视投影会根据物体的Z坐标,决定物体在投影平面的大小,原则是:远小近大,符合生活常识。而正交投影不考虑物体Z坐标,所有物体在投影平面上保持原来的大小。
不管透视投影,还是正交投影,都可以通过指定(GLdouble left, GLdouble right, GLdouble bottom, GLdouble top, GLdouble near, GLdouble far)6个参数来指定视见体。(left,bottom)指定了近裁剪面左下角的坐标,(right,top)指定了近裁剪面右上角的坐标,-near
表示近裁剪面,−far
表示远裁剪面。下面需要利用这6个参数,推导投影矩阵。
在摄像头坐标系下,摄像头指向-z轴,所以近裁剪面z=−near,远裁剪面z=−far。并且OpenGL是在近平面上成像的。
通过上述6个参数指定的透视投影变换如下所示:
通过上述6个参数指定的正交投影变换如下所示:
投影变换和透视除法后,摄像头坐标系中的顶点被映射到一个标准立方体中,即NDC坐标系。其中X轴上:[left,right]映射到[−1,1],Y轴上:[bottom,top]映射到[-1,1]中,Z轴上:[near,far]映射到[−1,1],下面的矩阵推导会利用这里的映射关系。下面我们分别看下两种投影矩阵的推导过程。
透视投影和透视除法的坐标映射如下所示:
上图中,摄像头坐标系是右手坐标系,NDC是左手坐标系,NDC坐标系的Z轴指向摄像头坐标系的-Z轴方向。
假设顶点V在摄像头坐标系的坐标 = ( , , , ),变换到裁剪坐标系的坐标 = ( , , , ),透视除法到NDC坐标系的坐标 = ( , , , )。我们的目标是计算出投影矩阵,使得:
接下来,我们根据、与NDC坐标的映射关系,推导出的前两行。
满足[left,right]映射到[-1,1],如下所示:
因为是线性映射关系,所以可以设置线性方程,求出系数K
和常量P
。
OK,继续看下的映射关系:满足[bottom,top]映射到[-1,1],如下所示:
同理,根据线性映射关系,可以得到如下公式:
除了可以通过(left,right,bottom,top,near,far)指定透视投影矩阵外,还可以通过函数glm::perspective
指定视角(Fov)、宽高比(Aspect)、近平面(Near)、远平面(Far)来生成透视投影矩阵,如下所示,指定了45度视角,近平面和远平面分别是0.1f和100.0f:
glm::mat4 proj = glm::perspective(glm::radians(45.0f), width/height, 0.1f, 100.0f);
观察视角的示意图如下所示:
通过视角指定的透视投影变换如下所示:
通过视角指定的透视投影矩阵的视见体是对称的:
由上图可知,近平面的宽和高如下所示:
相比于透视投影矩阵,正交投影矩阵要简单一些,如下所示:
因为正交投影不考虑远小近大的情况,所以正交投影矩阵的第4行始终为。
对于正交投影变换,投影到近平面的坐标( , ) = ( , ),因此可以直接利用与、与、与的线性映射关系,求出线性方程系数。X、Y、Z轴的映射关系如下所示:
映射关系 | 映射值 | 示意图 |
---|---|---|
与的映射关系 | [left , right] [-1 , 1] | |
与的映射关系 | [bottom , top] [-1 , 1] | |
与的映射关系 | [near , far] [-1 , 1] |
根据上述的映射关系,同时摄像头坐标系的 = 1,可以得到三个线性方程,如下所示:
经过投影变换和透视除法后,我们裁减掉了不可见物体,得到了NDC坐标。最后一步是把NDC坐标映射到屏幕坐标( , , )。如下所示:
在映射到屏幕坐标时,我们需要指定窗口的位置、宽高和深度。如下所示:
//指定窗口的位置和宽高
glViewport(GLint x , GLint y , GLsizei width , GLsizei height);
//指定窗口的深度
glDepthRangef(GLclampf near , GLclampf far);
那么可以NDC坐标和屏幕坐标的线性映射关系:
映射关系 | 映射值 |
---|---|
与的映射关系 | [-1 , 1] [x , x + width] |
与的映射关系 | [-1 , 1] [y , y + height] |
与的映射关系 | [-1 , 1] [near , far] |
因此,可以设置线性方程,求出系数K
和常量P
。
坐标分量 | 线性方程的系数K |
线性方程的常量P |
---|---|---|
X分量线性方程 | x + | |
Y分量线性方程 | y + | |
Z分量线性方程 |
通过上述各个坐标分量值,可以得到视口变换矩阵:
对于2D屏幕,
near
和far
一般为0。因此ViewPort
矩阵的第三行都是0。所以经过视口变换后,屏幕坐标的Z值都是0。
至此,OpenGL的整个坐标变换过程都介绍完了,关键还是要多实践、实践、实践!!!