作者:Dominik Göddeke 译者:华文广
These zip files contain a MS VC 2003.NET solution file, a linux Makefile and a set of batch files with preconfigured test environments. You might want to readthis section about the differences between Windows and Linux, NVIDIA and ATI first.
对本教程的引用, please use this BibTex citation.
Back to top
本教程的目的是为了介绍GPU编程的背景及在GPU上运算所需要的步骤,这里通过实现在GPU上运算一个线性代数的简单例子,来阐述我们的观点。saxpy() 是BLAS库上的一个函数,它实现的功能主要是这样的:已知两个长度为N的数组 x 和 y ,一个标量alpha,要求我们计算缩放比例数组之和:y = y + alpha * x。这个函数很简单。我们的目的只是在于向大家阐明一些GPGPU编程入门的必备知识和概念。本教程所介绍的一些编程实现技术,只要稍作修改和扩充,便能运用到复杂的GPU运算应用上。
本文不打算深入到在每一个细节,而是给对OpenGL编程有一定技术基础的朋友看的,你最好还要对图形显卡的组成及管道渲染有一定的了解。对于OpenGL刚入门的朋友,推荐大家看一下以下这些知识:Programming Guide (红宝书).PDF and HTML, 橙宝书 ("OpenGL Shading Language"), 以及NeHe's OpenGL教程
本教程是基于OpenGL写,目的主要是为不被MS Windows平台的限制。但是这里所阐述的大多数概念但能直接运用到DirectX上。
更多的预备知识,请到 GPGPU.org 上看一下。其中该网站上以下三篇文章,是作者极力推荐大家去看一下的:《Where can I learn about OpenGL and Direct3D?》,《How does the GPU pipeline work?》'《n what ways is GPU programming similar to CPU programming?》
译者注:在国内的GPGPU论坛可以到http://www.physdev.com物理开发网上讨论。该网站主要是交流PhysX物理引擎,GPU物理运算等计算机编程的前沿技术
你需要有NVIDIA GeForce FX 或者 ATI RADEON 9500 以上的显卡, 一些老的显卡可能不支持我们所需要的功能(主要是单精度浮点数据的存取及运算) 。
首先,你需要一个C/C++编译器。你有很多可以选择,如:Visual Studio .NET 2003, Eclipse 3.1 plus CDT/MinGW, the Intel C++ Compiler 9.0 及 GCC 3.4+等等。然后更新你的显卡驱动让它可以支持一些最新特性。
本文所附带的源代码,用到了两个扩展库,GLUT 和 GLEW 。对于windows系统,GLUT可以在 这里下载到,而Linux 的freeglut和freeglut-devel大多的版本都集成了。GLEW可以在 SourceForge 上下载到,对于着色语言,大家可以选择GLSL或者CG,GLSL在你安装驱动的时候便一起装好了。如果你想用CG,那就得下载 Cg Toolkit 。
大家如果要找DirectX版本的例子的话,请看一下Jens Krügers的《 Implicit Water Surface》 demo(该例子好像也有OpenGL 版本的)。当然,这只是一个获得高度评价的示例源代码,而不是教程的。
有一些从图形着色编程完全抽象出来的GPU的元程序语言,把底层着色语言作了封装,让你不用学习着色语言,便能使用显卡的高级特性,其中BrookGPU 和Sh 就是比较出名的两个项目。
Back to top
GLUT(OpenGLUtility Toolkit)该开发包主要是提供了一组窗口函数,可以用来处理窗口事件,生成简单的菜单。我们使用它可以用尽可能少的代码来快速生成一个OpenGL 开发环境,另外呢,该开发包具有很好的平台独立性,可以在当前所有主流的操作系统上运行 (MS-Windows or Xfree/Xorg on Linux / Unix and Mac)。
// include the GLUT header file #include < GL / glut.h > // call this and pass the command line arguments from main() void initGLUT( int argc, char ** argv) { glutInit ( &argc, argv ); glutCreateWindow("SAXPY TESTS"); }
许多高级特性,如那些要在GPU上进行普通浮点运算的功能,都不是OpenGL内核的一部份。因此,OpenGL Extensions通过对OpenGL API的扩展, 为我们提供了一种可以访问及使用硬件高级特性的机制。OpenGL扩展的特点:不是每一种显卡都支持该扩展,即便是该显卡在硬件上支持该扩展,但不同版本的显卡驱动,也会对该扩展的运算能力造成影响,因为OpenGL扩展设计出来的目的,就是为了最大限度地挖掘显卡运算的能力,提供给那些在该方面有特别需求的程序员来使用。在实际编程的过程中,我们必须小心检测当前系统是否支持该扩展,如果不支持的话,应该及时把错误信息返回给软件进行处理。当然,为了降低问题的复杂性,本教程的代码跳过了这些检测步骤。
OpenGL Extension Registry OpenGL扩展注册列表中,列出了几乎所有的OpenGL可用扩展,有需要的朋友可能的查看一下。
当我们要在程序中使用某些高级扩展功能的时候,我们必须在程序中正确引入这些扩展的扩展函数名。有一些小工具可以用来帮助我们检测一下某个给出的扩展函数是否被当前的硬件及驱动所支持,如:glewinfo, OpenGL extension viewer等等,甚至OpenGL本身就可以(在上面的连接中,就有一个相关的例子)。
如何获取这些扩展函数的入口指针,是一个比较高级的问题。下面这个例子,我们使用GLEW来作为扩展载入函数库,该函数库把许多复杂的问题进行了底层的封装,给我们使用高级扩展提供了一组简洁方便的访问函数。
void initGLEW ( void ) { // init GLEW, obtain function pointers int err = glewInit(); // Warning: This does not check if all extensions used // in a given implementation are actually supported. // Function entry points created by glewInit() will be // NULL in that case! if (GLEW_OK != err) { printf((char*)glewGetErrorString(err)); exit(ERROR_GLEW); } }
在传统的GPU渲染流水线中,每次渲染运算的最终结束点就是帧缓冲区。所谓帧缓冲区,其实是显卡内存中的一块,它特别这处在于,保存在该内存区块中的图像数据,会实时地在显示器上显示出来。根据显示器设置的不同,帧缓冲区最大可以取得32位的颜色深度,也就是说红、绿、蓝、alpha四个颜色通道共享这32位的数据,每个通道占8位。当然用32位来记录颜色,如果加起来的话,可以表示160万种不同的颜色,这对于显示器来说可能是足够了,但是如果我们要在浮点数字下工作,用8位来记录一个浮点数,其数学精度是远远不够的。另外还有一个问题就是,帧缓存中的数据最大最小值会被限定在一个范围内,也就是 [0/255; 255/255]
如何解决以上的一些问题呢?一种比较苯拙的做法就是用有符号指数记数法,把一个标准的IEEE 32位浮点数映射保存到8位的数据中。不过幸运的是,我们不需要这样做。首先,通过使用一些OpenGL的扩展函数,我们可以给GPU提供32位精度的浮点数。另外有一个叫EXT_framebuffer_object 的OpenGL的扩展, 该扩展允许我们把一个离屏缓冲区作为我们渲染运算的目标,这个离屏缓冲区中的RGBA四个通道,每个都是32位浮点的,这样一来, 要想GPU上实现四分量的向量运算就比较方便了,而且得到的是一个全精度的浮点数,同时也消除了限定数值范围的问题。我们通常把这一技术叫FBO,也就是Frame Buffer Object的缩写。
要使用该扩展,或者说要把传统的帧缓冲区关闭,使用一个离屏缓冲区作我们的渲染运算区,只要以下很少的几行代码便可以实现了。有一点值得注意的是:当我用使用数字0,来绑定一个FBO的时候,无论何时,它都会还原window系统的特殊帧缓冲区,这一特性在一些高级应用中会很有用,但不是本教程的范围,有兴趣的朋友可能自已研究一下。
Back to top
一维数组是本地CPU最基本的数据排列方式,多维的数组则是通过对一个很大的一维数组的基准入口进行坐标偏移来访问的(至少目前大多数的编译器都是这样做的)。一个小例子可以很好说明这一点,那就是一个MxN维的数组 a[i][j] = a[i*M+j];我们可能把一个多维数组,映射到一个一维数组中去。这些数组我开始索引都被假定为0;
而对于GPU,最基本的数据排列方式,是二维数组。一维和三维的数组也是被支持的,但本教程的技术不能直接使用。数组在GPU内存中我们把它叫做纹理或者是纹理样本。纹理的最大尺寸在GPU中是有限定的。每个维度的允许最大值,通过以下一小段代码便可能查询得到,这些代码能正确运行,前提是OpenGL的渲染上下文必须被正确初始化。
int maxtexsize; glGetIntegerv(GL_MAX_TEXTURE_SIZE, & maxtexsize); printf( " GL_MAX_TEXTURE_SIZE, %d " ,maxtexsize);
就目前主流的显卡来说,这个值一般是2048或者4096每个维度,值得提醒大家的就是:一块显卡,虽然理论上讲它可以支持4096*4096*4096的三维浮点纹理,但实际中受到显卡内存大小的限制,一般来说,它达不到这个数字。
在CPU中,我们常会讨论到数组的索引,而在GPU中,我们需要的是纹理坐标,有了纹理坐标才可以访问纹理中每个数据的值。而要得到纹理坐标,我们又必须先得到纹理中心的地址。
传统上讲,GPU是可以四个分量的数据同时运算的,这四个分量也就是指红、绿、蓝、alpha(RGBA)四个颜色通道。稍后的章节中,我将会介绍如何使用显卡这一并行运算的特性,来实现我们想要的硬件加速运算。
让我们来回顾一下前面所要实现的运算:也就是给定两个长度为N的数组,现在要求两数组的加权和y=y +alpha*x,我们现在需要两个数组来保存每个浮点数的值,及一个记录alpha值的浮点数。
float * dataY = ( float * )malloc(N * sizeof ( float )); float * dataX = ( float * )malloc(N * sizeof ( float )); float alpha;
虽然我们的实际运算是在GPU上运行,但我们仍然要在CPU上分配这些数组空间,并对数组中的每个元素进行初始化赋值。
这个话题需要比较多的解释才行,让我们首先回忆一下在CPU上是如何实现的,其实简单点来说,我们就是要在GPU上建立两个浮点数组,我们将使用浮点纹理来保存数据。
有许多因素的影响,从而使问题变得复杂起来。其中一个重要的因素就是,我们有许多不同的纹理对像可供我们选择。即使我们排除掉一些非本地的目标,以及限定只能使用2维的纹理对像。我们依然还有两个选择,GL_TEXTURE_2D是传统的OpenGL二维纹理对像,而ARB_texture_rectangle则是一个OpenGL扩展,这个扩展就是用来提供所谓的texture rectangles的。对于那些没有图形学背景的程序员来说,选择后者可能会比较容易上手。texture2Ds 和 texture rectangles 在概念上有两大不同之处。我们可以从下面这个列表来对比一下,稍后我还会列举一些例子。
texture2D | texture rectangle | |
texture target | GL_TEXTURE_2D | GL_TEXTURE_RECTANGLE_ARB |
纹理坐标 | 坐标必须被单位化,范围被限定在0到1之间,其它范围不在0到1之间的纹理坐标不会被支持。 | 纹理坐标不要求单位化 |
纹理大小 | 纹理大小必须是2的n次方,如1024,512等。当然如果你的显卡驱动支持ARB_non_power_of_two或者OpenGL2.0的话,则不会受到此限制。 |
纹理尺寸的大小是任意的,如 ( 513 x1025) |
另外一个重要的影响因素就是纹理格式,我们必须谨慎选择。在GPU中可能同时处理标量及一到四分量的向量。本教程主要关注标量及四分量向量的使用。比较简单的情况下我们可以在中纹理中为每个像素只分配一个单精度浮点数的储存空间,在OpenGL中,GL_LUMNANCE就是这样的一种纹理格式。但是如果我们要想使用四个通道来作运算的话,我们就可以采用GL_RGBA这种纹理格式。使用这种纹理格式,意味着我们会使用一个像素数据来保存四个浮点数,也就是说红、绿、蓝、alpha四个通道各占一个32位的空间,对于LUMINANCE格式的纹理,每个纹理像素只占有32位4个字节的显存空间,而对于RGBA格式,保存一个纹理像素需要的空间是4*32=128位,共16个字节。
接下来的选择,我们就要更加小心了。在OpenGL中,有三个扩展是真正接受单精度浮点数作为内部格式的纹理的。分别是:NV_float_buffer,ATI_texture_float 和ARB_texture_float.每个扩展都就定义了一组自已的列举参数及其标识,如:(GL_FLOAT_R32_NV) ,( 0x8880),在程序中使用不同的参数,可以生成不同格式的纹理对像,下面会作详细描述。
在这里,我们只对其中两个列举参数感兴趣,分别是GL_FLOAT_R32_NV和GL_FLOAT_RGBA32_NV. 前者是把每个像素保存在一个浮点值中,后者则是每个像素中的四个分量分别各占一个浮点空间。这两个列举参数,在另外两个扩展(ATI_texture_float andARB_texture_float )中也分别有其对应的名称:GL_LUMINANCE_FLOAT32_ATI,GL_RGBA_FLOAT32_ATI 和 GL_LUMINANCE32F_ARB, GL_RGBA32F_ARB 。在我看来,他们名称不同,但作用都是一样的,我想应该是多个不同的参数名称对应着一个相同的参数标识。至于选择哪一个参数名,这只是看个人的喜好,因为它们全部都既支持NV显卡也支持ATI的显卡。
最后还有一个要解决的问题就是,我们如何把CPU中的数组元素与GPU中的纹理元素一一对应起来。这里,我们采用一个比较容易想到的方法:如果纹理是LUMINANCE格式,我们就把长度为N的数组,映射到一张大小为sqrt(N) x sqrt(N)和纹理中去(这里规定N是刚好能被开方的)。如果采用RGBA的纹理格式,那么N个长度的数组,对应的纹理大小就是sqrt(N/4) x sqrt(N/4),举例说吧,如果N=1024^2,那么纹理的大小就是512*512 。
以下的表格总结了我们上面所讨论的问题,作了一下分类,对应的GPU分别是: NVIDIA GeForce FX (NV3x), GeForce 6 and 7 (NV4x, G7x) 和 ATI.
NV3x | NV4x, G7x (RECT) | NV4x, G7x (2D) | ATI | |
target | texture rectangle | texture rectangle | texture2D | texture2D and texture rectangle |
format | LUMINANCE and RGBA (and RG and RGB)* | |||
internal format |
NV_float_buffer | NV_float_buffer | ATI_texture_float ARB_texture_float |
ATI_texture_float ARB_texture_float |
(*) Warning: 这些格式作为纹理是被支持的,但是如果作为渲染对像,就不一定全部都能够得到良好的支持(seebelow).
讲完上面的一大堆基础理论这后,是时候回来看看代码是如何实现的。比较幸运的是,当我们弄清楚了要用那些纹理对像、纹理格式、及内部格式之后,要生成一个纹理是很容易的。
// create a new texture name GLuint texID; glGenTextures ( 1 , & texID); // bind the texture name to a texture target glBindTexture(texture_target,texID); // turn off filtering and set proper wrap mode // (obligatory for float textures atm) glTexParameteri(texture_target, GL_TEXTURE_MIN_FILTER, GL_NEAREST); glTexParameteri(texture_target, GL_TEXTURE_MAG_FILTER, GL_NEAREST); glTexParameteri(texture_target, GL_TEXTURE_WRAP_S, GL_CLAMP); glTexParameteri(texture_target, GL_TEXTURE_WRAP_T, GL_CLAMP); // set texenv to replace instead of the default modulate glTexEnvi(GL_TEXTURE_ENV, GL_TEXTURE_ENV_MODE, GL_REPLACE); // and allocate graphics memory glTexImage2D(texture_target, 0 , internal_format, texSize, texSize, 0 , texture_format, GL_FLOAT, 0 );
让我们来消化一下上面这段代码的最后那个OpenGL函数,我来逐一介绍一下它每个参数:第一个参数是纹理对像,上面已经说过了;第二个参数是0,是告诉GL不要使用多重映像纹理。接下来是内部格式及纹理大小,上面也说过了,应该清楚了吧。第六个参数是也是0,这是用来关闭纹理边界的,这里不需要边界。接下来是指定纹理格式,选择一种你想要的格式就可以了。对于参数GL_FLOAT,我们不要被它表面的意思迷惑,它并不会影响我们所保存在纹理中的浮点数的精度。其实它只与CPU方面有关系,目的就是要告诉GL稍后将要传递过去的数据是浮点型的。最后一个参数还是0,意思是生成一个纹理,但现在不给它指定任何数据,也就是空的纹理。该函数的调用必须按上面所说的来做,才能正确地生成一个合适的纹理。上面这段代码,和CPU里分配内存空间的函数malloc(),功能上是很相像的,我们可能用来对比一下。
最后还有一点要提醒注意的:要选择一个适当的数据排列映射方式。这里指的就是纹理格式、纹理大小要与你的CPU数据相匹配,这是一个非常因地制宜的问题,根据解决的问题不同,其相应的处理问题方式也不同。从经验上看,一些情况下,定义这样一个映射方式是很容易的,但某些情况下,却要花费你大量的时间,一个不理想的映射方式,甚至会严重影响你的系统运行。
在后面的章节中,我们会讲到如何通过一个渲染操作,来更新我们保存在纹理中的那些数据。在我们对纹理进行运算或存取的时候,为了能够正确地控制每一个数据元素,我们得选择一个比较特殊的投影方式,把3D世界映射到2D屏幕上(从世界坐标空间到屏幕设备坐标空间),另外屏幕像素与纹理元素也要一一对应。这种关系要成功,关键是要采用正交投影及合适的视口。这样便能做到几何坐标(用于渲染)、纹理坐标(用作数据输入)、像素坐标(用作数据输出)三者一一对应。有一个要提醒大家的地方:如果使用texture2D,我们则须要对纹理坐标进行适当比例的缩放,让坐标的值在0到1之间,前面有相关的说明。
为了建立一个一一对应的映射,我们把世界坐标中的Z坐标设为0,把下面这段代码加入到initFBO()这个函数中
// viewport for 1:1 pixel=texel=geometry mapping glMatrixMode(GL_PROJECTION); glLoadIdentity(); gluOrtho2D( 0.0 , texSize, 0.0 , texSize); glMatrixMode(GL_MODELVIEW); glLoadIdentity(); glViewport( 0 , 0 , texSize, texSize);
其实一个纹理,它不仅可以用来作数据输入对像,也还可以用作数据输出对像。这也是提高GPU运算效率和关键所在。通过使用 framebuffer_object这个扩展,我们可以把数据直接渲染输出到一个纹理上。但是有一个缺点:一个纹理对像不能同时被读写,也就是说,一个纹理,要么是只读的,要么就是只写的。显卡设计的人提供这样一个解释:GPU在同一时间段内会把渲染任务分派到几个通道并行运行, 它们之间都是相互独立的(稍后的章节会对这个问题作详细的讨论)。如果我们允许对一个纹理同时进行读写操作的话,那我们需要一个相当复杂的逻辑算法来解决读写冲突的问题, 即使在芯片逻辑上可以做到,但是对于GPU这种没有数据安全性约束的处理单元来说,也是没办法把它实现的,因为GPU并不是基von Neumann的指令流结构,而是基于数据流的结构。因此在我们的程序中,我们要用到3个纹理,两个只读纹理分别用来保存输入数组x,y。一个只写纹理用来保存运算结果。用这种方法意味着要把先前的运算公式:y = y + alpha * x 改写为:y_new = y_old + alpha * x.
FBO 扩展提供了一个简单的函数来实现把数据渲染到纹理。为了能够使用一个纹理作为渲染对像,我们必须先把这个纹理与FBO绑定,这里假设离屏帧缓冲已经被指定好了。
glFramebufferTexture2DEXT(GL_FRAMEBUFFER_EXT, GL_COLOR_ATTACHMENT0_EXT, texture_target, texID, 0 );
第一个参数的意思是很明显的。第二个参数是定义一个绑定点(每个FBO最大可以支持四个不同的绑定点,当然,不同的显卡对这个最大绑定数的支持不一样,可以用GL_MAX_COLOR_ATTACHMENTS_EXT来查询一下)。第三和第四个参数应该清楚了吧,它们是实际纹理的标识。最后一个参数指的是使用多重映像纹理,这里没有用到,因此设为0。
为了能成功绑定一纹理,在这之前必须先用glTexImage2D()来对它定义和分配空间。但不须要包含任何数据。我们可以把FBO想像为一个数据结构的指针,为了能够对一个指定的纹理直接进行渲染操作,我们须要做的就调用OpenGL来给这些指针赋以特定的含义。
不幸的是,在FBO的规格中,只有GL_RGB和GL_RGBA两种格式的纹理是可以被绑定为渲染对像的(后来更新这方面得到了改进),LUMINANCE这种格式的绑定有希望在后继的扩展中被正式定义使用。在我定本教程的时候,NVIDIA的硬件及驱动已经对这个全面支持,但是只能结会对应的列举参数NV_float_buffer一起来使用才行。换句话说,纹理中的浮点数的格式与渲染对像中的浮点数格式有着本质上的区别。
下面这个表格对目前不同的显卡平台总结了一下,指的是有哪些纹理格式及纹理对像是可能用来作为渲染对像的,(可能还会有更多被支持的格式,这里只关心是浮点数的纹理格式):
NV3x | NV4x, G7x | ATI | |
texture 2D, ATI/ARB_texture_float, LUMINANCE |
no | no | no |
texture 2D, ATI/ARB_texture_float, RGB, RGBA |
no | yes | yes |
texture 2D, NV_float_buffer, LUMINANCE |
no | no | no |
texture 2D, NV_float_buffer, RGB, RGBA |
no | no | no |
texture RECT, ATI/ARB_texture_float, LUMINANCE |
no | no | no |
texture RECT, ATI/ARB_texture_float, RGB, RGBA |
no | yes | yes |
texture RECT, NV_float_buffer, LUMINANCE |
yes | yes | no |
texture RECT, NV_float_buffer, RGB, RGBA |
yes | yes | no |
列表中最后一行所列出来的格式在目前来说,不能被所有的GPU移植使用。如果你想采用LUMINANCE格式,你必须使用ractangles纹理,并且只能在NVIDIA的显卡上运行。想要写出兼容NVIDIA及ATI两大类显卡的代是可能的,但只支持NV4x以上。幸运的是要修改的代码比较少,只在一个switch开关,便能实现代码的可移植性了。相信随着ARB新版本扩展的发布,各平台之间的兼容性将会得到进一步的提高,到时候各种不同的格式也可能相互调用了。
为了把数据传输到纹理中去,我们必须绑定一个纹理作为纹理目标,并通过一个GL函数来发送要传输的数据。实际上就是把数据的首地址作为一个参数传递给该涵数,并指定适当的纹理大小就可以了。如果用LUMINANCE格式,则意味着数组中必须有texSize x texSize个元数。而RGBA格式,则是这个数字的4倍。注意的是,在把数据从内存传到显卡的过程中,是全完不需要人为来干预的,由驱动来自动完成。一但传输完成了,我们便可能对CPU上的数据作任意修改,这不会影响到显卡中的纹理数据。 而且我们下次再访问该纹理的时候,它依然是可用的。在NVIDIA的显卡中,以下的代码是得到硬件加速的。
glBindTexture(texture_target, texID); glTexSubImage2D(texture_target, 0 , 0 , 0 ,texSize,texSize, texture_format,GL_FLOAT,data);
这里三个值是0的参数,是用来定义多重映像纹理的,由于我们这里要求一次把整个数组传输一个纹理中,不会用到多重映像纹理,因此把它们都关闭掉。
以上是NVIDIA显卡的实现方法,但对于ATI的显卡,以下的代码作为首选的技术。在ATI显卡中,要想把数据传送到一个已和FBO绑定的纹理中的话,只需要把OpenGL的渲染目标改为该绑定的FBO对像就可以了。
glDrawBuffer(GL_COLOR_ATTACHMENT0_EXT);
glRasterPos2i(0,0);
glDrawPixels(texSize,texSize,texture_format,GL_FLOAT,data);
第一个函数是改变输出的方向,第二个函数中我们使用了起点作为参与点,因为我们在第三个函数中要把整个数据块都传到纹理中去。
两种情况下,CPU中的数据都是以行排列的方式映射到纹理中去的。更详细地说,就是:对于RGBA格式,数组中的前四个数据,被传送到纹理的第一个元素的四个分量中,分别与R,G,B,A分量一一对应,其它类推。而对于LUMINANCE 格式的纹理,纹理中第一行的第一个元素,就对应数组中的第一个数据。其它纹理元素,也是与数组中的数据一一对应的。
这是一个反方向的操作,那就是把数据从GPU传输回来,存放在CPU的数组上。同样,有两种不同的方法可供我们选择。传统上,我们是使用OpenGL获取纹理的方法,也就是绑定一个纹理目标,然后调用glGetTexImage()这个函数。这些函数的参数,我们在前面都有见过。
glBindTexture(texture_target,texID);
glGetTexImage(texture_target,0,texture_format,GL_FLOAT,data);
但是这个我们将要读取的纹理,已经和一个FBO对像绑定的话,我们可以采用改变渲染指针方向的技术来实现。
glReadBuffer(GL_COLOR_ATTACHMENT0_EXT);
glReadPixels(0,0,texSize,texSize,texture_format,GL_FLOAT,data);
由于我们要读取GPU的整个纹理,因此这里前面两个参数是0,0。表示从0起始点开始读取。该方法是被推荐使用的。
一个忠告:比起在GPU内部的传输来说,数据在主机内存与GPU内存之间相互传输,其花费的时间是巨大的,因此要谨慎使用。由其是从CPU到GPU的逆向传输。
在前面“ 当前显卡设备运行的问题” 中 提及到该方面的问题。
现在是时候让我们回头来看一下前面要解决的问题,我强烈建议在开始一个新的更高级的话题之前,让我们先弄一个显浅的例子来实践一下。下面通过一个小的程序,尝试着使用各种不同的纹理格式,纹理对像以及内部格式,来把数据发送到GPU,然后再把数据从GPU取回来,保存在CPU的另一个数组中。在这里,两个过程都没有对数据作任何运算修该,目的只是看一下数据GPU和CPU之间相互传输,所需要使用到的技术及要注意的细节。也就是把前面提及到的几个有迷惑性的问题放在同一个程序中来运行一下。在稍后的章节中将会详细讨论如何来解决这些可能会出现的问题。
由于赶着要完成整个教程,这里就只写了一个最为简单的小程序,采用rectangle纹理、ARB_texture_float作纹理对像并且只能在NVIDIA的显卡上运行。
-------------- CPU ---------------- ------------- GPU ------------ | | | | | data arr: | | texture: | | [][][][][][][][][] --------------> [][][] | | | | [][][] | | | | [][][] | | | | / | | result: | | FBO: | | [][][][][][][][][] | | [][][] | | <----------------- [][][] | | | | [][][] | |-------------------------------| |--------------------------|
以上代码是理解GPU编程的基础,如果你完全看得懂,并且能对这代码作简单的修改运用的话,那恭喜你,你已经向成功迈进了一大步,并可以继续往下看,走向更深入的学习了。但如看不懂,那回头再看一编吧。
Back to top
在这一章节中,我们来讨论GPU和CPU两大运算模块最基本的区别,以及理清一些算法和思想。一但我们弄清楚了GPU是如何进行数据并行运算的,那我们要编写一个自已的着色程序,还是比较容易的。
让我们来回忆一下我们所想要解决的问题:y = y + alpha* x; 在CPU上,通常我们会使用一个循环来遍历数组中的每个元素。如下:
for ( int i = 0 ; i < N; i ++ ) dataY[i] = dataY[i] + alpha * dataX[i];
每一次的循环,都会有两个层次的运算在同时运作:在循环这外,有一个循环计数器在不断递增,并与我们的数组的长度值作比较。而在循环的内部,我们利用循环计数器来确定数组的一个固定位置,并对数组该位置的数据进行访问,在分别得到两个数组该位置的值之后,我们便可以实现我们所想要的运算:两个数组的每个元素相加了。这个运算有一个非常重要的特点:那就是我们所要访问和计算的每个数组元数,它们之间是相互独立的。这句话的意思是:不管是输入的数组,还是输出结果的数组,对于同一个数组内的各个元素是都是相互独立的,我们可以不按顺序从第一个算到最后一个,可先算最后一个,再算第一个,或在中间任意位置选一个先算,它得到的最终结果是不变的。如果我们有一个数组运算器,或者我们有N个CPU的话,我们便可以同一时间把整个数组给算出来,这样就根本不需要一个外部的循环。我们把这样的示例叫做SIMD(single instruction multiple data)。现在有一种技术叫做“partial loop unrolling”就是让允许编译器对代码进行优化,让程序在一些支持最新特性(如:SSE , SSE2)的CPU上能得到更高效的并行运行。
在我们这个例子中,输入数数组的索引与输出数组的索引是一样,更准确地说,是所有输入数组下标,都与输出数组的下标是相同的,另外,在对于两个数组,也没有下标的错位访问或一对多的访问现像,如:y[i] = -x[i-1] + 2*x[[i] - x[i+1] 。这个公式可以用一句不太专业的语言来描术:“组数Y中每个元素的值等于数组X中对应下标元素的值的两倍,再减去该下标位置左右两边元素的值。”
在这里,我们打算使用来实现我们所要的运算的GPU可编程模块,叫做片段管线(fragment pipeline),它是由多个并行处理单元组成的,在GeFore7800GTX中,并行处理单元的个数多达24个。在硬件和驱动逻辑中,每个数据项会被自动分配到不同的渲染线管线中去处理,到底是如何分配,则是没法编程控制的。从概念观点上看,所有对每个数据顶的运算工作都是相互独立的,也就是说不同片段在通过管线被处理的过程中,是不相互影响的。在前面的章节中我们曾讨论过,如何实现用一个纹理来作为渲染目标,以及如何把我们的数组保存到一个纹理上。因此这里我们分析一下这种运算方式:片段管线就像是一个数组处理器,它有能力一次处理一张纹理大小的数据。虽然在内部运算过程中,数据会被分割开来然后分配到不同的片段处理器中去,但是我们没办法控制片段被处理的先后顺序,我们所能知道的就是“地址”,也就是保存运算最终结果的那张纹理的纹理坐标。我们可能想像为所有工作都是并行的,没有任何的数据相互依赖性。这就是我们通常所说的数据并行运算(data-paralel computing)。
现在,我们已经知道了解决问题的核心算法,我们可以开始讨论如何用可编程片段管线来编程实现了。内核,在GPU中被叫做着色器。所以,我们要做的就是写一个可能解决问题的着色器,然后把它包含在我们的程序中。在本教程程中,我们会分别讨论如何用CG着色语言及GLSL着色语言来实现,接下来两个小节就是对两种语言实现方法的讨论,我们只要学会其中一种方法就可以了,两种语言各有它自已的优缺点,至于哪个更好一点,则不是本教程所要讨论的范围。
为了用CG语言来着色渲染,我们首先要来区分一下CG着色语言和CG运行时函数,前者是一门新的编程语言,所写的程序经编译后可以在GPU上运行,后者是C语言所写的一系列函数,在CPU上运算,主要是用来初始化环境,把数据传送给GPU等。在GPU中,有两种不同的着色,对应显卡渲染流水线的两个不同的阶段,也就是顶点着色和片段着色。本教程中,顶点着色阶段,我们采用固定渲染管线。只在片段着色阶段进行编程。在这里,使用片段管线能更容易解决我们的问题,当然,顶点着色也会有它的高级用途,但本文不作介绍。另外,从传统上讲,片段着色管线提供更强大的运算能力。
让我们从一段写好了的CG着色代码开始。回忆一下CPU内核中包含的一些算法:在两个包含有浮点数据的数组中查找对应的值。我们知道在GPU中纹理就等同于CPU的数组,因此在这里我们使用纹理查找到代替数组查找。在图形运算中,我们通过给定的纹理坐标来对纹理进行采样。这里有一个问题,就是如何利用硬件自动计算生成正确的纹理坐标。我们把这个问题压后到下面的章节来讨论。为了处理一些浮点的常量,我们有两种处理的方法可选:我们可以把这些常量包含在着色代码代中,但是如果要该变这些常量的值的话,我们就得把着色代码重新编译一次。另一种方法更高效一点,就是把常量的值作为一个uniform参数传递给GPU。uniform参数的意思就是:在整个渲染过程中值不会被改变的。以下代码就是采用较高较的方法写的。
float saxpy ( float2 coords : TEXCOORD0, uniform sampler2D textureY, uniform sampler2D textureX, uniform float alpha ) : COLOR { float result; float yval=y_old[i]; float y = tex2D(textureY,coords); float xval=x[i]; float x = tex2D(textureX,coords); y_new[i]=yval+alpha*xval; result = y + alpha * x; return result; }
从概念上讲,一个片段着色器,就是像上像这样的一段小程序,这段代码在显卡上会对每个片段运行一编。在我们的代码中,程序被命名为saxpy。它会接收几个输入参数,并返回一个浮点值。用作变量复制的语法叫做语义绑定(semantics binding):输入输出参数名称是各种不同的片段静态变量的标识,在前面的章节中我们把这个叫“地址”。片段着色器的输出参数必须绑定为COLOR语义,虽然这个语义不是很直观,因为我们的输出参数并不是传统作用上颜色,但是我们还是必须这样做。绑定一个二分量的浮点元组(tuple ,float2)到TEXCOORD0语义上,这样便可以在运行时为每个像素指定一对纹理坐标。对于如何在参数中定义一个纹理样本以及采用哪一个纹理采样函数,这就要看我们种用了哪一种纹理对像,参考下表:
texture2D | texture rectangle | |
样本定义 | uniform sampler2D | uniform samplerRECT |
纹理查找函数 | tex2D(name, coords) | texRECT(name, coords) |
如果我们使用的是四通道的纹理而不是LUMINANCE格式的纹理,那们只须把上面代码中的用来保存纹理查询结果的浮点型变量改为四分量的浮点变量(float4 )就可以了。由于GPU具有并行运算四分量数的能力,因此对于使用了rectangle为对像的RGBA格式纹理,我们可以采用以下代码:
在这一小节,中描术了如何在OpenGL应用程序中建立Cg运行环境。首先,我们要包含CG的头文件(#include <cg/cggl.h>),并且把CG的库函数指定到编译连接选项中,然后声明一些变量。
译注:对于CG入门,可以看一下《CG编程入门》这篇文章:http://www.physdev.com/phpbb/cms_view_article.php?aid=7
使用OpenGL的高级着色语言,我们不需要另外引入任何的头文件或库文件,因因它们在安装驱动程序的时候就一起被建立好了。三个OpenGL的扩展:(ARB_shader_objects,ARB_vertex_shader 和ARB_fragment_shader)定义了相关的接口函数。它的说明书(specification )中对语言本身作了定义。两者,API和GLSL语言,现在都是OpenGL2.0内核的一个重要组成部份。但是如果我们用的是OpenGL的老版本,就要用到扩展。
我们为程序对像定义了一系列的全局变量,包括着色器对像及数据变量的句柄,通过使用这些句柄,我们可以访问着色程序中的变量。前面两个对像是简单的数据容器,由OpenGL进行管理。一个完整的着色程序是由顶点着色和片段着色两大部份组成的,每部分又可以由多个着色程序组成。
// GLSL vars GLhandleARB programObject; GLhandleARB shaderObject; GLint yParam, xParam, alphaParam;
编写着色程序和使用Cg语言是相似的,下面提供了两个GLSL的例子,两个主程序的不同之处在于我们所采用的纹理格式。变量的类型入关键字与CG有很大的不同,一定要按照OpenGL的定义来写。
// shader for luminance data | // shader for RGBA data // and texture rectangles | // and texture2D | uniform samplerRect textureY; | uniform sampler2D textureY; uniform samplerRect textureX; | uniform sampler2D textureX; uniform float alpha; | uniform float alpha; | void main( void ) { | void main(void) { float y = textureRect( | vec4 y = texture2D( textureY, | textureY, gl_TexCoord[0].st).x; | gl_TexCoord[0].st); float x = textureRect( | vec4 x = texture2D( textureX, | textureX gl_TexCoord[0].st).x; | gl_TexCoord[0].st); gl_FragColor.x = | gl_FragColor = y + alpha*x; | y + alpha*x; } | }
下面代码就是把所有对GLSL的初始化工作放在一个函数中实现,GLSL API是被设计成可以模拟传统的编译及连接过程,更多的细节,请参考橙皮书(Orange Book),或者查找一些GLSL的教程来学习一下,推荐到Lighthouse 3D's GLSL tutorial 网站上看一下
Back to top
在这一章节里,我们来讨论一下如何把本教程前面所学到的知识拼凑起来,以及如何使用这些知识来解决前面所提出的加权数组相加问题:y_new =y_old +alpha *x 。关于执行运算的部份,我们把所有运算都放在performComputation()这个函数中实现。一共有四个步骤:首先是激活内核,然后用着色函数来分配输入输出数组的空间,接着是通过渲染一个适当的几何图形来触发GPU的运算,最后一步是简单验证一下我们前面所列出的所有的基本理论。
使用CG运行时函数来激活运算内核就是显卡着色程序。首先用enable函数来激活一个片段profile,然后把前面所写的着色代码传送到显卡上并绑定好。按规定,在同一时间内只能有一个着色器是活动的,更准确的说,是同一时间内,只能分别激活一个顶点着色程序和一个片段着色程序。由于本教程中采用了固定的顶点渲染管线,所以我们只关注片段着色就行了,只需要下面两行代码便可以了。
// enable fragment profile cgGLEnableProfile(fragmentProfile); // bind saxpy program cgGLBindProgram(fragmentProgram);
如果使用的是GLSL着色语言,这一步就更容易实现了,如果我们的着色代码已以被成功地编译连接,那么剩下我们所需要做的就只是把程序作为渲染管线的一部分安装好,代码如下:
glUseProgramObjectARB(programObject);
在CG环境中,我们先要把纹理的标识与对应的一个uniform样本值关联起来,然后激活该样本。这样该纹理样本便可以在CG中被直接使用了。
定义用于输出的纹理,从本质上讲,这和把数据传输到一个FBO纹理上的操作是一样的,我们只需要指定OpenGL函数参数的特定意义就可以了。这里我们只是简单地改变输出的方向,也就是,把目标纹理与我们的FBO绑定在一起,然后使用标准的GL扩展函数来把该FBO指为渲染的输出目标。
让们暂时先来回顾一下到目前为止,我们所做过了的工作:我们实现了目标像素、纹理坐标、要绘制的图形三者元素一一对应的关系。我们还写好了一个片段着色器,用来让每个片段渲染的时候都可以运行一次。现在剩下来还要做的工作就是:绘制一个“合适的几何图形” ,这个合适的几何图形,必须保证保存在目标纹理中的数据每个元素就会去执行一次我们的片段着色程序。换句话来说,我们必须保证纹理中的每个数据顶在片段着色中只会被访一次。只要指定好我们的投影及视口的设置,其它的工作就非常容易:我们所需要的就只是一个刚好能覆盖整个视口的填充四边形。我们定义一个这样的四边形,并调用标准的OpenGL函数来对其进行渲染。这就意味着我们要直接指定四边形四个角的顶点坐标,同样地我们还要为每个顶点指定好正确的纹理坐标。由于我们没有对顶点着色进行编程,程序会把四个顶点通过固定的渲染管线传输到屏幕空间中去。光册处理器(一个位于顶点着色与片段着色之间的固定图形处理单元)会在四个顶点之间进行插值处理,生成新的顶点来把整个四边形填满。插值操作除了生成每个插值点的位置之外,还会自动计算出每个新顶点的纹理坐标。它会为四边形中每个像素生成一个片段。由于我们在写片段着色器中绑定了相关的语义,因此插值后的片段会被自动发送到我们的片段着色程序中去进行处理。换句话说,我们渲染的这个简单的四边形,就可以看作是片段着色程序的数据流生成器。由于目标像素、纹理坐标、要绘制的图形三者元素都是一一对应的,从而我们便可以实现:为数组每个输出位置触发一次片段着色程序的运行。也就是说通过渲染一个带有纹理的四边形,我们便可以触发着色内核的运算行,着色内核会为纹理或数组中的每个数据项运行一次。
使用 texture rectangles 纹理坐标是与像素坐标相同的,我样使用下面一小段代码便可以实现了。
Back to top
当运算全部完成之后,的、得到的结果会被保存在目标纹理y_new中。
在一些通用运算中,我们会希望把前一次运算结果传递给下一个运算用来作为后继运算的输入变量。但是在GPU中,一个纹理不能同时被读写,这就意味着我们要创建另外一个渲染通道,并给它绑定不同的输入输出纹理,甚至要生成一个不同的运算内核。有一种非常重要的技术可以用来解决这种多次渲染传递的问题,让运算效率得到非常好的提高,这就是“乒乓”技术。
乒乓技术,是一个用来把渲染输出转换成为下一次运算的输入的技术。在本文中(y_new =y_old +alpha *x) ,这就意味我们要切换两个纹理的角色,y_new 和y_old 。有三种可能的方法来实现这种技术(看一下以下这篇论文Simon Green's FBO slides ,这是最经典的资料了):
由于每个FBO最多有4个绑定点可以被使用,而且,最后一种方法的运算是最快的,我们在这里将详细解释一下,看看我们是如何在两个不同的绑定点之间实现“乒乓” 的。
要实现这个,我们首先需要一组用于管理控制的变量。
Back to top
在附带的代码例子中,使用到了本文所有阐述过的所有概念,主要实现了以下几个运算:
在代码中,我们使用了一系列的结构体来保存各种可能的参数,主要是为了方便OpenGL的调用,例如:不同类型的浮点纹理扩展,不同的纹理格式,不同的着色器之间的细微差别,等等。下面这段代码就是这样一个结构体的示例,采用LUMINANCE格式,RECTANGLES纹理,及NV_float_buffer的扩展。
在程序中,使用命令行参数来对程序进行配置。如果你运行该程序而没带任何参数的话,程序会输出一个对各种不同参数的解释。提醒大家注意的是:本程序对命令行参数的解释是不稳定的,一个不正确的参数有可能会造成程序的崩溃。因此我强烈建义大家使用输出级的参数来显示运算的结果,这样可以降低出现问题的可能性,尤其是当你不相信某些运算错误的时候。请查看包含在示例中的批处理文件。
本程序可以用来对一个给定的GPU及其驱动的 结合进行测试,主要是测试一下,看看哪种内部格式及纹理排列是可以在FBO扩展中被组合在一起使用的。示例中有一个批处理文件叫做:run_test_*.bat,是使用各种不同的命令行参数来运行程序,并会生成一个报告文件。如果是在LINUX下,这个文件也可能当作一个shell脚本来使用,只需要稍作修改就可以了。这ZIP文档中包含有对一些显卡测试后的结果。
这种模式被写进程序中,完全是为了好玩。它可以对不同的问题产成一个运算时序,并在屏幕上生成MFLOP/s速率图,和其它的一些性能测试软件一样。它并不代表GPU运算能力的最高值,只是接近最高值的一种基准性能测试。想知道如何运行它的话,请查看命令行参数。
Back to top
对于NVIDIA的显卡,不管是Windows还是Linux,它们都提供了相同的函数来实现本教程中的例子。但如果是ATI的显卡,它对LINUX的支持就不是很好。因此如果是ATI显卡,目前还是建义在Windows下使用。
看一看这片相关的文章 table summarizing renderable texture formats on various hardware.
本文中提供下载的源代码,是在NV4X以上的显卡上编译通过的。对于ATI的用户,则要作以下的修改才行:在transferToTexture() 函数中,把NVIDIA相应部份的代码注释掉,然使用ATI版本的代码,如这里所描述的。
Cg 1.5 combined with the precompiled freeglut that ships with certain Linus distributions somehow breaks "true offscreen rendering" since a totally meaningless empty window pops up. There are three workarounds: Live with it. Use "real GLUT" instead of freeglut. Use plain X as described in the OpenGL.org wiki (just leave out the mapping of the created window to avoid it being displayed).
高度推荐大家在代码中经常使用以下函数来检测OpenGL运行过程中产生的错误。
void checkGLErrors( const char * label) { GLenum errCode; const GLubyte *errStr; if ((errCode = glGetError()) != GL_NO_ERROR) { errStr = gluErrorString(errCode); printf("OpenGL ERROR: "); printf((char*)errStr); printf("(Label: "); printf(label); printf(") ."); } }
EXT_framebuffer_object 扩展,定义了一个很好用的运行时Debug函数。这里只列出了它的一些常见的反回值作参考,要详细解释这些返回信息,请查看规格说明书的framebuffer completeness 部分。
在CG中检查错误有一些细微的不同,一个自写入的错误处理句柄被传递给CG的错误处理回调函数。
使用以下的函数来查看编译的结果:
Back to top
Writing this tutorial would have been impossible without all contributors at theGPGPU.org forums. They answered all my questions patiently, and without them, starting to work in the GPGPU field (and consequently, writing this tutorial) would have been impossible. I owe you one, guys!
如果没有GPGPU.org论坛所作出的贡献,可能也就没有这篇论文的产生。他们非常耐心地回答了我所有的问题,在大家的帮助下,我才踏入GPGPU的大门,也因此才有了这篇文章,感谢多位朋友:
Andrew Corrigan, Wojciech Jaskowski, Matthias Miemczyk, Stephan Wagner and especially Thomas Rohkämper were invaluably helpful in proof-reading the tutorial and beta-testing the implementation. Thanks a lot!
本译文可以自由转载,要求保留原作者信息
英文原文: http://www.mathematik.uni-dortmund.de/~goeddeke/gpgpu/tutorial.html
(c) 2005,2006 Dominik Göddeke, University of Dortmund, Germany.
The example code for this tutorial is released under a weakened version of thezlib/libPNG licence which basically says: Feel free to use the code in any way you want, but do not blame me if it does not work.
This software is provided 'as-is', without any express or implied warranty. In no event will the author be held liable for any damages arising from the use of this software. Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely.
Feedback (preferably by e-mail) is appreciated!