欢迎进入《你所不知的OSG》第二章,OpenSceneGraph与OpenGL着色语言(OpenGL Shading Language),顾名思义,这一次我们将尝试稍微深入地研究一下OSG与时下日渐流行的GLSL着色语言的结合方法,并且给出几个尽量简单但是有助于加深理解的实例程序。由于已经有了第一章《CMake初步》中打下的基础,因此从本章往后的所有章节均使用CMake来构建各个可执行工程,并保证使用Windows和Linux的朋友都可以顺利地编译和运行这形形色色的例子程序。
为了保证读者们可以与最新的OSG工程亲密接触,因此笔者将尽量使用最新版本的OpenSceneGraph来编写代码,还在执着于2.2之类陈旧版本的朋友不妨快些将自己的OSG以及显示卡驱动程序更新换代,以免例子程序的编译运行发生困难。
由于笔者所使用的nVIDIA显示卡和ATI等显示卡之间难免存在差异,因此不排除例子程序在某些朋友的计算机系统上无法正常运行。请大家自行排查或者与笔者以及更多朋友进行讨论。
什么,您不知道何谓GLSL?呃,这样说来,有一本名为《OpenGL着色语言》的“橙皮书”(Randi J. Rost著,中文版由人民邮电出版社出版)应当是您的案头必须,本文的第一个小节也会尝试给您些许的帮助。您更钟爱传统的glLight种种而非GLSL?那么,敬请跳过此章,并且仔细斟酌您的代码和最新图形技术之间越来越大的差距。(^_^)
2.1 着色语言概览
可编程的图形硬件是提高图形程序设计灵活性和效率的一个重要手段。旧有的OpenGL编程方式在越来越多的时候显得固定而呆板;而图形处理单元GPU的日益完善,以及强劲的同时处理固定渲染指令以及可编程渲染指令的能力,则使得它逐步居于与CPU同等重要的能力。在此趋势下,OpenGL着色语言(GLSL)应运而生,它使得开发人员可以对图形处理管线的大多数重要阶段实施完全的控制,并且因而实现各种惊人的、令前人渴望而不可及的图形渲染效果。
GLSL语言采用类似C语言的词法和语法格式,并使用专门的编译系统,因而非常利于开发者移植和实现自己的特效效果。最新的OpenGL标准中定义了三个可编程图形处理器,即“顶点着色器”(Vertex Shader),“片元着色器”(Fragment Shader)和“几何着色器”(Geometry Shader)。这与HLSL,Cg等主流着色语言的设置大同小异。
顶点着色器可以用于替代顶点和法线变换、纹理坐标生成和变换、光照以及材质应用这些传统的管线命令;片元着色器则用于替代纹理应用、雾化和像素汇总的工作;几何着色器则可以在图元装配的过程中重新生成新的图元。
各个着色器的语法格式十分类似,因此不必单独加以学习。GLSL中可用的数据类型通常包括:标量类型(float,int,bool),矢量类型(vec2,vec3,vec4等),矩阵(mat2,mat3,mat4等),纹理取样器(sampler1D,sampler2D,sampler3D,samplerCube等),以及结构体,数组和void类型。
矢量的组成部分可以使用“.”操作符后追加x/y/z/w或者r/g/b/a或者s/t/p/q来表示,例如:
矩阵的组成部分通过二维数组或者一维数组的形式来表示,例如:
取样器的值(纹理图像)只能从外部应用程序获得,而且不能被更改,不过可以通过内置函数和作为纹理索引坐标的矢量参数取出,例如:
GLSL与C语言语法相近的地方包括变量赋值,变量类型转换,变量作用域,操作符,条件分支语句,循环语句,函数以及预处理表达式等;而矢量的混合(Swizzle)则是GLSL语言所特有的,例如:
此外,矢量与矢量之间,矩阵与矩阵之间,矢量与矩阵之间的乘法法则也不同于简单的标量相乘,而是运用线性代数的原理执行计算。
GLSL语言中包括几种限定符,用于修饰函数形式参数或者指定着色器之间以及与外部应用程序的接口,这些接口也是我们控制可编程图形绘制的重要通道。
这里可以简单地解释一下“顶点属性”的概念。OpenGL中的“顶点”并非仅仅一个简单的坐标值,而是由一系列数据组成的。其中包括该顶点的位置坐标,法线坐标,颜色坐标,纹理坐标,雾坐标,以及自定义属性的坐标。对于传统的OpenGL开发者而言,要定义一个顶点的所有信息,往往需要编写形同下面的代码:
其中,glVertexAttrib函数就可以用于设置attribute变量的输入值。显而易见,此类变量的值是逐顶点(per-vertex)变化的,每个输入顶点的属性都不相同。
易变变量varying的意义与顶点属性变量attribute类似,其值也是逐顶点变化的,并且将顶点插值得到的结果从顶点着色器传入几何/片元着色器。
而一致变量uniform的特点则是取值不随顶点变化,对于参与着色器运算的所有的顶点而言,它都是“一致的”。
要深入理解attribute,uniform和varying变量的区别,以及了解顶点着色器的编写方法,我们可以参考一段非常简单的顶点着色器代码:
除了代码中定义的用户变量tangAttr,offset和tangent之外,这里还出现了一些GLSL系统内置的顶点属性变量和一致变量,以及一个用于输出实际顶点位置到后继渲染管线的特殊输出变量gl_Position。部分内置变量列举如下:
这段代码没有太大的实际意义,我们简单地解释一下它在字面上的意思:
GLSL着色器代码的主函数均为void main(void),在函数体的第1行,我们使用内置函数normalize对实际的切线矢量(法线变换矩阵乘以传入的属性量)进行归一化,并将结果传入一个易变变量。
第2-3行中我们直接将glTexCoord的纹理坐标设置为纹理单元0的实际纹理坐标,并且将纹理坐标的s值加上一个一致变量值。
第4行使用内置函数ftransform负责计算模型视点变换与投影变换的顶点坐标,它基本等价于下面的语句:
所有的顶点着色器代码都必须设置gl_Position的值,否则将无法得到正确的顶点渲染结果。
实现片元着色器的一段示例代码如下:
内置函数texture2D用于从纹理采样器(必须是一致变量)中取得纹理坐标对应的纹理像素值,而特殊输出量gl_FragColor则是所有的片元着色器代码都必须设置的,即各个顶点需要渲染的像素片元。注意这里我们没有使用刚刚传入的易变变量tangent,但是它的值完全可以用于片元着色器的计算过程和结果。
更多的内置变量和函数,以及各种着色器编程的风格与技巧,还请参阅《OpenGL着色语言》等教程书籍,本文无篇幅也无能力将它们尽皆详述。
最后我们再简介一下几何着色器,对于OpenGL开发者而言,它可以说是一个全新的事物,相关的资料也十分有限。几何着色器专用的内置函数实际上只有两个:EmitVertex()和EndPrimitive()。EmitVertex()可以看作是glVertex函数的一个着色器版本;而EndPrimitive()则相当于glEnd的作用,即结束一个图元的绘制;至于glBegin函数,EndPrimitive()本身也可以视为一个新的几何图元绘制的开始,因此不必再单独定义。
几何着色器的操作对象是图元。各种OpenGL图元的维数是不同的,例如,点图元(GL_POINTS)的维数是1,线图元(两个顶点组成,GL_LINES,GL_LINE_STRIP,GL_LINE_LOOP)为2,而三角形图元(三个顶点组成,可以设置为GL_TRIANGLES,GL_TRIANGLE_STRIP,GL_TRIANGLE_FAN)为3,因此,几何着色器需要使用内置变量gl_VerticesIn获取当前图元的维数,并进而获得图元中每个元素的数值:
几何着色器需要用户主动设置输入和输出图元的性质,例如以下的OpenGL代码:
它先后获取和设置了当前几何着色器可以输出的最大顶点数,设置输入图元类型为GL_LINES,而输出图元为GL_LINE_STRIP。此时用户应用程序中还应当编写了相应的顶点和图元信息,例如使用glBegin(GL_LINES)引领的顶点数据。
一个简单的几何着色器实现代码如下:
这段代码事实上没有执行什么特殊的工作,只是把需要渲染的图元数据发送到渲染管线当中。它的基本工作流程为:(1)对于每一个需要绘制的图元,向gl_Position传入自定义的数据;(2)调用EmitVertex()创建新的顶点;(3)调用EndPrimitive(),指示一个图元的绘制结束。
注意,由于几何着色器还不属于OpenGL核心标准的一部分,因此需要预先指定GLSL版本(#version),以及开启扩展功能GL_EXT_geometry_shader4(#extension)。
在结束这一节的介绍之前,作为OSG的开发者,我们还应当了解一些OSG中内置的一致变量,在自己的程序中定义这些变量之后,OSG系统将自动负责每帧对其进行更新,以方便着色器对应用程序信息的获取。具体的类型和命名如下所示:
那么下面我们进入OSG与GLSL联合开发的正题。
2.2 最简单的实现
从本节开始,笔者将尝试为您依次讲解:OSG与GLSL结合的最简单方式;如何传入一致变量(uniform)并实现动态更新;如何传入顶点属性变量(attribute);如何在节点树中共享和开关着色器对象;一个模型渐显效果的实现例子;一个几何着色器的实现例子;以及更多信息(MRT,Cg语言等)的概括和展望。
OSG提供了完整的GLSL着色语言的功能封装,其主要实现者包括osg命名空间中的以下几个类:
它属于StateAttribute的一个派生对象,可以被设置到一个节点或者一个可绘制物体(Drawable)上,从而将着色器绑定到指定的场景对象,这相当于对OpenGL函数glProgram的一个实现接口。
Program可以使用addShader()和removeShader()方法追加或删除着色器对象,使用setParameter()方法设置着色器参数,使用addBindAttribLocation()绑定顶点属性信息到着色器的属性变量,以及使用addBindFragDataLocation()绑定片元着色器输出数据到FBO。
这个类封装了顶点着色器,片元着色器和几何着色器的代码加载和编译功能,相当于OpenGL函数glShaderSource和glCompileShader的实现接口。
着色器一致变量的接口类。对于OpenGL着色语言而言,一致变量(uniform)是用户应用程序与着色器的主要交互接口。Uniform类支持绑定多种类型的一致变量,并使用set()和setArray()更新变量或变量数组的值。而为了实现一致变量的每帧变化,进而达到顶点和片元的各种动画特效,Uniform类还提供了相应的回调工具:使用setUpdateCallback设置自定义的回调类,并在其中更新这个一致变量的值,以实现所需的效果。
着色器一致变量的接口类。对于OpenGL着色语言而言,一致变量(uniform)是用户应用程序与着色器的主要交互接口。Uniform类支持绑定多种类型的一致变量,并使用set()和setArray()更新变量或变量数组的值。而为了实现一致变量的每帧变化,进而达到顶点和片元的各种动画特效,Uniform类还提供了相应的回调工具:使用setUpdateCallback设置自定义的回调类,并在其中更新这个一致变量的值,以实现所需的效果。
简介已毕,再加上前文中对于GLSL词法和语法的仓促讲解,再加上读者您自身的不懈学习和实践,相信早已有了足够的力量去体会OSG与GLSL联合开发的强大所在了吧?那么下面我们将尝试实现下面这个最简单的卡通着色器效果,如图所示:
我们使用一种非常简单的方式来实现卡通渲染:当直射光的方向与顶点法线的夹角较小时(即它们归一化之后的点积接近1.0时),使用较亮的颜色,反之则使用暗色;并且不采用插值等颜色过渡的方式。具体的算法介绍和代码实现可以参看教程网站LightHouse3D上的相关链接:
http://www.lighthouse3d.com/opengl/glsl/index.php?toon1
顶点着色器的代码如下:
片元着色器的代码如下:
我们将着色器的内容保存到字符串变量中,以便稍后在用户程序中使用。
这里的gl_LightSource显然是用来标识场景中灯光属性的内置变量,而ftransform(),dot()和normalize()都是GLSL的内置函数,此外我们还使用一个易变变量normal来传递各个顶点的法线值。着色器与用户程序之间没有使用一致变量和属性变量进行交互,因此需要OSG负责完成的唯一工作就是加载、编译着色器代码,并把它们绑定倒一个场景中的节点。
下面我们定义一个函数loadShaders(),用于加载多个着色器对象(osg:: Shader):
Shader类的构造函数可以传入两个参数,第一个参数决定了着色器的类型(VERTEX顶点、FRAGMENT片元、GEOMETRY几何),第二个参数则传入刚刚定义的字符串变量,即着色器的代码部分。
之类我们定义了一个Program渲染属性对象,使用addShader()函数将两个Shader对象依次添加到该渲染属性中,最后将Program对象绑定到指定的StateSet渲染状态集。一切就是这么简单,最后一步的工作仅仅是将渲染状态集赋予某个模型节点,执行仿真循环并欣赏您的成果而已:
综上所述,在OSG中嵌合着色语言的基本步骤如下: