3.3 顶点规范
现在我们已经在缓存中存储了数据,并且知道如何编写一个基本的顶点着色器,因此我们有必要将数据传递到着色器当中。我们已经了解顶点数组对象(vertex array object)的概念,它包含数据的位置和布局信息,以及类似glVertexAttribPointer()的一系列函数。现在,我们将更深入地了解顶点规范的相关内容、glVertexAttribPointer()的其他变种函数,以及如何设置一些非浮点数或者还没有启用的顶点属性数据。
3.3.1 深入讨论VertexAttribPointer
我们已经在第1章里简要地介绍过glVertexAttribPointer()命令。它的原型如下所示:
void glVertexAttribPointer(GLuint index, GLint size, GLenum type, GLboolean normalized, GLsizei stride, const GLvoid* pointer);
设置顶点属性在index位置可访问的数据值。pointer的起始位置也就是数组中的第一组数据值,它是以基本计算机单位(例如字节)度量的,由绑定到GL_ARRAY_BUFFER目标的缓存对象中的地址偏移量确定的。size表示每个顶点中需要更新的元素个数。type表示数组中每个元素的数据类型。normalized表示顶点数据是否需要在传递到顶点数组之前进行归一化处理。stride表示数组中两个连续元素之间的偏移字节数。如果stride为0,那么在内存当中各个数据就是紧密贴合的。
glVertexAttribPointer()所设置的状态会保存到当前绑定的顶点数组对象(VAO)中。size表示属性向量的元素个数(1、2、3、4),或者是一个特殊的标识符GL_BGRA,它专用于压缩顶点数据的格式设置。type参数设置了缓存对象中存储的数据类型。表3-6所示就是type中可以指定的标识符名称,以及对应的OpenGL数据类型。
注意,如果在type中传入了GL_SHORT或者GL_UNSIGNED_INT这样的整数类型,那么OpenGL只能将这些数据类型存储到缓存对象的内存中。OpenGL必须将这些数据转换为浮点数才可以将它们读取到浮点数的顶点属性中。执行这一转换过程可以通过normalize参数来控制。如果normalize为GL_FALSE,那么整数将直接被强制转换为浮点数的形式,然后再传入到顶点着色器中。换句话说,如果将一个整数4置入缓存,设置type为GL_INT,而normalize为GL_FALSE,那么着色器中传入的值就是4.0。如果normalize为GL_TRUE,那么数据在传入到顶点着色器之前需要首先进行归一化。为此,OpenGL会使用一个固定的依赖于输入数据类型的常数去除每个元素。如果数据类型是有符号的,那么相应的计算公式如下:
如果数据类型是无符号的,那么相应的计算公式如下:
这两个公式当中,f的结果就是浮点数值,c表示输入的整数分量,b表示数据类型的位数(例如GL_UNSIGNED_BYTE就是8,GL_SHORT就是16,以此类推)。注意,无符号数据类型在除以类型相关的常数之前,还需要进行缩放和偏移操作。之前我们向整数顶点属性中传入4作为示例,那么这里我们将得到:
这个结果相当于0.000000009313—这是一个非常小的数字!
整型顶点属性
如果你对浮点数值的工作方式比较熟悉的话,那么你应该也知道如果它的数值很大的时候,会造成精度的丢失,因此大范围的整数值不能直接使用浮点型属性传入顶点着色器中。因此,我们需要引入整型顶点属性。它们在顶点着色器中的表示方法为int、ivec2、ivec3以及ivec4,当然也有无符号的表现形式,即uint、uvec2、uvec3以及uvec4。
我们需要用到另一个顶点属性的函数将整数传递到顶点属性中,它不会执行自动转换到浮点数的操作。这个函数叫做glVertexAttribIPointer(),其中I表示整型的意思。
void glVertexAttribIPointer(GLuint index, GLint size, GLenum type, GLsizei stride, const GLvoid* pointer);
与glVertexAttribPointer()类似,不过它专用于向顶点着色器中传递整型的顶点属性。type必须是整型数据类型的一种,包括GL_BYTE、GL_UNSIGNED_BYTE、GL_SHORT、GL_UNSIGNED_SHORT、GL_INT,以及GL_UNSIGNED_INT。
注意,glVertexAttribIPointer()的参数与glVertexAttribPointer()是完全等价的,只是不再需要normalize参数。这是因为normalize对于整型顶点属性来说是没有意义的。这里的type参数只能使用GL_BYTE、GL_UNSIGNED_BYTE、GL_SHORT、GL_UNSIGNED_SHORT、GL_INT,以及GL_UNSIGNED_INT这些标识符。
双精度顶点属性
glVertexAttribPointer()的第三个变化就是glVertexAttribLPointer()—这里的L表示“long”。这个函数专门用于将属性数据加载到64位的双精度浮点型顶点属性中。
void glVertexAttribLPointer(GLuint index, GLint size, GLenum type, GLsizei stride, const GLvoid* pointer);
与glVertexAttribPointer()类似,不过对于传入顶点着色器的64位的双精度浮点型顶点属性来说,type必须设置为GL_DOUBLE。
这里再次说明,normalize参数依然是不需要的。glVertexAttribPointer()中的normalize只是用来处理那些不适宜直接使用的整型类型,因此在这里它并不是必须的。如果glVertexAttribPointer()函数也使用了GL_DOUBLE类型,那么实际上数据在传递到顶点着色器之前会被自动转换到32位单精度浮点型方式—即使我们的目标顶点属性已经声明为双精度类型,例如double、dvec2、dvec3、dvec4,或者双精度的矩阵类型,例如dmat4。但是,glVertexAttribLPointer()可以保证输入数据的完整精度,并且将它们直接传递到顶点着色器阶段。
顶点属性的压缩数据格式
回到glVertexAttribPointer()命令,之前已经提及,size参数的可选值包括1、2、3、4,以及一个特殊的标识符GL_BGRA。此外,type参数也可以使用某些特殊的数值,即GL_INT_2_10_10_10_REV或者GL_UNSIGNED_INT_2_10_10_10_REV,它们都对应于GLuint数据类型。这些特殊的标识符可以用来表达OpenGL支持的压缩数据格式。GL_INT_2_10_10_10_REV和GL_UNSIGNED_INT_2_10_10_10_REV标识符表示了一种有四个分量的数据格式,前三个分量均占据10个字节,第四个分量占据2个字节,这样压缩后的大小是一个32位单精度数据(GLuint)。GL_BGRA可以被简单地视为GL_ZYXW的格式。根据32位字符类型的数据布局方式,我们可以得到如图3-3的数据划分方式。
图3-3中,顶点元素分布在一个32位单精度整数中,顺序为w、x、y、z—反转之后就是z、y、x、w,或者用颜色分量来表示就是b、g、r、a。图3-4中,各个分量的压缩顺序为w、z、y、x,反转并写作颜色分量的形式就是r、g、b、a。
顶点数据可以使用GL_INT_2_10_10_10_REV或者GL_UNSIGNED_INT_2_10_10_10_REV这两种格式中的一种来设置。如果glVertexAttribPointer()的type参数设置为其中一种标识符,那么顶点数组中的每个顶点都会占据32位。这个数据会被分解为各个分量然后根据需要进行归一化(根据normalize参数的设置),最后被传递到对应的顶点属性当中。这种数据的排布方式对于法线等类型的属性设置特别有益处,三个主要分量的大小均为10位,因此精度可以得到额外的提高,并且此时通常不需要达到半浮点数的精度级别(每个分量占据16位)。这样节约了内存空间和系统带款,因此有助于提升程序性能。
3.3.2 静态顶点属性的规范
在第1章里,我们已经了解了glEnableVertexAttribArray()和glDisableVertexAttrib-Array()函数。
这些函数用来通知OpenGL,顶点缓存中记录了哪些顶点属性数据。在OpenGL从顶点缓存中读取数据之前,我们必须使用glEnableVertexAttribArray()启用对应的顶点属性数组。如果某个顶点属性对应的属性数组没有启用的话,会发生什么事情呢?此时,OpenGL会使用静态顶点属性。每个顶点的静态顶点属性都是一个默认值,如果某个属性没有启用任何属性数组的话,就会用到这个默认值。举例来说,我们的顶点着色器中可能需要从某个顶点属性中读取顶点的颜色值。如果某个模型中所有的顶点或者一部分顶点的颜色值是相同的,那么我们使用一个常数值来填充模型中所有顶点的数据缓存,这无疑是一种内存浪费和性能损失。因此,这里可以禁止顶点属性数组,并且使用静态的顶点属性值来设置所有顶点的颜色。
每个属性的静态顶点属性可以通过glVertexAttrib()系列函数来设置。如果顶点属性在顶点着色器中是一个浮点型的变量(例如f?loat、vec2、 vec3、vec4或者浮点型矩阵类型,例如mat4),那么我们就可以使用下面的glVertexAttrib()来设置它的数值。
void glVertexAttrib{1234}{fds}(GLuint index, TYPE values);
void glVertexAttrib{1234}{fds}v(GLuint index, const TYPE* values);
void glVertexAttrib4{bsifd ub us ui}v(GLuint index, const TYPE* values);
设置索引为index的顶点属性的静态值。如果函数名称末尾没有v,那么最多可以指定4个参数值,即x、y、z、w参数。如果函数末尾有v,那么最多有4个参数值是保存在一个数组中传入的,它的地址通过values来指定,存储顺序依次为x、y、z和w分量。
所有这些函数都会自动将输入参数转换为浮点数(除非它们本来就是浮点数形式),然后再传递到顶点着色器中。这里的转换就是简单的强制类型转换。也就是说,输入的数值被转换为浮点数的过程,与缓存中的数据通过glVertexAttribPointer()并设置normalize参数为GL_FALSE的转换过程是一样的。对于函数中需要传入整型数值的情况,我们也可以使用另外的函数,将数据归一化到[0, 1]或者[-1, 1]的范围内,其依据是输入参数是否为有符号(或者无符号)类型。这些函数的声明为:
void glVertexAttrib4Nub(GLuint index, GLubyte x, GLubyte y, GLubyte z, GLubyte w);
void glVertexAttrib4N{bsi ub us ui}v(GLuint index, const TYPE* v);
设置属性index所对应的一个或者多个顶点属性值,并且在转换过程中将无符号参数归一化到[0, 1]的范围,将有符号参数归一化到[-1, 1]的范围。
即使使用了这些函数,输入参数依然会转换为浮点数的形式,然后再传递给顶点着色器。因此他们只能用来设置单精度浮点数类型的静态属性数据。如果顶点属性变量必须声明为整数或者双精度浮点数的话,那么应该使用下面的函数形式:
void glVertexAttribI{1234}{i ui}(GLuint index, TYPE values);
void glVertexAttribI{123}{i ui}v(GLuint index, const TYPE* values);
void glVertexAttribI4{bsi ub us ui}v(GLuint index, const TYPE* values);
设置一个或者多个静态整型顶点属性值,以用于index位置的整型顶点属性。
此外,如果顶点属性声明为双精度浮点数类型,那么应该使用带有L字符的glVertexAttrib*()函数,也就是:
void glVertexAttribL{1234}(GLuint index, TYPE values);
void glVertexAttribL{1234}v(GLuint index, const TYPE* values);
设置一个或者多个静态顶点属性值,以用于index位置的双精度顶点属性。
glVertexAttribI()和glVertexAttribL()系列函数都是glVertexAttrib*()的变种,它们将参数到传递顶点属性的过程与glVertexAttribIPointer()等函数的实现过程是一样的。
如果你使用了某个glVertexAttrib()函数,但是传递给顶点属性的分量个数不足的话(例如使用glVertexAttrib()的2f形式,所设置的顶点属性实际上声明为vec4),那么缺少的分量中将自动填充为默认的值。对于w分量,默认值为1.0,而y和z分量的默认值为0.0。如果函数中包含的分量个数多于着色器中顶点属性的声明个数,那么多余的分量会被简单地进行抛弃处理。
静态顶点属性值是保存在当前VAO当中的,而不是程序对象。这也就意味着,如果当前的顶点着色器中存在一个vec3的输入属性,而我们使用glVertexAttrib*()的4fv形式设置了一个四分量的向量给它,那么第四个分量值虽然会被忽略,但是依然被保存了。如果改变顶点着色器的内容,重新设置当前属性为vec4的输入形式,那么之前设置的第四个分量值就会出现在属性w分量当中了。