前十章搭了一个完整的体素引擎。现在做两件事把性能压到极致:让非方块形的物体(草、花、树叶)用实例化一次画完,让看不见的 Chunk 根本不参与渲染。

1. 实例化渲染 — 一万根草一次画完

画 10000 个草方块的做法:

  • 普通方式:10000 次 glDrawElements × 每次改 u_model uniform → 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 建一次、在 uploadInstancesglBufferData 更新,GPU 直接从显存读。对于静态场景可以标记为 GL_STATIC_DRAW,动态场景用 GL_DYNAMIC_DRAW

视锥体剔除的一个坑: 远平面的错误设置会导致远处的 Chunk 被误剔除。projView 矩阵提取出来的远平面方程,要求你的投影矩阵确实是「近大远小」的透视投影——正交投影不适用。体素引擎用透视投影,所以没问题。


系列文章:体素引擎从零构建 | 作者:Logic