批量渲染

学习笔记:OpenGL 批量渲染 (Batch Rendering)

1. 核心概念:什么是批量渲染?

批量渲染是一种优化技术,其核心思想是将大量独立的、小型的绘制调用 (Draw Call) 合并成少数几个、大型的绘制调用

  • 问题场景: 想象一下,你要渲染一个由 10,000 个独立的方块(比如粒子、瓦片、UI 元素)组成的场景。
    • 朴素方法: 对每个方块都进行一次完整的渲染流程:Bind VAO -> Bind Shader -> Set Uniforms -> glDrawElements(...)。这将产生 10,000 次绘制调用。
    • 批量渲染方法: 将这 10,000 个方块的顶点数据打包到一个巨大的 VBO 中,然后用一次 glDrawElements 调用将它们全部绘制出来。

为什么绘制调用 (Draw Call) 很昂贵?
每一次 glDraw... 调用,CPU 都需要向 GPU 发送指令。这个过程涉及到驱动程序的介入、状态验证、上下文切换等开销。当绘制调用非常频繁时(每秒数千甚至数万次),CPU 会把大量时间花在“准备发号施令”上,而不是让 GPU 去“专心干活”,这会导致 **CPU 瓶颈 (CPU-bound)**,严重影响性能。

批量渲染的目标:最大限度地减少 CPU 与 GPU 的通信次数,让 GPU 能够一次性处理大量数据,充分发挥其并行计算的优势。


2. 批量渲染的策略

根据渲染物体的不同,批量渲染有多种实现策略。关键在于找到可以“批量处理”的共同点。

  • 静态批量处理 (Static Batching):

    • 适用场景: 大量静态的、不会移动、使用相同材质和纹理的物体(比如场景中的石头、树木、建筑)。
    • 方法: 在程序加载时,将所有这些物体的顶点数据(已经过模型变换)合并到一个巨大的 VBO 和 IBO 中。渲染时,只需一次绘制调用即可。
    • 优点: 性能极高,运行时开销几乎为零。
    • 缺点: 完全不灵活,物体无法移动或改变。
  • 动态批量处理 (Dynamic Batching):

    • 适用场景: 大量动态的、每帧都可能改变位置/颜色/UV,但使用相同着色器和纹理的物体(比如 2D 游戏中的粒子、精灵、瓦片地图)。
    • 方法:
      1. 创建一个足够大的动态 VBO (usage 设为 GL_DYNAMIC_DRAW)。
      2. 每一帧,清空或重置这个 VBO。
      3. 遍历所有需要绘制的物体,将其顶点数据动态地填充到这个大 VBO 中。
      4. 在这一帧的末尾,用一次绘制调用渲染整个 VBO。
    • 优点: 非常灵活,完美支持动态物体。
    • 缺点: 每帧都需要在 CPU 端准备数据并上传到 GPU,有一定的 CPU 和数据传输开销。这是 Cherno 教程中重点讲解的方法。
  • 实例化渲染 (Instanced Rendering):

    • 适用场景: 渲染大量几何形状完全相同,但位置、旋转、颜色等属性不同的物体(比如草地、森林、人群)。
    • 方法:
      1. 只上传一个物体的模型数据(VBO/IBO)。
      2. 创建一个额外的 VBO,用于存储每个实例的“逐实例属性”(如变换矩阵、颜色)。
      3. 使用 glDrawElementsInstanced(...) 一次性绘制,GPU 会自动为每个实例应用其独特的属性。
    • 优点: 极高的性能和灵活性,数据传输量最小。
    • 缺点: 所有实例必须共享完全相同的网格。

3. 动态批量渲染的具体实现 (以 2D 四边形为例)

这是 Cherno 教程中的核心实践。我们将构建一个可以批量渲染大量不同位置、颜色、纹理的 2D 四边形(Quad)的系统。

步骤 1: 定义顶点结构

我们需要一个 struct 来清晰地定义单个顶点的所有属性。

1
2
3
4
5
6
7
struct QuadVertex
{
glm::vec3 Position;
glm::vec4 Color;
glm::vec2 TexCoord;
float TexIndex; // 关键!用于在着色器中选择纹理
};
  • TexIndex: 这个浮点数将告诉片段着色器,这个顶点属于哪个纹理。
步骤 2: 初始化渲染器

在渲染器的构造函数中,我们需要设置一个巨大的、空的、动态的 VBO。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// BatchRenderer2D.cpp
const size_t MaxQuadCount = 10000;
const size_t MaxVertexCount = MaxQuadCount * 4;
const size_t MaxIndexCount = MaxQuadCount * 6;

BatchRenderer2D::BatchRenderer2D()
{
m_QuadVertexBuffer = new QuadVertex[MaxVertexCount]; // 在CPU端分配一个大数组

m_QuadVAO = new VertexArray();
m_QuadVBO = new VertexBuffer(MaxVertexCount * sizeof(QuadVertex), GL_DYNAMIC_DRAW); // 创建空的动态VBO

// 设置顶点布局
VertexBufferLayout layout;
layout.Push<float>(3); // Position
layout.Push<float>(4); // Color
layout.Push<float>(2); // TexCoord
layout.Push<float>(1); // TexIndex
m_QuadVAO->AddBuffer(*m_QuadVBO, layout);

// 生成索引缓冲 (这部分数据是固定的,可以一次性生成)
uint32_t indices[MaxIndexCount];
uint32_t offset = 0;
for (size_t i = 0; i < MaxIndexCount; i += 6)
{
indices[i + 0] = offset + 0;
indices[i + 1] = offset + 1;
indices[i + 2] = offset + 2;
indices[i + 3] = offset + 2;
indices[i + 4] = offset + 3;
indices[i + 5] = offset + 0;
offset += 4;
}
m_QuadIBO = new IndexBuffer(indices, MaxIndexCount);
// ...
}
  • 我们用 GL_DYNAMIC_DRAW 创建 VBO,并预分配足够大的空间。
  • IBO 的模式是固定的 (0,1,2, 2,3,0, 4,5,6, 6,7,4, …),所以可以一次性生成。
步骤 3: 帧的开始和结束

我们需要方法来开始一个新的批次和结束(并提交)一个批次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// BatchRenderer2D.cpp
void BatchRenderer2D::BeginScene()
{
m_QuadVertexBufferPtr = m_QuadVertexBuffer; // 重置指向CPU缓冲区的指针
m_IndexCount = 0; // 重置索引计数
m_TextureSlotIndex = 1; // 0号槽留给白色纹理
}

void BatchRenderer2D::EndScene()
{
// 计算这一帧填充了多少数据
GLsizeiptr size = (uint8_t*)m_QuadVertexBufferPtr - (uint8_t*)m_QuadVertexBuffer;

// 将CPU缓冲区的数据上传到GPU的动态VBO中
m_QuadVBO->Bind();
glBufferSubData(GL_ARRAY_BUFFER, 0, size, m_QuadVertexBuffer);

Flush(); // Flush 函数执行绘制
}

void BatchRenderer2D::Flush()
{
// 绑定所有使用的纹理
for (uint32_t i = 0; i < m_TextureSlotIndex; i++)
m_TextureSlots[i]->Bind(i);

// 绑定VAO和着色器
m_Shader->Bind();
m_QuadVAO->Bind();

// 执行一次绘制调用
glDrawElements(GL_TRIANGLES, m_IndexCount, GL_UNSIGNED_INT, nullptr);
}
  • BeginScene 负责重置状态。
  • EndScene 负责将这一帧积累的所有顶点数据一次性通过 glBufferSubData 上传到 GPU。glBufferSubDataglBufferData 更适合更新缓冲区的一部分,性能更好。
  • Flush 负责执行真正的绘制调用。
步骤 4: 提供绘制接口

我们需要提供简单的接口,让上层代码可以方便地“请求”绘制一个四边形。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// BatchRenderer2D.cpp
void BatchRenderer2D::DrawQuad(const glm::vec2& position, const glm::vec2& size, const glm::vec4& color)
{
// 如果缓冲区满了,就先提交一次
if (m_IndexCount >= MaxIndexCount)
{
EndScene();
BeginScene();
}

float textureIndex = 0.0f; // 0.0f 代表纯色(使用白色纹理)

// 计算四边形的四个顶点,并填充到 CPU 缓冲区中
m_QuadVertexBufferPtr->Position = { position.x, position.y, 0.0f };
m_QuadVertexBufferPtr->Color = color;
m_QuadVertexBufferPtr->TexCoord = { 0.0f, 0.0f };
m_QuadVertexBufferPtr->TexIndex = textureIndex;
m_QuadVertexBufferPtr++;
// ... 填充另外3个顶点 ...

m_IndexCount += 6;
}

// 带纹理的版本
void BatchRenderer2D::DrawQuad(const glm::vec2& position, const glm::vec2& size, const std::shared_ptr<Texture>& texture)
{
// ... 检查缓冲区是否已满 ...

// 查找这个纹理是否已经在本批次中被绑定
float textureIndex = 0.0f;
for (uint32_t i = 1; i < m_TextureSlotIndex; i++)
{
if (*m_TextureSlots[i].get() == *texture.get())
{
textureIndex = (float)i;
break;
}
}

// 如果没找到,就把它添加到下一个可用的纹理槽
if (textureIndex == 0.0f)
{
textureIndex = (float)m_TextureSlotIndex;
m_TextureSlots[m_TextureSlotIndex] = texture;
m_TextureSlotIndex++;
}

// ... 填充四个顶点的数据,这次 TexIndex 是算出来的 ...
}
  • 核心逻辑是:不直接调用 OpenGL API,而是将顶点数据写入一个 CPU 端的巨大数组 (m_QuadVertexBuffer)。
  • DrawQuad 负责计算和填充数据,并递增指针和索引计数。
  • 当批次中使用的纹理超过可用纹理单元时,也需要 Flush
步骤 5: 着色器修改

片段着色器需要能够根据 TexIndex 从不同的纹理采样器中选择。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// fragment shader
#version 330 core
// ...
in vec4 v_Color;
in vec2 v_TexCoord;
in float v_TexIndex;

uniform sampler2D u_Textures[32]; // 声明一个采样器数组

void main()
{
int index = int(v_TexIndex);
vec4 texColor = texture(u_Textures[index], v_TexCoord);
color = texColor * v_Color;
}
  • u_Textures 是一个采样器数组,它的值需要由 CPU 端的一个循环来设置 glUniform1iv

4. 优缺点总结

  • 优点:

    • 大幅提升性能: 将数千次绘制调用减少到几次,极大地降低了 CPU 开销。
    • 适用于大量相似物体: 对粒子系统、2D 游戏、UI 等场景效果拔群。
  • 缺点:

    • 实现复杂: 需要精心设计数据结构、渲染器状态管理。
    • 限制: 同一个批次中的所有物体通常必须使用同一个着色器同一个混合模式同一个坐标系(或者在着色器中处理变换)。
    • 内存开销: 需要预先分配一个巨大的 VBO,可能会造成内存浪费。

批量渲染是游戏和图形应用性能优化的一个关键技术,虽然实现起来有一定挑战,但带来的性能提升是巨大的。掌握它,是从“能画出东西”到“能高效地画出大量东西”的重要一步。