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.asteroids10.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.DrawMeshInstancedMaterialPropertyBlock 一次性提交大量相同网格。
  • 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. 练习

  1. 实现法线可视化:修改几何着色器示例,从每个三角形的重心沿法线方向绘制一条线段,用于调试和检查法线方向是否正确。
  2. 实例化绘制大量物体:使用 glDrawArraysInstanced 渲染 10000 个旋转的立方体,每个立方体有不同的位置、旋转角度和颜色。
  3. 性能测试:分别使用普通循环和实例化渲染同一批 50000 个物体,用 glfwGetTime() 测量帧时间,对比性能差异。
  4. 组合几何着色器和实例化:使用实例化渲染多个点,每个点通过几何着色器扩展为房子或公告板。
  5. UBO 实践:修改现有代码,将多个着色器的投影和视图矩阵通过 UBO 共享。