第五章-数据
本章我们会学到什么
- 如何创建缓冲和纹理,用它们来存储数据,以及程式如何访问数据。
- 如何使得OpenGL自动为我们的顶点属性提供数据。
- 如何从着色器中访问纹理和缓冲。
至今为止的示例中,我们要么在着色器直接使用硬编码的数据,要么将值一个一个地传入到着色器中。但要充分地演示OpenGL管线的构造,这很难代表现代图形编程。现代的图形处理器设计为流式处理器,可以吞吐大量的数据。一次给OpenGL传递很少的值是炒鸡没有效率的。要使得数据被OpenGL存储并访问,我们有两种主要的数据存储形式--缓冲和纹理。本章我们先介绍缓冲,它是无类型的线性数据块,可以被看成通用的内存配额。然后我们介绍纹理,它一般用来存储多维度数据,比如图像或者其他数据类型。
缓冲
在OpenGL中,缓冲是线性内存配额,可被用于多种用途。它们通过名字(names)来表示,名字就是OpenGL用来识别它们的句柄。在我们使用缓冲之前,先得要OpenGL为我们保留一些名字,然后用它们来分配内存并把数据放进去。为一个缓冲对象分别的内存被称为数据仓储(data store)。缓冲的数据仓储是OpenGL存放缓冲数据的地方。我们可以使用OpenGL命令来将数据放入缓冲,或者我们可以映射(map)缓冲对象得到一个指针,然后我们的应用可以使用这个指针直接读写缓冲。
一旦我们得到一个缓冲的名字,我们可以将它绑定到一个缓冲绑定点(buffer binding point)从而将它附加到OpenGL上下文。绑定点有时称为目标(targets),这些术语可以互换使用(从严格的技术角度来讲,目标 targets和绑定点 binding point是有区别的,一个目标可以有多个绑定点,不过,大多数情况下还是很容易理解真正的含义的)。在OpenGL中有很多的缓冲绑定点,并且每个都有不同的用处,尽管它们绑定的缓冲对象可能是同一个。比如:我们可以用缓冲的内容为顶点着色器自动提供输入;存储着色器会用到的变量的值;或者作为着色器存储生成数据的地方。我们甚至可以同时将一个缓冲用于多种用途。
创建缓冲并分配内存
在我们让OpenGL分配内存之前,我们需要先创建一个缓冲对象来表示这个配额。就像OpenGL中大多数对象一样,缓冲对象用GLuint变量来表示,这个变量也称为它的名字(names)。使用glCreateBuffers()函数可以创建一个或多个缓冲对象,它的原型为:
void glCreateBuffers(GLsizei n, GLuint* buffers);
glCreateBuffers()的第一个参数n
,是要创建的缓冲对象的数目。第二个参数buffers
,是用来存储缓冲对象名字的变量的地址。如果我们只需要创建一个缓冲对象,将n
设置为1,buffers
设置为单个GLuint变量的地址即可。如果我们需要一次创建多个缓冲,将n
设置为指定的数目,buffers
指向包含至少n
个GLuint变量的数组地址即可。OpenGL会假定这个数组足够大,它会向指定的地址写入n
个缓冲的名字。
从glCreateBuffers()获取到的每个名字都代表一个缓冲对象。我们可以调用glBindBuffer()将缓冲对象绑定到当前OpenGL上下文,glBindBuffer()的原型为:
void glBindBuffer(GLenum target, GLuint buffer);
在我们真正使用缓冲对象之前,我们需要分配它们的数据仓储(data stores),数据仓储是缓冲对象所使用内存的另一个术语。用来给一个缓冲对象分配内存的函数为glBufferStorage()何glNamedBufferStorage()。它们的原型为:
void glBufferStorage(GLenum target,
GLsizeiptr size,
const void* data,
GLbitfield flags);
void glNamedBufferStorage(GLuint buffer,
GLsieiptr size,
const void* data,
GLbitfield flags);
第一个函数作用于绑定到target
上绑定点的缓冲对象,第二个函数直接作用于buffer
指定的缓冲。其余的参数在两个函数中都是一样的。size
参数指定存储区域有多个字节大小。data
参数是一个指向任何数据的指针,用来初始化缓冲。如果data
为NULL
,那缓冲对象关联的存储在一开始不会被初始化。最后的参数flags
,用来指示OpenGL我们计划如何使用这个缓冲对象。
一旦我们使用glBufferStorage()或者glNamedBufferStorage()分配了缓冲对象的存储,存储就不能再重新分配或者重新指定,它可被当成是不可改变的。再清晰一点说,缓冲对象的数据仓储内容是可被改变的,但它的大小或者用途标志是不可更改的。如果我们要改变一个缓冲的大小,我们得删除它,创建一个新的,然后为这个新的缓冲设置新的存储。
这两个函数最有趣的参数是flags
。这个参数可以让OpenGL为我们开辟合适的内存提供足够的参考信息,并使得OpenGL为缓冲的存储需求做出明智的抉择。flags
是一个GLbitfield类型,这意味着它可以一个或多个位的组合。可以设置的标志值如表5.1。
表5.1 缓冲存储标志:
Flags Description
GL_DYNAMIC_STORAGE_BIT 缓冲的内容可以直接更新
GL_MAP_READ_BIT 缓冲的数据仓储可被映射进行读取
GL_MAP_WRITE_BIT 缓冲的数据仓储可被映射进行写入
GL_MAP_PERSISTENT_BIT 缓冲的数据仓储可被持久映射
GL_MAP_COHERENT_BIT 缓冲的映射是无缝的
GL_CLIENT_STORAGE_BIT 如果其他所有的条件都能满足,就将存储放在本地客户端(CPU),否则放在服务端(GPU)
表5.1列举的标志看起来有一点过于简洁,需要一些更多的解释。特别是有一些重要的标志的缺失会影响到OpenGL,有一些标志只能和其他的组合使用,这些标志的指定会影响到我们之后能对缓冲做些什么。我们在此会对这些标志做一个简短的解释,在之后涉及到深层次的功能时会深入了解其中的一些含义。
首先GL_DYNAMIC_STORAGE_BIT
标志用以指示OpenGL可能每次我们使用这些数据时都会直接更新缓冲的内容。如果没有设置这个标志,OpenGL会假设我们不会改变缓冲的内容,并将数据放到不易访问的地方。如果没有设置这个标志,我们无法使用glBufferSubData()之类的命令来更新缓冲的内容,尽管我们可以在GPU中使用其他OpenGL命令直接写入。
映射标志GL_MAP_READ_BIT
、GL_MAP_WRITE_BIT
、GL_MAP_PERSISTENT_BIT
、GL_MAP_COHERENT_BIT
指示OpenGL我们是否以及如何计划映射缓冲的数据仓储。映射就是获取一个指针,这个指针表示缓冲的底层数据仓储,我们可以在应用中使用它。比如我们可以指定GL_MAP_READ_BIT
或者GL_MAP_WRITE_BIT
来映射缓冲分别只进行读或者写访问。当然如果我们想映射缓冲用以读以及写,可以将这两个标志都指定。如果我们指定GL_MAP_PERSISTENT_BIT
,这个标志指示OpenGL我们要映射这个缓冲,并在我们调用其他绘制命令时将缓冲仍置于已映射状态。如果我们不设置这个标志,那我们在绘制命令中使用缓冲时OpenGL会将其置于未映射状态。支持持久映射(persistent map)会对性能产生一些花销,所以除非我们真的需要,不然最好不要设置这个标志。最后GL_MAP_COHERENT_BIT
标志会指示OpenGL我们想要和GPU共享十分紧密的数据。如果我们未设置这个标志位,当我们写入数据到缓冲后需要告诉OpenGL,就算我们并没有取消这个缓冲的映射。
清单 5.1 创建并初始化一个缓冲:
// The type used for names in OpenGL is GLuint
GLuint buffer;
// Create buffer
glCreateBuffer(1, &buffer);
// Specify the data store parameters for the buffer
glNamedBufferStorage(buffer, // Name of the buffer
1024 * 1024, // 1 MiB of space
NULL, // No initial data
GL_MAP_WRITE_BIT); // Allow map for writing
// Now bind it to the context using the GL_ARRAY_BUFFER binding point
glBindBuffer(GL_ARRAY_BUFFER, buffer);
清单5.1的代码执行后,buffer
包含一个缓冲对象的名字,缓冲对象已经被初始化了,用以表示我们选定数据的1兆字节存储。使用GL_ARRAY_BUFFER
目标引用缓冲对象提示OpenGL我们计划使用这个缓冲存储顶点数据,不过之后我们仍可以将这个缓冲绑定到其他的目标上。有好几种方法将数据放入缓冲对象。你可能已注意到在清单5.1中我们将NULL
作为第三个参数传递给glNamedBufferStorage()。若我们代之以一个指向一些数据的指针,这些数据会用来初始化这个缓冲对象。然而使用这个指针我们只能让初始数据存入缓冲中。
将数据放入缓冲的另一种方法是把缓冲给OpenGL并指示它将数据拷贝到那。这使得我们可以在缓冲初始化之后动态地更新它的内容。我们可以调用glBufferSubData()或者glNamedBufferSubData()来做这件事,传递我们要放入到缓冲中的数据的大小,从哪开始的偏移,以及要放入缓冲的数据的内存指针。glBufferSubData()和glNamedBufferSubData()声明如下:
void glBufferSubData(GLenum target,
GLintptr offset,
GLsizeiptr size,
const GLvoid* data);
void glNamedBufferSubData(GLuint buffer,
GLintptr offset,
GLsizeiptr size,
const void* data);
要使用glBufferSubData()来更新一个缓冲对象,我们必须告诉OpenGL我们想以这种方式来放入数据。在传递给glBufferStorage()或者glNamedBufferStorage()的flags
参数中包含GL_DYNAMIC_STORAGE_BIT
以期达成此目的。一如glBufferStorage()和glNamedBufferStorage(),glBufferSubData()作用于target
目标的绑定点绑定的缓冲,glNamedBufferStorage()作用于buffer
指定的缓冲对象。清单5.2展示了我们如果将数据(原先是清单3.1中使用过的)放入到缓冲对象中,这是为顶点着色器自动供应数据的第一步。
清单5.2 用glBufferSubData()更新缓冲的内容:
// 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);
另一种将数据放入缓冲对象的方法是让OpenGL得到一个表示缓冲对象内存的指针,然后自个把数据拷贝到目的地。这就是所谓的映射(mapping)缓冲。清单5.3展示了用glMapNamedBuffer()来达成此目的。
清单5.3 用glMapNamedBuffer()映射一个缓冲的数据仓储:
// 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 point to the buffer's data store
void* ptr = glMapNamedBuffer(buffer, GL_WRITE_ONLY);
// Copy our data into it...
memcpy(ptr, data, sizeof(data));
// Tell OpenGL that we're done with the pointer
glUnmapNamedBuffer(buffer);
就好像OpenGL中很多其他的函数一样,有两个版本--一个作用于当前上下文中目标绑定的缓冲,一个直接作用于用名字指定的缓冲。它们的原型如下:
void* glMapBuffer(GLenum target,
GLenum usage);
void* glMapNamedBuffer(GLuint buffer,
GLenum usage);
我们调用glUnmapBuffer()或者glUnmapNamedBuffer()来取消对缓冲的映射,就像清单5.3中所示。它们的原型为:
void glUnmapBuffer(GLenum target);
void glUnmapNamedBuffer(GLuint buffer);
当我们调用一个函数时如果我们并没有准备好所有的数据,此时映射一个缓冲就很有用处了。比如我们可能要生成数据,或者从文件中读入数据。如果我们要使用glBufferSubData()(或者传递给glBufferData()的初始指针),我们得将生成或读入的数据先放到一个临时的内存中,然后让OpenGL生成一份数据的拷贝放入到缓冲对象。如果我们映射了一个缓冲,我们可以简单地将文件的内容直接读入到映射的缓冲中。当我们取消对它的映射时,如果OpenGL可以避免生成一份数据的拷贝,那它就不会生成拷贝。不管我们是用glBufferSubData()还是glMapBuffer()加一份放入到缓冲对象的数据的显示拷贝,之后缓冲包含了data[]
的一份拷贝,然后我们就可以使用缓冲做为数据源来为顶点着色器提供数据。
glMapBuffer()和glMapNamedBuffer()函数有时候过于手工了。它们映射整个缓冲,并且除了usage
参数外不会为要执行的映射操作类型提供任何信息。甚至于usage
参数只是做为提示而已。一个更人性化的方法是用glMapBufferRange()或者glMapNamedBufferRange(),它们的原型为:
void* glMapBufferRange(GLenum target,
GLintptr offset,
GLsizeiptr length,
GLbitfield access);
void* glMapNamedBufferRange(GLuint buffer,
GLintptr offset,
GLsizeiptr length,
GLbitfield access);
一如glMapBuffer()和glMapNamedBuffer()函数,这些函数有两个版本--一个作用于当前绑定的缓冲,一个作用于直接指定的缓冲对象。这两个函数并不是映射整个缓冲对象,而是映射缓冲对象指定的一个区域。这个区域使用offset
和length
参数进行指定。access
包含了一些标志,用以告诉OpenGL映射应该如何执行。这些标志可以是表5.2中任意标志位的组合。
表5.2 缓冲映射标志:
Flag Description
GL_MAP_READ_BIT 缓冲数据仓储映射用以读入
GL_MAP_WRITE_BIT 缓冲数据仓储映射用以写出
GL_MAP_PERSISTENT_BIT 缓冲数据仓储可被持久映射
GL_MAP_COHERENT_BIT 缓冲映射是无缝的
GL_MAP_INVALIDATE_RANGE_BIT 告诉OpenGL我们不再在乎指定区域内的数据
GL_MAP_INVALIDATE_BUFFER_BIT 告诉OpenGL我们不再在乎整个缓冲的数据
GL_MAP_FLUSH_EXPLICIT_BIT 我们保证告诉OpenGL在映射区域修改的数据
GL_MAP_UNSYNCHRONIZED_BIT 告诉OpenGL我们会自己执行所有的同步
一如我们可以传递给glBufferStorage()的标志位,这些标志位可以控制一些OpenGL的高级功能,并且在某些情况下,它们得以正确使用依赖于其他OpenGL功能。然而这些标志位并不是提示,OpenGL会强制要求正确使用它们。如果我们打算从缓冲中进行读取那我们要设置GL_MAP_READ_BIT
,如果我们打算写入到缓冲那我们要设置GL_MAP_WRITE_BIT
。对映射区域进行读写而没有设置相应的标志为将会引发错误。GL_MAP_PERSISTENT_BIT
和GL_MAP_COHERENT_BIT
标志与glBufferStorage()中同名的标志有着相同的含义。这四个标志位在请求映射时必须与指定数据仓储的时候一致。换言之,如果我们使用GL_MAP_READ_BIT
映射一个缓冲以进行读取,那我们在调用glBufferStorage()(或者glNamedBufferStorage())时必须也指定了GL_MAP_READ_BIT
标志。
当我们在本书后面涉及到图元同步时我们会深入这里其他的标志。不过因为glMapBufferRange()和glMapNamedBufferRange()提供的额外控制和更强的约束,我们应该倾向于使用这些函数,而不是glMapBuffer()(或者glMapNamedBuffer())。就算我们不使用它们更多的高级特性我们也应该养成使用它们的习惯。
填充数据到缓冲以及拷贝数据到缓冲
使用glBufferStorage()为我们的缓冲对象分配存储空间之后,下一步可能就是为缓冲填充已知的数据。不管我们用glBufferStorage()的初始data
参数,还是使用glBufferSubData()将数据放入缓冲,或者使用glMapBufferRange()获取一个缓冲数据仓储的指针,然后在应用中填充数据,我们都需要在高效地使用缓冲之前将它置于一个已知状态。如果我们要放入缓冲的数据是常量值,用glClearBufferSubData()或者glClearNamedBufferSubData()会更有效率,它们的原型为:
void glClearBufferSubData(GLenum target,
GLenum internalformat,
GLintptr offset,
GLsizeiptr size,
GLenum format,
GLenum type,
const void* data);
void glClearNamedBufferSubData(GLuint buffer,
GLenum internalformat,
GLintptr offset,
GLsizeiptr size,
GLenum format,
GLenum type,
const void* data);
这些函数接收一个指针,指针指向的变量包含有我们想要用来清除缓冲对象的值,然后依照internalformat
指定的格式进行转换,转换后的数据复制到offset
和size
(以字节为单位)指定的缓冲数据仓储区域内。format
和type
告诉OpenGLdata
指向的数据的信息。format
可以是GL_RED
、GL_RG
、GL_RGB
、GL_RGBA
其中之一,它们分别用来指定单通道、双通道、三通道、四通道数据。同时,type
表示数据的类型。比如它可以是GL_UNSIGNED_BYTE
或者GL_FLOAT
,分别用以指定无符号字节或者浮点数据。OpenGL支持的常用类型和它们相应的C数据类型如表5.3。
表5.3 基本的OpenGL类型符号以及相应的C类型:
Type Token C 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
一旦数据发送到GPU,我们完全有可能想在多个缓冲间共享那份数据或者从一个缓冲拷贝到另一个缓冲。OpenGL为此提供了很简便的手段。glCopyBufferSubData()和glCopyNamedBufferSubData()让我们指定使用哪些缓冲以及相应的大小和偏移。
void glCopyBufferSubData(GLenum readtarget,
GLenum writetarget,
GLintptr readoffset,
GLintptr writeoffset,
GLsizeiptr size);
void glCopyNamedBufferSubData(GLuint readBuffer,
GLuint writeBuffer,
GLintptr readOffset,
GLintptr writeOffset,
GLsizeiptr size);
对于glCopyBufferSubData()来说,readtarget
和writetarget
是我们想要拷贝数据的缓冲绑定的目标。它们可以是绑定到任何可用缓冲绑定点的缓冲。然而,因为缓冲绑定点同一时刻只能有一个绑定的缓冲,所以我们不能在两个绑定到同一目标的缓冲间拷贝数据,比如两个绑定到GL_ARRAY_BUFFER
目标的缓冲。所以当我们进行拷贝时,我们需要挑选两个目标以绑定缓冲,而这样会影响到OpenGL的状态。
为了解决这一问题(拷贝可能影响到OpenGL的状态),OpenGL提供了GL_COPY_READ_BUFFER
和GL_COPY_WRITE_BUFFER
目标。这两个目标专门添加以使我们从一个缓冲拷贝数据到另一个缓冲而不引起非预期的副作用。这是因为这两个目标不会在OpenGL其他地方使用,我们可以将读和写的缓冲绑定到这些缓冲绑定点而不影响其他的缓冲目标。
做为另一种选择,我们可以使用glCopyNamedBufferSubData(),它直接接收两个缓冲的名字。当然我们可以将readBuffer
和writeBuffer
指定为同一个缓冲,然后在两个不同的偏移之间拷贝一段数据。不过值得注意的是要拷贝的区域不能重叠,否则这种情况下拷贝的结果将是未定义的。我们可以将作用于缓冲对象的glCopyNamedBufferSubData()函数当做成C的memcpy
函数。
readoffset
指示OpenGL要读取数据的缓冲的位置,writeoffset
指示OpenGL要写入数据的缓冲的位置,size
参数指示要拷贝多大的数据。需要确保的是无论是读取还是写入的区域都必须在绑定的缓冲之内,否则拷贝就会失败。
我们注意到readoffset
、writeoffset
和size
的类型是GLintptr
和GLsizeiptr
。这些类型是整型的特殊定义,它们都至少可以存放一个指针变量。
使用缓冲为顶点着色器提供数据
在第二章“我们的第一个OpenGL程式”中,我们简要介绍了顶点数组对象(VAO)。彼时我们解释了VAO如何表示顶点着色器的输入--不过那时我们并没有使用任何真实的输入给顶点着色器而是取而代之以硬编码的数据数组。然后在第三章我们介绍了顶点属性(vertex attributes)的概念,但我们只是讨论了如何修改它们的静态值。尽管顶点数组对象为我们存储了这些静态属性值,但它可以做更多的事。在我们进一步探索之前,我们需要创建一个顶点数组对象来存储我们的顶点数组状态并将它绑定到我们的上下文,这样我们才能使用这个顶点数组对象:
GLuint vao;
glCreateVertexArrays(1, &vao);
glBindVertexArray(vao);
现在我们的VAO已经创建并绑定了,我们可以开始填充它的状态了。与其在顶点着色器中使用硬编码的数据,我们可以完全依赖顶点属性的值并让OpenGL使用我们提供的存储在缓冲对象中的数据来自动填充这个顶点属性。每个顶点属性从一个绑定到某个顶点缓冲绑定(vertex buffer bindings)的缓冲获取数据。要设置顶点属性引用缓冲的绑定,调用glVertexArrayAttribBinding()函数:
void glVertexArrayAttribBinding(GLuint vaobj,
GLuint attribindex,
GLuint bindingindex);
glVertexArrayAttribBinding()函数指示OpenGL当vaobj
顶点数组对象绑定后,attribindex
指定的顶点属性应当从bindingindex
绑定的缓冲获取数据。要告诉OpenGL数据在哪个缓冲对象中以及数据在缓冲对象的哪个地方,我们使用glVertexArrayVertexBuffer()函数来绑定缓冲到一个顶点缓冲绑定。我们使用glVertexArrayAttribFormat()函数来描述数据的结构和格式,最后我们使用glEnableVertexAttribArray()启用属性的自动填充。glVertexArrayVertexBuffer的原型为:
void glVertexArrayVertexBuffer(GLuint vaobj,
GLuint bindingindex,
GLuint buffer,
GLintptr offset,
GLsizei stride);
其中,参数vaobj
是我们要修改状态的顶点数组对象。bindingindex
是顶点缓冲的索引,与传递给glVertexArrayAttribBinding()的参数一致。buffer
参数指定我们正在绑定的缓冲对象的名字。offset
和stride
告诉OpenGL顶点属性的数据在缓冲对象的何处。offset
指出第一个顶点的数据始于何处,stride
指出每个顶点数据相距多远。都以字节为单位。
接下来我们看看glVertexArrayAttribFormat()的原型:
void glVertexArrayAttribFormat(GLuint vaobj,
GLuint attribindex,
GLint size,
GLenum type,
GLboolean normalized,
GLuint relativeoffset);
其中第一个参数vaobj
仍然是我们要修改状态的顶点数组对象。attribindex
是顶点属性的索引。我们可以给一个顶点着色器定义很多属性作为输入,然后用它们的索引引用它们,一如第三章中“顶点属性”一节所释。size
是缓冲中存储的每个顶点为指定顶点属性分配的分量数量,type
是分量的数据类型,通常是表5.3中的一种类型。
还记得glVertexArrayVertexBuffer()的stride
参数指示OpenGL一个顶点数据的起始与紧连下一个顶点的起始相距多少个字节,但我们可以将这个参数设置为0,以此让OpenGL依据glVertexArrayAttribFormat()中size
和type
参数计算此值。
normalized
参数指示OpenGL缓冲中的数据在传递给顶点着色器之前是否应被标准化(缩放到-1.0到1.0或者0.0至1.0之间)。这个参数对于浮点数来说无关紧要,但对整型数据,诸如GL_UNSIGNED_BYTE
、GL_INT
来说很重要。举个栗子,如果GL_UNSIGNED_BYTE
数据被标准化,它在传递给顶点着色器的浮点输入之前会被除上255(一个无符号字节所能表达的最大值)。所以着色器将会看到输入属性的值在0.0到1.0之间。然而,如果数据不被标准化,它将被简单地转换为浮点值,着色器接收的值将在0.0到255.0之间。
最后relativeoffset
是指定属性数据起始在顶点数据中的偏移。这些看起来都很复杂,但计算在一个缓冲对象中的顶点属性位置的伪代码是很简单的:
location = binding[attrib.binding].memory + // Start of data store in memory
binding[attrib.binding].offset + // Offset of vertex attribute in buffer
binding[attrib.binding].stride * vertex.index + // Start of *this* vertex
vertex.relative_offset; // Start of attribute relative to vertex
译者注:译者为此做了一张图来表示这个关系(终于可以一展我美术科代表的实力了)
最后glEnableVertexAttribArray()以及相反的glDisableVertexAttribArray()有如下原型:
void glEnableVertexAttribArray(GLuint index);
void glDisableVertexAttribArray(GLuint index);
当一个顶点属性被启用,OpenGL将会基于我们使用glVertexArrayVertexBuffer()和glVertexArrayAttribFormat()提供的格式以及位置信息为顶点着色器供给数据。当一个顶点属性被禁用,提供给顶点着色器的数据将是我们调用glVertexAttrib*()提供的静态信息。
清单5.4展示了如何使用glVertexArrayVertexBuffer()和glVertexArrayAttribFormat()来配置顶点属性。值得注意的是在设置偏移、跨度和格式信息之后我们还调用了glEnableVertexArrayAttrib()。这指示OpenGL使用缓冲中的数据来填充顶点属性,而不是我们使用glVertexAttrib*()提供的数据。
清单5.4 设置一个顶点属性:
// First, bind a vertex buffer to the VAO
glVertexArrayVertexBuffer(vao, // Vertex array object
0, // First vertex buffer binding
buffer, // Buffer object
0, // Start from the beginning
sizeof(vmath::vec4)); // Each vertex is one vec4
// Now, describe the data to OpenGL, tell it where it is, and turn on automatic
// vertex fetching for the specified attribute
glVertexArrayAttribFormat(vao, // Vertex array object
0, // First attribute
4, // Four components
GL_FLOAT, // Floating-point data
GL_FALSE, // Normalized - ignored
0); // First element of the vertex
glEnableVertexArrayAttrib(vao, 0);
清单5.4的代码执行之后,OpenGL将会使用通过glVertexArrayVertexBuffer()绑定到VAO的缓冲的数据自动补充顶点着色器的第一个属性。
译者注:注意glEnableVertexAttribArray和glEnableVertexArrayAttib的参数可是不同的,具体细节请读者自行Google。
我们可以修改顶点着色器只使用输入顶点属性而不是硬编码的数组。更新过后的顶点着色器如清单5.5。
清单5.5 在顶点着色器中使用属性:
#version 450 core
layout (location = 0) in vec4 position;
void main(void)
{
gl_Position = position;
}
我们可以看到,清单5.5的着色器比第二章中的原先的着色器简单多了。去掉了硬编码的数组数据。得到加强的是,这个着色器可被任意个顶点使用。我们可以将数以千计的顶点数据放入缓冲对象,然后用一个命令来绘制它们,比如调用一次glDrawArrays()。
如果我们已经完成了使用缓冲对象的数据填充顶点属性,我们可以调用glDisableVertexAttribArray()来禁用这个属性。一旦我们禁用了某个顶点属性,它将会变为静态的,并且使用glVertexAttrib*()指定的值。
为有多个输入的顶点着色器供给数据
正如我们已经学到的,我们可以让OpenGL使用放在缓冲对象中的数据提供给顶点着色器。我们还可以为顶点着色器声明多个输入,并为每个输入赋予一个独一无二的位置从而对其进行引用。将这些组合起来就意味着我们可以让OpenGL为多个输入的顶点着色器同时提供数据。考量如清单5.6中的顶点着色器的输入声明。
清单5.6 为一个顶点着色器声明两个输入:
layout (location = 0) in vec3 position;
layout (location = 1) in vec3 color;
如果我们有一个链接好的程式对象,并且它的顶点着色器有多个输入,我们可以通过调用glGetAttribLocation()判断它的输入的位置,它的原型如:
void glGetAttribLocation(GLuint program, const GLchar* name);
其中program
是包含顶点着色器的程式对象,name
是顶点属性的名字。在清单5.6中示例的声明中,传递"position"给glGetAttribLocation()会得到0,传递"color"会得到1。传递一个不是顶点着色器输入的名字会得到-1。当然,如果我们总是在着色器代码中为顶点属性指定位置,那调用glGetAttribLocation()得到的值总是我们指定的位置。如果我们没有在着色器代码中为输入指定位置,OpenGL会帮我们赋予一个位置,那个位置就是glGetAttribLocation()返回的值。
有两种方式将我们应用中的数据与顶点着色器的输入相关联,一种是独立属性(separate attributes),另一种是交错属性(interleaved attributes)。当属性是独立的,它们要么位于不同的缓冲或者至少在同一个缓冲的不同位置。打个比方,如果我们想为两个顶点属性提供数据,我们可以创建两个缓冲对象,每一个缓冲对象使用glVertexArrayVertexBuffer()绑定到不同的顶点缓冲绑定,然后为每个属性调用glVertexArrayAttribBinding()时分别指定刚才的顶点缓冲点的索引。另一选择是只创建一个缓冲对象,将数据放在同一个缓冲的不同偏移处,通过分别为每个属性调用glVertexArrayVertexBuffer()和glVertexArrayAttribBinding()且传递同样的顶点缓冲绑定点索引。清单5.7展示了创建两个缓冲的方法。
清单5.7 多个缓冲对象供给多输入顶点着色器:
GLuint buffer[2];
GLuint vao;
static const GLfloat positions[] = { ... };
static const GLfloat colors[] = { ... };
// Create the vertex array object
glCreateVertexArrays(1, &vao);
// Get create two buffers
glCreateBuffers(2, &buffer[0]);
// Initialize the first buffer
glNamedBufferStorage(buffer[0], sizeof(positions), positions, 0);
// Bind it to the vertex array - offset zero, stride = sizeof(vmath::vec3)
glVertexArrayVertexBuffer(vao, 0, buffer[0], 0, sizeof(vmath::vec3));
// Tell OpenGL what the format of the attribute is
glVertexArrayAttribFormat(vao, 0, 3, GL_FLOAT, GL_FALSE, 0);
// Tell OpenGL which vertex buffer binding to use for this attribute
glVertexArrayAttribBinding(vao, 0, 0);
// Enable the attribute
glEnableVertexArrayAttrib(vao, 0);
// Perform similar initialization for the second buffer
glNamedBufferStorage(buffer[1], sizeof(colors), colors, 0);
glVertexArrayVertexBuffer(vao, 1, buffer[1], 0, sizeof(vmath::vec3));
glVertexArrayAttribFormat(vao, 1, 3, GL_FLOAT, GL_FALSE, 0);
glVertexArrayAttribBinding(vao, 1, 1);
glEnableVertexAttribArray(1);
译者注:我们同样可以展示下只创建一个缓冲对象的代码,见清单5.7-a(这种奇怪的编码是为了不打乱原书的代码清单编码):
清单5.7-a 单个缓冲对象供给多输入顶点着色器(直接拿清单5.7的代码改了,我感觉我懒到没救了):
GLuint buffer[1];
GLuint vao;
static const GLfloat positions[] = { ... };
static const GLfloat colors[] = { ... };
// Create the vertex array object
glCreateVertexArrays(1, &vao);
// Get create one buffer
glCreateBuffers(1, &buffer[0]);
// Initialize the buffer
glNamedBufferStorage(buffer[0], sizeof(positions)+sizeof(colors), NULL, 0);
glNamedBufferSubData(buffer[0], 0, sizeof(positions), positions);
glNamedBufferSubData(buffer[0], sizeof(positions), sizeof(colors), colors);
// Bind it to the vertex array - offset zero, stride = sizeof(vmath::vec3)
glVertexArrayVertexBuffer(vao, 0, buffer[0], 0, sizeof(vmath::vec3));
// Tell OpenGL what the format of the attribute is
glVertexArrayAttribFormat(vao, 0, 3, GL_FLOAT, GL_FALSE, 0);
// Tell OpenGL which vertex buffer binding to use for this attribute
glVertexArrayAttribBinding(vao, 0, 0);
// Enable the attribute
glEnableVertexArrayAttrib(vao, 0);
// Perform similar initialization for the second buffer
glVertexArrayVertexBuffer(vao, 1, buffer[0], sizeof(positions), sizeof(vmath::vec3));
glVertexArrayAttribFormat(vao, 1, 3, GL_FLOAT, GL_FALSE, 0);
glVertexArrayAttribBinding(vao, 1, 1);
glEnableVertexAttribArray(1);
这种情况缓冲对象的内存布局看起来像是这样(只给自己的代码配图会不会有点过分???):
在独立属性的各种情况下,我们将数据使用紧凑打包的(tightly packed)数组来提供给顶点属性。这实际上是数组的结构体(structure-of-arrays SoA)数据。我们有一系列打包紧凑的、独立的数据的数组。然而还可以用结构体的数组(array-of-structures AoS)数据形式,也就是交错属性。考量如下结构表示一个顶点:
struct vertex
{
// Position
float x;
float y;
float z;
// Color
float r;
float g;
float b;
};
现在我们将顶点着色器的两个输入(位置position和颜色color)交织在了一个结构体中。显而易见的是如果我们创建一个这个结构体的数组,我们的数据就是一个AoS(array-of-structure)结构。要使用glVertexArrayVertexBuffer()表示这,我们得使用它的stride
参数。stride
参数指示OpenGL每个顶点数据的起始相距多少字节。如果我们将它置为0,OpenGL将会为每个顶点使用同样的数据。不过要使用上面声明的vertex
结构,我们可以简单地将stride
参数设置为sizeof(vertex)
就行,然后一切就会运转如常。清单5.8展示了代码完成这项工作。
清单5.8 交错存储的顶点属性:
GLuint vao;
GLuint buffer;
static const vertex vertices[] = { ... };
// Create the vertex array object
glCreateVertexArrays(1, &vao);
// Allocate and initialize a buffer object
glCreateBuffers(1, &buffer);
glNamedBufferStorage(buffer, sizeof(vertices), vertices, 0);
// Set up two vertex attributes - first positions
glVertexArrayAttribBinding(vao, 0, 0);
glVertexArrayAttribFormat(vao, 0, 3, GL_FLOAT, GL_FALSE, offsetof(vertex, x));
glEnableVertexArrayAttrib(vao, 0);
// Now colors
glVertexArrayAttribBinding(vao, 1, 0);
glVertexArrayAttribFormat(vao, 1, 3, GL_FLOAT, GL_FALSE, offsetof(vertex, r));
glEnableVertexArrayAttrib(vao, 1);
// Finally, bind our one and only buffer to the vertex array object
glVertexArrayVertexBuffer(vao, 0, buffer);
执行完清单5.8的代码后,我们可以绑定顶点数组对象并且开始从绑定到它的缓冲获取数据。
译者注:(我的话会不会有点太多了)实际上我们只需要保证属性的数据在缓冲对象的存储中是交错的就行,并不一定非要使用一个vertex结构体来表示顶点的数据,虽然那么做显然要好看很多。清单5.8-a的代码可供参阅:
清单5.8-a 交错存储的顶点属性:
// 如清单5.7一样原始数据是分开的
static const GLfloat positions[] = { ... };
static const GLfloat colors[] = { ... };
// 交错存储到缓冲对象就行
glNamedBufferStorage(buffer, sizeof(positions)+sizeof(colors), NULL, 0);
static const int vertex_unit_size = sizeof(GLfloat)*3/*position size*/+sizeof(GLfloat)*3/*+color size*/;
for (int vertexi=0; vertexi < sizeof(positions)/(sizeof(GLfloat)*3); ++ vertexi) {
glNamedBufferSubData(buffer, vertex_unit_size*vertexi, sizeof(GLfloat)*3, positions+sizeof(GLfloat)*3*vertexi);
glNamedBufferSubData(buffer, vertex_unit_size*vertexi+sizeof(GLfloat)*3, sizeof(GLfloat)*3, colors+sizeof(GLfloat)*3*vertexi);
}
这种情况缓冲对象的内存布局看起来像是这样:
在使用glVertexArrayAttribFormat()设置顶点的格式信息之后,我们可以之后调用glVertexArrayAttribBinding()来更改已绑定的顶点缓冲。如果我们要渲染很多保存在不同缓冲中的几何图形,但它们顶点格式都一样,这种情况下我们可以简单地调用glVertexArrayAttribBinding()来切换缓冲然后开始绘制它们。
从文件载入对象
可以预见的一种情况是我们可能会在一个顶点着色器中使用大量的顶点属性。随着我们阅览各种技术,将会发现我们通常需要使用到四、五个顶点属性或者可能更多的。将缓冲填充好数据供给所有的属性,然后设置好顶点数组对象和所有的顶点属性指针,这实在是一件无聊的事情。�另外将我们所有的几何数据写在应用里也是一种很业余的行为。所以将模型数据存储在文件中然后在我们的应用中载入才是正道。当今世上有很多种类的模型文件格式,并且大多数的建模程式也都支持好几种普遍的格式。
为了本书的教学目的,我们发明了一个简单的对象文件定义,叫做.SBM,它存储了我们需要的信息,又不至于太简单或者太工程性质。这个格式的完整文档可在"附录B-SBM文件格式"( 简书 )中查看。sb7
框架中还包含有一个此模型文件的加载器,叫做sb7::object
。要加载一个对象文件,创建一个sb7::object
的实例,然后调用这个实例的load
方法,代码如下:
sb7::object my_object;
my_object.load("filename.sbm");
这个操作成功执行之后,文件中的模型会被加载到sb7::object
的实例中,然后我们就可以渲染它了。在加载的过程中,这个类会创建并设置好对象的顶点数组对象,然后配置好模型文件中的所有顶点属性。这个类还包含有一个render
的方法,这个方法绑定了对象的顶点数组对象并调用相应的绘制命令。比如调用:
my_object.render();
会使用当前的着色器渲染对象的一个拷贝。在本书剩下部分的诸多示例中,我们将使用这个对象加载器来方便地加载对象文件(有一些对象文件包含在本书的源代码中)并渲染它们。
一致变量(Uniforms)
尽管一致变量不是一种真正的存储形式,但它的确是将数据从我们的应用传递到着色器的一个重要手段。我们已经领略过如何使用顶点属性将数据传递到顶点着色器中以及如何使用数据块接口(interface block)在不同的阶段间传递数据。一致变量使得我们可以直接从应用中将数据传递到任何着色器阶段。一致变量有两个变种,它们的区别在于声明的方式。第一种是在缺省区块(default block)中声明的一致变量,第二种是在一致区块(uniform blocks)中声明的,一致区块中声明的一致变量的值存储在缓冲对象中。我们将会对这两种一致变量进行讨论。
缺省区块一致变量(Default Block Uniforms)
对于每一个顶点的位置、表面法线、纹理坐标来说都需要属性,但一致变量可以让我们在一次完整的图元批次中甚至更长的阶段内将相同一致的数据传递到着色器的各个阶段。有一个可能非常普遍的一致变量就是顶点着色器的变换矩阵。我们在顶点着色器中使用变换矩阵来操作顶点位置和其他向量。任何着色器变量都可以被指定为一致变量,并且一致变量可以存在于任何着色器阶段(尽管本章我们只讨论顶点和片段着色器)。创建一个一致变量是如此的简单,只需要把关键字uniform
放在变量的声明之前就行:
uniform float fTime;
uniform int iIndex;
uniform vec4 vColorValue;
uniform vec4 mvpMatrix;
一致变量应该总是被认为是无法在着色器代码中进行赋值的。不过我们可以在声明的时候给它设置初始值:
uniform int answer = 42;
如果我们在多个着色器阶段声明同样的一致变量,那其中任何一个阶段都会看到一样的值。
�排列我们的一致变量
在着色器被编译并链接进程式对象中之后我们可以使用OpenGL中定义的诸多函数来设置一致变量的值(假设我们不想使用着色器定义的缺省值)。一如顶点属性一般,在城市对象中这些函数也都使用一致变量的位置(location)来引用它。在着色器代码中我们可以使用位置(location)布局指示器(layout qualifier)来指定一致变量的位置。当我们在着色器中指定了一致变量的位置之后,OpenGL就会试图将我们指定的位置赋予一致变量。位置布局指示器看起来像这样:
layout (location = 17) uniform vec4 myUniform;
注意一致变量和顶点着色器输入变量的位置布局指示器的相似之处。在上例中myUniform
会被分配到17的位置。如果我们不在着色器代码中为一致变量指定位置,OpenGL将会自动为它分配一个位置。我们可以使用glGetUniformLocation()函数来获取自动分配的位置,这个函数原型如:
GLint glGetUniformLocation(GLuint program, const GLchar* name);
这个函数一个有符号整型数,用以表示程式program
中名为name
的变量的位置。比如我们想要获取一个名为vColorValue
的一致变量的位置,我们可以编码如下:
GLint iLocation = glGetUniformLocation(myProgram, "vColorValue");
在上例中将"myUniform"传给glGetUniformLocation()会得到返回值17。如果我们在着色器中指定了一致变量的位置从而使得我们事先知道它们的位置,那我们就可以避免调用glGetUniformLocation()来找到它们。这是一种比较推荐的做法。
如果glGetUniformLocation()的返回值为-1,这表示指定的一致变量名称无法在程式中找到。我们要记住一件事情,就算一个着色器成功编译,但如果一个一致变量在附加的着色器中一次都没有直接使用到,那它可能会从程式中“消失”--即便我们在着色器代码中为它显示地赋予了一个位置。我们无需担心这种一致变量被优化掉的情况,如果我们声明一个一致变量却没有使用它,编译器就会忽略它。另外着色器变量名称是大小写敏感的,所以检索一致变量位置时得注意。
设置一致变量
OpenGL在着色器语言和API中支持一大票的数据类型。为了让我们得以传递数据,OpenGL包含有大量的函数来设置一致变量的值。单个标量或矢量的数据类型可以使用如下glUniform*()函数的各个变种进行设置。
假设在一个着色器中声明了如下四个变量:
layout (location = 0) uniform float fTime;
layout (location = 1) uniform int iIndex;
layout (location = 2) uniform vec4 vColorValue;
layout (location = 3) uniform bool bSomeFlag;
为了找到并设置着色器中的这些一致变量,我们的C/C++代码看起来像这样:
glUseProgram(myShader);
glUniform1f(0, 45.2f);
glUniform1i(1, 42);
glUniform4f(2, 1.0f, 0.0f, 0.0f, 1.0f);
glUniform1i(3, GL_FALSE);
值得注意的是我们使用glUnifrom*()的整型版本传递bool值。布尔值同样可以使用浮点值来传递,其中0.0表示false,其他非零值表示true。
glUnifrom*()函数还有一个接收指针(可能是一个值的数组)的变种。这种变种函数都以v
结尾,表明它们接收一个矢量,以及一个count
的值表示分量为x的数组元素个数,x位于函数名称的尾部。比如我们有一个四分量的一致变量:
uniform vec4 vColor;
在C/C++中我们可以将它表示为一个浮点数组:
GLfloat vColor[4] = {1.0f, 1.0f, 1.0f, 1.0f};
我们可以将它看成是四个值的一个数组,所以可以将它如下传递给着色器:
glUniform4fv(iColorLocation, 1, vColor);
现在假设我们在着色器中有一个颜色值得数组如下:
uniform vec4 vColors[2];
在C++中我们可以如下表示并传递它:
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);
最后让我们看看如何设置矩阵型一致变量。着色器矩阵数据类型只有单精度和双精度浮点数,所以这个函数的变种会少很多。要设置矩阵型一致变量的值,我们调用glUniformMatrix*()相关的函数。
在glUniformMatrix*()所有变种的函数中,参数count
表示指针参数m
指向的数组存储的矩阵数目(是的,我们可以使用矩阵数组)。如果矩阵已经以列优先(OpenGL倾向的方式)进行存储,布尔标示transpose
设置为GL_FALSE
。将transpose
设置为GL_TRUE
将导致矩阵被拷贝到着色器中时进行变换(由行优先变换为列优先)。这在我们使用行优先的其他矩阵库时是很有用的。