第十三章:高级GLSL:UBO、几何着色器与实例化渲染
说实话,到了这个阶段,咱们已经能用OpenGL ES画出不少东西了。但如果你做过稍微复杂点的项目,一定会遇到两个痛点:一是uniform变量太多,每次切换shader都要重新设置,烦得很;二是场景里要画大量重复物体,比如一片森林、一群星星,draw call数量直接爆炸。
嗯,今天要聊的三个东西——UBO、几何着色器、实例化渲染,就是专门解决这些问题的。我当年在做一个粒子系统的时候,就是靠它们把性能从20帧拉到了60帧。来,咱们一个一个看。
13.1 Uniform Buffer Object(UBO)
先说说UBO。说白了,它就是一个能在多个shader程序之间共享的uniform数据块。你想想看,如果你的场景里有10个不同的shader,每个都需要投影矩阵、视图矩阵、光源位置……那你就得在每个shader里分别设置这些uniform。代码重复不说,还容易漏掉。
UBO的做法是:把一组uniform打包成一个buffer,然后多个shader程序绑定到同一个buffer上。改一次,所有shader都生效。
13.1.1 定义UBO块
在GLSL里,用layout(std140)来声明一个uniform块。std140是内存布局标准,保证数据在CPU端和GPU端的排列一致。
// 在顶点着色器或片段着色器中
layout(std140, binding = 0) uniform Matrices {
mat4 projection;
mat4 view;
mat4 model;
};
注意这个binding = 0,它指定了这个UBO绑定点是0。后面在CPU端也要对应绑定到0。
13.1.2 在CPU端创建和填充UBO
创建UBO跟创建VBO差不多,用glGenBuffers和glBindBuffer,目标类型是GL_UNIFORM_BUFFER。
GLuint uboMatrices;
glGenBuffers(1, &uboMatrices);
glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
glBufferData(GL_UNIFORM_BUFFER, sizeof(Matrices), &matrices, GL_DYNAMIC_DRAW);
glBindBufferBase(GL_UNIFORM_BUFFER, 0, uboMatrices); // 绑定到绑定点0
glBindBufferBase这步很关键,它把buffer挂到绑定点0上。之后所有shader里声明了binding = 0的uniform块,都能读到这个buffer里的数据。
13.1.3 更新UBO数据
更新数据时,可以用glBufferSubData只更新一部分,不用整个buffer重传。比如只更新model矩阵:
glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
glBufferSubData(GL_UNIFORM_BUFFER, offsetof(Matrices, model), sizeof(glm::mat4), glm::value_ptr(modelMatrix));
13.2 几何着色器基础
几何着色器(Geometry Shader)是顶点着色器和片段着色器之间的一个可选阶段。它接收一个图元(点、线、三角形),然后可以输出零个或多个图元。说白了,它能“变”出新的顶点来。
我记得第一次用几何着色器,是在做一个爆炸效果。每个粒子爆炸后要变成一堆碎片,如果用CPU去生成这些碎片的顶点,数据量太大了。用几何着色器,只需要传入一个点,它就能生成几十个三角形,效率高得多。
13.2.1 几何着色器的基本结构
#version 320 es
layout(points) in;
layout(triangle_strip, max_vertices = 4) out;
void main() {
// 输入的点位置
vec4 pos = gl_in[0].gl_Position;
// 生成一个四边形(两个三角形)
gl_Position = pos + vec4(-0.1, -0.1, 0.0, 0.0);
EmitVertex();
gl_Position = pos + vec4( 0.1, -0.1, 0.0, 0.0);
EmitVertex();
gl_Position = pos + vec4(-0.1, 0.1, 0.0, 0.0);
EmitVertex();
gl_Position = pos + vec4( 0.1, 0.1, 0.0, 0.0);
EmitVertex();
EndPrimitive();
}
这里layout(points) in表示输入是点,layout(triangle_strip, max_vertices = 4) out表示输出是三角形带,最多4个顶点。每次调用EmitVertex()就输出一个顶点,最后用EndPrimitive()结束当前图元。
13.2.2 几何着色器的应用场景
- 粒子系统: 每个粒子用一个点表示,几何着色器把它扩展成带纹理的四边形。
- 法线可视化: 输入三角形,输出每个顶点的法线方向线段,方便调试。
- 阴影体生成: 从光源方向拉伸三角形,生成阴影体。
- 毛发/草地渲染: 输入一个点,生成多条线段模拟毛发。
13.3 实例化渲染
实例化渲染(Instanced Rendering)是解决大量重复物体渲染的终极方案。它的核心思想是:一次draw call,绘制多个相同的物体,每个物体可以有不同的变换矩阵、颜色等属性。
你想想看,如果要画1000棵树,传统做法是循环1000次draw call,每次设置不同的model矩阵。但每次draw call都有CPU到GPU的通信开销,1000次下来性能就崩了。实例化渲染只需要一次draw call,所有数据都提前放在GPU里。
13.3.1 使用实例化数组
实例化渲染有两种方式:一种是使用gl_InstanceID在shader里计算变换,另一种是用实例化数组(Instanced Arrays)传入每个实例的数据。
我个人更推荐实例化数组,因为它更灵活。做法是把每个实例的变换矩阵放在一个VBO里,然后通过glVertexAttribDivisor告诉OpenGL这个属性每个实例更新一次,而不是每个顶点更新一次。
// 设置实例数据VBO
GLuint instanceVBO;
glGenBuffers(1, &instanceVBO);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(glm::mat4) * instanceCount, instanceMatrices, GL_STATIC_DRAW);
// 绑定到顶点属性,每个实例更新一次
glEnableVertexAttribArray(3);
glVertexAttribPointer(3, 4, GL_FLOAT, GL_FALSE, sizeof(glm::mat4), (void*)0);
glEnableVertexAttribArray(4);
glVertexAttribPointer(4, 4, GL_FLOAT, GL_FALSE, sizeof(glm::mat4), (void*)(sizeof(glm::vec4)));
glEnableVertexAttribArray(5);
glVertexAttribPointer(5, 4, GL_FLOAT, GL_FALSE, sizeof(glm::mat4), (void*)(2 * sizeof(glm::vec4)));
glEnableVertexAttribArray(6);
glVertexAttribPointer(6, 4, GL_FLOAT, GL_FALSE, sizeof(glm::mat4), (void*)(3 * sizeof(glm::vec4)));
glVertexAttribDivisor(3, 1);
glVertexAttribDivisor(4, 1);
glVertexAttribDivisor(5, 1);
glVertexAttribDivisor(6, 1);
这里把mat4拆成了4个vec4属性(位置3、4、5、6),每个属性都设置glVertexAttribDivisor(..., 1),表示每个实例更新一次。
13.3.2 在shader中使用实例数据
#version 320 es
layout(location = 0) in vec3 aPos;
layout(location = 3) in mat4 aInstanceMatrix;
void main() {
gl_Position = projection * view * aInstanceMatrix * vec4(aPos, 1.0);
}
然后调用glDrawArraysInstanced或glDrawElementsInstanced来绘制:
glDrawElementsInstanced(GL_TRIANGLES, indexCount, GL_UNSIGNED_INT, 0, instanceCount);
13.3.3 实例化渲染的性能对比
| 方法 | 100个物体 | 1000个物体 | 10000个物体 |
|---|---|---|---|
| 传统循环draw call | ~0.5ms | ~5ms | ~50ms(卡顿) |
| 实例化渲染 | ~0.1ms | ~0.3ms | ~2ms |
数据是我在实际项目中测的,不同设备会有差异,但趋势很明显。实例化渲染在大量物体时优势巨大。
13.4 三者结合:一个完整的例子
咱们把UBO、几何着色器、实例化渲染结合起来,做一个简单的草地渲染。每个草叶用一个点表示,几何着色器把它扩展成三条线段,UBO存放投影和视图矩阵,实例化数组存放每棵草的位置和高度。
这个例子虽然简单,但涵盖了今天讲的三个核心知识点。实际项目中,这种组合能解决很多性能问题。
- UBO:多个shader共享uniform数据,减少CPU-GPU通信
- 几何着色器:在GPU端生成/修改图元,适合粒子、毛发等效果
- 实例化渲染:一次draw call绘制大量重复物体,性能提升显著
- 三者可以组合使用,解决复杂场景的性能瓶颈
好了,这一章的内容就到这。UBO、几何着色器、实例化渲染,这三个工具用好之后,你的OpenGL ES水平会上一个大台阶。下次遇到性能问题,先想想能不能用它们来优化,而不是盲目地减少物体数量。