第四章 矩阵变换
通过前三章的学习,我们知道了如何使用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代表球体的半径
var SpObj:GLUQuadricObj;
begin
spObj:=gluNewQuadric;
gluQuadricNormals(SpObj,GLU_SMOOTH);
gluQuadricOrientation(SpObj,GLU_OUTSIDE);
gluSphere(SpObj,R,50,50);
gluDeleteQuadric(spObj);
end;
4.3.1.1 平移
当我们调用DrawSphere时,会在原点绘制一个球体。现在我们想在点(0,10,0)上绘制这个球体,就必须在绘制之前将坐标系沿+Y方向平移10个单位。于是我们会写出这样的代码:
//建立一个将坐标系沿+Y方向平移10个单位的矩阵:
....
//用当前模型视图矩阵乘以这个矩阵:
...
DrawSphere(5);//绘制一个半径为5的球体
但事实上,我们不需要这么麻烦。OpenGL为我们提供了这样一个函数:
glTranslatef(x,y,z:Single);
其中,x,y,z分别表示在X、Y、Z轴上平移的量。调用这个函数之后,OpenGL会自动生成一个平移矩阵,然后应用这个矩阵。因此,我们可以这样写代码:
glTranslatef(0,10,0);
DrawSphere(5);
这样就能在(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);
DrawSphere(5);
4.3.1.3 缩放
缩放变换其实是将坐标系的x、y、z轴按不同的缩放因子展宽,从而实现缩放效果。函数
glScalef(x,y,z:Single);
把坐标系的X、Y、Z轴分别缩放x、y、z倍。例如:
glScalef(2,2,2);
DrawSphere(5);
将绘制一个半径为10的球体。
4.3.1.4 变换的叠加性质
使用变换时,我们应该注意的是,变换是叠加在上次变换的基础上的。也就是说,变换的效果会累积。每次调用变换函数时,会生成一个新的函数来乘以当前的模型 视图矩阵,随后,新的矩阵将成为当前的模型变换矩阵,在下次执行变换时,会被新的矩阵相乘,因此作用效果将不断累积。举个例子就能很明白地说明这一点。
例如,你想在(0,10,0)上绘制一个球体,完后在(10,0,0)上绘制另一个,得到如图4.3-1所示的图形:
图4.3-1 |
你可能会写出如下代码:
//沿Y轴向上平移10个单位
glTranslatef(0,10,0);
//画第一个球体
DrawSphere(5);
//沿X轴向左平移10个单位
glTranslatef(10,0,0);
//画第二个球体
DrawSphere(5);
然而,你不应该忘记,变换的作用效果是累积的。在绘制第二个球体时,由于此时坐标系已经向Y轴移动了10个单位,再向X方向移动10个单位之后,新的坐标系的原点应是绝对坐标系中的点(10,10)。因此,上述程序将绘制出如图4.3-2所示的图形。
图4.3-2 |
你可能会在绘制第二个球体之前调用glTranslatef(0,-10,0);把坐标系往回移动10个单位。但这样会降低代码的可读性,还会给CPU增加额外的运算。这个时候,我们可以使用单位矩阵。
我们可以调用glLoadIdentity();函数将当前模型视图变换矩阵重置到初始状态,再进行新的绘制:
procedure RenderScene();
begin
glMatrixMode(GL_MODELVIEW);
//沿Y轴向上平移10个单位
glTranslatef(0,10,0);
//画第一个球体
DrawSphere(5);
//加载单位矩阵
glLoadIdentity;
//沿X轴向上平移10个单位
glTranslatef(10,0,0);
//画第二个球体
DrawSphere(5);
end;
请看第一行代码。这里调用了glMatrixMode函数。这个函数的作用是通知OpenGL我们将对模型视图变换矩阵进行操作。也就是要进行模型视图变换。glMatrixMode可用参数如下:
GL_PROJECTION :用于修改投影矩阵
GL_MODELVIEW :用于修改模型视图变换矩阵
4.3.1.5 矩阵堆栈
如果每次变换前都把当前矩阵恢复到单位矩阵,也比较麻烦。更多时候,我们希望保存当前矩阵,执行一些变换之后,把当前矩阵恢复到上次保存时的状态。
OpenGL为我们提供了一个“矩阵堆栈”满足我们的这种要求。我们可以把当前矩阵压入堆栈中,然后执行一些变换,再弹出刚才压入的矩阵,从而把当前矩阵恢复到上次变换之前的状态。我们调用
glPushMatrix();
把当前矩阵压入矩阵堆栈,调用
glPopMatrix();
弹出矩阵。我们还可以分别调用
glGet(GL_MAX_MODELVIEW_STACK_DEPTH);
glGet(GL_MAX_PROJECTION_STACK_DEPTH);
来获取模型视图矩阵堆栈和投影矩阵堆栈的最大堆栈深度。一般情况下(在Windows平台上),模型视图的最大堆栈深度是32,而投影堆栈的最大深度是2。
使用矩阵堆栈,4.3.1.4节中的程序可以改写为:
procedure RenderScene();
begin
glMatrixMode(GL_MODELVIEW);
//推入矩阵堆栈
glPushMatrix;
//沿Y轴向上平移10个单位
glTranslatef(0,10,0);
//画第一个球体
DrawSphere(5);
//恢复到上次保存时的状态
glPopMatrix;
//沿X轴向左平移10个单位
glTranslatef(10,0,0);
//画第二个球体
DrawSphere(5);
end;
4.3.2 投影矩阵
设置投影矩阵往往在OpenGL绘图和模型视图变换之前。一般情况下,我们调用
glMatrixMode(GL_PROJECTION);
将当前矩阵设置为投影矩阵。再调用
glOrtho 或 gluPerspective 来创建平行或透视投影。创建完后,再调用
glMatrixMode(GL_MODELVIEW);
将当前变换矩阵设置为模型视图变换矩阵。
至此,你应该能够理解前面章节的示例程序中的 SetView 过程的意义了吧。请再看一次SetView过程:
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;
除了使用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所示。
图4.4-1 |
矩阵定义完后,调用
glLoadMatrix(M);
可以用矩阵M替换当前矩阵,调用
glMultMatrix(M);
用当前矩阵乘以矩阵M。
要说明的是,使用glLoadMatrix或glMultMatrix的速度没有OpenGL的高级变换函数快。所以如果不是高级变换函数完成不了的变换,就不要使用glLoadMatrix或者glMultMatrix。
4.5 示例程序
这是一个经典的示例程序。它演示了太阳系中地月系与太阳之间的运动关系:月球饶地球转,整个地月系饶太阳转,所有的星球都自转。这个例子很好地展示了矩阵 变换的性质和矩阵堆栈的作用。为了增加视觉效果,本程序中加入了光照渲染。同时,我们也加入了纹理贴图,这是为了能看星球自转的景象。有关光照和纹理贴图 的详细内容,我们都将会在今后的章节中具体讲解。
以下是渲染过程的代码:
procedure TfrmMain.RenderScene;
begin
glEnable(GL_CULL_FACE);
glClear(GL_COLOR_BUFFER_BIT OR GL_DEPTH_BUFFER_BIT);
glLoadIdentity;
glTranslatef(0,0,-110);
glRotatef(yDeg,0,1,0);
glRotatef(xDeg,1,0,1);
RenderLights;//光照处理
//绘制太阳
glColor3ub(255,100,64);
glPushMatrix;
glRotate(SunSelfAng,0,1,0);
DrawSphere(10);
glPopMatrix;
glPushMatrix;//推入当前矩阵
//绘制地月系
glRotatef(EarthCommonAng,0,1,0);
glTranslatef(50,0,0);
glPushMatrix;//绘制地球
glRotatef(EarthSelfAng,0,1,0);
glColor3ub(20,50,255);
DrawSphere(5);
glPopMatrix;
glPushMatrix;//绘制月球
glColor3ub(200,200,200);
glRotatef(MoonAng,0,1,0);
glTranslatef(10,0,0);
glRotatef(MoonSelfAng,0,1,0);
DrawSphere(2);
glPopMatrix;
glPopMatrix;//弹出矩阵
SwapBuffers(wglGetCurrentDC);
end;