1 着色器和程序(Shaders and Programs)
1.1 着色器语言(Language Overview)
着色器的编程语言是基于C语言开发的,被称为GLSL。其和C语言最大的区别是它定义了向量好矩阵两个数据类型,另外GLSL对于高并发进行特殊优化,通常在一个程序中,GPU同一时间会执行上千个着色器的调用。为了达到上述要求,其牺牲了部分性能,如在GLSL中禁止使用递归,浮点数的运算精度也没有C语言中常用IEEE标准那么高。
1.1.1 数据类型(Data Types)
GLSL中定义的数据类型有标量、向量、数组、结构体以及一些用于标识纹理和其他数据结构的不透明数据类型(opaque data)。
标量(Scalar Types)
GLSL中支持32和64位浮点型数据,32位有符号和无符号整形数据,Boolean类型数据。
bool true 或者false
float IEEE-754格式32位浮点型数据
double IEEE-754格式64位浮点型数据
int 32位整形数据
unsigned int 32位无符号型数据
其中int和unsigned int能表示的数据范围和C语言中一样。float类型数据用1位表示符号位,8位表示指部分,23位表示小数部分,其中8位的指数部分范围为-127到+127,会被修正为0到254。用b表示整个数据的二进制位数据,e表示指数部分的值,m表示小数部分的值,那么其最终的值可以通过以下公式获得。
类似的,double类型数据含1位符号位,11位指数位,52位小数位,其公式和上面公式类似,只有i的范围取值为1到52,最后部分为e-1023两处不同。
需要注意的是,GLSL并不严格要求执行IEEE-754标准,对于一些与NaNs、infinites(无穷数)和denormal(极小数)类型数据运算时会出现误差,因此需要避免上述情况。GLSL并不支持异常检测,当做一些如和0相除的不合理操作时,只有运行时才能发现。
向量和矩阵(Vectors and Matrices)
标量 bool float double int unsigned int
2/3/4维向量 bvec2/3/4 vec2/3/4 dvec2/3/4 ivec2/3/4 uvec2/3/4
2*2/3*3/4*4矩阵 --- mat2/3/4 dmat2/3/4 --- ---
2*3矩阵 --- mat2*3 dmat2*3 --- ---
还支持 2*4/3*2/3*4/4*2/4*3/4*4矩阵
向量的构造可以用标量或者矩阵或者他们的混合模式,如。
vec3 foo = vec3(1.0); vec3 bar = vec3(foo);
vec4 baz = vec4(1.0, 2.0, 3.0, 4.0); vec4 bat = vec4(1.0, foo);
向量元素的获取方式可以使用类似数组的方式,或者使用xyzw(坐标)、stpq(纹理坐标)、rgba(颜色)分别获取各个元素。另外可以使用其成员构建任意类型的向量,如vec4 temp = vec4(bar.yzz, 1.0)
。
在GLSL中矩阵被看做为向量的数组,每个向量表示矩阵的某一列,同时每个向量可以被看做数组,因此矩阵也被看做标量的二维数组,以列优先的方式遍历整个矩阵。可以通过mat[m][n]的方式获取其中的成员变量,其中m表示列,n表示行。+和-运算和标量运算相似,※运算不具有交换性,矩阵和向量除以标量为其中各个元素分别除以标量,矩阵和向量除以矩阵或向量时,两个操作对象必须有相同的维度。
数组和结构体(Arrays and Structures)
数组的声明方式和C++中类似,结构体的声明省略了关键字typedef。在GLSL中数组的声明方式有如下两种。
float foo[5]; ivec2 bar[13]; dmat3 baz[29];
float[5] foo; ivec2[13] bar; dmat3[29] baz;
数组声明时可以同时初始化数据,OpenGL4.2以前只能使用第一种。
float[6] var = float[6](1.0, 2.0, 3.0, 4.0, 5.0, 6.0);
float var[6] = { 1.0, 2.0, 3.0, 4.0, 5.0, 6.0 };
结构体以及结构体数组的定义方式如下。
struct foo {
int a; vec2 b; mat4 c;
};
struct bar {
vec3 a; foo[7] b;
};
bar[29] baz;
对于数组变量,可以使用函数.length
获取数组的元素个数(GLSL不支持C++语法中的成员函数,这是一个特例)。另外该函数也能获取向量的元素个数,以及矩阵的列数。如。
float a[10]; // Declare an array of 10 elements
float b[a.length()]; // Declare an array of the same size
mat4 c;
float d = float(c.length()); // d is now 4
int e = c[0].length(); // e is the height of c (4)
int i;
// This loop iterates 10 times
for (i = 0; i < a.length(); i++) {
b[i] = a[i];
}
GLSL不直接支持多维数组,但是其支持将数组包装到另外一个数组中。如下。
float a[10]; // "a" 数组包含10个浮点型数据
float b[10][2]; // "b" 数组包含两个数组,其中每个数组包含10个浮点型数据
float c[10][2][5]; // "c" 数组包含5个数组,其中每个数组包含2个数组,其中每个数组包含10个元素
1.1.2 内部函数(Built-In Functions)
GLSL包含上百个内部函数,大多数是用于处理纹理和内存,这些函数在其相关章节中说明,此处只关心处理数据的内部函数,他们用于基础数学,矩阵,向量,数据包装以及数据解包装。
术语(Terminology)
GLSL中的函数支持函数重载,即函数名相同具有不同参数。为了给数据类型分类以使其相关函数能更简洁的表示,GLSL引入了一些标准术语。
genType表示单精度的标量和向量数据,genUType表示无符号整形的向量和标量数据,genIType表示有符号整形的向量和标量数据,genDType表示双精度的向量和标量数据,mat表示单精度的矩阵,dmat表示双精度的矩阵。
内置的矩阵和向量函数
正如前文所讲,矩阵和向量在GLSL中是基本数据类型,它们通用+、-、*
运算符号,此外它们有额外的函数。函数matrixCompMult()
为对应元素相乘(component-wise multiplication),连个矩阵大小必须完全相同。函数transpose()
用于矩阵转置。
函数inverse()
用于求矩阵的逆矩阵,需要注意的是该项计算非常消耗性能,最好在程序中计算然后通过统一变量的方式传入着色器,另外非方阵不支持该函数。函数determinant()
用于计算方阵的行列式。最好需要注意的是对于病态矩阵(ill-conditioned matrices)(可以简单理解为矩阵列向量线性相关性过大,表示的特征太过于相似),不存在逆运算和行列式运算,他们作为参数时会得到未定义的结果(undefined result)。
函数outerProduct()
用于计算两个向量的“外积”,两个N维向量作为参数,运算时第一个向量作为1N的矩阵,第二个向量作为N1的矩阵,用矩阵2矩阵1,返回结果为NN的矩阵。用于比较向量的函数有lessThan(), lessThanEqual(), greaterThan(), greaterThanEqual(), equal(), 和 notEqual()
,上述函数都有两个相同大小和相同类型的向量作为参赛,返回一个同等大小的Boolean向量(bvec2, bvec3, or bvec4),向量各元素相互比较结果和返回结果向量中元素一一对应。
对于Boolean向量,可以使用函数any()和all()
测试测试其中某个元素或者所有元素是否为true。函数not()
可以对所有元素取反。
另外处理向量的内置函数还有,函数length()
返回向量的长度。函数distance()
返回向量表示两个点之间的距离。函数normalize()
对向量进行标准化。函数dot()和����cross()
分别用于向量的点成和叉乘。
函数reflect()和refract()
通过一个平面的法向量计算入射向量的反射和折射向量,另外后面一个函数需要窜扰额外的参数用于标识折射角。函数faceforward()
传入三个向量,如果后两个向量内积为负,则返回第一个向量,反之返回第一个向量的负向量,其中第一个和第三个参数分别两个曲面的法向量。
内置数学函数(Built–In Math Functions)
GLSL中的通用数学函数包括abs(), sign(), ceil(), floor(), trunc(), round(), roundEven(), fract(), mod(), modf(), min(),和max()
。他们可以对标量和向量使用。其中大多数函数和C语言标注库中的用法相同,但是有部分例外。如函数roundEven()
并没有C语言版本,该函数取离参数最近的整数,但是当参数小数部分为0.5时候,它总返回最近的偶数值。
函数clamp()
有两种不同的声明如下。它将x中的值限定在minval和maxval指定的最小和最大值之间。
vec4 clamp(vec4 x, float minVal, float maxVal);
vec4 clamp(vec4 x, vec4 minVal, vec4 maxVal);
函数mix()
用于在连个输入变量之间进行插值运算,其计算过程可以表示为如下公式。
vec4 mix(vec4 x, vec4 y, float a) {
return x + a * (y - x);
}
函数step()
定义为vec4 step(vec4 edge, vec4 x)
,如果x中相应元素的值小于edge中对应元素的值则返回0,反正则返回1。函数smoothstep()
定义为vec4 smoothstep(vec4 edge0, vec4 edge1, vec4 x)
,它通过内部的计算生成0到1的值,其计算规则如下。
vec4 smoothstep(vec4 edge0, vec4 edge1, vec4 x) {
vec4 t = clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0);
return t * t * (vec4(3.0) - 2.0 * t);
}
该函数产生一个埃尔米特插值曲线,通常用于标识淡入淡出效果,其函数如下可以表示为下图所示。
函数fma()
执行其前两个参数相乘结果加上第三个参数的操作,该函数生成的结果精度比代码中分开计算更高。在部分GPU中,会针对该复合操作进行特殊优化,使其执行的效率高于分开执行。
OpenGL中大多数函数用于浮点数的运算,但是函数uaddCarry() 和 usubBorrow()
分别用于无符号整形标量和向量的计算,第一个函数将头两个参数的和放入第三个参数中,后一个函数将前两个参数的差放入第三个函数中。函数imulExtended() 和 umulExtended()
允许将两个32位的无符号和有符号整形数据相乘,其结果为64位数据用两个32位数据保存。
此外GLSL还支持三角函数sin(), cos(), 和 tan()
,以及他们的反三角函数asin(), acos() 和 atan()
,以及双曲函数sinh(), cosh(), tanh(), asinh(), acosh(), 和 atanh()
。双曲函数的计算表达式此处不描述,只描述其几何意义。
对于下图中从原点发出的射线与单位双曲线(x^2 - y^2 = 1
)相交于点(cosh a,sinh a)。这里的a为射线、双曲线和x轴围成的面积的两倍。对于双曲线上位于x轴下方的点,这个面积被认为是负值。其中cosh a就是a的双曲余弦函数,其他双曲线函数类似。
GLSL还支持幂函数,指数函数和对数函数,pow(genType x, genType y)
表示x^y
, exp(genType x)
表示e^x
, log(genType x)
取x的自然对数, exp2(genType x)
表示2^x
, log2(genType x)
取以2为底的对数, sqrt(genType x)
为开平方运算,inversesqrt(genType x)
表示对x开平方后取其倒数。OpenGL中的运算都是以弧度表示角度如π,同时它提供函数radians和degress
分别将角度转换为弧度和相反运算。
内置的数据操作函数
GLSL同时提供了函数用于获取数据的类别结构,函数frexp()
可以将一个浮点数划分为小数部分和指数部分。函数ldexp()
将小数部分和指数部分组合为一个新的浮点数据(这里的转换是将位数据取出以浮点型数据编码的方式生成新的数)。函数intBitsToFloat() 和 uintBitsToFloat()
将一个整形数据转换为浮点型数据。函数floatBitsToInt() 和 floatBitsToUint()
将浮点型数据转换为整形数据。需要注意的是,在进行转换的时候,并非所有的位组合都会产生有效的浮点数据,可能得到极小值、无效数据和无限值,可以通过函数isnan() 或者 isinf()
分别对结果进行测试。
函数packUnorm4x8() 和 packSnorm4x8()
将vec4向量缩放至每个元素包装为8位的无符号或者有符号的整形数据,然后将它们组合成为1个32位的无符号整形数据。函数unpackUnorm4x8() 和 unpackSnorm4x8()
执行相反的操作,函数packUnorm2x16(), packSnorm2x16(), unpackUnormx16(), 和 unpackSnorm16()
用于处理二维向量。
上述函数中的关键字norm指的为标准化,对于无符号和有符号的标准化数据,其对应的浮点型数据范围分别为0到1和-1到1。这意味着将整数转换为向量时,小于0或-255的数被映射为0.0,大于255的树被映射为1.0。
函数packDouble2x32() 和 unpackDouble2x32()
执行针对双精度数据相似的操作。函数packHalf2x16()
将2个32位的小数包装为2个16位的小数然后再包装为1个32位的uint数据。注意GLSL不直接支持16位的小数,机软数据能以这个格式被存在内存中,但是GLSL包含函数在使用时将其解包为可用的数据类型。
函数bitfieldExtract()
从整数中提取部分为位生成一个新的整数,函数bitfieldInsert()执行相反操作。另外的位操作函数还有bitfieldReverse(), bitCount(), findLSB(), 和 findMSB()
分别用于位反序,有效位计数,查找最低有效位和最高有效位。
1.2 程序的编译、链接和执行(Compiling, Linking, and Examining Programs)
每个OpenGL的实现中都有一个编译器和连接器,在编译链接的过程中经常会遇到一些错误,OpenGL提供了很多函数来获取这些错误信息。
1.2.1 从编译器获取信息(Getting Information from the Compiler)
检查着色器语法错误的第一步是检查编译器状态,调用函数void glGetShaderiv(GLuint shader, GLenum pname, GLint * params);
可以获取编译器状态,参数shader表示着色器的句柄,参数pname表示查询目的,参数params指定查询结果的地址。pname可选的类型可以使GL_COMPILE_STATUS表示编译是否成功,成功返回1,失败返回0。该函数还支持的pname有GL_SHADER_TYPE,GL_DELETE_STATUS,GL_SHADER_SOURCE_LENGTH,GL_INFO_LOG_LENGTH。
当时获取到日志长度后,可以调用函数void glGetShaderInfoLog(GLuint shader, GLsizei bufSize, GLsizei * length, GLchar * infoLog);
获得着色器的编译日志。参数infolog指定了日志保存的地址。参数bufzie为准备好的缓存字节大学,参数length用于接受写入的日志长度。其使用方法如下。
GLint status = 0;
glGetShaderiv(*shader, GL_COMPILE_STATUS, &status);
if (status == 0) {
GLint logLen = 0;
glGetShaderiv(*shader, GL_INFO_LOG_LENGTH, &logLen);
GLchar *infoLog = malloc(sizeof(char) * logLen);
glGetShaderInfoLog(*shader, logLen, NULL, infoLog);
NSLog(@"Shader at: %@", path);
fprintf(stderr, "Info Log: %s\n", infoLog);
glDeleteShader(*shader);
free(infoLog);
return NO;
}
对于如下着色器代码。
#version 410 core //1
//2
layout (location = 0) out vec4 color; //3
//4
uniform scale; //5
uniform vec3 bias; //6
//7
void main(void) //8
{ //9
color = vec4(1.0, 0.5, 0.2, 1.0) * scale + bias; //10
} //11
使用上述日志输出信息后可以得到如下所示的错误信息。
ERROR: 0:5: error(#12) Unexpected qualifier
ERROR: 0:10: error(#143) Undeclared identifier: scale
WARNING: 0:10: warning(#402) Implicit truncation of vector from
size: 4 to size: 3
ERROR: 0:10: error(#162) Wrong operand types: no operation "+" exists that takes a left-hand operand of type "4-component vector of vec4" and a right operand of type "uniform 3-component vector of vec3" (or there is no acceptable conversion)
ERROR: error(#273) 3 compilation errors. No code generated
可以看到,着色器中的错日志分为警告个错误两类,其后紧跟的是着色器代码源的索引(需要注意的是函数glShaderSource()
允许为一个着色器对象分配多个着色器字符串),其后紧跟的是行号。
上文的第一个错误指的是第5行的变量缺少类型修饰符。第二个错误表示第10行使用未定义的变量scale。第三个警告表示正在尝试从vec4中截取vec3。第四个错误表示无法将vec4和vec3执行加法操作。
1.2.2 从连接器获取信息(Getting Information from the Linker)
正如编译作色器时会发生错误,链接程序时也可能会发生未知错误。获取连接器的状信息和获取编译器信息类似,函数为void glGetProgramiv(GLuint program, GLenum pname, GLint * params);
,参数program为要查询的程序句柄。参数params指定了接受结果信息的地址。pname为表示想要获取的信息类型,其枚举值很多,常见的有GL_DELETE_STATUS,GL_LINK_STATUS,GL_INFO_LOG_LENGTH,GL_ATTACHED_SHADERS,GL_ACTIVE_ATTRIBUTES(编译器认为顶点着色器使用的属性个数),GL_ACTIVE_UNIFORMS(程序中使用的统一变量个数),GL_ACTIVE_UNIFORM_BLOCKS
。
获取程序链接日志信息的函数为void glGetProgramInfoLog(GLuint program, GLsizei bufSize, GLsizei * length, GLchar * infoLog);
,其中各个参数和从编译器中获取日志信息函数的参数一致。假如现在有如下片段着色器代码。
#version 410 core
layout (location = 0) out vec4 color;
vec3 myFunction();
void main(void) {
color = vec4(myFunction(), 1.0);
}
正如C语言中可以将函数的实现和声明分别放在两个不同的文件中一样,GLSL也允许着色器中的函数的声明和实现在被链接到同一个程序的同类型的不同着色器字符串中(GLSL中允许将多个同类型的着色器字符串链接至一个程序对象)。当调用函数glLinkProgram()
,在本实例中,连接器将会查找所有的片段着色器字符串,如果未发现函数myFunction的实现,将会记录错误日志如下。
Vertex shader(s) failed to link, fragment shader(s) failed to link. ERROR: error(#401) Function: myFunction() is not implemented
1.2.3 分离程序(Separate Programs)
到目前为止,本系列文章中使用的程序都可以被认为是集成程序对象(monolithic program objects),即它们包括被激活各阶段的着色器对象。这种连接方式允许编译器执行一些内部优化,例如某个阶段着色器代码生成的结果在紧接阶段永远不会被使用时,相关代码将会被移除。然而,这种方式会牺牲应用的灵活性,甚至可能损失部分性能。对于每一种顶点着色器、片段着色器等的组合,该方式都需要为其单独分配一个程序,这种方式代价很大。
现在考虑如下情况,当需要使用一个顶点着色器,多个片段着色器时,采用传统的集成程序方式,需要分配多个程序对象,此时加入有多个顶点着色器,多个片段着色器,甚至多个阶段,此时分配的程序对象很容易膨胀至上千个,甚至更多。
为了解决这个问题,OpenGL允许使用分离模式(separable mode)的程序对象。这种类型的程序对象可以包含单个或几个阶段的着色器对象。表示一个管道各个部分的程序对象可以被附着在一个程序管道对象(program pipeline object)上,在运行时它们将会被组合在一起而不是在链接的时候。OpenGL在每个程序对象内部仍会对代码进行优化,并且一个程序管道对着中的程序对象可以以很小的性能消耗完成切换操作。
要使用分离模式,必须在链接程序对象之前调用函数glProgramParameteri()
和参数GL_PROGRAM_SEPARABLE,GL_TRUE启用分离模式的程序。该函数的效果还包含,该程序中着色器如果有未被使用的输出结果,相关代码不会被移除。同时它也会组织内部的数据布局,以便相邻的程序之间,前者最后一个着色器的输出数据和后者第一个着色器中具有相同布局的数据类型之间能够进行数据交流。紧接着,调用函数glGenProgramPipelines()
生成程序管道,再调用函数glUseProgramStages()
将程序绑定至程序管道对象之中。使用分类模式的例子如下。
// Create a vertex shader
GLuint vs = glCreateShader(GL_VERTEX_SHADER);
// Attach source and compile
glShaderSource(vs, 1, vs_source, NULL);
glCompileShader(vs);
// Create a program for our vertex stage and attach the vertex shader to it
GLuint vs_program = glCreateProgram();
glAttachShader(vs_program, vs);
// Important part - set the GL_PROGRAM_SEPARABLE flag to GL_TRUE *then* link
glProgramParameteri(vs_program, GL_PROGRAM_SEPARABLE, GL_TRUE); glLinkProgram(vs_program);
// Now do the same with a fragment shader
GLuint fs = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fs, 1, fs_source, NULL);
glCompileShader(fs);
GLuint fs_program = glCreateProgram();
glAttachShader(fs_program, vs);
glProgramParameteri(fs_program, GL_PROGRAM_SEPARABLE, GL_TRUE); glLinkProgram(fs_program);
// The program pipeline represents the collection of programs in use: Generate the name for it here.
GLuint program_pipeline;
glGenProgramPipelines(1, &program_pipeline);
// Now, use the vertex shader from the first program and the fragment shader from the second program.
glUseProgramStages(program_pipeline, GL_VERTEX_SHADER_BIT, vs_program);
glUseProgramStages(program_pipeline, GL_FRAGMENT_SHADER_BIT, fs_program);
上述实例只是一个最简单的引用,我们甚至可以包含更多的程序对象,或者在单个程序对象中韩韩对个着色器。例如曲面细分控制和曲面细分评估函数通常紧密连接,很少被分开。当一个program中只包含1个着色器时,可以使用函数GLuint glCreateShaderProgramv (GLenum type, GLsizei count, const char ** strings);
创建程序。参数type为使用的着色器类型(GL_VERTEX_SHADER等),参数count为数据源个数,参数strings为数据源数组指针。该函数内部会自动启用分割程序,编译着色器,附着着色器,链接程序,删除着色器的操作。
设置好各阶段着色器后,函数glBindProgramPipeline()
可以将绑定程序管道,一旦某个程序管道被绑定至当前上下文中,那么它将被用于渲染计算。
接口匹配
GLSL提供了一套特殊的规则用于匹配某一阶段的输出结果和下一个阶段的输入结果。当使用集成程序是,OpenGL连接器会对不正确的匹配生成错误信息。
然而当使用分离程序时,当切换program以及错误的排序都可能产生错误。因此在编程是注重这些规则从而避免上述问题十分重要。
总的说来相邻着色器的输出-输入变量需要有相同的名字、类型和结构,此外对于接口闭包和结构体,其内部的成员变量名字以及顺序也必须相同。对于数组变量,输出和输入的数组大学必须一致。唯一的特例是曲面细分着色器和几何着色器的输入以及输出可以有单个的元素类型匹配数组的输出类型。
当将多个阶段着色器连接到一个程序中时,OpenGL会对着色器中的代码进行优化,例如假如有顶点着色器和片段着色器,顶点着色其将一个常量直接写入到片段着色中,在编译后OpenGL会移除顶点着色器的代码,而是在片段着色器中会直接使用此常量。使用分类程序策略时,不会有该效果。
当多人进行开发或者着色器数量不断增加时,记住每一个着色器中的输出输入变量非常困难。然而,使用layout修饰符为着色器集合中的每个输入输出变量分配一个位置(Location)是可行的。OpenGL可以使用每个输入输出变量的位置来完成匹配操作。这种情况下,变量的名字并不重要,只要他们有相同的类型和修饰符。
void glGetProgramInterfaceiv(GLuint program,
GLenum programInterface,
GLenum pname,
GLint * params);
void glGetProgramResourceiv(GLuint program,
GLenum programInterface,
GLuint index,
GLsizei propCount,
const Glenum * props,
GLsizei bufSize,
GLsizei * length,
GLint * params);
上述两个函数可以获取各个变量的位置信息。第一个函数中,参数program为需要查找的程序,参数programInterface可选GL_PROGRAM_INPUT或者GL_PROGRAM_OUTPUT
,为了继续使用第二个函数,此处参数pname应制定GL_ACTIVE_RESOURCES
,此时程序中使用的输出或者输入变量的个数将会被写入地址params中。
第二个函数可以获取变量的多种信息,参数index指定变量在前一个函数获取清单中的索引,propCount指定获取属性的个数,参数props数组指定了需要获取哪些描述信息,参数params指定了查询结果写入的数组地址,参数bufSize指定了params中每个成员的内存大小(the size of which (in elements) is given in bufSize),参数length通常直接指定为NULL(或者返回属性个数写入该地址中)。
props可选枚举变量如下。
GL_TYPE 获取变量类型
GL_ARRAY_SIZE 获取数组元素个数(非数组变量返回0)
GL_REFERENCED_BY_VERTEX_SHADER,GL_REFERENCED_BY_TESS_CONTROL_SHADER,GL_REFERENCED_BY_TESS_EVALUATION_SHADER,
GL_REFERENCED_BY_GEOMETRY_SHADER,GL_REFERENCED_BY_FRAGMENT_SHADER,GL_REFERENCED_BY_COMPUTE_SHADER
获取变量是否被某个阶段引用,引用返回非0值,未引用返回0
GL_LOCATION 获取变量的布局位置
GL_LOCATION_INDEX 只能在programInterface为GL_PROGRAM_OUTPUT时才能使用,获取片段着色器输出变量的位置
GL_IS_PER_PATCH 用于判断曲面细分控制着色器的输出变量或者曲面细分评价着色器的输入变量是否声明为分批接口
( if an output of a tessellation control shader or an input to a tessellation evaluation shader is declared as a per-patch interface)
void glGetProgramResourceName(GLuint program, GLenum programInterface, GLuint index,
GLsizei bufSize, GLsizei * length, char * name);
获取变量的名字需要调用函数如上。参数program, programInterface, 和 index的含义和函数glGetProgramResourceiv
中对应参数相同。参数bufSize指定参数name所分配内存的大小,参数length通常指定为NULL(否则返回实际名字长度)。一个获取某个程序中活跃的输出变量信息实例如下。
// Get the number of outputs
GLint outputs;
glGetProgramInterfaceiv(program, GL_PROGRAM_OUTPUT, GL_ACTIVE_RESOURCES, &outputs);
// A list of tokens describing the properties we wish to query
static const GLenum props[] = { GL_TYPE, GL_LOCATION };
// Various local variables
GLint i;
GLint params[2];
GLchar name[64];
const char * type_name;
for (i = 0; i < outputs; i++) {
// Get the name of the output
glGetProgramResourceName(program, GL_PROGRAM_OUTPUT, i, sizeof(name), NULL, name);
// Get other properties of the output,这里buffersize使用2,而不是GLint的内存大小4,还需验证
glGetProgramResourceiv(program, GL_PROGRAM_OUTPUT, i, 2, props, 2, NULL, params);
// type_to_name() is a function that returns the GLSL name of type given its enumerant value
type_name = type_to_name(params[0]);
// Print the result
printf("Index %d: %s %s @ location %d.\n", i, type_name, name, params[1]);
}
一个片段着色器的部分声明代码如下。
out vec4 color;
layout (location = 2) out ivec2 data;
out float extra;
对于上述代码允许上述示例查询输出变量信息,可以得到如下输出结果。
Index 0: vec4 color @ location 0.
Index 1: ivec2 data @ location 2.
Index 2: float extra @ location 1.
输出的变量索引和定义的变量索引相同,当声明变量使用了布局位置信息时,OpenGL不做额外操作,否则会从0开始自动为变量设置位置信息。
1.2.4 着色器子程序(Shader Subroutines)
使用离散程序时,不同程序对象的切换时仍耗费性能。一个替代的方法是使用子程序,在着色器中它是一个Uniform变量,可以理解为C语言中的函数指针,通过在一个着色器中声明多个函数,而在渲染时决定具体使用某个函数从而使实现部分离散程序的功能,同时优化应用性能。Subroutines在着色器中的声明方式如下。
#version 430 core
// First, declare the subroutine type
subroutine vec4 sub_mySubroutine(vec4 param1);
// Next declare a couple of functions that can be used as subroutine...
subroutine (sub_mySubroutine) vec4 myFunction1(vec4 param1) {
return param1 * vec4(1.0, 0.25, 0.25, 1.0);
}
subroutine (sub_mySubroutine) vec4 myFunction2(vec4 param1) {
return param1 * vec4(0.25, 0.25, 1.0, 1.0);
}
// Finally, declare a subroutine uniform that can be "pointed"
// at subroutine functions matching its signature
subroutine uniform sub_mySubroutine mySubroutineUniform;
// Output color
out vec4 color;
void main(void) {
// Call subroutine through uniform
color = mySubroutineUniform(vec4(1.0));
}
每个Subroutine的子函数都有自己的索引值,在GLSL430及其以后,可以直接在着色器代码中指定索引值,设置方式如下。
layout (index = 2)
subroutine (sub_mySubroutine)
vec4 myFunction1(vec4 param1) {
return param1 * vec4(1.0, 0.25, 0.25, 1.0);
}
layout (index = 1);
subroutine (sub_mySubroutine)
vec4 myFunction2(vec4 param1) {
return param1 * vec4(0.25, 0.25, 1.0, 1.0);
}
对于GLSL430以前的版本,在链接程序后,OpenGL会自动为subroutine的子函数分配索引值,调用函数为GLuint glGetProgramResourceIndex(GLuint program, GLenum programInterface, const char * name);
。其中参数program表示链接的程序。参数programInterface根据具体要查找的着色器阶段可选GL_VERTEX_SUBROUTINE, GL_TESS_CONTROL_SUBROUTINE, GL_TESS_EVALUATION_SUBROUTINE, GL_GEOMETRY_SUBROUTINE, GL_FRAGMENT_SUBROUTINE, 或者 GL_COMPUTE_SUBROUTINE
。参数name为函数的名称。当未查找到对应函数时,返回GL_INVALID_VALUE。
同样的,当知道某个subroutine类型函数的索引时,可以调用函数void glGetProgramResourceName(GLuint program, GLenum programInterface, GLuint index, GLsizei bufSize, GLsizei * length, char * name);
获取要查询函数的名字。参数program和programInterface和上一个函数相同,参数index为需要查找的函数索引值,参数bufSize为函数名将要写入的地址name分配的内存空间大小,函数名的字符数量将会被写入到地址length中。
void glGetProgramStageiv(GLuint program, GLenum shadertype, GLenum pname, GLint *values);
对于某个程序的某一个着色器阶段,其中活跃的subroutine类型函数数量可以通过上述函数获取。其中参数program表示要查询的program,参数shadertype为需要查询的着色器阶段,参数pname这里设置为GL_ACTIVE_SUBROUTINES,返回值会写入在value地址内。当调用函数glGetActiveSubroutineName
时,其参数index必须为有效的索引值。
当确定subroutine的索引后,调用函数void glUniformSubroutinesuiv(GLenum shadertype, GLsizei count, const GLunit *indices);
可以设置subroutine类型的Uniform变量值,从而决定在着色器中具体调用的是哪个子函数。参数count指定了subroutine类型的Uniform变量个数,参数indices数组中的每个索引对应的成员会赋值给相同位置的uniform变量。这里通常只为一个Uniform变量赋值,要为多个Uniform变量赋值时,调用函数glGetSubroutineUniformLocation
获取其位置,或者在着色器中指定位置。
需要注意的是为subroutine类型Uniform变量赋值不同于普通的Uniform变量。
- subroutine Uniform变量的值存储在当前上下文中,而不是program对象中,这样可以在同一个program对象不同上下文中存储不同的Uniform变量值。
- 当调用函数
glUseProgram(), glUseProgramStages() 或者 glBindProgramPipeline
时,subroutine Uniform变量的值会丢失。这意味着当使用一个新的program或者使用一个新的program阶段时都必须重设这些变量。 - program中每个阶段的所有subroutine Uniform变量都必须赋值,调用函数
glUniformSubroutinesuiv
赋值时,超出count参数指定的变量都不会被赋值,此时调用这些类型的变量会造成应用崩溃。
在链接program后,调用以下代码获取subroutine子函数的索引值。
subroutines[0] = glGetProgramResourceIndex(render_program, GL_FRAGMENT_SHADER_SUBROUTINE, "myFunction1");
subroutines[1] = glGetProgramResourceIndex(render_program, GL_FRAGMENT_SHADER_SUBROUTINE, "myFunction2");
在获取索引值后,在程序中调用如下代码以完成绘制操作。
void subroutines_app::render(double currentTime) {
glUseProgram(render_program);
glUniformSubroutinesuiv(GL_FRAGMENT_SHADER, 1, &subroutines[1]);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
}
1.2.5 程序二进制文档(Program Binaries)
编译链接完程序后,可以获取程序的二进制对象,在某个时刻能够直接将该二进制对象交给OpenGL处理,从而绕过编译和链接的步骤。使用该特性需调用函数glProgramParameteri()
,参数pname设置为GL_PROGRAM_BINARY_RETRIEVABLE_HINT
,其值设置为GL_TRUE,然后在调用函数glLinkProgram()
。
要获取二进制对象,首先需要调用函数glGetProgramiv()
获取二进制对象的大小,参数pname设置为GL_PROGRAM_BINARY_LENGTH
,接下来调用函数void glGetProgramBinary (GLuint program, GLsizei bufsize, GLsizei * length, GLenum * binaryFormat, void * binary)
获取二进制对象。
其中二进制数据会被写入参数binary指定的内存中,数据格式会被写入到地址binaryformat中,bufferzise为binary内存空间的大小,实际写入的数据大小将会被写入地址length中。二进制文件的格式会和GPU及OpenGL驱动的制造商相关联。一个获取程序的二进制文件的实例如下。
// Create a simple program containing only a vertex shader
static const GLchar source[] = { ... };
// First create and compile the shader
GLuint shader;
shader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(shader, 1, suorce, NULL);
glCompileShader(shader);
// Create the program and attach the shader to it
GLuint program;
program = glCreateProgram();
glAttachShader(program, shader);
// Set the binary retrievable hint and link the program
glProgramParameteri(program, GL_PROGRAM_BINARY_RETRIEVABLE_HINT, GL_TRUE); glLinkProgram(program);
// Get the expected size of the program binary
GLint binary_size = 0;
glGetProgramiv(program, GL_PROGRAM_BINARY_SIZE, &binary_size);
// Allocate some memory to store the program binary
unsigned char * program_binary = new unsigned char [binary_size];
// Now retrieve the binary from the program object
GLenum binary_format = GL_NONE;
glGetProgramBinary(program, binary_size, NULL, &binary_format, program_binary);
当获取到二进制文件后,可以将其存储到磁盘上(可以压缩),然后在下一次应用启动时直接加载。需要注意的是,二进制程序对象的格式和GPU制造商以及OpenGL驱动相关,因此不能在不同机器以及同一个机器不同驱动中共用。该机制不适用于发布程序,更多的意义在于缓存程序对象。
在前文中使用的所有示例其program都很小,但是考虑到如游戏类的大型应用,使用该特性有非常明显的有效。游戏应用中通常包含上千个着色器,在游戏启动时需要编译链接程序,这通常很耗时,采用二进制文件缓存策略能省去大量的时间。但是有一个问题需要注意,对于复杂的应用,OpenGL会在程序运行是对着色器进行重编译。
OpenGL中大多数特性都能直接被现代的GPU直接支持,然而部分特性在着色器中并不支持,当应用编译着色器时,OpenGL的实现会给大多数的特性实现默认配置,并在编译时对着色器采用这些默认配置。如果着色器中并未使用默认配置,那么,OpenGL的实现至少需要重编译该部分着色器代码以应对这种改变。这样会导致应用卡顿。
为了优化上述问题,强烈建议在链接program之前,将其GL_PROGRAM_BINARY_RETRIEVABLE _HINT
属性设置为GL_TRUE
,并且在进行多次渲染步骤后再获取二进制文件。这样可以在真正获取二进制文件之前让应用有时间进行必要的重编译,并且能再一个二进制文件中保存多个程序的版本。以后再次加载二进制文件时,OpenGL的实现需要使用某一个特别变量的时候,它会在该二进制文件中找到重编译的那部分可执行代码。
在将二进制文件载入OpenGL之前,需要为新建的program对象调用函数lProgramBinary()
,参数binaryFormat,data和length分别设置为从函数glGetProgramBinary()
获取的值。
1.3 总结(Summary)
该部分内容讨论了着色器以及它们是如何工作,可编程程序语言GLSL以及OpenGL是如何使用该部分代码,以及它们和图形管道的关系。
2 顶点处理和绘制命令(Vertex Processing and Drawing Commands)
2.1 顶点处理(Vertex Processing)
OpenGL运行后第一个阶段为顶点抓取阶段(vertex fetch stage),该阶段抓取数据并传入顶点着色器(vertex shader)。顶点着色器是可编程部分,用于确定模型顶点位置。
2.1.1 顶点着色器输入(Vertex Shader Inputs)
到目前为止出现的示例中,顶点着色器输入的数据类型都是浮点型数据,然而OpenGL支持大量的顶点属性,每个属性都有自己的格式,顶点类型以及成员等。同样的OpenGL也能从不同的缓存对象中读取数据并输入到每个属性中。函数glVertexAttribPointer()
可以为顶点着色器中的属性赋值数据,此外OpenGL还提供了以下几个辅助方法。
void glVertexAttribFormat(GLuint attribindex, GLint size, GLenum type, GLboolean normalized, GLuint relativeoffset);
void glVertexAttribBinding(GLuint attribindex, GLuint bindingindex);
void glBindVertexBuffer(GLuint bindingindex, GLuint buffer, GLintptr offset, GLintptr stride);
为了说明上述方法,考虑有如下的顶点着色器代码。
#version 430 core
// Declare a number of vertex attributes
layout (location = 0) in vec4 position;
layout (location = 1) in vec3 normal;
layout (location = 2) in vec2 tex_coord;
// Note that we intentionally skip location 3 here
layout (location = 4) in vec4 color;
layout (location = 5) in int material_id;
上述的5个输入变量可以用一个C语言中的结构体变量来表,其定义如下。
typedef struct VERTEX_t {
vmath::vec4 position;
vmath::vec3 normal;
vmath::vec2 tex_coord;
GLubyte color[3];
int material_id;
} VERTEX;
为属性position调用格式描述函数glVertexAttribFormat()
时,参数size为4,参数type为GL_FLOAT。属性normal通常用于指定该几何表面的法向量,参数size为4,参数type为GL_FLOAT。属性tex_coord通常用于描述纹理的坐标,参数size为2,参数type为GL_FLOAT。
对于属性color,在着色器中定义的输入可惜为vec4,然而提供的原始数据类型为一个3字节的数组。此处的大小和类型都不相同。OpenGL可以在读取数据后将其进行转换再传入着色器中。为了将3个单字节成员变量构成的原始数据属性和4个float类型变量构成的着色器输入属性匹配,需要调用函数glVertexAttribFormat()
,参数size为3,参数type为GL_UNSIGNED_BYTE。该数据类型表示的是非标准化数据,其取值位于0到255,在将数据传入着色器时,参数normalized设置为GL_TRUE时,OpenGL会将颜色的分量都除以255后便得到取值为0到1的标准化数据,如果设置为GL_FALSE,OpenGL会将其值直接强制类型转换。
对于顶点着色器中的个输入变量,其数据类型和取值范围如下。后三个类型不能被标准化。GL_FIXED是一个特殊的数据类型,有32位组成,高16位为整数部分,低16位为小数部分,该类型也不能被标准化。
Type OpenGL Type Range
GL_BYTE GLbyte -128 to 127
GL_SHORT Glshort -32,768 to 32767
GL_INT GLint -2,147,483,648 to 2,147,483,647
GL_FIXED GLfixed -32,768 to 32767
GL_UNSIGNED_BYTE GLubyte 0 to 255
GL_UNSIGNED_SHORT GLushort 0 to 65535
GL_UNSIGNED_INT GLuint 4,294,967,295
GL_HALF_FLOAT GLhalf —
GL_FLOAT GLfloat —
GL_DOUBLE GLdouble —
除了上述的标量,函数glVertexAttribFormat()也支持使用包装变量,即用一个整形变量标识多个成员。如格式GL_UNSIGNED_INT_2_10_10_10_REV和GL_INT_2_10_10_10_REV
,他们都将4个变量合成带了一个32位的变量中。
格式GL_UNSIGNED_INT_2_10_10_10_REV
的x、y、z分量为10位,w分量只有2位,并且他们都是无符号整数。因此x、y、z的取值范围为0到1023,w的取值范围为0到3。格式GL_INT_2_10_10_10_REV
类似,其x、y、z的取值范围为-512到511,w的取值范围为-2到1。该格式并不是很有用,但是他可以用于标识3维向量,尽管有2位的内存空间会被浪费。
当指定为上述两个包装数据格式时,蚕食size必须指定为4或者GL_BGRA。此时OpenGL会自动将输入数据的分量顺序RGB转换为BGR。这样能够提升着色器的兼容性。注,BGRA的颜色顺序广泛运用于图像存储中,是大多数图像API的默认顺序。
回到前文声明的顶点着色器,对于属性material_id,需要传入int类型的值,因此需要调用函数glVertexAttribFormat()
的扩展形式glVertexAttribIFormat()
,其参数含义相同。不同的是该函数中不包含参数normalized,这是因为该函数的type只能是GL_BYTE, GL_SHORT, GL_INT以及他们对应的无符号类型,或者包装的数据格式,该类型数据永远也不会被标准化处理。因此需要关联自定义C语言的结构体输入,和着色器中对应的属性需要调用以下函数。
// position
glVertexAttribFormat(0, 4, GL_FLOAT, GL_FALSE, offsetof(VERTEX, position));
// normal
glVertexAttribFormat(1, 3, GL_FLOAT, GL_FALSE, offsetof(VERTEX, normal));
// tex_coord
glVertexAttribFormat(2, 2, GL_FLOAT, GL_FALSE, offsetof(VERTEX, texcoord));
// color[3]
glVertexAttribFormat(4, 3, GL_UNSIGNED_BYTE, GL_TRUE, offsetof(VERTEX, color));
// material_id
glVertexAttribIFormat(5, 1, GL_INT, offsetof(VERTEX, material_id));
当关联元素数据存储格式和着色器声中数据声明格式后,需要指定数据的读取缓存源(buffer)。将缓存映射至统一变量闭包和将缓存映射至顶点属性类似。每个顶点着色器都可以包含不超过上限个数的属性,OpenGL同样也能从不高于上限个数的缓存中向着色器中传递数据。部分顶点属性数据可以在一个缓冲中共享内存空间,其余的存储在不同的缓存中。通常并不会为每个顶点属性指定缓存对象,相反的,通常将输入对象分组,然后将这些组合缓存绑定集合相关联。
在应用中调用函数glVertexAttribBinding()
可以在缓存绑定点和顶点属性之间建立映射关系。参数attribindex为顶点属性的索引,参数bindingindex是缓存绑定点的索引。此处将所有顶点属性分为1组,并绑定至同一个绑定点。当然也可以将它们绑定至多个绑定点,这里不再说明。
void glVertexAttribBinding(0, 0); // position
void glVertexAttribBinding(1, 0); // normal
void glVertexAttribBinding(2, 0); // tex_coord
void glVertexAttribBinding(4, 0); // color
void glVertexAttribBinding(5, 0); // material_id
最后只需调用函数glBindVertexBuffer()
将缓存对象绑定至绑定点即可。其中参数bindingindex为绑定点的索引,buffer为缓存对象的名字,Offset为顶点数据的偏移量,参数stride为每个顶点数据起点之间的内存间隔,他们单位都为字节。当顶点数据紧密包装时,参数stride可以设置为整个顶点数据的大小,即示例中的sizeof(VERTEX),否则需要计算真实的内存间隔。
上述为顶点着色器输入数据的方式和首先创建缓存,然后调用函数glVertexAttribPointer
和函数glEnableVertexAttribArray
的方式有相同的效果。
2.1.2 顶点着色器输出(Vertex Shader Outputs)
当顶点着色器对顶点数据处理后,需要输出结果。前文中已经使用过内部变量gl_Position来创建输出结果。和gl_Position一样,OpenGL还提供另外两个内部变量可以在顶点着色器中使用,它们被封装在闭包gl_Pervertex中,使用时可以直接访问内部数据不用带闭包名。其声明如下。
out gl_PerVertex {
vec4 gl_Position;
float gl_PointSize;
float gl_ClipDistance[];
};
gl_ClipDistance用于剪切图元,其使用方式本章末尾介绍。gl_PointSize用于控制渲染时的点大小。
默认情况下,OpenGL使用单个片段的大小(a size of a single fragment)去绘制一个点。如前文在应用中使用函数glPointSize()
直接改变点的大小。OpenGL在不同实现中支持的单个点渲染大小上限不尽相同,但是至少是64像素。函数glGetIntegerv()
和参数GL_POINT_SIZE_RANGE
可以获取到一个尺寸为2的数组,第一个元素表示最小点尺寸,通常为1,第二个元素表示最大点尺寸。
OpenGL支持在着色器中设置点的大小,在着色器中直接为变量gl_PointSize赋值即可。在此之前必须在应用中调用函数glEnable(GL_PROGRAM_POINT_SIZE)
来启用该功能。
该特性的一种使用案例是根据点距离观察着的距离来设置点的大小。在应用中调用函数glPointSize()
设置点大小,所有的点大小都一致。而在着色器中设置点的大小具有更高的灵活性。该方式也能用于几何着色器中,或者在曲面细分评估着色器指定点模式(point_mode)时,该方式也能用于曲面细分引擎内。
下面公式用于计算基于距离的点尺寸衰减,其中d表示点距眼睛的距离。可以将a、b、c、d四个值声明为uniform类型变量,并在绘制时不断更新,也可以将它们设置为有意义的常量。该等式中,当b、c为0,a不为0时,点大小和距离无关,当a、b为0,c不为0时,点大小和距离以二次函数递减方式。
2.2 绘制命令(Drawing Commands)
到目前为止的示例中,渲染图形都只调用了函数glDrawArrays()
。其实OpenGL提供了多个函数用于渲染模型,他们被分为有索引-无索引的,直接-间接地。没类绘制函数都包含如下几个部分。
2.2.1 有索引的绘制命令(Indexed Drawing Commands)
函数glDrawArrays()
为非索引绘图命令。使用该方式绘制图形时,OpenGL从缓存中读取数据后直接按原始顺序传入着色器之中。有索引的绘图方式包含了一些间接的步骤将这些缓存中的数据看做为一个数组,此时OpenGL不会有序的从数组中读取数据,相反的会从根据另外一个索引数组来确定读取顺序。为了实现有索引的绘图命令,必须在GL_ELEMENT_ARRAY_BUFFER
目标上绑定一个缓存对象。该缓存对象包含需要绘制的顶点索引值。接下来,就可以调用有索引的绘制函数,这些函数的名字中都有关键字names。
函数void glDrawElements(GLenum mode, GLsizei count, GLenum type, const GLvoid * indices);
是最简单的一个有索引的绘制命令。参数mode和参数type的含义和函数glDrawArrays()
中一致。参数type用于描述索引数据的格式,可选GL_UNSIGNED_BYTE,GL_UNSIGNED_SHORT和GL_UNSIGNED_INT
,它们分别表示每个索引占用8、16、32位内存空间。参数indices描述了当前绑定至GL_ELEMENT_ARRAY_BUFFER
目标的缓存中数据的偏移值。下图表示了通过函数glDrawElements()
指定的索引值是如何被使用的。
实际上,函数glDrawArrays()和glDrawElements()
是OpenGL支持的直接绘图命令的子集。下面列举了OpenGL中最普遍的绘图命令,在OpenGL中所有的绘图命令都由其中部分函数组成。
Draw Type Command
Direct, Non-Indexed glDrawArraysInstancedBaseInstance()
Direct, Indexed glDrawElementsInstancedBaseVertexBaseInstance()
Indirect, Non-Indexed glMultiDrawArraysIndirect()
Indirect, Indexed glMultiDrawElementsIndirect()
前文中有个示例为旋转的立方体,之前使用了12个三角形(每个面两个),36个顶点来绘制一个立方体。然而,一个立方体实际只含有8个角,因此只需要8个顶点,通过有索引的绘图命令可以减少顶点的数量,特别是应用在包含大量顶点的着色器上时。
此时可以定义8个顶点数据和36个索引数据来实现上述特性,其实现代码如下。下述的代码可以将内存空间从432字节降低到144字节。
static const GLfloat vertex_positions[] = {
-0.25f, -0.25f, -0.25f, -0.25f, 0.25f, -0.25f, 0.25f, -0.25f, -0.25f, 0.25f, 0.25f, -0.25f,
0.25f, -0.25f, 0.25f, 0.25f, 0.25f, 0.25f, -0.25f, -0.25f, 0.25f, -0.25f, 0.25f, 0.25f,
};
static const GLushort vertex_indices[] = {
0, 1, 2, 2, 1, 3, 2, 3, 4, 4, 3, 5, 4, 5, 6, 6, 5, 7,
6, 7, 0, 0, 7, 1, 6, 0, 2, 2, 4, 6, 7, 5, 3, 7, 3, 1
};
glGenBuffers(1, &position_buffer);
glBindBuffer(GL_ARRAY_BUFFER, position_buffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertex_positions), vertex_positions, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, NULL);
glEnableVertexAttribArray(0);
glGenBuffers(1, &index_buffer);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, index_buffer);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(vertex_indices), vertex_indices, GL_STATIC_DRAW);
当有了顶点数据和索引数据后,调用函数glDrawElements()
或者其扩展函数即可以绘制模型,其绘制逻辑代码如下。
// Clear the framebuffer with dark green
static const GLfloat green[] = { 0.0f, 0.25f, 0.0f, 1.0f };
glClearBufferfv(GL_COLOR, 0, green);
// Activate our program
glUseProgram(program);
// Set the model-view and projection matrices
glUniformMatrix4fv(mv_location, 1, GL_FALSE, mv_matrix);
glUniformMatrix4fv(proj_location, 1, GL_FALSE, proj_matrix);
// Draw 6 faces of 2 triangles of 3 vertices each = 36 vertices
glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_SHORT, 0);
基础顶点(The Base Vertex)
函数void glDrawElementsBaseVertex(GLenum mode, GLsizei count, GLenum type, GLvoid * indices, GLint basevertex);
是函数glDrawElements
的扩展形式。该函数会在通过索引从数据数组缓存中取出顶点数据之前为索引添加偏移值。因此当该函数参数basevertex为0时,其和函数glDrawElements
等价。该操作的逻辑如下图所示。
使用图元重启联合顶点(Combining Geometry using Primitive Restart)
OpenGL中有很多工具可以条带化几何形(“stripify” geometry)。这些工具的原理是通过使用表示大量未连接的三角形集合的三角形混合体(triangle soup),并尝试将其合成一个三角形条带集合的方式来提高性能。由于单个三角形由三个顶点表示,但是三角形条带可以将其降低至每个三角形由一个顶点表示(除了第一个三角形),因此该方案是可行的。通过将几何形从三角形混合体向三角形条带转换可以减少需要处理的几何体数据,同时应用能够运行得更快。判断一个工具的优良的标注是,是否能够形成更少的三角形条带以及单个三角线条带能够包含更多的三角形。对于该算法有大量的研究,一个算法是否成功的判断方式是使用心得条带化器处理一些完善的模型,将处理结果的条带数量和单个条带的长度和最先进的条带化器比较。
三角形混合体的渲染可以只调用一次函数glDrawArrays
或者glDrawElements
,而三条形条带集合的渲染需要多次调用函数(此处还有函数能够一次渲染三角形条带,该特例下文讲解)。这意味着使用三角形条带在应用中将会出现更多的函数调用,如果条带化器性能较差或者模型不能被很好被条带化,这种操作看上去将会浪费性能。
OpenGL提供了图元重启特性(primitive restart)用于解决上述问题。图元重启适用的种类有GL_TRIANGLE_STRIP, GL_TRIANGLE_FAN, GL_LINE_STRIP和GL_LINE_LOOP
。该方法通知OpenGL一个条带(或者扇区,或者环)已经结束,另一个新的条带(或者扇区,或者环)即将开始。为了标识几何形中某个条带的结束和另一个条带开始的位置,数组中会包含一个预留标记。随着OpenGL从元素数组中抓取顶点数据,OpenGL会监测这个预留标记,当检查到该标记时,OPenGL会结束当前条带绘制,并开启一个新的条带。
该特性默认下是禁用的,可以调用函数glEnable(GL_PRIMITIVE_RESTART);
来启用改特性,当然调用函数glDisable(GL_PRIMITIVE_RESTART);
可以禁用该特性。为索引数组中某个元素配置标记调用函数glPrimitiveRestartIndex(index);
,其参数index为某个条带的最后一个顶点对应的索引数组中元素在索引数组中的下标。需要注意的是,该特性只对有索引的绘图命令生效,否则将无效。
(多个标记使用待研究)图元重启的默认标记索引值为0,它几乎是将会包含到模型中的一个真实顶点。在使用图元重启模式时,将重置索引设置一个新的值是一个不错的选择。一个不错的值是使用期数据类型下的最大值,如4字节类型数据时使用0xFFFFFFFF,因为该索引值几乎不可能是一个有效的顶点索引。
大多数条带化工具都包含一个是否使用重启索引创建分离条带或者创建单个条带的选项。条带化工具可能使用了一个预定义的索引或者直接输出它在创建条带化版本模型时使用的索引值(例如一个比顶点数组容量更大的值)。在确定这个值后必须调用函数glPrimitiveRestartIndex()
以使用工具的输出值。下图说明了图元重启时标记的工作原理。
上图中,圆圈内的数字表示顶点对于的索引值。图a中,该条带由由17个顶点组成,共形成了15个三角形。在启用图元重启属性,并将重启索引设置为8后,OpenGL将会识别到该特殊标记,并作出响应,最后绘制出连个三角形条带,分别还包含8个顶点和6个三角形。
2.2.2 举例(Instancing)
当大量重复绘制某个模型,比如绘制一片草地,或者一片星空时可能会耗费很多时间。这中情况通常会出现上千个副本,他们都是单一几何集合的重复,每个副本之间只有很小的变化。一个简单的应用可能循环的绘制草叶,在每次绘制时调用函数glDrawArrays()
,并在每次绘制迭代中更新Uniform类型变量的值。假定每片草叶都由一个包含4个三角形的条带组成,这种简单程序的代码如下。
glBindVertexArray(grass_vao);
for (int n = 0; n < number_of_blades_of_grass; n++) {
SetupGrassBladeParameters();
glDrawArrays(GL_TRIANGLE_STRIP, 0, 6);
}
然而,一片草地上的叶子数量(number_of_blades_of_grass)可能达到上千个甚至高达几百万个。每片草叶在屏幕上只占一小块地方,并且每片草叶包含的顶点数量也很小。此时,GPU渲染单片草叶的时间并不长,但是会耗费大量的时间来发送绘制命令。OpenGL通过举例渲染来解决这个问题,该特性通知GPU绘制某个几何图形的副本。举例渲染的函数如下。
void glDrawArraysInstanced(GLenum mode, GLint first, GLsizei count, GLsizei instancecount);
void glDrawElementsInstanced(GLenum mode, GLsizei count, GLenum type, const void * indices, GLsizei instancecount);
这两个函数的功能和函数glDrawArrays()和glDrawElements()
类似。第一个函数参数mode、first、count以及第二个函数参数mode、count、type、indices和非举例版本函数对应参数含义相同。当调用上述函数时,OpenGL值做一次必要的渲染前准备操作(如将顶点数据拷贝至GPU的内存),然后多次渲染同一个模型。另外相似的绘制函数如下。
void glDrawElementsInstancedBaseVertex(GLenum mode, GLsizei count, GLenum type, GLvoid * indices, GLsizei instancecount, GLint basevertex);
glDrawElementsInstancedBaseInstance();
// 以下两个函数为所有的直接绘制函数的最复杂形态,通过其参数basevertex和baseinstance设置为0,参数instancecount设置为1可以得到和其一般函数效果类似的绘制操作
glDrawArraysInstancedBaseInstance();
glDrawElementsInstancedBaseVertexBaseInstance();
让举例渲染和普通渲染不同以及强大的关键是OpenGL提供的内置变量gl_InstanceID。它以整形变量的形式出现,当第一个顶点的拷贝被发送至OpenGL时,其值为0,每接收到1个拷贝,其值加一,直到增长为例子数量再减1。函数glDrawArraysInstanced()
的原理可以用如下代码解释。
// Loop over all of the instances (i.e., instancecount)
for (int n = 0; n < instancecount; n++) {
// Set the gl_InstanceID attribute - here gl_InstanceID is a C variable holding the location of the "virtual" gl_InstanceID input.
glVertexAttrib1i(gl_InstanceID, n);
// Now, when we call glDrawArrays, the gl_InstanceID variable in the shader will contain the index of the instance that’s being rendered.
glDrawArrays(mode, first, count);
}
同样的函数glDrawElementsInstanced()
的实现原理也可以用如下代码表示。
for (int n = 0; n < instancecount; n++) {
// Set the value of gl_InstanceID
glVertexAttrib1i(gl_InstanceID, n);
// Make a normal call to glDrawElements
glDrawElements(mode, count, type, indices);
}
当然,gl_InstanceID不是一个真正的顶点属性,不能通过函数glGetAttribLocation()
获取到它的位置。该属性的值由OpenGL负责管理,并且其具有硬件加速机制,这意味着它的使用基本不会增加性能负担。正是由于该变量的灵活使用以及举例数组,才使得举例渲染具有很强大的性能优势。
gl_InstanceID的值可以被直接用于着色器函数的参数,或者用作数组索引取获取纹理或者统一变量数组中的成员。回到草地的例子中,需要找到一个方法使用变量gl_InstanceID绘制出在不同位置生长的上千片草叶。每片草叶由6个顶点,4个三角形的三角形带组成。这里需要使用一点小技巧让它们看上去都不相同。另外,使用着色器魔法,我们能够让草地上的每一片草叶看上去都不同从而得到有意思的结果。这里不会展示着色器代码,只讨论如何使用gl_InstanceID为场景增加变化。
首先,必须为每一片草叶分配不同的位置。如果需要渲染的草叶数量是2的幂,那么可以将变量gl_InstanceID一半的二进制位用于表示x坐标,剩余的二进制位表示z坐标。在草地中,草地的平面为xz坐标平面,而y坐标表示其高度。在这个例子中,渲染2^20
片草叶(104,8576),使用0-9位表示x坐标,10-19位表示z坐标,此时可以得到一个由草叶组成的网格,其中的每片草叶都可以看做是另外一片草叶平移所得,其绘制效果如图。草地Demoyua。
上图效果看上去草叶分布过于规律,并不像生活中的草地。为了让草地效果更逼真,需要将每片草叶的位置做一些随机的微调整。生成随机数的一个简便方法是将一个随机数种子乘以一个大数,然后取其乘积二进制位的子集,将该结果作为随机函数输出值并将其用于下一次迭代中的随机数种子。这里并不需要一个完美的随机数生成器,该简单函数足够满足需求。另外通常在该类型算法中,需要在下一次迭代中重用上一次生成的随机数,但是在该示例中,每次迭代使用的随机种子直接指定为变量gl_InstanceID,并且每次生成两个连续的随机数分别用于表示x和z值。此时可以得到如下所示结果。
此时,尽管每片草叶的位置上面有了随机的改变,但是每片草叶的形态仍是完全一致的。实际上,使用了和生成随机位置偏移一样的随机数生成器处理颜色,以使得每片草叶的颜色有一定的差异。现在还需要为每片草叶的形态做出一些调整,以使得草地看上去更加真实。因此,在该实例中选用纹理来保存草叶的朝向和长度信息。使用纹理中的红色分量来表示长度值,将其和草叶的顶点坐标的y值相乘从而使得草叶变长或变短。长度为0时草叶消失,长度为1时草叶为其最长值。按照上述设想,只需要设计一张纹理,其中每个纹素包含对应坐标草叶的长度信息即可完善该方案。
接下来需要调整草叶绕y轴上的旋转角度,使用纹理中的绿色分量来保存角度信息,0表示不旋转,1表示旋转360度。此时仍用前文提到的随机数生成函数来初始化每个草叶的旋转角度。最后渲染结果如下。
此时草地的效果看上去仍然不是很真实,所有的草叶都笔直向上,并且都没有移动。真正的草地会随风摆动,并且当有物体从上面滚过时会被压倒。此时还需要让草叶弯曲。这里使用纹理的蓝色分量来表示弯曲因子。在使用绿色分量前先使用蓝色分量的数据时草叶绕x轴旋转(需要注意的是这里变化模型的参考坐标系都是世界坐标系)。仍然用0表示未弯曲,用1表示平躺在地上。通常,草叶只会轻微弯曲,因此该值一般较小。
最后,还需要控制草叶的颜色。逻辑上看上去只需要将颜色信息存储在一个大的纹理中即可。这种方式在绘制一个复杂的包含线条、记号以及广告等元素的运动场时是一个很好的想法,但是此处用于存储草的颜色确实是一种浪费。更聪明的方法是使用前文纹理中剩余的alpha通道数据和一个调色板来完成颜色存储需求。alpha通道中存储在颜色通道中的索引值,调色板中的颜色从枯草黄色逐渐过渡至翠绿色。应用上述操作后期渲染结果如下(这里调色板的1维纹理数据未在原著示例中找到,依然使用随机颜色)。
最后的草坪包含上百万片草叶,它们均匀分布,另外应用控制了他们的长度,朝向,弯曲度以及颜色。输入着色器的唯一变量为gl_InstanceID,它使得每一片草叶都不尽相同,发送给OpenGL的顶点数据总共只有6个顶点,该示例中的渲染命令仅仅包含一句代码glDrawArraysInstanced()
。
可以使用线性纹理采样的方式使得不同区域之间的草叶平滑过渡,但是该方式得到的只是低分辨率的纹理。如果想生成草叶随风摆动,以及军队行军经过的践踏感,只需在每次绘制前对纹理进行适当更新从而得到动画效果。当然,由于gl_InstanceID被用于生成随机数的随机种子,因此在将其传递至随机数生成器之前加上一个偏移值也能够出现类似的动画效果。
自动获取数据(Getting Your Data Automatically)
当使用绘制函数glDrawArraysInstanced()和glDrawElementsInstanced()
时,在着色器中就可以使用内置的变量gl_InstanceID,它表示当前处理的几何形在数组中的索引,并且每绘制一个实例,该值加1。当为使用举例绘制函数时,该值为0。
可以使用变量gl_InstanceID在和实例数组同等大小的数组中取值。事实上,此处可以假定任务着色器中包含一个举例属性(instanced attribute)。也就是说每当绘制完一个实例时,该属性的值将会被更新。OpenGL中将数据按这种方式读入的操作由举例数组特性支持。在使用举例数组时,在着色器中声明变量的方式同往常一样。其数据的读也和普通属性一样采用函数glVertexAttribPointer()
。通常,顶点属性的更新规则为每处理一个顶点,着色器中相应的属性会被更新一次。然而,为了让OpenGL在每绘制完成一个实例后再对属性进行更新,必须调用函数void glVertexAttribDivisor(GLuint index, GLuint divisor);
。
上述函数的参数index为属性的位置,参数divisor为属性每次更新的实例间隔。如果参数divisor为0,那么该属性的更新规则就是每个顶点操作更新一次,如果其为非0值,那么每处理完divisor设置数量的实例,对应属性就会更新一次。例如,当其值为1时,更新策略就是每个实例更新一次。一个运用该特性的实例是当需要为每个实例绘制不同的颜色时。
为了使每个实例具有不同的位置,添加属性instance_position,为了使每个实例有不同的颜色,添加属性instance_color,使用该特性的顶点着色器代码如下。
#version 430 core
in vec4 position;
in vec4 instance_color;
in vec4 instance_position;
out Fragment {
vec4 color;
} fragment;
uniform mat4 mvp;
void main() {
gl_Position = mvp * (position + instance_position);
fragment.color = instance_color;
}
现在着色器中包含了两个位置变量,它们的更新策略分别是按照每个顶点和每个实例更新。函数glVertexAttribDivisor
可以用于任意类型的属性,在一些高级的应用中甚至还能用于矩阵顶点属性,或者将转换矩阵包装到同一变量中,而使用举例数组存储矩阵权重因子。该特性能够用于渲染军队场景,其中每个士兵拥有不同的姿势,每艘星际战舰朝着不同的方向飞行。
片段着色器中的代码很简单,只需直接将输入颜色输出即可。接下来,需要声明数据并将数据填充到缓存中,然后将缓存绑定至顶点数组对象上。该部分代码如下。
static const GLfloat square_vertices[] =
{-1.0f, -1.0f, 0.0f, 1.0f, 1.0f, -1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 0.0f, 1.0f, -1.0f, 1.0f, 0.0f, 1.0f};
static const GLfloat instance_colors[] =
{1.0f, 0.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, 0.0f, 1.0f};
static const GLfloat instance_positions[] =
{-2.0f, -2.0f, 0.0f, 0.0f, 2.0f, -2.0f, 0.0f, 0.0f, 2.0f, 2.0f, 0.0f, 0.0f, -2.0f, 2.0f, 0.0f, 0.0f};
GLuint offset = 0;
glGenVertexArrays(1, &square_vao);
glGenBuffers(1, &square_vbo);
glBindVertexArray(square_vao);
glBindBuffer(GL_ARRAY_BUFFER, square_vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(square_vertices) + sizeof(instance_colors) + sizeof(instance_positions), NULL, GL_STATIC_DRAW);
glBufferSubData(GL_ARRAY_BUFFER, offset, sizeof(square_vertices), square_vertices);
offset += sizeof(square_vertices);
glBufferSubData(GL_ARRAY_BUFFER, offset, sizeof(instance_colors), instance_colors);
offset += sizeof(instance_colors); glBufferSubData(GL_ARRAY_BUFFER, offset,
sizeof(instance_positions), instance_positions); offset += sizeof(instance_positions);
glVertexAttribPointer(0, 4, GL_FLOAT, GL_FALSE, 0, 0);
glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, 0, (GLvoid *)sizeof(square_vertices));
glVertexAttribPointer(2, 4, GL_FLOAT, GL_FALSE, 0, (GLvoid *)(sizeof(square_vertices) + sizeof(instance_colors)));
glEnableVertexAttribArray(0);
glEnableVertexAttribArray(1);
glEnableVertexAttribArray(2);
接下来设置顶点属性的更新规则。
glVertexAttribDivisor(1, 1);
glVertexAttribDivisor(2, 1);
接下来绘制之前放入缓存重的4个几何形实例。每个实例的instance color和instance position相同,不同实例之间不同。绘制部分代码如下。
static const GLfloat black[] = { 0.0f, 0.0f, 0.0f, 0.0f };
glClearBufferfv(GL_COLOR, 0, black);
glUseProgram(instancingProg);
glBindVertexArray(square_vao);
glDrawArraysInstanced(GL_TRIANGLE_FAN, 0, 4, 4);
绘制结果如下图,此处仅仅处理了4个矩形。而对于GPU,它可以轻松的处理上千甚至上百外个实例而不会出现任何问题。Chapter 7/7.7-instancedattribs。
在使用举例顶点属性的时候,可以使用函数glDrawArraysInstancedBaseInstance()
中的参数baseInstance(OpenGL 4.2)来修正举例数组中数据的获取是使用的索引值,其原理和参数basevertex类似。当其为0时,读取数据的方式和不带此参数的绘制命令相同。其实际的索引计算公式为。在接下来的例子中会用到该特性。
2.2.3 间接绘制(Indirect Draws)(要求OpenGL 4.3)
到目前为止用到的绘制函数都是直接绘制,需要在函数参数中指定顶点或者实例的数量。此外,OpenGL还提供了一系列的绘制命令允许每次绘制的参数存在缓存对象中。这意味着在调用这些绘制函数时,不再需要制定相关参数,而只需指定参数所在的缓存对象的位置。这样还能为带来两个有趣的体验。
第一,应用可以在绘制之前就生成相关参数,甚至可以离线生成,然后将其载入OpenGL中用于渲染图形。
第二,能够在程序运行的时候生成渲染参数,并在着色器中将这些参数存至缓存对象中,用于之后的渲染操作。
OpenGL中包含4个间接绘图命令。前两个都有其对应的直接绘制命令版本。如glDrawArraysIndirect(GLenum mode, const void * indirect)对应glDrawArraysInstancedBaseInstance(),glDrawElementsIndirect(GLenum mode, GLenum type, const void * indirect)对应glDrawElementsInstancedBaseVertexBaseInstance()
。
对于上述两个函数,modes都表示图元类型,可选枚举变量有GL_TRIANGLES或者GL_PATCHES等。对于第二个函数,参数type是索引的数据格式,如GL_UNSIGNED_INT等。两个函数中的参数indirect都表示数据在绑定至GL_DRAW_INDIRECT_BUFFRT目标的缓存对象中的偏移值。该缓存对象中的数据在两个结构中并不相同。使用c语言结构体来解释其中的数据格式如下。
// 函数glDrawArraysIndirect()中使用的数据格式
typedef struct {
GLuint vertexCount;
GLuint instanceCount;
GLuint firstVertex;
GLuint baseInstance;
} DrawArraysIndirectCommand;
// 函数glDrawElementsIndirect()中使用的数据格式
typedef struct {
GLuint vertexCount;
GLuint instanceCount;
GLuint firstIndex;
GLint baseVertex;
GLuint baseInstance;
} DrawElementsIndirectCommand;
上述简介函数的调用和调用其对应的直接绘制函数类似,不同的是第二个函数中的firstindex单位是索引个数,而在使用直接绘制函数glDrawElements()
时,其中的参数indexes是以字节为单位的,因此这些需要特别留心单位的转化。上述两个函数看上去已经很便利,但是真正使得该特性强大的函数是他们的扩展版本。
void glMultiDrawArraysIndirect(GLenum mode, const void * indirect, GLsizei drawcount, GLsizei stride);
void glMultiDrawElementsIndirect(GLenum mode, GLenum type, const void * indirect, GLsizei drawcount, GLsizei stride);
上述两个函数用于处理一组绘制命令,本质上它们是在一个绘制命令数组中多次执行了它们一般形式的绘制函数。参数drawcount制定了数组中绘制命令结构体的数量,参数stride制定了每个结构体首地址之间的内存间隔,以字节为单位,如果该值为0,那么绘制命令结构体为紧密包装类型。
上述函数每一次能处理的绘制命令集数量由可存储该命令集的内存空间决定。参数drawcount的大小可以高达上百万,但是当每个绘制命令通常占用16或者20字节,而需要绘制上百万次时,此时总共需要200亿字节的可用内存空间,并且会花上几秒甚至几分钟来完成渲染操作。但是通过一个缓存一次处理上万条绘制命令仍然是非常合理的。在使用该特性时,可以预加载包含绘制命令参数数据的缓存对象,或者也可以直接使用在GPU上生成绘制命令的参数。当再GPU上直接生成绘制命令的时候,在调用间接绘制命令之前不用关心这些相关绘制参数是否准备就绪,另外参数数据也不会从GPU传递到应用中再传递回去,这些数据一直保存在GPU中。
函数glMultiDrawArraysIndirect()
的使用简单实例如下。
typedef struct {
GLuint vertexCount;
GLuint instanceCount;
GLuint firstVertex;
GLuint baseInstance;
} DrawArraysIndirectCommand;
DrawArraysIndirectCommand draws[] =
{{42, 1, 0, 0}, {192, 1, 327, 0}, {99, 1, 901, 0}};
// Put "draws[]" into a buffer object
GLuint buffer;
glGenBuffers(1, &buffer);
glBindBuffer(GL_DRAW_INDIRECT_BUFFER, buffer);
glBufferData(GL_DRAW_INDIRECT_BUFFER, sizeof(draws), draws, GL_STATIC_DRAW);
// This will produce 3 draws (the number of elements in draws[]), each
// drawing disjoint pieces of the bound vertex arrays
glMultiDrawArraysIndirect(GL_TRIANGLES, NULL, sizeof(draws) / sizeof(draws[0]), 0);
仅仅打包三条绘制命令并不能体现出该特性的强大,为了充分展示其强大的性能,这里将绘制一个小行星带,其中包含3万个小行星。这里小行星带的网格数据仍然存储在原著定义的模型文件中。通过加载该模型文件将示例中的所有模型顶点数据加载到缓存对象中,并管理安置顶点数组对象。每个子对象都包含一个开始顶点以及描述该子对象的顶点个数。使用原书的方法get_sub_object_info()能够获取这些信息,当然此处本文将会重写该方法,在Objective-c的环境中实现。get_sub_object_count()可以获取子对象的个数。因此可以以间接绘制的方式完成模型渲染,其设置绘制命令缓存代码如下。
object.load("media/objects/asteroids.sbm");
glGenBuffers(1, &indirect_draw_buffer);
glBindBuffer(GL_DRAW_INDIRECT_BUFFER, indirect_draw_buffer);
glBufferData(GL_DRAW_INDIRECT_BUFFER, NUM_DRAWS * sizeof(DrawArraysIndirectCommand), NULL, GL_STATIC_DRAW);
DrawArraysIndirectCommand * cmd = (DrawArraysIndirectCommand *) glMapBufferRange(GL_DRAW_INDIRECT_BUFFER, 0, NUM_DRAWS * sizeof(DrawArraysIndirectCommand), GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT);
for (i = 0; i < NUM_DRAWS; i++) {
object.get_sub_object_info(i % object.get_sub_object_count(), cmd[i].first, cmd[i].count);
cmd[i].primCount = 1;
cmd[i].baseInstance = i;
}
glUnmapBuffer(GL_DRAW_INDIRECT_BUFFER);
接下来,需要在着色器中获取到当前绘制小行星的索引值,OpenGL并没有提供现成的数据通信方式可以实现该需求。但是,多重间接绘制可以看做值举例间接绘制,因此可以通过使用举例数组作为着色器的属性,从而将索引值传入到着色器中。因此需要为间接绘制命令设置baseInstance为当前索引值以保证着色器能够正确的从举例属性数组中正确的取值。其顶点着色器中输入变量的声明如下。
#version 430 core
layout (location = 0) in vec4 position;
layout (location = 1) in vec3 normal;
layout (location = 10) in uint draw_id;
变量position和normal的获取和之前顶点数组注入数据相同。这里需要单独将draw_id的数据存储到一个缓存中,并将其绑定至当前GPU上下文中。其代码如下。
glBindVertexArray(object.get_vao());
glGenBuffers(1, &draw_index_buffer);
glBindBuffer(GL_ARRAY_BUFFER, draw_index_buffer);
glBufferData(GL_ARRAY_BUFFER, NUM_DRAWS * sizeof(GLuint), NULL, GL_STATIC_DRAW);
GLuint * draw_index = (GLuint *)glMapBufferRange(GL_ARRAY_BUFFER, 0, NUM_DRAWS * sizeof(GLuint), GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_BUFFER_BIT);
for (i = 0; i < NUM_DRAWS; i++) {
draw_index[i] = i;
}
glUnmapBuffer(GL_ARRAY_BUFFER);
glVertexAttribIPointer(10, 1, GL_UNSIGNED_INT, 0, NULL);
glVertexAttribDivisor(10, 1);
glEnableVertexAttribArray(10);
着色器中得到当前绘制图形的索引值后,变可以计算每个小行星的位置,并且规律的分布它们。包含详细计算代码的着色器代码此处省略,详见示例程序。其中使用了光照模式以增强立体感,该特性后面会再次讲到。
渲染部分的代码很简单,为了对比使用多重绘制和不使用多重绘制命令,这里通过宏定义定义了两个版本的代码。通常不使用多重绘制命令耗时更长。
glBindVertexArray(object.get_vao());
if (mode == MODE_MULTIDRAW) {
glMultiDrawArraysIndirect(GL_TRIANGLES, NULL, NUM_DRAWS, 0);
} else if (mode == MODE_SEPARATE_DRAWS) {
for (j = 0; j < NUM_DRAWS; j++) {
GLuint first, count;
object.get_sub_object_info(j % object.get_sub_object_count(), first, count);
glDrawArraysInstancedBaseInstance(GL_TRIANGLES, first, count, 1, j);
}
}
最后绘制出的效果如下图。Chapter 7/7..9-multidrawindirect。
在原书中的例子,使用的GPU能够以每秒60帧的性能绘制3万个(实际上Demo中绘制了5万个)不同的模型,也就是说每秒处理了180万条绘制命令。每个模型有接近500个顶点,也就是说每秒渲染的顶点数高达10亿个。
灵活的使用draw_id而不是顶点属性,能够渲染出有着更多复杂变形的几何体。例如,可以使用纹理映射来处理物体表面细节,将不同的表面存储在一个纹理数组中,再通过draw_id选取其中固定的某一层。同样的没有理由规定存储间接命令的缓存对象必须是静态的,实际上,可以受用很多技术直接在GPU上生成这些绘制命令,他们能够真正的实现动态渲染而不需要程序的介入。
2.3 存储变换后顶点(Storing Transform Vertices)
OpenGL中允许将顶点、曲面细分评价或者几何着色器的结果存储至一个或者多个缓存中。该特性被称为转换反馈(transform feedback),程序中在着色器管道的前端末尾使用该特性非常高效。尽管该特性在OpenGL图形处理管道中是一个不可编程,固定的阶段,但是它仍然可以高效的装配。当使用转换反馈后,当前着色器管道的前端最后一个着色器会输出一组特定的属性,并将其写入到一组缓存中。
当几何着色器不存在时,顶点或者曲面细分评估着色器处理的顶点结果将被记录。当几何着色器存在时,函数EmitVertex()
生成的顶点数据将会被存储,记录的数据量取决于着色器的中的代码行为。用于存储上述数据的缓存被称为转换反馈缓存。转换反馈类型的缓存中的数据可以通过两种方式读取,使用函数glGetBufferSubData()
获取数据,或者直接使用函数glMapBuffer()
获取数据在内存中的地址。它们也可以用做接下来的绘制命令的数据源。该部分剩余的内容都将围绕顶点着色器作为管线前段最后阶段来展开。但须注意这不是唯一的情形。
2.3.1 使用变换反馈(Using Transform Feedback)
建立转换反馈之前,必须确定图形管道前端部分哪些输出结果需要被记录。函数原型为。
void glTransformFeedbackVaryings(GLuint program, GLsizei count, const GLchar * const * varying, GLenum bufferMode);
参数program为程序对象的名字,转换反馈的状态有程序保存。这意味着在不同的程序中,尽管使用了相同的着色器,但是它们仍然能够记录不同的顶点属性集合。参数count为需要记录的属性个数。参数varying是由c语言字符串组成的数组,其大小必须和count匹配,这些字符串指定了需要记录的属性,它们和顶点着色器中的属性标识符一致。参数buffermode变量记录的模式,可选GL_SEPARATE_ATTRIBS和GL_INTERLEAVED_ATTRIBS。如果选interleaved,每个变量依次记录在单个缓存中,反之它们都会记录在各自的缓存中。
对于具有以下声明的如下顶点着色器。
out vec4 vs_position_out;
out vec4 vs_color_out;
out vec3 vs_normal_out;
out vec3 vs_binormal_out;
out vec3 vs_tangent_out;
为了将上述输出变量存储在一个交错存储转换反馈缓存中,需要在程序中使用如下C语言代码。
static const char * varying_names[] = {
"vs_position_out",
"vs_color_out",
"vs_normal_out",
"vs_binormal_out",
"vs_tangent_out"
};
const int num_varyings = sizeof(varying_names) / sizeof(varying_names[0]);
glTransformFeedbackVaryings(program, num_varyings, varying_names, GL_INTERLEAVED_ATTRIBS);
并非从顶点(或者几何)着色器中输出的所有变量都需要存储到转换反馈缓存中。可以存储输出变量的子集到转换反馈缓存中,同时将更多的数据输入到片段着色器中用于插值计算。同样的,也可以存储部分顶点着色器的数据到转换反馈缓存中使得片段着色器不会获得这些数据。基于这个特性,顶点着色器中不活跃部分的输出(不会被片段着色器使用)因为被存储至转换反馈缓存中再次变得活跃。调用函数glTransformFeedbackVaryings()
指定要存储的输出子集后需要调用函数glLinkProgram(program);
重新链接程序。
当改变转换反馈捕获的子集时,尽管有时这些改变并不会产生任何影响,但是仍然有必要调用函数再次链接程序。一旦转换反馈变量被指定,并且程序被成功链接,它们就可以被正常使用。在真正捕获转换反馈变量之前,需要创建一个缓存并且将其绑定至一个带索引的转换反馈绑定点之上。在此之前还必须为该缓存分配内存空间。代码如下。
GLuint buffer;
glGenBuffers(1, &buffer);
glBindBuffer(GL_TARNSFORM_FEEDBACK_BUFFER, buffer);
glBufferData(GL_TRANSFORM_FEEDBACK_BUFFER, size, NULL, GL_DYNAMIC_COPY);
上述代码中的参数GL_DYNAMIC_COPY中DYNAMIC表示缓存中的数据经常更新,每次更新会使用多次,COPY表示数据又OpenGL更新,更新后的数据由OpenGL使用,用于类似于绘制等功能。
反馈缓存绑定点有多个,但他们都和一个统一缓存绑定点相关,下图描述了该关系结构图。
绑定反馈缓存调用函数glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, index, buffer);
。参数GL_..._BUFFER表示缓存的用途,参数buffer为存储转换反馈结果的缓存名字,参数index为转换反馈缓存绑定点的索引值。这里需要注意的是调用该函数后不能再向其缓存内读写数据或者分配内存空间。但是,在将顶点缓存绑定至带索引的绑定点同时,OpenGL也将其绑定至通用顶点缓存绑定点,只要在调用该函数后获得通用缓存顶点仍能为缓存分配内存空间。
绑定反馈缓存更高级的函数为void glBindBufferRange(GLenum target, GLuint index, GLuint buffer, GLintptr offset, GLsizeiptr size);
,该函数允许将缓存的一部分绑定至某个绑定点,而通常的函数glBindBuffer()、glBindBufferBase()
只能将整个缓存绑定至单个索引。该函数前三个参数的含义和函数glBindBufferBase()
相同。参数offset和zise分别表示需要绑定的缓存内存地址的开始位置和大小。使用该函数可以将输出顶点属性以GL_SEPARATE_ATTRIBS
模式分别存储在单个缓存的不同区域中。如果应用将所有的属性打包在一个顶点缓存中,并且指定的偏移量为0,这样就可以很轻易的将转换反馈的输出结果和顶点着色器的输入相匹配。
如果转换反馈属性输出结果的存储方式为GL_INTERLEAVED_ATTRIBS,那么数据将会以紧密包装的形式写入到0号转换反馈绑定点(索引值为0)关联的缓存中。如果存储方式为GL_SEPARATE_ATTRIBS,那么顶点着色器的每一个转换反馈属性都会被保存在它们自己的缓存中(或者一个缓存的不同分区)。GPU能支持的最大转换反馈绑定点个数可以通过函数glGetIntegerv()
和参数GL_MAX _TRANSFORM _FEEDBACK _SEPARATE _ATTRIBS获得。
在GL_INTERLEAVED_ATTRIBS模式下并没有固定的转换反馈变量最大数量,例如单个缓存中写入vec3类型的转换反馈变量的数量可以比vec4类型的变量。但是单个缓存中能够支持的成员数量是有限制的。同样的使用函数glGetIntegerv()
和参数GL_ MAX_ TRANSFORM_ FEEDBACK_ INTERLEAVED_ COMPONENTS可以获得。
如果需要可以在转换反馈缓存存储的输出数据结构的各个成员之间留出内存间距。当使用该方式保存数据时,内存间距指向的内存空间将会被直接跳过,不做任何改变。要实现这个特性,需要在着色器的声明和C语言定义的结构体中都包含以下4个虚拟变量的其中一个,gl_SkipComponents1, gl_SkipComponents2, gl_SkipComponents3, or gl_SkipComponents4
,此处被跳过的内存空间由这些虚拟变量在着色器中定义的数据类型确定。
OpenGL还允许将一个子集交错的保存在一个缓存中,剩余的子集保存在另外一个缓存中。要开启这个特性,需要使用虚拟变量gl_NextBuffer
,它表示函数glTransformFeedbackVaryings()
在读存储下一个变量时将会移至下一个绑定点。但是需要注意的是,此时只能使用参数GL_INTERLEAVED_ATTRIBS
以交错的方式保存数据。示例如下。
static const char * varying_names[] = {
"carrots",
"peas",
"gl_NextBuffer",
"beans",
"potatoes"
};
const int num_varyings = sizeof(varying_names) / sizeof(varying_names[0]);
glTransformFeedbackVaryings(program,
num_varyings,
varying_names,
GL_INTERLEAVED_ATTRIBS);
2.3.2 开始、暂停和停止转换反馈(Starting, Pausing, and Stopping Transform Feedback)
当设置缓存变量、类型,准备缓存对象等一系列准备工作完成后,调用函数glBeginTransformFeedback(GLenum primitiveMode);
可以激活转换反馈模式。此时,管线前端最后一个着色器处理完成后的顶点数据将会被存储到顶点转换反馈缓存中。参数primitiveMode表示几何体的类型,可选值有GL_POINTS, GL_LINES, 和GL_TRIANGLES
。当调用函数glDrawArrays()
或其他OpenGL绘图命令时,基础的几何体类型必须和转换反馈缓存中的几何体类型一致,或者必须包含一个输出正确的的几何体类型的几何着色器。例如,如果参数primitiveMode为GL_TRIANGLES,管道前端的最后一个阶段必须输出三角形。这就意味着当开启几何着色器后,其输出的图元必须是triangle_strip,如果包含曲面细分评估着色器并且没有几何着色器时,输出模式也必须为三角形,如果两者都不包含时调用绘制函数时必须指定GL_TRIANGLES, GL_TRIANGLE_STRIP 或者 GL_TRIANGLE_FAN
。
另外GL_PATCHES也能用于绘制命令的参数mode,只要曲面细分评估着色器或者几何着色器输出了正确的图元类型。当转换反馈模式激活后,临时暂停该功能可以调用函数glPauseTransformFeedback()
,重启该功能可以调用函数glResumeTransformFeedback()
,此时OpenGL将从Buffer中上次暂停时的位置继续记录,只要转换反馈功能未暂停,OpenGL会持续记录转换反馈输出的数据,直到退出转换反馈或者缓存空间耗尽。退出转换反馈调用函数glEndTransformFeedback();
。
每次当函数glBeginTransformFeedback()
调用,OpenGL会从当前绑定的转换反馈缓存的起始位置写入数据,这里可能会出现重写现象。需要注意的是当转换反馈特性处于激活状态时,某些操作是不被允许的,无论是否被暂停。例如改变缓存的绑定或者重新分配缓存的内存空间。
2.3.3 使用转换反馈结束管道(Ending the Pipeline with Transform Feedback)
在使用转换反馈特性的应用中,更多的是记录转换反馈阶段生成的结果,并不需要真正的绘制任何东西。由于光栅化阶段在图形处理管道中位于转换反馈阶段之后,因此可以通过调用函数glEnable(GL_RASTERIZER_DISCARD);
来关闭光栅化阶段及其后续阶段。该函数调用后,转换反馈执行后,OpenGL将不会继续处理图元数据。调用函数glDisable(GL _RASTERIZER _DISCARD);
可以重新开启光栅化阶段。
2.3.4 转换反馈示例-物理模拟(Transform Feedback Example -- Physical Simulation)
在弹簧质点(springmass)模型中,将会建立一个弹簧和质量的物理模拟。每个代表单位质量的顶点都和最大4个相邻顶点咦弹性绳相连。除了一个常规的属性数组,该示例中还使用一个纹理缓存对象(TBO)持有顶点位置数据。同一个缓存与TBO和为顶点着色器提供位置输入的顶点属性相关联。这样就可以随意的获取其他顶点的当前位置。同时使用一个整形顶点属性来持有相邻顶点的索引值。此外,还使用转换反馈来存储每次迭代算法中的每个质点的位置和加速度。
对于每个顶点,需要一个位置,加速度和质量。可以将位置和质量打包进入一个顶点数组中,将加速度放入另外一个数组中。位置数组的每一个元素都是一个vec4变量,其中x、y、z分量保存了顶点的三维坐标,w分量保存了顶点的重量。加速度数组的每个元素为vec3类型变量。另外,使用一个ivec4的数组来保存关于将质点连接在一起的弹簧信息。每个顶点都包含1个ivec4变量,向量的4个分量分别表示连接顶点弹簧另外一端的顶点,该向量被称为连接向量。当对应方向没有连接时,对应的分量值为-1。该示例描述的模型图如下所示。
连接向量中各个方向的质点连接顺序咦顶点12为例可以描述为<11,7,13,17>。顶点14的链接向量可以描述为<13,9,-1,19>。通过将连接向量的4个分量都设置为-1,可以固定一个顶点,此时该顶点对应的位置和加速度计算都会被跳过,同时将与该顶点相关联的力设置为0。相应的对每个顶点的初始化代码此处省略,具体请参考示例源码555。
为了更新整个系统,使用一个顶点着色器通过常规的顶点属性获取每个顶点自己的位置和连接向量。借下来使用连接向量(同时也是一个常规的顶点属性)中的元素作为在TBO中的索引值,从而获得其当前连接顶点的当前位置。TBO的初始化请参照示例源码555。
对于每一个连接顶点,着色器能够计算出它们之间的距离,这样就能计算出他们之间虚拟弹簧的张力。基于此,可以计算出通过弹簧施加在质点上的力,结合质量计算出该张力产生加速度,并且计算出下一次迭代中使用的新位置和加速度向量。这只是牛顿物理学和胡克定律。胡克定律的公式如下。
在该公式中F为弹簧的张力,k是弹性系数,x是弹簧形变长度。在该示例中,弹簧的放松长度被设置为一个常量并存放在一个Uniform类型变量中。x有正负,正值表拉伸,负值表示压缩。物理上的力是一个向量,此处力的表示方法如下,其中d为沿着弹簧方向的标准向量。
如果简单的将这个力直接施加在质点上,系统将会震荡,并且由于数值上的误差,系统最终将会变得不稳定。现实生活中的弹簧系统都会由于摩擦力产生一定的损失,为了模拟这个特性可以将阻尼考虑到力的方程中。阻尼引起的力可以由以下方程表示。
其中c表示阻尼系数。理论情况下,可以计算出每一条弹簧的阻力,在这个简单系统中,基于质点速度的力可以完成该任务。同样的,在每个时间阶段使用初始速度来估计这个等式所需要的持续的差异。在着色器中,通过将阻力和弹力相加计算出合力F。最后再将重力带入等式中既可以得到每个质量的最终合力可以表示为如下等式。需要注意的是合力的计算方式应该是所有向量力的和,弹簧力由于原书中使用的是作用点到施力点的标准向量和形变距离的负数,因此需要添加负号,而阻尼力又和速度的方向相反,因此也需要取负。
得到合力后,根据牛顿定律可以很快的计算出每个质点的加速度。可以描述为如下等式。
这里,F为上一个等式所计算出的合力,m是顶点的质量(存储在位置属性的w分量中),a是计算出的加速度。将初始加速度放到下面的等式中便可以计算出在确定时间的速度和位移。
此处u是初始速度(从速度属性数组中获取),v是最终速度,t是时间,s为位移。需要记住的是,这些变量都是向量。顶点着色器的源码请参照Chapter 7/7.13-springmass。
执行该着色器后,应用会迭代更新缓存对象中的顶点数据。此时需要使用两个缓存对象来保存顶点的位置和速度信息,我们从一个buffer中读取数据,并将新的数据写入另外一个缓存中,在下一次迭代时交换两个缓存对象的角色来实现数据的更新。作为一个常量,每一次的连接信息都一致。可以通过之前设置好的VAO数组来实现该功能。第一个VAO对象有一个位置和速度属性集合,以及相同的连接信息。第二个VAO对象包含另外一组位置和速度属性集合,以及相同的连接信息。
除了VBO数组,我们还需要TBO数组。对于位置顶点缓存对象VBO,同时我们将其关联至纹理缓存对象TBO。这也许看上去非常奇怪,但是在OpenGL的语法中,这是合法的。我们可以通过两个不同的方法从同一个缓存中读取数据。为了完成上述目标,生成两个纹理并将他们绑定至GL_TEXTURE_BUFFER绑定点,并使用前文讲到的glTexBuffer函数将缓存和纹理关联。此时顶点位置属性和samplerBuffer类型变量tex_Position中会有相同的数据。
应用固定了部分顶点因此整个系统并不会全部坠落到屏幕底部。一旦我们挂载了所有缓存,只需调用函数glDrawArrays()就能模拟系统自由下落的物理现象。系统中的每个节点都有一个GL_POINTS图元表示。系统初始化后可以得到以下结果。
在每一帧,我们都会运行物理模拟多次,每一次迭代都会交换VAO数组和TBO数组。迭代循环的代码请参照Chapter 7/7.13-springmass。每一次迭代所有节点的位置和速度信息都会被更新一次。通过减少时间梯度可以让整个系统的模拟变得更加流畅,从而得到更好的视觉效果。
在迭代时,禁用光栅化功能,使得数据经历了转换反馈阶段后不会再沿着图形处理管道继续流动。在迭代完成后重新启用光栅化功能使图形被渲染到屏幕上。在经历足够多的迭代后,我们能够以我们希望的方式绘制出所有的顶点。使用一个简单的程序来渲染图形,将系统中所有节点以点的方式绘制,他们之间的连接以线的方式绘制。源码请参照Chapter 7/7.13-springmass。绘制结果参照上图。
在绘制出点后,通过咦GL_LINES图元类型和有索引绘制函数glDrawElements绘制出节点之间的连接线使物理模拟更加逼真。第二次绘制时可以使用相同的顶点位置,但是我们需要构建另外一个绑定至GL_ELEMENT_ARRAY的缓存对象,其中必须包含每个弹簧两端的顶点索引值。额外的操作和源代码请参照Chapter 7/7.13-springmass。最终的绘制结果如下图。
当然物理模拟(以及其生成的顶点)可以被用于任何场景。例如,尽管还非常基础,该技术可以用于模拟衣服的自然下坠。该系统并不能处理内部节点的相互作用(self-interaction),但是这对于现实的衣服模拟并不重要。然而很多系统内部的粒子相互作用都是通过一种特定的方式,该规律可以通过单个顶点着色器和转换反馈模拟并建模。
2.4 剪切(Clipping)
正如在章节跟随管线“Following the Pipeline”中提到的,剪切阶段确定了哪些图元能够完全显示或者部分显示,并且用它们构建新的图元以完整展示在整个视口内。
点图元的裁剪逻辑很简单,如果该店的坐标位于可视范围内就进入下一阶段,反之则被丢弃。线图元的剪切稍复杂一点,如果线的两个端点都位于裁剪空间的同一个平面外(如两个点的x分量都小于-1.0)(B),该线图元直接被丢弃。如果线的两个顶点都位于裁剪空间内,那么该图元会被进一步处理(A)。如果两个端点一个位于剪裁空间内部,一个位于剪裁空间外部(C),或者这个线有一部分在剪裁空间内,那么该图元将会被裁剪(D),裁减掉超出剪裁空间的部分,形成一条新的更短线。剪裁逻辑示意如下。E是一个特殊案例,在确定被丢弃之前可能会经过剪裁,涉及到剪裁数学逻辑,不展开。
三角形的裁剪问题看上去更加复杂,但实际上使用了相同的方式。和点类似,如果三角形的三个顶点全部在剪裁空间外将会被丢弃(B),如果全部在空间内将会被直接发送到下一个处理流程(A)。如果三个顶点在剪裁空间内外都有分布,那么它会被裁剪为多个三角形。下图用两维空间示意,但是需要知道实际上剪裁空间是一个三维的模型。
防护带(The Guard Band)
正如上图所示,三角形被剪切后会分裂为多个小三角形,这会给以固定速率处理三角形的GPU带来问题。在某些情况下,直接将这些三角形传入下一个阶段,让光栅化器丢弃不可见部分会使应用运行更快,提升性能。为了达到这个目的,一些GPU带有防护带特性,它是位于剪裁空间外的区域,该空间内的三角形尽管不可见但是仍不会被裁剪,直接进入下一步处理流程。防护带示意图如下。
防护带的存在并不会影响全部保留(A)和全部剔除(B)的三角形,它们们仍按之前的逻辑被处理。另外当三角形超出了剪裁空间时,当其未超出防护带时会被发送至下一阶段(C/D),反之仍会被裁剪(E)。
实际上防护带的宽度(内外矩形之间的间距)非常大,几乎和视口空间一样大,只有绘制非常大的矩形时才会超出其边界。这些特性尽管不能够以可视化方式呈现,但是其能够提升程序的性能。
2.4.1 用户定义的剪切(User-Defined Clipping)
点位于平面哪一侧的可以通过计算带点到平面的有向距离确定,其值表示点到平面的距离,符号表示其位于哪一侧。OpenGL不一定采用这种方式,但是在自己的代码中可以使用这个算法。
除了到视图截头椎体六个表面的六个距离外,应用中还可以使用另外一组距离,他们可以在顶点或者几何着色器中设置。顶点着色器中可以通过内置变量gl_ClipDistance[]来设置裁剪距离,该变量为一个浮点型的数组。正如本章之前讲到的gl_ClipDistance[]是gl_PerVertex闭包的成员,它能够在最后一个着色器是顶点、曲面细分评估或者几何着色器的时候设置。剪切距离能够支持的个数取决于OpenGL的具体实现方式。这些距离可以看做是内置的剪切距离。在应用中调用函数glEnable(GL_CLIP_DISTANCE0 + n);
可以启用用户自定义剪切距离功能。
这里这需要开启的剪切距离索引值,他们可以在标准OpenGL头文件中找到。最大值可以通过函数glGetIntegerv(GL_MAX_CLIP_DISTANCES)
获得。同时可以调用函数glDisable()
以及相同参数来关闭该功能。如果某个索引值的剪切距离没有被启用,那么使用数组gl_ClipDistance[]
写入值时将会自动被忽略。
正如内置的剪切平面一样,写入数组gl_ClipDistance[]
中的距离符号用于决定该顶点是位于用户定义的裁剪空间内部还是外部。如果单个三角形图元的每个顶点的符号都为负,那么该三角形将会被裁剪。如果部分位于三角形外,部分位于三角形内,那么OpenGL会对三角形内的每一个像素进行距离的线性插值运算以决定它们是否可见。该功能使得用户可以沿着任意平面集合裁剪集合图形(点到平面的距离可以通过点乘获得)。
数组gl_ClipDistance[]
中作为片段着色器的输入,在片段着色器内部也是可用的。任意片段只要在该数组中存在一个值为负,那么它将被裁剪掉,不会进入到片段着色器。但是当所有值为正时该片段能正常到达片段着色器,此时可以读取对应的gl_ClipDistance[]
值。在示例程序中基于片段的裁剪距离接近0的程度减少其alpha值从而使用该功能来隐藏片段。该特性使得通过顶点着色器沿着一个平面裁剪的大图元以平滑的方式隐藏,或者在片段着色器中对它实现抗锯齿效果而不会生成一个非常明显的剪切边。
需要注意的是如果一个图元的所有顶点沿着一个同一个平面被裁剪掉,那么整个图元都会被清除。但是在处理点图元和线图元的时候需要特别小心。在绘制点图元的时候可以通过变量gl_PointSize设置大于1的值,这时当顶点的中心位于可视范围外时,尽管加大后的顶点部分位于可视范围内,但是整个顶点仍然会被裁剪掉。同样的在绘制线图元时可以设置线的宽度,它的处理逻辑和点图元相同。
下面代码展示了顶点着色器如何写入两个裁剪距离。第一个裁剪距离为物体空间的顶点到一个四维向量定义的片面clip_plane。第二个裁剪距离为每个顶点到球的距离。首先获取从物体空间中顶点到球体中心的向量长度,再将其减去球体的半径(存储在clip_sphere的w分量中)。
#version 410 core
// More uniforms here
// Clip plane
uniform vec4 clip_plane = vec4(1.0, 1.0, 0.0, 0.85);
uniform vec4 clip_sphere = vec4(0.0, 0.0, 0.0, 4.0);
void main() {
// Lighting code goes here
// Write clip distances
gl_ClipDistance[0] = dot(position, clip_plane);
gl_ClipDistance[1] = length(position.xyz / position.w - clip_sphere.xyz) - clip_sphere.w;
// Calculate the clip-space position of each vertex
gl_Position = proj_matrix * P;
}
裁剪结果如下图所示,可以看见模型沿着屏幕和球体被裁剪,源代码见Chapter 7-17 Clipdistance。
2.5 总结(Summary)
本章包含了OpenGL从应用提供的缓存中读取顶点数据的部分细节,以及如何匹配顶点着色器的输入顶点数据以及应用中输入的顶点数据。同时也讨论了顶点着色器的职责以及他能写入的内部输出变量。顶点着色器不仅能设置它所产生的顶点的位置,还能设置他渲染的顶点大小,甚至能控制剪切过程使得用户可以根据任意形状裁剪模型。
OpenGL提供转换反馈功能,它的强大功能使得顶点着色器可以将任意数据存储在缓存中。本章介绍了OpenGL如何沿着窗口的可见区域裁剪图元,以及图元从一个裁剪空间中的应用过度到多个裁剪空间的应用。下一章将介绍图形处理管道的前端终端曲面细分和几何着色器。