OpenGL入门学习[三] http://xiaxveliang.blog.163.com/blog/static/2970803420126246501930/
OpenGL入门学习[十一]
我们在前一课中,学习了简单的像素操作,这意味着我们可以使用各种各样的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将无法识别这个参数,造成错误)
第四、五个参数是二维纹理像素的宽度和高度。这里有一个很需要注意的地方:OpenGL在以前的很多版本中,限制纹理的大小必须是2的整数次方,即纹理的宽度和高度只能是16, 32, 64, 128, 256等值,直到最近的新版本才取消了这个限制。而且,一些OpenGL实现(例如,某些PC机上板载显卡的驱动程序附带的OpenGL)并没有支持到如此高的OpenGL版本。因此在使用纹理时要特别注意其大小。尽量使用大小为2的整数次方的纹理,当这个要求无法满足时,使用gluScaleImage函数把图象缩放至所指定的大小(在后面的例子中有用到)。另外,无论旧版本还是新版本,都限制了纹理大小的最大值,例如,某OpenGL实现可能要求纹理最大不能超过1024*1024。可以使用如下的代码来获得OpenGL所支持的最大纹理:
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);,则最终会形成一种渐变的效果(例如线段中点,就是红色和绿色的中间色)。
类似的,在绘制一条线段时,我们设置其中一个端点使用“纹理图象中最左下角的颜色”作为它的颜色,另一个端点使用“纹理图象中最右上角的颜色”作为它的颜色,则OpenGL会自动在纹理图象中选择合适位置的颜色,填充到线段的各个像素(例如线段中点,可能就是选择纹理图象中央的那个像素的颜色)。
我们在类比时,使用了“纹理图象中最左下角的颜色”这种说法。但这种说法在很多时候不够精确,我们需要一种精确的方式来表示我们究竟使用纹理中的哪个像素。纹理坐标也就是因为这样的要求而产生的。以二维纹理为例,规定纹理最左下角的坐标为(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*256的纹理图象应用到一个128*128的正方形),应该如何处理。可选择的设置有GL_NEAREST,GL_LINEAR,GL_NEAREST_MIPMAP_NEAREST,GL_NEAREST_MIPMAP_LINEAR,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),在GL_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个纹理对象的编号
零是一个特殊的纹理对象编号,表示“默认的纹理对象”,在分配正确的情况下,glGenTextures不会分配这个编号。与glGenTextures对应的是glDeleteTextures,用于销毁一个纹理对象。
在分配了纹理对象编号后,使用glBindTexture函数来指定“当前所使用的纹理对象”。然后就可以使用glTexImage*系列函数来指定纹理像素、使用glTexParameter*系列函数来指定纹理参数、使用glTexCoord*系列函数来指定纹理坐标了。如果不使用glBindTexture函数,那么glTexImage*、glTexParameter*、glTexCoord*系列函数默认在一个编号为0的纹理对象上进行操作。glBindTexture函数有两个参数,第一个参数是需要使用纹理的目标,因为我们现在只学习二维纹理,所以指定为GL_TEXTURE_2D,第二个参数是所使用的纹理的编号。
使用多个纹理对象,就可以使OpenGL同时保存多个纹理。在使用时只需要调用glBindTexture函数,在不同纹理之间进行切换,而不需要反复载入纹理,因此动画的绘制速度会有非常明显的提升。典型的代码如下所示:
// 在程序开始时:分配好纹理编号,并载入纹理
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、示例程序
纹理入门所需要掌握的知识点就介绍到这里了。但是如果不实际动手操作的话,也是不可能真正掌握的。下面我们来看看本课开头的那个纹理效果是如何实现的吧。
因为代码比较长,我把它拆分成了三段,大家如果要编译的话,应该把三段代码按顺序连在一起编译。如果要运行的话,除了要保证有一个名称为dummy.bmp,图象大小为1*1的24位BMP文件,还要把本课开始的两幅纹理图片保存到正确位置(一幅名叫ground.bmp,另一幅名叫wall.bmp。注意:我为了节省网络空间,把两幅图片都转成jpg格式了,读者把图片保存到本地后,需要把它们再转化为BMP格式。可以使用Windows XP带的画图程序中的“另存为”功能完成这一转换)。
第一段代码如下。其中的主体——grab函数,是我们在第十课介绍过的,这里仅仅是抄过来用一下,目的是为了将最终效果图保存到一个名字叫grab.bmp的文件中。(当然了,为了保证程序的正确运行,那个大小为1*1的dummy.bmp文件仍然是必要的,参见第十课)
#define WindowWidth 400
#define WindowHeight 400
#define WindowTitle "OpenGL纹理测试"
#include <gl/glut.h>
#include <stdio.h>
#include <stdlib.h>
/* 函数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("dummy.bmp", "rb");
if( pDummyFile == 0 )
exit(0);
pWritingFile = fopen("grab.bmp", "wb");
if( pWritingFile == 0 )
exit(0);
// 读取像素
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
glReadPixels(0, 0, WindowWidth, WindowHeight,
GL_BGR_EXT, GL_UNSIGNED_BYTE, pPixelData);
// 把dummy.bmp的文件头复制为新文件的文件头
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和height
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("ground.bmp");
texWall = load_texture("wall.bmp");
// 开始显示
glutMainLoop();
return 0;
}
小结:
本课介绍了OpenGL纹理的入门知识。
利用纹理可以进行比glReadPixels和glDrawPixels更复杂的像素绘制,因此可以实现很多精彩的效果。
本课只涉及了二维纹理。OpenGL还支持一维和三维纹理,其原理是类似的。
在使用纹理前,要启用纹理。并且,还需要将像素数据载入到纹理中。注意纹理的宽度和高度,目前很多OpenGL的实现都还要求其值为2的整数次方,如果纹理图象本身并不满足这个条件,可以使用gluScaleImage函数来进行缩放。为了正确的使用纹理,需要设置纹理参数。
载入纹理所需要的系统开销是比较大的,应该尽可能减少载入纹理的次数。如果程序中只使用一幅纹理,则只在第一次使用前载入,以后不必重新载入。如果程序中要使用多幅纹理,不应该反复载入它们,而应该将每个纹理都用一个纹理对象来保存,并使用glBindTextures在各个纹理之间进行切换。
本课还给出了一个程序(到目前为止,它是这个OpenGL教程系列中所给出的程序中最长的)。该程序演示了纹理的基本使用方法,本课程涉及到的几乎所有内容都被包括其中,这是对本课中文字说明的一个补充。如果读者有什么不明白的地方,也可以以这个程序作为参考。
OpenGL入门学习[十二]
片断测试其实就是测试每一个像素,只有通过测试的像素才会被绘制,没有通过测试的像素则不进行绘制。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测试。将图片中所有需要透明的地方的Alpha值设置为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 XP自带的画图工具打开,并另存为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, pixels);
// 修改像素中的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<count; ++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("pic.bmp");
texWindow = load_texture("window.bmp");
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_STENCIL,例如:
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的二进制值为00000011,因为只比较最后两位,5的最后两位为01,其实是小于3的,因此会通过测试。
如何设置像素的“模板值”呢?glClear函数可以将所有像素的模板值复位。代码如下:
glClear(GL_STENCIL_BUFFER_BIT);
可以同时复位颜色值和模板值:
glClear(GL_COLOR_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
正如可以使用glClearColor函数来指定清空屏幕后的颜色那样,也可以使用glClearStencil函数来指定复位后的“模板值”。
每个像素的“模板值”会根据模板测试的结果和深度测试的结果而进行改变。
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);
glDepthMask(GL_TRUE);
// 绘制一个与先前球体关于平面镜对称的球体,注意光源的位置也要发生对称改变
// 因为平面镜是在X轴和Y轴所确定的平面,所以只要Z坐标取反即可实现对称
// 为了保证球体的绘制范围被限制在平面镜内部,使用模板测试
glStencilFunc(GL_EQUAL, 1, 0xFF);
glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE);
glScalef(1.0f, 1.0f, -1.0f);
draw_sphere();
// 交换缓冲
glutSwapBuffers();
// 截图
grab();
}
其中display函数的末尾调用了一个grab函数,它保存当前的图象到一个BMP文件。这个函数本来是在第十课和第十一课中都有所使用的。但是我发现它有一个bug,现在进行了修改:在函数最开头的部分加上一句:glReadBuffer(GL_FRONT);即可。注意这个函数最好是在绘制完毕后(如果是使用双缓冲,则应该在交换缓冲后)立即调用。
=====================未完,请勿跟帖=====================
大家可能会有这样的感觉:模板测试的设置是如此复杂,它可以实现的功能应该很多,肯定不止这样一个“限制像素的绘制范围”。事实上也是如此,不过现在我们暂时只讲这些。
其实,如果不需要绘制半透明效果,有时候可以用混合功能来代替模板测试。就绘制镜像这个例子来说,可以采用下面的步骤:
(1) 清除屏幕,在glClearColor中设置合适的值确保清除屏幕后像素的Alpha值为0.0
(2) 关闭混合功能,绘制球体本身,设置合适的颜色(或者光照与材质)以确保所有被绘制的像素的Alpha值为0.0
(3) 绘制平面镜,设置合适的颜色(或者光照与材质)以确保所有被绘制的像素的Alpha值为1.0
(4) 启用混合功能,用GL_DST_ALPHA作为源因子,GL_ONE_MINUS_DST_ALPHA作为目标因子,这样就实现了只有原来Alpha为1.0的像素才能被修改,而原来Alpha为0.0的像素则保持不变。这时再绘制镜像物体,注意确保所有被绘制的像素的Alpha值为1.0。
在有的OpenGL实现中,模板测试是软件实现的,而混合功能是硬件实现的,这时候可以考虑这样的代替方法以提高运行效率。但是并非所有的模板测试都可以用混合功能来代替,并且这样的代替显得不自然,复杂而且容易出错。
另外始终注意:使用混合来模拟时,即使某个像素原来的Alpha值为0.0,以致于在绘制后其颜色不会有任何变化,但是这个像素的深度值有可能会被修改,而如果是使用模板测试,没有通过测试的像素其深度值不会发生任何变化。而且,模板测试和混合功能中,像素模板值的修改方式是不一样的。
=====================未完,请勿跟帖=====================
4、深度测试
在本课的开头,已经简单的叙述了深度测试。这里是完整的内容。
深度测试需要深度缓冲区,跟模板测试需要模板缓冲区是类似的。如果使用GLUT工具包,可以在调用glutInitDisplayMode函数时在参数中加上GLUT_DEPTH,这样来明确指定要求使用深度缓冲区。
深度测试和模板测试的实现原理很类似,都是在一个缓冲区保存像素的某个值,当需要进行测试时,将保存的值与另一个值进行比较,以确定是否通过测试。两者的区别在于:模板测试是设定一个值,在测试时用这个设定值与像素的“模板值”进行比较,而深度测试是根据顶点的空间坐标计算出深度,用这个深度与像素的“深度值”进行比较。也就是说,模板测试需要指定一个值作为比较参考,而深度测试中,这个比较用的参考值是OpenGL根据空间坐标自动计算的。
通过glEnable/glDisable函数可以启用或禁用深度测试。
glEnable(GL_DEPTH_TEST); // 启用深度测试
glDisable(GL_DEPTH_TEST); // 禁用深度测试
至于通过测试的条件,同样有八种,与Alpha测试中的条件设置相同。条件设置是通过glDepthFunc函数完成的,默认值是GL_LESS。
glDepthFunc(GL_LESS);
与模板测试相比,深度测试的应用要频繁得多。几乎所有的三维场景绘制都使用了深度测试。正因为这样,几乎所有的OpenGL实现都对深度测试提供了硬件支持,所以虽然两者的实现原理类似,但深度测试很可能会比模板测试快得多。当然了,两种测试在应用上很少有交集,一般不会出现使用一种测试去代替另一种测试的情况。
=====================未完,请勿跟帖=====================
小结:
本次课程介绍了OpenGL所提供的四种测试,分别是剪裁测试、Alpha测试、模板测试、深度测试。OpenGL会对每个即将绘制的像素进行以上四种测试,每个像素只有通过一项测试后才会进入下一项测试,而只有通过所有测试的像素才会被绘制,没有通过测试的像素会被丢弃掉,不进行绘制。每种测试都可以单独的开启或者关闭,如果某项测试被关闭,则认为所有像素都可以顺利通过该项测试。
剪裁测试是指:只有位于指定矩形内部的像素才能通过测试。
Alpha测试是指:只有Alpha值与设定值相比较,满足特定关系条件的像素才能通过测试。
模板测试是指:只有像素模板值与设定值相比较,满足特定关系条件的像素才能通过测试。
深度测试是指:只有像素深度值与新的深度值比较,满足特定关系条件的像素才能通过测试。
上面所说的特定关系条件可以是大于、小于、等于、大于等于、小于等于、不等于、始终通过、始终不通过这八种。
模板测试需要模板缓冲区,深度测试需要深度缓冲区。这些缓冲区都是在初始化OpenGL时指定的。如果使用GLUT工具包,则可以在glutInitDisplayMode函数中指定。无论是否开启深度测试,OpenGL在像素被绘制时都会尝试修改像素的深度值;而只有开启模板测试时,OpenGL才会尝试修改像素的模板值,模板测试被关闭时,OpenGL在像素被绘制时也不会修改像素的模板值。
利用这些测试操作可以控制像素被绘制或不被绘制,从而实现一些特殊效果。利用混合功能可以实现半透明,通过设置也可以实现完全透明,因而可以模拟像素颜色的绘制或不绘制。但注意,这里仅仅是颜色的模拟。OpenGL可以为像素保存颜色、深度值和模板值,利用混合实现透明时,像素颜色不发生变化,但深度值则会可能变化,模板值受glStencilFunc函数中第三个参数影响;利用测试操作实现透明时,像素颜色不发生变化,深度值也不发生变化,模板值受glStencilFunc函数中前两个参数影响。
此外,修正了第十课、第十一课中的一个函数中的bug。在grab函数中,应该在最开头加上一句glReadBuffer(GL_FRONT);以保证读取到的内容正好就是显示的内容。
因为论坛支持附件了,我会把程序源代码和所使用的图片上传到附件里,方便大家下载。
===================== 第十二课 完 =====================
=====================TO BE CONTINUED=====================
OpenGL入门学习[十三]
前一段时间里,论坛有位朋友问什么是状态机。按我的理解,状态机就是一种存在于理论中的机器,它具有以下的特点:
1. 它有记忆的能力,能够记住自己当前的状态。
2. 它可以接收输入,根据输入的内容和自己的状态,修改自己的状态,并且可以得到输出。
3. 当它进入某个特殊的状态(停机状态)的时候,它不再接收输入,停止工作。
理论说起来很抽象,但实际上是很好理解的。
首先,从本质上讲,我们现在的电脑就是典型的状态机。可以对照理解:
1. 电脑的存储器(内存、硬盘等等),可以记住电脑自己当前的状态(当前安装在电脑中的软件、保存在电脑中的数据,其实都是二进制的值,都属于当前的状态)。
2. 电脑的输入设备接收输入(键盘输入、鼠标输入、文件输入),根据输入的内容和自己的状态(主要指可以运行的程序代码),修改自己的状态(修改内存中的值),并且可以得到输出(将结果显示到屏幕)。
3. 当它进入某个特殊的状态(关机状态)的时候,它不再接收输入,停止工作。
OpenGL也可以看成这样的一种机器。让我们先对照理解一下:
1. OpenGL可以记录自己的状态(比如:当前所使用的颜色、是否开启了混合功能,等等,这些都是要记录的)
2. OpenGL可以接收输入(当我们调用OpenGL函数的时候,实际上可以看成OpenGL在接收我们的输入),根据输入的内容和自己的状态,修改自己的状态,并且可以得到输出(比如我们调用glColor3f,则OpenGL接收到这个输入后会修改自己的“当前颜色”这个状态;我们调用glRectf,则OpenGL会输出一个矩形)
3. OpenGL可以进入停止状态,不再接收输入。这个可能在我们的程序中表现得不太明显,不过在程序退出前,OpenGL总会先停止工作的。
还是没理解?呵呵,看来这真不是个好的开始呀,难得等了这么久,好不容易教程有更新了,怎么如此的难懂啊??没关系,实在没理解,咱就不理解它了。接着往下看。
为什么我要提到“状态机”这个枯燥的、晦涩的概念呢?其实它可以帮助我们理解一些东西。
比如我在前面的教程里面,经常说:
可以使用glColor*函数来选择一种颜色,以后绘制的所有物体都是这种颜色,除非再次使用glColor*函数重新设定。
可以使用glTexCoord*函数来设置一个纹理坐标,以后绘制的所有物体都是采用这种纹理坐标,除非再次使用glTexCoord*函数重新设置。
可以使用glBlendFunc函数来指定混合功能的源因子和目标因子,以后绘制的所有物体都是采用这个源因子和目标因子,除非再次使用glBlendFunc函数重新指定。
可以使用glLight*函数来指定光源的位置、颜色,以后绘制的所有物体都是采用这个光源的位置、颜色,除非再次使用glBlendFunc函数重新指定。
……
呵呵,很繁,是吧?“状态机”可以简化这个描述。
OpenGL是一个状态机,它保持自身的状态,除非用户输入一条命令让它改变状态。
颜色、纹理坐标、源因子和目标因子、光源的各种参数,等等,这些都是状态,所以这一句话就包含了上面叙述的所有内容。
此外,“是否启用了光照”、“是否启用了纹理”、“是否启用了混合”、“是否启用了深度测试”等等,这些也都是状态,也符合上面的描述:OpenGL会保持状态,除非我们调用OpenGL函数来改变它。
取得OpenGL的当前状态
OpenGL保存了自己的状态,我们可以通过一些函数来取得这些状态。
首先来说一些启用/禁用的状态。
我们通过glEnable来启用状态,通过glDisable来禁用它们。例如:
glEnable(GL_DEPTH_TEST);
glEnable(GL_BLEND);
glEnable(GL_CULL_FACE);
glEnable(GL_LIGHTING);
glEnable(GL_TEXTURE_2D);
可以用glIsEnabled函数来检测这些状态是否被开启。例如:
glIsEnabled(GL_DEPTH_TEST);
glIsEnabled(GL_BLEND);
glIsEnabled(GL_CULL_FACE);
glIsEnabled(GL_LIGHTING);
glIsEnabled(GL_TEXTURE_2D);
如果状态是开启的,则glIsEnabled函数返回GL_TRUE(这是一个不为零的常量,一般被定义为1);否则返回GL_FALSE(这是一个常量,其值为零)
我们可以在程序里面写:
if( glIsEnabled(GL_BLEND) ) {
// 当前开启了混合功能
} else {
// 当前没有开启混合功能
}
再看其它类型的状态。
比如当前颜色,其值是四个浮点数,当前设置的直线宽度,其值是一个浮点数,当前的视口(Viewport,参见第五课),其值是四个整数。
为了取得整数类型、浮点数类型的状态,OpenGL提供了glGetBooleanv, glGetIntegerv, glGetFloatv, glGetDoublev这四个函数。调用函数时,指定需要得到的状态的名称,以及需要将状态值存放到的位置(一个指针),则这四个函数可以把状态值存放到指针所值位置。例如:
// 取得当前的直线宽度
GLfloat lw;
glGetFloatv(GL_LINE_WIDTH, &lw);
// 取得当前的颜色
GLfloat cc[4];
glGetFloatv(GL_CURRENT_COLOR, cc);
// 取得当前的视口
GLint viewport[4];
glGetIntegerv(GL_VIEWPORT, viewport);
说明:
1. 注意元素的个数。比如GL_LINE_WIDTH状态只有一个值,而GL_CURRENT_COLOR有四个值。应该小心的定义变量或者数组,避免下标越界。
2. 使用四个不同的函数,同一种状态也可以返回为不同类型的值。比如要得到当前的颜色,一般可以返回GLfloat类型或者GLdouble类型。代码如下:
GLfloat cc[4];
GLdouble dcc[4];
glGetFloatv(GL_CURRENT_COLOR, cc);
glGetDoublev(GL_CURRENT_COLOR, dcc);
glGetBooleanv, glGetIntegerv, glGetFloatv, glGetDoublev这四个函数可以得到OpenGL中多数的状态,但是还有一些状态不便用这四个函数来取得。比如光源的状态,因为可能有多个光源,所以不可能使用类似glGetFloatv(GL_LIGHT_POSITION, pos);这样的方法来得到光源位置。为了解决这个问题,OpenGL专门提供了glGetLight*系列函数,来取得光源的状态。
类似的,还有glGetMaterial*, glGetTexParameter*等,每个函数都有自己的适用范围。
设置OpenGL状态
呵呵,读者可能会有疑问。既然有getXXX这样的函数来取得OpenGL的状态,那么为什么没有setXXX这样的函数来设置OpenGL状态呢?
答案很简单,因为OpenGL已经提供了大量的函数来设置状态了:glColor*, glMaterial*, glEnable, glDisable, 等等,大多数OpenGL函数都是用来设置OpenGL状态的,因此不需要再设计一个setXXX函数来设置OpenGL状态。
从“状态机”的角度来看。状态机根据输入来修改自己的状态,而不是由外界直接修改自己的状态。所以不设置setXXX这样的函数,也是很合理的。
OpenGL工作流程
教程都放到第十三课了,但是我一直没有对“工作流程”这种东西做过说明。OpenGL是按照什么样的流程来进行工作的呢?下面的图片可以简要的说明一下:
声明:该图片来自www.opengl.org,该图片是《OpenGL编程指南》一书的附图,由于该书的旧版(第一版,1994年)已经流传于网络,我希望没有触及到版权问题。
因为图片中的文字是英语,这里还翻译一下。说明文字也夹杂在翻译之中了。
1. Vertex data: 顶点数据。比如我们指定的颜色、纹理坐标、法线向量、顶点坐标等,都属于顶点数据。
2. Pixel data: 像素数据。我们在绘制像素、指定纹理时都会用到像素数据。
3. Display list: 显示列表。可以把调用的OpenGL函数保存起来。(参见第八课)
4. Evaluators: 求值器。这个我们在前面的课程中没有提到,以后估计也不太会提到。利用求值器可以指定贝赛尔曲线或者贝赛尔曲面,但是实际上还是可以理解为指定顶点、指定纹理坐标、指定法线向量等。
5. Per-vertex operations and primitive assembly: 单一的顶点操作以及图元装配。首先对单一的顶点进行操作,比如变换(参见第五课)。然后把顶点装配为图元(图元就是OpenGL所能绘制的最简单的图形,比如点、线段、三角形、四边形、多边形等,参见第二课)
6. Pixel operations: 像素操作。例如把内存中的像素数据格式转化为图形硬件所支持的数据格式。对于纹理,可以替换其中的一部分像素,这也属于像素操作。
7. Rasterization: 光栅化。顶点数据和像素数据在这里交汇(可以想像成:顶点和纹理,一起组合成了具有纹理的三角形),形成完整的、可以显示的一整块(可能是点、线段、三角形、四边形,或者其它不规则图形),里面包含若干个像素。这一整块被称为fragment(片段)。
8. Per-fragment operations: 片段操作。包括各种片段测试(参见第十二课)。
9. Framebuffer: 帧缓冲。这是一块存储空间,显示设备从这里读取数据,然后显示到屏幕。
10. Texture assembly: 纹理装配,这里我也没怎么弄清楚:(,大概是说纹理的操作和像素操作是相关的吧。
说明:图片中实线表示正常的处理流程,虚线表示数据可以反方向读取,比如可以用glReadPixels从帧缓冲中读取像素数据(实际上是从帧缓冲读取数据,经过像素操作,把显示设备中的像素数据格式转化为内存中的像素数据格式,最终成为内存中的像素数据)。
小结
本课是枯燥的理论知识。
OpenGL是一个状态机,它维持自己的状态,并根据用户调用的函数来改变自己的状态。根据状态的不同,调用同样的函数也可能产生不同的效果。
可以通过一些函数来获取OpenGL当前的状态。常用的函数有:glIsEnabled, glGetBooleanv, glGetIntegerv, glGetFloatv, glGetDoublev。
OpenGL的工作流程,输入像素数据和顶点数据,两种数据分别操作后,通过光栅化,得到片段,再经过片段处理,最后绘制到帧缓冲区。绘制的结果也可以逆方向传送,最终转化为像素数据。
OpenGL入门学习[十四]
OpenGL从推出到现在,已经有相当长的一段时间了。其间,OpenGL不断的得到更新。到今天为止,正式的OpenGL已经有九个版本。(1.0, 1.1, 1.2, 1.2.1, 1.3, 1.4, 1.5, 2.0, 2.1)
每个OpenGL版本的推出,都增加了一些当时流行的或者迫切需要的新功能。同时,到现在为止,OpenGL是向下兼容的,就是说如果某个功能在一个低版本中存在,则在更高版本中也一定存在。这一特性也为我们编程提供了一点方便。
当前OpenGL的最新版本是OpenGL 2.1,但是并不是所有的计算机系统都有这样最新版本的OpenGL实现。举例来说,Windows系统如果没有安装显卡驱动,或者显卡驱动中没有附带OpenGL,则Windows系统默认提供一个软件实现的OpenGL,它没有使用硬件加速,因此速度可能较慢,版本也很低,仅支持1.1版本(听说Windows Vista默认提供的OpenGL支持到1.4版本,我也不太清楚)。nVidia和ATI这样的显卡巨头,其主流显卡基本上都提供了对OpenGL 2.1的支持。但一些旧型号的显卡因为性能不足等原因,只能支持到OpenGL 2.0或者OpenGL 1.5。Intel的集成显卡,很多都只提供了OpenGL 1.4(据说目前也有更高版本的了,但是我没有见到)。
OpenGL 2.0是一次比较大的改动,也因此升级了主版本号。可以认为OpenGL 2.0版本是一个分水岭,是否支持OpenGL 2.0版本,直接关系到运行OpenGL程序时的效果。如果要类比一下的话,我觉得OpenGL 1.5和OpenGL 2.0的差距,就像是DirectX 8.1和DirectX 9.0c的差距了。
检查自己的OpenGL版本
可以很容易的知道自己系统中的OpenGL版本,方法就是调用glGetString函数。
const char* version = (const char*)glGetString(GL_VERSION);
printf("OpenGL 版本:%s\n", version);
glGetString(GL_VERSION);会返回一个表示版本的字符串,字符串的格式为X.X.X,就是三个整数,用小数点隔开,第一个数表示OpenGL主版本号,第二个数表示OpenGL次版本号,第三个数表示厂商发行代号。比如我在运行时得到的是"2.0.1",这表示我的OpenGL版本为2.0(主版本号为2,次版本号为0),是厂商的第一个发行版本。
通过sscanf函数,也可以把字符串分成三个整数,以便详细的进行判断。
int main_version, sub_version, release_version;
const char* version = (const char*)glGetString(GL_VERSION);
sscanf(version, "%d.%d.%d", &main_version, &sub_version, &release_version);
printf("OpenGL 版本:%s\n", version);
printf("主版本号:%d\n", main_version);
printf("次版本号:%d\n", sub_version);
printf("发行版本号:%d\n", release_version);
glGetString还可以取得其它的字符串。
glGetString(GL_VENDOR); 返回OpenGL的提供厂商。
glGetString(GL_RENDERER); 返回执行OpenGL渲染的设备,通常就是显卡的名字。
glGetString(GL_EXTENSIONS); 返回所支持的所有扩展,每两个扩展之间用空格隔开。详细情况参见下面的关于“OpenGL扩展”的叙述。
版本简要历史
版本不同,提供功能的多少就不同。这里列出每个OpenGL版本推出时,所增加的主要功能。当然每个版本的修改并不只是下面的内容,读者如果需要知道更详细的情形,可以查阅OpenGL标准。
OpenGL 1.1
顶点数组。把所有的顶点数据(颜色、纹理坐标、顶点坐标等)都放到数组中,可以大大的减少诸如glColor*, glVertex*等函数的调用次数。虽然显示列表也可以减少这些函数的调用次数,但是显示列表中的数据是不可以修改的,顶点数组中的数据则可以修改。
纹理对象。把纹理作为对象来管理,同一时间OpenGL可以保存多个纹理(但只使用其中一个)。以前没有纹理对象时,OpenGL只能保存一个“当前纹理”。要使用其它纹理时,只能抛弃当前的纹理,重新载入。原来的方式非常影响效率。
OpenGL 1.2
三维纹理。以前的OpenGL只支持一维、二维纹理。
像素格式。新增加了GL_BGRA等原来没有的像素格式。允许压缩的像素格式,例如GL_UNSIGNED_SHORT_5_5_5_1格式,表示两个字节,存放RGBA数据,其中R, G, B各占5个二进制位,A占一个二进制位。
图像处理。新增了一个“图像处理子集”,提供一些图像处理的专用功能,例如卷积、计算柱状图等。这个子集虽然是标准规定,但是OpenGL实现时也可以选择不支持它。
OpenGL 1.2.1
没有加入任何新的功能。但是引入了“ARB扩展”的概念。详细情况参见下面的关于“OpenGL扩展”的叙述。
OpenGL 1.3
压缩纹理。在处理纹理时,使用压缩后的纹理而不是纹理本身,这样可以节省空间(节省显存)和传输带宽(节省从内存到显存的数据流量)
多重纹理。同时使用多个纹理。
多重采样。一种全屏抗锯齿技术,使用后可以让画面显示更加平滑,减轻锯齿现象。对于nvidia显卡,在设置时有一项“3D平滑处理设置”,实际上就是多重采样。通常可以选择2x, 4x,高性能的显卡也可以选择8x, 16x。其它显卡也几乎都有类似的设置选项,但是也有的显卡不支持多重采样,所以是0x。
OpenGL 1.4
深度纹理。可以把深度值像像素值一样放到纹理中,在绘制阴影时特别有用。
辅助颜色。顶点除了有颜色外还有辅助颜色。在使用光照时可以表现出更真实的效果。
OpenGL 1.5
缓冲对象。允许把数据(主要指顶点数据)交由OpenGL保存到较高性能的存储器中,提高绘制速度。比顶点数组有更多优势。顶点数组只是减少函数调用次数,缓冲对象不仅减少函数调用次数,还加快数据访问速度。
遮挡查询。可以计算一个物体有几个像素会被绘制到屏幕上。如果物体没有任何像素会被绘制,则不需要加载相关的数据(例如纹理数据)。
OpenGL 2.0
可编程着色。允许编写一小段代码来代替OpenGL原来的顶点操作/片段操作。这样提供了巨大的灵活性,可以实现各种各样的丰富的效果。
纹理大小不再必须是2的整数次方。
点块纹理。把纹理应用到一个点(大小可能不只一个像素)上,这样比绘制一个矩形可能效率更高。
OpenGL 2.1
可编程着色,编程语言由原来的1.0版本升级为1.2版本。
缓冲对象,原来仅允许存放顶点数据,现在也允许存放像素数据。
获得新版本的OpenGL
要获得新版本OpenGL,首先应该登陆你的显卡厂商网站,并查询相关的最新信息。根据情况,下载最新的驱动或者OpenGL软件包。
如果自己的显卡不支持高版本的OpenGL,或者自己的操作系统根本就没有提供OpenGL,怎么办呢?有一个被称为MESA的开源项目,用C语言编写了一个OpenGL实现,最新的mesa 7.0已经实现了OpenGL 2.1标准中所规定的各种功能。下载MESA的代码,然后编译,就可以得到一个最新版本的OpenGL了。呵呵,不要高兴的太早。MESA是软件实现的,就是说没有用到硬件加速,因此运行起来会较慢,尤其是使用新版本的OpenGL所规定的一些高级特性时,慢得几乎无法忍受。MESA不能让你用旧的显卡玩新的游戏(很可能慢得没法玩),但是如果你只是想学习或尝试一下新版本OpenGL的各种功能,MESA可以满足你的一部分要求。
OpenGL扩展
OpenGL版本的更新并不快。如果某种技术变得流行起来,但是OpenGL标准中又没有相关的规定对这种技术提供支持,那就只能通过扩展来实现了。
厂商在发行OpenGL时,除了遵照OpenGL标准,提供标准所规定的各种功能外,往往还提供其它一些额外的功能,这就是扩展。
扩展的存在,使得各种新的技术可以迅速的被应用到OpenGL中。比如“多重纹理”,它是在OpenGL 1.3中才被加入到标准中的,在OpenGL 1.3出现以前,很多OpenGL实现都通过扩展来支持“多重纹理”。这样,即使OpenGL版本不更新,只要增加新的扩展,也可以提供新的功能了。这也说明,即使OpenGL版本较低,也不一定不支持一些高版本OpenGL才提供的功能。实际上某些OpenGL 1.5的实现,也可能提供了最新的OpenGL 2.1版本所规定的大部分功能。
当然扩展也有缺点,那就是程序在运行的时候必须检查每个扩展功能是否被支持,导致编写程序代码复杂。
扩展的名字
每个OpenGL扩展,都必须向OpenGL的网站注册,确认后才能成为扩展。注册后的扩展有编号和名字。编号仅仅是一个序号,名字则与扩展所提供的功能相关。
名字用下划线分为三部分。举例来说,一个扩展的名字可能为:GL_NV_half_float,其意义如下:
第一部分为扩展的目标。比如GL表示这是一个OpenGL扩展。如果是WGL则表示这是一个针对Windows的OpenGL扩展,如果是GLX则表示这是一个针对linux的X Window系统的OpenGL扩展。
第二部分为提供扩展的厂商。比如NV表示这是nVidia公司所提供的扩展。相应的还有ATI, IBM, SGI, APPLE, MESA等。
剩下的部分就表示扩展所提供的内容了。比如half_float,表示半精度的浮点数,每个浮点数的精度只有单精度浮点数的一半,因此只需要两个字节就可以保存。这种扩展功能可以节省内存空间,也节省从内存到显卡的数据传输量,代价就是精确度有所降低。
EXT扩展和ARB扩展
最初的时候,每个厂商都提供自己的扩展。这样导致的结果就是,即使是提供相同的功能,不同的厂商却提供不同的扩展,这样在编写程序的时候,使用一种功能就需要依次检查每个可能支持这种功能的扩展,非常繁琐。
于是出现了EXT扩展和ARB扩展。
EXT扩展是由多个厂商共同协商后形成的扩展,在扩展名字中,“提供扩展的厂商”一栏将不再是具体的厂商名,而是EXT三个字母。比如GL_EXT_bgra,就是一个EXT扩展。
ARB扩展不仅是由多个厂商共同协商形成,还需要经过OpenGL体系结构审核委员会(即ARB)的确认。在扩展名字中,“提供扩展的厂商”一栏不再是具体的厂商名字,而是ARB三个字母。比如GL_ARB_imaging,就是一个ARB扩展。
通常,一种功能如果有多个厂商提出,则它成为EXT扩展。在以后的时间里,如果经过了ARB确认,则它成为ARB扩展。再往后,如果OpenGL的维护者认为这种功能需要加入到标准规定中,则它不再是扩展,而成为标准的一部分。
例如point_parameters,就是先有GL_EXT_point_parameters,再有GL_ARB_point_parameters,最后到OpenGL 1.4版本时,这个功能为标准规定必须提供的功能,不再是一个扩展。
在使用OpenGL所提供的功能时,应该按照标准功能、ARB扩展、EXT扩展、其它扩展这样的优先顺序。例如有ARB扩展支持这个功能时,就不使用EXT扩展。
在程序中,判断OpenGL是否支持某个扩展
前面已经说过,glGetString(GL_EXTENSIONS)会返回当前OpenGL所支持的所有扩展的名字,中间用空格分开,这就是我们判断是否支持某个扩展的依据。
#include <string.h>
// 判断OpenGL是否支持某个指定的扩展
// 若支持,返回1。否则返回0。
int hasExtension(const char* name) {
const char* extensions = (const char*)glGetString(GL_EXTENSIONS);
const char* end = extensions + strlen(extensions);
size_t name_length = strlen(name);
while( extensions < end ) {
size_t position = strchr(extensions, ' ') - extensions;
if( position == name_length &&
strncmp(extensions, name, position) == 0 )
return 1;
extensions += (position + 1);
}
return 0;
}
上面这段代码,判断了OpenGL是否支持指定的扩展,可以看到,判断时完全是靠字符串处理来实现的。循环检测,找到第一个空格,然后比较空格之前的字符串是否与指定的名字一致。若一致,说明扩展是被支持的;否则,继续比较。若所有内容都比较完,则说明扩展不被支持。
编写程序调用扩展的功能
扩展的函数、常量,在命名时与通常的OpenGL函数、常量有少许区别。那就是扩展的函数、常量将以厂商的名字作为后缀。
比如ARB扩展,所有ARB扩展的函数,函数名都以ARB结尾,常量名都以_ARB结尾。例如:
glGenBufferARB(函数)
GL_ARRAY_BUFFER_ARB(常量)
如果已经知道OpenGL支持某个扩展,则如何调用扩展中的函数?大致的思路就是利用函数指针。但是不幸的是,在不同的操作系统中,取得这些函数指针的方法各不相同。为了能够在各个操作系统中都能顺利的使用扩展,我向大家介绍一个小巧的工具:GLEE。
GLEE是一个开放源代码的项目,可以从网络上搜索并下载。其代码由两个文件组成,一个是GLee.c,一个是GLee.h。把两个文件都放到自己的源代码一起编译,运行的时候,GLee可以自动的判断所有扩展是否被支持,如果支持,GLEE会自动读取对应的函数,供我们调用。
我们自己编写代码时,需要首先包含GLee.h,然后才包含GL/glut.h(注意顺序不能调换),然后就可以方便的使用各种扩展功能了。
#include "GLee.h"
#include <GL/glut.h> // 注意顺序,GLee.h要在glut.h之前使用
GLEE也可以帮助我们判断OpenGL是否支持某个扩展,因此有了GLEE,前面那个判断是否支持扩展的函数就不太必要了。
示例代码
让我们用一段示例代码结束本课。
我们选择一个目前绝大多数显卡都支持的扩展GL_ARB_window_pos,来说明如何使用GLEE来调用OpenGL扩展功能。通常我们在绘制像素时,需要用glRasterPos*函数来指定绘制的位置。但是,glRasterPos*函数使用的不是屏幕坐标,例如指定(0, 0)不一定是左下角,这个坐标需要经过各种变换(参见第五课,变换),最后才得到屏幕上的窗口位置。
通过GL_ARB_window_pos扩展,我们可以直接用屏幕上的坐标来指定绘制的位置,不再需要经过变换,这样在很多场合会显得简单。
#include "GLee.h"
#include <GL/glut.h>
void display(void) {
glClear(GL_COLOR_BUFFER_BIT);
if( GLEE_ARB_window_pos ) { // 如果支持GL_ARB_window_pos
// 则使用glWindowPos2iARB函数,指定绘制位置
printf("支持GL_ARB_window_pos\n");
printf("使用glWindowPos函数\n");
glWindowPos2iARB(100, 100);
} else { // 如果不支持GL_ARB_window_pos
// 则只能使用glRasterPos*系列函数
// 先计算出一个经过变换后能够得到
// (100, 100)的坐标(x, y, z)
// 然后调用glRasterPos3d(x, y, z);
GLint viewport[4];
GLdouble modelview[16], projection[16];
GLdouble x, y, z;
printf("不支持GL_ARB_window_pos\n");
printf("使用glRasterPos函数\n");
glGetIntegerv(GL_VIEWPORT, viewport);
glGetDoublev(GL_MODELVIEW_MATRIX, modelview);
glGetDoublev(GL_PROJECTION_MATRIX, projection);
gluUnProject(100, 100, 0.5, modelview, projection, viewport,
&x, &y, &z);
glRasterPos3d(x, y, z);
}
{ // 绘制一个5*5的像素块
GLubyte pixels[5][5][4];
// 把像素中的所有像素都设置为红色
int i, j;
for(i=0; i<5; ++i)
for(j=0; j<5; ++j) {
pixels[i][j][0] = 255; // red
pixels[i][j][1] = 0; // green
pixels[i][j][2] = 0; // blue
pixels[i][j][3] = 255; // alpha
}
glDrawPixels(5, 5, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
}
glutSwapBuffers();
}
int main(int argc, char* argv[]) {
glutInit(&argc, argv);
glutInitDisplayMode(GLUT_RGBA | GLUT_DOUBLE);
glutInitWindowPosition(100, 100);
glutInitWindowSize(512, 512);
glutCreateWindow("OpenGL");
glutDisplayFunc(&display);
glutMainLoop();
}
可以看到,使用了扩展以后,代码会简单得多了。不支持GL_ARB_window_pos扩展时必须使用较多的代码才能实现的功能,使用GL_ARB_window_pos扩展后即可简单的解决。
如果把代码修改一下,不使用扩展而直接使用else里面的代码,可以发现运行效果是一样的。
工具软件
在课程的最后我还向大家介绍一个免费的工具软件,这就是OpenGL Extension Viewer(各大软件网站均有下载,请自己搜索之),目前较新的版本是3.0。
这个软件可以查看自己计算机系统的OpenGL信息。包括OpenGL版本、提供厂商、设备名称、所支持的扩展等。
软件可以查看的信息很详细,比如查看允许的最大纹理大小、最大光源数目等。
在查看扩展时,可以在最下面一栏输入扩展的名字,按下回车后即可连接到OpenGL官方网站,查找关于这个扩展的详细文档,非常不错。
可以根据电脑的配置情况,自动连接到对应的官方网站,方便下载最新驱动。(比如我是nVidia的显卡,则连接到nVidia的驱动下载页面)
可以进行OpenGL测试,看看运行起来性能如何。
可以给出总体报告,如果一些比较重要的功能不被支持,则会用粗体字标明。
软件还带有一个数据库,可以查询各厂商、各型号的显卡对OpenGL各种扩展的支持情况。
小结
本课介绍了OpenGL版本和OpenGL扩展。
OpenGL从诞生到现在,经历了1.0, 1.1, 1.2, 1.2.1, 1.3, 1.4, 1.5, 2.0, 2.1这些版本。
每个系统中的OpenGL版本可能不同。使用glGetString(GL_VERSION);可以查看当前的OpenGL版本。
新版本的OpenGL将兼容旧版本的OpenGL,同时提供更多的新特性和新功能。
OpenGL在实现时可以通过扩展,来提供额外的功能。
OpenGL扩展有厂家扩展、EXT扩展、ARB扩展。通常应该尽量使用标准功能,其次才是ARB扩展、EXT扩展、厂家扩展。
GLEE是一个可以免费使用的工具,使用它可以方便的判断当前的OpenGL是否支持某扩展,也可以方便的调用扩展。
OpenGL Extension Viewer是一个软件,可以检查系统所支持OpenGL的版本、支持的扩展、以及很多的详细信息。
OpenGL入门学习[十五]
这次讲的所有内容都装在一个立方体中,呵呵。
呵呵,绘制一个立方体,简单呀,我们学了第一课第二课,早就会了。
先别着急,立方体是很简单,但是这里只是拿立方体做一个例子,来说明OpenGL在绘制方法上的改进。
从原始一点的办法开始
一个立方体有六个面,每个面是一个正方形,好,绘制六个正方形就可以了。
glBegin(GL_QUADS);
glVertex3f(...);
glVertex3f(...);
glVertex3f(...);
glVertex3f(...);
// ...
glEnd();
为了绘制六个正方形,我们为每个正方形指定四个顶点,最终我们需要指定6*4=24个顶点。但是我们知道,一个立方体其实总共只有八个顶点,要指定24次,就意味着每个顶点其实重复使用了三次,这样可不是好的现象。最起码,像上面这样重复烦琐的代码,是很容易出错的。稍有不慎,即使相同的顶点也可能被指定成不同的顶点了。
如果我们定义一个数组,把八个顶点都放到数组里,然后每次指定顶点都使用指针,而不是使用直接的数据,这样就避免了在指定顶点时考虑大量的数据,于是减少了代码出错的可能性。
// 将立方体的八个顶点保存到一个数组里面
static const GLfloat vertex_list[][3] = {
-0.5f, -0.5f, -0.5f,
0.5f, -0.5f, -0.5f,
// ...
};
// 指定顶点时,用指针,而不用直接用具体的数据
glBegin(GL_QUADS);
glVertex3fv(vertex_list[0]);
glVertex3fv(vertex_list[2]);
glVertex3fv(vertex_list[3]);
glVertex3fv(vertex_list[1]);
// ...
glEnd();
修改之后,虽然代码变长了,但是确实易读得多。很容易就看出第0, 2, 3, 1这四个顶点构成一个正方形。
稍稍观察就可以发现,我们使用了大量的glVertex3fv函数,其实每一句都只有其中的顶点序号不一样,因此我们可以再定义一个序号数组,把所有的序号也放进去。这样一来代码就更加简单了。
// 将立方体的八个顶点保存到一个数组里面
static const GLfloat vertex_list[][3] = {
-0.5f, -0.5f, -0.5f,
0.5f, -0.5f, -0.5f,
-0.5f, 0.5f, -0.5f,
0.5f, 0.5f, -0.5f,
-0.5f, -0.5f, 0.5f,
0.5f, -0.5f, 0.5f,
-0.5f, 0.5f, 0.5f,
0.5f, 0.5f, 0.5f,
};
// 将要使用的顶点的序号保存到一个数组里面
static const GLint index_list[][4] = {
0, 2, 3, 1,
0, 4, 6, 2,
0, 1, 5, 4,
4, 5, 7, 6,
1, 3, 7, 5,
2, 6, 7, 3,
};
int i, j;
// 绘制的时候代码很简单
glBegin(GL_QUADS);
for(i=0; i<6; ++i) // 有六个面,循环六次
for(j=0; j<4; ++j) // 每个面有四个顶点,循环四次
glVertex3fv(vertex_list[index_list[i][j]]);
glEnd();
这样,我们就得到一个比较成熟的绘制立方体的版本了。它的数据和程序代码基本上是分开的,所有的顶点放到一个数组中,使用顶点的序号放到另一个数组中,而利用这两个数组来绘制立方体的代码则很简单。
关于顶点的序号,下面这个图片可以帮助理解。
正对我们的面,按逆时针顺序,背对我们的面,则按顺时针顺序,这样就得到了上面那个index_list数组。
为什么要按照顺时针逆时针的规则呢?因为这样做可以保证无论从哪个角度观察,看到的都是“正面”,而不是背面。在计算光照时,正面和背面的处理可能是不同的,另外,剔除背面只绘制正面,可以提高程序的运行效率。(关于正面、背面,以及剔除,参见第三课,绘制几何图形的一些细节问题)
例如在绘制之前调用如下的代码:
glFrontFace(GL_CCW);
glCullFace(GL_BACK);
glEnable(GL_CULL_FACE);
glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
则绘制出来的图形就只有正面,并且只显示边线,不进行填充。
效果如图:
顶点数组
(提示:顶点数组是OpenGL 1.1所提供的功能)
前面的方法中,我们将数据和代码分离开,看起来只要八个顶点就可以绘制一个立方体了。但是实际上,循环还是执行了6*4=24次,也就是说虽然代码的结构清晰了不少,但是程序运行的效率,还是和最原始的那个方法一样。
减少函数的调用次数,是提高运行效率的方法之一。于是我们想到了显示列表。把绘制立方体的代码装到一个显示列表中,以后只要调用这个显示列表即可。
这样看起来很不错,但是显示列表有一个缺点,那就是一旦建立后不可再改。如果我们要绘制的不是立方体,而是一个能够走动的人物,因为人物走动时,四肢的位置不断变化,几乎没有办法把所有的内容装到一个显示列表中。必须每种动作都使用单独的显示列表,这样会导致大量的显示列表管理困难。
顶点数组是解决这个问题的一个方法。使用顶点数组的时候,也是像前面的方法一样,用一个数组保存所有的顶点,用一个数组保存顶点的序号。但最后绘制的时候,不是编写循环语句逐个的指定顶点了,而是通知OpenGL,“保存顶点的数组”和“保存顶点序号的数组”所在的位置,由OpenGL自动的找到顶点,并进行绘制。
下面的代码说明了顶点数组是如何使用的:
glEnableClientState(GL_VERTEX_ARRAY);
glVertexPointer(3, GL_FLOAT, 0, vertex_list);
glDrawElements(GL_QUADS, 24, GL_UNSIGNED_INT, index_list);
其中:
glEnableClientState(GL_VERTEX_ARRAY); 表示启用顶点数组。
glVertexPointer(3, GL_FLOAT, 0, vertex_list); 指定顶点数组的位置,3表示每个顶点由三个量构成(x, y, z),GL_FLOAT表示每个量都是一个GLfloat类型的值。第三个参数0,参见后面介绍“stride参数”。最后的vertex_list指明了数组实际的位置。
glDrawElements(GL_QUADS, 24, GL_UNSIGNED_INT, index_list); 根据序号数组中的序号,查找到相应的顶点,并完成绘制。GL_QUADS表示绘制的是四边形,24表示总共有24个顶点,GL_UNSIGNED_INT表示序号数组内每个序号都是一个GLuint类型的值,index_list指明了序号数组实际的位置。
上面三行代码代替了原来的循环。可以看到,原来的glBegin/glEnd不再需要了,也不需要调用glVertex*系列函数来指定顶点,因此可以明显的减少函数调用次数。另外,数组中的内容可以随时修改,比显示列表更加灵活。
详细一点的说明。
顶点数组实际上是多个数组,顶点坐标、纹理坐标、法线向量、顶点颜色等等,顶点的每一个属性都可以指定一个数组,然后用统一的序号来进行访问。比如序号3,就表示取得颜色数组的第3个元素作为颜色、取得纹理坐标数组的第3个元素作为纹理坐标、取得法线向量数组的第3个元素作为法线向量、取得顶点坐标数组的第3个元素作为顶点坐标。把所有的数据综合起来,最终得到一个顶点。
可以用glEnableClientState/glDisableClientState单独的开启和关闭每一种数组。
glEnableClientState(GL_VERTEX_ARRAY);
glEnableClientState(GL_COLOR_ARRAY);
glEnableClientState(GL_NORMAL_ARRAY);
glEnableClientState(GL_TEXTURE_COORD_ARRAY);
用以下的函数来指定数组的位置:
glVertexPointer
glColorPointer
glNormalPointer
glTexCoordPointer
为什么不使用原来的glEnable/glDisable函数,而要专门的规定一个glEnableClientState/glDisableClientState函数呢?这跟OpenGL的工作机制有关。OpenGL在设计时,认为可以将整个OpenGL系统分为两部分,一部分是客户端,它负责发送OpenGL命令。一部分是服务端,它负责接收OpenGL命令并执行相应的操作。对于个人计算机来说,可以将CPU、内存等硬件,以及用户编写的OpenGL程序看做客户端,而将OpenGL驱动程序、显示设备等看做服务端。
通常,所有的状态都是保存在服务端的,便于OpenGL使用。例如,是否启用了纹理,服务端在绘制时经常需要知道这个状态,而我们编写的客户端OpenGL程序只在很少的时候需要知道这个状态。所以将这个状态放在服务端是比较有利的。
但顶点数组的状态则不同。我们指定顶点,实际上就是把顶点数据从客户端发送到服务端。是否启用顶点数组,只是控制发送顶点数据的方式而已。服务端只管接收顶点数据,而不必管顶点数据到底是用哪种方式指定的(可以直接使用glBegin/glEnd/glVertex*,也可以使用顶点数组)。所以,服务端不需要知道顶点数组是否开启。因此,顶点数组的状态放在客户端是比较合理的。
为了表示服务端状态和客户端状态的区别,服务端的状态用glEnable/glDisable,客户端的状态则用glEnableClientState/glDisableClientState。
stride参数。
顶点数组并不要求所有的数据都连续存放。如果数据没有连续存放,则指定数据之间的间隔即可。
例如:我们使用一个struct来存放顶点中的数据。注意每个顶点除了坐标外,还有额外的数据(这里是一个int类型的值)。
typedef struct __point__ {
GLfloat position[3];
int id;
} Point;
Point vertex_list[] = {
-0.5f, -0.5f, -0.5f, 1,
0.5f, -0.5f, -0.5f, 2,
-0.5f, 0.5f, -0.5f, 3,
0.5f, 0.5f, -0.5f, 4,
-0.5f, -0.5f, 0.5f, 5,
0.5f, -0.5f, 0.5f, 6,
-0.5f, 0.5f, 0.5f, 7,
0.5f, 0.5f, 0.5f, 8,
};
static GLint index_list[][4] = {
0, 2, 3, 1,
0, 4, 6, 2,
0, 1, 5, 4,
4, 5, 7, 6,
1, 3, 7, 5,
2, 6, 7, 3,
};
glEnableClientState(GL_VERTEX_ARRAY);
glVertexPointer(3, GL_FLOAT, sizeof(Point), vertex_list);
glDrawElements(GL_QUADS, 24, GL_UNSIGNED_INT, index_list);
注意最后三行代码,可以看到,几乎所有的地方都和原来一样,只在glVertexPointer函数的第三个参数有所不同。这个参数就是stride,它表示“从一个数据的开始到下一个数据的开始,所相隔的字节数”。这里设置为sizeof(Point)就刚刚好。如果设置为0,则表示数据是紧密排列的,对于3个GLfloat的情况,数据紧密排列时stride实际上为3*4=12。
混合数组。如果需要同时使用颜色数组、顶点坐标数组、纹理坐标数组、等等,有一种方式是把所有的数据都混合起来,指定到同一个数组中。这就是混合数组。
GLfloat arr_c3f_v3f[] = {
1, 0, 0, 0, 1, 0,
0, 1, 0, 1, 0, 0,
0, 0, 1, -1, 0, 0,
};
GLuint index_list[] = {0, 1, 2};
glInterleavedArrays(GL_C3F_V3F, 0, arr_c3f_v3f);
glDrawElements(GL_TRIANGLES, 3, GL_UNSIGNED_INT, index_list);
glInterleavedArrays,可以设置混合数组。这个函数会自动调用glVertexPointer, glColorPointer等函数,并且自动的开启或禁用相关的数组。
函数的第一个参数表示了混合数组的类型。例如GL_C3F_V3F表示:三个浮点数作为颜色、三个浮点数作为顶点坐标。也可以有其它的格式,比如GL_V2F, GL_V3F, GL_C4UB_V2F, GL_C4UB_V3F, GL_C3F_V3F, GL_N3F_V3F, GL_C4F_N3F_V3F, GL_T2F_V3F, GL_T4F_V4F, GL_T2F_C4UB_V3F, GL_T2F_C3F_V3F, GL_T2F_N3F_V3F, GL_T2F_C4F_N3F_V3F, GL_T4F_C4F_N3F_V4F等等。其中T表示纹理坐标,C表示颜色,N表示法线向量,V表示顶点坐标。
再来说说顶点数组与显示列表的区别。两者都可以明显的减少函数的调用次数,但是还是各有优点的。
对于顶点数组,顶点数据是存放在内存中的,也就是存放在客户端。每次绘制的时候,需要把所有的顶点数据从客户端(内存)发送到服务端(显示设备),然后进行处理。对于显示列表,顶点数据是放在显示列表中的,显示列表本身又是存放在服务器端的,所以不会重复的发送数据。
对于顶点数组,因为顶点数据放在内存中,所以可以随时修改,每次绘制的时候都会把当前数组中的内容作为顶点数据发送并进行绘制。对于显示列表,数据已经存放到服务器段,并且无法取出,所以无法修改。
也就是说,显示列表可以避免数据的重复发送,效率会较高;顶点数组虽然会重复的发送数据,但由于数据可以随时修改,灵活性较好。
顶点缓冲区对象
(提示:顶点缓冲区对象是OpenGL 1.5所提供的功能,但它在成为标准前是一个ARB扩展,可以通过GL_ARB_vertex_buffer_object扩展来使用这项功能。前面已经讲过,ARB扩展的函数名称以字母ARB结尾,常量名称以字母_ARB结尾,而标准函数、常量则去掉了ARB字样。很多的OpenGL实现同时支持vertex buffer object的标准版本和ARB扩展版本。我们这里以ARB扩展来讲述,因为目前绝大多数个人计算机都支持ARB扩展版本,但少数显卡仅支持OpenGL 1.4,无法使用标准版本。)
前面说到顶点数组和显示列表在绘制立方体时各有优劣,那么有没有办法将它们的优点集中到一起,并且尽可能的减少缺点呢?顶点缓冲区对象就是为了解决这个问题而诞生的。它数据存放在服务端,同时也允许客户端灵活的修改,兼顾了运行效率和灵活性。
顶点缓冲区对象跟纹理对象有很多相似之处。首先,分配一个缓冲区对象编号,然后,为对应编号的缓冲区对象指定数据,以后可以随时修改其中的数据。下面的表格可以帮助类比理解。
纹理对象 顶点缓冲区对象
分配编号 glGenTextures glGenBuffersARB
绑定(指定为当前所使用的对象) glBindTexture glBindBufferARB
指定数据 glTexImage* glBufferDataARB
修改数据 glTexSubImage* glBufferSubDataARB
顶点数据和序号各自使用不同的缓冲区。具体的说,就是顶点数据放在GL_ARRAY_BUFFER_ARB类型的缓冲区中,序号数据放在GL_ELEMENT_ARRAY_BUFFER_ARB类型的缓冲区中。
具体的情况可以用下面的代码来说明:
static GLuint vertex_buffer;
static GLuint index_buffer;
// 分配一个缓冲区,并将顶点数据指定到其中
glGenBuffersARB(1, &vertex_buffer);
glBindBufferARB(GL_ARRAY_BUFFER_ARB, vertex_buffer);
glBufferDataARB(GL_ARRAY_BUFFER_ARB,
sizeof(vertex_list), vertex_list, GL_STATIC_DRAW_ARB);
// 分配一个缓冲区,并将序号数据指定到其中
glGenBuffersARB(1, &index_buffer);
glBindBufferARB(GL_ELEMENT_ARRAY_BUFFER_ARB, index_buffer);
glBufferDataARB(GL_ELEMENT_ARRAY_BUFFER_ARB,
sizeof(index_list), index_list, GL_STATIC_DRAW_ARB);
在指定缓冲区数据时,最后一个参数是关于性能的提示。一共有STREAM_DRAW, STREAM_READ, STREAM_COPY, STATIC_DRAW, STATIC_READ, STATIC_COPY, DYNAMIC_DRAW, DYNAMIC_READ, DYNAMIC_COPY这九种。每一种都表示了使用频率和用途,OpenGL会根据这些提示进行一定程度的性能优化。
(提示仅仅是提示,不是硬性规定。也就是说,即使使用了STREAM_DRAW,告诉OpenGL这段缓冲区数据一旦指定,以后不会修改,但实际上以后仍可修改,不过修改时可能有较大的性能代价)
当使用glBindBufferARB后,各种使用指针为参数的OpenGL函数,行为会发生变化。
以glColor3fv为例,通常,这个函数接受一个指针作为参数,从指针所指的位置取出连续的三个浮点数,作为当前的颜色。
但使用glBindBufferARB后,这个函数不再从指针所指的位置取数据。函数会先把指针转化为整数,假设转化后结果为k,则会从当前缓冲区的第k个字节开始取数据。特别一点,如果我们写glColor3fv(NULL);因为NULL转化为整数后通常是零,所以从缓冲区的第0个字节开始取数据,也就是从缓冲区最开始的位置取数据。
这样一来,原来写的
glVertexPointer(3, GL_FLOAT, 0, vertex_list);
glDrawElements(GL_QUADS, 24, GL_UNSIGNED_INT, index_list);
在使用缓冲区对象后,就变成了
glVertexPointer(3, GL_FLOAT, 0, NULL);
glDrawElements(GL_QUADS, 24, GL_UNSIGNED_INT, NULL);
以下是完整的使用了顶点缓冲区对象的代码:
static GLfloat vertex_list[][3] = {
-0.5f, -0.5f, -0.5f,
0.5f, -0.5f, -0.5f,
-0.5f, 0.5f, -0.5f,
0.5f, 0.5f, -0.5f,
-0.5f, -0.5f, 0.5f,
0.5f, -0.5f, 0.5f,
-0.5f, 0.5f, 0.5f,
0.5f, 0.5f, 0.5f,
};
static GLint index_list[][4] = {
0, 2, 3, 1,
0, 4, 6, 2,
0, 1, 5, 4,
4, 5, 7, 6,
1, 3, 7, 5,
2, 6, 7, 3,
};
if( GLEE_ARB_vertex_buffer_object ) {
// 如果支持顶点缓冲区对象
static int isFirstCall = 1;
static GLuint vertex_buffer;
static GLuint index_buffer;
if( isFirstCall ) {
// 第一次调用时,初始化缓冲区
isFirstCall = 0;
// 分配一个缓冲区,并将顶点数据指定到其中
glGenBuffersARB(1, &vertex_buffer);
glBindBufferARB(GL_ARRAY_BUFFER_ARB, vertex_buffer);
glBufferDataARB(GL_ARRAY_BUFFER_ARB,
sizeof(vertex_list), vertex_list, GL_STATIC_DRAW_ARB);
// 分配一个缓冲区,并将序号数据指定到其中
glGenBuffersARB(1, &index_buffer);
glBindBufferARB(GL_ELEMENT_ARRAY_BUFFER_ARB, index_buffer);
glBufferDataARB(GL_ELEMENT_ARRAY_BUFFER_ARB,
sizeof(index_list), index_list, GL_STATIC_DRAW_ARB);
}
glBindBufferARB(GL_ARRAY_BUFFER_ARB, vertex_buffer);
glBindBufferARB(GL_ELEMENT_ARRAY_BUFFER_ARB, index_buffer);
// 实际使用时与顶点数组非常相似,只是在指定数组时不再指定实际的数组,改为指定NULL即可
glEnableClientState(GL_VERTEX_ARRAY);
glVertexPointer(3, GL_FLOAT, 0, NULL);
glDrawElements(GL_QUADS, 24, GL_UNSIGNED_INT, NULL);
} else {
// 不支持顶点缓冲区对象
// 使用顶点数组
glEnableClientState(GL_VERTEX_ARRAY);
glVertexPointer(3, GL_FLOAT, 0, vertex_list);
glDrawElements(GL_QUADS, 24, GL_UNSIGNED_INT, index_list);
}
可以分配多个缓冲区对象,顶点坐标、颜色、纹理坐标等数据,可以各自单独使用一个缓冲区。
每个缓冲区可以有不同的性能提示,比如在绘制一个运动的人物时,顶点坐标数据经常变化,但法线向量、纹理坐标等则不会变化,可以给予不同的性能提示,以提高性能。
小结
本课从绘制一个立方体出发,描述了OpenGL在各个版本中对于绘制的处理。
绘制物体的时候,应该将数据单独存放,尽量不要到处写类似glVertex3f(1.0f, 0.0f, 1.0f)这样的代码。将顶点坐标、顶点序号都存放到单独的数组中,可以让绘制的代码变得简单。
可以把绘制物体的所有命令装到一个显示列表中,这样可以避免重复的数据传送。但是因为显示列表一旦建立,就无法修改,所以灵活性很差。
OpenGL 1.1版本,提供了顶点数组。它可以指定数据的位置、顶点序号的位置,从而有效的减少函数调用次数,达到提高效率的目的。但是它没有避免重复的数据传送,所以效率还有待进一步提高。
OpenGL 1.5版本,提供了顶点缓冲区对象。它综合了显示列表和顶点数组的优点,同时兼顾运行效率和灵活性,是绘制物体的一个好选择。如果系统不支持OpenGL 1.5,也可以检查是否支持扩展GL_ARB_vertex_buffer_object。
第十六课,在Windows系统中显示文字
增加了两个文件,showline.c, showtext.c。分别为第二个和第三个示例程序的main函数相关部分。
在ctbuf.h和textarea.h最开头部分增加了一句#include <stdlib.h>
附件中一共有三个示例程序:
第一个,飘动的“曹”字旗。代码为:flag.c, GLee.c, GLee.h
第二个,带缓冲的显示文字。代码为:showline.c, ctbuf.c, ctbuf.h, GLee.c, GLee.h
第三个,显示歌词。代码为:showtext.c, ctbuf.c, ctbuf.h, textarea.c, textarea.h, GLee.c, GLee.h
其中,GLee.h和GLee.c可以从网上下载,因此这里并没有放到附件中。在编译的时候应该将这两个文件和其它代码文件一起编译。
本课我们来谈谈如何显示文字。
OpenGL并没有直接提供显示文字的功能,并且,OpenGL也没有自带专门的字库。因此,要显示文字,就必须依赖操作系统所提供的功能了。
各种流行的图形操作系统,例如Windows系统和Linux系统,都提供了一些功能,以便能够在OpenGL程序中方便的显示文字。
最常见的方法就是,我们给出一个字符,给出一个显示列表编号,然后操作系统由把绘制这个字符的OpenGL命令装到指定的显示列表中。当需要绘制字符的时候,我们只需要调用这个显示列表即可。
不过,Windows系统和Linux系统,产生这个显示列表的方法是不同的(虽然大同小异)。作为我个人,只在Windows系统中编程,没有使用Linux系统的相关经验,所以本课我们仅针对Windows系统。
OpenGL版的“Hello, World!”
写完了本课,我的感受是:显示文字很简单,显示文字很复杂。看似简单的功能,背后却隐藏了深不可测的玄机。
呵呵,别一开始就被吓住了,让我们先从“Hello, World!”开始。
前面已经说过了,要显示字符,就需要通过操作系统,把绘制字符的动作装到显示列表中,然后我们调用显示列表即可绘制字符。
假如我们要显示的文字全部是ASCII字符,则总共只有0到127这128种可能,因此可以预先把所有的字符分别装到对应的显示列表中,然后在需要时调用这些显示列表。
Windows系统中,可以使用wglUseFontBitmaps函数来批量的产生显示字符用的显示列表。函数有四个参数:
第一个参数是HDC,学过Windows GDI的朋友应该会熟悉这个。如果没有学过,那也没关系,只要知道调用wglGetCurrentDC函数,就可以得到一个HDC了。具体的情况可以看下面的代码。
第二个参数表示第一个要产生的字符,因为我们要产生0到127的字符的显示列表,所以这里填0。
第三个参数表示要产生字符的总个数,因为我们要产生0到127的字符的显示列表,总共有128个字符,所以这里填128。
第四个参数表示第一个字符所对应显示列表的编号。假如这里填1000,则第一个字符的绘制命令将被装到第1000号显示列表,第二个字符的绘制命令将被装到第1001号显示列表,依次类推。我们可以先用glGenLists申请128个连续的显示列表编号,然后把第一个显示列表编号填在这里。
还要说明一下,因为wglUseFontBitmaps是Windows系统特有的函数,所以在使用前需要加入头文件:#include <windows.h>。
现在让我们来看具体的代码:
#include <windows.h>
// ASCII字符总共只有0到127,一共128种字符
#define MAX_CHAR 128
void drawString(const char* str) {
static int isFirstCall = 1;
static GLuint lists;
if( isFirstCall ) { // 如果是第一次调用,执行初始化
// 为每一个ASCII字符产生一个显示列表
isFirstCall = 0;
// 申请MAX_CHAR个连续的显示列表编号
lists = glGenLists(MAX_CHAR);
// 把每个字符的绘制命令都装到对应的显示列表中
wglUseFontBitmaps(wglGetCurrentDC(), 0, MAX_CHAR, lists);
}
// 调用每个字符对应的显示列表,绘制每个字符
for(; *str!='\0'; ++str)
glCallList(lists + *str);
}
显示列表一旦产生就一直存在(除非调用glDeleteLists销毁),所以我们只需要在第一次调用的时候初始化,以后就可以很方便的调用这些显示列表来绘制字符了。
绘制字符的时候,可以先用glColor*等指定颜色,然后用glRasterPos*指定位置,最后调用显示列表来绘制。
void display(void) {
glClear(GL_COLOR_BUFFER_BIT);
glColor3f(1.0f, 0.0f, 0.0f);
glRasterPos2f(0.0f, 0.0f);
drawString("Hello, World!");
glutSwapBuffers();
}
效果如图:
指定字体
在产生显示列表前,Windows允许选择字体。
我做了一个selectFont函数来实现它,大家可以看看代码。
void selectFont(int size, int charset, const char* face) {
HFONT hFont = CreateFontA(size, 0, 0, 0, FW_MEDIUM, 0, 0, 0,
charset, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS,
DEFAULT_QUALITY, DEFAULT_PITCH | FF_SWISS, face);
HFONT hOldFont = (HFONT)SelectObject(wglGetCurrentDC(), hFont);
DeleteObject(hOldFont);
}
void display(void) {
selectFont(48, ANSI_CHARSET, "Comic Sans MS");
glClear(GL_COLOR_BUFFER_BIT);
glColor3f(1.0f, 0.0f, 0.0f);
glRasterPos2f(0.0f, 0.0f);
drawString("Hello, World!");
glutSwapBuffers();
}
最主要的部分就在于那个参数超多的CreateFont函数,学过Windows GDI的朋友应该不会陌生。没有学过GDI的朋友,有兴趣的话可以自己翻翻MSDN文档。这里我并不准备仔细讲这些参数了,下面的内容还多着呢:(
如果需要在自己的程序中选择字体的话,把selectFont函数抄下来,在调用glutCreateWindow之后、在调用wglUseFontBitmaps之前使用selectFont函数即可指定字体。函数的三个参数分别表示了字体大小、字符集(英文字体可以用ANSI_CHARSET,简体中文字体可以用GB2312_CHARSET,繁体中文字体可以用CHINESEBIG5_CHARSET,对于中文的Windows系统,也可以直接用DEFAULT_CHARSET表示默认字符集)、字体名称。
效果如图:
显示中文
原则上,显示中文和显示英文并无不同,同样是把要显示的字符做成显示列表,然后进行调用。
但是有一个问题,英文字母很少,最多只有几百个,为每个字母创建一个显示列表,没有问题。但是汉字有非常多个,如果每个汉字都产生一个显示列表,这是不切实际的。
我们不能在初始化时就为每个字符建立一个显示列表,那就只有在每次绘制字符时创建它了。当我们需要绘制一个字符时,创建对应的显示列表,等绘制完毕后,再将它销毁。
这里还经常涉及到中文乱码的问题,我对这个问题也不甚了解,但是网上流传的版本中,使用了MultiByteToWideChar这个函数的,基本上都没有出现乱码,所以我也准备用这个函数:)
通常我们在C语言里面使用的字符串,如果中英文混合的话,例如“this is 中文字符.”,则英文字符只占用一个字节,而中文字符则占用两个字节。用MultiByteToWideChar函数,可以转化为所有的字符都占两个字节(同时解决了前面所说的乱码问题:))。
转化的代码如下:
// 计算字符的个数
// 如果是双字节字符的(比如中文字符),两个字节才算一个字符
// 否则一个字节算一个字符
len = 0;
for(i=0; str[i]!='\0'; ++i)
{
if( IsDBCSLeadByte(str[i]) )
++i;
++len;
}
// 将混合字符转化为宽字符
wstring = (wchar_t*)malloc((len+1) * sizeof(wchar_t));
MultiByteToWideChar(CP_ACP, MB_PRECOMPOSED, str, -1, wstring, len);
wstring[len] = L'\0';
// 用完后记得释放内存
free(wstring);
加上前面所讲到的wglUseFontBitmaps函数,即可显示中文字符了。
void drawCNString(const char* str) {
int len, i;
wchar_t* wstring;
HDC hDC = wglGetCurrentDC();
GLuint list = glGenLists(1);
// 计算字符的个数
// 如果是双字节字符的(比如中文字符),两个字节才算一个字符
// 否则一个字节算一个字符
len = 0;
for(i=0; str[i]!='\0'; ++i)
{
if( IsDBCSLeadByte(str[i]) )
++i;
++len;
}
// 将混合字符转化为宽字符
wstring = (wchar_t*)malloc((len+1) * sizeof(wchar_t));
MultiByteToWideChar(CP_ACP, MB_PRECOMPOSED, str, -1, wstring, len);
wstring[len] = L'\0';
// 逐个输出字符
for(i=0; i<len; ++i)
{
wglUseFontBitmapsW(hDC, wstring[i], 1, list);
glCallList(list);
}
// 回收所有临时资源
free(wstring);
glDeleteLists(list, 1);
}
注意我用了wglUseFontBitmapsW函数,而不是wglUseFontBitmaps。wglUseFontBitmapsW是wglUseFontBitmaps函数的宽字符版本,它认为字符都占两个字节。因为这里使用了MultiByteToWideChar,每个字符其实是占两个字节的,所以应该用wglUseFontBitmapsW。
void display(void) {
glClear(GL_COLOR_BUFFER_BIT);
selectFont(48, ANSI_CHARSET, "Comic Sans MS");
glColor3f(1.0f, 0.0f, 0.0f);
glRasterPos2f(-0.7f, 0.4f);
drawString("Hello, World!");
selectFont(48, GB2312_CHARSET, "楷体_GB2312");
glColor3f(1.0f, 1.0f, 0.0f);
glRasterPos2f(-0.7f, -0.1f);
drawCNString("当代的中国汉字");
selectFont(48, DEFAULT_CHARSET, "华文仿宋");
glColor3f(0.0f, 1.0f, 0.0f);
glRasterPos2f(-0.7f, -0.6f);
drawCNString("傳統的中國漢字");
glutSwapBuffers();
}
效果如图:
纹理字体
把文字放到纹理中有很多好处,例如,可以任意修改字符的大小(而不必重新指定字体)。
对一面飘动的旗帜使用带有文字的纹理,则文字也会随着飘动。这个技术在“三国志”系列游戏中经常用到,比如关羽的部队,旗帜上就飘着个“关”字,张飞的部队,旗帜上就飘着个“张”字,曹操的大营,旗帜上就飘着个“曹”字。三国人物何其多,不可能为每种姓氏都单独制作一面旗帜纹理,如果能够把文字放到纹理上,则可以解决这个问题。(参见后面的例子:绘制一面“曹”字旗)
如何把文字放到纹理中呢?自然的想法就是:“如果前面所用的显示列表,可以直接往纹理里面绘制,那就好了”。不过,“绘制到纹理”这种技术要涉及的内容可不少,足够我们专门拿一课的篇幅来讲解了。这里我们不是直接绘制到纹理,而是用简单一点的办法:先把汉字绘制出来,成为像素,然后用glCopyTexImage2D把像素复制为纹理。
glCopyTexImage2D与glTexImage2D的用法是类似的(参见第11课),不过前者是直接把绘制好的像素复制到纹理中,后者是从内存传送数据到纹理中。要使用到的代码大致如下:
// 先把文字绘制好
glRasterPos2f(XXX, XXX);
drawCNString("關");
// 分配纹理编号
glGenTextures(1, &texID);
// 指定为当前纹理
glBindTexture(GL_TEXTURE_2D, texID);
// 把像素作为纹理数据
// 将屏幕(0, 0) 到 (64, 64)的矩形区域的像素复制到纹理中
glCopyTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 0, 0, 64, 64, 0);
// 设置纹理参数
glTexParameteri(GL_TEXTURE_2D,
GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D,
GL_TEXTURE_MAG_FILTER, GL_LINEAR);
然后,我们就可以像使用普通的纹理一样来做了。绘制各种物体时,指定合适的纹理坐标即可。
有一个细节问题需要特别注意。大家看上面的代码,指定文字显示的位置,写的是glRasterPos2f(XXX, XXX);这里来讲讲如何计算这个显示坐标。
让我们首先从计算文字的大小谈起。大家知道即使是同一字号的同一个文字,大小也可能是不同的,英文字母尤其如此,有的字体中大写字母O和小写字母l是一样宽的(比如Courier New),有的字体中大写字母O比较宽,而小写字母l比较窄(比如Times New Roman),汉字通常比英文字母要宽。
为了计算文字的宽度,Windows专门提供了一个函数GetCharABCWidths,它计算一系列连续字符的ABC宽度。所谓ABC宽度,包括了a, b, c三个量,a表示字符左边的空白宽度,b表示字符实际的宽度,c表示字符右边的空白宽度,三个宽度值相加得到整个字符所占宽度。如果只需要得到总的宽度,可以使用GetCharWidth32函数。如果要支持汉字,应该使用宽字符版本,即GetCharABCWidthsW和GetCharWidth32W。在使用前需要用MultiByteToWideChar函数,将通常的字符串转化为宽字符串,就像前面的wglUseFontBitmapsW那样。
解决了宽度,我们再来看看高度。本来,在指定字体的时候指定大小为s的话,所有的字符高度都为s,只有宽度不同。但是,如果我们使用glRasterPos2i(-1, -1)从最左下角开始显示字符的话,其实是不能得到完整的字符的:(。我们知道英文字母在写的时候可以分上中下三栏,这时绘制出来只有上、中两栏是可见的,下面一栏则不见了,字母g尤其明显。见下图:
所以,需要把绘制的位置往上移一点,具体来说就是移动下面一栏的高度。这个高度是多少像素呢?这个我也不知道有什么好办法来计算,根据我的经验,移动整个字符高度的八分之一是比较合适的。例如字符大小为24,则移动3个像素。
还要注意,OpenGL 2.0以前的版本,通常要求纹理的大小必须是2的整数次方,因此我们应该设置字体的高度为2的整数次方,例如16, 32, 64,这样用起来就会比较方便。
现在让我们整理一下思路。首先要做的是将字符串转化为宽字符的形式,以便使用wglUseFontBitmapsW和GetCharWidth32W函数。然后设置字体大小,接下来计算字体宽度,计算实际绘制的位置。然后产生显示列表,利用显示列表绘制字符,销毁显示列表。最后分配一个纹理编号,把字符像素复制到纹理中。
呵呵,内容已经不少了,让我们来看看代码。
#define FONT_SIZE 64
#define TEXTURE_SIZE FONT_SIZE
GLuint drawChar_To_Texture(const char* s) {
wchar_t w;
HDC hDC = wglGetCurrentDC();
// 选择字体字号、颜色
// 不指定字体名字,操作系统提供默认字体
// 设置颜色为白色
selectFont(FONT_SIZE, DEFAULT_CHARSET, "");
glColor3f(1.0f, 1.0f, 1.0f);
// 转化为宽字符
MultiByteToWideChar(CP_ACP, MB_PRECOMPOSED, s, 2, &w, 1);
// 计算绘制的位置
{
int width, x, y;
GetCharWidth32W(hDC, w, w, &width); // 取得字符的宽度
x = (TEXTURE_SIZE - width) / 2;
y = FONT_SIZE / 8;
glWindowPos2iARB(x, y); // 一个扩展函数
}
// 绘制字符
// 绘制前应该将各种可能影响字符颜色的效果关闭
// 以保证能够绘制出白色的字符
{
GLuint list = glGenLists(1);
glDisable(GL_DEPTH_TEST);
glDisable(GL_LIGHTING);
glDisable(GL_FOG);
glDisable(GL_TEXTURE_2D);
wglUseFontBitmaps(hDC, w, 1, list);
glCallList(list);
glDeleteLists(list, 1);
}
// 复制字符像素到纹理
// 注意纹理的格式
// 不使用通常的GL_RGBA,而使用GL_LUMINANCE4
// 因为字符本来只有一种颜色,使用GL_RGBA浪费了存储空间
// GL_RGBA可能占16位或者32位,而GL_LUMINANCE4只占4位
{
GLuint texID;
glGenTextures(1, &texID);
glBindTexture(GL_TEXTURE_2D, texID);
glCopyTexImage2D(GL_TEXTURE_2D, 0, GL_LUMINANCE4,
0, 0, TEXTURE_SIZE, TEXTURE_SIZE, 0);
glTexParameteri(GL_TEXTURE_2D,
GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D,
GL_TEXTURE_MAG_FILTER, GL_LINEAR);
return texID;
}
}
为了方便,我使用了glWindowPos2iARB这个扩展函数来指定绘制的位置。如果某个系统中OpenGL没有支持这个扩展,则需要使用较多的代码来实现类似的功能。为了方便的调用这个扩展,我使用了GLEE。详细的情形可以看本教程第十四课,最后的那一个例子。GL_ARB_window_pos扩展在OpenGL 1.3版本中已经成为标准的一部分,而几乎所有现在还能用的显卡在正确安装驱动后都至少支持OpenGL 1.4,所以不必担心不支持的问题。
另外,占用的空间也是需要考虑的问题。通常,我们的纹理都是用GL_RGBA格式,OpenGL会保存纹理中每个像素的红、绿、蓝、alpha四个值,通常,一个像素就需要16或32个二进制位才能保存,也就是2个字节或者4个字节才保存一个像素。我们的字符只有“绘制”和“不绘制”两种状态,因此一个二进制位就足够了,前面用16个或32个,浪费了大量的空间。缓解的办法就是使用GL_LUMINANCE4这种格式,它不单独保存红、绿、蓝颜色,而是把这三种颜色合起来称为“亮度”,纹理中只保存这种亮度,一个像素只用四个二进制位保存亮度,比原来的16个、32个要节省不少。注意这种格式不会保存alpha值,如果要从纹理中取alpha值的话,总是返回1.0。
应用纹理字体的实例:飘动的旗帜
(提示:这一段需要一些数学知识)
有了纹理,只要我们绘制一个正方形,适当的设置纹理坐标,就可以轻松的显示纹理图象了(参见第十一课),因为这里纹理图象实际上就是字符,所以我们也就显示出了字符。并且,随着正方形大小的变化,字符的大小也会随着变化。
直接贴上纹理,太简单了。现在我们来点挑战性的:画一个飘动的曹操军旗帜。效果如下图,很酷吧?呵呵。
效果是不错,不过它也不是那么容易完成的,接下来我们一点一点的讲解。
为了完成上面的效果,我们需要具备以下的知识:
1. 用多个四边形(实际上是矩形)连接起来,制作飘动的效果
2. 使用光照,计算法线向量
3. 把纹理融合进去
因为要使用光照,法线向量是不可少的。这里我们通过不共线的三个点来得到三个点所在平面的法线向量。
从数学的角度看,原理很简单。三个点v1, v2, v3,可以用v2减v1,v3减v1,得到从v1到v2和从v1到v3的向量s1和s2。然后向量s1和s2进行叉乘,得到垂直于s1和s2所在平面的向量,即法线向量。
为了方便使用,应该把法线向量缩放至单位长度,这个也很简单,计算向量的模,然后向量的每个分量都除以这个模即可。
#include <math.h>
// 设置法线向量
// 三个不在同一直线上的点可以确定一个平面
// 先计算这个平面的法线向量,然后指定到OpenGL
void setNormal(const GLfloat v1[3],
const GLfloat v2[3],
const GLfloat v3[3]) {
// 首先根据三个点坐标,相减计算出两个向量
const GLfloat s1[] = {
v2[0]-v1[0], v2[1]-v1[1], v2[2]-v1[2]};
const GLfloat s2[] = {
v3[0]-v1[0], v3[1]-v1[1], v3[2]-v1[2]};
// 两个向量叉乘得到法线向量的方向
GLfloat n[] = {
s1[1]*s2[2] - s1[2]*s2[1],
s1[2]*s2[0] - s1[0]*s2[2],
s1[0]*s2[1] - s1[1]*s2[0]
};
// 把法线向量缩放至单位长度
GLfloat abs = sqrt(n[0]*n[0] + n[1]*n[1] + n[2]*n[2]);
n[0] /= abs;
n[1] /= abs;
n[2] /= abs;
// 指定到OpenGL
glNormal3fv(n);
}
好的,飘动的旗帜已经做好,现在来看最后的步骤,将纹理贴到旗帜上。
细心的朋友可能会想到这样一个问题:明明绘制文字的时候使用的是白色,放到纹理中也是白色,那个“曹”字是如何显示为黄色的呢?
这就要说到纹理的使用方法了。大家在看了第十一课“纹理的使用入门”以后,难免认为纹理就是用一幅图片上的像素颜色来替换原来的颜色。其实这只是纹理最简单的一种用法,它还可以有其它更复杂但是实用的用法。
这里我们必须提到一个函数:glTexEnv*。从OpenGL 1.0到OpenGL 1.5,每个OpenGL版本都对这个函数进行了修改,如今它的功能已经变的非常强大(但同时也非常复杂,如果要全部讲解,只怕又要花费一整课的篇幅了)。
最简单的用法就是:
glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);
它指定纹理的使用方式为“代替”,即用纹理中的颜色代替原来的颜色。
我们这里使用另一种用法:
GLfloat color[] = {1.0f, 1.0f, 0.0f, 1.0f};
glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_BLEND);
glTexEnvfv(GL_TEXTURE_ENV, GL_TEXTURE_ENV_COLOR, color);
其中第二行指定纹理的使用方式为“混合”,它与OpenGL的混合功能类似,但源因子和目标因子是固定的,无法手工指定。最终产生的颜色为:纹理的颜色*常量颜色 + (1.0-纹理颜色)*原来的颜色。常量颜色是由第三行代码指定为黄色。
因为我们的纹理里面装的是文字,只有黑、白两种颜色。如果纹理中某个位置是黑色,套用上面的公式,发现结果就是原来的颜色,没有变化;如果纹理中某个位置是白色,套用上面的公式,发现结果就是常量颜色。所以,文字的颜色就由常量颜色决定。我们指定常量颜色,也就指定了文字的颜色。
主要的知识就是这些了,结合前面课程讲过的视图变换(设置观察点)、光照(设置光源、材质),以及动画,飘动的旗帜就算制作完成。
呵呵,代码已经比较庞大了,限于篇幅,完整的版本这里就不发上来了,不过附件里面有一份源代码flag.c。
缓冲机制
走出做完旗帜的喜悦后,让我们回到二维文字的问题上来。
前面说到因为汉字的数目众多,无法在初始化时就为每个汉字都产生一个显示列表。不过,如果每次显示汉字时都重新产生显示列表,效率上也说不过去。一个好的办法就是,把经常使用的汉字的显示列表保存起来,当需要显示汉字时,如果这个汉字的显示列表已经保存,则不再需要重新产生。如果有很多的汉字都需要产生显示列表,占用容量过多,则删除一部分最近没有使用的显示列表,以便释放出一些空间来容纳新的显示列表。
学过操作系统原理的朋友应该想起来了,没错,这与内存置换的算法是一样的。内存速度快但是容量小,硬盘(虚拟内存)速度慢但是容量大,需要找到一种机制,使性能尽可能的达到最高。这就是内存置换算法。
常见的内存置换算法有好几种,这里我们选择一种简单的。那就是随机选择一个显示列表并且删除,空出一个位置用来装新的显示列表。
还要说一下,我们不再直接用显示列表来显示汉字了,改用纹理。因为纹理更加灵活,而且根据实验,纹理比显示列表更快。一个显示列表只能保存一个字符,但是纹理只要足够大,则可以保存很多的字符。假设字符的高度是32,则宽度不超过32,如果纹理是256*256的话,就可以保存8行8列,总共64个汉字。
我们要做的功能:
1. 缓冲机制的初始化
2. 缓冲机制的退出
3. 根据一个文字字符,返回对应的纹理坐标。如果字符本身不在纹理中,则应该先把字符加入到纹理中(如果纹理已经装不下了,则先删除一个),然后返回纹理坐标。
要改进缓冲机制的性能,则应该使用更高效的置换算法,不过这个已经远超出OpenGL的范围了。大家如果有空也可以看看linux源码什么的,应该会找到好的置换算法。
即使我们使用最简单的置换算法,完整的代码仍然有将近200行,其实这些都是算法基本功了,跟OpenGL关系并不太大。仍然是由于篇幅限制,仅在附件中给出,就不贴在这里了。文件名为ctbuf.h和ctbuf.c,在使用的时候把这两个文件都加入到工程中,并调用ctbuf.h中声明的函数即可。
这里我们仅仅给出调用部分的代码。
#include "ctbuf.h"
void display(void) {
static int isFirstCall = 1;
if( isFirstCall ) {
isFirstCall = 0;
ctbuf_init(32, 256, "黑体");
}
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glEnable(GL_TEXTURE_2D);
glPushMatrix();
glTranslatef(-1.0f, 0.0f, 0.0f);
ctbuf_drawString("美好明天就要到来", 0.1f, 0.15f);
glTranslatef(0.0f, -0.15f, 0.0f);
ctbuf_drawString("Best is yet to come", 0.1f, 0.15f);
glPopMatrix();
glutSwapBuffers();
}
注意这里我们是用纹理来实现字符显示的,因此文字的大小会随着窗口大小而变化。最初的Hello, World程序就不会有这样的效果,因为它的字体硬性的规定了大小,不如纹理来得灵活。
显示大段的文字
有了缓冲机制,显示文字的速度会比没有缓冲时快很多,这样我们也可以考虑显示大段的文字了。
基本上,前面的ctbuf_drawString函数已经可以快速的显示一个较长的字符串,但是它有两个缺点。
第一个缺点是不会换行,一直横向显示到底。
第二个缺点是即使字符在屏幕以外,也会尝试在缓冲中查找这个字符,如果没找到,还会重新生成这个字符。
让我们先来看看第一个问题,换行。所谓换行其实就是把光标移动到下一行的开头,如果知道每一行开头的位置的话,只需要很短的代码就可以实现。
不过,OpenGL显示文字的时候并不会保存每一行开头的位置,所以这个需要我们自己动手来做。
第二个问题是关于性能的,如果字符本身不会显示出来,那么为它产生显示列表和纹理就是一种浪费,如果为了容纳它的显示列表或者纹理,而把缓冲区中其它有用的字符的显示列表或者纹理给删除了,那就更加得不偿失。
所以,判断字符是否会显示也是很重要的。像我们的浏览器,如果显示一个巨大的网页,其实它也只绘制最必要的部分。
为了解决上面两个问题,我们再单独的编写一个模块。初始化的时候指定显示区域的大小、每行多少个字符、每列多少个字符,在模块内部判断是否需要换行,以及判断每个文字是否真的需要显示。
呃,小小的感慨一下,为什么每当我做好一份代码,就发现它实在太长,长到我不想贴出来呢?唉……
先看看图:
注意观察就可以发现,歌词分为多行,只有必要的行才会显示,不会从头到尾的显示出来。
代码中主要是算法和C语言基本功,跟OpenGL关系并不大。还是照旧,把主要的代码放到附件里,文件名为textarea.h和textarea.c,使用时要与前面的ctbuf.h和ctbuf.c一起使用。
这里仅给出调用部分的代码。
const char* g_string =
"《合金装备》(Metal Gear Solid)结尾曲歌词\n"
// 歌词很多很长
"因为。。。。。。。。 \n"
"美好即将到来\n";
textarea_t* p_textarea = NULL;
void display(void) {
static int isFirstCall = 1;
if( isFirstCall ) {
isFirstCall = 0;
ctbuf_init(24, 256, "隶书");
p_textarea = ta_create(-0.7f, -0.5f, 0.7f, 0.5f,
20, 10, g_string);
glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE);
}
glClear(GL_COLOR_BUFFER_BIT);
// 显示歌词文字
glEnable(GL_TEXTURE_2D);
ta_display(p_textarea);
// 用半透明的效果显示一个方框
// 这个框是实际需要显示的范围
glEnable(GL_BLEND);
glDisable(GL_TEXTURE_2D);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
glColor4f(1.0f, 1.0f, 1.0f, 0.5f);
glRectf(-0.7f, -0.5f, 0.7f, 0.5f);
glDisable(GL_BLEND);
// 显示一些帮助信息
glEnable(GL_TEXTURE_2D);
glPushMatrix();
glTranslatef(-1.0f, 0.9f, 0.0f);
ctbuf_drawString("歌词显示程序", 0.1f, 0.1f);
glTranslatef(0.0f, -0.1f, 0.0f);
ctbuf_drawString("按W/S键实现上、下翻页", 0.1f, 0.1f);
glTranslatef(0.0f, -0.1f, 0.0f);
ctbuf_drawString("按ESC退出", 0.1f, 0.1f);
glPopMatrix();
glutSwapBuffers();
}
轮廓字体
其实上面我们所讲那么多,只讲了一类字体,即像素字体,此外还有轮廓字体。所以,这个看似已经很长的课程,其实只讲了“显示文字”这个课题的一半。估计大家已经看不下去了,其实我也写不下去了。好长……
那么,本课就到这里吧。有种虎头蛇尾的感觉:(
小结
本课的内容不可谓不多。列表如下:
1. 以Hello, World开始,说明英文字符(ASCII字符)是如何绘制的。
2. 给出了一个设置字体的函数selectFont。
3. 讲了如何显示中文字符。
4. 讲了如何把字符保存到纹理中。
5. 给出了一个大的例子,绘制一面“曹”字旗。(附件flag.c)
6. 讲解了缓冲机制,其实跟内存的置换算法原理是一样的。我们给出了一个最简单的缓冲实现,采用随机的置换算法。(做成了模块,附件ctbuf.h,ctbuf.c,调用的例子在本课正文中可以找到)
7. 通过缓冲机制,实现显示大段的文字。主要是注意换行的处理,还有就是只显示必要的行。(做成了模块,附件textarea.h,textarea.c,调用的例子在本课正文中可以找到)
最后两个模块虽然是以附件形式给出的,但是原理我想我已经说清楚了,并且这些内容跟OpenGL关系并不大,主要还是相关专业的知识,或者C语言基本功。主要是让大家弄清楚原理,附件代码只是作为参考用。
说说我的感受:显示文字很简单,显示文字很复杂。除了最基本的显示列表、纹理等OpenGL常识外,更多的会涉及到数学、数据结构与算法、操作系统等各个领域。一个大型的程序通常都要实现一些文字特殊效果,仅仅是调用几个显示列表当然是不行的,需要大量的相关知识来支撑。
本课的门槛突然提高,搞得我都不知道这还算不算是“入门教程”了,希望各位不要退缩哦。祝大家愉快。