第 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_pendingCount 是 std::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
三个关键点:
-
方块数据填充在主线程。 因为
generateBaseTerrain可能读邻接 Chunk 的数据,而邻接 Chunk 可能正在被另一个线程修改。把同步的、跨 Chunk 的操作留主线程,把孤立的重计算(Mesh 生成)丢线程池。 -
Qt::QueuedConnection必须显式指定。QRunnable的run()在子线程执行,如果finished信号用默认的AutoConnection,槽函数会在子线程调用——这时候访问 OpenGL 的上下文就炸了。QueuedConnection强制把槽函数排到主线程的事件队列。 -
setAutoDelete(true)。 QRunnable 默认运行完后不删除,你需要手动管理生命周期或者设为自动。
4. Mesh 未生成完就渲染 — 真实 Bug 复盘
这是我在第一版 ChunkManager 里遇到的一个经典 Bug:
现象: 玩家快速移动时,偶尔会看到一个 Chunk 的顶点全部错位——方块飞到了远处。
排查过程:
- 用 RenderDoc 抓帧,发现出错的 Chunk 的顶点数据只有前半段是正确的,后半段全是未初始化的内存值
- 查看时间线:主线程在
paintGL→chunk->mesh().draw()时,工作线程正在写同一个Mesh的 VBO - 根源:
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 我迭代了三版:
- 单线程版 — 每次移动遍历所有 Chunk,加载一个卡一帧。走 10 步卡 10 次。
- 多线程但共享 Mesh — 工作线程和主线程同时访问同一个 VBO,随机崩溃。修了三天。
- 双缓冲 + 任务队列(当前)— 工作线程写 pendingMesh,主线程 swap。简洁、正确、性能够。
最深的教训: 多线程 Bug 不是「每次都出」——它取决于时序。我的崩溃只在快速移动时触发,慢走从来不崩。这种 Bug 最难定位,最好从设计上避免而不是靠调试找。
系列文章:体素引擎从零构建 | 作者:Logic