第 3 章做出来一个能高效存储和渲染的 Chunk。现在把它管理起来——只加载玩家周围的 Chunk,走远的卸载掉。这件事的根本挑战不是「开个线程」,而是「开线程之后数据怎么安全地传回主线程」。

1. 设计:玩家为中心的环形加载区

不是加载整个世界。只加载以玩家为中心、半径为 R 的 Chunk 区域。

graph TD
    subgraph LOADED["已加载区域(半径 R=8)"]
        P["玩家位置<br/>Chunk(cx, cz)"]
    end
    subgraph QUEUE["加载队列"]
        Q1["Chunk(3,5) 待加载"]
        Q2["Chunk(-2,7) 待生成"]
    end
    subgraph UNLOAD["待卸载"]
        U1["Chunk(10,10) 太远了"]
    end

    P -->|"距离 < R"| LOADED
    P -->|"距离 > R"| UNLOAD
    P -->|"距离 = R 边界"| QUEUE

规则:

  • 玩家每移动到一个新 Chunk,触发一次加载判断
  • 距离 ≤ R 的 Chunk:没加载的就加载,已经在的保持
  • 距离 > R 的 Chunk:标记为待卸载,下一帧安全清理
  • 不在队列里、不在加载区的 Chunk:从内存中释放

2. ChunkManager 骨架

// ChunkManager.h
#pragma once
#include <QObject>
#include <QThreadPool>
#include <QHash>
#include <QVector>
#include <mutex>
#include "Chunk.h"

namespace ve {

class ChunkManager : public QObject {
    Q_OBJECT
public:
    explicit ChunkManager(QObject *parent = nullptr);
    ~ChunkManager();

    /// 玩家移动时调用,触发加载/卸载判断
    void update(const QVector3D &playerPos);

    /// 获取 Chunk,未加载返回 nullptr
    Chunk *getChunk(int cx, int cz) const;

    /// 所有已加载 Chunk 的列表
    QVector<Chunk*> loadedChunks() const;

    void setLoadRadius(int r) { m_loadRadius = r; }

    int pendingCount() const { return m_pendingCount.load(); }

signals:
    /// Chunk Mesh 生成完成,主线程可以开始使用
    void chunkReady(int cx, int cz);

private:
    void loadChunk(int cx, int cz);
    void unloadChunk(int cx, int cz);
    void processQueue();

    int m_loadRadius = 8;

    // 所有 Chunk,key = cx<<16 | cz
    QHash<int64_t, Chunk*> m_chunks;

    // 待加载队列
    QVector<QPair<int, int>> m_loadQueue;

    // 当前玩家所在的 Chunk 坐标
    int m_playerCX = 0, m_playerCZ = 0;

    QThreadPool *m_threadPool;
    std::atomic<int> m_pendingCount{0};
    mutable std::mutex m_mutex;
};

} // namespace ve

线程安全设计: m_chunks 的读写用 m_mutex 保护。m_pendingCountstd::atomic——多个工作线程同时减少它不需要锁。


3. 任务队列 + 线程池

Qt 的 QThreadPool + QRunnable 组合拳:

// ChunkLoadTask.h
#pragma once
#include <QRunnable>
#include <QObject>

namespace ve {

class ChunkLoadTask : public QObject, public QRunnable {
    Q_OBJECT
public:
    ChunkLoadTask(Chunk *chunk) : m_chunk(chunk) {
        setAutoDelete(true);  // 运行完自动 delete
    }

    void run() override {
        if (!m_chunk) return;
        m_chunk->rebuildMesh();  // 耗时的 Mesh 生成在工作线程
        emit finished(m_chunk->chunkX(), m_chunk->chunkZ());
    }

signals:
    void finished(int cx, int cz);

private:
    Chunk *m_chunk;
};

} // namespace ve
// ChunkManager.cpp — 提交任务
void ChunkManager::loadChunk(int cx, int cz) {
    auto *chunk = new Chunk(cx, cz);

    // 1. 同步初始化(方块数据填充在主线程,因为可能读邻接 Chunk)
    generateBaseTerrain(chunk);

    {
        std::lock_guard lock(m_mutex);
        m_chunks[makeKey(cx, cz)] = chunk;
    }

    // 2. 异步生成 Mesh(耗时操作,在线程池里跑)
    auto *task = new ChunkLoadTask(chunk);

    // 关键:用 Qt::QueuedConnection 确保回调在主线程执行
    connect(task, &ChunkLoadTask::finished,
            this, &ChunkManager::onChunkMeshReady,
            Qt::QueuedConnection);

    m_pendingCount++;
    m_threadPool->start(task);
}

void ChunkManager::onChunkMeshReady(int cx, int cz) {
    m_pendingCount--;

    // Mesh 已生成,现在可以安全渲染了
    emit chunkReady(cx, cz);
}
graph LR
    MAIN["主线程<br/>update() → loadChunk()"]
    POOL["QThreadPool<br/>ChunkLoadTask::run()"]
    CALLBACK["主线程<br/>onChunkMeshReady()"]

    MAIN -->|"提交 QRunnable<br/>填充方块数据"| POOL
    POOL -->|"生成 Mesh<br/>(耗时,子线程)"| POOL
    POOL -->|"emit finished<br/>Qt::QueuedConnection"| CALLBACK
    CALLBACK -->|"chunkReady 信号<br/>渲染线程可用"| MAIN

三个关键点:

  1. 方块数据填充在主线程。 因为 generateBaseTerrain 可能读邻接 Chunk 的数据,而邻接 Chunk 可能正在被另一个线程修改。把同步的、跨 Chunk 的操作留主线程,把孤立的重计算(Mesh 生成)丢线程池。

  2. Qt::QueuedConnection 必须显式指定。 QRunnablerun() 在子线程执行,如果 finished 信号用默认的 AutoConnection,槽函数会在子线程调用——这时候访问 OpenGL 的上下文就炸了。QueuedConnection 强制把槽函数排到主线程的事件队列。

  3. setAutoDelete(true) QRunnable 默认运行完后不删除,你需要手动管理生命周期或者设为自动。


4. Mesh 未生成完就渲染 — 真实 Bug 复盘

这是我在第一版 ChunkManager 里遇到的一个经典 Bug:

现象: 玩家快速移动时,偶尔会看到一个 Chunk 的顶点全部错位——方块飞到了远处。

排查过程:

  1. 用 RenderDoc 抓帧,发现出错的 Chunk 的顶点数据只有前半段是正确的,后半段全是未初始化的内存值
  2. 查看时间线:主线程在 paintGLchunk->mesh().draw() 时,工作线程正在写同一个 Mesh 的 VBO
  3. 根源:Mesh::upload() 不是线程安全的。 工作线程在 rebuildMesh 中调 glBufferData 写入新顶点,主线程同时 glDrawElements 读旧顶点——VBO 没有 double-buffering

修复方案:

// Chunk 里加一个「待就绪」Mesh
class Chunk {
    Mesh m_mesh;          // 正在渲染的 Mesh(只主线程访问)
    Mesh m_pendingMesh;   // 工作线程写入的 Mesh
    std::atomic<bool> m_pendingReady{false};

public:
    void rebuildMeshAsync() {
        // 工作线程写 m_pendingMesh
        m_pendingMesh.upload(vertices, indices);
        m_pendingReady.store(true);
    }

    void swapMeshIfReady() {
        // 主线程在 update() 中调,原子交换
        if (m_pendingReady.load()) {
            std::swap(m_mesh, m_pendingMesh);
            m_pendingReady.store(false);
        }
    }
};

工作流程:工作线程写完 m_pendingMesh → 设原子标记 → 主线程检测到标记 → swap 两个 Mesh → 清标记。整个过程无锁,数据不冲突。

教训: 不共享可变数据是线程安全的最高境界。Double-buffer 是最简单的不共享实现——两个线程各自拥有一份数据的所有权,只在交换时传递所有权。


5. 章节收尾

graph TD
    UPDATE["玩家移动<br/>ChunkManager::update()"]
    DECIDE{"距离 < R ?"}
    LOAD["loadChunk()<br/>主线程:填方块数据"]
    TASK["ChunkLoadTask<br/>线程池:生成 Mesh"]
    SWAP["swapMeshIfReady()<br/>主线程:交换 Mesh"]
    RENDER["paintGL()<br/>渲染"]
    UNLOAD["unloadChunk()<br/>释放内存"]

    UPDATE --> DECIDE
    DECIDE -->|"是"| LOAD
    DECIDE -->|"否"| UNLOAD
    LOAD --> TASK
    TASK --> SWAP
    SWAP --> RENDER

架构师复盘

ChunkManager 我迭代了三版:

  1. 单线程版 — 每次移动遍历所有 Chunk,加载一个卡一帧。走 10 步卡 10 次。
  2. 多线程但共享 Mesh — 工作线程和主线程同时访问同一个 VBO,随机崩溃。修了三天。
  3. 双缓冲 + 任务队列(当前)— 工作线程写 pendingMesh,主线程 swap。简洁、正确、性能够。

最深的教训: 多线程 Bug 不是「每次都出」——它取决于时序。我的崩溃只在快速移动时触发,慢走从来不崩。这种 Bug 最难定位,最好从设计上避免而不是靠调试找。


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