LearnOpenGL 系列课程
第0021课:几何着色器与实例化
本课介绍两个独立的 OpenGL 高级特性:几何着色器(Geometry Shader)和实例化(Instancing)。几何着色器位于顶点和片段着色器之间,可以生成或销毁顶点;实例化允许一次绘制调用渲染大量相同物体。两者都是现代渲染引擎中提高灵活性和性能的关键技术。
同时,本课也会介绍统一缓冲对象(Uniform Buffer Object, UBO),一种在多个着色器程序间共享 uniform 数据的高效方式。
第一部分:高级 GLSL 与 UBO
UBO:统一缓冲对象
当多个着色器程序需要使用相同的 uniform 数据(如投影矩阵、视图矩阵)时,UBO 允许我们创建一块 GPU 缓冲,在所有着色器间共享:
// 1. 获取 uniform block 索引
unsigned int blockIndex = glGetUniformBlockIndex(shader.ID, "Matrices");
// 2. 将 block 绑定到 binding point
glUniformBlockBinding(shader.ID, blockIndex, 0);
// 3. 创建 UBO 并分配内存
glGenBuffers(1, &uboMatrices);
glBindBuffer(GL_UNIFORM_BUFFER, uboMatrices);
glBufferData(GL_UNIFORM_BUFFER, 2 * sizeof(glm::mat4), NULL, GL_STATIC_DRAW);
glBindBufferRange(GL_UNIFORM_BUFFER, 0, uboMatrices, 0, 2 * sizeof(glm::mat4));
// 4. 更新 UBO 数据(只需做一次,所有着色器自动共享)
glBufferSubData(GL_UNIFORM_BUFFER, 0, sizeof(glm::mat4), glm::value_ptr(projection));
glBufferSubData(GL_UNIFORM_BUFFER, sizeof(glm::mat4), sizeof(glm::mat4), glm::value_ptr(view));着色器中的对应声明:
// 着色器中使用 uniform block
layout (std140) uniform Matrices {
mat4 projection;
mat4 view;
};
INFO
std140 布局规则
UBO 使用 std140 内存布局规范,每个 mat4 占用 4 个 vec4(16 字节对齐),所以 2 个 mat4 需要
2 * 4 * 4 * 4 = 128字节。了解 std140 规则对于正确填充 UBO 数据至关重要。
第二部分:几何着色器
2.1 几何着色器概述
几何着色器(Geometry Shader)位于顶点着色器和片段着色器之间,接收一个完整的图元(点、线段或三角形)作为输入,可以:
- 不改变图元:直接传递
- 改变图元类型:将点变为三角形,三角形变为点等
- 生成新顶点:一个输入顶点生成多个输出顶点
- 销毁顶点:丢弃输入图元
几何着色器的声明语法:
#version 330 core
layout (points) in; // 输入图元类型
layout (triangle_strip, max_vertices = 5) out; // 输出图元类型和最大顶点数
void main() {
gl_Position = gl_in[0].gl_Position + vec4(-0.2, -0.2, 0.0, 0.0);
EmitVertex();
gl_Position = gl_in[0].gl_Position + vec4( 0.2, -0.2, 0.0, 0.0);
EmitVertex();
// ...
EndPrimitive();
}2.2 几何着色器的输入输出
几何着色器可以通过接口块(Interface Block)从顶点着色器接收数据,并传递数据到片段着色器:
// 顶点着色器输出接口
out VS_OUT {
vec3 color;
} vs_out;
// 几何着色器输入接口(数组!)
in VS_OUT {
vec3 color;
} gs_in[];
// 几何着色器输出
out vec3 fColor;
注意:几何着色器的输入是一个数组,因为一个图元包含多个顶点。
2.3 应用一:绘制房子 (9.1.geometry_shader_houses)
该示例从 4 个点出发,通过几何着色器将每个点变成一个房子形状(三角形条带,5 个顶点):
layout (points) in;
layout (triangle_strip, max_vertices = 5) out;
void build_house(vec4 position) {
fColor = gs_in[0].color;
gl_Position = position + vec4(-0.2, -0.2, 0.0, 0.0); // 左下
EmitVertex();
gl_Position = position + vec4( 0.2, -0.2, 0.0, 0.0); // 右下
EmitVertex();
gl_Position = position + vec4(-0.2, 0.2, 0.0, 0.0); // 左上
EmitVertex();
gl_Position = position + vec4( 0.2, 0.2, 0.0, 0.0); // 右上
EmitVertex();
gl_Position = position + vec4( 0.0, 0.4, 0.0, 0.0); // 屋顶尖
fColor = vec3(1.0, 1.0, 1.0);
EmitVertex();
EndPrimitive();
}每个点输入被扩展为 5 个顶点组成的三角形条带,形成一个带屋顶的小房子。
2.4 应用二:爆炸效果 (9.2.geometry_shader_exploding)
该示例加载纳米服模型,在几何着色器中沿法线方向移动每个三角形的顶点,产生爆炸效果:
layout (triangles) in;
layout (triangle_strip, max_vertices = 3) out;
uniform float time;
vec4 explode(vec4 position, vec3 normal) {
float magnitude = 2.0;
vec3 direction = normal * ((sin(time) + 1.0) / 2.0) * magnitude;
return position + vec4(direction, 0.0);
}
vec3 GetNormal() {
vec3 a = vec3(gl_in[0].gl_Position) - vec3(gl_in[1].gl_Position);
vec3 b = vec3(gl_in[2].gl_Position) - vec3(gl_in[1].gl_Position);
return normalize(cross(a, b));
}
void main() {
vec3 normal = GetNormal();
gl_Position = explode(gl_in[0].gl_Position, normal);
EmitVertex();
gl_Position = explode(gl_in[1].gl_Position, normal);
EmitVertex();
gl_Position = explode(gl_in[2].gl_Position, normal);
EmitVertex();
EndPrimitive();
}关键点:法线是在几何着色器中从三角形顶点位置计算得出的(叉积),随时间变化的 sin 值控制爆炸程度。
INFO
几何着色器的应用场景
几何着色器在实际引擎中的常见用途:
法线可视化:调试时从每个三角形生成法线线段
粒子系统:将点精灵扩展为四边形或公告板
毛发/草地渲染:从基础网格生成大量细长三角形
阴影体积:从灯光方向挤出 silhouette 边缘
LOD 过渡:根据距离动态调整图元复杂度
注意:几何着色器可能成为性能瓶颈,因为它对每个图元都执行一次,且输出的顶点数有限。
第三部分:实例化 (Instancing)
3.1 实例化的动机
假设我们要渲染 10000 个相同的物体。传统做法是在循环中绘制 10000 次:
for (unsigned int i = 0; i < 10000; i++) {
shader.setMat4("model", modelMatrices[i]);
rock.Draw(shader);
}
这样有 10000 次绘制调用(Draw Call),每次调用都需要 CPU 设置 uniform、切换状态,CPU 到 GPU 的通信开销极大。
实例化将数据一次性发送到 GPU,使用一次绘制调用渲染所有实例:
glDrawArraysInstanced(GL_TRIANGLES, 0, 6, 100); // 2D
glDrawElementsInstanced(GL_TRIANGLES, count, GL_UNSIGNED_INT, 0, amount); // 3D
3.2 实例化数组
每个实例需要不同的属性(如位置、缩放、旋转)。实例化数组使用 glVertexAttribDivisor 控制属性更新的频率:
- divisor = 0:每个顶点更新一次(默认,逐顶点)
- divisor = 1:每个实例更新一次(逐实例)
- divisor = N:每 N 个实例更新一次
3.3 基础实例化 (10.1.instancing_quads)
该示例渲染 100 个彩色四边形,每个四边形使用不同的位置偏移:
// 生成 100 个偏移量
glm::vec2 translations[100];
int index = 0;
for (int y = -10; y < 10; y += 2)
for (int x = -10; x < 10; x += 2) {
translations[index] = glm::vec2((float)x / 10.0f, (float)y / 10.0f);
index++;
}
// 存储到实例缓冲
unsigned int instanceVBO;
glGenBuffers(1, &instanceVBO);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(glm::vec2) * 100, &translations[0], GL_STATIC_DRAW);
// 设置实例属性
glEnableVertexAttribArray(2);
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), (void*)0);
glVertexAttribDivisor(2, 1); // 每个实例更新一次
// 一次绘制调用渲染全部
glDrawArraysInstanced(GL_TRIANGLES, 0, 6, 100);3.4 小行星带:性能对比
10.2.asteroids 和 10.3.asteroids_instanced 展示了性能差异:
| 方式 | 绘制调用次数 | 物体数量 | 性能 |
|---|---|---|---|
| 普通循环 (10.2) | 1000 次 | 1000 | 较慢 |
| 实例化 (10.3) | 1 次 | 100000 | 快速流畅 |
实例化版本的关键点:将每个实例的 model 矩阵编码为 4 个 vec4 属性,使用 4 个连续的 attrib 位置和 divisor:
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);INFO
mat4 作为实例属性的特殊处理
OpenGL 不支持直接将 mat4 作为顶点属性。解决方法是将 mat4 拆分为 4 个 vec4,占用 4 个连续的顶点属性位置(这里用位置 3~6)。每个 vec4 设置 divisor 为 1,实现逐实例更新。
4. 引擎连接
INFO
Unity 引擎对应
- GPU Instancing:Unity 的 GPU Instancing 功能直接对应 OpenGL 的实例化。使用
Graphics.DrawMeshInstanced或MaterialPropertyBlock一次性提交大量相同网格。- SRP Batcher:Unity 可编程渲染管线的批处理器,原理与 UBO 类似——将材质数据存储在 GPU 缓冲中,减少 CPU 设置 uniform 的开销。
- 粒子系统:Unity 粒子系统大量使用实例化渲染,每个粒子是一个实例,共享同一个网格(四边形或自定义网格)。
- 植被系统:SpeedTree 和地形系统的植被渲染使用实例化,在单次 draw call 中渲染大量草、树。
WARNING
UE 引擎对应
- Instance Static Mesh:UE 的实例化静态网格组件,用于渲染大量相同的静态物体(如石头、树木)。
- Hierarchical Instanced Static Mesh (HISM):带 LOD 和视锥裁剪的层次化实例化静态网格,用于植被系统。
- Niagara 粒子系统:UE5 的 Niagara 粒子系统同样依赖 GPU 实例化来渲染大量粒子。
5. 练习
- 实现法线可视化:修改几何着色器示例,从每个三角形的重心沿法线方向绘制一条线段,用于调试和检查法线方向是否正确。
- 实例化绘制大量物体:使用 glDrawArraysInstanced 渲染 10000 个旋转的立方体,每个立方体有不同的位置、旋转角度和颜色。
- 性能测试:分别使用普通循环和实例化渲染同一批 50000 个物体,用 glfwGetTime() 测量帧时间,对比性能差异。
- 组合几何着色器和实例化:使用实例化渲染多个点,每个点通过几何着色器扩展为房子或公告板。
- UBO 实践:修改现有代码,将多个着色器的投影和视图矩阵通过 UBO 共享。