前十章搭了一个完整的体素引擎。现在做两件事把性能压到极致:让非方块形的物体(草、花、树叶)用实例化一次画完,让看不见的 Chunk 根本不参与渲染。
1. 实例化渲染 — 一万根草一次画完
画 10000 个草方块的做法:
- 普通方式:10000 次
glDrawElements× 每次改u_modeluniform → 10000 次 Draw Call - 实例化方式:1 次
glDrawElementsInstanced,GPU 自己算每个实例的位置
Draw Call 的数量直接决定帧率上限。 10000 个 Draw Call 在集成显卡上可能只有 30 FPS,1 个就能跑满 60。
1.1 着色器改造
// instanced.vert
#version 330 core
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_texCoord;
// 每个实例的独立数据
layout(location = 3) in mat4 a_instanceModel; // 实例的 model 矩阵
layout(location = 7) in vec4 a_instanceColor; // 实例的色调微调
uniform mat4 u_view;
uniform mat4 u_proj;
out vec3 v_normal;
out vec2 v_texCoord;
out vec4 v_color;
void main() {
mat4 mvp = u_proj * u_view * a_instanceModel;
gl_Position = mvp * vec4(a_position, 1.0);
v_normal = mat3(a_instanceModel) * a_normal;
v_texCoord = a_texCoord;
v_color = a_instanceColor;
}
关键细节: layout(location = 3) in mat4 a_instanceModel 占用了 4 个 vertex attribute location(3/4/5/6)。所以 a_instanceColor 从 location 7 开始。
1.2 实例数据上传
// InstanceRenderer.h
struct InstanceData {
QMatrix4x4 model;
float variation = 0.0f; // 色调微调(-0.1~0.1)
};
class InstanceRenderer {
public:
void uploadInstances(const QVector<InstanceData> &instances);
void draw(QOpenGLFunctions *gl, GLsizei vertexCount);
private:
QOpenGLBuffer m_instanceVBO{QOpenGLBuffer::VertexBuffer};
QOpenGLVertexArrayObject m_vao;
GLsizei m_count = 0;
};
// InstanceRenderer.cpp
void InstanceRenderer::uploadInstances(const QVector<InstanceData> &instances) {
if (instances.isEmpty()) return;
m_count = instances.size();
m_vao.bind();
// 实例数据 VBO
m_instanceVBO.create();
m_instanceVBO.bind();
m_instanceVBO.allocate(instances.constData(),
instances.size() * sizeof(InstanceData));
// 复用同一个 VAO 的 vertex attribute 设置...
}
1.3 绘制
// 画 10000 根草
auto &mesh = s_grassMesh; // 静态共享 Mesh(一根草的 4 个顶点)
auto &instances = m_grassInstances; // QVector<InstanceData>
instanceRenderer.uploadInstances(instances);
// 注意:最后一个参数是实例数量
glDrawElementsInstanced(GL_TRIANGLES,
mesh.indexCount(), // 每个实例的顶点数
GL_UNSIGNED_INT,
nullptr,
instanceCount); // 画多少个实例!
graph LR
MESH["一根草的 Mesh<br/>4 顶点, 2 三角形"]
INST["10000 个 InstanceData<br/>(worldPos, variation)"]
GPU["GPU<br/>glDrawElementsInstanced<br/>1 次 Draw Call"]
SCREEN["屏幕:10000 根草"]
MESH --> GPU
INST --> GPU
GPU --> SCREEN
style MESH fill:#50b86c,color:#fff
style INST fill:#f39c12,color:#fff
style GPU fill:#e74c3c,color:#fff
style SCREEN fill:#2ecc71,color:#fff
1.4 性能对比
| 场景 | 普通 Draw Call | 实例化 | FPS 提升 |
|---|---|---|---|
| 1000 根草 | 1000 | 1 | 12→60 (5×) |
| 5000 根草 | 5000 | 1 | 4→60 (15×) |
| 10000 根草 | 10000 | 1 | 2→55 (27×) |
实例化是对「大量相似物体」的终极优化。 每个实例只有一个 mat4 model 矩阵不同,GPU 的并行架构天生适合这种「数据不同、逻辑相同」的工作负载。
2. 视锥体剔除 — 看不见的不画
视锥体就是你能看到的那个金字塔形的空间。外面的一切都可以跳过。
graph TD
subgraph FRUSTUM["相机视锥体"]
CAM2["相机位置"]
NEAR["近平面"]
FAR["远平面"]
end
subgraph CHUNKS["Chunk 世界"]
C1["Chunk A<br/>✓ 可见"]
C2["Chunk B<br/>✓ 可见"]
C3["Chunk C<br/>✗ 在视锥体外面<br/>跳过"]
C4["Chunk D<br/>✗ 在相机背后<br/>跳过"]
end
FRUSTUM -->|"AABB 六面检测"| CHUNKS
style C1 fill:#2ecc71,color:#fff
style C2 fill:#2ecc71,color:#fff
style C3 fill:#e74c3c,color:#fff
style C4 fill:#e74c3c,color:#fff
2.1 视锥体六面提取
从 proj * view 矩阵中提取六个裁剪面的平面方程:
struct Plane {
QVector3D normal;
float distance;
// 点到平面的有符号距离(正 = 在平面前方)
float signedDistance(const QVector3D &point) const {
return QVector3D::dotProduct(normal, point) + distance;
}
};
struct Frustum {
Plane planes[6]; // 近/远/左/右/上/下
};
Frustum extractFrustum(const QMatrix4x4 &projView) {
Frustum f;
// 从 MVP 矩阵的行组合推导出六个平面
// 左平面:row3 + row0
f.planes[0] = planeFromRow(projView, 3, 0, true);
// 右平面:row3 - row0
f.planes[1] = planeFromRow(projView, 3, 0, false);
// 下平面:row3 + row1
f.planes[2] = planeFromRow(projView, 3, 1, true);
// 上平面:row3 - row1
f.planes[3] = planeFromRow(projView, 3, 1, false);
// 近平面:row3 + row2
f.planes[4] = planeFromRow(projView, 3, 2, true);
// 远平面:row3 - row2
f.planes[5] = planeFromRow(projView, 3, 2, false);
// 归一化所有平面
for (auto &p : f.planes) {
float len = p.normal.length();
p.normal /= len;
p.distance /= len;
}
return f;
}
2.2 AABB vs 视锥体检测
bool isAABBInFrustum(const Frustum &frustum, const AABB &box) {
QVector3D corners[8] = {
{box.min.x(), box.min.y(), box.min.z()},
{box.max.x(), box.min.y(), box.min.z()},
{box.min.x(), box.max.y(), box.min.z()},
{box.max.x(), box.max.y(), box.min.z()},
{box.min.x(), box.min.y(), box.max.z()},
{box.max.x(), box.min.y(), box.max.z()},
{box.min.x(), box.max.y(), box.max.z()},
{box.max.x(), box.max.y(), box.max.z()},
};
// 对六个面依次检测
for (int i = 0; i < 6; ++i) {
int outside = 0;
for (int j = 0; j < 8; ++j) {
if (frustum.planes[i].signedDistance(corners[j]) < 0)
outside++;
}
// 8 个顶点全在这个面的外侧 → 完全不可见
if (outside == 8) return false;
}
return true; // 可见(或部分可见)
}
为什么只用 AABB 的中心+半长不够? 因为一个横跨视锥体边界的 Chunk,它的 AABB 中心可能在视锥体外面,但半边在视锥体里面。用 8 个顶点逐一检测才能避免误剔除。
2.3 集成到 ChunkManager
void ChunkManager::render(const QMatrix4x4 &projView) {
Frustum frustum = extractFrustum(projView);
int visible = 0, culled = 0;
for (auto *chunk : loadedChunks()) {
if (!isAABBInFrustum(frustum, chunk->boundingBox())) {
culled++;
continue; // 跳过!
}
visible++;
chunk->mesh().draw();
}
// qDebug() << "Visible:" << visible << "Culled:" << culled;
}
性能影响(512 Chunk 场景,FOV 90°):
| 指标 | 无剔除 | 有剔除 | 提升 |
|---|---|---|---|
| 绘制 Chunk 数 | 512 | ~120 | 4.3× 减少 |
| FPS | 85 | 210 | 2.5× |
| CPU 剔除耗时 | 0ms | 0.15ms | 可忽略 |
即使只有这个 isAABBInFrustum 的 O(n) 循环(n=512),每个 Chunk 的检测只需要几十次浮点运算,总耗时 < 0.2ms。换来的却是渲染负载降到原来的 1/4。
3. 两招合体后的性能全景
| 优化 | 效果 | 代价 |
|---|---|---|
| 实例化(草/花/树叶) | Draw Call 从 10000→3 | 着色器多加 4 个 attribute |
| 视锥体剔除(Chunk) | 渲染 Chunk 从 512→120 | CPU 端 0.15ms/帧 |
| 面剔除(第 2 章) | 三角形减少 ~50% | GPU 自动,零代价 |
| 静态共享 Mesh(第 2 章) | VAO 从 1000→1 | 无 |
把这几招叠起来之后,在 i7-13700K / GTX 1660 上的典型表现:
FPS: 210 (512 Chunks, ~120 可见)
内存: 512 Chunks × 128KB = 64MB
Draw Call: 3 (实例化) + 120 (Chunk) = 123
VRAM: ~80MB (顶点 + 纹理图集)
这已经足够在任意现代硬件上稳定跑 60 FPS。
架构师复盘
实例化我一开始做错了——把成千上万个实例数据放在 CPU 端每次上传。正确做法是把 InstanceData VBO 建一次、在 uploadInstances 中 glBufferData 更新,GPU 直接从显存读。对于静态场景可以标记为 GL_STATIC_DRAW,动态场景用 GL_DYNAMIC_DRAW。
视锥体剔除的一个坑: 远平面的错误设置会导致远处的 Chunk 被误剔除。projView 矩阵提取出来的远平面方程,要求你的投影矩阵确实是「近大远小」的透视投影——正交投影不适用。体素引擎用透视投影,所以没问题。
系列文章:体素引擎从零构建 | 作者:Logic