强曰为道

与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

第 9 章:实例化渲染

第 9 章:实例化渲染

当需要渲染成千上万个相同物体时(如草地、树木、粒子),逐个调用绘制命令会造成严重的 CPU 瓶颈。实例化渲染(Instancing)通过一次绘制调用渲染所有实例。


9.1 问题背景

9.1.1 传统方式的瓶颈

假设要渲染 10,000 棵树:

// ❌ 每棵树一次绘制调用,共 10,000 次
for (int i = 0; i < 10000; i++) {
    glm::mat4 model = computeModelMatrix(i);
    shader.setMat4("model", model);
    glDrawArrays(GL_TRIANGLES, 0, vertexCount);
}
步骤每次调用开销
glDrawArrays 调用~1-5 μs
CPU→GPU 通信~10 μs
状态切换~1-3 μs

10,000 次调用 = 10-80 ms 仅在 CPU 端!目标是 16.67 ms(60 fps),这完全不可接受。

9.1.2 实例化渲染的解决方案

// ✅ 一次绘制调用,渲染 10,000 棵树
glDrawArraysInstanced(GL_TRIANGLES, 0, vertexCount, 10000);

9.2 基本实例化

9.2.1 使用 gl_InstanceID

每个实例自动获得一个唯一的 gl_InstanceID

// 实例化顶点着色器
#version 460 core

layout (location = 0) in vec3 aPos;

uniform mat4 projection;
uniform mat4 view;

// 实例数据(每个实例不同的偏移)
uniform vec3 offsets[1000];  // 限制:uniform 数量有限

void main() {
    vec3 pos = aPos + offsets[gl_InstanceID];
    gl_Position = projection * view * vec4(pos, 1.0);
}
// C++ 端:设置偏移
glm::vec3 translations[1000];
int index = 0;
for (int y = -10; y < 10; y += 2) {
    for (int x = -10; x < 10; x += 2) {
        translations[index++] = glm::vec3(x, y, 0.0f);
    }
}

for (int i = 0; i < 1000; i++) {
    std::string name = "offsets[" + std::to_string(i) + "]";
    glUniform3fv(glGetUniformLocation(shader.ID, name.c_str()), 1,
                 glm::value_ptr(translations[i]));
}

// 一次绘制所有实例
glDrawArraysInstanced(GL_TRIANGLES, 0, 36, 1000);

⚠️ 上面的方案使用 uniform 数组传递偏移,但 uniform 数量有上限(通常 1024 个 vec4)。更好的方式是使用 Instanced Array。


9.3 Instanced Array(推荐方案)

9.3.1 原理

将每个实例的数据存入 VBO 中,使用 glVertexAttribDivisor 控制更新频率:

普通属性:        每个顶点更新
Instance 属性:   每个实例更新(而非每个顶点)

普通 VBO:    [v0, v1, v2, v3, ...]  → 每顶点
Instance VBO: [inst0, inst1, inst2, ...]  → 每实例

9.3.2 完整实现

// ===== 立方体顶点数据 =====
float cubeVertices[] = { /* 36 个顶点 × 位置(3) + 法线(3) */ };

// ===== 实例数据:100 个偏移 =====
glm::vec3 instancePositions[100];
int idx = 0;
for (int y = -5; y < 5; y++) {
    for (int x = -5; x < 5; x++) {
        instancePositions[idx++] = glm::vec3(x * 2.0f, 0.0f, y * 2.0f);
    }
}

// ===== 创建 VAO =====
unsigned int cubeVAO, cubeVBO, instanceVBO;
glGenVertexArrays(1, &cubeVAO);
glGenBuffers(1, &cubeVBO);
glGenBuffers(1, &instanceVBO);

glBindVertexArray(cubeVAO);

// 顶点数据
glBindBuffer(GL_ARRAY_BUFFER, cubeVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(cubeVertices), cubeVertices, GL_STATIC_DRAW);

// 属性 0: 位置
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)0);

// 属性 1: 法线
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));

// 实例数据
glBindBuffer(GL_ARRAY_BUFFER, instanceVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(instancePositions), instancePositions, GL_STATIC_DRAW);

// 属性 2: 实例偏移
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 3, GL_FLOAT, GL_FALSE, sizeof(glm::vec3), (void*)0);
glVertexAttribDivisor(2, 1);  // 关键!每个实例更新一次

glBindVertexArray(0);

// ===== 渲染 =====
shader.use();
glBindVertexArray(cubeVAO);
glDrawArraysInstanced(GL_TRIANGLES, 0, 36, 100);  // 100 个实例

9.3.3 glVertexAttribDivisor

glVertexAttribDivisor(index, divisor);
divisor 值行为
0默认:每个顶点更新(普通属性)
1每个实例更新一次
N每 N 个实例更新一次

9.4 实例化渲染 + 不同外观

9.4.1 每实例颜色

// 实例颜色 VBO
glm::vec3 instanceColors[100];
for (int i = 0; i < 100; i++) {
    float r = (float)(rand() % 100) / 100.0f;
    float g = (float)(rand() % 100) / 100.0f;
    float b = (float)(rand() % 100) / 100.0f;
    instanceColors[i] = glm::vec3(r, g, b);
}

glBindBuffer(GL_ARRAY_BUFFER, instanceColorVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(instanceColors), instanceColors, GL_STATIC_DRAW);

glEnableVertexAttribArray(3);
glVertexAttribPointer(3, 3, GL_FLOAT, GL_FALSE, sizeof(glm::vec3), (void*)0);
glVertexAttribDivisor(3, 1);  // 每实例更新

9.4.2 每实例变换矩阵

传递完整的 4×4 矩阵需要占用 4 个属性槽:

// 实例变换矩阵 VBO
glm::mat4 instanceModels[100];
// ... 填充数据 ...

glBindBuffer(GL_ARRAY_BUFFER, instanceMatrixVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(instanceModels), instanceModels, GL_STATIC_DRAW);

// 矩阵需要 4 个连续的 vec4 属性
std::size_t vec4Size = sizeof(glm::vec4);
for (int i = 0; i < 4; i++) {
    glEnableVertexAttribArray(3 + i);
    glVertexAttribPointer(3 + i, 4, GL_FLOAT, GL_FALSE, 4 * vec4Size, (void*)(i * vec4Size));
    glVertexAttribDivisor(3 + i, 1);
}
// 顶点着色器
layout (location = 3) in mat4 instanceMatrix;  // 占 location 3,4,5,6

void main() {
    gl_Position = projection * view * instanceMatrix * vec4(aPos, 1.0);
}

9.5 性能对比

9.5.1 测试场景:渲染 N 个立方体

实例数量逐个绘制 (ms)实例化 (ms)加速比
1001.20.3
1,000120.524×
10,0001201.580×
100,00012008150×

💡 实例化渲染的瓶颈从 CPU 调用开销转移到了 GPU 实际绘制吞吐量。

9.5.2 何时使用实例化?

场景是否适合实例化
同一模型不同位置✅ 完美适合
大量粒子系统✅ 适合(或计算着色器)
植被(草地/树木)✅ 经典用例
地图上的建筑✅ 适合
每个物体完全不同的网格❌ 不适合

9.6 高级实例化技术

9.6.1 实例剔除(Frustum Culling on GPU)

在实例化绘制前,用计算着色器剔除不在视锥体内的实例:

// 计算着色器:视锥体剔除
#version 460 core

layout (local_size_x = 64) in;

struct InstanceData {
    vec3 position;
    float radius;  // 包围球半径
};

layout (std430, binding = 0) buffer InstanceBuffer {
    InstanceData instances[];
};

layout (std430, binding = 1) buffer VisibleBuffer {
    uint visibleIndices[];
};

layout (std430, binding = 2) buffer CounterBuffer {
    uint visibleCount;
};

uniform mat4 viewProjection;
uniform vec6 frustumPlanes[6];  // 视锥体 6 个面

void main() {
    uint idx = gl_GlobalInvocationID.x;
    if (idx >= instances.length()) return;

    InstanceData inst = instances[idx];
    bool visible = true;

    // 与 6 个裁剪面进行距离测试
    for (int i = 0; i < 6; i++) {
        float dist = dot(frustumPlanes[i].xyz, inst.position) + frustumPlanes[i].w;
        if (dist < -inst.radius) {
            visible = false;
            break;
        }
    }

    if (visible) {
        uint outIdx = atomicAdd(visibleCount, 1u);
        visibleIndices[outIdx] = idx;
    }
}
// C++ 端:间接绘制
glBindBuffer(GL_DISPATCH_INDIRECT_BUFFER, dispatchBuffer);
glDispatchCompute(numInstances / 64 + 1, 1, 1);

glMemoryBarrier(GL_COMMAND_BARRIER_BIT);

glBindBuffer(GL_DRAW_INDIRECT_BUFFER, indirectBuffer);
glDrawArraysIndirect(GL_TRIANGLES, &indirectCmd);

9.6.2 间接绘制(Indirect Drawing)

// 间接绘制命令结构体
struct DrawArraysIndirectCommand {
    GLuint count;         // 每个实例的顶点数
    GLuint instanceCount; // 实例数量(GPU 剔除后修改)
    GLuint first;         // 第一个顶点
    GLuint baseInstance;  // 基础实例 ID
};

// 设置初始命令
DrawArraysIndirectCommand cmd = { 36, 1000, 0, 0 };
glBindBuffer(GL_DRAW_INDIRECT_BUFFER, indirectBuffer);
glBufferData(GL_DRAW_INDIRECT_BUFFER, sizeof(cmd), &cmd, GL_DYNAMIC_DRAW);

// 使用计算着色器修改 instanceCount
// ...

// 间接绘制(使用 GPU 端的命令数据)
glDrawArraysIndirect(GL_TRIANGLES, 0);

9.7 大规模场景优化策略

9.7.1 LOD(Level of Detail)

// 根据距离选择不同的 LOD 级别
struct LODLevel {
    float maxDistance;
    unsigned int vao;
    unsigned int vertexCount;
};

LODLevel lodLevels[] = {
    { 50.0f,  highDetailVAO, 10000 },  // 近处:高精度
    { 200.0f, midDetailVAO,  2000  },  // 中距离:中精度
    { 500.0f, lowDetailVAO,  500   },  // 远处:低精度
};

// 按 LOD 级别分组实例化绘制
for (auto& lod : lodLevels) {
    // 使用计算着色器按距离分组
    // 然后实例化绘制对应 LOD
    glBindVertexArray(lod.vao);
    glDrawArraysInstanced(GL_TRIANGLES, 0, lod.vertexCount, lodInstanceCount);
}

9.7.2 实例数据更新策略

策略适用场景方法
静态场景中不移动的物体GL_STATIC_DRAW
动态移动的物体GL_DYNAMIC_DRAW + glBufferSubData
流式粒子系统GL_STREAM_DRAW + 三重缓冲
GPU 驱动大规模场景计算着色器 + 间接绘制

9.8 注意事项

⚠️ 属性槽限制:OpenGL 通常至少提供 16 个通用顶点属性槽(GL_MAX_VERTEX_ATTRIBS)。矩阵实例化占用 4 个,需要合理规划。

⚠️ 实例数据大小:如果实例数据非常大(>100MB),考虑使用 Buffer Storage + 持久映射(Persistent Mapping)避免 CPU 端缓冲区管理开销。

⚠️ gl_InstanceID 从 0 开始:第一个实例的 ID 是 0,不是 1。

⚠️ uniform 数组的限制:通过 uniform 数组传递实例数据仅适用于少量实例(<1000)。大量实例必须使用 Instanced Array。


9.9 业务场景

场景 1:森林渲染

数万棵树使用实例化渲染,每棵树有随机的位置、旋转和缩放。结合 LOD 和视锥体剔除,在中等 GPU 上实现 60fps。

场景 2:星系模拟

数百万个星体使用 GPU 驱动的实例化渲染 + 计算着色器更新位置。

场景 3:城市建筑

大量重复建筑模型使用实例化,每个实例有不同的变换矩阵和材质索引。


9.10 扩展阅读

资源说明
Learn OpenGL - Instancing实例化教程
GPU-Driven RenderingSIGGRAPH GPU 驱动渲染课程
Mesh Shader (Vulkan/DX12)下一代几何管线

本章小结

  • 实例化渲染将 N 次绘制调用合并为 1 次,大幅提升大量物体的渲染性能
  • glVertexAttribDivisor(attrib, 1) 使属性每实例更新一次
  • gl_InstanceID 在着色器中标识当前实例
  • Instanced Array 比 uniform 数组更适合大量实例
  • 间接绘制 + 计算着色器可以实现 GPU 驱动的剔除和 LOD
  • 仅对相同网格的不同实例使用实例化

上一章第 8 章:高级 OpenGL 下一章第 10 章:计算着色器