本文主要解决一个问题:
如何高效地将同一个物体绘制很多份?
引言
思考一个问题:如果想要在一个场景中绘制一片草地,但你手里只有一颗草的纹理,你该怎么做?
我想你可能会采取下面的这种做法:
for (int i = 0; i < 草的数量; ++i) {
//一些预备工作
glDrawArrays(GL_TRIANGLES, 0, 顶点数量);
}
没错,这是一种最简单粗暴的做法,在大多数情况下也行的通。但是我们是有自尊心的程序员,怎么能容忍就只有这么一种笨办法呢?用这种方法绘制,我们的程序很快会达到性能瓶颈。OpenGL在每次实现glDrawArrays或者glDrawElements绘制的时候需要做一些必要的准备工作,这大大影响了绘制的效率。和单纯的绘制相比,CPU和GPU之间的通信代价十分高昂,频繁地调用glDrawArrays等函数是将时间消耗到了通信上,完全是吃力不讨好的行为。
不过不用担心,作为地球上最聪明的一群人,发明OpenGL的人也都不是吃素的。为了应对这种情况,OpenGL提供了另外两个函数:glDrawArraysInstanced和glDrawElementsInstanced。这就是高效绘制多个相同物体的终极方法:实例化。
实例化
由于多次调用glDrawArrays(glDrawElements)的代价高昂,我们自然就希望调用一次绘制函数就能绘制大量的物体,将物体的一些位置等不同的参数一并传过去,给每个要绘制的物体编号,将其与自身的位置参数关联起来绘制不就行了。
没错,OpenGL也是这么想的。
glDrawArraysInstanced(glDrawElementsInstanced)函数与glDrawArrays(glDrawElements)函数的不同之处在于前者多了一个数量的参数,它允许我们制定需要绘制多少个物体的实例。这样,我们只需要调用glDrawArraysInstenced一次就可以绘制多个实例,大大节省了通信资源的消耗。
千万别觉得调用glDrawArraysInstanced(glDrawElementsInstanced)函数的代价小,不信你可以调用个成百上千次glDrawArraysInstenced,每次都绘制一个物体试试。
有了可以绘制多个实例的函数之后,我们还需要每个实例的相关属性。在顶点着色器找那个有个内置变量gl_InstancedID。这个变量表明了当前绘制的物体的索引(从0开始),由于这个索引的特性,我们可以将它作为属性数组的索引来使用,方便。
为了有一个对实例化概念的直观感受,我们来绘制100个矩形框,每行十个,铺满整个窗口。每个矩形框都由两个三角形组成,其坐标如下:
float quadVertices[] = {
-0.05f, 0.05f,
0.05f, -0.05f,
-0.05f, -0.05f,
-0.05f, 0.05f,
0.05f, -0.05f,
0.05f, 0.05f
};
顶点着色器中,我们声明一个偏移数组,包含100个元素,指明每个实例的偏移量。将这个偏移量和输入的位置相加,得到矩形框最终的位置:
#version 330 core
layout (location = 0) in vec2 aPos;
uniform vec2 offsets[100];
void main() {
vec2 offset = offsets[gl_InstanceID];
gl_Position = vec4(aPos + offset, 0.0, 1.0);
}
片元着色器不需要太多的代码,简单地输出一个绿色就行了。接着,在进入渲染循环之前,我们要生成并且设置偏移信息:
glm::vec2 translations[100];
int index = 0;
float offset = 0.1f;
for(int y = -10; y < 10; y += 2)
{
for(int x = -10; x < 10; x += 2)
{
glm::vec2 translation;
translation.x = (float)x / 10.0f + offset;
translation.y = (float)y / 10.0f + offset;
translations[index++] = translation;
}
}
在一个10x10的网格中,设置好每个位置的偏移值,然后调用着色器的设置函数将每个值都设置好,注意我们无法使用类似内存块复制的函数,只能使用循环去设置每个元素:
shader.use();
for(unsigned int i = 0; i < 100; i++)
{
stringstream ss;
string index;
ss << i;
index = ss.str();
shader.setVec2(("offsets[" + index + "]").c_str(), translations[i]);
}
这里我们用了一个小技巧,将i作为字符串与offsets拼接成uniform变量名进行设置,非常方便。一切准备就绪之后,就可以绘制了,注意要用glDrawArraysInstanced函数:
glBindVertexArray(VAO);
glDrawArraysInstanced(GL_TRIANGLES, 0, 6, 100);
编译运行,我们想要的结果出来了:
没时间手写代码的童鞋到可以下载这里的 源码看效果。
实例数组
对前面的例子来说,我们采用uniform数组的形式没什么问题。但是uniform这种类型的变量是有上限的,而且这个上限值还很小,根本不够用来设置大量物体的属性。这时候,我们就要用到另外一个名叫实例数组的东西了。使用它和给物体定义一个顶点属性一样,不同的是实例数组中的东西只有在每次渲染新实例的时候会更新(普通顶点属性是每个顶点都会更新)。
还是用上面的例子,我们怎么用实例数组的方式来显示这100个矩形?首先把顶点着色器中的uniform变量去掉,换成顶点属性输入的格式:
#version 330 core
layout (location = 0) in vec2 aPos;
layout (location = 1) in vec2 aOffset;
void main() {
gl_Position = vec4(aPos + aOffset, 0.0, 1.0);
}
我们不需要用gl_InstanceID了,取而代之的是可以直接用offset属性来加到位置上去。
因为在形式上实例数组就是一个顶点属性,所以,我们也要像设置位置变量那样把偏移数据保存到VBO中去,然后通过设置顶点属性的方式来设置偏移值:
unsigned int instanceVBO;
glGenBuffers(1, &instanceVBO);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(glm::vec2) * 100, &translations[0], GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
接着,设置顶点属性并且启用属性:
glEnableVertexAttribArray(1);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)(0));
glBindBuffer(GL_ARRAY_BUFFER, 0);
glVertexAttribDivisor(1, 1);
这里的一个关键函数是glVertexAttribDivisor。这个函数告诉了OpenGL什么时候将哪个属性更新成下一个元素。第一个参数表明要更新的是哪个属性,第二个参数表示在什么时候更新。我们把第一个参数设置成1表示要设置的是偏移值,第二个参数设置成1表示在渲染一个新实例的时候更新此值。如果将这个参数设置成0,表示每个顶点都更新,设置成2表示每2个实例更新一次。这里我们要的是每个实例更新一次,所以设置成1最合适。
最后,我们做一点小改动:根据实例索引号逐渐增大矩形框.只要对顶点着色器稍稍修改一下就行了:
void main() {
vec2 pos = aPos * (gl_InstanceID / 100.0);
gl_Position = vec4(pos + aOffset, 0.0, 1.0);
}
编译运行代码,看到这样的效果:
如果还是有问题,可以参考这里的代码。
至此,实例化的原理都已经说明完毕,但这就够了吗?我们要来点有趣的东西才成。来实现一个小行星带!
一个小行星带
说起行星带,笔者的第一反应就是土星,没错,土星很行星带。要实现这样一个行星带,必然要用到我们之前的实例数组知识。加载一个行星模型和一个岩石模型,将行星置于世界坐标的原点,将岩石放到围绕这行星的某个圆环位置(加上些许偏移),这样就显示出一个行星带了。
先做一些准备工作,到这里下载行星模型和岩石模型。将模型加载项目拷贝一份,我们就在这个副本上修改。
现在我们来考虑实现的具体细节。行星的位置放在原点,不用加什么变换。小行星(岩石)要放在一定半径的圆上,加上一定的偏移。然后,我们海需要将所有的小行星都设置一个随机的比例变换和一个旋转角度,模拟小行星杂乱无章的样子。将这些变换都保存到一个矩阵数组中传递到顶点着色器中就大功告成了。来看实现代码:
unsigned int amount = 1000; //数量1000
glm::mat4* modelMatrices = NULL;
modelMatrices = new glm::mat4[amount];
srand(glfwGetTime());
float radius = 50.0;
float offset = 2.5f;
for (int i = 0; i < amount; ++i) {
glm::mat4 model;
//1、平移到距离行星50,并且具有一定偏移量的位置
float angle = (float)i / (float)amount * 360.0f;
float displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset;
float x = sin(angle) * radius + displacement;
displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset;
float y = displacement * 0.4f; //在y轴上偏移一个小角度
displacement = (rand() % (int)(2 * offset * 100)) / 100.0f - offset;
float z = cos(angle) * radius + displacement;
model = glm::translate(model, glm::vec3(x, y, z));
//2、将尺寸缩小一定的比例
float scale = (rand() % 20) / 100.0f + 0.05;
//3、旋转一定角度
float rotAngle = (rand() % 360);
model = glm::rotate(model, rotAngle, glm::vec3(0.4f, 0.6f, 0.8f));
//4、保存变换的矩阵
modelMatrices[i] = model;
}
直接看代码可能不太容易理解,我们把它一点点拆开来。首先准备工作是计算当前的小行星在行星带上的角度(angle)。接着,计算小行星的x轴坐标,角度乘以半径再加上一个随机的偏移值(displacement)。然后计算y轴坐标,因为y轴上不需要非常大的上下波动,于是将随机到的偏移值缩小到40%的大小。最后计算z轴坐标,同样是角度加上偏移值。将x、y、z坐标都准备好之后,生成一个平移的矩阵。依次生成缩放变换和旋转变换的矩阵,统统放到model变量中保存起来备用。
绘制工作我们先来用普通的方法绘制,后面再用实例数组的方式,这样更能看出区别。普通方法很简单,在绘制之前设置模型变换矩阵后在调用模型的Draw函数就可以了,代码如下:
ourShader.use();
glm::mat4 projection = glm::perspective(glm::radians(camera.Zoom), (float)SCR_WIDTH / (float)SCR_HEIGHT, 0.1f, 500.0f);
glm::mat4 view = camera.GetViewMatrix();
ourShader.setMat4("projection", glm::value_ptr(projection));
ourShader.setMat4("view", glm::value_ptr(view));
//绘制行星
glm::mat4 model;
model = glm::translate(model, glm::vec3(0.0f, -3.0f, 0.0f));
model = glm::scale(model, glm::vec3(4.0f, 4.0f, 4.0f));
ourShader.setMat4("model", glm::value_ptr(model));
planet.Draw(ourShader);
//绘制行星带
for (unsigned int i = 0; i < amount; i++) {
ourShader.setMat4("model", glm::value_ptr(modelMatrices[i]));
rock.Draw(ourShader);
}
把投影矩阵的远裁剪面加到500,这样才能看到整个行星带,否则会远处的会被裁剪掉。
如果还有问题,也可以下载这里的源码做对比。
经过笔者的尝试,当小行星的数量达到3000的时候,移动摄像机就已经会产生一卡一卡的效果,而这时候环绕的小行星在数量上还不足以给人一种很多的感觉。因此,这种普通的方法显然不能实现我们想要的功能,那么实例数组的方式如何呢,我们来试试。
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec3 aNormal;
layout (location = 2) in vec2 aTexCoords;
layout (location = 3) in mat4 instanceMatrix;
out vec2 TexCoords;
//uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
void main()
{
TexCoords = aTexCoords;
gl_Position = projection * view * /*model*/instanceMatrix * vec4(aPos, 1.0);
}
添加实例矩阵数组作为输入,注释掉原有uniform类型的model变量,计算最终位置的时候使用实例矩阵数组作为模型变换矩阵。
接着来设置实例矩阵数组。同前面的例子一样,先分配一个VBO用来保存矩阵数据(这里笔者把这个数字调大了,100000个!)。设置属性的时候和之前的例子不一样。因为我们模型的网格类中自带有VAO的信息,所以我们在绘制的时候必须将实例数组绑定到每个网格的VAO:
unsigned int instanceVBO;
glGenBuffers(1, &instanceVBO);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(glm::mat4) * amount, &modelMatrices[0], GL_STATIC_DRAW);
for (unsigned int i = 0; i < rock.meshes.size(); ++i) {
unsigned int tVAO = rock.meshes[i].VAO;
glBindVertexArray(tVAO);
GLsizei vec4Size = sizeof(glm::vec4);
glEnableVertexAttribArray(3);
glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)0);
glEnableVertexAttribArray(4);
glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(vec4Size));
glEnableVertexAttribArray(5);
glVertexAttribPointer(5, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(2 * vec4Size));
glEnableVertexAttribArray(6);
glVertexAttribPointer(6, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(3 * vec4Size));
glVertexAttribDivisor(3, 1);
glVertexAttribDivisor(4, 1);
glVertexAttribDivisor(5, 1);
glVertexAttribDivisor(6, 1);
glBindVertexArray(0);
}
在绘制的时候,我们要一个新的Shader用于绘制小行星带(之前的shader用来绘制中间的行星),然后手动调用glDrawElementsInstanced函数绘制岩石的每个网格,将每个网格绘制100000份,组合起来就是100000个小行星了:
//绘制行星带
instanceShader.use();
instanceShader.setMat4("projection", glm::value_ptr(projection));
instanceShader.setMat4("view", glm::value_ptr(view));
for (unsigned int i = 0; i < rock.meshes.size(); i++) {
glBindVertexArray(rock.meshes[i].VAO);
glDrawElementsInstanced(GL_TRIANGLES,rock.meshes[i].indices.size(),GL_UNSIGNED_INT, 0, amount);
}
应该没啥问题了,编译运行,我们的行星带偏移范围都已经被塞的满满的了:
看着怪好笑的,哈哈!关键是这种绘制方式即便是达到10万个数量笔者的机子上也不觉得卡,可见两种绘制方式之间的差距!
最后,如果没的显示或者显示不对,看这里。
总结
本章我们实际上就是学习了两个函数:glDrawArraysInstanced和glDrawElementsInstanced。我们学习了这两个函数中的实例参数实现的背后原理以及如何去用它实现大量物体绘制功能。原理简单,将本章中的例子吃透之后就更简单了。
下一篇
目录
上一篇
参考资料
www.learnopengl.com(非常好的网站,建议学习)