第一章 初始化OpenGL 无论是什么东西,要使用它,就必须对它进行初始化。如果你之前使用过GDI,你应该也多多少少了解到GDI在绘制图形之前要为之创建渲染环境。OpenGL也一样。本章给出的代码,大家可以不必理解其中的具体意义,反正以后每次初始化是使用这个代码即可。 首先,在一个新的应用程序中,我们需要添加对OpenGL库的引用。Delphi已经为我们写好了OpenGL的头文件,因此我们只须直接在单元的uses中添加OpenGL即可: ... uses Windows, Graphics, OpenGL, ... ...
在创建窗口时,应添加如下代码: procedure Form1.Create(Sender:TObject);
end; 上面的代码是Windows下初始化OpenGL的固定代码。因为本教程不使用任何第三方库,所以在初始化上显得麻烦些。但这些代码是固定的,每次初始化OpenGL时,照抄代码即可。下面对上述代码作简单的说明。 首先,我们用GetDC(Handle)获取当前窗口的设备目录。然后初始化一个TPIXELFORMATDESCRIPTOR类型的字段,用于描述将要使用的像素格式。在此字段中,dwFlags表示渲染方式,一般情况下使用PFD_SUPPORT_OPENGL or PFD_DRAW_to_WINDOW or PFD_DOUBLEBUFFER。其中,PFD_DOUBLEBUFFER表示支持双缓冲。这将会在今后的章节中讲到。cColorBits表示使用的色深,一般情况下使用24位色深。在今后的教程中,会根据不同的需求而改变TPIXELFORMATDESCRIPTOR字段中的值,而其他代码一般都是不变的。 初始化完OpenGL之后,就可以调用OpenGL的绘制函数来绘制图形了。一般情况下,我们把所有绘制图形的函数调用写在一个名为RenderScene的过程中: procedure TForm1.RenderScene; begin ... end; 然后,我们使用一个Timer,其Interval属性设为1,然后在OnTimer事件中调用RenderScene即可。 在窗体关闭时,我们还必须关闭渲染环境以释放内存。方法如下: procedure TForm1.Form1Close(Sender:TObject;....); begin wglMakeCurrent(DC,HRC); end; 其实,关于OpenGL初始化,我已经写好了一个类。用这个类初始化,可以让窗体的代码简洁一些。首先,下载glInit.pas,并引用它。然后向单元中加入如下代码初始化OpenGL。 ... uses ...,GLInit; var glInitor:TOpenGLInit; implementation procedure TForm1.Create(....); begin glInitor:=TOpenGLInit.Create(Handle,24,stdDoubleBuffer); end; procedure TForm1.Close(...); begin glInitor.Free; end; 在本章的示例程序中,我们将初始化OpenGL,并用OpenGL绘制一个三角形。在此程序中,我们还对OpenGL作了其他一些设置,现在你无须理解其中的意义。此程序中,InitOpenGL过程用于初始化OpenGL,FreeOpenGL过程用于销毁OpenGL,SetView过程用于设置视图投影(将在今后的章节中讲到),在RenderScene过程中,我们使用了一个OpenGL的函数绘制了一个三角形。 以下就是本章示例程序的代码。 unit untMainForm;
interface
uses Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms, Dialogs, ExtCtrls, OpenGL;
type TfrmMain = class(TForm) tmrRender: TTimer; procedure FormCreate(Sender: TObject); procedure FormClose(Sender: TObject; var Action: TCloseAction); procedure tmrRenderTimer(Sender: TObject); private procedure InitOpenGL; procedure FreeOpenGL; procedure SetView; procedure RenderScene; public
end;
var frmMain: TfrmMain;
DC: HDC; HRC :HGLRC ; implementation
{$R *.dfm}
procedure TfrmMain.FreeOpenGL; begin wglMakeCurrent(DC,HRC); wglDeleteContext(hRc); ReleaseDC(Handle,DC); end;
procedure TfrmMain.InitOpenGL; var pfd:TPIXELFORMATDESCRIPTOR; pixelFormat:integer; begin DC := GetDC(Handle); with pfd do begin nSize:=sizeof(TPIXELFORMATDESCRIPTOR); // 此结构尺寸 nVersion:=1; dwFlags:=PFD_SUPPORT_OPENGL or PFD_DRAW_to_WINDOW or PFD_DOUBLEBUFFER; // 使用双缓冲区 iPixelType:=PFD_TYPE_RGBA; //使用RGBA颜色空间 cColorBits:=24; //24位真彩色 cRedBits:=0; cRedShift:=0; cGreenBits:=0; cGreenShift:=0; cBlueBits:=0; cBlueShift:=0; cAlphaBits:=0; cAlphaShift:=0; cAccumBits:=0; cAccumRedBits:=0; cAccumGreenBits:=0; cAccumBlueBits:=0; cAccumAlphaBits:=0; cDepthBits:=16; cStencilBits:=0; cAuxBuffers:=0; iLayerType:=PFD_MAIN_PLANE; bReserved:=0; dwLayerMask:=0; dwVisibleMask:=0; dwDamageMask:=0; end; pixelFormat := ChoosePixelFormat(DC, @pfd); if (pixelFormat = 0) then exit; if (SetPixelFormat(DC, pixelFormat, @pfd) <> TRUE) then exit; hRc := wglCreateContext(DC); wglMakeCurrent(DC,HRC); end; procedure TfrmMain.FormCreate(Sender: TObject); begin InitOpenGL; SetView; end;
procedure TfrmMain.FormClose(Sender: TObject; var Action: TCloseAction); begin FreeOpenGL; end;
procedure TfrmMain.tmrRenderTimer(Sender: TObject); begin RenderScene; end;
procedure TfrmMain.RenderScene; begin glLoadIdentity; glClear(GL_COLOR_BUFFER_BIT or GL_DEPTH_BUFFER_BIT);//清空缓冲区 glColor3ub(0,255,0); //将颜色设置为绿色。 glBegin(GL_TRIANGLES);//告诉OpenGL将要绘制三角形 glVertex2f(200,300); //传输三角形的三个顶点坐标给OpenGL glVertex2f(400,300); glVertex2f(300,150); glEnd; //结束图元的绘制。 SwapBuffers(DC);//交换双缓冲区内容,这将把刚绘制的图形翻印到屏幕上。 end;
procedure TfrmMain.SetView; begin glClearColor(0,0,0,0);//设置背景颜色为黑色 glViewPort(0,0,ClientWidth,ClientHeight);//指定OpenGL在此区域内绘图。 glMatrixMode(GL_PROJECTION);//设置视图投影变换矩阵 glLoadIdentity;//加载单位矩阵。 glOrtho(0,ClientWidth,ClientHeight,0,1,-1);//创建平行投影空间。 //在平行投影空间中,远处的物体和近处的物体大小是一致的。 glMatrixMode(GL_MODELVIEW);//将矩阵变换对象切换为模型视图变换。 end;
end. 程序的运行结果: 第二章 OpenGL 工作机制
要使用好OpenGL,了解它的工作机制是非常必要的。学习完本章内容之后,你就能理解上一章示例程序中在绘图之前那些工作的意义了。
在本章内容中,你将了解:
在3D空间中,场景是物体或模型的集合。在3D图形渲染中,所有的物体都是由三角形构成的。这是因为一个三角形可以表示一个平面,而3D物体就是由一个或多个平面构成的。比如下图表示了一个非常复杂的3D地形,它门也不过是由许许多多三角形表示的。
因此,在OpenGL中,我们只要指定一个或多个三角形,就可以表示任意3D物体。那么如何指定三角形呢?OpenGL提供三种指定三角形的方法:即单个三角形、三角条形和三角扇形。
指定单个三角形。这是最简单,最直接的方法。即调用特定的OpenGL函数,传入三个顶点坐标,指定一个三角形。如下图:
三角条形。这种方式适合于同时绘制多个三角形,且这些三角形之间至少存在一条公共边。一个三角条形是在单个三角形的基础上,再指定一个或多个顶点。这些顶点按照次序同上一顶点一起构成一个新的三角形。下图演示了这种推进过程。
三角扇形。三角扇形中,所有顶点按照一个中心点成扇形排列。如下图,是一个以V1为中心点的三角扇形。
既然使用三角形就可以表示任何图形,为什么还要使用三角条形和三角扇形呢?这是因为在OpenGL渲染流水线中,对于每个顶点都要进行变换运算。而对于一些连接在一起的三角形组来说,使用三角条形或三角扇形就减少了顶点的数目,这意味着减少了对顶点的运算,因此提高了渲染速度。例如,上图中第三个三角扇形,该扇形描述了4个三角形。如果把这四个三角形都一一作为单个三角形传给OpenGL的话,我们需要3*4=12个顶点,而使用了三角扇形之后,我们只使用了6个顶点。这节约了一半的运算量!
当我们把要绘制的三角形传给OpenGL之后,OpenGL还要做许多工作以完成3D空间到屏幕的投影。这一系列的过程被称为OpenGL的渲染流水线。一般地,OpenGL的渲染流程如下:
2.2.1 视图变换
当一个场景确定之后,如果我们想移动某个物体,或者要实现场景内的漫游,就必须进行模型视图的变换。模型视图变换可以根据需要,移动或旋转一个或多个物体。例如,如果我们想在3D空间中沿着Z轴向前走的话,只需要把所有物体向-Z方向移动n个单位即可。如果我们要向左看,就应该把所有物体沿着Y轴渲染向右旋转N个角度。下图演示了这个过程。
2.2.2 背面隐藏
在一些封闭的3D物体中,朝着物体内部的面总是不可见的。对于这些永远不可见的平面,我们可以使用背面隐藏忽略对它的绘制以提高渲染速度。为了实现背面隐藏,我们在绘制三角形的时候必须注意三角形的绕法。一般的,OpenGL默认为逆时针缠绕的面是正面。如下图所示的三角形中,如果把顶点按照V1->V3->V2的顺序传给OpenGL,那么OpenGL就会认为这个三角形朝着屏幕的面是正面。
使用背面隐藏,就要求我们在把图形传给OpenGL的时候要始终遵守正面使用逆时针绕法的规定。要开启背面隐藏的功能,只需调用函数:
glEnable(GL_CULL_FACE);
当然,我们也可以改变OpenGL的设置,决定是对物体的正面还是背面进行隐藏。调用如下函数:
glCullFace(GL_FRONT);
来隐藏正面,也可调用
glCullFace(GL_BACK);
来隐藏背面。
2.2.3 光照渲染
如果你开启了光照渲染,并且为每个顶点指定了它的法线,在此过程中,OpenGL将根据顶点的法线和光源的位置及性质重新计算顶点的颜色。使用光照效果可以大大提高画面的真实性。我们将在第六章中讲到光照。
2.2.4 剪裁
剪裁就是把那些不在视见空间,或者一半在可视空间中的物体剔除或剪裁,以保证不该出现在屏幕上的图形就不出现。
2.2.5 投影
要把一个3D空间中的物体显示在屏幕上,就要进行投影。投影又有两种方式:平行投影和透视投影。在平行投影中,远处的物体和近处的物体是一样大的,这种投影主要运用在计算机辅助设计(CAD)上,由于这种投影没有立体感,所以一般情况下使用透视投影。在透视投影中,远处的物体会变得较小,因此在透视投影中,可视空间是一个平头截体(或台体)。下图表明了投影变换的原理。
2.2.6 视见空间变换
当3D空间中的图形经过投影成为2D图形之后,我们还要把图形缩放到窗口或屏幕上。这个过程被称为视见空间变换。对于一般的游戏来说,视见空间应该是整个屏幕或窗体。但是视见空间也可以是它的子集。
2.2.7 光栅化
当2D图形的所有变换都完成之后,就要把它们栅格化以显示在屏幕上,或保存为BMP图片。栅格化其实是把变换得到的2D矢量图转化为位图的过程。
2.2.8 绘制
在这一步中,将由Windows GDI把光栅化的图形显示在屏幕上。
在OpenGL1.1库中,包含了大约300多个API函数。为了方便程序员记忆和使用,这些函数都按照一定的规则进行命名。例如,函数glVertex用于传入顶点数据,而glVertex又有glVertex2i, glVertex3f, glVertex2f, glVertex2d,glVertex3d等变种。它们各自有什么意义呢?
在函数glVertex3i中,"gl"表示当前函数属于OpenGL库。当然,此前缀如果是"glu",则表示是GLU库(OpenGL辅助库)。
第三章 使用OpenGL绘图
从本章开始,我们将正式开始使用OpenGL来绘制图形。学习本章内容,你将发现使用计算机绘制3D图形原来如此容易。你将了解:
3.1 绘制之前的必要工作
从章节2.1中,你应该了解到,在使用OpenGL绘图之前,我们应该决定使用何种投影方式,设置渲染后的图形应出现在窗口的位置等等。本节中,我们将了解这些步骤的具体实现方法。
在OpenGL初始化完成之后,我们应该进行一些视图设置。首先是设定视见区域,即告诉OpenGL应把渲染之后的图形绘制在窗体的哪个部位。当视见区域是整个窗体时,OpenGL将把渲染结果绘制到整个窗口。我们调用glViewPort函数来决定视见区域:
procedure glViewPort(x:GLInt;y:GLInt;Width:GLSizei;Height:GLSizei);
其中,参数X,Y指定了视见区域的左下角在窗口中的位置,一般情况下为(0,0),Width和Height指定了视见区域的宽度和高度。注意OpenGL使用的窗口坐标和WindowsGDI使用的窗口坐标是不一样的。图3.1-1表示了在WindowsGDI中的窗口坐标,而图3.1-2则是OpenGL所定义的窗口坐标。
例如,要设置如图3.1-3中的视见区域,我们应该调用函数:
glViewPort(100,100,Width,Height);
接下来,我们要设置一种投影变换。投影变换分为平行投影和透视投影。平行投影中,物体无论远近,大小都是一样的,而透视投影则相反。因此,透视投影更像是我们眼睛所看到的景物。但在某些特殊的时候,平行投影还是有它的作用的,比如3D建摸程序。图3.1-4是甲烷分子模型在平行投影下的渲染结果,而图3.1-5是在透视投影下的渲染结果。可以看到,平行投影下,四个氢原子(绿色的球体)大小是一样的,而在透视投影下,远处的氢原子要小一些。
值得注意的是,本节所讲的内容涉及矩阵变换这一主题。关于OpenGL中的矩阵,我们将在下一章中作具体说明。因此本章不讨论矩阵变换的原理。
3.1.2.1 创建平行投影
调用glOrtho函数,可以创建一个平行投影:
procedure glOrtho(left, right, bottom, top, zNear, zFar: GLdouble);
其中,left指定了该平行投影最左边的平面;
right指定了该平行投影最右边的平面; bottom指定了该平行投影最下边的平面; top指定了该平行投影最上边的平面; zNear,zFar指定了近修剪平面和远修建平面。 也就是说,仅当顶点v(x,y,z)满足条件 x>left and x
我们使用下面的代码创建一个平行投影:
glMatrixMode(GL_PROJECTION);
glOrtho(-ClientWidth div 2,ClientWidth div 2,-ClientHeight div 2,ClientHeight div 2,1,100);
glMatrixMode(GL_MODELVIEW);
在上面的代码中,你看到了一个陌生的函数:glMatrixMode。它的作用是告诉OpenGL接下来我们将要设置投影变换矩阵。这涉及到下一章的主题,这里就不多讨论了。现在你可以暂时把上面的代码当作固定代码使用。
3.1.2.2 创建透视投影
透视投影对远处的物体根据距离进行缩短或压缩变换。这使得远处的物体看起来小些,从而更加真实。因为远处的景物更小,所以随着距离的增加,观察者应该能看到更多的景物。因此,透视投影的可视区域应是一个被称为平截头体的几何形状。如图3.1-6所示。
和平行投影相似,只要把函数glOrtho的调用该为glFustum或者gluPerspective。
调用glFrustum函数,可以指定一个平截头体。
procedure glFrustum (left, right, bottom, top, zNear, zFar: GLdouble);
可以看到,glFrustum的参数和glOrtho完全一样。
但对于平截头体的性质来说,使用glFrustum总是不太直观。因此gluPerspective反而更常用:
procedure gluPerspective(fovy, aspect, zNear, zFar: GLdouble);
其中,fovy为垂直方向上可见区域的角度(即上修剪平面和下修剪平面的二面角);
aspect为高度与宽度的纵横比(即 Width/Height 的比值); zNear和zFar为近、远修剪平面。 如图3.1-7所示。
我们用下面的代码定义透视投影:
glMatrixMode(GL_PROJECTION);
gluPerspective(60,ClientWidth/ClientHeight,1,zFar);
glMatrixMode(GL_MODELVIEW);
其中,zFar根据要绘制场景的大小设置不同的值。
3.1.3 设置背景颜色
这一步是可选的。我们可以调用glClearColor函数来设置用于清空屏幕和缓冲区的颜色。
glClearColor(R,G,B,A:GLFloat);
其中,R,G,B,A分别表示颜色的R、G、B分值和透明度值。取值范围均为0-1之间。例如,下面的代码把背景色设置为黑色:
glClearColor(0,0,0,1);
3.1.4 绘制之前,清空屏幕和缓冲区
一般地,我们把所有绘制函数的调用写在RenderScene过程中。在每次绘制之前,我们都应该清空屏幕和缓冲区。下面的代码用指定的清空颜色清空它们:
procedure RenderScene;
begin
glClear(GL_COLOR_BUFFER_BIT or GL_DEPTH_BUFFER_BIT);
...图形绘制...
end;
3.1.5 整理代码
章节3.1.1-3.1.3中,所提到的所有代码在整个渲染流程中只需被执行一次。因此,我们可以向窗体添加一个过程,将上述代码放到该过程中。并且在OpenGL初始化工作完毕之后执行它。
然后,在FormCreate过程中添加对过程SetView的调用:
我们的SetView过程看起来应该是这样的:
一切复杂的东西都是由简单而基本的元素构成的。在OpenGL中,组成复杂图形的基本元素被成为图元。掌握了基本图元的绘制方法,就能绘制出任何复杂的物体。
OpenGL为我们提供了以下几种图元:
3.2.1 绘制三角形
绘制三角形是非常简单的。我们只需通过glVertex的调用传给OpenGL必要的顶点值即可。
在调用glVertex之前和之后,我们需要调用glBegin和glEnd这两个函数来标识图元的开始和结束。
在调用glBegin函数时,我们需要传入一个参数,以告诉OpenGL我们将绘制什么类型的图元。传入GL_TRIANGLES表明我们将要绘制三角形。例如:
glBegin(GL_TRIANGLES);
glVertex(1,0,1);
glVertex(0,1,0);
glVertex(1,1,0);
glEnd;
将绘制一个以点(1,0,1)、(0,1,0)、(1,1,0)为顶点的三角形。
3.2.2 绘制三角条形
和绘制三角形相同,只要把glBegin的参数该为GL_TRIANGLE_STRIP即可。例如:
glBegin(GL_TRIANGLE_STRIP);
glVertex(1,0,1);
glVertex(0,1,0);
glVertex(1,1,0);
glVertex(1,3,0);
glVertex(4,8,2);
glEnd;
3.2.3 绘制三角扇形
和绘制三角形相同,只要把glBegin的参数该为GL_TRIANGLE_FAN即可。由于绘制方法大致相同,这里不再举例了。
3.2.4 绘制点、线、连续线、封闭线
下面列举了绘制这些图元应传给glBegin的值。
值得说明的是图元GL_LINE_STRIP和GL_LINE_LOOP。
GL_LINE_STRIP和GL_TRIANGLE_STRIP原理是一样的。也就是从第2个顶点开始,第n个顶点与第n-1个顶点构成一条直线。例如:
glBegin(GL_LINE_STRIP);
glVertex(A.x,A.y,A.z);
glVertex(B.x,B.y,B.z);
glVertex(C.x,C.y,C.z);
glEnd;
将绘制出两条线段:线段AB和线段BC。
图元GL_LINE_LOOP建立在GL_LINE_STRIP的基础之上。与GL_LINE_STRIP不同的是,GL_LINE_LOOP会在最后一个顶点和第一个顶点之间再连一根直线,构成一个封闭图形。如果把上述代码的GL_LINE_STRIP参数该为GL_LINE_LOOP,那么将绘制出三条线段:线段AB、BC和CA。
3.2.5 绘制四边形和多边形
四边形(QUAD)也属于使用几率较高的图元。只要把glBegin的参数改为GL_QUADS,就可以绘制四边形。把参数改为GL_POLYGON,则可以绘制一个多边形。
然而,仔细观察你将发现,无论是四边形还是多边形,只要花一点工夫,他们都可以使用三角形来表示。而且,由于现在的显卡都对三角形的绘制做了大量的优化,使得绘制三角形的速度比绘制多边形的速度快得多。因此请尽量不要使用多边形这种图元以提高渲染速度。
如果你要使用四边形或多边形,请注意以下几点:
1.使用OpenGL绘制的多边形,必须是凸多边形;
2.绘制的多边形的所有顶点都必须处在同一个平面上。
由于这些限制,使得绘制多边形这种图元显得不怎么方便。这也突出了使用三角形的优点——你永远也不用担心绘制出来的三角形是无效的。你可以尽情地使用三角形绘制各种复杂多边形。
我们知道,当一个平面或物体挡住了另一个物体时,后面的物体是不可见的。此时,我们应该避免绘制后面的物体。这个时候,我们可以使用OpenGL的一个功能:深度测试(Depth Test,也称深度缓冲(z-Buffer))来剔除这些被挡住的表面。深度测试就是在绘制像素时,计算该像素所代表的物体离观察者的距离,称为z值。如果该值在同一个像素上所有的z值中是最小的,就绘制该像素,否则就跳过。这是一个解决深度问题的有效方法。我们只需要调用函数
glEnable(GL_DEPTH_TEST);
就可以开启深度测试。调用
glDisable(GL_DEPTH_TEST);
关闭深度测试。
如果你将要绘制一些实心的物体,那么这个实心物体内部的表面将永远是不可见的。而OpenGL并不知道这些面不可见,它会照样对他们进行计算和绘制。虽然最后还是没有将这些背面绘制在屏幕上,但是浪费了许多不必要的时间。因此,我们应该开启背面隐藏功能剔除这些不可见的表面。
绕法
如果我们把一个物体朝着外面的表面都按照逆时针的顺序传给OpenGL,那么OpenGL就会认为这个面是朝外面的。这个时候,我们开启背面剔除就不会有什么影响。但如果你没有遵守这个规定就开启了背面剔除,将得不到正确的渲染结果。我们可以通过函数glFrontFace的调用来改变这一规则。例如:
glFrontFace(GL_CCW);
将让OpenGL认为所有逆时针缠绕的表面是正面,如果把 GL_CCW 改为 GL_CW ,那么OpenGL将认为所有逆时针缠绕的面是正面。
开启表面剔除
在渲染之前,添加下面的代码来打开表面剔除:
glCullFace(GL_BACK);//隐藏背面,如果把参数改为GL_FRONT则隐藏正面。
glEnable(GL_CULL_FACE);
关闭表面剔除
有些物体,无论是正面还是背面都有可能是可见的(比如一张纸,既有正面又有背面),在渲染这些物体的时候,我们应该关闭表面剔除。只需添加以下代码:
glDisable(GL_CULL_FACE);
在所有物体绘制完成之后,我们要调用函数SwapBuffers来显示渲染结果:
procedure RenderScene;
begin
glClear(GL_COLOR_BUFFER_BIT or GL_DEPTH_BUFFER_BIT);
glBegin(GL_TRIANGLE_STRIP);
...
glEnd;
...
SwapBuffers(wglGetCurrentDC);
end;
请注意SwapBuffers的参数总是wglGetCurrentDC。wglGetCurrentDC也是一个函数,它返回当前OpenGL的渲染环境句柄。
3.6 本章示例程序
在本章的示例程序中,我们使用四边形创建了一个立方体。并为6个面指定了不同的颜色。你可以使用方向键旋转立方体。在这个程序中,我们综合使用了透视投影、图元的绘制、背面隐藏和深度测试。你可以关闭深度测试看一看渲染结果,这样就可以更好地理解深度测试的意义。图3.6-1是此程序的运行结果。
第四章 矩阵变换 通过前三章的学习,我们知道了如何使用OpenGL在3D空间中绘制基本图元,并把使用图元组成模型。然而,在我们绘制完一个物体或一个场景之后,我们总希望从多个角度观察这个物体,或者在场景中走动。这时,我们需要OpenGL的另一个功能:变换。 OpenGL为我们提供了许多方面和类型的变换。你可以对投影方式进行变换,也可以对物体/模型 进行变换。你可以改变自己的位置和方向,也可以改变物体的大小和角度。学习本章内容,你将了解: 变换(Transform),可以使3D空间中的物体投影到2D平面上。使用变换,你可以移动、旋转、缩放甚至弯曲一个物体。然而变换并没有直接修改顶点数据,取而代之,变换修改了坐标系。如果旋转一个坐标系,然后再在旋转后的坐标系里绘图,绘制后的图形就好像被旋转了。在基本OpenGL渲染流程中,将进行以下变换: 视图变换 :用于指定观察者的位置和方向; 4.1.1 视图变换 在一个场景中,我们希望改变观察者的位置和观察角度。用于改变观察者方位和角度的变换,就是视图变换。默认情况下(没有执行任何变换时),观察者位于点(0,0,0),且视线朝着-Z方向。也就是说,只有在z<0的地方绘图,才有可能被观察到。 4.1.2 模型视图变换 此变换用于移动和旋转场景中的物体。使用模型视图变换完全可以代替视图变换。道理是很简单的:比如你想使用视图变换将观察者向-Z轴移动10个单位,此时场景中所有的物体都向+Z轴移动了10个单位。这跟你直接使用模型视图变换将场景中所有物体向+Z方向移动10个单位的效果是完全一样的。 4.1.3 投影变换 要把3D场景投影到2D平面上,就必须执行投影变换。投影变换有两种形式,即平行投影变换和透视投影变换。关于平行投影和透视投影,在第3中已进行了具体的介绍,这里不在复述。现在要强调的是,投影也是一种变换,实现投影,本质上是对场景中所有物体进行特殊的变换,使得它们能够被画在一个平面上。比如透视投影变换会将场景中所有物体按照远近不同进行缩放和扭曲,使它们看起来具有立体感。 4.1.4 视见区变换 这里又回到了第二章中的主题。视见区变换就是对投影后的2D图象进行缩放和剪裁,使它能够被正确地显示在窗口上。你可以回到第二章了解视见区的具体概念。 矩阵(Matrix)是那样的强大以至于几乎所有的变换都可以由矩阵来表达。矩阵是又n行m列的数组成的一个阵列(m、n≥1)。通过矩阵的乘法运算就可以运用各种变换。在OpenGL中,统一使用大小为4×4的矩阵。 由于矩阵的运算法则和具体数学内容,和OpenGL这一主题并没有太大关系(使用OpenGL并不需要了解矩阵是怎样运算的,因为OpenGL会帮你完成一切),所以这里不再介绍。但这并不代表这些数学知识是不重要的,灵活地运用矩阵,可以自己创造出许多OpenGL没有提供的变换,并提高运算速度。你可以参看《线性代数》了解更多内容。 在OpenGL进行变换操作时,会首先把顶点转换为1×4的矩阵(第1-3行分别存放顶点的x、y、z坐标,第4行存放w坐标,即缩放因子,一般总为1.0),然后将这个点依次乘以模型视图变换矩阵、投影矩阵、视见区变换矩阵……最后得到因出现在屏幕上的2D屏幕坐标,完成变换。幸运的是,你不需要任何数学基础,哪怕你对矩阵一无所知,也能顺利地完成这一流程。因为OpenGL已经封装了高级函数,这使得你不用自己动手写矩阵,就能完成所有的基本变换。稍后就将介绍这些基本函数。 对于每一种变换,OpenGL都有自己的函数用来生成这些变换的矩阵并应用它们。下面将一一介绍。 4.3.1 模型变换矩阵 这是本章最重要的内容。使用模型变换,你就可以完成物体的旋转和移动,并产生移动观察者的效果。这正是本章的主题。为了能够完成我们的示例,我们定义以下函数在原点绘制一个球体。以下函数涉及到二次曲面的内容,这是OpenGL的另一个高级主题,我们将在今后的章节中具体讲解,现在我们只用它来绘制球体: procedure DrawSphere(R:Single); //R代表球体的半径 4.3.1.1 平移 当我们调用DrawSphere时,会在原点绘制一个球体。现在我们想在点(0,10,0)上绘制这个球体,就必须在绘制之前将坐标系沿+Y方向平移10个单位。于是我们会写出这样的代码: //建立一个将坐标系沿+Y方向平移10个单位的矩阵: 但事实上,我们不需要这么麻烦。OpenGL为我们提供了这样一个函数: glTranslatef(x,y,z:Single); 其中,x,y,z分别表示在X、Y、Z轴上平移的量。调用这个函数之后,OpenGL会自动生成一个平移矩阵,然后应用这个矩阵。因此,我们可以这样写代码: glTranslatef(0,10,0); 这样就能在(0,10,0)上绘制一个球体了。 4.3.1.2 旋转 与平移类似,OpenGL也为我们提供了一个高级函数用于旋转物体: glRotatef(Angle,x,y,z:Single); 这个函数将生成并应用一个将坐标系以向量(x,y,z)为轴,旋转angle个角度的矩阵。如果我们想将一个球体以Y轴自转50度,就可以调用: glRotatef(50,0,1,0); 4.3.1.3 缩放 缩放变换其实是将坐标系的x、y、z轴按不同的缩放因子展宽,从而实现缩放效果。函数 glScalef(x,y,z:Single); 把坐标系的X、Y、Z轴分别缩放x、y、z倍。例如: glScalef(2,2,2); 将绘制一个半径为10的球体。 4.3.1.4 变换的叠加性质 使用变换时,我们应该注意的是,变换是叠加在上次变换的基础上的。也就是说,变换的效果会累积。每次调用变换函数时,会生成一个新的函数来乘以当前的模型视图矩阵,随后,新的矩阵将成为当前的模型变换矩阵,在下次执行变换时,会被新的矩阵相乘,因此作用效果将不断累积。举个例子就能很明白地说明这一点。 例如,你想在(0,10,0)上绘制一个球体,完后在(10,0,0)上绘制另一个,得到如图4.3-1所示的图形:
你可能会写出如下代码: //沿Y轴向上平移10个单位 然而,你不应该忘记,变换的作用效果是累积的。在绘制第二个球体时,由于此时坐标系已经向Y轴移动了10个单位,再向X方向移动10个单位之后,新的坐标系的原点应是绝对坐标系中的点(10,10)。因此,上述程序将绘制出如图4.3-2所示的图形。
你可能会在绘制第二个球体之前调用glTranslatef(0,-10,0);把坐标系往回移动10个单位。但这样会降低代码的可读性,还会给CPU增加额外的运算。这个时候,我们可以使用单位矩阵。 我们可以调用glLoadIdentity();函数将当前模型视图变换矩阵重置到初始状态,再进行新的绘制: procedure RenderScene(); 请看第一行代码。这里调用了glMatrixMode函数。这个函数的作用是通知OpenGL我们将对模型视图变换矩阵进行操作。也就是要进行模型视图变换。glMatrixMode可用参数如下: GL_PROJECTION :用于修改投影矩阵 4.3.1.5 矩阵堆栈 如果每次变换前都把当前矩阵恢复到单位矩阵,也比较麻烦。更多时候,我们希望保存当前矩阵,执行一些变换之后,把当前矩阵恢复到上次保存时的状态。 OpenGL为我们提供了一个“矩阵堆栈”满足我们的这种要求。我们可以把当前矩阵压入堆栈中,然后执行一些变换,再弹出刚才压入的矩阵,从而把当前矩阵恢复到上次变换之前的状态。我们调用 glPushMatrix(); 把当前矩阵压入矩阵堆栈,调用 glPopMatrix(); 弹出矩阵。我们还可以分别调用 glGet(GL_MAX_MODELVIEW_STACK_DEPTH); 来获取模型视图矩阵堆栈和投影矩阵堆栈的最大堆栈深度。一般情况下(在Windows平台上),模型视图的最大堆栈深度是32,而投影堆栈的最大深度是2。 使用矩阵堆栈,4.3.1.4节中的程序可以改写为: procedure RenderScene(); 4.3.2 投影矩阵 设置投影矩阵往往在OpenGL绘图和模型视图变换之前。一般情况下,我们调用 glMatrixMode(GL_PROJECTION); 将当前矩阵设置为投影矩阵。再调用 glOrtho 或 gluPerspective 来创建平行或透视投影。创建完后,再调用 glMatrixMode(GL_MODELVIEW); 将当前变换矩阵设置为模型视图变换矩阵。 至此,你应该能够理解前面章节的示例程序中的 SetView 过程的意义了吧。请再看一次SetView过程:
除了使用OpenGL为我们提供的几个高级变换函数之外,我们还可以自己创建一个矩阵,并使用当前矩阵乘以该矩阵来进行特殊的变换。你可以创建一个4×4的二维数组用于描述一个矩阵。如: M:array[1..4] of array[1..4] of Single; 其中M[j,i]表示矩阵M的第j行,第i列的数据。 你也可以创建一个一维数组: M:array[1..16]of Single; 无论是2维数组还是一维数组,都是按照列优先的顺序保存的。如图4.4-1所示。
矩阵定义完后,调用 glLoadMatrix(M); 可以用矩阵M替换当前矩阵,调用 glMultMatrix(M); 用当前矩阵乘以矩阵M。 要说明的是,使用glLoadMatrix或glMultMatrix的速度没有OpenGL的高级变换函数快。所以如果不是高级变换函数完成不了的变换,就不要使用glLoadMatrix或者glMultMatrix。 4.5 示例程序 这是一个经典的示例程序。它演示了太阳系中地月系与太阳之间的运动关系:月球饶地球转,整个地月系饶太阳转,所有的星球都自转。这个例子很好地展示了矩阵变换的性质和矩阵堆栈的作用。为了增加视觉效果,本程序中加入了光照渲染。同时,我们也加入了纹理贴图,这是为了能看星球自转的景象。有关光照和纹理贴图的详细内容,我们都将会在今后的章节中具体讲解。 以下是渲染过程的代码: procedure TfrmMain.RenderScene; 程序运行结果如图4.5-1。
第五章 颜色、光照和材质 在第四章的示例程序中,我们使用了光照效果,这使得图形更加逼真。本章就要具体讲解OpenGL中的色彩调配和光照系统。学习本章,你将了解: 在未使用光照系统的前提下,我们可以直接为图元指定颜色。在传入顶点之前调用glColor函数,就可以为即将指定的顶点设置颜色。例如: glBegin(GL_TRIANGLES); 将绘制一个红色的三角形。在绘制图元时,OpenGL会自动将图元的第一个顶点的颜色作为整个图元的颜色。但有些时候,你可能希望为一个图元的各个顶点指定不同的颜色,使它们自然过度,就像图5.1-1那样。
这时,我们需要在绘制图元之前调用如下函数: glShadeModel(GL_SMOOTH); 让OpenGL对顶点之间的颜色进行平滑过度。你可以把参数改为GL_FLAT,禁止OpenGL对顶点进行平滑过度。 OpenGL的光照模型是用来模拟现实生活中的光照的。你可以使用OpenGL中的光照模型以产生逼真的光照图象。它根据顶点的法线向量和光源的位置决定顶点的明暗程度,根据顶点的材质和光源中三原色的成分来决定物体将表现出怎样的颜色。有关法线是如何能够决定明暗程度的,在初中物理课本中已经有了详细的讲解,这里不再复述。 值得一提的是材质。OpenGL中的材质并非我们平常所说的组成物体的元素(如木材、金属材质),而是指一个物体对不同颜色的光的反射和吸收程度。比如,在光照系统中,如果一个物体表现为红色,则是因为这个物体吸收了从光源放射出来的绿色和蓝色光,而将绝大多数红色的光反射了出来。正因如此,一旦你开启了光照系统,就要通过指定物体的材质来决定这个物体是什么颜色。既然这样,你可能会想到怎样表现类似金属、玻璃等物质质感,但这些除了要使用光照系统并为它们指定合适的材质外,还要使用纹理贴图来表现质感。有关纹理贴图我们会在今后讲到。 使用OpenGL的光照模型包括以下几个步骤:
环境光 环境光是一种无处不在的光。环境光源放出的光线被认为来自任何方向。因此,当你仅为场景指定环境光时,所有的物体无论法向量如何,都将表现为同样的明暗程度。 点光源 由这种光源放出的光线来自同一点,且方向辐射自四面八方,如图5.3-1所示。
平行光 平行光又称镜面光,这种光线是互相平行的。从手电筒、太阳等物体射出的光线都属于平行光。如图5.3-2所示。
聚光灯 这种光源的光线从一个锥体中射出,在被照射的物体上产生聚光的效果。使用这种光源需要指定光的射出方向以及锥体的顶角α。如图5.3-3所示。
5.3.2 光的成分 对于每一种光源,都有漫射光和平行光两种成分。在OpenGL中,环境光也被作为一种特殊的光源的成分来看待。漫射光是指在光源中能够被漫反射的光的颜色成分(白色则包含所有颜色),而平行光是指光源中所有能够被镜面反射的光的颜色成分。通过指定这两种成分的颜色,就能决定光源是平行光源还是点光源。 OpenGL可以同时为我们提供8个有效的光源。也就是说,我们最多可以同时启用8个光源。它们分别是GL_LIGHT0,GL_LIGHT1,GL_LIGHT2 …… 其中,GL_LIGHT0是最特殊的一个光源。我们可以为GL_LIGHT0指定环境光成分。在本章所有示例代码中,都只用到GL_LIGHT0这个光源。 5.3.3.1 设置环境光 对于GL_LIGHT0,我们可以为其指定环境光成分。我们可以调用glLightf或glLightfv函数来设置光源的各项参数。glLightf用来设置一个数值参数,而glLightfv传入一个数组参数。调用 glLightfv(GL_LIGHT0,GL_AMBIENT,@ambientLight); 来设置场景的环境光。在上述函数调用中,第一个参数表示我们要对GL_LIGHT0进行设置,第二个参数表示我们要设置的是环境光成分,第三个参数则是一个数组,它有4个值,分别表示光源中含有红、绿、蓝三种光线的成分。一般情况下都为1,最后一项为透明度值,一般也为1。完整的代码是这样的: procedure SetLight; 请注意在上述代码的第三行和第四行我们分别调用了glEnable函数开启GL_LIGHT0光源和光照系统。 5.3.3.2 设置漫射光成分 通过对漫射光成分的设置,我们可以产生一个点光源。方法和设置环境光成分相似,只需调用 glLightfv(GL_LIGHT0,GL_DIFFUSE,@DiffuseLight); 即可。其中DiffuseLight是漫射光的颜色成分。一般情况下也为(1,1,1,1)。 5.3.3.3 设置镜面光成分 通过对镜面光成分的设置,我们可以产生一个平行光源。方法和设置漫射光成分相似,只需调用 glLightfv(GL_LIGHT0,GL_SPECULAR,@SpecularLight); 即可。其中SpecularLight是漫射光的颜色成分。可以根据不同需要指定不同的颜色。
5.3.4 设置光源的位置 对于点光源和平行光源,我们常常需要指定光源的位置来产生需要的效果。方法仍然是调用glLightfv函数,仅仅是换换参数而已: glLightfv(GL_LIGHT0,GL_POSITION,@LightPosition); 其中,LightPosition也是一个四维数组,四维数组的前3项依次为光源位置的X,Y,Z分量,第四个值很特殊,一般为1或-1。当LightPosition[4]=-1的时候,表示光源位于距离场景无限远的地方,无论前面设置的X,Y,Z是什么值。当LightPosition[4]=1时,光源的位置就是前三项所指定的位置。 5.3.5 创建聚光灯 5.3.5.1 设置光源剪裁角度 调用 glLightf(GL_LIGHT0,GL_SPOT_CUTOFF,LightCutOff); LightCutOff为聚光灯中图5.3-2所示的α角,即聚光灯圆锥的顶角。默认情况下,这个值是180°,即一个点光源。 5.3.5.2 设置光线衰减系数 我们知道,光线在空气中传播时,由于大气中的悬浮颗粒和小水珠的折射和吸收作用,会使光线的强度不断衰减。为了实现这一效果,我们可以调用 glLightf(GL_LIGHT0,AttenuationWay,SpotAttenuation); 来设置光源的衰减系数。其中AttenuationWay可以取以下几个值: GL_CONSTANT_ATTENUATION -- 表示光线按常熟衰减(与距离无关) GL_LINEAR_ATTENUATION -- 表示光线按距离线性衰减 GL_QUADRATIC_ATTENUATION -- 表示光线按距离以二次函数衰减。 参数 SpotAttenuation为光线的衰减系数。 5.3.5.3 设置光照方向 可以调用 glLightfv(GL_LIGHT0,GL_SPOT_DIRECTION,@SpotDir); 其中,SpotDir是一个三维数组,是一个表示聚光灯照射方向的向量。 5.3.5.4 设置聚光指数 最后,应调用 glLightfv(GL_LIGHT0,GL_SPOT_EXPONENT,SpotExponent); 设置聚光指数。SpotExponent指定了聚光灯的聚光强度。 至此,我们完成了所有可能的光源设置。但仅仅设置光源是不够的,我们还必须在绘制物体之前指定图元的一些参数,以让OpenGL的光照系统能够正常运行。 OpenGL必须通过图元的法线向量来确定图元的明暗程度。只有场景中的物体有了明暗的不同,场景才有立体感。对比图5.4-1和5.4-2,你就能更好地理解这一点。
确定一个平面的法向量是一件相当简单的事情。在一个平面上,随意寻找两个互不平行的向量,它们的外积(叉积)就是这个平面的法线。如图5.4-3所示。
计算两向量的外积可以使用公式:
上述公式中,U、V为两不平行向量,S为U、V的外积(即U×V)。计算完外积之后,我们还需要将得到的向量转换为单位向量。只要将一个向量的x、y、z因子全部除以该向量的模就可以得到单位向量。因此,可以使用下面的代码计算一个三角形的法线向量。 type 请注意应该将三角形的顶点按逆时针顺序传给函数以获得正确结果。 通过计算得到法线向量之后,我们需要在绘制顶点之前调用glNormal函数为顶点或图元指定法线。例如: glBegin(GL_TRIANGLES); 除此之外,也可以为同一图元的不同顶点指定不同法线向量。例如: glBegin(GL_TRIANGLES); 指定了图元的法线之后,我们还需要为其指定相应的材质以决定物体对各种颜色的光的反射程度,这将影响物体表现为何种颜色。和光源的成分相似,材质也分为漫射光反光率、平行光反光率。材质的漫射光成分将决定该材质对环境中的漫射光的反射程度,相似的,平行光成分将决定材质对平行光的反射程度。与设置光源相似,我们只需在绘制图元之前调用glMaterialf或glMaterialfv函数就能对即将绘制的图元的材质的各项参数进行设定。 5.5.1 设置材质对各种光的反光率 调用: glMaterialfv(GL_FRONT,GL_DIFFUSE,@Diffuse); 可以指定材质对漫射光的反射率。其中,第一个参数决定该材质运用于图元的正面还是反面。可以取GL_FRONT(正面),GL_BACK(反面),GL_FRONT_AND_BACK(正反两面)。第2个值表示对何种光进行设置,GL_DIFFUSE表示对漫射光反射率进行设置,可以取GL_AMBIENT(环境光)、GL_DIFFUSE(漫射光)、GL_AMBIENT_AND_DIFFUSE(环境光和漫射光)、GL_SPECULAR(平行/镜面光)。而第三个参数是一个四维数组,这个数组描述了反光率的RGBA值,每一项取值都为0-1之间。例如 Diffuse[1]:=1;Diffuse[2]:=0;Diffuse[3]:=0;Diffuse[4]:=1; 将会使物体在有红色成分的光照下表现为红色。 5.5.2 使用颜色跟踪 在启用光照系统之后,为图元指定颜色变得不太方便。首先我们需要创建一个数组,然后调用glMaterial函数将数组传给材质,以此决定物体的颜色。为了简便,我们可以开启颜色跟踪来简化代码。调用 glEnable(GL_CORLOR_MATERIAL); 启动颜色跟踪,再调用 glColorMaterial(GL_FRONT,GL_AMBIENT_AND_DIFFUSE); 来决定对物体的正面还是反面,对环境光、镜面光还是漫射光进行颜色跟踪。第一个参数可以取GL_FRONT、GL_BACK、GL_FRONT_AND_BACK中的任意一种,第二个参数可以取GL_AMBIENT、GL_DIFFUSE、GL_AMBIENT_AND_DIFFUSE、GL_SPECULAR中的任意一种。 启动颜色跟踪之后,我们就可以像以前一样,使用glColor函数来指定图元的颜色了。这时,OpenGL将自动根据从glColor函数传递的颜色来决定物体材质,省去了我们手工指定材质的麻烦。 例如,要在启用光照系统的前提下绘制一个红色的三角形,可以这样做: glEnable(GL_COLOR_MATERIAL); 5.5.3 材质的镜面指数 当光源中含有镜面光成分,且镜面光较强时,一些光滑的物体便会出现一些高亮的焦点,如图5.4-1。
我们可以通过设置材质的镜面指数来确定光斑的大小和聚焦程度。调用 glMateriali(GL_FRONT,GL_SHININESS,N ); 可以对镜面指数进行设定。如果N值越大,则光斑尺寸越小,物体越有光泽,反之越大。N值可取1-128之间的任意整数。 5.5.4 辐射性材质 有些物体,本身会发光。我们可以设置材质的Emission成分来使物体看起来有发光效果。只需添加如下代码: var LightEmission :array[1..4] of single; 其中,LightEmission表示物体所发光的颜色的RGBA值。 5.6 本章示例程序 至此,我们讲完了所有关于OpenGL光照系统的知识。建议你再回头研究一下第四章的示例程序,因为里面用到了许多光照效果。在本章,我们将修改一下第三章的示例程序,使它看起来更具立体感。我们并没有为立方体的各个面指定不同的颜色,而是对场景增添了光照效果,并为立方体的各个面指定了法线向量。你依然可以使用方向键来旋转这个立方体。程序的最终运行结果如图5.6-1所示。
第六章 纹理贴图 截至目前为止,我们已经能够绘制各种3D物体,并为它们增加光照效果,使它们更具有立体感。但是,在实际运用中,我们不仅仅需要简单地绘制一个物体的轮廓,我们需要绘制蓝天白云、真实的墙壁和物体、人物和树木。要达到这一目的,简单的将模型的轮廓绘制出来时远远不够的。我们需要使用纹理贴图来再现真实的细节。 在计算机硬件高速发展的今天,即使是最低端的显卡也能很快地处理大量的纹理,满足我们的需求。这些条件可以让我们毫无顾虑的使用OpenGL的纹理贴图功能,绘制出形形色色的物体。 本章将是学习OpenGL的最后一个基础章节。本章之后,我们将开始接触一些OpenGL的高级专题。 学习本章内容,你将了解:
6.0 准备工作 由于Delphi为我们所提供的OpenGL头文件是OpenGL1.0版本的,因此里面缺少一些函数的声明,这导致我们不能使用直接调用这些函数。因此我们要手动为OpenGL库添加下面的代码一进行补完: procedure glBindTexture(target: GLEnum; texture: GLuint); stdcall; external opengl32; 6.1 什么是纹理贴图 纹理贴图,从本质上讲,是一个位图图像。在程序中,一个纹理图像就是一个一维或二维数组,存储每一个像素的颜色值(包括R、G、B、A分值,分别存储一个像素的红、绿、蓝、透明度分值)。而运用一个纹理贴图,就是把纹理图像根据纹理坐标对应到图元上。 比如,现在有一个三角形,顶点坐标分别是V1、V2、V3,以及一幅纹理图像,如图6.1-1所示:
现在,我们可以在绘制三角形之前,为顶点V1、V2、V3指定纹理坐标,使每一个顶点的坐标和它的纹理坐标一一对应,如图6.1-2所示:
一旦我们建立起这种对应关系,就可以开启纹理贴图功能,让OpenGL根据顶点的贴图坐标渲染出指定的三角形,如图6.1-3那样:
这就是纹理贴图的原理。 6.2 创建纹理图像 正如上一节所提到的那样,纹理贴图其实是一个二维数组。而对于一般程序而言,纹理贴图可以从位图中读取。你可以定义自己的纹理图像格式,然后使用Delphi提供的TFileStream类读取文件中的像素,但在初学OpenGL的阶段,我们暂时从Bmp文件中读取纹理图像,然后使用OpenGL将这幅图像打包成纹理。OpenGL要求纹理的高度和宽度都必须是2的n次方大小,只有满足这个条件,这个纹理图片才是有效的。一旦我们完成了这一步,我们就可以在任意时刻使用这个纹理图像来渲染图形了。 首先,我们创建一个TBitmap类型的对象,用于读取Bmp文件: var Bit:TBitmap; 接下来,我们可以使用Bitmap对象的ScanLine属性获取这个Bitmap中每一像素的颜色值。有关ScanLine属性的用法,可以参看我早期的一篇文章——《探究Delphi的图形处理 之六 -- 使用ScanLine属性进行高效的图像处理》。这里不再详细讲解。总之,你可以使用下面的代码来获取Bitmap对象中每一点的像素值:
在上述过程中,将要获取像素值的Bitmap对象作为Bit参数传给子程序,子程序将返回Pixels和PixPointer。Pixels存储了该位图图像中的每一点的颜色值,而PixPointer则是每一像素点的指针,通过这个指针我们可以修改像素的值。 一旦获取了像素值,我们就可以将这些数据传给OpenGL,让OpenGL生成一个纹理贴图: glGenTextures(1,@Texture); 上面的代码中,glGenTextures和glBindTexture函数用于创建和绑定纹理对象,稍后我们会详细讲解;glTexImage2D函数将Pixels数组中的像素值传给当前绑定的纹理对象,于是便创建了纹理。glTexImage函数的参数分别是纹理的类型,纹理的等级(稍后讲解),每个像素的字节数,纹理图像的宽度和高度,边框大小,像素数据的格式,像素值的数据类型,像素数据。 像素数据的格式可以取下面几种值: GL_RGB - 像素值为RGB颜色 6.3 OpenGL中的贴图方式 OpenGL为我们提供了三种纹理——GL_TEXTURE_1D、GL_TEXTURE_2D和GL_TEXTURE_3D。它们分别表示1维纹理、2维纹理和3维纹理。无论是哪一中纹理,使用方法都是相同的:即先创建一个纹理对象和一个存储纹理数据的n维数组,在调用glTexImageN D函数来传入相应的纹理数据。除此之外,我们可以一些函数来设置纹理的其他特性。 6.3.1 设置贴图模式 OpenGL提供了3种不同的贴图模式:GL_MODULATE,GL_DECAL和GL_BLEND。默认情况下,贴图模式是GL_MODULATE,在这种模式下,OpenGL会根据当前的光照系统调整物体的色彩和明暗。第二种模式是GL_DECAL,在这种模式下所有的光照效果都是无效的,OpenGL将仅依据纹理贴图来绘制物体的表面。最后是GL_BLEND,这种模式允许我们使用混合纹理。在这种模式下,我们可以把当前纹理同一个颜色混合而得到一个新的纹理。我们可以调用glTexEnvi函数来设置当前贴图模式: glTexEnvi(GL_TEXTURE_ENV,GL_TEXTURE_ENV_MODE,TextureMode); 其中TextureMode就是想要设置的纹理模式,可以为GL_MODULATE,GL_DECAL和GL_BLEND中的任何一种。 另外,对于GL_BLEND模式,我们可以调用 glTexEnvfv(GL_TEXUTRE_ENV,GL_TEXTURE_ENV_COLOR,@ColorRGBA); 其中,ColorRGBA为一个表示RGBA颜色的4维数组。 6.3.2 纹理滤镜 在纹理映射的过程中,如果图元的大小不等于纹理的大小,OpenGL便会对纹理进行缩放以适应图元的尺寸。我们可以通过设置纹理滤镜来决定OpenGL对某个纹理采用的放大、缩小的算法。 调用glTexParameter来设置纹理滤镜。如: glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILETER, MagFilter);//设置放大滤镜 上述调用中,第一个参数表明是针对何种纹理进行设置,第二个参数表示要设置放大滤镜还是缩小滤镜。第三个参数表示使用的滤镜。可以为下面的值之一:
有关多贴图的内容,我们稍候将详细讲解。 至此,我们已经可以产生一个纹理,并为它指定各种参数。但我们在绘制图元时必须进行纹理映射,才能将纹理贴在图元表面上。 6.4 纹理映射 6.4.1 纹理坐标 要使用当前的纹理绘制图元,我们必须在绘制每个顶点之前为该顶点指定纹理坐标。只需调用 glTexCoord2d(s:Double;t:Double); 函数即可。其中,s、t是对于2D纹理而言的s、t坐标。对于任何纹理,它的纹理坐标都如同图6.4-1所示的那样:
对于任何纹理,无论纹理的真正大小如何,其顶端(左上角)的纹理坐标恒为(0,0),右下角的纹理坐标恒为(1,1)。也就是说,纹理坐标应是一个介于0到1之间的一个小数。 例如,下面的代码将使用当前纹理绘制一个三角形: var Tex:GLUInt; glTexCoord2d(1,1); glEnd; 6.4.2 纹理缠绕 前面提到,纹理坐标应位于0-1之间。那么当纹理坐标大于这个值会出现什么情况呢? 我们可以对OpenGL进行设置,以决定当纹理坐标不位于这一区间时应采取的操作。我们可以指定两种操作:GL_CLAMP和GL_REPEAT。对于GL_CLAMP,超出纹理坐标的区域会使用纹理图像的边界颜色来代替,如图6.4-2所示。
而GL_REPEAT方式则是对纹理坐标进行重置而得到重复的图像。观察图6.4-3,你就能很容易地发现这一点。
可以调用glTexParameter设置缠绕方式: glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_WRAP_S,WrapMode);//在s方向上的缠绕方式 其中,WrapMode可取GL_CLAMP或者GL_REPEAT。 6.5 纹理对象 在前面的小节中,我们一直都在接触纹理对象,这里将详细地说明。 创建和使用文理对象 在OpenGL中,我们使用glGenTextures创建纹理对象: glGenTextures(Count:Integer;TexObjs:Pointer); 其中,Count是我们要创建的纹理数目,当我们只想创建一个纹理时,只需调用 var Texture:GLUint; 这样,Texture变量中就存储了我们创建的纹理的ID号。 创建之后,我们使用glBindTexture将创建的纹理绑定到当前纹理。这样所有的纹理函数都将针对当前纹理。 glBindTexture(Texture:GLUint); 这样,我们就可以调用glTexParameter、glTexImage2D等函数来设置这个纹理对象了。 删除纹理对象 在纹理资源使用完毕后(一般是程序退出或场景转换时),一定要删除纹理对象,释放资源。 调用 glDeleteTextures(Count:Integer;TexObj:Pointer); 来删除纹理对象。例如 glDeleteTextures(1,@Texture); 6.6 多贴图纹理 多贴图纹理(Mip Mapping)为一个纹理对象生成不同尺寸的图像。在需要时,根据绘制图形的大小来决定采用的纹理等级或者在不同的纹理等级之间进行线性内插。使用多贴图纹理的好处在于消除纹理躁动。这种情况在所绘制的景物离观察者较远时常常发生(如图6.6-1和6.6-2)。由于多贴图纹理现在的渲染速度已经很快,以至于和普通纹理没有什么区别,我们现在一般都使用多贴图纹理。
使用多贴图纹理并不麻烦。首先,我们需要创建不同等级(尺寸)的纹理图片。我们需要调用n次glTexImage2D函数,生成不同等级的纹理贴图。例如: 这些函数调用的第二个参数表示当前纹理的等级。0级的分辨率最大。之后,每一级的分辨率是上一级分辨率的一半。这样的函数调用应一直进行下去,直至图像的高度和宽度都为1。 但有时候,这样做总并不是很方便。我们可以借助一个glu函数帮我们自动生成这些多贴图纹理。只需要把生成纹理图像的函数调用由glTexImage2D改为gluBuild2DMipMaps即可: gluBuild2DMipMaps(GL_TEXTURE_2D,3,Bit.Width,Bit.Height,0,GL_RGB,GL_UNSIGNED_BYTE,Pixels); 此外,还必须把纹理的滤镜改为MIP_MAP滤镜。例如: glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR_MIPMAP_LINEAR); 有关滤镜的参数可以参考表6.3-1。 6.7 本章代码综合 由于本章涉及到许多OpenGL的纹理函数,而且从文件创建纹理对象是一个极其复杂的过程,因此我们把本章的函数封装成类。使用这个类,我们可以很轻松地完成纹理的载入和设置。我们的类结构如下: THyTexture = Class(TObject) private FPixels : array of array of TColor4ub; //纹理贴图的像素数据 FTextureID: GLuint; //纹理对象的id号 FHeight: Integer; FWidth: Integer; FTransparentColor: THyPixel; procedure SetHeight(const Value: Integer); procedure SetWidth(const Value: Integer); procedure SetTransparentColor(const Value: THyPixel); //将某种颜色设置为透明色 function GetPixel(X, Y: Integer): TColor4ub; procedure SetPixel(X, Y: Integer; const Value: TColor4ub); public constructor Create; procedure Free; procedure Bind; procedure Generate; //创建纹理对象 procedure LoadFromFile(FileName:String); //从文件载入 procedure LoadFromBitmap(Bit:TBitmap); //从位图载入 property Pixels[X,Y:Integer]:TColor4ub read GetPixel write SetPixel; property TextureID:GLuint read FTextureID; property Width:Integer read FWidth write SetWidth; property Height:Integer read FHeight write SetHeight; property TransparentColor:THyPixel read FTransparentColor write SetTransparentColor; end; 这个类使用起来将非常方便: var Tex:ThyTexture; ... <指定纹理坐标并绘制图形...> ... 同时,我们还支持了Alpha通道。以下给出该类的代码。
6.8 本章示例程序 在这一章中,我们将使用前面的木箱纹理包装第五章中的示例程序。在本章的示例程序中,我们使用了6.7节中所创建的类。阅读这个程序,你就能体会到使用面向对象的编程方法的好处。本章示例程序的截图如图6.8-1所示。
仔细观察上面的截图,你可能会发现一个问题。这个问题实际上在第五章的示例程序中遗留下来的。那就是这个立方体看起来明暗过度并不自然。有些地方显得较黑,而有些地方显得又太亮。更让人无法忍受的是,当你旋转立方体时,感觉光照的效果几乎没有任何过度,着色起来十分不自然。图6.8-2是第五章示例程序中的一个截图,它很能够说明这个问题。
这是什么原因呢?是OpenGL技术上的问题?当然不是。我们知道,OpenGL处理明暗实际上是根据各个顶点的法线向量来决定该顶点的颜色的。在完成对顶点颜色的计算之后,OpenGL会利用明暗模型对顶点之间的颜色进行过度。而这样的过度是线性的,很多时候不能表现出逼真的光照效果。因此,要得到更好的画面,我们就要传入更多的顶点,让OpenGL进行更多的光照运算而不是简单的线性过度。如此以来,我们就能获得高质量的画面。 这好比你要绘制一条曲线,而在计算机绘图函数中,并没有直接提供绘制曲线的功能。这样,我们便会使用许许多多的较短的线段来逼近真实的曲线。而绘制的线段越多,最终的图形就越接近于真实的曲线。这样绘制图形的方法叫做图形的镶嵌。 既然如此,我们可以使用下面的代码来绘制立方体的一个面: procedure DrawSurface(X1, Y1, X2, Y2: Single; 借助矩阵变换,我们可以用下面的代码来绘制立方体: procedure TfrmMain.DrawCube; begin glPushMatrix; glTranslate(0,-10,0); glRotate(90,1,0,0); DrawSurface(-10,-10,10,10,0.025,0.025,40); glPopMatrix; glPushMatrix; glTranslate(0,10,0); glRotate(-90,1,0,0); DrawSurface(-10,-10,10,10,0.025,0.025,40); glPopMatrix; glPushMatrix; glTranslate(0,0,10); DrawSurface(-10,-10,10,10,0.025,0.025,40); glPopMatrix; glPushMatrix; glTranslate(0,0,-10); glRotate(180,0,1,0); DrawSurface(-10,-10,10,10,0.025,0.025,40); glPopMatrix; glPushMatrix; glTranslate(-10,0,0); glRotate(-90,0,1,0); DrawSurface(-10,-10,10,10,0.025,0.025,40); glPopMatrix; glPushMatrix; glTranslate(10,0,0); glRotate(90,0,1,0); DrawSurface(-10,-10,10,10,0.025,0.025,40); glPopMatrix; end; 由此,我们得到了更好的画面。图6.8-3显示了这个立方体在聚光灯的照射下明暗过度自然的渲染结果。
第七章 使用3D模型 通过前6章的学习你应该完全掌握了使用OpenGL绘制物体的所有内容。这包括对基本图元的渲染,颜色和光照以及上一章所讲述的纹理贴图。运用上面的知识,我们已经具备了完整的渲染任何一个3D模型的能力。但在真正的游戏中,我们要绘制的3D物体往往十分复杂,对于这样的情况,使用代码来创建模型显得即复杂又不直观,难以调试。因此,我们通常的做法都是在专门的建模工具(如3DS Max, Maya等)中创建模型,再将模型导出为特定的格式,然后在我们的程序载入这个模型。这正是本章的主题:3D模型。 在计算机平台上,常用的3D模型有3DS、OBJ、MD2、MD3、MDL等多种格式。这些格式在存储方式存在很大的差别,但基本思想大同小异。3D模型又分为动态模型和静态模型。在动态模型中除了保存模型的定点和表面数据之外,还有与动画相关的信息。这些动态模型在渲染起来较为复杂。在这里,我们只介绍一种简单的静态模型格式——OBJ模型。 学习本章,你将了解:
7.1 使用建模工具创建模型 要渲染一个模型,首先要有一个模型文件。目前绝大多数流行的建模软件都支持将模型输出为OBJ文件。这里以3DS Max为例,介绍创建OBJ模型的方法。 首先,启动3DS Max,并向往常一样创建模型。如图7.1-1所示。
模型创建完后,执行文件(File)菜单->导出(Export)命令。在弹出的保存对话框中,输入文件名,并在“保存类型”项中,选则“Wavefront Object (*.obj)”项。如图7.1-2所示。
单击“保存”按钮后,会弹出如图7.1-3所示的选项对话框。
在这里,请注意在Faces栏中,选择Triangles以使用三角形作为基本图元。还要注意钩去Relative Vertex一项,禁止使用相对顶点索引。这是因为在后面的程序中,我们将不考虑相对顶点索引以简化代码。 单击“OK”按钮后,会紧接着弹出另一个选项对话框,如图7.1-4所示。
这一步是在设置与此OBJ文件对应的MTL文件(用于保存材质信息),注意要选择"Scene"并勾选"Export"复选框。然后单击“OK”按钮完成设置。这样,在指定的文件夹下,我们得到了两个文件:一个*.OBJ文件,存储了模型的顶点、法线和纹理坐标信息;一个*.MTL文件,存储了该模型所用的材质信息。 7.2 OBJ文件格式 OBJ文件格式是非常简单的。这种文件以纯文本的形式存储了模型的顶点、法线和纹理坐标和材质使用信息。OBJ文件的每一行,都有极其相似的格式。在OBJ文件中,每行的格式如下:
其中,前缀标识了这一行所存储的信息类型。参数则是具体的数据。OBJ文件的前缀可以有:
我们使用3DS Max创建了一个长方体,并保存为OBJ格式。用写字板打开这个OBJ文件,可以看到如下内容:
仔细观察,你会发现这个文件中包含了一些我们没有提到的前缀,如以“#”开头的注释,以g开头的表示组的前缀等等。但这些前缀并不影响模型的外观,因此我们可以忽略它们。 在解释以f为前缀的行的格式之前,我们不得不提一个新的概念,这就是顶点索引(Vertex Indices)。我们知道,对于每一个三角形,都需要用3个顶点来表示。例如在上面的立方体模型中,一共有6×2×3=36个顶点。仔细想想就会知道,在这36个顶点中,又相当数量的顶点是重合的。如果把这些重合的顶点都一一表示出来,就太浪费存储空间了。于是,我们提出了顶点索引的想法,解决空间占用问题。顶点索引的思想是建立两个数组,一个数组用于存储模型中所有的顶点坐标值,另一个数组则存储每一个表面所对应的三个顶点在第一个数组中的索引。图7.2-1显示了这种一一对应的关系。
建立这样的顶点索引显然更加节约存储空间。 假设Indices:array of Integer是顶点索引数组,Vertices:array of TVertex是顶点数组,使用下面的代码段就可以把整个顶点索引对应的所有三角形绘制出来: procedure DrawIndex(Indices:array of Integer;Vertices:array of TVertex); var i :Integer; begin glBegin(GL_TRIANGLES); for i := 0 to (High(Vertices)+1) div 3 -1 do begin glVertex3fv(@Vertices[Indices[i*3]]); glVertex3fv(@Vertices[Indices[i*3+1]]); glVertex3fv(@Vertices[Indices[i*3+2]]); end; glEnd; end; 以此类推,我们可以为模型中所有的法线、纹理坐标都建立起相应的索引,以节省更多的空间。而事实上,OBJ文件就是这么做的。 现在,我们再来看一下OBJ文件的结构。在一个OBJ文件中,首先有一些以v、vt或vn前缀开头的行指定了所有的顶点、纹理坐标、法线的坐标。然后再由一些以f开头的行指定每一个三角形所对应的顶点、纹理坐标和法线的索引。在顶点、纹理坐标和法线的索引之间,使用符号“/”隔开的。一个f行可以以下面几种格式出现: f 1 2 3 这样的行表示以第1、2、3号顶点组成一个三角形。 f 1/3 2/5 3/4 这样的行表示以第1、2、3号顶点组成一个三角形,其中第一个顶点的纹理坐标的索引值为3,第二个顶点的纹理坐标的索引值为5,第三个顶点的纹理坐标的索引值为4。 f 1/3/4 2/5/6 3/4/2 这样的行表示以第1、2、3号顶点组成一个三角形,其中第一个顶点的纹理坐标的索引值为3,其法线的索引值是4;第二个顶点的纹理坐标的索引值为5,其法线的索引值是6;第三个顶点的纹理坐标的索引值为6,其法线的索引值是2。 f 1//4 2//6 3//2 这样的行表示以第1、2、3号顶点组成一个三角形,且忽略纹理坐标。其中第一个顶点的法线的索引值是4;第二个顶点的法线的索引值是6;第三个顶点的法线的索引值是2。 值得注意的是文件中的索引值是以1作为起点的,这一点与Delphi中以0作为起点有很大的不同。在渲染的时候应注意将从文件中读取的坐标值减去1。 7.3 用于存储材质信息的MTL文件 MTL文件与OBJ文件极其相似。只是用于标识行的前缀有所不同。这些前缀的意义如表7.3-1所示。
由这些信息,我们就足以写一个类,用于读取和渲染OBJ模型了。 7.4 读取OBJ模型 为了保存这些模型数据,你最好建立6个动态数组,分别存储顶点数据、顶点索引、法线数据、法线索引、纹理坐标数据、纹理坐标索引。读取文件时,把文件中的数据存入数组中。 要读取一个文本文件,我们可以使用Delphi中自带的TStringList类。TStringList的用法十分简单,创建实例之后,执行 TStringList.LoadFromFile(FileName)就可以载入文本文件了。 载入完之后,可以使用TStringList的Strings属性来获取文本文件每一行的值。如 Line5:=StringList.Strings[4]; //读取第5行 载入模型的重点是读取这些数据,我们可以建立一个循环,循环判别每一行的前缀,然后根据不同的前缀执行不同的操作。你可以这样写代码: procedure LoadFromFile(FileName:String); var i:Integer; sl:TStringList; prefix:String; begin sl:=TStringList.Create; for i := 0 to sl.Count-1 do begin prefix := ReadPrefix(SL.Strings[i]); if prefix = 'v' then //处理顶点数据 ... //用于读取顶点数据的代码 else if prefix = 'vt' then //处理纹理坐标数据 ... //用于读取纹理坐标数据的代码 ... //处理其他前缀行 end; sl.Free; end; 此外,你应该建立一个函数用于分割和提取每一行中的各个参数。对于这个函数,你可以参考下面的代码。
在OBJ文件中,与其关联的MTL文件是由一个以mtllib开头的行指定的。当读到mtllib时,你应该开始载入mtllib行所指定的MTL文件。你可以参考下面的做法: 首先,定义
TMaterials = array of TMaterial; 用于存储所有的材质。然后使用下面的代码来读取MTL文件:
7.5 渲染模型 所有的数据读取完毕,我们就可以渲染了。渲染程序的代码十分简单,这里就不再讲解了。你可以参考下面的代码。
7.7 TObjMdl类的完整代码 以下代码已包含在示例程序中。其中引用的单元(GLInit,RapidUI)全部可以在http://program.上下载。
|
|