前八章搭了一个可以折腾的世界。但关了程序,辛辛苦苦挖的山洞、盖的房子全没了。这一章给世界加「保存」按钮——把玩家的创造持久化到磁盘。

1. 设计原则:不存能算出来的东西

graph TD
    SEED["世界种子 (64-bit)"]
    NOISE["程序化地形生成"]
    MODIFIED["玩家修改过的 Chunk<br/>(x,z) → 方块数据"]
    LOAD["加载世界"]
    SAVE["保存世界"]

    SEED --> NOISE
    NOISE --> LOAD
    MODIFIED --> LOAD
    MODIFIED --> SAVE
    SAVE --> MODIFIED

    style SEED fill:#e74c3c,color:#fff
    style NOISE fill:#f39c12,color:#fff
    style MODIFIED fill:#2ecc71,color:#fff

核心思想:只存玩家改过的东西。 未被涉足的 Chunk 靠世界种子和地形算法重建——它们是确定性的,同样输入永远产出同样输出。一个玩家盖了房子、挖了矿洞的世界,实际只有不到 2% 的 Chunk 被修改过。不存那 98% 能省 90% 以上的空间。


2. 文件格式设计

把整个世界存成一个文件(或一个目录下的多个文件)。

2.1 文件头

Offset  Size  Field
────────────────────────────────────
0       4     魔数 "VOXL" (0x4C584F56)
4       4     版本号 (当前 = 1)
8       8     世界种子 (64-bit)
16      4     Chunk 尺寸 (W=16)
20      4     压缩算法 (0=zlib, 1=zstd)
24      4     Chunk 数量 N
28      N*8   Chunk 索引表
28+N*8  变长  Chunk 数据区

魔数 VOXL 的作用:打开文件时用 4 个字节就能判断这是不是体素世界的存档,不是的话立刻报错。

版本号的作用:将来改格式(比如把方块 ID 从 8-bit 扩到 16-bit),老存档可以用旧版加载器读出来、转成新格式。

2.2 Chunk 索引表

每个 Chunk 一条 8 字节记录:

Offset  Size  Field
────────────────────────────────────
0       4     Chunk X 坐标 (int32)
4       2     Chunk Z 坐标 (int16)  
6       2     压缩后数据大小 (uint16, 最大 64KB)

索引表的存在让你可以快速判断某个 Chunk 是否已被保存,而不需要扫描整个数据区。加载世界时,先把索引表读进 QHash<int64_t, ChunkEntry>,后续查询就是 O(1) 的哈希查找。

2.3 Chunk 数据区

每个 Chunk 的方块数据用 Run-Length Encoding(游程编码)压缩,再用 zstd 压一层:

// 第一步:RLE 编码(把连续相同的方块合在一起)
struct RLEBlock {
    uint16_t count;  // 连续多少个
    uint8_t type;    // 方块类型
};
// "石头,石头,石头,石头,石头,空气,空气,空气"
// 变成 [{count=5, type=Stone}, {count=3, type=Air}]

// 第二步:zstd 压缩 RLE 结果
// ZSTD_compress(rleData, rleSize, compressedBuf, compressedCapacity, 3)

为什么两步压缩?因为 RLE 把「65536 个方块」变成「几百条 Run」,体积已经从 128KB 降到几 KB 了。zstd 在已经稀疏的数据上再做一层压缩,最终单个 Chunk 通常只有 1-3KB。

对比:傻存整个 Chunk 的裸数据 → 128KB/Chunk。RLE + zstd → 平均 2KB/Chunk。压缩比 60:1。


3. 保存实现

#include <fstream>
#include <zstd.h>

bool WorldStorage::save(const QString &path,
                         const QVector<Chunk*> &chunks)
{
    std::ofstream file(path.toStdString(), std::ios::binary);
    if (!file) return false;

    // 1. 写文件头
    writeHeader(file);
    // 2. 预留索引表位置(写完 Chunk 数据后回填)
    auto indexPos = file.tellp();
    uint32_t chunkCount = 0;
    file.seekp(4 * 8); // 跳过 N*8 字节给索引表

    // 3. 写 Chunk 数据
    struct IndexEntry { int32_t cx; int16_t cz; uint16_t size; };
    QVector<IndexEntry> index;

    for (auto *chunk : chunks) {
        if (!chunk->isModified()) continue;  // 只存修改过的!

        // RLE 编码
        auto rleData = encodeRLE(chunk);

        // zstd 压缩
        size_t compBound = ZSTD_compressBound(rleData.size());
        auto compBuf = std::make_unique<uint8_t[]>(compBound);
        size_t compSize = ZSTD_compress(
            compBuf.get(), compBound,
            rleData.data(), rleData.size(),
            3  // 压缩级别 1-19,3 是速度与体积的平衡点
        );

        // 写数据
        file.write(reinterpret_cast<const char*>(compBuf.get()), compSize);
        index.push_back({chunk->chunkX(), chunk->chunkZ(),
                         static_cast<uint16_t>(compSize)});
    }

    // 4. 回填索引表
    auto dataStart = file.tellp();
    file.seekp(indexPos);
    file.write(reinterpret_cast<const char*>(&chunkCount), 4);
    for (auto &e : index) {
        file.write(reinterpret_cast<const char*>(&e.cx), 4);
        file.write(reinterpret_cast<const char*>(&e.cz), 2);
        file.write(reinterpret_cast<const char*>(&e.size), 2);
    }

    return true;
}

4. 加载实现

bool WorldStorage::load(const QString &path,
                         ChunkManager &manager,
                         uint64_t &seed)
{
    std::ifstream file(path.toStdString(), std::ios::binary);
    if (!file) return false;

    // 1. 读文件头 + 验证
    uint32_t magic;
    file.read(reinterpret_cast<char*>(&magic), 4);
    if (magic != 0x4C584F56) {
        qWarning() << "Invalid world file: bad magic";
        return false;
    }

    uint32_t version;
    file.read(reinterpret_cast<char*>(&version), 4);
    file.read(reinterpret_cast<char*>(&seed), 8);

    // 2. 读索引表
    uint32_t chunkCount;
    file.read(reinterpret_cast<char*>(&chunkCount), 4);
    QVector<IndexEntry> index(chunkCount);
    file.read(reinterpret_cast<char*>(index.data()),
              chunkCount * sizeof(IndexEntry));

    // 3. 按需加载 Chunk(先不全部加载,只注册到索引)
    for (auto &e : index) {
        auto *chunk = new Chunk(e.cx, e.cz);
        chunk->setHasSavedData(true);  // 标记:数据在磁盘,等玩家靠近再解压
        manager.registerChunk(chunk);
    }

    // 4. ChunkManager 在玩家靠近时调 loadChunkData
    return true;
}

bool WorldStorage::loadChunkData(Chunk *chunk,
                                  std::ifstream &file,
                                  const IndexEntry &entry)
{
    file.seekg(entry.dataOffset);

    auto compBuf = std::make_unique<uint8_t[]>(entry.compSize);
    file.read(reinterpret_cast<char*>(compBuf.get()), entry.compSize);

    // zstd 解压
    size_t decSize = ZSTD_getFrameContentSize(compBuf.get(), entry.compSize);
    auto rleBuf = std::make_unique<uint8_t[]>(decSize);
    ZSTD_decompress(rleBuf.get(), decSize, compBuf.get(), entry.compSize);

    // RLE 解码 → 方块数据
    decodeRLE(chunk, rleBuf.get(), decSize);
    chunk->rebuildMesh();
    return true;
}

按需加载的好处: 玩家可能走遍了半个地图、修改了 200 个 Chunk。但每次打开游戏只需要看得到的那 100 个 Chunk。索引表让你知道「哪些 Chunk 有存过的数据」,但实际解压只在玩家靠近时才触发。


5. 格式演进:版本号的价值

bool WorldStorage::load(const QString &path, ...) {
    // ...
    if (version == 1) {
        loadV1(file, manager, seed);
    } else if (version == 2) {
        loadV2(file, manager, seed);
    } else {
        qWarning() << "Unsupported world version:" << version;
        return false;
    }
}

版本 2 可能是什么?比如:

  • 方块 ID 从 8-bit 扩到 16-bit(支持 65536 种方块而不是 256 种)
  • 增加每个方块的附加数据(比如箱子内容、告示牌文字)
  • 存储光照信息而不只是方块类型

只要版本号不变,格式就是固定的。只要版本号变了,旧版加载器就报错。新版加载器保留旧版读取逻辑。


6. 性能数据

场景 裸数据大小 RLE 后 zstd 后 保存耗时
刚生成的世界 (0 修改) 0 0 0 写入种子即可
100 个修改过的 Chunk ~12.8 MB ~500 KB ~180 KB ~15ms
500 个修改过的 Chunk ~64 MB ~2.5 MB ~900 KB ~45ms
1000 个修改过的 Chunk ~128 MB ~5 MB ~1.8 MB ~80ms

7. 章节收尾

graph TD
    SAVE_BTN["玩家点击保存"]
    MOD_CHUNKS["筛选已修改的 Chunk"]
    RLE["RLE 游程编码"]
    ZSTD["zstd 压缩"]
    WRITE["写入二进制文件<br/>文件头 + 索引表 + 数据"]
    DISK["磁盘: world.voxl"]

    SAVE_BTN --> MOD_CHUNKS
    MOD_CHUNKS --> RLE
    RLE --> ZSTD
    ZSTD --> WRITE
    WRITE --> DISK

    style SAVE_BTN fill:#e74c3c,color:#fff
    style RLE fill:#f39c12,color:#fff
    style ZSTD fill:#9b59b6,color:#fff
    style DISK fill:#2ecc71,color:#fff

架构师复盘

最开始我做了一件蠢事——把整个世界存成一个巨大的 JSON 文件。一个中等大小的世界(300 个修改过的 Chunk)→ JSON 文件 150MB,加载 30 秒。而且 JSON 解析在主线程,界面卡死。

教训:二进制格式不是过度设计,是必需。 体素世界的存档就是高频读写的大数据块,JSON 的「可读性」在这里没有任何价值——没人读得懂几 MB 的方块 ID。

换二进制 + zstd 之后,同样的世界只有 700KB,加载 200ms。这才是玩家能接受的速度。


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