OpenGL 数据处理(上)

在OpenGL中,大量的数据在着色器中传递,数据通过Buffer和Texture两种形式组织。

1 缓存(Buffer)

OpenGL中Buffer为一段连续的内存空间,用names标识,在使用buffer时首先调用函数保存某个GLuint类型的name,将其绑定到GL的上下文,然后再为它分配内存空间,最后输入数据。

1.1 缓存生成以及内存空间分配

将缓存绑定至GL上下文时,需根据不同的缓存使用场景时指定缓存绑定目标。分配内存的GL函数如下;

void glBufferData(GLenum target, 
                  GLsizeiptr size,
                  const GLvoid * data, 
                  GLenum usage);

target参数指定了缓存区类型(The target parameter tells OpenGL which target the buffer you want to allocate storage for is bound to),只能根据使用场景选用OpenGL提供的枚举值。当存储顶点数据用于输入顶点着色器时,target可选用GL_ARRAY_BUFFER(但是OpenGL并不会真正的指定缓存的类型,该buffer后期可以指定其他类型target)。data为需要绑定数据的的首地址,当不需要立即缓存数据时可以指定NULL,usage指定了buffer的用途。

Buffer的使用枚举类型如下:
GL_STREAM_DRAW:代码输入,用于绘制。设置一次,并且很少使用。
GL_STREAM_READ:接受OpenGL输出,用于绘制。设置一次,并且很少使用。
GL_STREAM_COPY:接受OpenGL输出,用于绘制或者用于拷贝至图片。设置一次,很少使用。
GL_STATIC_DRAW:代码输入,用于绘制或者拷贝至图片。设置一次,经常使用。
GL_STATIC_READ:接受OpenGL输出,用于绘制。设置一次,代码经常查询。
GL_STATIC_COPY:接受OpenGL输出,用于绘制或者用于拷贝至图片。设置一次,经常使用。
GL_DYNAMIC_DRAW:代码经常更新其内容,用于绘制或者用于拷贝至图片,使用频率高。
GL_DYNAMIC_READ:OpenGL输出经常更新其内容,代码经常查询。
GL_DYNAMIC_COPY:OpenGL输出经常更新其内容,用于绘制或者用于拷贝至图片,使用频率高。

生成缓存的代码如下:

// The type used for names in OpenGL is GLuint
GLuint buffer;
// Generate a name for the buffer, 此处第一个参数为生成缓存个数,可以一次生成多个缓存
glGenBuffers(1, &buffer);
// Now bind it to the context using the GL_ARRAY_BUFFER binding point
glBindBuffer(GL_ARRAY_BUFFER, buffer);
// Specify the amount of storage we want to use for the buffer,size单位为Byte,此处分配1MB空间
glBufferData(GL_ARRAY_BUFFER, 1024 * 1024, NULL, GL_STATIC_DRAW);

为分配好内存空间的缓存绑定数据三种方式,第一种是分配内存时同时指定数据。第二种代码如下,此处并未指定buffer的标识name,因为bindbuffer可以指定当前上下文活跃缓存,OpenGL默认为当前上下文中活跃的缓存输入数据。

// This is the data that we will place into the buffer object
static const float data[] = {
         0.25, -0.25, 0.5, 1.0,
        -0.25, -0.25, 0.5, 1.0,
         0.25,  0.25, 0.5, 1.0
};
// Put the data into the buffer at offset zero
glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(data), data);

第三种为缓存输入数据的方法如下,同理,此处并未指定buffer的标识name,因为bindbuffer可以指定当前上下文活跃缓存,OpenGL默认为当前上下文中活跃的缓存输入数据。

// This is the data that we will place into the buffer object
static const float data[] = {
         0.25, -0.25, 0.5, 1.0,
        -0.25, -0.25, 0.5, 1.0,
         0.25,  0.25, 0.5, 1.0
};
// Get a pointer to the buffer’s data store
void * ptr = glMapBuffer(GL_ARRAY_BUFFER, GL_WRITE_ONLY); // Copy our data into it...
memcpy(ptr, data, sizeof(data));
// Tell OpenGL that we’re done with the pointer
glUnmapBuffer(GL_ARRAY_BUFFER);

相交于前两种方法必须准备额外的内存空间读取文件中的数据,然后将其拷贝至缓存中,第三种方式可以直接获取缓存的地址,将文件读取到缓存中。

1.2 为缓存填充和拷贝数据

上一节为缓存输入数据的三个方法都会重写整个缓存区,当为缓存区输入常量时,使用glClearBufferSubData()函数会更高效。

void glClearBufferSubData(GLenum target, GLenum internalformat, GLintptr offset, GLsizeiptr size, GLenum format, GLenum type, const void * data);

该函数将data指向的数据转换为internalformat指定的格式,并将其复制到有offset和size(单位为bytes)指定的区域内。format描述数据颜色通道信息,其枚举值为GL_RED, GL_RG, GL_RGB, or GL_RGBA。type指定了数据的类型,其具体类型为。

(GL_BYTE -- GLchar)                (GL_UNSIGNED_BYTE -- GLuchar)  (GL_SHORT -- GLshort)
(GL_UNSIGNED_SHORT -- GLushort)    (GL_INT -- GLint),             (GL_UNSIGNED_INT -- GLuint)
(GL_FLOAT -- GLfloat)              (GL_DOUBLE -- GLdouble)。

OpenGL中拷贝数据的函数如下:

void glCopyBufferSubData(GLenum readtarget, GLenum writetarget, GLintptr readoffset, GLintptr writeoffset, GLsizeiptr size);

由于一个Target在同一时间只能绑定一个buffer,因此不能在同一类型的buffer中拷贝数据。为此OpenGL为拷贝数据提供了两个专有target:GL_COPY_READ_BUFFER和GL_COPY_WRITE_BUFFER。此处需注意拷贝和读取数据的范围都不能超过buffer的边界,否则拷贝失败。

1.3 从缓存向顶点着色器传递数据

为顶点着色器传递的顶点数据称为顶点数组对象(VAO),其初始化方法如下:

GLuint vao;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);

函数glVertexAttribPointer负责描述数据,其第一个参数用于指定在顶点着色器中的输入参数的索引,type指定数据变量类型,normalized描述数据的取值是否在0到1之间,stride描述了每个顶点的大小(单位为bytes),该参数可以设置为0使OpenGL根据size和type自动计算,pointer的名字取得很独特,但他真正描述的是顶点数据在缓存中的偏移量。函数glEnableVertexAttribArray负责填充数据。

void glVertexAttribPointer(GLuint index, GLint size, GLenum type, GLboolean normalized, GLsizei stride, const GLvoid * pointer);
void glEnableVertexAttribArray(GLuint index);

使用buffer为顶点着色器传递数据的代码如下:

// First, bind our buffer object to the GL_ARRAY_BUFFER binding
// The subsequent call to glVertexAttribPointer will reference this buffer glBindBuffer(GL_ARRAY_BUFFER, buffer);
// Now, describe the data to OpenGL, tell it where it is, and turn on // automatic vertex fetching for the specified attribute
glVertexAttribPointer(0,          // Attribute 0
                      4,          // Four components
                      GL_FLOAT,   // Floating-point data
                      GL FALSE,   // Not normalized
                      0,          // (floating-point data never is tightly packed)
                      NULL);      // Offset zero (NULL pointer)
glEnableVertexAttribArray(0);

其对应的顶点着色器代码如下:

#version 410 core
layout (location = 0) in vec4 position;
void main(void) { 
  gl_Position = position;
}

在数据输入完毕后,可以调用(通常不调用)glDisableVertexAttribArray()禁用属性。

1.4 顶点着色器多属性输入

对于接收多属性的顶点着色器如下,当其被链接至program时可以使用函数GLint glGetAttribLocation(GLuint program, const GLchar * name);获取属性位置。该函数中name传入着色器中同名字符串,如“position”的返回值为0,“color”的返回值为1。如果传入为定义的字符串,此时返回值为-1。如果在顶点着色器中未为输入属性指定位置,在调用该函数时OpenGL会自动为其分配一个位置并将其返回。

layout (location = 0) in vec3 position; 
layout (location = 1) in vec3 color;

顶点着色器多属性输入的方式分为分离属性(separate attributes)交错属性(interleaved attributes)。

分离属性指将数据放置在两个缓存中,分别通过glVertexAttribPointer等函数将数据传入着色器中,代码如下。

GLuint buffer[2];
static const GLfloat positions[] = { ... };
static const GLfloat colors[] = { ... }; 

// Get names for two buffers
glGenBuffers(2, &buffers);

// Bind the first and initialize it
glBindBuffer(GL_ARRAY_BUFFER, buffer[0]);
glBufferData(GL_ARRAY_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, NULL); 
glEnableVertexAttribArray(0);

// Bind the second and initialize it
glBindBuffer(GL_ARRAY_BUFFER, buffer[1]); 
glBufferData(GL_ARRAY_BUFFER, sizeof(colors), colors, GL_STATIC_DRAW);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 0, NULL); 
glEnableVertexAttribArray(1);

交错属性指将顶点着色器多个属性的数据都存储在同一个缓存中,多次调用函数glVertexAttribPointer并指定缓存地质偏移量为多个属性传递数据,其代码如下。该函数最后一个参数表示该属性数据起点在和每个顶点数据起始内存地址之间的偏移值。因此数据的组织形式必须是以顶点为单位,即将单个顶点的所有数据描述完后再继续描述下一个顶点。

// 定义保存顶点数据的结构体如下
struct vertex {
// Position float x; float y; float z; 
// Color float r; float g; float b; 
}

GLuint buffer;
static const vertex vertices[] = { ... };

// Allocate and initialize a buffer object
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

// Set up two vertex attributes - first positions
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(vertex), (void *)offsetof(vertex, x));
glEnableVertexAttribArray(0);

// Now colors,offsetof为c语言宏,用于计算结构体中成员的内存地址偏移位,单位为bytes
glVertexAttribPointer(1, 3, GL FLOAT, GL FALSE, sizeof(vertex), (void *)offsetof(vertex, r)); 
glEnableVertexAttribArray(1);

1.5 从文件中加载数据

在实际的编程中模型是都很复杂,其中的顶点几乎不能通过代码定义。模型通常都由3D艺术家在Blender、3DS Max或者Maya等软件生成,这些软件可以将顶点数据导出在文件中,但是不同软件的导出文件格式都不相同,通常使用Assimp库导入模型文件。Assimp能够导入很多种不同的模型文件格式(并也能够导出部分的格式),它会将所有的模型数据加载至Assimp的通用数据结构中。其具体教程见链接,此处由于是超级圣杯这本书的读书笔记,因此使用其提供的文件结构SBM

总的来说,想要读取某种格式的模型文件,通常需要了解其数据组织结构,使用c语言标注库<stdio.h>中的FILE相关函数读取二进制文件。超级圣杯源代码中提供了sb6::object类用于读取sbm格式模型文件,SBM文件结构简介如下,具体见原著。

1.5.1 文件头

SBM文件有一个文件头和多个数据块及原始数据三部分组成,每个数据块又由一个数据头和数据体组成。其中数据都是以小端模式保存,并且数据块只是描述数据,真正的顶点数据都保存在原始数据区域内。其中文件头结构体定义如下。

typedef struct SB6M_HEADER_t {
  union {
    unsigned int    magic;
    char            magic_name[4];
  };
  unsigned int   size;
  unsigned int   num_chunks;
  unsigned int   flags;
} SB6M_HEADER;

其中magic和magic_name[4]为共用体,为SBM文件的标识,其固定为0x4d364253,使用小端模式保存了字符串SB6M(SuperBible 6 Model)。Size指定了文件头的大小,单位为字节(定义为16个字节)。num_chunks定义了文件中数据块的个数。读取数据时允许跳过无法识别的数据块。flags为预留标记位,以标识文件类型以便进一步处理。

1.5.2 数据头

每个数据块都包含一个通用的数据头,其结构定义如下。

typedef struct SB6M_CHUNK_HEADER_t {
  union {
    unsigned int    chunk_type;
    char            chunk_name[4];
  };
  unsigned int  size;
} SB6M_CHUNK_HEADER;

chunk_type和chunk_name为共用体,定义了数据块的唯一标识类型。size标识包括数据头在内的整个数据块的字节大小。

1.5.3 索引数据块(Index Data Chunk)

索引数据保存在SBM文件中,索引数据块引用了模型的索引数据,其定义如下。

typedef struct SB6M_CHUNK_INDEX_DATA_t {
  SB6M_CHUNK_HEADER   header;
  unsigned int        index_type;
  unsigned int        index_count;
  unsigned int        index_data_offset;
} SB6M_CHUNK_INDEX_DATA;

索引数据块的数据头header的type定义为0x58444e49,name定义为“INDX”。通常该数据块的大学为20字节, index_type定义了数据的类型,合法的类型为0x1401 (GL_UNSIGNED_BYTE), 0x1403 (GL_UNSIGNED_SHORT), 和0x1405 (GL_UNSIGNED_INT)。index_count表示索引的个数,通过type和我count可以计算出索引数据的内存字节大小。index_data_offset表示索引数据到原始数据起点的偏移字节。

1.5.4 顶点数据块(Vertex Data Chunk)

顶点数据保存在SBM文件中,顶点数据块引用模型的顶点数据,其结构定义如下。

typedef struct SB6M_CHUNK_VERTEX_DATA_t {
  SB6M_CHUNK_HEADER   header;
  unsigned int        data_size;
  unsigned int        data_offset;
  unsigned int        total_vertices;
} SB6M_CHUNK_VERTEX_DATA;

顶点数据块的数据头header的type定义为0x58545256,name定义为“VRTX”。顶点数据块的内存大小通吃为20bytes。data_size保存了所有顶点数据的总内存大小,data_offset表示顶点数据从原始数据的偏移内存大小。total_vertices定义了顶点个数。

1.5.5 顶点属性块(Vertex Attribute Chunk)

顶点属性块定义如下。

typedef struct SB6M_VERTEX_ATTRIB_CHUNK_t {
  SB6M_CHUNK_HEADER           header; 
  unsigned int                attrib_count; 
  SB6M_VERTEX_ATTRIB_DECL     attrib_data[1];
} SB6M_VERTEX_ATTRIB_CHUNK;

其header的type定义为0x42525441,header的name定义为“ATRB”。顶点数据块的内存大小由attrib_count定义的属性数量决定,attrib_data数组中保存了真正的属性数据。SB6M_VERTEX_ATTRIB_DECL定义如下。

typedef struct SB6M_VERTEX_ATTRIB_DECL_t {
  char              name[64];
  unsigned int      size;
  unsigned int      type;
  unsigned int      stride;
  unsigned int      flags;
  unsigned int      data_offset;
} SB6M_VERTEX_ATTRIB_DECL;

name为属性的名字,包括NULL。size表示每个顶点的该属性包含元素个数。type表示OpenGL中的数据类型,包含0x1406 (GL_FLOAT), 0x1400 (GL_BYTE), 和0x140B (GL_HALF_FLOAT)等合法的GL数据类型。stride表示该属性取值应该偏离当前顶点的内存单位。flags标识数据是否经过标准化处理(SB6M _VERTEX _ATTRIB _FLAG _NORMALIZED 0x00000001, SB6M_VERTEX_ATTRIB_FLAG_INTEGER 0x00000002)。data_offset标识属性数据从距离原始数据起点的内存偏移大小。

1.5.6 注释块

注释块并没有实际的意义,其结构定义如下。

typedef struct SB6M_CHUNK_COMMENT_t {
  SB6M_CHUNK_HEADER     header; 
  char                  comment[1];
} SB6M_CHUNK_COMMENT;

其header的type定义为0x544E4D43,header的name定义为“CMNT”。

1.5.7 对象列表块

对象列表块描述了SBM文件中的子对象,每个子对象所引用的顶点和索引数据都被保存在相同的缓存中,其结构定义如下。

typedef struct SB6M_CHUNK_SUB_OBJECT_LIST_t  {
  SB6M_CHUNK_HEADER        header;
  unsigned int             count;
  SB6M_SUB_OBJECT_DECL_t   sub_object[1];
} SB6M_CHUNK_SUB_OBJECT_LIST;

其header的type定义为0x54534C4F,header的name定义为“OLST”。count定义了SBM文件中对象的格式,SB6M_SUB_OBJECT_DECL描述了单个对象,其定义如下。

typedef struct SB6M_SUB_OBJECT_DECL_t {
  unsigned int first;
  unsigned int count; 
} SB6M_SUB_OBJECT_DECL;

如果对象有索引数据,first和count分别表示了第一个索引的位置和索引的数量,反之,它们分别表示第一个顶点的位置和顶点的数量。

下图为一个SBM文件的一个模板,其中有部分数据(如vertex trunk的header size不可能为1字节)不标准,但是不影响理解。

2 Uniform变量

顶点属性能够从程序中传递数据到顶点着色器,in、out关键字能够在连续的着色器中传递数据,而uniform类型变量可以在程序中为任意着色器传递数据。声明Uniform变量的方式分为默认闭包(default block)和统一闭包(uniform blocks)。

2.1 Default Block Uniforms

Uniforms变量的特点是在某一处理批次中,该类型变量对于所有的顶点、法线等数据都有相同的值,如最常用的Uniform变量为仿射矩阵。在着色器中定义Uniform类型变量的声明为uniform float fTime;。在着色器中,uniform变量可以视之为常量,着色器中禁止更改其数值,但是可以为其定义初值,如uniform answer = 42;。多个着色器中可以定义相同的uniform变量,它们有着相同的值。

2.1.1 使用统一变量

当一个着色器呗编译并连接至某个程序后,可以调用函数为着色器中的统一变量赋值。同顶点属性一样,统一变量的唯一标识为着色器中定义的location值。着色器中统一变量的完整定义如layout (location = 17) uniform vec4 myUniform;。同顶点属性一样,统一变量也可以不用在程序中指定location,此时OpenGL会自动分配位置,该值可以通过函数获得GLint glGetUniformLocation(GLuint program, const GLchar* name);

更常用的方法是使用指定location的方式,从而避免调用函数获取位置。当未在着色器中找到对应变量时,位置返回为-1。如果着色器中的Uniform变量未被使用,尽管着色器已经成功编译,但是编译器仍会丢弃掉改变量。另外,统一变量的标识符区分大小写。

2.1.2 设置标量和向量类型的Uniform变量

OpenGL中定义了大量的数据类型,同时也给出了大量的API来为这些类型的Uniform变量赋值,其函数如下。

//该函数也用于将纹理对象传入着色器中,第二个参数为纹理索引,其与函数glActiveTexture(GL_TEXTURE2);
//中的参数相对应,如此处应该使用2
void glUniform1f(GLint location, GLfloat v0);  
void glUniform2f(GLint location, Glfloat v0, GLfloat v1); 
void glUniform3f(GLint location, GLfloat v0, GLfloat v1, GLfloat v2);
void glUniform4f(GLint location, GLfloat v0, GLfloat v1, GLfloat v2, GLfloat v3); 

void glUniform1i(GLint location, GLint v0);
void glUniform2i(GLint location, GLint v0, GLint v1); 
void glUniform3i(GLint location, GLint v0, GLint v1, GLint v2);
void glUniform4i(GLint location, GLint v0, GLint v1, GLint v2, GLint v3);

void glUniform1ui(GLint location, GLuint v0);
void glUniform2ui(GLint location, GLuint v0, GLuint v1); 
void glUniform3ui(GLint location, GLuint v0, GLuint v1, GLuint v2);
void glUniform4ui(GLint location, GLuint v0, GLuint v1, GLuint v2, GLint v3);
2.1.3 设置数组Uniform变量

为数组Uniform变量赋值的时候传入数组的指针即可,其函数如下。

void glUniform1fv(GLint location, GLuint count, const GLfloat* value); 
...
void glUniform4fv(GLint location, GLuint count, const GLfloat* value);

void glUniform1iv(GLint location, GLuint count, const GLint* value); 
...
void glUniform4iv(GLint location, GLuint count, const GLint* value);

void glUniform1uiv(GLint location, GLuint count, constGLuint* value); 
...
void glUniform4uiv(GLint location, GLuint count, constGLuint* value);

上述函数都是为二维数组赋值,其中函数中的数字定义了二维数组的列数,参数中的count标识二维数组的行数。其使用方法如下。

GLfloat vColor[4] = {  1.0f, 1.0f, 1.0f, 1.0f };
glUniform4fv(iColorLocation, 1, vColor);

GLfloat vColors[4][2] = {{1.0f, 1.0f, 1.0f, 1.0f }, {1.0f, 0.0f, 0.0f, 1.0f }};
glUniform4fv(iColorLocation, 2, vColors);

GLfloat fValue = 45.2f;
glUniform1fv(iLocation, 1, &fValue);
2.1.4 设置矩阵Uniform变量

设置矩阵Uniform变量函数如下。其中count表示矩阵的个数,上述函数都可以为矩阵数组赋值,transpose表示是否需要对矩阵转置,openGL使用的是列优先的矩阵。

glUniformMatrix2fv(GLint location, GLuint count, GLboolean transpose, const GLfloat *m);
glUniformMatrix3fv(GLint location, GLuint count, GLboolean transpose, const GLfloat *m);
glUniformMatrix4fv(GLint location, GLuint count, GLboolean transpose, const GLfloat *m);

glUniformMatrix2dv(GLint location, GLuint count, GLboolean transpose, const GLdouble *m);
glUniformMatrix3dv(GLint location, GLuint count, GLboolean transpose, const GLdouble *m);
glUniformMatrix4dv(GLint location, GLuint count, GLboolean transpose, const GLdouble *m);

2.2 Uniforms Block

当程序中使用大量的着色器和大量Uniform变量时,为了更好维护这些变量,OpenGL允许将一组Uniform变量组合到一个Uniform block中同时将其存储到缓存对象中。此时可以通过改变缓存绑定或者重写缓存内容来为整组Uniform变量赋值。改缓存对象称为统一缓存对象或者UBO。Uniform Block和Default Block的定义方式不同,其和接口闭包(Interface Blocks)的定义类似,其定义如下。

uniform TransformBlock {
  float scale;                // Global scale to apply to everything
  vec3 translation;           // Translation in X, Y, and Z
  float rotation[3];          // Rotation around X, Y, and Z axes
  mat4 projection_matrix;     // A generalized projection matrix to apply after scale and rotate
} transform;

在着色器中,和接口闭包(Interface Blocks)使用点语法访问成员变量类似,此处可以使用成员变量标识符访问(transform.projection_matrix)。

2.2.1 构建Uniform Blocks

标准布局方式
数据在缓存对象的布局有两种方式。第一种方式为标准布局,标准布局定义了闭包中每个元素距离头部的内存偏移量,这可能会使各个成员之间存在空内存空间,造成内存浪费,但是这种方式总是最安全的。改方式以std关键字开头,常用的是std140

共享布局方式
另外一个方法是让OpenGL自己决定数据的布局方式,这样可以构建更高效率的着色器。这种模式下,数据以共享布局(shared layout)方式组织,在不同着色器中,OpenGL的编译器会优化掉不使用的统一变量,从而提升着色器效率,但是也直接导致了在不同着色器中,每个元素的内存偏移量不一致。这是默认的布局方式。这种模式下,数据在多个program中的布局方式相同,同时各个着色器共享相同的统一闭包(uniform block)声明。

标准布局方式
首先介绍标准布局standard layout,在使用Uniform block时推荐使用该布局方式,尽管它不是默认的布局方式。使用该布局方式时,着色器中必须声明布局信息。代码如下。

layout(std140) uniform TransformBlock {
  float scale;                // Global scale to apply to everything
  vec3 translation;           // Translation in X, Y, and Z
  float rotation[3];          // Rotation around X, Y, and Z axes
  mat4 projection_matrix;     // A generalized projection matrix to apply after scale and rotate
} transform;

该布局规范共有9条规则用于计算不同变量的内存偏移位置,详情参见官方定义。简单的可以记为,每个成员都有基本对齐宽度(base alignment)、基本位移(base offset)和对齐位移(alignment offset),最后一个为各个成员变量的真实位移,OpenGL的语法会根据上述九条规则在各个成员之间插入内存间隔(padding)。其中int、float、bool都被定义为4个字节的对齐宽度,用N表示,长度为2的向量对齐宽度为2N,长度为3和4的向量为4N。整个Uniform变量的第一个成员的基本位移和对齐位移都为0,此后每个成员的基本位移紧贴前一个成员的真正占用内存末尾,它的基本对齐根据上述规则确定,它的对齐位移为基本位移向上取到基本对齐的整数倍。在数组成员的末尾会重新用4N基本对齐的标准插入内存间隔。另外矩阵被看做是向量的数组,对于结构体成员,将其中最大的子成员基本对齐向上取到4N的倍数作为结构体的开始计算其对齐位移,在结构体末尾再以4N位基本对齐计算尾部插入间隔。

为了简单理解,下图为一个统一闭包使用std140布局后的布局计算。

在使用标准布局时需要注意std140布局和c语言编译器对于数组的差别,后者中数组是紧密排列的,但是std140规定的数组并不是。因此不能使用Uniform block中的数组创建c语言中的float数组,也不能从c数组中直接拷贝数据到Uniform block。

使用这种布局模式的着色器统一闭包能高效的被图形硬件处理。上文中的Uniform block中各个成员的内存大小以及其偏移量计算结果如下。

layout(std140) uniform TransformBlock {
// Member                base alignment        aligned offset
float scale;             //4                   0    
vec3 translation;        //16                  16
float rotation[3];       //16                  32 (rotation[0])
                         //                    48 (rotation[1])
                         //                    64 (rotation[2])
mat4 projection matrix;  //16                  80 (column 0)
                         //                    96 (column 1)
                         //                    112 (column 2)
                         //                    128 (column 3)
} transform;

获取统一变量布局信息
可以通过函数获取每个闭包成员的内存偏移量。每一个成员都含有一个索引用于确定它的大小和位置信息。获取索引的函数如下。

void glGetUniformIndices(GLuint program, GLsizei uniformCount, const GLchar ** uniformNames, GLuint * uniformIndices);

该函数可以获取不同统一闭包中的统一变量的索引值,uniformCount为需要查询的统一变量数量,uniformNames为需要查询的统一变量标识符索引值数组,其用法如下。

static const GLchar * uniformNames[4] = {
  "TransformBlock.scale",
  "TransformBlock.translation",
  "TransformBlock.rotation",
  "TransformBlock.projection matrix"
};
GLuint uniformIndices[4];
glGetUniformIndices(program, 4, uniformNames, uniformIndices);

获取到索引数组后,可以调用如下函数获取位置信息。

void glGetActiveUniformsiv(GLuint program, GLsizei uniformCount, const GLuint * uniformIndices, GLenum pname, GLint * params);

该函数可以通过指定pname类型以获取不同的信息,该参数枚举值为GL _UNIFORM _OFFSET、GL_UNIFORM_ARRAY_STRIDE和GL_UNIFORM_MATRIX_STRIDE等。其中offset指偏移量,array stride指数组内部存储间隔,matrix stride指矩阵内部存储间隔。该函数的使用实例如下。

GLint uniformOffsets[4];
GLint arrayStrides[4];
GLint matrixStrides[4]; 
glGetActiveUniformsiv(program, 4, uniformIndices, GL_UNIFORM_OFFSET, uniformOffsets); 
glGetActiveUniformsiv(program, 4, uniformIndices, GL_UNIFORM_ARRAY_STRIDE, arrayStrides); 
glGetActiveUniformsiv(program, 4, uniformIndices, GL_UNIFORM_MATRIX_STRIDE, matrixStrides);

在初始化缓存对象时,对于含有复杂数据类型的统一闭包,通常需要获取更多的信息。OpenGL中的pname参数可选枚举类型如下。
GL_UNIFORM_TYPE:数据类型。
GL_UNIFORM_SIZE:数组的大小,单位为元素个数,对于非数组统一变量,总返回1。
GL_UNIFORM_NAME_LENGTH:统一变量的长度,以字符数为单位。
GL_UNIFORM_BLOCK_INDEX:闭包的索引值,The index of the block that the uniform is a member of.
GL_UNIFORM_OFFSET:统一变量的内存偏移量。
GL_UNIFORM_ARRAY_STRIDE:数组中元素间的内存间距,如果统一变量不是数组,该值总为0。
GL_UNIFORM_MATRIX_STRIDE:列优先矩阵中每列元素间的存储间隔,或者行优先矩阵中每行元素间隔存储间隔,如果统一变量不是矩阵,该值总为0。
GL_UNIFORM_IS_ROW_MAJOR:对于行优先矩阵的统一变量,该值为有元素1组成的数组,否则为0组成的数组。

为缓存赋值
如果统一变量的类型是int、float、bool或者由它们组成的向量,此时只需获取其内存偏移量即可。只要知道统一变量的位置信息,可以使用glBufferSubData()函数并传入offset变量为其赋值,或者直接通过glMapBuffer()函数直接获取指针变量为其赋值。

为基本数据类型统一变量赋值的代码如下。

// Allocate some memory for our buffer (don’t forget to free it later)
unsigned char * buffer = (unsigned char *)malloc(4096);

// We know that TransformBlock.scale is at uniformOffsets[0] bytes
// into the block, so we can offset our buffer pointer by that value and // store the scale there.
*((float *)(buffer + uniformOffsets[0])) = 3.0f;

为向量类型统一变量赋值代码如下。

// Put three consecutive GLfloat values in memory to update a vec3
((float *)(buffer + uniformOffsets[1]))[0] = 1.0f; 
((float *)(buffer + uniformOffsets[1]))[1] = 2.0f; 
((float *)(buffer + uniformOffsets[1]))[2] = 3.0f;

为数组类型统一变量赋值代码如下。

// TransformBlock.rotations[0] is at uniformOffsets[2] bytes into the buffer. Each element 
// of the array is at a multiple of arrayStrides[2] bytes past that
const GLfloat rotations[] = { 30.0f, 40.0f, 60.0f };
unsigned int offset = uniformOffsets[2];
for (int n = 0; n < 3; n++) {
  *((float *)(buffer + offset)) = rotations[n];
  offset += arrayStrides[2];
}

为矩阵类型统一变量赋值代码如下。在OpenGL中,矩阵类型都为列优先矩阵,矩阵可以看做是一个向量数组,其中每一个向量都代表矩阵中某一列的元素。

// The first column of TransformBlock.projection_matrix is at uniformOffsets[3] bytes 
// into the buffer. The columns are spaced matrixStride[3] bytes apart and are essentially 
// vec4s. This is the source matrix - remember, it’s column major so 
const GLfloat matrix[] = {
  1.0f, 2.0f, 3.0f, 4.0f,
  9.0f, 8.0f, 7.0f, 6.0f,
  2.0f, 4.0f, 6.0f, 8.0f,
  1.0f, 3.0f, 5.0f, 7.0f
};
for (int i = 0; i < 4; i++) {
  GLuint offset = uniformOffsets[3] + matrixStride[3] * i; 
  for (j = 0; j < 4; j++) {
    *((float *)(buffer + offset)) = matrix[i * 4 + j];
    offset += sizeof(GLfloat); 
  }
}

查询每个统一闭包成员的内存偏移以及间隔对于任意布局类型都有效。但是当使用共享布局时,必须使用前文查询函数,程序员中需要大量代码去确定每个成员变量的内存偏移和存储间隔。因此,此处推荐使用标准布局(standard layout),尽管这样会造成内存浪费并影响着色器的性能,但是能避免程序中大量额外代码并提升程序性能仍是有价值的。

将缓存数据输入着色器
OpenGL在编译着色器时,编译器会为每个统一闭包分配一个索引值,在将缓存中数据输入到统一变量闭包时,首先必须确定统一变量闭包的索引。对于一个程序(program),闭包的数量是受限的,可以通过函数glGetIntegerv()和参数GL _MAX_ UNIFORM_ BUFFERS、GL _MAX _VERTEX _UNIFORM _BUFFERS、GL _MAX _GEOMETRY _UNIFORM _BUFFERS、GL _MAX _TESS _CONTROL _UNIFORM _BUFFERS、GL _MAX _TESS _EVALUATION _UNIFORM _BUFFERS、GL _MAX _FRAGMENT _UNIFORM _BUFFERS分别获取整个程序和各个着色器中允许的最大统一闭包数量。

函数GLuint glGetUniformBlockIndex(GLuint program, const GLchar * uniformBlockName);可以获取统一闭包的索引。统一闭包被分配了绑定点,缓存都被限定在这些绑定点之上,因此当多个程序(programs)切换时不用再改换缓存绑定,新的程序依旧能够正常获取统一变量。而在默认闭包中,统一变量对于每个程序都有独立的状态,即使两个程序包含同名的统一变量,当切换程序时,统一变量都必须重新设置。

为统一闭包分配绑定点调用函数void glUniformBlockBinding(GLuint program, GLuint uniformBlockIndex, GLuint uniformBlockBinding);。其中program为统一闭包所处的program,uniformBlockIndex为需要分配绑定点的统一闭包索引值,uniformBlockBinding为统一闭包的绑定点。绑定点此处是受限的,可以通过函数glGetIntegerv()和参数GL_MAX_UNIFORM_BUFFER_BINDINGS获得。

另外,也可直接在顶点着色器内直接指定统一闭包的绑定点,其声明如下。直接在代码中分配绑定点可以避免调用绑定缓存和统一闭包的函数glUniformBlockBinding(),因此这是常用的方法。

layout(std140, binding = 2) uniform TransformBlock {
...
} transform;

当通过上述两个方法为统一闭包分配变量后,通过调用函数glBindBufferBase (GL _UNIFORM _BUFFER, index, buffer);通过绑定点将缓存中的数据输入至统一变量中。其中,GL_UNIFORM_BUFFER表示将缓存中的数据缓存至统一变量闭包中。index是统一缓存绑定点的索引,和着色器代码中指定的索引或者通过函数glUniformBlockBinding()指定的索引相同。buffer为提供数据的缓存对象名字。需要注意的是这里的索引和统一闭包的索引值不一致,而是绑定点的索引值。

缓存、统一缓存绑定点和统一闭包之间的关系如下图。

上图示例中,统一闭包Harry和绑定点1级缓存C关联,统一闭包Bob和绑定点3及缓存A关联,统一闭包Susan和绑定点0级缓存B关联,绑定点2并未使用。其对应代码如下。

// Get the indices of the uniform blocks using glGetUniformBlockIndex 
GLuint harry_index = glGetUniformBlockIndex(program, "Harry"); 
GLuint bob_index = glGetUniformBlockIndex(program, "Bob");
GLuint susan_index = glGetUniformBlockIndex(program, "Susan");

// Assign buffer bindings to uniform_blocks, using their indices 
glUniformBlockBinding(program, harry index, 1); 
glUniformBlockBinding(program, bob_index, 3); 
glUniformBlockBinding(program, susan_index, 0);

// Bind buffers to the binding points
// Binding 0, buffer B, Susan’s data 
glBindBufferBase(GL_UNIFORM_BUFFER, 0, buffer_b); 
// Binding 1, buffer C, Harry’s data 
glBindBufferBase(GL_UNIFORM_BUFFER, 1, buffer_c); 
// Note that we skipped binding 2
// Binding 3, buffer A, Bob’s data 
glBindBufferBase(GL_UNIFORM_BUFFER, 3, buffer_a);

为了避免调用函数glUniformBlockBinding(),需在着色器中指定绑定点,代码如下。

layout (binding = 1) uniform Harry {};
layout (binding = 3) uniform Bob {};
layout (binding = 0) uniform Susan {};

在着色器中直接指定绑定点有以下几个优点。第一,可以减少函数调用。第二,程序不需要知道统一变量的标识符而直接通过绑定点为其输入数据,因此不同着色器中可以使用不同的标识符。

使用统一闭包示例
统一闭包可用于分离稳定状态和短暂状态(A common use for uniform blocks is to separate steady state from transient state)。例如,对于固定变量如投影矩阵、视口大小信息等,可以将它们放入一个统一闭包中,并将其分配至绑定点0,因此,当使用函数glUseProgram()切换程序时,可以随时访问这些存储在缓存中的变量。

对于处理材质着色的片段着色器,可以将年描述材质的参数放入另外一个缓存中,并将着色器中的统一变量绑定至绑定点1,每个对象都持有自己的缓存对象,在渲染不同对象时,使用通用的片段着色器并将该对象的缓存通过绑定点1输入至统一变量中即可。

最后一个明显的优势是统一闭包的容量可以很大。可以通过函数glGetIntegerv()和参数GL_MAX_UNIFORM_BLOCK_SIZE获取OpenGL支持的单个统一闭包的最大容量。同样的单个程序中统一闭包的最大数量可以通过改函数和参数GL_MAX_UNIFORM_BLOCK_BINDINGS获得。OpenGL中每个统一变量的大小至少为64KB,单个程序至少支持14个统一闭包。对于前两段中的示例,可以将所有的材质属性打包为单个由结构体数组组成的大容量统一闭包,在渲染某个物体时,只需要根据索引取出对应的材质参数。这比渲染不用对象时更新缓存内容或者改变缓存绑定更高效。这样可以只使用一段代码渲染由不同表面组成的对象数组。

2.3 使用统一变量转换几何图形

这一小节是使用旋转矩阵和传递数据的示例,使用缓存对象为顶点着色器提供顶点数据,使用Uniform变量控制模型旋转,通过仿射矩阵和投影矩阵将模型投影到屏幕之上。

在正式开始绘制模型之前,需要确定当前NSOpenGLView的上下文属性是否分配深度缓存空间,并启用深度测试功能,从而避免本应被挡住的面却因为绘制顺序在后而覆盖了前面的面。其配置属性代码如下。

NSOpenGLPixelFormatAttribute pixelFormatAttributes[] = {
    NSOpenGLPFAColorSize, 32,
    NSOpenGLPFADepthSize, 24,
    NSOpenGLPFAStencilSize, 8,
    NSOpenGLPFAAccelerated,
    NSOpenGLPFAOpenGLProfile, NSOpenGLProfileVersion4_1Core,
    0
};

启用深度测试。

- (void)prepareOpenGL {
    glEnable(GL_DEPTH_TEST);
}

首先,使用顶点数组对象描述模型顶点数据,如下。

// First, create and bind a vertex array object
glGenVertexArrays(1, &_vertexArray);
glBindVertexArray(_vertexArray);
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,       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,       0.25f, -0.25, 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, 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,  0.25f,
    0.25f,  0.25f,  0.25f,        -0.25f,  0.25f,  0.25f,     -0.25f,  0.25f, -0.25f
};
// Now generate some data and put it in a buffer object
glGenBuffers(1, &_vertexArrayBuffer);
glBindBuffer(GL_ARRAY_BUFFER, _vertexArrayBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertex_positions), vertex_positions, GL_STATIC_DRAW);
// Set up our vertex attribute
glVertexAttribPointer(GLKVertexAttribPosition, 3, GL_FLOAT, GL_FALSE, 0, NULL);
glEnableVertexAttribArray(0);

接下来对于每一帧图片,需要计算出表示模型的位置和方位的矩阵,这里简单通过在z轴上平移建立相机矩阵。模型-视图矩阵构建代码如下。

// _lifeDuration为程序运行时间
GLfloat f = _lifeDuration*M_PI*0.1f;
GLKMatrix4 xRotateMatrix = GLKMatrix4MakeRotation(_lifeDuration*GLKMathDegreesToRadians(81.0f), 1.0f, 0.0f, 0.0f);
GLKMatrix4 yRotateMatrix = GLKMatrix4MakeRotation(_lifeDuration*M_PI_4, 0.0f, 1.0f, 0.0f);
GLKMatrix4 translateMatrix = GLKMatrix4MakeTranslation(sinf(2.1f*f)*0.5f, cosf(1.7f*f)*0.5f, sinf(1.3f*f)*cosf(1.5f*f)*2.0f);
GLKMatrix4 modelWroldMatrix = GLKMatrix4Multiply(GLKMatrix4Multiply(translateMatrix, yRotateMatrix), xRotateMatrix);
GLKMatrix4 viewMatrix = GLKMatrix4MakeTranslation(0.0f, 0.0f, -3.0f);
_mv_Matrix = GLKMatrix4Multiply(viewMatrix, modelWroldMatrix);

随着窗口尺寸的改变,都必须调用函数glViewport()更新视口(viewport)并更新投影矩阵。代码如下。

- (void)reshape {
    NSRect bounds = [self bounds];
    glViewport(0, 0, NSWidth(bounds), NSHeight(bounds));
    GLfloat aspect = NSWidth(bounds)/NSHeight(bounds);
    _proj_Matrix = GLKMatrix4MakePerspective(GLKMathDegreesToRadians(65.0), aspect, 0.1f, 1000.0f);
}

当所有矩阵更新完毕后需要调用函数glDrawArrays()绘制模型。

- (void)drawRect:(NSRect)dirtyRect {
    const GLfloat green[] = { 0.0f, 0.15f,
        0.0f, 1.0f };
    glClearBufferfv(GL_COLOR, 0, green);
    glClear(GL_DEPTH_BUFFER_BIT);
    
    glUseProgram(_program);
    _mv_location = glGetUniformLocation(_program, "mv_matrix");
    _proj_location = glGetUniformLocation(_program, "proj_matrix");
    glUniformMatrix4fv(_mv_location, 1, GL_FALSE, _mv_Matrix.m);
    glUniformMatrix4fv(_proj_location, 1, GL_FALSE, _proj_Matrix.m);
    glDrawArrays(GL_TRIANGLES, 0, 36);
    glFlush();
}

在渲染图形之前,需要编辑顶点着色器,并使用仿射矩阵和投影矩阵更新模型位置,并将其投影至屏幕之上。顶点着色器代码如下。

#version 410 core
in vec4 position;
out VS_OUT {
    vec4 color;
} vs_out;
uniform mat4 mv_matrix;
uniform mat4 proj_matrix;

void main(void) {
    gl_Position = proj_matrix*mv_matrix*position;
    vec4 colortemp = position*2.0 + vec4(0.5,0.5,0.5,0.0);
    vs_out.color = vec4(colortemp.rgb, 1.0);
}

片段着色器代码如下。

#version 410 core
out vec4 color;
in VS_OUT {
    vec4 color;
} fs_in;

void main(void) {
    color = fs_in.color;
}

在附着完顶点着色器后需为着色器中的输入变量绑定索引。

glAttachShader(_program, vertexShader);
glAttachShader(_program, fragShader);
glBindAttribLocation(_program, GLKVertexAttribPosition, "position");

在链接完程序后需获取默认闭包类型的统一变量的索引值。

if (![self linkProgram:_program]) {...}
_mv_location = glGetUniformLocation(_program, "mv_matrix");
_proj_location = glGetUniformLocation(_program, "proj_matrix");

上述代码的效果将得到一个如下旋转的彩色立方体。

绘制多个立方体
使用同一个顶点缓存对象,不同的矩阵以绘制多个立方体。矩阵的更新移到绘制逻辑中,代码如下。

- (void)drawRect:(NSRect)dirtyRect {
    static const GLfloat green[] = { 0.0f, 0.15f,
        0.0f, 1.0f };
    static const GLfloat depth = 1.0f;
    glClearBufferfv(GL_COLOR, 0, green);
    glClearBufferfv(GL_DEPTH, 0, &depth);
    
    glUseProgram(_program);
    glUniformMatrix4fv(_proj_location, 1, GL_FALSE, _proj_Matrix.m);
    for (int i = 0; i < 24; i++) {
        GLfloat f = _lifeDuration*0.3f + i;
        GLKMatrix4 xRotateMatrix = GLKMatrix4MakeRotation(_lifeDuration*GLKMathDegreesToRadians(21.0f), 1.0f, 0.0f, 0.0f);
        GLKMatrix4 yRotateMatrix = GLKMatrix4MakeRotation(_lifeDuration*M_PI_4, 0.0f, 1.0f, 0.0f);
        GLKMatrix4 translateMatrix = GLKMatrix4MakeTranslation(sinf(2.1f*f)*2.0f, cosf(1.7f*f)*2.0f, sinf(1.3f*f)*cosf(1.5f*f)*2.0f);
        GLKMatrix4 modelWroldMatrix = GLKMatrix4Multiply(GLKMatrix4Multiply(translateMatrix, yRotateMatrix), xRotateMatrix);
        GLKMatrix4 viewMatrix = GLKMatrix4MakeTranslation(0.0f, 0.0f, -7.0f);
        _mv_Matrix = GLKMatrix4Multiply(viewMatrix, modelWroldMatrix);
        glUniformMatrix4fv(_mv_location, 1, GL_FALSE, _mv_Matrix.m);
        glDrawArrays(GL_TRIANGLES, 0, 36);
    }
    glFlush();
}

运行上述代码,其结果如下。

3 着色器存储闭包(Shader Storage Blocks)

3.1 声明着色器存储闭包

如统一闭包一样,缓存对象同样能为着色器中的着色器存储闭包提供数据。这些缓存对象同样能绑定至一系列的GL_SHADER_STORAGE_BUFFER类型绑定点之上。统一变量闭包和着色器存储闭包最大的区别是,在着色器代码内部,前者是只读的,后者是可读可写的,甚至后者的各个成员可以执行原子操作(atomic operation)。另外着色器存储闭包同样有着更高的存储上限。

声明着色器存储闭包时使用关键字buffer,类似于前文同样变量闭包使用std140布局标准(为OpenGL 3.1的规范),着色器储存闭包还可以使用std430布局标准(为OpenGL 4.3的规范),他们主要的区别是前者对于整形和浮点型数组以及包含他们的结构体并不是紧凑布局,而后者是。这样430标准有着更好的内存使用效率,并且和C++编译器语言规范的结构体更相似。着色器存储闭包的声明如下。

#version 430 core
struct my_structure {
  int      pea; 
  int      carrot; 
  vec4     potato;
};

layout (binding = 0, std430) buffer my_storage_block {
  vec4           foo; 
  vec3           bar; 
  int            baz[24]; 
  my_structure   veggies;
};

3.2 使用着色器存储闭包(OpenGL4.2)

使用函数glBufferData()等可以为着色器存储闭包赋值,使用glMapBuffer()和参数GL _READ _ONLY (or GL_READ_WRITE)可以读取着色器中的数据。

着色器存储闭包和其相关的缓存对象相对于统一闭包有着额外的优势。例如,着色器存储闭包内存大小并不受限。当然,对于过于极端的情况,OpenGL可能也无法分配内存,但是确实没有明确的实际存储上限。值得注意的是,由于统一闭包变量有着严格的对其要求以及更小的内存上限,一些硬件处理统一变量闭包时有别于处理着色器存储闭包,并且从着色器存储闭包中读取数据会更加高效。

#version 430 core
struct vertex {
  vec4 position;
  vec3 color; 
};
layout (binding = 0, std430) buffer my_vertices {
  vertex          vertices[];
};
uniform mat4 transform_matrix;
out VS_OUT {
  vec3 color;
} vs_out;

void main(void) {
  gl_Position = transform_matrix * my_vertices.vertices[gl_VertexID].position;
  vs_out.color = my_vertices.vertices[gl_VertexID].color;
}

着色器存储闭包的灵活性牺牲了它的效率,因此不能替代统一变量闭包和顶点属性。例如,OpenGL能够更快的读取统一变量闭包中的常量数据。同样,使用顶点属性可以在着色器运行之前就能读取相应的数据,这能平衡OpenGL的内存子系统。如果在着色器中读取顶点数据可能会极大的降低着色器的性能。

3.3 原子内存操作(Atomic Memory Operation)(OpenGL4.2)

当OpenGL4.2版本Api中引入了Shader Storage Block后,在着色器中就不限于对Uniform Block的只读特性,此时着色器中可以向闭包中写入数据。由于GPU的高并发性,因此必须考虑多线程下数据安全问题。OpenGL提供了三种方式来保证多线程下数据安全,(1)-使用原子内存操作,(2)-使用内存屏障,(3)-使用原子计数变量。

首先,需要知道原子内存操作保证了单次数据读写的安全性。除了简单的对于内存的读写操作,着色器存储闭包支持在内存中执行原子操作。原子操作指的是,在一个写操作不会被之后的一系列读取操作干扰,并且读操作都能获取正确的值。考虑这样一个例子,两次单独的着色器调用都执行了代码m = m + 1,两个代码中的m内存地址相同。对于非原子操作,会得到错误的值m可能实际上只增加了1,对于原子操作能获取正确的值。OpenGL为了解决这个问题,提供了一系列的原子操作函数如下。

atomicAdd(mem, data):从men地址中读取数据,和数data相加,将结果存入men地址中,返回mem中执行加法操作前的值。
atomicAnd(mem,data):逻辑与操作。
atomicOr(mem, data):逻辑或操作。
atomicXor(mem, data):逻辑异或操作。
atomicMin(mem, data):从men地址中读取数据,和data比较,取较小值写入地址mem中,返回mem中的原始数据。
atomicMax(mem, data):取较大值。
atomicExchange(mem, data):从mem中读取数据,将data写入mem中,返回mem中的原始数据。
atomicCompSwap(mem, comp, data):从mem中读取数据,如果comp和data相等,将data写入mem中,返回mem中的原始数据。

上述函数都有int和uint两个版本。对于int版本中所有参数和返回值类型都是int类型,反之都是uint类型。需要注意的是,对于浮点型变量、向量、矩阵以及非32bits的整形数据,都没有原子操作函数(还需查看文档怎么保证这些类型数据的读写安全)。当原子操作被多线程执行时,他们都被有顺序的执行。

3.4 同步访问内存(Synchronizing Access to Memory)(OpenGL4.2)

对于只读取缓存对象数据的着色器而言,读取缓存的顺序并不重要。然而,当在着色器中需要将数据写入中缓存对象时,无论是直接写入变量或者使用原子操作写入变量,都需要小心避免内存风险。这是由于对于紧接着的多重读写命令,OpenGL会为了优化性能对代码的调用进行重排序,导致出现不想要的结果。内存风险主要分以下三类。

写-读操作(Read-After-Write RAW)风险,即当程序向某个内存位置写入数据后,立即读取该位置的数据。取决于系统架构,读写操作可能会被重新排序,从而使得读的操作早于写操作之前执行,从而使得执行操作之前的数据被返回。

写-写操作(Write-After-Write WAW)风险,即当程序向某个内存位置执行连续的两次写入操作。在某些系统架构上,内存中最终存储的数据会是第一次写入中的数据。

读-写操作(Write-After-Read WAR)风险,该风险通常只发生在并行操作系统(GPU)中的多线程操作。读取数据和写入数据的线程被重新排序,导致读取到的数据为写入线程执行后的数据。

由于OpenGL运行时的高管道和高并发性特性,其通过了大量的机制缓和以及控制内存风险。没有这些特性,OpenGL关于重排序着色器并在多线程上运行他们将会变得更加保守。处理内存风险的主要手段是内存屏障(memory barrier)。

内存屏障作为一个标志,它告诉OpenGL“如果你要重排序调用,没问题-只要不让该标记之后的调用早于标记之前的调用执行就可以”。内屏屏障既可以用于程序代码中,也可以用于着色器中。

3.5 在程序中使用内存屏障(Using Barriers in Your Application)

插入内存屏障的函数为void glMemoryBarrier(GLbitfield barriers);参数barriers指定了OpenGL的哪一个子系统应当遵循该限制,哪些子系统不需要遵循。使用参数GL_ALL_BARRIER_BITS时,OpenGL所有子系统对于内部的操作都会同步执行。具体代表各个子系统的参数如下。

GL_SHADER_STORAGE_BARRIER_BIT
该参数指定该内存屏障之前的各个着色器中执行的数据操作(特别是写操作)一定会先执行完,然后屏障之后需要访问同样内存位置的各个着色器才会继续执行。这意味着当你通过shader向着色器存储缓存中写入数据后,立即调用函数glMemoryBarrier()并使用该类型参数,此时,屏障后的各个着色器将会看到写入后的数据,如果没有调用该函数,那么可能读到的是写入缓存之前的原始数据。

GL_UNIFORM_BARRIER_BIT
该参数指定了如果某个缓存在该类型屏障之后被用于统一变量闭包缓存,那么在屏障之前的写入操作一定会先执行完成,然后,屏障之后需要读取该统一变量闭包缓存中数据的各个着色器才会执行操作。因此,当需要在某个着色器中使用着色器存储闭包向某个缓存中写入数据,然后想将该缓存作为统一变量闭包时可以使用该类型缓存屏障。

GL_VERTEX_ATTRIB_ARRAY_BARRIER_BIT
该参数确保了OpenGL会先执行完数据写入缓存的操作,然后任何晚于此屏障之后要将其缓存中的数据以顶点方式作为顶点数据源的着色器才会执行操作。例如,当通过着色器闭包向某个buffer中写入数据后将此buffer作为顶点数组的一部分并将其中的数据传入后续绘制命令使用的顶点着色器时,此时需要设置内存屏障。

OpenGL中还有更多的参数控制用于设置不同子系统的内存屏障,它们将会在后文中讨论。对于内存屏障,关键在于理解参数barriers指定了目标子系统,并且更新数据的机制并不是相关的。

3.6 在着色器中使用内存屏障(Using Barriers in Your Shaders)

着色器中也可以使用屏障阻止OpenGL不按代码调用顺序执行读写操作。GLSL中基本的内存屏障调用函数为void memoryBarrier();

如果在着色器代码中调用该函数,那么函数一定会等待屏蔽之前的代码执行完后才会返回。如果不使用屏障,那么该函数之后执行的读取操作可能会读到旧的数据。

为了为被重排序的各类型内存访问提供更精确的控制,该函数有很多变形。例如memoryBarrierBuffer()函数只在向缓存中读写数据的操作生效。下午讨论各内存屏障保护的数据类型时将会讨论其他类型的函数变种。

4 原子计数变量(Atomic counters)

首先该特性必须在OpenGL 4.2环境中才能使用,原子计数器是一个特殊类型变量,它表示在多个着色器调用中的共享存储。这个存储和一个缓冲对象关联,GLSL中提供了该缓存数据的加法和减法操作。这些操作的特点是他们都具有原子属性,正如前文统一存储闭包中原子操作函数一样,原子计算器相关函数返回的也是执行操作前的原始值。正如其他原子操作一样,如果两个着色器调用中同时访问统一内存位置的原子计算器变量,OpenGL将会对这些操作排序。同样,同着色器存储闭包原子操作相同,原子计数器的多个原子操作执行顺序并不能确定,因此多线程调用时不能得到一个确定的值。

声明原子计数器变量如layout (binding = 0) uniform atomic_uint my_variable;,OpenGL提供了一些绑定点,可以将存储原子计数器变量的缓存绑定至这些绑定点。此外,每个原子计算器储存在缓存对象有着指定的内存偏移量。绑定点序号和内存偏移量可以通过关键字bindingoffset指定。其声明如layout (binding = 3, offset = 8) uniform atomic_uint my_variable;。为了给原子计数器变量提供存储策略,可以使用参数GL_ATOMIC_COUNTER_BUFFER将缓存对象绑定至绑定点上。代码如下。

// Generate a buffer name
GLuint buf;
glGenBuffers(1, &buf); 
// Bind it to the generic GL ATOMIC COUNTER BUFFER target and initialize its storage 
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, buf); 
glBufferData(GL_ATOMIC_COUNTER_BUFFER, 16 * sizeof(GLuint), NULL, GL_DYNAMIC_COPY);
// Now bind it to the fourth indexed atomic counter buffer target
glBindBufferBase(GL_ATOMIC_COUNTER_BUFFER, 3, buf);

在着色器中使用原子计数器变量之前,最好先重设该变量。要重设变量,可以通过调用函数glBufferSubData()重设数据,或者通过glMapBufferRange()获取地址,并且直接赋值,或者使用函数glClearBufferSubData()。对应三个方法的代码分别如下。

// Bind our buffer to the generic atomic counter buffer binding point 
glBindBuffer(GL_ATOMIC_COUNTER_BUFFER, buf);

// Method 1 - use glBufferSubData to reset an atomic counter.
const GLuint zero = 0; 
glBufferSubData(GL_ATOMIC_COUNTER_BUFFER, 2 * sizeof(GLuint), sizeof(GLuint), &zero);

// Method 2 - Map the buffer and write the value directly into it
GLuint * data = (GLuint *)glMapBufferRange(GL ATOMIC COUNTER BUFFER, 0, 16 * sizeof(GLuint), GL_MAP_WRITE_BIT | GL_MAP_INVALIDATE_RANGE_BIT);
data[2] = 0;
glUnmapBuffer(GL ATOMIC COUNTER BUFFER);

// Method 3 - use glClearBufferSubData
glClearBufferSubData(GL_ATOMIC_COUNTER_BUFFER, GL_R32UI, 2 * sizeof(GLuint), sizeof(GLuint), GL_RED_INTEGER, GL_UNSIGNED_INT, &zero);

现在已经创建了一个原子计数器变量专用的缓存对象,并在着色器中声明了一个原子计数器统一变量。原子计数器变量的递增函数为uint atomicCounterIncrement(atomic_uint c);。该函数读取了当前值,并加1,将结果存入变量中,并将未执行操作之前的值作为返回值。由于不同调用之间的执行顺序是不确定的,连续调用该函数两次不一定能够得到递增两次的结果。

原子计数变量的递减函数为uint atomicCounterDecrement(atomic_uint c);,该函数读取原子计数变量的值,并且执行递减操作,并将结果存入原子计数值中,并返回执行操作之后的值,注意这一点和地震函数相反。如果只有一个着色器访问该原子计数变量,调用顺序为递增-递减操作,原子计数变量的值不会改变。然而,在大多数情况下,多个着色器的调用会并行执行,因此通常不会得到上述结果。

如果只想获取原子计数变量的值,可以调用函数uint atomicCounter(atomic_uint c);,该函数仅仅返回当前的原子计数变量值。一个使用原子计数变量的实例如下。该示例展示了一个片段着色器每执行一次便对一个原子计数变量递增一次。

#version 410 core
layout (binding = 0, offset = 0) uniform atomic_uint area;
void main(void) {
  atomicCounterIncrement(area);
}

上述代码中没有任何输出变量,并且不会输出任何数据到帧缓(framebuffer)存中。实际上,在使用这个着色器时,故意禁用片段着色器写数据至帧缓存中。禁用片段着色器该输出功能调用函数glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE);,启用该功能调用函数glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);

因为原子计数变量存储在缓存中,因此可以绑定原子计数变量至另外的缓存目标(buffer target),例如GL_UNIFORM_BUFFER类型的绑定目标,然后从其中读取数据。这允许使用原子计数变量的值去控制着色器的执行,稍后程序将会允许这些着色器。下面的代码展示了通过统一闭包读取原子计数变量的值,并将其作为输出颜色计算的一部分。

#version 410 core
layout (binding = 0) uniform area_block {
  uint counter_value;
};
out vec4 color;
uniform float max_area;
void main(void) {
  float brightness = clamp(float(counter_value) / max_area, 0.0, 1.0);
  color = vec4(brightness, brightness, brightness, 1.0); 
}

当执行上述代码时,第一段代码仅仅计算出将要渲染的几何区域(area)。该属性接下来在第二段代码中并定义为area_block统一变量闭包的唯一成员counter_value。将该值除以最大的区域(maximum expected area),然后将其结果作为几何图形的亮度。考虑当使用这两个着色器渲染图形后将会发生什么。如果一个模型很贴近观察者,它将会显得更小,原子计数值并不会很大。原子计数变量的值将会被映射至第二个着色器的统一变量闭包中,参与将要被渲染的几何图形的亮度值计算。

4.1 同步原子计数变量访问(Synchronizing Access to Atomic Counters)

当着色器被执行的时候,原子计数变量的值将被保存在GPU内专用的内存空间(该内存空间经过优化,使得其计算速度相较于简单的着色器存储闭包成员的原子操作更快)。然而,当着色器执行完操作后,原子计数变量的值将会被写入到内存中。同样的,原子计数变量的递增和递减操作都被认为是内存操作的一种形式,因此容易受到前文描述的内存风险的影响。实际上,函数glMemoryBarrier()支持从OpenGL中同步访问原子计数变量。

调用函数glMemoryBarrier(GL_ATOMIC_COUNTER_BARRIER_BIT);能确保在一个缓存对象中任何对于一个原子计数变量的访问将会通过一个着色器映射为对应缓存的更新。当某些数据被写入到一个缓存中,并且想在其后的操作中访问该原子计数变量时,需要调用该函数。如果在一个缓存中更新了原子计数变量的值,然后接下来该缓存有其他用途,glMemoryBarrier中的参数必须和其用途相匹配,该参数除GL _ATOMIC _COUNTER _BARRIER _BIT外还可以指定其他类型。

同样的,该函数在GLSL中除了函数memoryBarrier(),还有对应的变形memory Barrier AtomicCounter(),这确保了原子计算变量的操作一定会在函数返回之前完成。

推荐阅读更多精彩内容