2024年5月9日发(作者:)

显示列表的内容就是这么多了,下面我们看一个例子。

假设我们需要绘制一个旋转的彩色正四面体,则可以这样考虑:设置一个全局变量an

gle,然后让它的值不断的增加(到达360后又恢复为0,周而复始)。每次需要绘制图形

时,根据angle的值进行旋转,然后绘制正四面体。这里正四面体采用显示列表来实现,

即把绘制正四面体的若干OpenGL函数装到一个显示列表中,然后每次需要绘制时,调用

这个显示列表即可。

将正四面体的四个顶点颜色分别设置为红、黄、绿、蓝,通过数学计算,将坐标设置

为:

(-0.5, -5*sqrt(5)/48, sqrt(3)/6),

( 0.5, -5*sqrt(5)/48, sqrt(3)/6),

( 0, -5*sqrt(5)/48, -sqrt(3)/3),

( 0, 11*sqrt(6)/48, 0)

2007年4月24日修正:以上结果有误,通过计算AB, AC, AD, BC, BD, CD的长度,

发现AD, BD, CD的长度与1.0有较大偏差。正确的坐标应该是:

A点:( 0.5, -sqrt(6)/12, -sqrt(3)/6)

B点:( -0.5, -sqrt(6)/12, -sqrt(3)/6)

C点:( 0, -sqrt(6)/12, sqrt(3)/3)

D点:( 0, sqrt(6)/4, 0)

程序代码中也做了相应的修改

下面给出程序代码,大家可以从中体会一下显示列表的用法。

#include

#define WIDTH 400

#define HEIGHT 400

#include

#define ColoredVertex(c, v) do{ glColor3fv(c); glVertex3fv(v); }while(0)

GLfloat angle = 0.0f;

void myDisplay(void)

{

static int list = 0;

if( list == 0 )

{

// 如果显示列表不存在,则创建

/* GLfloat

PointA[] = {-0.5, -5*sqrt(5)/48, sqrt(3)/6},

PointB[] = { 0.5, -5*sqrt(5)/48, sqrt(3)/6},

PointC[] = { 0, -5*sqrt(5)/48, -sqrt(3)/3},

PointD[] = { 0, 11*sqrt(6)/48, 0}; */

// 2007年4月27日修改

GLfloat

PointA[] = { 0.5f, -sqrt(6.0f)/12, -sqrt(3.0f)/6},

PointB[] = {-0.5f, -sqrt(6.0f)/12, -sqrt(3.0f)/6},

PointC[] = { 0.0f, -sqrt(6.0f)/12, sqrt(3.0f)/3},

PointD[] = { 0.0f, sqrt(6.0f)/4, 0};

GLfloat

ColorR[] = {1, 0, 0},

ColorG[] = {0, 1, 0},

ColorB[] = {0, 0, 1},

ColorY[] = {1, 1, 0};

list = glGenLists(1);

glNewList(list, GL_COMPILE);

glBegin(GL_TRIANGLES);

// 平面ABC

ColoredVertex(ColorR, PointA);

ColoredVertex(ColorG, PointB);

ColoredVertex(ColorB, PointC);

// 平面ACD

ColoredVertex(ColorR, PointA);

ColoredVertex(ColorB, PointC);

ColoredVertex(ColorY, PointD);

// 平面CBD

ColoredVertex(ColorB, PointC);

ColoredVertex(ColorG, PointB);

ColoredVertex(ColorY, PointD);

// 平面BAD

ColoredVertex(ColorG, PointB);

ColoredVertex(ColorR, PointA);

ColoredVertex(ColorY, PointD);

glEnd();

glEndList();

glEnable(GL_DEPTH_TEST);

}

// 已经创建了显示列表,在每次绘制正四面体时将调用它

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

glPushMatrix();

glRotatef(angle, 1, 0.5, 0);

glCallList(list);

glPopMatrix();

glutSwapBuffers();

}

void myIdle(void)

{

++angle;

if( angle >= 360.0f )

angle = 0.0f;

myDisplay();

}

int main(int argc, char* argv[])

{

glutInit(&argc, argv);

glutInitDisplayMode(GLUT_RGBA | GLUT_DOUBLE);

glutInitWindowPosition(200, 200);

glutInitWindowSize(WIDTH, HEIGHT);

glutCreateWindow("OpenGL 窗口");

glutDisplayFunc(&myDisplay);

glutIdleFunc(&myIdle);

glutMainLoop();

return 0;

}

在程序中,我们将绘制正四面体的OpenGL函数装到了一个显示列表中,但是,关于

旋转的操作却在显示列表之外进行。这是因为如果把旋转的操作也装入到显示列表,则每

次旋转的角度都是一样的,不会随着angle的值的变化而变化,于是就不能表现出动态的

旋转效果了。

程序运行时,可能感觉到画面的立体感不足,这主要是因为没有使用光照的缘故。如

果将glColor3fv函数去掉,改为设置各种材质,然后开启光照效果,则可以产生更好的立

体感。大家可以自己试着使用光照效果,唯一需要注意的地方就是法线向量的计算。由于

这里的正四面体四个顶点坐标选取得比较特殊,使得正四面体的中心坐标正好是(0, 0, 0),

因此,每三个顶点坐标的平均值正好就是这三个顶点所组成的平面的法线向量的值。

void setNormal(GLfloat* Point1, GLfloat* Point2, GLfloat* Point3)

{

GLfloat normal[3];

int i;

for(i=0; i<3; ++i)

normal[i] = (Point1[i]+Point2[i]+Point3[i]) / 3;

glNormal3fv(normal);

}

限于篇幅,这里就不给出完整的程序了。不过,大家可以自行尝试,看看使用光照后

效果有何种改观。尤其是注意四面体各个表面交界的位置,在未使用光照前,几乎看不清

轮廓,在使用光照后,可比较容易的区分各个平面,因此立体感得到加强。(见图1,图2)

当然了,这样的效果还不够。如果在各表面的交界处设置很多细小的平面,进行平滑处理,

则光照后的效果将更真实。但这已经远离本课的内容了。

图一

图二

小结

本课介绍了显示列表的知识和简单的应用。

可以把各种OpenGL函数调用的动作装到显示列表中,以后调用显示列表,就相当于

调用了其中的OpenGL函数。显示列表中除了存放对OpenGL函数的调用外,不会存放

其它内容。

使用显示列表的过程是:分配一个未使用的显示列表编号,把OpenGL函数调用装入

显示列表,调用显示列表,销毁显示列表。

使用显示列表有可能带来程序运行速度的提升,但是这种提升并不一定会很明显。显

示列表本身也存在一定的开销。

把绘制固定的物体的OpenGL函数放到一个显示列表中,是一种不错的编程思路。本

课最后的例子中使用了这种思路。

9. 第九课:

今天介绍关于OpenGL混合的基本知识。混合是一种常用的技巧,通常可以用来实现

半透明。但其实它也是十分灵活的,你可以通过不同的设置得到不同的混合结果,产生一

些有趣或者奇怪的图象。

混合是什么呢?混合就是把两种颜色混在一起。具体一点,就是把某一像素位置原来

的颜色和将要画上去的颜色,通过某种方式混在一起,从而实现特殊的效果。

假设我们需要绘制这样一个场景:透过红色的玻璃去看绿色的物体,那么可以先绘制

绿色的物体,再绘制红色玻璃。在绘制红色玻璃的时候,利用“混合”功能,把将要绘制

上去的红色和原来的绿色进行混合,于是得到一种新的颜色,看上去就好像玻璃是半透明

的。

要使用OpenGL的混合功能,只需要调用:glEnable(GL_BLEND);即可。

要关闭OpenGL的混合功能,只需要调用:glDisable(GL_BLEND);即可。

注意:只有在RGBA模式下,才可以使用混合功能,颜色索引模式下是无法使用混合

功能的。

一、 源因子和目标因子

二、 前面我们已经提到,混合需要把原来的颜色和将要画上去的颜色找出来,经过

某种方式处理后得到一种新的颜色。这里把将要画上去的颜色称为“源颜色”,把原来的

颜色称为“目标颜色”。

三、 OpenGL会把源颜色和目标颜色各自取出,并乘以一个系数(源颜色乘以的系

数称为“源因子”,目标颜色乘以的系数称为“目标因子”),然后相加,这样就得到了新

的颜色。(也可以不是相加,新版本的OpenGL可以设置运算方式,包括加、减、取两者

中较大的、取两者中较小的、逻辑运算等,但我们这里为了简单起见,不讨论这个了)

四、 下面用数学公式来表达一下这个运算方式。假设源颜色的四个分量(指红色,

绿色,蓝色,alpha值)是(Rs, Gs, Bs, As),目标颜色的四个分量是(Rd, Gd, Bd, Ad),又

设源因子为(Sr, Sg, Sb, Sa),目标因子为(Dr, Dg, Db, Da)。则混合产生的新颜色可以表示

为:

五、 (Rs*Sr+Rd*Dr, Gs*Sg+Gd*Dg, Bs*Sb+Bd*Db, As*Sa+Ad*Da)

六、 当然了,如果颜色的某一分量超过了1.0,则它会被自动截取为1.0,不需要考

虑越界的问题。

七、 源因子和目标因子是可以通过glBlendFunc函数来进行设置的。glBlendFunc

有两个参数,前者表示源因子,后者表示目标因子。这两个参数可以是多种值,下面介绍

比较常用的几种。

八、 GL_ZERO: 表示使用0.0作为因子,实际上相当于不使用这种颜色参与混

合运算。

九、 GL_ONE: 表示使用1.0作为因子,实际上相当于完全的使用了这种颜色

参与混合运算。

十、 GL_SRC_ALPHA:表示使用源颜色的alpha值来作为因子。

十一、 GL_DST_ALPHA:表示使用目标颜色的alpha值来作为因子。

十二、 GL_ONE_MINUS_SRC_ALPHA:表示用1.0减去源颜色的alpha值来作为因

子。

十三、 GL_ONE_MINUS_DST_ALPHA:表示用1.0减去目标颜色的alpha值来作为

因子。

十四、 除此以外,还有GL_SRC_COLOR(把源颜色的四个分量分别作为因子的四个

分量)、GL_ONE_MINUS_SRC_COLOR、GL_DST_COLOR、

GL_ONE_MINUS_DST_COLOR等,前两个在OpenGL旧版本中只能用于设置目标因子,

后两个在OpenGL旧版本中只能用于设置源因子。新版本的OpenGL则没有这个限制,

并且支持新的GL_CONST_COLOR(设定一种常数颜色,将其四个分量分别作为因子的四

个分量)、GL_ONE_MINUS_CONST_COLOR、GL_CONST_ALPHA、

GL_ONE_MINUS_CONST_ALPHA。另外还有GL_SRC_ALPHA_SATURATE。新版本的

OpenGL还允许颜色的alpha值和RGB值采用不同的混合因子。但这些都不是我们现在

所需要了解的。毕竟这还是入门教材,不需要整得太复杂~

十五、 举例来说:

十六、 如果设置了glBlendFunc(GL_ONE, GL_ZERO);,则表示完全使用源颜色,完

全不使用目标颜色,因此画面效果和不使用混合的时候一致(当然效率可能会低一点点)。

如果没有设置源因子和目标因子,则默认情况就是这样的设置。

十七、 如果设置了glBlendFunc(GL_ZERO, GL_ONE);,则表示完全不使用源颜色,

因此无论你想画什么,最后都不会被画上去了。(但这并不是说这样设置就没有用,有些时

候可能有特殊用途)

十八、 如果设置了

glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);,则表示源颜色乘以自身

的alpha值,目标颜色乘以1.0减去源颜色的alpha值,这样一来,源颜色的alpha值越

大,则产生的新颜色中源颜色所占比例就越大,而目标颜色所占比例则减小。这种情况下,

我们可以简单的将源颜色的alpha值理解为“不透明度”。这也是混合时最常用的方式。

十九、 如果设置了glBlendFunc(GL_ONE, GL_ONE);,则表示完全使用源颜色和目

标颜色,最终的颜色实际上就是两种颜色的简单相加。例如红色(1, 0, 0)和绿色(0, 1, 0)相

加得到(1, 1, 0),结果为黄色。

二十、 注意:

二十一、 所谓源颜色和目标颜色,是跟绘制的顺序有关的。假如先绘制了一个红色的

物体,再在其上绘制绿色的物体。则绿色是源颜色,红色是目标颜色。如果顺序反过来,

则红色就是源颜色,绿色才是目标颜色。在绘制时,应该注意顺序,使得绘制的源颜色与

设置的源因子对应,目标颜色与设置的目标因子对应。不要被混乱的顺序搞晕了。

二、二维图形混合举例

下面看一个简单的例子,实现将两种不同的颜色混合在一起。为了便于观察,我们绘

制两个矩形:glRectf(-1, -1, 0.5, 0.5);glRectf(-0.5, -0.5, 1, 1);,这两个矩形有一个重叠的

区域,便于我们观察混合的效果。

先来看看使用glBlendFunc(GL_ONE, GL_ZERO);的,它的结果与不使用混合时相同。

void myDisplay(void)

{

glClear(GL_COLOR_BUFFER_BIT);

glEnable(GL_BLEND);

glBlendFunc(GL_ONE, GL_ZERO);

glColor4f(1, 0, 0, 0.5);

glRectf(-1, -1, 0.5, 0.5);

glColor4f(0, 1, 0, 0.5);

glRectf(-0.5, -0.5, 1, 1);

glutSwapBuffers();

}

尝试把glBlendFunc的参数修

改为

及glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

glBlendFunc(GL_ONE, GL_ONE);,观察效果。第一种情况下,效果与没有使用混合时相

同,后绘制的图形会覆盖先绘制的图形。第二种情况下,alpha被当作“不透明度”,由

于被设置为0.5,所以两个矩形看上去都是半透明的,乃至于看到黑色背景。第三种是将颜

色相加,红色和绿色相加得到黄色。

二十二、 实现三维混合

二十三、 也许你迫不及待的想要绘制一个三维的带有半透明物体的场景了。但是现在

恐怕还不行,还有一点是在进行三维场景的混合时必须注意的,那就是深度缓冲。

二十四、 深度缓冲是这样一段数据,它记录了每一个像素距离观察者有多近。在启用

深度缓冲测试的情况下,如果将要绘制的像素比原来的像素更近,则像素将被绘制。否则,

像素就会被忽略掉,不进行绘制。这在绘制不透明的物体时非常有用——不管是先绘制近

的物体再绘制远的物体,还是先绘制远的物体再绘制近的物体,或者干脆以混乱的顺序进

行绘制,最后的显示结果总是近的物体遮住远的物体。

二十五、 然而在你需要实现半透明效果时,发现一切都不是那么美好了。如果你绘制

了一个近距离的半透明物体,则它在深度缓冲区内保留了一些信息,使得远处的物体将无

法再被绘制出来。虽然半透明的物体仍然半透明,但透过它看到的却不是正确的内容了。

二十六、 要解决以上问题,需要在绘制半透明物体时将深度缓冲区设置为只读,这样

一来,虽然半透明物体被绘制上去了,深度缓冲区还保持在原来的状态。如果再有一个物

体出现在半透明物体之后,在不透明物体之前,则它也可以被绘制(因为此时深度缓冲区

中记录的是那个不透明物体的深度)。以后再要绘制不透明物体时,只需要再将深度缓冲区

设置为可读可写的形式即可。嗯?你问我怎么绘制一个一部分半透明一部分不透明的物

体?这个好办,只需要把物体分为两个部分,一部分全是半透明的,一部分全是不透明的,

分别绘制就可以了。

二十七、 即使使用了以上技巧,我们仍然不能随心所欲的按照混乱顺序来进行绘制。

必须是先绘制不透明的物体,然后绘制透明的物体。否则,假设背景为蓝色,近处一块红

色玻璃,中间一个绿色物体。如果先绘制红色半透明玻璃的话,它先和蓝色背景进行混合,

则以后绘制中间的绿色物体时,想单独与红色玻璃混合已经不能实现了。

二十八、 总结起来,绘制顺序就是:首先绘制所有不透明的物体。如果两个物体都是

不透明的,则谁先谁后都没有关系。然后,将深度缓冲区设置为只读。接下来,绘制所有

半透明的物体。如果两个物体都是半透明的,则谁先谁后只需要根据自己的意愿(注意了,

先绘制的将成为“目标颜色”,后绘制的将成为“源颜色”,所以绘制的顺序将会对结果

造成一些影响)。最后,将深度缓冲区设置为可读可写形式。

二十九、 调用glDepthMask(GL_FALSE);可将深度缓冲区设置为只读形式。调用

glDepthMask(GL_TRUE);可将深度缓冲区设置为可读可写形式。

三十、 一些网上的教程,包括大名鼎鼎的NeHe教程,都在使用三维混合时直接将深

度缓冲区禁用,即调用glDisable(GL_DEPTH_TEST);。这样做并不正确。如果先绘制一个

不透明的物体,再在其背后绘制半透明物体,本来后面的半透明物体将不会被显示(被不

透明的物体遮住了),但如果禁用深度缓冲,则它仍然将会显示,并进行混合。NeHe提到

某些显卡在使用glDepthMask函数时可能存在一些问题,但可能是由于我的阅历有限,

并没有发现这样的情况。

那么,实际的演示一下吧。我们来绘制一些半透明和不透明的球体。假设有三个球体,

一个红色不透明的,一个绿色半透明的,一个蓝色半透明的。红色最远,绿色在中间,蓝

色最近。根据前面所讲述的内容,红色不透明球体必须首先绘制,而绿色和蓝色则可以随

意修改顺序。这里为了演示不注意设置深度缓冲的危害,我们故意先绘制最近的蓝色球体,

再绘制绿色球体。

为了让这些球体有一点立体感,我们使用光照。在(1, 1, -1)处设置一个白色的光源。

代码如下:

void setLight(void)

{

static const GLfloat light_position[] = {1.0f, 1.0f, -1.0f, 1.0f};

static const GLfloat light_ambient[] = {0.2f, 0.2f, 0.2f, 1.0f};

static const GLfloat light_diffuse[] = {1.0f, 1.0f, 1.0f, 1.0f};

static const GLfloat light_specular[] = {1.0f, 1.0f, 1.0f, 1.0f};

glLightfv(GL_LIGHT0, GL_POSITION, light_position);

glLightfv(GL_LIGHT0, GL_AMBIENT, light_ambient);

glLightfv(GL_LIGHT0, GL_DIFFUSE, light_diffuse);

glLightfv(GL_LIGHT0, GL_SPECULAR, light_specular);

glEnable(GL_LIGHT0);

glEnable(GL_LIGHTING);

glEnable(GL_DEPTH_TEST);

}

每一个球体颜色不同。所以它们的材质也都不同。这里用一个函数来设置材质。

void setMatirial(const GLfloat mat_diffuse[4], GLfloat mat_shininess)

{

static const GLfloat mat_specular[] = {0.0f, 0.0f, 0.0f, 1.0f};

static const GLfloat mat_emission[] = {0.0f, 0.0f, 0.0f, 1.0f};

glMaterialfv(GL_FRONT, GL_AMBIENT_AND_DIFFUSE, mat_diffuse);

glMaterialfv(GL_FRONT, GL_SPECULAR, mat_specular);

glMaterialfv(GL_FRONT, GL_EMISSION, mat_emission);

glMaterialf (GL_FRONT, GL_SHININESS, mat_shininess);

}

有了这两个函数,我们就可以根据前面的知识写出整个程序代码了。这里只给出了绘

制的部分,其它部分大家可以自行完成。

void myDisplay(void)

{

// 定义一些材质颜色

const static GLfloat red_color[] = {1.0f, 0.0f, 0.0f, 1.0f};

const static GLfloat green_color[] = {0.0f, 1.0f, 0.0f, 0.3333f};

const static GLfloat blue_color[] = {0.0f, 0.0f, 1.0f, 0.5f};

// 清除屏幕

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

// 启动混合并设置混合因子

glEnable(GL_BLEND);

glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);

// 设置光源

setLight();

// 以(0, 0, 0.5)为中心,绘制一个半径为.3的不透明红色球体(离观察者最远)

setMatirial(red_color, 30.0);

glPushMatrix();

glTranslatef(0.0f, 0.0f, 0.5f);

glutSolidSphere(0.3, 30, 30);

glPopMatrix();

// 下面将绘制半透明物体了,因此将深度缓冲设置为只读

glDepthMask(GL_FALSE);

// 以(0.2, 0, -0.5)为中心,绘制一个半径为.2的半透明蓝色球体(离观察者最近)

setMatirial(blue_color, 30.0);

glPushMatrix();

glTranslatef(0.2f, 0.0f, -0.5f);

glutSolidSphere(0.2, 30, 30);

glPopMatrix();

// 以(0.1, 0, 0)为中心,绘制一个半径为.15的半透明绿色球体(在前两个球体之间)

setMatirial(green_color, 30.0);

glPushMatrix();

glTranslatef(0.1, 0, 0);

glutSolidSphere(0.15, 30, 30);

glPopMatrix();

// 完成半透明物体的绘制,将深度缓冲区恢复为可读可写的形式

glDepthMask(GL_TRUE);

glutSwapBuffers();

}

大家也可以将上面两处glDepthMask删去,结果会看到最近的蓝色球虽然是半透明

的,但它的背后直接就是红色球了,中间的绿色球没有被正确绘制。

小结:

本课介绍了OpenGL混合功能的相关知识。

混合就是在绘制时,不是直接把新的颜色覆盖在原来旧的颜色上,而是将新的颜色与

旧的颜色经过一定的运算,从而产生新的颜色。新的颜色称为源颜色,原来旧的颜色称为

目标颜色。传统意义上的混合,是将源颜色乘以源因子,目标颜色乘以目标因子,然后相

加。

源因子和目标因子是可以设置的。源因子和目标因子设置的不同直接导致混合结果的

不同。将源颜色的alpha值作为源因子,用1.0减去源颜色alpha值作为目标因子,是一

种常用的方式。这时候,源颜色的alpha值相当于“不透明度”的作用。利用这一特点可

以绘制出一些半透明的物体。

在进行混合时,绘制的顺序十分重要。因为在绘制时,正要绘制上去的是源颜色,原

来存在的是目标颜色,因此先绘制的物体就成为目标颜色,后来绘制的则成为源颜色。绘

制的顺序要考虑清楚,将目标颜色和设置的目标因子相对应,源颜色和设置的源因子相对

应。

在进行三维混合时,不仅要考虑源因子和目标因子,还应该考虑深度缓冲区。必须先

绘制所有不透明的物体,再绘制半透明的物体。在绘制半透明物体时前,还需要将深度缓

冲区设置为只读形式,否则可能出现画面错误。

10.第十课:

第十课、第十一课中的一个函数中的bug:在grab函数中,应该在最开头加上一句

glReadBuffer(GL_FRONT);以保证读取到的内容正好就是显示的内容。

学过多媒体技术的朋友可能知道,计算机保存图象的方法通常有两种:一是“矢量

图”,一是“像素图”。矢量图保存了图象中每一几何物体的位置、形状、大小等信息,

在显示图象时,根据这些信息计算得到完整的图象。“像素图”是将完整的图象纵横分为

若干的行、列,这些行列使得图象被分割为很细小的分块,每一分块称为像素,保存每一

像素的颜色也就保存了整个图象。

这两种方法各有优缺点。“矢量图”在图象进行放大、缩小时很方便,不会失真,但

如果图象很复杂,那么就需要用非常多的几何体,数据量和运算量都很庞大。“像素图”

无论图象多么复杂,数据量和运算量都不会增加,但在进行放大、缩小等操作时,会产生

失真的情况。

前面我们曾介绍了如何使用OpenGL来绘制几何体,我们通过重复的绘制许多几何体,

可以绘制出一幅矢量图。那么,应该如何绘制像素图呢?这就是我们今天要学习的内容了。

1、BMP文件格式简单介绍

BMP文件是一种像素文件,它保存了一幅图象中所有的像素。这种文件格式可以保存

单色位图、16色或256色索引模式像素图、24位真彩色图象,每种模式种单一像素的大

小分别为1/8字节,1/2字节,1字节和3字节。目前最常见的是256色BMP和24位色

BMP。这种文件格式还定义了像素保存的几种方法,包括不压缩、RLE压缩等。常见的BMP

文件大多是不压缩的。

这里为了简单起见,我们仅讨论24位色、不使用压缩的BMP。(如果你使用Windows

自带的画图程序,很容易绘制出一个符合以上要求的BMP)

Windows所使用的BMP文件,在开始处有一个文件头,大小为54字节。保存了包

括文件格式标识、颜色数、图象大小、压缩方式等信息,因为我们仅讨论24位色不压缩

的BMP,所以文件头中的信息基本不需要注意,只有“大小”这一项对我们比较有用。图

象的宽度和高度都是一个32位整数,在文件中的地址分别为0x0012和0x0016,于是我

们可以使用以下代码来读取图象的大小信息:

GLint width, height; // 使用OpenGL的GLint类型,它是32位的。

// 而C语言本身的int则不一定是32位的。

FILE* pFile;

// 在这里进行“打开文件”的操作

fseek(pFile, 0x0012, SEEK_SET); // 移动到0x0012位置

fread(&width, sizeof(width), 1, pFile); // 读取宽度

fseek(pFile, 0x0016, SEEK_SET); // 移动到0x0016位置

// 由于上一句执行后本就应该在0x0016位置

// 所以这一句可省略

fread(&height, sizeof(height), 1, pFile); // 读取高度

54个字节以后,如果是16色或256色BMP,则还有一个颜色表,但24位色BMP

没有这个,我们这里不考虑。接下来就是实际的像素数据了。24位色的BMP文件中,每

三个字节表示一个像素的颜色。

注意,OpenGL通常使用RGB来表示颜色,但BMP文件则采用BGR,就是说,顺序

被反过来了。

另外需要注意的地方是:像素的数据量并不一定完全等于图象的高度乘以宽度乘以每

一像素的字节数,而是可能略大于这个值。原因是BMP文件采用了一种“对齐”的机制,

每一行像素数据的长度若不是4的倍数,则填充一些数据使它是4的倍数。这样一来,一

个17*15的24位BMP大小就应该是834字节(每行17个像素,有51字节,补充为52

字节,乘以15得到像素数据总长度780,再加上文件开始的54字节,得到834字节)。

分配内存时,一定要小心,不能直接使用“图象的高度乘以宽度乘以每一像素的字节数”

来计算分配空间的长度,否则有可能导致分配的内存空间长度不足,造成越界访问,带来

各种严重后果。

一个很简单的计算数据长度的方法如下:

int LineLength, TotalLength;

LineLength = ImageWidth * BytesPerPixel; // 每行数据长度大致为图象宽度乘以

// 每像素的字节数

while( LineLength % 4 != 0 ) // 修正LineLength使其为4的倍数

++LineLenth;

TotalLength = LineLength * ImageHeight; // 数据总长 = 每行长度 * 图象高度

这并不是效率最高的方法,但由于这个修正本身运算量并不大,使用频率也不高,我

们就不需要再考虑更快的方法了。

2、简单的OpenGL像素操作

OpenGL提供了简洁的函数来操作像素:

glReadPixels:读取一些像素。当前可以简单理解为“把已经绘制好的像素(它可能

已经被保存到显卡的显存中)读取到内存”。

glDrawPixels:绘制一些像素。当前可以简单理解为“把内存中一些数据作为像素数

据,进行绘制”。

glCopyPixels:复制一些像素。当前可以简单理解为“把已经绘制好的像素从一个位

置复制到另一个位置”。虽然从功能上看,好象等价于先读取像素再绘制像素,但实际上

它不需要把已经绘制的像素(它可能已经被保存到显卡的显存中)转换为内存数据,然后

再由内存数据进行重新的绘制,所以要比先读取后绘制快很多。

这三个函数可以完成简单的像素读取、绘制和复制任务,但实际上也可以完成更复杂

的任务。当前,我们仅讨论一些简单的应用。由于这几个函数的参数数目比较多,下面我

们分别介绍。

3、glReadPixels的用法和举例

3.1 函数的参数说明

该函数总共有七个参数。前四个参数可以得到一个矩形,该矩形所包括的像素都会被

读取出来。(第一、二个参数表示了矩形的左下角横、纵坐标,坐标以窗口最左下角为零,

最右上角为最大值;第三、四个参数表示了矩形的宽度和高度)

第五个参数表示读取的内容,例如:GL_RGB就会依次读取像素的红、绿、蓝三种数

据,GL_RGBA则会依次读取像素的红、绿、蓝、alpha四种数据,GL_RED则只读取像素

的红色数据(类似的还有GL_GREEN,GL_BLUE,以及GL_ALPHA)。如果采用的不是RGBA

颜色模式,而是采用颜色索引模式,则也可以使用GL_COLOR_INDEX来读取像素的颜色

索引。目前仅需要知道这些,但实际上还可以读取其它内容,例如深度缓冲区的深度数据

等。

第六个参数表示读取的内容保存到内存时所使用的格式,例如:GL_UNSIGNED_BYTE

会把各种数据保存为GLubyte,GL_FLOAT会把各种数据保存为GLfloat等。

第七个参数表示一个指针,像素数据被读取后,将被保存到这个指针所表示的地址。

注意,需要保证该地址有足够的可以使用的空间,以容纳读取的像素数据。例如一幅大小

为256*256的图象,如果读取其RGB数据,且每一数据被保存为GLubyte,总大小就是:

256*256*3 = 196608字节,即192千字节。如果是读取RGBA数据,则总大小就是

256*256*4 = 262144字节,即256千字节。

注意:glReadPixels实际上是从缓冲区中读取数据,如果使用了双缓冲区,则默认是

从正在显示的缓冲(即前缓冲)中读取,而绘制工作是默认绘制到后缓冲区的。因此,如

果需要读取已经绘制好的像素,往往需要先交换前后缓冲。

再看前面提到的BMP文件中两个需要注意的地方:

3.2 解决OpenGL常用的RGB像素数据与BMP文件的BGR像素数据顺序不一致问

可以使用一些代码交换每个像素的第一字节和第三字节,使得RGB的数据变成BGR

的数据。当然也可以使用另外的方式解决问题:新版本的OpenGL除了可以使用GL_RGB

读取像素的红、绿、蓝数据外,也可以使用GL_BGR按照相反的顺序依次读取像素的蓝、

绿、红数据,这样就与BMP文件格式相吻合了。即使你的gl/gl.h头文件中没有定义这个

GL_BGR,也没有关系,可以尝试使用GL_BGR_EXT。虽然有的OpenGL实现(尤其是旧

版本的实现)并不能使用GL_BGR_EXT,但我所知道的Windows环境下各种OpenGL实

现都对GL_BGR提供了支持,毕竟Windows中各种表示颜色的数据几乎都是使用BGR

的顺序,而非RGB的顺序。这可能与IBM-PC的硬件设计有关。

3.3 消除BMP文件中“对齐”带来的影响

实际上OpenGL也支持使用了这种“对齐”方式的像素数据。只要通过glPixelStore

修改“像素保存时对齐的方式”就可以了。像这样:

int alignment = 4;

glPixelStorei(GL_UNPACK_ALIGNMENT, alignment);

第一个参数表示“设置像素的对齐值”,第二个参数表示实际设置为多少。这里像素

可以单字节对齐(实际上就是不使用对齐)、双字节对齐(如果长度为奇数,则再补一个字

节)、四字节对齐(如果长度不是四的倍数,则补为四的倍数)、八字节对齐。分别对应

alignment的值为1, 2, 4, 8。实际上,默认的值是4,正好与BMP文件的对齐方式相吻

合。

glPixelStorei也可以用于设置其它各种参数。但我们这里并不需要深入讨论了。

现在,我们已经可以把屏幕上的像素读取到内存了,如果需要的话,我们还可以将内

存中的数据保存到文件。正确的对照BMP文件格式,我们的程序就可以把屏幕中的图象

保存为BMP文件,达到屏幕截图的效果。

我们并没有详细介绍BMP文件开头的54个字节的所有内容,不过这无伤大雅。从一

个正确的BMP文件中读取前54个字节,修改其中的宽度和高度信息,就可以得到新的文

件头了。假设我们先建立一个1*1大小的24位色BMP,文件名为,又假设

新的BMP文件名称为。则可以编写如下代码:

FILE* pOriginFile = fopen("", "rb);

FILE* pGrabFile = fopen("", "wb");

char BMP_Header[54];

GLint width, height;

/* 先在这里设置好图象的宽度和高度,即width和height的值,并计算像素的总长

度 */

// 读取中的头54个字节到数组

fread(BMP_Header, sizeof(BMP_Header), 1, pOriginFile);

// 把数组内容写入到新的BMP文件

fwrite(BMP_Header, sizeof(BMP_Header), 1, pGrabFile);

// 修改其中的大小信息

fseek(pGrabFile, 0x0012, SEEK_SET);

fwrite(&width, sizeof(width), 1, pGrabFile);

fwrite(&height, sizeof(height), 1, pGrabFile);

// 移动到文件末尾,开始写入像素数据

fseek(pGrabFile, 0, SEEK_END);

/* 在这里写入像素数据到文件 */

fclose(pOriginFile);

fclose(pGrabFile);

我们给出完整的代码,演示如何把整个窗口的图象抓取出来并保存为BMP文件。

#define WindowWidth 400

#define WindowHeight 400

#include

#include

/* 函数grab

* 抓取窗口中的像素

* 假设窗口宽度为WindowWidth,高度为WindowHeight

*/

#define BMP_Header_Length 54

void grab(void)

{

FILE* pDummyFile;

FILE* pWritingFile;

GLubyte* pPixelData;

GLubyte BMP_Header[BMP_Header_Length];

GLint i, j;

GLint PixelDataLength;

// 计算像素数据的实际长度

i = WindowWidth * 3; // 得到每一行的像素数据长度

while( i%4 != 0 ) // 补充数据,直到i是的倍数

++i; // 本来还有更快的算法,

// 但这里仅追求直观,对速度没有太高要求

PixelDataLength = i * WindowHeight;

// 分配内存和打开文件

pPixelData = (GLubyte*)malloc(PixelDataLength);

if( pPixelData == 0 )

exit(0);

pDummyFile = fopen("", "rb");

if( pDummyFile == 0 )

exit(0);

pWritingFile = fopen("", "wb");

if( pWritingFile == 0 )

exit(0);

// 读取像素

glPixelStorei(GL_UNPACK_ALIGNMENT, 4);

glReadPixels(0, 0, WindowWidth, WindowHeight,

GL_BGR_EXT, GL_UNSIGNED_BYTE, pPixelData);

// 把的文件头复制为新文件的文件头

fread(BMP_Header, sizeof(BMP_Header), 1, pDummyFile);

fwrite(BMP_Header, sizeof(BMP_Header), 1, pWritingFile);

fseek(pWritingFile, 0x0012, SEEK_SET);

i = WindowWidth;

j = WindowHeight;

fwrite(&i, sizeof(i), 1, pWritingFile);

fwrite(&j, sizeof(j), 1, pWritingFile);

// 写入像素数据

fseek(pWritingFile, 0, SEEK_END);

fwrite(pPixelData, PixelDataLength, 1, pWritingFile);

// 释放内存和关闭文件

fclose(pDummyFile);

fclose(pWritingFile);

free(pPixelData);

}

把这段代码复制到以前任何课程的样例程序中,在绘制函数的最后调用grab函数,

即可把图象内容保存为BMP文件了。(在我写这个教程的时候,不少地方都用这样的代码

进行截图工作,这段代码一旦写好,运行起来是很方便的。)

4、glDrawPixels的用法和举例

glDrawPixels函数与glReadPixels函数相比,参数内容大致相同。它的第一、二、

三、四个参数分别对应于glReadPixels函数的第三、四、五、六个参数,依次表示图象宽

度、图象高度、像素数据内容、像素数据在内存中的格式。两个函数的最后一个参数也是

对应的,glReadPixels中表示像素读取后存放在内存中的位置,glDrawPixels则表示用于

绘制的像素数据在内存中的位置。

注意到glDrawPixels函数比glReadPixels函数少了两个参数,这两个参数在glRea

dPixels中分别是表示图象的起始位置。在glDrawPixels中,不必显式的指定绘制的位置,

这是因为绘制的位置是由另一个函数glRasterPos*来指定的。glRasterPos*函数的参数与

glVertex*类似,通过指定一个二维/三维/四维坐标,OpenGL将自动计算出该坐标对应的

屏幕位置,并把该位置作为绘制像素的起始位置。

很自然的,我们可以从BMP文件中读取像素数据,并使用glDrawPixels绘制到屏幕

上。我们选择Windows XP默认的桌面背景作为绘制的内容(如果你使用的是

Windows XP系统,很可能可以在硬盘中搜索到这个文件。当然你也可以使用其它BMP

文件来代替,只要它是24位的BMP文件。注意需要修改代码开始部分的FileName的定

义),先把该文件复制一份放到正确的位置,我们在程序开始时,就读取该文件,从而获得

图象的大小后,根据该大小来创建合适的OpenGL窗口,并绘制像素。

绘制像素本来是很简单的过程,但是这个程序在骨架上与前面的各种示例程序稍有不

同,所以我还是打算给出一份完整的代码。

#include

#define FileName ""

static GLint ImageWidth;

static GLint ImageHeight;

static GLint PixelLength;

static GLubyte* PixelData;

#include

#include

void display(void)

{

// 清除屏幕并不必要

// 每次绘制时,画面都覆盖整个屏幕

// 因此无论是否清除屏幕,结果都一样

// glClear(GL_COLOR_BUFFER_BIT);

// 绘制像素

glDrawPixels(ImageWidth, ImageHeight,

GL_BGR_EXT, GL_UNSIGNED_BYTE, PixelData);

// 完成绘制

glutSwapBuffers();

}

int main(int argc, char* argv[])

{

// 打开文件

FILE* pFile = fopen("", "rb");

if( pFile == 0 )

exit(0);

// 读取图象的大小信息

fseek(pFile, 0x0012, SEEK_SET);

fread(&ImageWidth, sizeof(ImageWidth), 1, pFile);

fread(&ImageHeight, sizeof(ImageHeight), 1, pFile);

// 计算像素数据长度

PixelLength = ImageWidth * 3;

while( PixelLength % 4 != 0 )

++PixelLength;

PixelLength *= ImageHeight;

// 读取像素数据

PixelData = (GLubyte*)malloc(PixelLength);

if( PixelData == 0 )

exit(0);

fseek(pFile, 54, SEEK_SET);

fread(PixelData, PixelLength, 1, pFile);

// 关闭文件

fclose(pFile);

// 初始化GLUT并运行

glutInit(&argc, argv);

glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA);

glutInitWindowPosition(100, 100);

glutInitWindowSize(ImageWidth, ImageHeight);

glutCreateWindow(FileName);

glutDisplayFunc(&display);

glutMainLoop();

// 释放内存

// 实际上,glutMainLoop函数永远不会返回,这里也永远不会到达

// 这里写释放内存只是出于一种个人习惯

// 不用担心内存无法释放。在程序结束时操作系统会自动回收所有内存

free(PixelData);

return 0;

}

这里仅仅是一个简单的显示24位BMP图象的程序,如果读者对BMP文件格式比较

熟悉,也可以写出适用于各种BMP图象的显示程序,在像素处理时,它们所使用的方法

是类似的。

OpenGL在绘制像素之前,可以对像素进行若干处理。最常用的可能就是对整个像素

图象进行放大/缩小。使用glPixelZoom来设置放大/缩小的系数,该函数有两个参数,分

别是水平方向系数和垂直方向系数。例如设置glPixelZoom(0.5f, 0.8f);则表示水平方向变

为原来的50%大小,而垂直方向变为原来的80%大小。我们甚至可以使用负的系数,使得

整个图象进行水平方向或垂直方向的翻转(默认像素从左绘制到右,但翻转后将从右绘制

到左。默认像素从下绘制到上,但翻转后将从上绘制到下。因此,glRasterPos*函数设置

的“开始位置”不一定就是矩形的左下角)。

5、glCopyPixels的用法和举例

从效果上看,glCopyPixels进行像素复制的操作,等价于把像素读取到内存,再从内

存绘制到另一个区域,因此可以通过glReadPixels和glDrawPixels组合来实现复制像素

的功能。然而我们知道,像素数据通常数据量很大,例如一幅1024*768的图象,如果使

用24位BGR方式表示,则需要至少1024*768*3字节,即2.25兆字节。这么多的数据

要进行一次读操作和一次写操作,并且因为在glReadPixels和glDrawPixels中设置的数

据格式不同,很可能涉及到数据格式的转换。这对CPU无疑是一个不小的负担。使用gl

CopyPixels直接从像素数据复制出新的像素数据,避免了多余的数据的格式转换,并且也

可能减少一些数据复制操作(因为数据可能直接由显卡负责复制,不需要经过主内存),因

此效率比较高。

glCopyPixels函数也通过glRasterPos*系列函数来设置绘制的位置,因为不需要涉及

到主内存,所以不需要指定数据在内存中的格式,也不需要使用任何指针。

glCopyPixels函数有五个参数,第一、二个参数表示复制像素来源的矩形的左下角坐

标,第三、四个参数表示复制像素来源的举行的宽度和高度,第五个参数通常使用GL_CO

LOR,表示复制像素的颜色,但也可以是GL_DEPTH或GL_STENCIL,分别表示复制深度

缓冲数据或模板缓冲数据。

值得一提的是,glDrawPixels和glReadPixels中设置的各种操作,例如glPixelZoo

m等,在glCopyPixels函数中同样有效。

下面看一个简单的例子,绘制一个三角形后,复制像素,并同时进行水平和垂直方向

的翻转,然后缩小为原来的一半,并绘制。绘制完毕后,调用前面的grab函数,将屏幕

中所有内容保存为。其中WindowWidth和WindowHeight是表示窗口宽度

和高度的常量。

void display(void)

{

// 清除屏幕

glClear(GL_COLOR_BUFFER_BIT);

// 绘制

glBegin(GL_TRIANGLES);

glColor3f(1.0f, 0.0f, 0.0f); glVertex2f(0.0f, 0.0f);

glColor3f(0.0f, 1.0f, 0.0f); glVertex2f(1.0f, 0.0f);

glColor3f(0.0f, 0.0f, 1.0f); glVertex2f(0.5f, 1.0f);

glEnd();

glPixelZoom(-0.5f, -0.5f);

glRasterPos2i(1, 1);

glCopyPixels(WindowWidth/2, WindowHeight/2,

WindowWidth/2, WindowHeight/2, GL_COLOR);

// 完成绘制,并抓取图象保存为BMP文件

glutSwapBuffers();

grab();

}

小结:

本课结合Windows系统常见的BMP图象格式,简单介绍了OpenGL的像素处理功

能。包括使用glReadPixels读取像素、glDrawPixels绘制像素、glCopyPixels复制像素。

本课仅介绍了像素处理的一些简单应用,但相信大家已经可以体会到,围绕这三个像

素处理函数,还存在一些“外围”函数,比如glPixelStore*,glRasterPos*,以及

glPixelZoom等。我们仅使用了这些函数的一少部分功能。

本课内容并不多,例子足够丰富,三个像素处理函数都有例子,大家可以结合例子来

体会。

11. 第十一课:

我们在前一课中,学习了简单的像素操作,这意味着我们可以使用各种各样的BMP

文件来丰富程序的显示效果,于是我们的OpenGL图形程序也不再像以前总是只显示几个

多边形那样单调了。——但是这还不够。虽然我们可以将像素数据按照矩形进行缩小和放

大,但是还不足以满足我们的要求。例如要将一幅世界地图绘制到一个球体表面,只使用

glPixelZoom这样的函数来进行缩放显然是不够的。OpenGL纹理映射功能支持将一些像

素数据经过变换(即使是比较不规则的变换)将其附着到各种形状的多边形表面。纹理映

射功能十分强大,利用它可以实现目前计算机动画中的大多数效果,但是它也很复杂,我

们不可能一次性的完全讲解。这里的课程只是关于二维纹理的简单使用。但即使是这样,

也会使我们的程序在显示效果上迈出一大步。

下面几张图片说明了纹理的效果。前两张是我们需要的纹理,后一张是我们使用纹理

后,利用OpenGL所产生出的效果。

纹理的使用是非常复杂的。因此即使是入门教程,在编写时我也多次进行删改,很多

东西都被精简掉了,但本课的内容仍然较多,大家要有一点心理准备~

1、启用纹理和载入纹理

就像我们曾经学习过的OpenGL光照、混合等功能一样。在使用纹理前,必须启用它。

OpenGL支持一维纹理、二维纹理和三维纹理,这里我们仅介绍二维纹理。可以使用以下

语句来启用和禁用二维纹理:

glEnable(GL_TEXTURE_2D); // 启用二维纹理

glDisable(GL_TEXTURE_2D); // 禁用二维纹理

使用纹理前,还必须载入纹理。利用glTexImage2D函数可以载入一个二维的纹理,

该函数有多达九个参数(虽然某些参数我们可以暂时不去了解),现在分别说明如下:

第一个参数为指定的目标,在我们的入门教材中,这个参数将始终使用GL_TEXTURE

_2D。

第二个参数为“多重细节层次”,现在我们并不考虑多重纹理细节,因此这个参数设置

为零。

第三个参数有两种用法。在OpenGL 1.0,即最初的版本中,使用整数来表示颜色分

量数目,例如:像素数据用RGB颜色表示,总共有红、绿、蓝三个值,因此参数设置为3,

而如果像素数据是用RGBA颜色表示,总共有红、绿、蓝、alpha四个值,因此参数设置

为4。而在后来的版本中,可以直接使用GL_RGB或GL_RGBA来表示以上情况,显得更

直观(并带来其它一些好处,这里暂时不提)。注意:虽然我们使用Windows的BMP文

件作为纹理时,一般是蓝色的像素在最前,其真实的格式为GL_BGR而不是GL_RGB,在

数据的顺序上有所不同,但因为同样是红、绿、蓝三种颜色,因此这里仍然使用GL_RGB。

(如果使用GL_BGR,OpenGL将无法识别这个参数,造成错误)

第四、五个参数是二维纹理像素的宽度和高度。这里有一个很需要注意的地方:Ope

nGL在以前的很多版本中,限制纹理的大小必须是2的整数次方,即纹理的宽度和高度只

能是16, 32, 64, 128, 256等值,直到最近的新版本才取消了这个限制。而且,一些Ope

nGL实现(例如,某些PC机上板载显卡的驱动程序附带的OpenGL)并没有支持到如此

高的OpenGL版本。因此在使用纹理时要特别注意其大小。尽量使用大小为2的整数次方

的纹理,当这个要求无法满足时,使用gluScaleImage函数把图象缩放至所指定的大小(在

后面的例子中有用到)。另外,无论旧版本还是新版本,都限制了纹理大小的最大值,例如,

某OpenGL实现可能要求纹理最大不能超过1024*1024。可以使用如下的代码来获得Op

enGL所支持的最大纹理:

GLint max;

glGetIntegerv(GL_MAX_TEXTURE_SIZE, &max);

这样max的值就是当前OpenGL实现中所支持的最大纹理。

在很长一段时间内,很多图形程序都喜欢使用256*256大小的纹理,不仅因为256

是2的整数次方,也因为某些硬件可以使用8位的整数来表示纹理坐标,2的8次方正好

是256,这一巧妙的组合为处理纹理坐标时的硬件优化创造了一些不错的条件。

第六个参数是纹理边框的大小,我们没有使用纹理边框,因此这里设置为零。

最后三个参数与glDrawPixels函数的最后三个参数的使用方法相同,其含义可以参考

glReadPixels的最后三个参数。大家可以复习一下第10课的相关内容,这里不再重复。

举个例子,如果有一幅大小为width*height,格式为Windows系统中使用最普遍的

24位BGR,保存在pixels中的像素图象。则把这样一幅图象载入为纹理可使用以下代码:

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0, GL_BGR_EXT, GL_

UNSIGNED_BYTE, pixels);

注意,载入纹理的过程可能比较慢,原因是纹理数据通常比较大,例如一幅512*512

的BGR格式的图象,大小为0.75M。把这些像素数据从主内存传送到专门的图形硬件,

这个过程中还可能需要把程序中所指定的像素格式转化为图形硬件所能识别的格式(或最

能发挥图形硬件性能的格式),这些操作都需要较多时间。

2、纹理坐标

我们先来回忆一下之前学过的一点内容:

当我们绘制一个三角形时,只需要指定三个顶点的颜色。三角形中其它各点的颜色不

需要我们指定,这些点的颜色是OpenGL自己通过计算得到的。

在我们学习OpneGL光照时,法线向量、材质的指定,都是只需要在顶点处指定一下

就可以了,其它地方的法线向量和材质都是OpenGL自己通过计算去获得。

纹理的使用方法也与此类似。只要指定每一个顶点在纹理图象中所对应的像素位置,

OpenGL就会自动计算顶点以外的其它点在纹理图象中所对应的像素位置。

这听起来比较令人迷惑。我们可以这样类比一下:

在绘制一条线段时,我们设置其中一个端点为红色,另一个端点为绿色,则OpenGL

会自动计算线段中其它各像素的颜色,如果是使用glShadeMode(GL_SMOOTH);,则最

终会形成一种渐变的效果(例如线段中点,就是红色和绿色的中间色)。

类似的,在绘制一条线段时,我们设置其中一个端点使用“纹理图象中最左下角的颜

色”作为它的颜色,另一个端点使用“纹理图象中最右上角的颜色”作为它的颜色,则O

penGL会自动在纹理图象中选择合适位置的颜色,填充到线段的各个像素(例如线段中点,

可能就是选择纹理图象中央的那个像素的颜色)。

我们在类比时,使用了“纹理图象中最左下角的颜色”这种说法。但这种说法在很多

时候不够精确,我们需要一种精确的方式来表示我们究竟使用纹理中的哪个像素。纹理坐

标也就是因为这样的要求而产生的。以二维纹理为例,规定纹理最左下角的坐标为(0, 0),

最右上角的坐标为(1, 1),于是纹理中的每一个像素的位置都可以用两个浮点数来表示(三

维纹理会用三个浮点数表示,一维纹理则只用一个即可)。

使用glTexCoord*系列函数来指定纹理坐标。这些函数的用法与使用glVertex*系列

函数来指定顶点坐标十分相似。例如:glTexCoord2f(0.0f, 0.0f);指定使用(0, 0)纹理坐标。

通常,每个顶点使用不同的纹理,于是下面这样形式的代码是比较常见的。

glBegin( /* ... */ );

glTexCoord2f( /* ... */ ); glVertex3f( /* ... */ );

glTexCoord2f( /* ... */ ); glVertex3f( /* ... */ );

/* ... */

glEnd();

当我们用一个坐标表示顶点在三维空间的位置时,可以使用glRotate*等函数来对坐

标进行转换。纹理坐标也可以进行这种转换。只要使用glMatrixMode(GL_TEXTURE);,

就可以切换到纹理矩阵(另外还有透视矩阵GL_PROJECTION和模型视图矩阵

GL_MODELVIEW,详细情况在第五课有讲述),然后glRotate*,glScale*,glTranslate*

等操作矩阵的函数就可以用来处理“对纹理坐标进行转换”的工作了。在简单应用中,可

能不会对矩阵进行任何变换,这样考虑问题会比较简单。

3、纹理参数

到这里,入门所需要掌握的所有难点都被我们掌握了。但是,我们的知识仍然是不够

的,如果仅利用现有的知识去使用纹理的话,你可能会发现纹理完全不起作用。这是因为

在使用纹理前还有某些参数是必须设置的。

使用glTexParameter*系列函数来设置纹理参数。通常需要设置下面四个参数:

GL_TEXTURE_MAG_FILTER:指当纹理图象被使用到一个大于它的形状上时(即:有

可能纹理图象中的一个像素会被应用到实际绘制时的多个像素。例如将一幅256*256的纹

理图象应用到一个512*512的正方形),应该如何处理。可选择的设置有GL_NEAREST

和GL_LINEAR,前者表示“使用纹理中坐标最接近的一个像素的颜色作为需要绘制的像素

颜色”,后者表示“使用纹理中坐标最接近的若干个颜色,通过加权平均算法得到需要绘制

的像素颜色”。前者只经过简单比较,需要运算较少,可能速度较快,后者需要经过加权平

均计算,其中涉及除法运算,可能速度较慢(但如果有专门的处理硬件,也可能两者速度

相同)。从视觉效果上看,前者效果较差,在一些情况下锯齿现象明显,后者效果会较好(但

如果纹理图象本身比较大,则两者在视觉效果上就会比较接近)。

GL_TEXTURE_MIN_FILTER:指当纹理图象被使用到一个小于(或等于)它的形状上

时(即有可能纹理图象中的多个像素被应用到实际绘制时的一个像素。例如将一幅256*2

56的纹理图象应用到一个128*128的正方形),应该如何处理。可选择的设置有GL_NEA

REST,GL_LINEAR,GL_NEAREST_MIPMAP_NEAREST,GL_NEAREST_MIPMAP_LIN

EAR,GL_LINEAR_MIPMAP_NEAREST和GL_LINEAR_MIPMAP_LINEAR。其中后四个

涉及到mipmap,现在暂时不需要了解。前两个选项则和GL_TEXTURE_MAG_FILTER中

的类似。此参数似乎是必须设置的(在我的计算机上,不设置此参数将得到错误的显示结

果,但我目前并没有找到根据)。

GL_TEXTURE_WRAP_S:指当纹理坐标的第一维坐标值大于1.0或小于0.0时,应该

如何处理。基本的选项有GL_CLAMP和GL_REPEAT,前者表示“截断”,即超过1.0的按

1.0处理,不足0.0的按0.0处理。后者表示“重复”,即对坐标值加上一个合适的整数(可

以是正数或负数),得到一个在[0.0, 1.0]范围内的值,然后用这个值作为新的纹理坐标。例

如:某二维纹理,在绘制某形状时,一像素需要得到纹理中坐标为(3.5, 0.5)的像素的颜色,

其中第一维的坐标值3.5超过了1.0,则在GL_CLAMP方式中将被转化为(1.0, 0.5),在G

L_REPEAT方式中将被转化为(0.5, 0.5)。在后来的OpenGL版本中,又增加了新的处理方

式,这里不做介绍。如果不指定这个参数,则默认为GL_REPEAT。

GL_TEXTURE_WRAP_T:指当纹理坐标的第二维坐标值大于1.0或小于0.0时,应该

如何处理。选项与GL_TEXTURE_WRAP_S类似,不再重复。如果不指定这个参数,则默

认为GL_REPEAT。

设置参数的代码如下所示:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);

4、纹理对象

前面已经提到过,载入一幅纹理所需要的时间是比较多的。因此应该尽量减少载入纹

理的次数。如果只有一幅纹理,则应该在第一次绘制前就载入它,以后就不需要再次载入

了。这点与glDrawPixels函数很不相同。每次使用glDrawPixels函数,都需要把像素数

据重新载入一次,因此用glDrawPixels函数来反复绘制图象的效率是较低的(如果只绘制

一次,则不会有此问题),使用纹理来反复绘制图象是可取的做法。

但是,在每次绘制时要使用两幅或更多幅的纹理时,这个办法就行不通了。你可能会

编写下面的代码:

glTexImage2D( /* ... */ ); // 载入第一幅纹理

// 使用第一幅纹理

glTexImage2D( /* ... */ ); // 载入第二幅纹理

// 使用第二幅纹理

// 当纹理的数量增加时,这段代码会变得更加复杂。

在绘制动画时,由于每秒钟需要将画面绘制数十次,因此如果使用上面的代码,就会

反复载入纹理,这对计算机是非常大的负担,以目前的个人计算机配置来说,根本就无法

让动画能够流畅的运行。因此,需要有一种机制,能够在不同的纹理之间进行快速的切换。

纹理对象正是这样一种机制。我们可以把每一幅纹理(包括纹理的像素数据、纹理大

小等信息,也包括了前面所讲的纹理参数)放到一个纹理对象中,通过创建多个纹理对象

来达到同时保存多幅纹理的目的。这样一来,在第一次使用纹理前,把所有的纹理都载入,

然后在绘制时只需要指明究竟使用哪一个纹理对象就可以了。

使用纹理对象和使用显示列表有相似之处:使用一个正整数来作为纹理对象的编号。

在使用前,可以调用glGenTextures来分配纹理对象。该函数有两种比较常见的用法:

GLuint texture_ID;

glGenTextures(1, &texture_ID); // 分配一个纹理对象的编号

或者:

GLuint texture_ID_list[5];

glGenTextures(5, texture_ID_list); // 分配5个纹理对象的编号

零是一个特殊的纹理对象编号,表示“默认的纹理对象”,在分配正确的情况下,glG

enTextures不会分配这个编号。与glGenTextures对应的是glDeleteTextures,用于销

毁一个纹理对象。

在分配了纹理对象编号后,使用glBindTexture函数来指定“当前所使用的纹理对象”。

然后就可以使用glTexImage*系列函数来指定纹理像素、使用glTexParameter*系列函数

来指定纹理参数、使用glTexCoord*系列函数来指定纹理坐标了。如果不使用glBindText

ure函数,那么glTexImage*、glTexParameter*、glTexCoord*系列函数默认在一个编号

为0的纹理对象上进行操作。glBindTexture函数有两个参数,第一个参数是需要使用纹

理的目标,因为我们现在只学习二维纹理,所以指定为GL_TEXTURE_2D,第二个参数是

所使用的纹理的编号。

使用多个纹理对象,就可以使OpenGL同时保存多个纹理。在使用时只需要调用glB

indTexture函数,在不同纹理之间进行切换,而不需要反复载入纹理,因此动画的绘制速

度会有非常明显的提升。典型的代码如下所示:

// 在程序开始时:分配好纹理编号,并载入纹理

glGenTextures( /* ... */ );

glBindTexture(GL_TEXTURE_2D, texture_ID_1);

// 载入第一幅纹理

glBindTexture(GL_TEXTURE_2D, texture_ID_2);

// 载入第二幅纹理

// 在绘制时,切换并使用纹理,不需要再进行载入

glBindTexture(GL_TEXTURE_2D, texture_ID_1); // 指定第一幅纹理

// 使用第一幅纹理

glBindTexture(GL_TEXTURE_2D, texture_ID_2); // 指定第二幅纹理

// 使用第二幅纹理

提示:纹理对象是从OpenGL 1.1版开始才有的,最旧版本的OpenGL 1.0并没有处

理纹理对象的功能。不过,我想各位的机器不会是比OpenGL 1.1更低的版本

(Windows 95就自带了OpenGL 1.1版本,遗憾的是,Microsoft对OpenGL的支持并

不积极,Windows XP也还采用1.1版本。据说Vista使用的是OpenGL 1.4版。当然了,

如果安装显卡驱动的话,现在的主流显卡一般都附带了适用于该显卡的OpenGL 1.4版或

更高版本),所以这个问题也就不算是问题了。

5、示例程序

纹理入门所需要掌握的知识点就介绍到这里了。但是如果不实际动手操作的话,也是

不可能真正掌握的。下面我们来看看本课开头的那个纹理效果是如何实现的吧。

因为代码比较长,我把它拆分成了三段,大家如果要编译的话,应该把三段代码按顺

序连在一起编译。如果要运行的话,除了要保证有一个名称为,图象大小为

1*1的24位BMP文件,还要把本课开始的两幅纹理图片保存到正确位置(一幅名叫

,另一幅名叫。注意:我为了节省网络空间,把两幅图片都转成jpg

格式了,读者把图片保存到本地后,需要把它们再转化为BMP格式。可以使用Windows XP

带的画图程序中的“另存为”功能完成这一转换)。

第一段代码如下。其中的主体——grab函数,是我们在第十课介绍过的,这里仅仅是

抄过来用一下,目的是为了将最终效果图保存到一个名字叫的文件中。(当然

了,为了保证程序的正确运行,那个大小为1*1的文件仍然是必要的,参

见第十课)

#define WindowWidth 400

#define WindowHeight 400

#define WindowTitle "OpenGL纹理测试"

#include

#include

#include

/* 函数grab

* 抓取窗口中的像素

* 假设窗口宽度为WindowWidth,高度为WindowHeight

*/

#define BMP_Header_Length 54

void grab(void)

{

FILE* pDummyFile;

FILE* pWritingFile;

GLubyte* pPixelData;

GLubyte BMP_Header[BMP_Header_Length];

GLint i, j;

GLint PixelDataLength;

// 计算像素数据的实际长度

i = WindowWidth * 3; // 得到每一行的像素数据长度

while( i%4 != 0 ) // 补充数据,直到i是的倍数

++i; // 本来还有更快的算法,

// 但这里仅追求直观,对速度没有太高要求

PixelDataLength = i * WindowHeight;

// 分配内存和打开文件

pPixelData = (GLubyte*)malloc(PixelDataLength);

if( pPixelData == 0 )

exit(0);

pDummyFile = fopen("", "rb");

if( pDummyFile == 0 )

exit(0);

pWritingFile = fopen("", "wb");

if( pWritingFile == 0 )

exit(0);

// 读取像素

glPixelStorei(GL_UNPACK_ALIGNMENT, 4);

glReadPixels(0, 0, WindowWidth, WindowHeight,

GL_BGR_EXT, GL_UNSIGNED_BYTE, pPixelData);

// 把的文件头复制为新文件的文件头

fread(BMP_Header, sizeof(BMP_Header), 1, pDummyFile);

fwrite(BMP_Header, sizeof(BMP_Header), 1, pWritingFile);

fseek(pWritingFile, 0x0012, SEEK_SET);

i = WindowWidth;

j = WindowHeight;

fwrite(&i, sizeof(i), 1, pWritingFile);

fwrite(&j, sizeof(j), 1, pWritingFile);

// 写入像素数据

fseek(pWritingFile, 0, SEEK_END);

fwrite(pPixelData, PixelDataLength, 1, pWritingFile);

// 释放内存和关闭文件

fclose(pDummyFile);

fclose(pWritingFile);

free(pPixelData);

}

第二段代码是我们的重点。它包括两个函数。其中power_of_two比较简单,虽然实

现手段有点奇特,但也并非无法理解(即使真的无法理解,读者也可以给出自己的解决方

案,用一些循环以及多使用一些位操作也没关系。反正,这里不是重点啦)。另一个load_

texture函数却是重头戏:打开BMP文件、读取其中的高度和宽度信息、计算像素数据所

占的字节数、为像素数据分配空间、读取像素数据、对像素图象进行缩放(如果必要的话)、

分配新的纹理编号、填写纹理参数、载入纹理,所有的功能都在同一个函数里面完成了。

为了叙述方便,我把所有的解释都放在了注释里。

/* 函数power_of_two

* 检查一个整数是否为2的整数次方,如果是,返回1,否则返回0

* 实际上只要查看其二进制位中有多少个,如果正好有1个,返回1,否则返回0

* 在“查看其二进制位中有多少个”时使用了一个小技巧

* 使用n &= (n-1)可以使得n中的减少一个(具体原理大家可以自己思考)

*/

int power_of_two(int n)

{

if( n <= 0 )

return 0;

return (n & (n-1)) == 0;

}

/* 函数load_texture

* 读取一个BMP文件作为纹理

* 如果失败,返回0,如果成功,返回纹理编号

*/

GLuint load_texture(const char* file_name)

{

GLint width, height, total_bytes;

GLubyte* pixels = 0;

GLuint last_texture_ID, texture_ID = 0;

// 打开文件,如果失败,返回

FILE* pFile = fopen(file_name, "rb");

if( pFile == 0 )

return 0;

// 读取文件中图象的宽度和高度

fseek(pFile, 0x0012, SEEK_SET);

fread(&width, 4, 1, pFile);

fread(&height, 4, 1, pFile);

fseek(pFile, BMP_Header_Length, SEEK_SET);

// 计算每行像素所占字节数,并根据此数据计算总像素字节数

{

GLint line_bytes = width * 3;

while( line_bytes % 4 != 0 )

++line_bytes;

total_bytes = line_bytes * height;

}

// 根据总像素字节数分配内存

pixels = (GLubyte*)malloc(total_bytes);

if( pixels == 0 )

{

fclose(pFile);

return 0;

}

// 读取像素数据

if( fread(pixels, total_bytes, 1, pFile) <= 0 )

{

free(pixels);

fclose(pFile);

return 0;

}

// 在旧版本的OpenGL中

// 如果图象的宽度和高度不是的整数次方,则需要进行缩放

// 这里并没有检查OpenGL版本,出于对版本兼容性的考虑,按旧版本处理

// 另外,无论是旧版本还是新版本,

// 当图象的宽度和高度超过当前OpenGL实现所支持的最大值时,也要进行缩放

{

GLint max;

glGetIntegerv(GL_MAX_TEXTURE_SIZE, &max);

if( !power_of_two(width)

|| !power_of_two(height)

|| width > max

|| height > max )

{

const GLint new_width = 256;

const GLint new_height = 256; // 规定缩放后新的大小为边长的正方形

GLint new_line_bytes, new_total_bytes;

GLubyte* new_pixels = 0;

// 计算每行需要的字节数和总字节数

new_line_bytes = new_width * 3;

while( new_line_bytes % 4 != 0 )

++new_line_bytes;

new_total_bytes = new_line_bytes * new_height;

// 分配内存

new_pixels = (GLubyte*)malloc(new_total_bytes);

if( new_pixels == 0 )

{

free(pixels);

fclose(pFile);

return 0;

}

// 进行像素缩放

gluScaleImage(GL_RGB,

width, height, GL_UNSIGNED_BYTE, pixels,

new_width, new_height, GL_UNSIGNED_BYTE, new_pixels);

// 释放原来的像素数据,把pixels指向新的像素数据,并重新设置width和he

ight

free(pixels);

pixels = new_pixels;

width = new_width;

height = new_height;

}

}

// 分配一个新的纹理编号

glGenTextures(1, &texture_ID);

if( texture_ID == 0 )

{

free(pixels);

fclose(pFile);

return 0;

}

// 绑定新的纹理,载入纹理并设置纹理参数

// 在绑定前,先获得原来绑定的纹理编号,以便在最后进行恢复

glGetIntegerv(GL_TEXTURE_BINDING_2D, &last_texture_ID);

glBindTexture(GL_TEXTURE_2D, texture_ID);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);

glTexEnvf(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, width, height, 0,

GL_BGR_EXT, GL_UNSIGNED_BYTE, pixels);

glBindTexture(GL_TEXTURE_2D, last_texture_ID);

// 之前为pixels分配的内存可在使用glTexImage2D以后释放

// 因为此时像素数据已经被OpenGL另行保存了一份(可能被保存到专门的图形硬件

中)

free(pixels);

return texture_ID;

}

第三段代码是关于显示的部分,以及main函数。注意,我们只在main函数中读取

了两幅纹理,并把它们保存在各自的纹理对象中,以后就再也不载入纹理。每次绘制时使

用glBindTexture在不同的纹理对象中切换。另外,我们使用了超过1.0的纹理坐标,由

于GL_TEXTURE_WRAP_S和GL_TEXTURE_WRAP_T参数都被设置为GL_REPEAT,所以

得到的效果就是纹理像素的重复,有点向地板砖的花纹那样。读者可以试着修改“墙”的

纹理坐标,将5.0修改为10.0,看看效果有什么变化。

/* 两个纹理对象的编号

*/

GLuint texGround;

GLuint texWall;

void display(void)

{

// 清除屏幕

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

// 设置视角

glMatrixMode(GL_PROJECTION);

glLoadIdentity();

gluPerspective(75, 1, 1, 21);

glMatrixMode(GL_MODELVIEW);

glLoadIdentity();

gluLookAt(1, 5, 5, 0, 0, 0, 0, 0, 1);

// 使用“地”纹理绘制土地

glBindTexture(GL_TEXTURE_2D, texGround);

glBegin(GL_QUADS);

glTexCoord2f(0.0f, 0.0f); glVertex3f(-8.0f, -8.0f, 0.0f);

glTexCoord2f(0.0f, 5.0f); glVertex3f(-8.0f, 8.0f, 0.0f);

glTexCoord2f(5.0f, 5.0f); glVertex3f(8.0f, 8.0f, 0.0f);

glTexCoord2f(5.0f, 0.0f); glVertex3f(8.0f, -8.0f, 0.0f);

glEnd();

// 使用“墙”纹理绘制栅栏

glBindTexture(GL_TEXTURE_2D, texWall);

glBegin(GL_QUADS);

glTexCoord2f(0.0f, 0.0f); glVertex3f(-6.0f, -3.0f, 0.0f);

glTexCoord2f(0.0f, 1.0f); glVertex3f(-6.0f, -3.0f, 1.5f);

glTexCoord2f(5.0f, 1.0f); glVertex3f(6.0f, -3.0f, 1.5f);

glTexCoord2f(5.0f, 0.0f); glVertex3f(6.0f, -3.0f, 0.0f);

glEnd();

// 旋转后再绘制一个

glRotatef(-90, 0, 0, 1);

glBegin(GL_QUADS);

glTexCoord2f(0.0f, 0.0f); glVertex3f(-6.0f, -3.0f, 0.0f);

glTexCoord2f(0.0f, 1.0f); glVertex3f(-6.0f, -3.0f, 1.5f);

glTexCoord2f(5.0f, 1.0f); glVertex3f(6.0f, -3.0f, 1.5f);

glTexCoord2f(5.0f, 0.0f); glVertex3f(6.0f, -3.0f, 0.0f);

glEnd();

// 交换缓冲区,并保存像素数据到文件

glutSwapBuffers();

grab();

}

int main(int argc, char* argv[])

{

// GLUT初始化

glutInit(&argc, argv);

glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA);

glutInitWindowPosition(100, 100);

glutInitWindowSize(WindowWidth, WindowHeight);

glutCreateWindow(WindowTitle);

glutDisplayFunc(&display);

// 在这里做一些初始化

glEnable(GL_DEPTH_TEST);

glEnable(GL_TEXTURE_2D);

texGround = load_texture("");

texWall = load_texture("");

// 开始显示

glutMainLoop();

return 0;

}

小结:

本课介绍了OpenGL纹理的入门知识。

利用纹理可以进行比glReadPixels和glDrawPixels更复杂的像素绘制,因此可以实

现很多精彩的效果。

本课只涉及了二维纹理。OpenGL还支持一维和三维纹理,其原理是类似的。

在使用纹理前,要启用纹理。并且,还需要将像素数据载入到纹理中。注意纹理的宽

度和高度,目前很多OpenGL的实现都还要求其值为2的整数次方,如果纹理图象本身并

不满足这个条件,可以使用gluScaleImage函数来进行缩放。为了正确的使用纹理,需要

设置纹理参数。

载入纹理所需要的系统开销是比较大的,应该尽可能减少载入纹理的次数。如果程序

中只使用一幅纹理,则只在第一次使用前载入,以后不必重新载入。如果程序中要使用多

幅纹理,不应该反复载入它们,而应该将每个纹理都用一个纹理对象来保存,并使用

glBindTextures在各个纹理之间进行切换。

本课还给出了一个程序(到目前为止,它是这个OpenGL教程系列中所给出的程序中

最长的)。该程序演示了纹理的基本使用方法,本课程涉及到的几乎所有内容都被包括其中,

这是对本课中文字说明的一个补充。如果读者有什么不明白的地方,也可以以这个程序作

为参考。

12.第十二课:

片断测试其实就是测试每一个像素,只有通过测试的像素才会被绘制,没有通过测试

的像素则不进行绘制。OpenGL提供了多种测试操作,利用这些操作可以实现一些特殊的

效果。

我们在前面的课程中,曾经提到了“深度测试”的概念,它在绘制三维场景的时候特

别有用。在不使用深度测试的时候,如果我们先绘制一个距离较近的物体,再绘制距离较

远的物体,则距离远的物体因为后绘制,会把距离近的物体覆盖掉,这样的效果并不是我

们所希望的。

如果使用了深度测试,则情况就会有所不同:每当一个像素被绘制,OpenGL就记录

这个像素的“深度”(深度可以理解为:该像素距离观察者的距离。深度值越大,表示距离

越远),如果有新的像素即将覆盖原来的像素时,深度测试会检查新的深度是否会比原来的

深度值小。如果是,则覆盖像素,绘制成功;如果不是,则不会覆盖原来的像素,绘制被

取消。这样一来,即使我们先绘制比较近的物体,再绘制比较远的物体,则远的物体也不

会覆盖近的物体了。

实际上,只要存在深度缓冲区,无论是否启用深度测试,OpenGL在像素被绘制时都

会尝试将深度数据写入到缓冲区内,除非调用了glDepthMask(GL_FALSE)来禁止写入。

这些深度数据除了用于常规的测试外,还可以有一些有趣的用途,比如绘制阴影等等。

除了深度测试,OpenGL还提供了剪裁测试、Alpha测试和模板测试。

1、剪裁测试

剪裁测试用于限制绘制区域。我们可以指定一个矩形的剪裁窗口,当启用剪裁测试后,

只有在这个窗口之内的像素才能被绘制,其它像素则会被丢弃。换句话说,无论怎么绘制,

剪裁窗口以外的像素将不会被修改。

有的朋友可能玩过《魔兽争霸3》这款游戏。游戏时如果选中一个士兵,则画面下方

的一个方框内就会出现该士兵的头像。为了保证该头像无论如何绘制都不会越界而覆盖到

外面的像素,就可以使用剪裁测试。

可以通过下面的代码来启用或禁用剪裁测试:

glEnable(GL_SCISSOR_TEST); // 启用剪裁测试

glDisable(GL_SCISSOR_TEST); // 禁用剪裁测试

可以通过下面的代码来指定一个位置在(x, y),宽度为width,高度为height的剪裁

窗口。

glScissor(x, y, width, height);

注意,OpenGL窗口坐标是以左下角为(0, 0),右上角为(width, height)的,这与

Windows系统窗口有所不同。

还有一种方法可以保证像素只绘制到某一个特定的矩形区域内,这就是视口变换(在

第五课第3节中有介绍)。但视口变换和剪裁测试是不同的。视口变换是将所有内容缩放到

合适的大小后,放到一个矩形的区域内;而剪裁测试不会进行缩放,超出矩形范围的像素

直接忽略掉。

2、Alpha测试

在前面的课程中,我们知道像素的Alpha值可以用于混合操作。其实Alpha值还有一

个用途,这就是Alpha测试。当每个像素即将绘制时,如果启动了Alpha测试,OpenGL

会检查像素的Alpha值,只有Alpha值满足条件的像素才会进行绘制(严格的说,满足条

件的像素会通过本项测试,进行下一种测试,只有所有测试都通过,才能进行绘制),不满

足条件的则不进行绘制。这个“条件”可以是:始终通过(默认情况)、始终不通过、大于

设定值则通过、小于设定值则通过、等于设定值则通过、大于等于设定值则通过、小于等

于设定值则通过、不等于设定值则通过。

如果我们需要绘制一幅图片,而这幅图片的某些部分又是透明的(想象一下,你先绘

制一幅相片,然后绘制一个相框,则相框这幅图片有很多地方都是透明的,这样就可以透

过相框看到下面的照片),这时可以使用Alpha测试。将图片中所有需要透明的地方的Al

pha值设置为0.0,不需要透明的地方Alpha值设置为1.0,然后设置Alpha测试的通过

条件为:“大于0.5则通过”,这样便能达到目的。当然也可以设置需要透明的地方Alpha

值为1.0,不需要透明的地方Alpha值设置为0.0,然后设置条件为“小于0.5则通过”。

Alpha测试的设置方式往往不只一种,可以根据个人喜好和实际情况需要进行选择。

可以通过下面的代码来启用或禁用Alpha测试:

glEnable(GL_ALPHA_TEST); // 启用Alpha测试

glDisable(GL_ALPHA_TEST); // 禁用Alpha测试

可以通过下面的代码来设置Alpha测试条件为“大于0.5则通过”:

glAlphaFunc(GL_GREATER, 0.5f);

该函数的第二个参数表示设定值,用于进行比较。第一个参数是比较方式,除了

GL_LESS(小于则通过)外,还可以选择:

GL_ALWAYS(始终通过),

GL_NEVER(始终不通过),

GL_LESS(小于则通过),

GL_LEQUAL(小于等于则通过),

GL_EQUAL(等于则通过),

GL_GEQUAL(大于等于则通过),

GL_NOTEQUAL(不等于则通过)。

现在我们来看一个实际例子。一幅照片图片,一幅相框图片,如何将它们组合在一起

呢?为了简单起见,我们使用前面两课一直使用的24位BMP文件来作为图片格式。(因

为发布到网络上,为了节约容量,我所发布的是JPG格式。大家下载后可以用Windows X

P自带的画图工具打开,并另存为24位BMP格式)

注:第一幅图片是著名网络游戏《魔兽世界》的一幅桌面背景,用在这里希望没有涉

及版权问题。如果有什么不妥,请及时指出,我会立即更换。

在24位的BMP文件格式中,BGR三种颜色各占8位,没有保存Alpha值,因此无

法直接使用Alpha测试。注意到相框那幅图片中,所有需要透明的位置都是白色,所以我

们在程序中设置所有白色(或很接近白色)的像素Alpha值为0.0,设置其它像素Alpha

值为1.0,然后设置Alpha测试的条件为“大于0.5则通过”即可。这种使用某种特殊颜

色来代表透明颜色的技术,有时又被成为Color Key技术。

利用前面第11课的一段代码,将图片读取为纹理,然后利用下面这个函数来设置“当

前纹理”中每一个像素的Alpha值。

/* 将当前纹理BGR格式转换为BGRA格式

* 纹理中像素的RGB值如果与指定rgb相差不超过absolute,则将Alpha设置为0.

0,否则设置为1.0

*/

void texture_colorkey(GLubyte r, GLubyte g, GLubyte b, GLubyte absolute)

{

GLint width, height;

GLubyte* pixels = 0;

// 获得纹理的大小信息

glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_WIDTH, &width);

glGetTexLevelParameteriv(GL_TEXTURE_2D, 0, GL_TEXTURE_HEIGHT, &height);

// 分配空间并获得纹理像素

pixels = (GLubyte*)malloc(width*height*4);

if( pixels == 0 )

return;

glGetTexImage(GL_TEXTURE_2D, 0, GL_BGRA_EXT, GL_UNSIGNED_BYTE, pixel

s);

// 修改像素中的Alpha值

// 其中pixels[i*4], pixels[i*4+1], pixels[i*4+2], pixels[i*4+3]

// 分别表示第i个像素的蓝、绿、红、Alpha四种分量,0表示最小,255表示最大

{

GLint i;

GLint count = width * height;

for(i=0; i

{

if( abs(pixels[i*4] - b) <= absolute

&& abs(pixels[i*4+1] - g) <= absolute

&& abs(pixels[i*4+2] - r) <= absolute )

pixels[i*4+3] = 0;

else

pixels[i*4+3] = 255;

}

}

// 将修改后的像素重新设置到纹理中,释放内存

glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0,

GL_BGRA_EXT, GL_UNSIGNED_BYTE, pixels);

free(pixels);

}

有了纹理后,我们开启纹理,指定合适的纹理坐标并绘制一个矩形,这样就可以在屏

幕上将图片绘制出来。我们先绘制相片的纹理,再绘制相框的纹理。程序代码如下:

void display(void)

{

static int initialized = 0;

static GLuint texWindow = 0;

static GLuint texPicture = 0;

// 执行初始化操作,包括:读取相片,读取相框,将相框由BGR颜色转换为BGRA,

启用二维纹理

if( !initialized )

{

texPicture = load_texture("");

texWindow = load_texture("");

glBindTexture(GL_TEXTURE_2D, texWindow);

texture_colorkey(255, 255, 255, 10);

glEnable(GL_TEXTURE_2D);

initialized = 1;

}

// 清除屏幕

glClear(GL_COLOR_BUFFER_BIT);

// 绘制相片,此时不需要进行Alpha测试,所有的像素都进行绘制

glBindTexture(GL_TEXTURE_2D, texPicture);

glDisable(GL_ALPHA_TEST);

glBegin(GL_QUADS);

glTexCoord2f(0, 0); glVertex2f(-1.0f, -1.0f);

glTexCoord2f(0, 1); glVertex2f(-1.0f, 1.0f);

glTexCoord2f(1, 1); glVertex2f( 1.0f, 1.0f);

glTexCoord2f(1, 0); glVertex2f( 1.0f, -1.0f);

glEnd();

// 绘制相框,此时进行Alpha测试,只绘制不透明部分的像素

glBindTexture(GL_TEXTURE_2D, texWindow);

glEnable(GL_ALPHA_TEST);

glAlphaFunc(GL_GREATER, 0.5f);

glBegin(GL_QUADS);

glTexCoord2f(0, 0); glVertex2f(-1.0f, -1.0f);

glTexCoord2f(0, 1); glVertex2f(-1.0f, 1.0f);

glTexCoord2f(1, 1); glVertex2f( 1.0f, 1.0f);

glTexCoord2f(1, 0); glVertex2f( 1.0f, -1.0f);

glEnd();

// 交换缓冲

glutSwapBuffers();

}

其中:load_texture函数是从第11课中照搬过来的(该函数还使用了一个

power_of_two函数,一个BMP_Header_Length常数,同样照搬),无需进行修改。main

函数跟其它课程的基本相同,不再重复。

程序运行后,会发现相框与相片的衔接有些不自然,这是因为相框某些边缘部分虽然

肉眼看上去是白色,但其实RGB值与纯白色相差并不少,因此程序计算其Alpha值时认

为其不需要透明。解决办法是仔细处理相框中的每个像素,在需要透明的地方涂上纯白色,

这也许是一件很需要耐心的工作。

大家可能会想:前面我们学习过混合操作,混合可以实现半透明,自然也可以通过设

定实现全透明。也就是说,Alpha测试可以实现的效果几乎都可以通过OpenGL混合功能

来实现。那么为什么还需要一个Alpha测试呢?答案就是,这与性能相关。Alpha测试只

要简单的比较大小就可以得到最终结果,而混合操作一般需要进行乘法运算,性能有所下

降。另外,OpenGL测试的顺序是:剪裁测试、Alpha测试、模板测试、深度测试。如果

某项测试不通过,则不会进行下一步,而只有所有测试都通过的情况下才会执行混合操作。

因此,在使用Alpha测试的情况下,透明的像素就不需要经过模板测试和深度测试了;而

如果使用混合操作,即使透明的像素也需要进行模板测试和深度测试,性能会有所下降。

还有一点:对于那些“透明”的像素来说,如果使用Alpha测试,则“透明”的像素不会

通过测试,因此像素的深度值不会被修改;而使用混合操作时,虽然像素的颜色没有被修

改,但它的深度值则有可能被修改掉了。

因此,如果所有的像素都是“透明”或“不透明”,没有“半透明”时,应该尽量采

用Alpha测试而不是采用混合操作。当需要绘制半透明像素时,才采用混合操作。

3、模板测试

模板测试是所有OpenGL测试中比较复杂的一种。

首先,模板测试需要一个模板缓冲区,这个缓冲区是在初始化OpenGL时指定的。如

果使用GLUT工具包,可以在调用glutInitDisplayMode函数时在参数中加上GLUT_STE

NCIL,例如:

glutInitDisplayMode(GLUT_DOUBLE | GLUT_RGBA | GLUT_STENCIL);

在Windows操作系统中,即使没有明确要求使用模板缓冲区,有时候也会分配模板

缓冲区。但为了保证程序的通用性,最好还是明确指定使用模板缓冲区。如果确实没有分

配模板缓冲区,则所有进行模板测试的像素全部都会通过测试。

通过glEnable/glDisable可以启用或禁用模板测试。

glEnable(GL_STENCIL_TEST); // 启用模板测试

glDisable(GL_STENCIL_TEST); // 禁用模板测试

OpenGL在模板缓冲区中为每个像素保存了一个“模板值”,当像素需要进行模板测试

时,将设定的模板参考值与该像素的“模板值”进行比较,符合条件的通过测试,不符合

条件的则被丢弃,不进行绘制。

条件的设置与Alpha测试中的条件设置相似。但注意Alpha测试中是用浮点数来进行

比较,而模板测试则是用整数来进行比较。比较也有八种情况:始终通过、始终不通过、

大于则通过、小于则通过、大于等于则通过、小于等于则通过、等于则通过、不等于则通

过。

glStencilFunc(GL_LESS, 3, mask);

这段代码设置模板测试的条件为:“小于3则通过”。glStencilFunc的前两个参数意义

与glAlphaFunc的两个参数类似,第三个参数的意义为:如果进行比较,则只比较mask

中二进制为1的位。例如,某个像素模板值为5(二进制101),而mask的二进制值为0

0000011,因为只比较最后两位,5的最后两位为01,其实是小于3的,因此会通过测试。

如何设置像素的“模板值”呢?glClear函数可以将所有像素的模板值复位。代码如下:

glClear(GL_STENCIL_BUFFER_BIT);

可以同时复位颜色值和模板值:

glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);

正如可以使用glClearColor函数来指定清空屏幕后的颜色那样,也可以使用glClear

Stencil函数来指定复位后的“模板值”。

每个像素的“模板值”会根据模板测试的结果和深度测试的结果而进行改变。

glStencilOp(fail, zfail, zpass);

该函数指定了三种情况下“模板值”该如何变化。第一个参数表示模板测试未通过时

该如何变化;第二个参数表示模板测试通过,但深度测试未通过时该如何变化;第三个参

数表示模板测试和深度测试均通过时该如何变化。如果没有起用模板测试,则认为模板测

试总是通过;如果没有启用深度测试,则认为深度测试总是通过)

变化可以是:

GL_KEEP(不改变,这也是默认值),

GL_ZERO(回零),

GL_REPLACE(使用测试条件中的设定值来代替当前模板值),

GL_INCR(增加1,但如果已经是最大值,则保持不变),

GL_INCR_WRAP(增加1,但如果已经是最大值,则从零重新开始),

GL_DECR(减少1,但如果已经是零,则保持不变),

GL_DECR_WRAP(减少1,但如果已经是零,则重新设置为最大值),

GL_INVERT(按位取反)。

在新版本的OpenGL中,允许为多边形的正面和背面使用不同的模板测试条件和模板

值改变方式,于是就有了glStencilFuncSeparate函数和glStencilOpSeparate函数。这

两个函数分别与glStencilFunc和glStencilOp类似,只在最前面多了一个参数face,用

于指定当前设置的是哪个面。可以选择GL_FRONT, GL_BACK, GL_FRONT_AND_BACK。

注意:模板缓冲区与深度缓冲区有一点不同。无论是否启用深度测试,当有像素被绘

制时,总会重新设置该像素的深度值(除非设置glDepthMask(GL_FALSE);)。而模板测试

如果不启用,则像素的模板值会保持不变,只有启用模板测试时才有可能修改像素的模板

值。(这一结论是我自己的实验得出的,暂时没发现什么资料上是这样写。如果有不正确的

地方,欢迎指正)

另外,模板测试虽然是从OpenGL 1.0就开始提供的功能,但是对于个人计算机而言,

硬件实现模板测试的似乎并不多,很多计算机系统直接使用CPU运算来完成模板测试。因

此在一些老的显卡,或者是多数集成显卡上,大量而频繁的使用模板测试可能造成程序运

行效率低下。即使是当前配置比较高端的个人计算机,也尽量不要使用

glStencilFuncSeparate和glStencilOpSeparate函数。

从前面所讲可以知道,使用剪裁测试可以把绘制区域限制在一个矩形的区域内。但如

果需要把绘制区域限制在一个不规则的区域内,则需要使用模板测试。

例如:绘制一个湖泊,以及周围的树木,然后绘制树木在湖泊中的倒影。为了保证倒

影被正确的限制在湖泊表面,可以使用模板测试。具体的步骤如下:

(1) 关闭模板测试,绘制地面和树木。

(2) 开启模板测试,使用glClear设置所有像素的模板值为0。

(3) 设置

glStencilFunc(GL_ALWAYS, 1, 1); glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);绘制

湖泊水面。这样一来,湖泊水面的像素的“模板值”为1,而其它地方像素的“模板值”

为0。

(4) 设置

glStencilFunc(GL_EQUAL, 1, 1); glStencilOp(GL_KEEP, GL_KEEP, GL_KEEP);绘制倒影。

这样一来,只有“模板值”为1的像素才会被绘制,因此只有“水面”的像素才有可能被

倒影的像素替换,而其它像素则保持不变。

我们仍然来看一个实际的例子。这是一个比较简单的场景:空间中有一个球体,一个

平面镜。我们站在某个特殊的观察点,可以看到球体在平面镜中的镜像,并且镜像处于平

面镜的边缘,有一部分因为平面镜大小的限制,而无法显示出来。整个场景的效果如下图:

绘制这个场景的思路跟前面提到的湖面倒影是接近的。

假设平面镜所在的平面正好是X轴和Y轴所确定的平面,则球体和它在平面镜中的镜

像是关于这个平面对称的。我们用一个draw_sphere函数来绘制球体,先调用该函数以绘

制球体本身,然后调用glScalef(1.0f, 1.0f, -1.0f); 再调用draw_sphere函数,就可以绘制

球体的镜像。

另外需要注意的地方就是:因为是绘制三维的场景,我们开启了深度测试。但是站在

观察者的位置,球体的镜像其实是在平面镜的“背后”,也就是说,如果按照常规的方式绘

制,平面镜会把镜像覆盖掉,这不是我们想要的效果。解决办法就是:设置深度缓冲区为

只读,绘制平面镜,然后设置深度缓冲区为可写的状态,绘制平面镜“背后”的镜像。

有的朋友可能会问:如果在绘制镜像的时候关闭深度测试,那镜像不就不会被平面镜

遮挡了吗?为什么还要开启深度测试,又需要把深度缓冲区设置为只读呢?实际情况是:

虽然关闭深度测试确实可以让镜像不被平面镜遮挡,但是镜像本身会出现若干问题。我们

看到的镜像是一个球体,但实际上这个球体是由很多的多边形所组成的,这些多边形有的

代表了我们所能看到的“正面”,有的则代表了我们不能看到的“背面”。如果关闭深度测

试,而有的“背面”多边形又比“正面”多边形先绘制,就会造成球体的背面反而把正面

挡住了,这不是我们想要的效果。为了确保正面可以挡住背面,应该开启深度测试。

绘制部分的代码如下:

void draw_sphere()

{

// 设置光源

glEnable(GL_LIGHTING);

glEnable(GL_LIGHT0);

{

GLfloat

pos[] = {5.0f, 5.0f, 0.0f, 1.0f},

ambient[] = {0.0f, 0.0f, 1.0f, 1.0f};

glLightfv(GL_LIGHT0, GL_POSITION, pos);

glLightfv(GL_LIGHT0, GL_AMBIENT, ambient);

}

// 绘制一个球体

glColor3f(1, 0, 0);

glPushMatrix();

glTranslatef(0, 0, 2);

glutSolidSphere(0.5, 20, 20);

glPopMatrix();

}

void display(void)

{

// 清除屏幕

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

// 设置观察点

glMatrixMode(GL_PROJECTION);

glLoadIdentity();

gluPerspective(60, 1, 5, 25);

glMatrixMode(GL_MODELVIEW);

glLoadIdentity();

gluLookAt(5, 0, 6.5, 0, 0, 0, 0, 1, 0);

glEnable(GL_DEPTH_TEST);

// 绘制球体

glDisable(GL_STENCIL_TEST);

draw_sphere();

// 绘制一个平面镜。在绘制的同时注意设置模板缓冲。

// 另外,为了保证平面镜之后的镜像能够正确绘制,在绘制平面镜时需要将深度缓冲

区设置为只读的。

// 在绘制时暂时关闭光照效果

glClearStencil(0);

glClear(GL_STENCIL_BUFFER_BIT);

glStencilFunc(GL_ALWAYS, 1, 0xFF);

glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);

glEnable(GL_STENCIL_TEST);

glDisable(GL_LIGHTING);

glColor3f(0.5f, 0.5f, 0.5f);

glDepthMask(GL_FALSE);

glRectf(-1.5f, -1.5f, 1.5f, 1.5f);