首页 > 代码库 > 三维图像技术与OpenGL基础理论

三维图像技术与OpenGL基础理论

英文原文:3D Graphics with OpenGL Basic Theory

中文译文:三维图像技术与OpenGL基础理论

1. 计算机图像硬件


1.1 GPU(图像处理单元)

如今,计算机拥有用来专门做图像处理显示的GPU模块,拥有独立的图像处理储存(显存)。

1.2 像素和画面

任何图像显示都是基于栅格的格式。一个栅格既是一张二维的像素直角坐标网。像素具有两个属性:颜色和位置。颜色通常使用RGB(红绿蓝)来表示,典型的有用8位或者24位二进制位(真彩色)表示一种颜色。位置则用坐标(x,y)表示。原点(0,0)位于左上角,x轴指向右,y轴指向下。这与我们平常熟知的2D笛卡尔坐标不同,它的y轴是指向上的。表示颜色的二进制位数,称为颜色深度(颜色精度)。像素直角坐标网的行列数,称为显示分辨率,一般有640x480(VGA),800x600(SVGA),1024x768(XGA),1920x1080(FHD)或者更高。

1.3 帧缓存和刷新频率

所有的像素颜色都会保存在显存中,我们称之为帧缓存。GPU将颜色值写入帧缓存中。显示器则是从帧缓存中一行行读取颜色值,从左到右,从上到下,然后逐个像素显示到屏幕上。这就是传说中的光栅扫描。显示器每秒钟刷新屏幕几十次,典型的LCD显示器是60Hz,或者CRT显示器更高的频率。这就是刷新频率。一副完整的屏幕图像,我们称为一帧。

1.4 双缓冲与场同步

当显示器正在读取帧缓存来显示当前帧的时候,我们可能需要更新帧缓存为下一帧做准备(不是必须的)。这将导致画面撕裂问题,也即,显示器从帧缓存中,读取到一部分旧像素,和一部分新像素。因此,我们通过双缓冲(双缓存)来解决这个问题。GPU采用了两个帧缓存空间——前置缓存和后置缓存。显示器从前置缓存中读取,同时我们可以将下一帧像素写入后置缓存中。我们写完后,通知GPU切换前后缓存。(缓存交换、画面交换)。

但是,单单使用双缓存技术并不能最终解决问题。因为缓存交换有可能在不恰当的时刻发生。比如说,我们行缓存交换,但是此时显示器正在读取显示一张旧的帧,这个时候并不应该进行缓存交换。于是我们通过场同步(vertical synchronization, VSync)来处理这个问题。当我们通知GPU要进行缓存交换,GPU并不会立刻响应,而是等到显示完当前帧,要刷新下一次全屏的时候才进行交换。

最关键一点:如果采用了场同步缓存交换技术,我们刷新帧缓存的速度不可能比显示器刷新速度高!LCD/LED显示器的刷新频率一般是60HZ,或者60帧每秒,或者16.7毫秒每帧。另外,如果你的应用软件是固定频率刷新,那么最终的刷新频率可能是显示刷新频率的整数倍关系。比如1/2,1/3,1/4等等。

2. 三维图像渲染管道

管道是计算机技术的一个术语,是指一系列的处理阶段,一个状态的输出将作为下一个状态的输入。类似于工厂中的流水线,在大量的并行操作中,管道可以大大的提高整体的吞吐量。计算机图像技术中,渲染是从物体模型到显示图像的处理过程。3D图形渲染管道,允许我们采用图元(比如三角形,点,直线,四边形)的顶点来描述一个3D物体,然后最终生成用于显示的像素。

3D图像渲染管道包括以下几个主要步骤:

1,顶点处理:对模型的各个顶点进行计算(着色)以及坐标变换

2,光栅化:将每个图元转换为一系列的碎片。我们可以认为一个碎片就是三维空间中的一个像素,同样具有位置,颜色,法线和纹理属性。

3,碎片处理:光栅化之后,我们得到一些列的碎片,这个阶段则是对碎片进行处理(着色)

4,合并输出:将所有的碎片(3D概念中)转化成二维的颜色像素,最后输出显示到屏幕上

上述4个步骤中,顶点处理和碎片处理是可编程的。你可以编写顶点着色程序和碎片着色程序,来实现顶点和碎片的自定义运算处理。一般着色程序使用类似C语言的高级语言编写,比如GLSL(OpenGL Shading Language),HLSL(High-Level Shading Language for Miscrosoft Direct3D),或者Cg(C for Graphics by NVIDIA)。

此外,光栅化和合并输出两个步骤是不可编程的,但是可以配置GPU中处理这两个步骤的一些参数。

3,顶点/图元/碎片/像素

3.1 三维图像坐标系统

OpenGL 采用的是右手坐标系(Right-Hand Coordinate System(简称RHS))。 RHS中,x轴指向右,y轴指向上,z轴从屏幕里指向外。RHS中,如果让你的手指从x轴弯曲向y轴,那么大拇指将指向z轴。RHS是逆时针方向的(counter-clockwise(简称CCW))。3D笛卡尔坐标就是一种RHS。

一些图像处理软件(比如微软的Direct3D)使用的是左手坐标系(Left-hand System(简称LHS)),它们的z轴是反方向的。LHS是顺时针的(clockwise (CW))。在这篇文章中,我们采用的是RHS和CCW。

3.2 图元

图形渲染管道的输入量是几何图元(比如,三角形,点,直线或者四边形),它们都可以通过一个或者若干个几何顶点来描述。OpenGL支持三种类型的几何图元:点,线段和封闭的多边形。它们都是通过顶点来描述。每个顶点都有几个特定的属性,比如位置,颜色,法线和纹理。OpenGL提供了10种图元(如下图)。球体,三维盒子,角锥体都不是图元,它们都是可以通过图元组合生成。


3.3 顶点

再次提及一下,一个图元是由一个或者若干个顶点组成的。在计算机图像技术中,一个顶点具备以下几个属性:
5,其他属性

1, 位置:3D空间的坐标V=(x,y,z), 经常定义为浮点数

2, 颜色:由RGB(Red-Green-Blue)或者RGBA(Red-Green-Blue-Alpha)表示。这些颜色值的标准范围是0.0-1.0,其中Alpha值用来指定透明度,0代表完全透明,1代表不透明。

3, 法线:N=(nx,ny,nz)。平面法线的概念我们比较熟悉:法线是正交垂直于平面的线。然而,计算机图像技术中,我们同样需要为每个顶点也定义一个法线向量,称为顶点法线。法线可以用来区分平面的正背面,另外涉及到后面即将讲到的光照效果。OpenGL采用右手定则(逆时针定则),法线是从“里面”指向“外面”的,这样便指明了“外面”(前面)和“里面”(背面)。

4, 纹理:T=(s,t)。计算机图像技术中,我们经常将物体的表面贴上二维图像,让他们看起来更加真实。每个顶点对应一个二维的纹理坐标(s,t),为二维纹理图形提供了一个参考点。

3.3.1 OpenGL 图元和顶点

接下来将举一个例子,以下的代码片段声明了一个以原点为中心的立方体。我们使用glBegin()和glEnd()函数来装入代表物体模型的所有顶点。使用的图元类型作为输入参数,以S结尾(比如 GL_QUADS)的图元类型表示我们可以多次重复装入同种类型的图形顶点。



立方体的6个面都是一个四边形。我们首先使用glColor3f(red,green,blue)设置颜色值。这个颜色值将会应用于接下来定义的顶点,直到我们重新设置颜色为止。四边形的4个顶点坐标我们使用glVertex3f(x,y,z)来定义,注意我们采用的是逆时针的顺序进行定义。如图所示,平面的法线指向外面,表明了平面的正背面。同时,4个顶点将平面的法线作为他们的顶点法线。

glBegin(GL_QUADS); // of the color cube
 
   // Top-face
   glColor3f(0.0f, 1.0f, 0.0f); // green
   glVertex3f(1.0f, 1.0f, -1.0f);
   glVertex3f(-1.0f, 1.0f, -1.0f);
   glVertex3f(-1.0f, 1.0f, 1.0f);
   glVertex3f(1.0f, 1.0f, 1.0f);
 
   // Bottom-face
   glColor3f(1.0f, 0.5f, 0.0f); // orange
   glVertex3f(1.0f, -1.0f, 1.0f);
   glVertex3f(-1.0f, -1.0f, 1.0f);
   glVertex3f(-1.0f, -1.0f, -1.0f);
   glVertex3f(1.0f, -1.0f, -1.0f);
 
   // Front-face
   glColor3f(1.0f, 0.0f, 0.0f); // red
   glVertex3f(1.0f, 1.0f, 1.0f);
   glVertex3f(-1.0f, 1.0f, 1.0f);
   glVertex3f(-1.0f, -1.0f, 1.0f);
   glVertex3f(1.0f, -1.0f, 1.0f);
 
   // Back-face
   glColor3f(1.0f, 1.0f, 0.0f); // yellow
   glVertex3f(1.0f, -1.0f, -1.0f);
   glVertex3f(-1.0f, -1.0f, -1.0f);
   glVertex3f(-1.0f, 1.0f, -1.0f);
   glVertex3f(1.0f, 1.0f, -1.0f);
 
   // Left-face
   glColor3f(0.0f, 0.0f, 1.0f); // blue
   glVertex3f(-1.0f, 1.0f, 1.0f);
   glVertex3f(-1.0f, 1.0f, -1.0f);
   glVertex3f(-1.0f, -1.0f, -1.0f);
   glVertex3f(-1.0f, -1.0f, 1.0f);
 
   // Right-face
   glColor3f(1.0f, 0.0f, 1.0f); // magenta
   glVertex3f(1.0f, 1.0f, -1.0f);
   glVertex3f(1.0f, 1.0f, 1.0f);
   glVertex3f(1.0f, -1.0f, 1.0f);
   glVertex3f(1.0f, -1.0f, -1.0f);
 
glEnd(); // of the color cube


3.3.2 定义顶点的索引

我们发现,图元之间经常会共用一些顶点。所以,与其我们一直重复性地定义图元顶点,还不如我们先来定义出所有顶点,然后使用顶点的索引数组来代表一个图元,这样显得更加有效率。

比如说,以下的代码片段将定义一个金字塔(角锥体),它具有5个顶点。我们首先使用一个数组定义5个顶点的坐标以及颜色值。接着,为了表示角锥体的5个面,我们只需要提供一串顶点的索引(编号)以及颜色索引。

float[] vertices = { // 5 vertices of the pyramid in (x,y,z)
      -1.0f, -1.0f, -1.0f,  // 0. left-bottom-back
       1.0f, -1.0f, -1.0f,  // 1. right-bottom-back
       1.0f, -1.0f,  1.0f,  // 2. right-bottom-front
      -1.0f, -1.0f,  1.0f,  // 3. left-bottom-front
       0.0f,  1.0f,  0.0f   // 4. top
};
          
float[] colors = {  // Colors of the 5 vertices in RGBA
      0.0f, 0.0f, 1.0f, 1.0f,  // 0. blue
      0.0f, 1.0f, 0.0f, 1.0f,  // 1. green
      0.0f, 0.0f, 1.0f, 1.0f,  // 2. blue
      0.0f, 1.0f, 0.0f, 1.0f,  // 3. green
      1.0f, 0.0f, 0.0f, 1.0f   // 4. red
};
  
byte[] indices = { // Vertex indices of the 4 Triangles
      2, 4, 3,   // front face (CCW)
      1, 4, 2,   // right face
      0, 4, 1,   // back face
      4, 0, 3    // left face
};
 
// Transfer the arrays to vertex-buffer, color-buffer and index-buffer.
// Draw the primitives (triangle) from the index buffer

3.4 像素与碎片

像素指的是显示器上的点。而二维像素网格的行和列与显示器的分辨率是相对应的。像素属于二维空间中的概念,它具有(x,y)坐标和RGB颜色值(像素本身没有alpha值)。图像渲染管道,就是在给定图元的情况下,生成所有像素的颜色值然后显示到屏幕中。


为了生成这种网格排布的像素,图像渲染管道中的光栅化程序,会将每个输入的图元进行光栅扫描,然后生成一系列按照网格排布的碎片。这里,碎片是属于三维空间中的概念,具有(x,y,z)坐标。其中(x,y)与二维的像素网格对齐。z坐标值则是代表它的深度。我们需要用z坐标值来获得各种图元之间的相对深度,合并输出显示的阶段中,我们通过对比Z值,选择性忽略掉“被挡在其他物体后面”的物体(如果是半透明的情况,我们会进行混合计算alpha通道)。

碎片是通过顶点间像素插入生成的。所以,碎片也同样具有顶点的属性,比如颜色,碎片法线和纹理坐标。

如今的GPU,都支持对顶点处理与碎片处理阶段进行自定义编程(可编程)。这种程序分别称为顶点着色器和碎片着色器。

Direct3D中采用“像素”术语表达一个三维空间概念的碎片。

4. 顶点处理

4.1 坐标变换

计算机图像处理技术中,顶点坐标变换就是为了实现在显示器中表现出三维的场景,就像我们使用相机拍照一样。

总共需要完成4个过程的变换:

1,在世界坐标系中摆放和排布好物体模型(模型变换/世界变换)

2,相机位置和方向调节(视图变换)

3,相机镜头选择(广角镜头,普通镜头或者长焦镜头),调节焦距以及缩放因数来得到一个合适的视图(投影变换)

4,在指定的纸上打印照片(视口变换)- 光栅化阶段完成


每个变换,都是将顶点V从一个空间(坐标系)转换到另外一个空间(坐标系)V‘中。计算机图像技术中,我们通过将向量与一个变换矩阵相乘来实现坐标变换。

4.2 模型变换(本地变换,世界变换)


三维场景中的物体通常都是在其本身的坐标系中声明定义的,也既是基于模型空间定义。在我们组合形成三维物体的时候,我们需要将基于模型空间描述的顶点变换到世界坐标系中。这种变换称为模型变换。模型变换中包含一系列的伸缩,旋转和平移变换。

旋转和伸缩属于线性变换。线性变换和平移共同构成所谓的仿射变换(affine transformation)。仿射变换过程中,直线仍然是直线,点与点之间的距离比例保持不变。

OpenGL中,一个顶点V(x,y,z)用一个3x1的列向量表示。


其他一些系统,比如Direct3D,则是使用行向量来表示一个顶点。

4.2.1 伸缩变换

三维伸缩变换可以使用以下3x3变换矩阵来表示:


其中,ax, ay, az 分别代表x,y,z方向的伸缩因数。如果所有因数相等,我们就称为均匀伸缩。

我们可以得到顶点V变换之后的结果V‘ 如下所示:

4.2.2 旋转变换

三维旋转变换是基于某个旋转轴进行(二维旋转是基于旋转中心点)。三维空间中,基于x,y,z轴旋转角度θ(逆时针为正方向),可以用如下的3x3矩阵表示:

围绕x,y,z轴的旋转角度使用欧拉角表示,欧拉角可以用来指定物体的任意方向,与之相关的变换也称为欧拉变换。

4.2.3 平移变换

平移并不属于线性变换。但是可以用一个向量加法来模拟。


幸运的是,我们可以使用一个4x4的矩阵(齐次坐标矩阵),然后通过矩阵相乘来得到平移变换结果。这里采用的是四阶齐次坐标(x,y,z,1)来表示顶点,其中包含第四行元素w,它的值为1。(后面会讲到w元素在投影变换中的作用。正常我们认为,如果w值不等于1,那么(x,y,z,w)对应于笛卡尔坐标的(x/w, y/w, z/w);如果w值为0 ,那么它代表一个向量,而不是一个顶点)。

采用四阶齐次坐标,平移变换可以由以下的4x4矩阵表示:


其中,变换矩阵可以通过矩阵乘法求得,结果是:

4.2.4 仿射变换小结

我们重新用4x4齐次矩阵来表达伸缩变换和旋转变换。


4.2.5 逐次变换

对顶点V进行的一系列的逐次仿射变换操作,其实可以串在一起相乘。V‘ = ...T3T2T1V。而且这些变换矩阵,在作用到顶点之前可以先进行合并,因为矩阵乘法可以合并。

4.2.6 例子

[TODO]

4.2.7 顶点法线的变换

再次说明以下,顶点除了有(x,y,z)坐标和颜色外,还有一个顶点法线。假设法线的变换矩阵为M,那么它只能用于没有 非均匀伸缩变换的顶点中。否则,法线将不再与平面正交。针对非均匀-伸缩变换,我们可以使用M-1T来作为变换矩阵,保证变换后的法线仍然是正交垂直于平面。

[TODO] 图表与其他

[TODO] 验证

4.3 视图变换

世界变换后,所有物体都基于世界坐标系。接下来我们拿出我们的相机来拍照。




4.3.1 相机摆放

在三维图像中,我们通过指定三个视图参数来表示相机的位置:眼睛,面向,上方方向。(世界坐标中)

1,眼睛(EYE):相机的位置(观测点)

2,面向(AT aiming at):表示相机的朝向。正常情况下,相机会朝向坐标的中心,或者被观测物体的中心

3,上方方向(UP):相机认为的朝向上的方向。正常就是y轴的正方向。AT和UP两个方向基本都是垂直的,但不是一定的。因为UP和AT共同定义了一个平面,于是我们可以在相机的坐标空间中构造出一个垂直于AT的向量。

需要注意的是,这9个坐标值实际上只定义了相机的6个空间自由度,其中有3个值并不是自由的。

4.3.2 OpenGL

在OpenGL中,我们可以使用GLU静态方法gluLookAt()来定义相机的位置:

void gluLookAt(GLdouble xEye, GLdouble yEye, GLdouble zEye, 
               GLdouble xAt, GLdouble yAt, GLdouble zAt,
               GLdouble xUp, GLdouble yUp, GLdouble zUp)
这个方法的默认设置是:
gluLookAt(0.0, 0.0, 0.0, 0.0, 0.0, -100.0, 0.0, 1.0, 0.0)
也既是说,默认情况下,相机在原点(0,0,0)的位置上,指向屏幕里面(z轴负方向),并以y轴正方向为上方。


4.3.3 相机坐标计算

插图来自:http://blog.csdn.net/hiramtan/article/details/9020419

按照Eye,AT和Up,我们来让相机坐标变换为世界坐标。相机坐标系中的Z轴与AT方向相反,既是说,AT指向Z轴负方向。AT与UP的向量乘积则可以得到x轴的方向。最后,X轴与Z轴的向量乘积可得到Y轴方向。再次注意一下,UP是大概的,并不需要一定要与AT垂直。


4.3.4 从世界坐标到相机坐标的变换

译者:定义相机的位置,实际上,是将模型按照相机移动的相反方向做变换,从而完成了从世界坐标到相机坐标的变换过程。

世界坐标使用一组正交坐标基来表示(e1,e2,e3),其中e1=(1,0,0),e2=(0,1,0),e3=(0,0,1), 以及一个原点(0,0,0)。而相机坐标空间也同样有正交坐标基(xc,yc,zc),以及一个观测点坐标(ex,ey,ez)。

在相机空间中表示所有的坐标非常方便。视图变换就是要来完成这件事情。视图变换包括两个操作:平移变换(将观测点移动到原点),接着是旋转变换。

4.3.5 视图变换矩阵

我们可以将以上两个变换合并成一个变换矩阵。

4.3.6 模型-视图 变换

计算机图像技术中,基于固定相机位置移动被测物体(模型变换),或者基于固定物体位置移动相机(视图变换),得到效果是一样的,因此它们实际是等效的。因此OpenGL采用同一种方式进行模型变换和视图变换,既是模型-视图变换。投影变换则是使用投影矩阵。

4.4 投影变换 - 透视投影

一旦相机位置以及方向确定下来后,我们需要确认相机的摄像范围(类似于调节焦距以及缩放),以及物体怎样被投射到相机屏幕中。我们需要选择一种投影模式(透视投影或者正射投影),并且指定一个视窗或者三维裁剪体。超出三维裁剪体的物体将不可见。

4.4.1 透视图中的视见体(View Frustum)

相机只能拍到有限的视图,既是平头角锥体中陈列的视图,平头角锥体由4个参数指定:fovy,aspect,zNear,zFar。

1, Fovy, 指定整个竖直方向的视角

2, Aspect, 宽高比值。

3, 近平面

4, 远平面

为了方便,以下相机空间坐标表示为(x,y,z)

插图来自:http://blog.csdn.net/hiramtan/article/details/9020419

采用平头角锥体的投影方式,就是透视投影。透视投影中,物体越接近投影面将显得越大。

如果物体超出了平头角锥体的范围,那么将不能被相机拍摄到。为了提高性能,超出的部分就应当被忽略掉。这就是传说中的平头角锥体裁剪。如果物体部分超出平头角锥体(视见体),那么它将在最后一个步骤中被裁剪。

4.4.2 OpenGL

OpenGL提供了两个函数用来选择投影模式以及设置裁剪参数:

1,常用的GLU函数 gluPerspective()

void gluPerspective(GLdouble fovy, GLdouble aspectRatio, GLdouble zNear, GLdouble zFar)
       // fovy is the angle between the bottom and top of the projectors;
       // aspectRatio is the ratio of width and height of the front (and also back) clipping plane;
       // zNear and zFar specify the front and back clipping planes.
2, GL 核心函数 glFrustum

void glFrustum(GLdouble xLeft, GLdouble xRight, GLdouble yBottom, GLdouble yTop, GLdouble zNear, GLdouble zFar)
       // xLeft, xRight, yBottom and yTop specifies the front clipping plane.
       // zNear and zFar specify the positions of the front and back clipping planes.

4.4.3 立方形裁剪体

如图所示,接下来我们需要使用透视投影矩阵对视锥体进行变换,得到一个轴对齐的,以近平面为中心的,2x2x1的立方形裁剪体。近平面的Z坐标为0,远平面Z坐标为-1。近平面和远平面都是2x2的面积,坐标范围是从-1到1。


4.4.4 透视投影矩阵

投影矩阵如下:


请注意矩阵的最后一行不再是(0,0,0,1)。将顶点(x,y,z,1)进行坐标变换后,w元素的结果不再是1。此时我们需要将齐次坐标(x,y,z,w)正常化为(x/w,y/w,z/w,1),第四行的值为1。很神奇,使用齐次坐标我们实现了平移变换,也实现了透视投影变换。

最后一步,便是对Z轴做翻转,得到的结果是,近平面的Z值为0,但是远平面的Z值翻转后变成1(而不是-1)。也既是说,如果Z值越大,将代表物体离得越远。我们可以很简单地将投影变换矩阵的第三行设置为负数,来实现Z轴翻转。


翻转后,我们的坐标系从右手坐标系变成左手坐标系。

4.4.5 OpenGL中的模型视图矩阵和投影矩阵

OpenGL通过两个矩阵来处理坐标变换:模型视图矩阵(GL_MODELVIEW控)和投影矩阵(GL_PROJECTION)。这两个矩阵都可以单独进行操作。

我们首先需要选择即将进行操作的对象矩阵:

void glMatrixMode(GLenum matrix)    // Select matrix for manipulating, e.g., GL_PROJECTION, GL_MODELVIEW.
然后使用以下方法来重置对象矩阵:

void glLoadIdentity()
另外,可以使用以下方法,将当前矩阵保存到栈中,以及从栈中恢复当前矩阵:

void glPushMatrix()
void glPopMatrix()
Push/Pop 操作使用的是栈数据结构,所以该操作可以嵌套进行。

4.5 投影变换 - 正射投影

除了通常使用的透视投影模式,还有正射投影(平行投影),我们把正射投影看做是一种特殊的透视投影。既是,当相机处于无限远处的时候(类似于望远镜)的透视投影。正射投影的视见体是一个平行六面体。(透视投影的视见体是一个平头角锥体)

4.5.1 OpenGL

我们可以使用glOrtho()函数将投影模式设置为正射投影,并且指定裁剪参数:

void glOrtho(GLdouble xLeft, GLdouble xRight, GLdouble yBottom, GLdouble yTop, GLdouble zNear, GLdouble zFar)
对于二维图形,可以使用gluOrtho2D()函数选择二维正射投影,并且指定裁剪区域:

void gluOrtho2D(GLdouble xLeft, GLdouble xRight, GLdouble yBottom, GLdouble yTop)

默认情况下,OpenGL的三维投影使用的是正射投影方式,参数是(-1.0, 1.0, -1.0, 1.0 -1.0, 1.0)。既是,一个以原点为中心的,边长为2的立方体。

4.6 顶点处理阶段的输出

所有的顶点以及它们的法线都已经进行了坐标变换并裁剪后放置到一个立方体中。x和y坐标(-1与1之间)代表它们在屏幕上的位置,z坐标代表它们的深度,既是与屏幕的距离。顶点处理阶段只是针对独个顶点,并没有考虑到顶点之间的关系。

5. 光栅化

上个阶段关于顶点的处理,一般都是采用浮点数来表示,但是在像素网格中并不需要使用浮点数。另外,顶点处理阶段也没有考虑到顶点之间的关系。光栅化的阶段,由顶点定义的图元(三角形,四边形,点和直线),经过光栅扫描后,生成一些列的碎片。碎片在三维空间中可以认为是像素,它们与像素网格对齐。二维概念的像素具有位置和颜色属性。三维概念的碎片,则是通过对顶点间像素插值得到的,具有与像素一样的属性:位置,颜色,法线和纹理。


光栅化又包括几个子阶段:视口变换,裁剪,透视除法,背面剔除,扫描转换。光栅化是不可编程的,但是可以通过指令进行配置。

5.1 视口变换

5.1.1 视口

视口既是软件窗口中的一个矩形显示区域,它基于屏幕上的坐标(像素为单位,原点位于左上角)。视口定义了显示区域的大小和形状,它将投影场面映射到应用软件界面上。它可以是占满全屏,也可以不是。

5.1.2 OpenGL

默认情况下,视口占满整个应用软件窗口。我们可以使用glViewport()函数设置一个相对小的区域。(分区屏幕或者多窗口应用)

void glViewport(GLint xTopLeft, GLint yTopLeft, GLsizei width, GLsizei height)
我们可以使用glDepthRange()设置Z值的范围

glDepthRange(GLint minZ, GLint maxZ)

5.1.3 视口变换

在我们最后的变换——视口变换,将裁减体(2x2x1立方体)映射到三维视口中,如图所示。


视口变换包括一系列的反射变换(基于Y轴),伸缩变换(x,y,z轴),平移变换(原点从裁剪体的近平面中心移动至三维视口的左上角)。视口变换的矩阵如下:


若视口覆盖了全屏,那么minX=minY=minZ=0, w=屏幕宽, h=屏幕高。

5.1.4 视口的高宽比和投影平面

非常明显,视口的高宽比(glViewport()设置)跟投影平面(gluPerspective(), glOrtho())不同,映射的时候,不能忽略掉它们的形状。所以,将视口和投影平面的高宽比设置为一样显得非常重要。

glViewport()函数需要在reshaped()函数里面调用,这样,在任何时候屏幕窗口发生变化都会被调用到。非常重要的一点,为了不产生失真,投影平面的高宽比需要重新配置使得与视口的高宽比一致。总的说,就是glViewport()和gluPerpective()/glOrtho()函数需要同时调用。

// Callback when the OpenGL's window is re-sized.
void reshape(GLsizei width, GLsizei height) {  // GLsizei for non-negative integer
   if (height == 0) height = 1;                        // To prevent divide by 0
   GLfloat aspect = (GLfloat)width / (GLfloat)height; // Compute aspect ratio
   
   // Set the viewport (display area on the window) to cover the whole application window
   glViewport(0, 0, width, height);
   
   // Adjust the aspect ratio of projection's clipping volume to match the viewport 
   glMatrixMode(GL_PROJECTION);   // Select Projection matrix
   glLoadIdentity();              // Reset the Projection matrix
   
   // Either "perspective projection" or "orthographic projection", NOT both
   
   // 3D Perspective Projection (fovy, aspect, zNear, zFar), relative to camera's eye position 
   gluPerspective(45.0, aspect, 0.1, 100.0);
   
   // OR

   // 3D Orthographic Projection (xLeft, xRight, yBottom, yTop, zNear, zFar),
   // relative to camera's eye position.
   if (width <= height) {
      glOrtho(-1.0, 1.0, -1.0 / aspect, 1.0 / aspect, -1.0, 1.0);  // aspect <= 1
   } else {
      glOrtho(-1.0 * aspect, 1.0 * aspect, -1.0, 1.0, -1.0, 1.0);  // aspect > 1
   }
   
   // Reset the Model-View matrix
   glMatrixMode(GL_MODELVIEW);
   glLoadIdentity();
}

5.1.5 背面剔除

平头角锥体剔除掉了处于视体外部的物体,背面剔除则是剔除掉那些被挡住,没能让相机拍摄到的图元。“背面”可以通过法线向量声明。如果物体是透明的,或者使能了alpha混合,那么就不能使用“背面剔除”。

5.1.6 OpenGL

默认情况下,背面剔除功能是关闭的,“正面”和“背面”都会被渲染。我们可以使用glCullFace()函数来指定被渲染的面,“背面”或者“正面”,或者“正背面”。

6. 碎片处理

光栅化之后,我们得到图元的一系列碎片。每个碎片都是通过像素插值得到的,它们都有位置属性,并与像素网格对齐。另外还有深度,颜色,法线和纹理坐标。碎片处理关注的是纹理和光照效果。对最终显示的图像效果有着非常大的影响。后面的章节我们讲解纹理与光照效果。

碎片处理包括以下几个操作:

1,纹理材质贴图

2,原色和混合色结合,有时还加上烟雾效果

3,可选的裁剪测试,alpha测试,模板测试(stencil),深度测试(depth)

4,混合,抖动,逻辑操作,位掩码

7,合并输出

7.1 深度和背面剔除

Z缓存(深度缓存)就是用来剔除背面(由法线向量定义)的。屏幕的深度缓存默认值为1(最远),颜色缓存则被初始化为背景颜色。所有的碎片处理完之后,它们的Z值与缓存中的值比较。如果Z值比缓存中对应的Z值小,那么碎片的Z值和颜色值就会被写入缓存中。否则,我们认为正准备输出显示的碎片因为被其他物体(碎片)挡住了,所以不显示它,直接忽略。这种算法不会受到碎片处理顺序的影响。

7.1.1 OpenGL

使用深度测试剔除背面,我们需要做以下几步操作:

1,调用gluInitDisplayMode()函数,使能深度缓存:

glutInitDisplayMode(GLUT_RGBA | GLUT_DOUBLE | GLUT_DEPTH);     // GLUT_DEPTH to request for depth-buffer

2,使能深度测试

glEnable(GL_DEPTH_TEST);

3,初始化深度缓存(初始值为1,代表最远)和颜色缓存(初始值为背景颜色)

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);  // Clear color and depth buffers

7.2 Alpha混合(半透明混合)

背面剔除只是针对我们要显示的物体都是不透明的情况。但是实际上我们处理的碎片并不一定是不透明的。它可以包含一个alpha值,指定它的透明度。alpha值的范围为[0,1],0代表完全透明,1代表不透明。如果一个碎片不完全"不透明", 那么透过这个碎片可以看到后面的背景色。这就是alpha混合的效果。alpha混合与背面剔除是互斥的,不能同时存在。最简单的混合公式如下:

c = αscs + (1 - αs)cd

cs是原颜色,αs是原透明度(alpha),cd是目的位置颜色(背景颜色),三个颜色通道(RGB)独自进行混合。混合公式中,碎片顺排序很重要,它们必须是从"后面"到"前面"的顺序,最大深度的最先处理。计算中不需要用到目的位置的透明度。还有很多其他混合公式,可以产生不同的效果。

7.2.1 OpenGL

为了使用透明度混合,我们需要打开混合功能,并将深度测试关闭。例如:

if (blendingEnabled) {
   glEnable(GL_BLEND);        // Enable blending
   glDisable(GL_DEPTH_TEST);  // Need to disable depth testing
} else {
   glDisable(GL_BLEND);
   glEnable(GL_DEPTH_TEST);
}

7.2.2 混合因子

OpenGL中,我们可以使用glBlendFunc()函数来指定混合因子:

void glBlendFunc(GLenum sourceBlendingFactor, GLenum destinationBlendingFactor)
假设一个新的物体(source源)与当前的物体(destination目)进行颜色混合。源物体的颜色值(Rs,Gs,Bs,As),目的物体的颜色值(Rd,Gd,Bd,Ad)。两个颜色值分别根据它们的混合因数加权,然后合并生成混合结果。每种颜色都是独立计算。

举个例子,假设源物体的绿色分量值为p,而目的物体的绿色分量值为q,那么混合生成的绿色值为 p*Gs + q*Gd。

混合因数可以有多种选择,常用的:

glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
所有的源物体颜色分量都是使用alpha值(As)加权,而目的物体颜色分量使用(1-As)加权。这种情况下,如果颜色分量值处于0.0至1.0之间,那么混合结果也必定是处于这个范围中。唯一的缺点就是,最终混合生成的颜色受到处理平面的顺序的影响(因为目的物体的alpha值被忽略了)。

另外一个混合因数例子:

glBlendFunc(GL_SRC_ALPHA, GL_ONE);

指定所有源物体的颜色分量都是使用alpha值来加权,而目的物体的颜色分量的权值为1。

这将导致混合值可能溢出。但是,这种情况,最终混合颜色与渲染顺序无关。

其他的混合因子还有:GL_ZERO, GL_ONE, GL_SRC_COLOR, GL_ONE_MINUS_SRC_COLOR, GL_DST_COLOR, GL_ONE_MINUS_DST_COLOR, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_DST_ALPHA, GL_ONE_MINUS_DST_ALPHA, GL_CONSTANT_COLOR, GL_ONE_MINUS_CONSTANT_COLOR, GL_CONSTANT_ALPHA, 和 GL_ONE_MINUS_CONSTANT_ALPHA.

对于源物体的默认混合因数是GL_ONE, 对目的物体的默认混合因数是GL_ZERO, 这是不透明的情况。

通过计算我们也可以得知为什么alpha混合与深度测试无法同时打开。这是因为,最终混合颜色是由源碎片颜色和目的碎片颜色共同决定的,而不是通过对比深度决定(取深度较小的颜色)(针对不透明的情况)。

8. 光照

光照涉及到光源与三位物体之间的交互作用。如果要做一个非常逼真的场景,那光照效果就非常重要。我们眼睛看到的颜色是光源与有色材料物体平面决定的。另一种说法,光照效果部分将涉及到:观察者,光源,和物体材质。光线(一定的光谱)从光源处照射到一个平面上,一部分会被吸收,一部分被反射和散射。反射角度由入射角度和平面法线决定。散射的强度则是由材质平面的光滑程度决定。反射光线同样跨度一定的颜色光谱,它由入射光谱和平面的吸收性能决定。反射光线的强度由光源的位置,光源距离,以及观察者和平面材质决定。反射光线还可能照射到别的平面,同样的其中的一部分光线被吸收,再次反射。我们感知到的平面的颜色,是平面的反射光线照射到我们的眼睛里面造成的。二维摄像或者印刷,加入微小的颜色变化就可以让物体看起来非常像是三维的物体,这也正是渲染器的工作。

有两种光照模型:

局部光照:只考虑直射的情况。平面的颜色由平面反射性能和直射光决定。

全局光照:实际生活中,物体受到来自其他物体和环境的间接照射。全局照明模型将考虑这种间接照射,它比较复杂,属于计算密集型。

8.1 冯氏照明模型中的光线-材质交互

冯氏照明模型是一种局部照明模型,它不需要做过多的计算,是目前比较流行的一种计算模型。它涉及到四种光照类型:漫射光,反射光,环境光和直射光。

我们考虑有一个碎片p处于平面中,有以下几个参数:光源L,观察者V,碎片法线N,全反射光杯R。根据牛顿定律(入射角与反射角相等),全反射率R可以由平面法线N和入射光L计算得出。


8.1.1 漫射光

由遥远的方向的光源发出的光线(比如阳光)。这种光照射到平面上,得到的所有方向的反射光线都是一样的,既是对任何不同位置的观察者来说,产生的效果都是一样的。光照的强度则由光源和法线决定,也就是L和N的点积。


最终颜色计算如下:

入射光强度:max(L?N,0).这里使用max函数,忽略强度小于0的情况(入射角大于90度)。假设光源颜色sdiff, 碎片漫反射率为mdiff,最终颜色cdiff:

cdiff = max(L?N, 0) sdiff mdiff

其中,RGB三个颜色分量都是单独进行计算。

8.1.2 反射光

反射光集中在全反射光杯R的方向上。观察者观察到的光照效果将由观察者视线与反射光杯之间的角度决定。最终颜色C:

cspec = max(R?V, 0)sh sspec mspec

其中sh就是光亮因子,sh值越大,光锥就越窄,光斑就显得越小。

8.1.3 环境光

一个常量作用于每个点上。最终颜色:

camb =samb mamb

8.1.4 直射光

有一些平面会自己发出光线。最终颜色:

cem = mem

8.1.5 颜色合成

最终合成的颜色由以下四个部分构成:

cfinal =cdiff + cspec + camb + cem

8.2 OpenGL的光照和材质

OpenGL提供了点光源(全方向), 聚光灯光(锥形), 环境光(常量因数)。光源可以位于某个固定位置,或者无限远处。每个光源都有独立的环境光,漫射光,反射光分量,都有RGB颜色分量。光照效果计算对于每个分量都是独立的(局部照明:不考虑间接照射)。

材质也是使用同样的方式模型化。每种类型的材质都有独立的环境光,漫射光,反射光分量,以及指定了被反射部分的一些参数。材质也可以自身发射光线。

OpenGL中,我们需要打开光照效果,以及打开每个光源。光源标记为:GL_LIGHT0~GL_LIGHTn

glEnable(GL_LIGHTING);  // Enable lighting
glEnable(GL_LIGHT0);    // Enable light source 0
glEnable(GL_LIGHT1);    // Enable light source 1
一旦打开光照效果后,使用glColor()函数指定的颜色值将不再使用。物体的颜色将由光照-材质交互效果以及观察者的位置决定。你可以使用glLight()来定义光源(GL_LIGHT0~GL_LIGHTn)

void glLight[if](GLenum lightSourceID, GLenum parameterName, type parameterValue);
void glLight[if]v(GLenum lightSourceID, GLenum parameterName, type *parameterValue);
  // lightSourceID: ID for the light source, GL_LIGHT0 to GL_LIGHTn.
  // parameterName: such as GL_AMBIENT, GL_DIFFUSE, GL_SPECULAR, GL_POSITION.
  // parameterValue: values of the parameter.
默认的光源位置GL_POSITION是相机坐标系中的(0,0,1)。它处于相机(0,0,0)的背后。

光源GL_LIGHT0比较特别,它的GL_AMBIENT,GL_DIFFUSE,GL_SPECULAR分量默认都是白色(1,1,1)。你可以打开GL_LIGHT0光源但是不进行任何设置。

其他光源(GL_LIGHT1~GL_LIGHTn),GL_AMBIENT,GL_DIFFUSE,GL_SPECULAR分量都是默认黑色(0,0,0)。

8.2.1 材质

与光源类似,针对不同类型的光照(反射,漫射,环境光),材料也有反射率参数(含RGBA颜色分量),指定了发生光照反射的部分。表面也可以自身发射光线(GL_EMISSION)。表面还有光亮参数(GL_SHININESS)——光亮参数越大,反射光就越集中在光杯的小范围内,表面看起来就更加光亮。另外,一个表面具有两个面:前面和背面,它们的参数可以相同也可以不同。

你可以使用函数glMaterial()指定这些参数值,包括前面(GL_FRONT),背面(GL_BACK),或者同时指定(GL_FRONT_AND_BACK)。"前面"由平面法线定义(默认地按照右手定则确认,或者使用glNormal()函数指定)。

void glMaterial[if](GLenum face, GLenum parameterName, type parameterValue)
void glMaterial[if]v(GLenum face, GLenum parameterName, type *parameterValues)
     // face: GL_FRONT, GL_BACK, GL_FRONT_AND_BACK.
     // parameterName: GL_DIFFUSE, GL_SPECULAR, GL_AMBIENT, GL_AMBIENT_AND_DIFFUSE, GL_EMISSION, GL_SHININESS.

默认情况下,材质的表面是灰色的(白色光照下),以及比较小的环境光反射率(0.2,0.2,0.2,1.0),比较大的漫反射率(0.8,0.8,0.8,1.0),镜面反射率则为0(0.0,0.0,0.0,1.0)。

8.3 顶点与碎片着色器

[TODO]

8.4 全局光照模型

[TODO]

9. 纹理

计算机图像技术中,我们经常在物体的表面贴上一层图片(纹理),让它看起来更加真实。纹理是典型的二维图片。纹理的每个单元称为纹素,类似于像素。二维纹理坐标(s,t)规范范围是[0.0,1.0],原点位于左上角。S坐标轴指向右,T坐标轴指向下。


9.1 纹理包装

虽然二维纹理坐标的规范范围是[0.0,1.0],但是我们仍可以配置如何操作超出范围的部分。典型的处理方法有:

1,纹理坐标固定为[0.0,1.0],然后忽略掉超出的部分。

2,基于S轴,T轴或者S,T轴,对纹理进行重新包装(或者重复)。你可以设置镜面模式让纹理连续显示。

OpenGL中,我们使用glTexParameter()函数来配置包装操作,并且可以独立对S,T轴进行配置(GL_TEXTURE_WRAP_S和GL_TEXTURE_WRAP_T)。两个模式都支持GL_REPEAT(复制)和GL_CLAMP(不复制,固定范围)。

glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);  // Repeat the pattern
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP);   // Clamped to 0.0 or 1.0
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP);

9.2 纹理过滤

正常情况下,纹理(要显示的贴图)的分辨率与碎片(等价于屏幕上的点)的分辨率不同。如果纹理图片的分辨率小些,我们需要让它进行"纹理放大"来适应显示。相反的,如果纹理图片的分辨率大些,那么我们需要让它进行"纹理缩小"。

9.2.1 纹理放大

常用的方法:

1,最近点过滤:碎片的颜色由最接近的纹素(纹理像素)采样得到。这种过滤方式会导致有斑点,因为很多碎片会使用来自同一个纹素的颜色值。

2,双线性过滤:碎片的颜色是通过将最接近的4个纹素进行双线性过滤得到。这种方式得到的效果比较平滑。



9.2.2 纹理缩小

如果纹理图片的分辨率比碎片分辨率大,那么就需要进行纹理缩小。同样的,你可以使用“最近点采样”,或者“双线性过滤”的方法。但是,这种抽样的方式因为采样率较低而通常被称为"伪像"。在透视投影中,远处的物体会因为采样率非常低而导致失真严重。

9.2.3 微缩地图

有一个更好的方法来处理纹理缩小,那就是微缩地图,它创建了一系列“低分辨率”版本的纹理图片。比如,假设原始图片分辨率64*64,那我们就可以创建低分辨率版本:32*32,16*16,8*8,4*4,2*2,1*1. 最高的分辨率记为等级0,第二高是等级1,以此类推。于是,我们便可以在这些不同分辨率版本中,选择最合适的一张,或者使用其中两张进行线性插值得到。


9.2.4 OpenGL纹理过滤

我们可以调用以下方法分别设置纹理放大,纹理缩小过滤器:

// Nearest Point Sampling - fast but visual artifacts
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
// 2x2 linear averaging - slower but smoother
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
我们可以使用一张图片,然后调用函数gluBuild2Dmipmaps()让OpenGL生成一系列的低分辨率版本图片。

int gluBuild2DMipmaps(GLenum target, GLint internalFormat, GLsizei width, GLsizei height,
                      GLenum imageDataFormat, GLenum imageDataType, const void *imageData)
我们可以指定贴图过滤器参数:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);                // MAG filter is linear
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_NEAREST); // MIN filter is mipmap
另外,透视投影中,纹理快速插值机制可能没有处理因为透视带来的失真问题。下面的函数便是用来通知

渲染器以性能为代价生成更好的纹理图片。

glHint(GL_PERSPECTIVE_CORRECTION_HINT, GL_NICEST);


链接:OpenGL/计算机图像参考文档与资源
链接:open.gl

三维图像技术与OpenGL基础理论