大地形与开放世界渲染:LOD、分块与实例化

开放世界游戏渲染的难点在于几何规模巨大——数平方公里地形、数十万棵树、上亿三角形。本文详解地形的高度场与体素表示、四叉树 LOD 与几何裁剪、GPU 驱动的地形渲染(Compute/曲面细分)、植被的实例化与 GPU Culling、遮挡剔除,以及流式加载与内存管理,构建大世界渲染的完整工程方案。

开放世界(Open World)游戏的核心挑战不是画质,而是规模:GTA、塞尔达、原神这类作品的地图动辄数十平方公里,植被数以百万计,若用传统"每物体一次 Draw Call"的方式渲染,一帧的 CPU 开销就会把帧率拖垮。大世界渲染的本质是把"不可见的东西"挡在管线外、把"重复的东西"成批处理。本文从地形表示、LOD 分层、GPU 驱动绘制到流式内存,逐层拆解这套工程系统。


一、地形表示:Heightfield 与体素

一句话:地形分两种主流表示——高度场(Heightfield)内存小、硬件友好,适合地表;体素(Voxel)支持洞穴与悬垂结构,但内存与网格化成本高。

1.1 高度场(Heightfield)

高度场是最经典的地形表示:一张灰度高度图 H(x, z),每个 texel 存该点的高度值。内存代价极小(16 位高度图 1 km² @ 1m 精度仅 2 MB),且天然与 GPU 网格、纹理采样兼容。缺点是只能表示单值表面——无法表达洞穴、悬垂、拱门。

精度覆盖 1 km² 内存(R16)覆盖 1 km² 内存(R32)
1 m/texel2 MB4 MB
0.5 m/texel8 MB16 MB
0.25 m/texel32 MB64 MB
// GLSL:从高度图求地表法线(有限差分)
vec3 computeTerrainNormal(sampler2D heightMap, vec2 uv, float heightScale) {
    float hL = texture(heightMap, uv + vec2(-1.0, 0.0)).r;
    float hR = texture(heightMap, uv + vec2( 1.0, 0.0)).r;
    float hD = texture(heightMap, uv + vec2( 0.0, -1.0)).r;
    float hU = texture(heightMap, uv + vec2( 0.0,  1.0)).r;
    vec3 n = normalize(vec3(hL - hR, 2.0, hD - hU) * heightScale);
    return n;
}

1.2 体素地形(Voxel Terrain)

体素用三维栅格表示地形(每格标记空/实),支持任意拓扑:洞穴、隧道、浮岛、悬垂。内存开销大(1 km³ @ 1m 为 10⁹ 个体素),但可用稀疏八叉树(SVO)或 SDF 压缩。网格化常用 Marching Cubes / Transvoxel 算法从体素场提取等值面,配合多个 LOD 层实现"无缝细节地形"(如 Minecraft 的 mod、UE5 的 voxel 插件、Teardown 的破坏地形)。

1.3 混合方案

工业实践通常是混合:地表主体用高度场 + 细节法线/置换贴图;洞穴、地下等特殊区域单独用体素或手工模型。高度场决定宏观形状,体素/手工几何补充微观拓扑。


二、四叉树 LOD 与几何裁剪

一句话:四叉树把地形按块递归四等分,每块按与相机的距离选择精细或粗糙网格——远处一块大三角形、近处密集细分,几何量恒定为 O(screen size)。

2.1 四叉树结构与误差度量

将地形视锥投影到 XZ 平面,递归分割为四块(Quad)。每个节点记录其包围盒与"如果渲染这块,几何误差有多大"。LOD 选择的关键指标是屏幕空间误差(Screen-Space Error):节点简化后,最坏情况下像素偏移超过阈值(如 1~2 px),则继续细分;否则渲染当前粗糙块。

屏幕空间误差 ≈ (几何误差 × 投影因子) / 距离
projectedError = geometricError * viewportHeight / (2 * tan(fov/2) * distance)

2.2 相邻块裂缝(Crack / T-Junction)

不同 LOD 的相邻块共享边缘时,粗块边缘顶点与细块不一致,会产生裂缝(T-Junction)。经典解法:

  • 裙边(Skirt):给每块底部加一圈下探的裙边,裂缝被盖住(简单但会在陡坡露出)。
  • 裁剪/缝合(Stitching):渲染粗块时,沿边界把细块多出的顶点"折叠"到粗块边缘,不产生裂缝(UE Landscape 的方式)。
// C++:四叉树 LOD 选择(伪代码)
struct TerrainNode {
    AABB bounds;
    float geometricError;
    int lodLevel;
    std::array<TerrainNode*, 4> children;
};

void selectLOD(TerrainNode* node, const Camera& cam, float screenSpaceError,
               std::vector<TerrainNode*>& outNodes) {
    float distance = length(cam.position - node->bounds.center());
    float projectedError = node->geometricError * cam.projectionScale() / distance;
    bool withinScreenBudget = projectedError <= screenSpaceError;

    if (withinScreenBudget || node->children.empty()) {
        outNodes.push_back(node);   // 渲染此块
    } else {
        for (auto* child : node->children)
            selectLOD(child, cam, screenSpaceError, outNodes);
    }
}

2.3 渐进网格 vs 离散块

四叉树 LOD 是离散块方案(每块固定一个 LOD),现代 GPU 友好的替代是**渐进网格(Progressive Mesh)**或 Geometry Clipmap(2004 年 NVIDIA 提出:固定数量同心环,每环一个分辨率,随相机移动滚动更新)。Geometry Clipmap 无需树结构、GPU 缓存友好,是早期大世界(如群星原力)的主流,但近年更偏好 compute 驱动的离散块 + 间接绘制。

2.4 LOD 切换的 Pop-in 消除

相机移动时地形块在 LOD 间切换,若网格顶点突然增减,会看到明显的"突变弹出"(Pop-in)。工业界的三层防线:

  • Dither Fade:切换瞬间对整块地形做噪声抖动淡入淡出(透明度在 0~1 间渐变),配合深度测试避免穿插。廉价通用,但画面有短暂颗粒。
  • 几何渐变(Morphing):过渡期内把粗块顶点沿垂直方向"抬升"到与细分块一致(顶点位移插值),消除几何跳变,需要预先记录相邻 LOD 的误差向量。
  • 高度混合(Height Blend):对地表贴图(草/石/沙)切换使用按高度/坡度混合,视觉上地块"长出来"而非"跳出来"。
// GLSL:LOD 过渡的几何渐变(垂直方向抬升到细分姿态)
// uMorphFactor 在过渡期内从 1 渐变到 0
float h_fine    = sampleHeightFine(uv);        // 细分 LOD 的顶点高度
float h_coarse  = sampleHeightCoarse(uv);      // 当前粗 LOD 的顶点高度
float h = mix(h_fine, h_coarse, uMorphFactor); // 粗块顶点随过渡抬升

Dither Fade 与 Morphing 常组合使用:Morphing 处理连续的小 LOD 差,Dither 处理较大的 LOD 跳变(如地形块首次加载)。


三、GPU 地形渲染:Compute 与曲面细分

一句话:地形网格的顶点由高度图采样程序化生成,GPU 只存低模包围盒 + 采样器,CPU 与 GPU 之间只传绘制命令——这就是 GPU-driven 地形。

3.1 三种生成路径

路径顶点来源优点缺点
CPU 每帧更新 VBCPU 采样高度图写缓冲简单、兼容老管线带宽大、CPU 负载高
TessellationHull/Domain Shader 细分自适应细节固定功能开销、边界处理复杂
Compute + 间接绘制Compute 生成顶点写入 VB全 GPU 驱动、可批量剔除需 meshlet/indirect 管线

3.2 Compute 生成地形块

每块地形预分配固定顶点上限,compute shader 按块 LOD 计算顶点数并生成顶点位置(采样高度图 + 法线 + UV),同时统计可见顶点,通过原子操作写入间接绘制参数(Indirect Draw Args),随后一次 vkCmdDrawIndexedIndirect 绘制所有可见块。

// GLSL(Compute):按 LOD 生成地形块顶点
layout(local_size_x = 64) in;
layout(std430, binding = 0) buffer TerrainVertices { vec4 v[]; };       // pos.xyz + lod
layout(std430, binding = 1) buffer IndirectArgs { uint count, instanceCount, firstIndex, baseVertex, baseInstance; };

layout(binding = 2) uniform sampler2D uHeightMap;
uniform float uTileSize;    // 块世界尺寸
uniform int  uGridSize;     // 该 LOD 每边顶点数

void main() {
    uint idx = gl_GlobalInvocationID.x;
    uint grid = uint(uGridSize);
    uint row = idx / grid;
    uint col = idx % grid;
    if (row >= grid || col >= grid) return;

    vec2 uv = (vec2(col, row) / float(grid - 1)) * uTileSize;
    float h = textureLod(uHeightMap, uv, 0).r * uHeightScale;
    v[idx] = vec4(uv.x, h, uv.y, 1.0);
    // 生成后 atomicAdd(count) 并让最终顶点数驱动 indirect draw
}

3.3 地表混合与细节

地形渲染还需多层材质混合(Texture Splatting):按权重图混合 草地/岩石/沙地 等多层贴图,配合同一张权重图的 R/G/B/A 通道。现代方案用虚拟纹理(每层纹理切成页,GPU 采样时合并)避免采样上限。

// GLSL:四层 splat 混合
vec4 finalColor = vec4(0.0);
for (int i = 0; i < 4; i++) {
    float weight = texture(uSplatMap, uv)[i];
    vec3 layerColor = texture(uLayers[i], uv * uLayerScale).rgb;
    finalColor.rgb += layerColor * weight;
}

四、植被实例化与 GPU Culling

一句话:几十万棵树靠"一次绘制 + 每实例变换 + GPU 裁剪"完成——CPU 只提交顶点数据一次,之后全靠 GPU 决定画什么。

4.1 实例化渲染(Instancing)

植被(树、草、岩石)由相对少量的几何模型反复摆放。传统做法每棵树一次 Draw Call(CPU 忙死);实例化则把所有树的变换矩阵放进一个 Instanced Buffer,一次 draw(vertexCount, instanceCount) 批量绘制。现代 GPU-driven 方案更进一步:CPU 完全不遍历可见性,只提交全部实例,GPU compute 按视锥/遮挡/距离裁剪后,用间接绘制让剩余实例生效。

// C++/Vulkan:实例化绘制
cmd->bindVertexBuffers(0, 1, &vertexBuffer, &offsets);
cmd->bindVertexBuffers(1, 1, &instanceBuffer, &offsets);   // 每实例变换矩阵
cmd->bindIndexBuffer(indexBuffer, 0, VK_INDEX_TYPE_UINT32);
cmd->drawIndexed(indexCount, instanceCount, 0, 0, 0);      // 一次绘制 N 个实例

4.2 GPU Culling 流程

GPU-driven 实例剔除(如 UE5 Nanite/Foliage 的雏形)分四步:

  1. Instance 数据上传:所有树的世界矩阵 + 包围球,写入 SSBO。
  2. Culling Compute:每个线程处理一个实例,做视锥剔除(包围球 vs 6 平面)、距离 LOD 选择、可选的遮挡剔除;通过的实例把索引写进紧凑的"可见列表"。
  3. 前缀和(Scan):统计每批可见数量,生成间接绘制参数。
  4. 间接绘制:vkCmdDrawIndexedIndirect 按可见列表绘制。
// GLSL(Compute):视锥剔除
layout(std430, binding = 0) buffer Instances { InstanceData instances[]; };
layout(std430, binding = 1) buffer Visible   { uint visibleIndices[]; };
layout(std430, binding = 2) buffer DrawArgs  { uint count; };

uniform vec4 uFrustumPlanes[6];

bool sphereInFrustum(vec3 center, float radius) {
    for (int i = 0; i < 6; i++) {
        if (dot(uFrustumPlanes[i].xyz, center) + uFrustumPlanes[i].w < -radius)
            return false;
    }
    return true;
}

void main() {
    uint idx = gl_GlobalInvocationID.x;
    InstanceData inst = instances[idx];
    if (sphereInFrustum(inst.center, inst.radius)) {
        uint slot = atomicAdd(count, 1u);
        visibleIndices[slot] = idx;
    }
}

4.3 草地的特殊处理

草地数量是植被的 100 倍量级(数百万株),不能逐株绘制。方案:

  • Cluster / 分块:把草按 64×64 m 网格分块,每块一个实例批次,块内草的偏移随机化,GPU culling 到块粒度。
  • GPU 粒子式草地:compute 生成草叶顶点的噪声位移,用间接绘制一次画大片。
  • 层叠替代:远处用草地贴片(Billboard)+ 中景实例 + 近景高模。

五、遮挡剔除(Occlusion Culling)

一句话:视锥剔除只挡掉屏幕外的物体,屏幕内被墙挡住的物体要靠遮挡剔除——开放世界的几何减负第一功臣。

视锥剔除对大规模场景不够:城市里一堵墙后面可能有数万三角形看不见却仍被绘制。遮挡剔除利用"某物体被更近的大物体挡住"这一事实:

方案原理优点缺点
软件遮挡查询(HZB)每帧渲染一个极低分辨率的深度图(Hierarchical Z-Buffer),逐层级测试包围盒精确、CPU/GPU 均可需一帧延迟(或两阶段查询)
硬件遮挡查询(Occlusion Query)GPU 记录实际被绘制的像素数简单有延迟、结果回读慢
Portal / PVS预计算视点可达区域零运行时开销只适用室内

现代 GPU-driven 引擎的标准是 HZB(Hierarchical Z-Buffer):每帧先渲染不透明场景的低分辨率深度(Hierarchical Z),随后 compute 剔除阶段用这个 HZB 做包围盒遮挡测试,被完全遮挡的实例直接从可见列表剔除。剔除发生在绘制之前,无延迟。

// C++:HZB 深度层级构建
// 1. 正常渲染主深度缓冲(全分辨率)
// 2. compute 逐级降采样:每 2x2 取最远深度(保守遮挡),构建 mip 链
// 3. 剔除阶段:从最粗 mip 逐级下钻测试包围盒,被完全覆盖则剔除

注意事项:

  • HZB 需处理保守性:宁可多画不可漏画(漏画=黑块),所以降采样取 max 深度。
  • 动态物体(移动的门)与半透明不能依赖静态遮挡。
  • 遮挡剔除与 LOD 选择应合并:被遮挡的远处树连 LOD 都不用评估。

六、流式加载与内存管理

一句话:大世界装不下全部资产,按"玩家位置 × 可见性"分块加载——网格、纹理、碰撞体随玩家移动异步进出内存。

6.1 分块与 Level Streaming

把世界切成 Cell / Chunk(如 128×128 m 或按四叉树节点),记录每块的资产清单(网格、纹理、碰撞、实体)。玩家移动时,以玩家所在块为中心,加载半径内的块,卸载远块:

加载半径:距离 < 256 m  → 完整加载(高 LOD 网格 + 全纹理)
          距离 < 512 m  → 中 LOD + 低纹理
          距离 > 1024 m → 卸载(或仅保留碰撞/AI 数据)
// C++:流式加载队列
void updateStreaming(const glm::vec3& playerPos) {
    std::vector<ChunkID> toLoad, toUnload;
    for (auto& [id, chunk] : chunks) {
        float dist = glm::length(chunk.center - playerPos);
        if (dist < kLoadRadius && !chunk.loaded) toLoad.push_back(id);
        if (dist > kUnloadRadius && chunk.loaded) toUnload.push_back(id);
    }
    // 后台线程加载 toLoad(IO),主线程卸载 toUnload(释放 GPU 资源)
}

6.2 异步加载的关键纪律

  • 绝不阻塞渲染线程:网格/纹理加载走后台线程 + 队列,上传用暂存缓冲在后台队列完成。
  • 先占位后替换:加载完成前用低 LOD 占位(Impostor 或简单盒),避免"弹出"(Pop-in)。
  • 卸载是双向:GPU 资源卸载 + CPU 数据释放 + 碰撞/AI 实体清理,避免内存泄漏累积。
  • 内存预算表:为网格、纹理、地形各建预算,流送算法在预算内做 LRU 决策。

6.3 资源驻留策略

资源类型驻留策略说明
地形高度图/权重图常驻(小)采样廉价,可留全量
地表纹理虚拟纹理分页按需加载页
植被网格常驻(少量模型)模型复用,变换在实例缓冲
建筑/道具网格分块流送随块进出
音频/碰撞独立流送层与渲染解耦

七、性能与质量权衡

一句话:大世界的性能瓶颈顺序通常是 CPU Draw Call → GPU 几何量 → 显存带宽 → IO,每层都有对应的工程武器。

瓶颈层症状武器
CPU Draw Call帧率随物体数骤降实例化、GPU-driven 间接绘制、合并
GPU 几何三角形数过高LOD、四叉树裁剪、曲面细分
显存/带宽贴图模糊、闪烁纹理压缩(ASTC/BC7)、虚拟纹理流送
IO 吞吐卡顿、加载慢预加载、异步 IO、DDS/KTX 压缩读取

优化顺序建议:先杀 Draw Call(实例化 + 间接绘制),再压几何(LOD + 裁剪),再管纹理带宽,最后优化 IO。每一步都先用 profiler(Nsight / RenderDoc / Adreno)确认瓶颈,避免过度优化非瓶颈层。

工程参数参考(中端桌面)

参数推荐值
地形块尺寸64~128 m
屏幕空间误差阈值1~2 px
植被实例上限500K~2M
草块尺寸32~64 m
流送加载半径512~1024 m
HZB 分辨率主深度 1/4

八、常见问题(FAQ)

Q1:地形 LOD 切换时裂缝/T 型接缝反复出现?

A1:优先检查相邻块是否渲染相同分辨率的共享边缘顶点:若粗块边界顶点与细块不一致,用裙边(Skirt)或边界缝合(把细块多出的顶点折叠到粗块边)解决。注意裙边在下坡地形会露出,缝合更稳妥;同时确认屏幕空间误差阈值统一,避免相邻块 LOD 级差过大(超过 2 级必然出缝)。

Q2:植被实例化后近处树变少/远处树消失?

A2:先查 GPU 剔除逻辑:视锥剔除是否误用了缩放后的包围球、距离 LOD 的切换半径是否与包围球半径匹配(LOD 切换瞬间包围球突变会导致闪烁消失)。其次检查间接绘制的计数原子是否为 0 误写,建议用 RenderDoc 验证剔除前后可见列表长度与包围球绘制(Debug 绘制包围球)。

Q3:流式加载出现卡顿/掉帧,如何定位?

A3:把加载工作拆成"IO 读取(后台线程)→ 解压/上传(异步队列)→ GPU 资源创建"三段,用 profiler 分别计时。卡顿通常来自主线程同步等待(同步创建 VkImage/VkBuffer)——务必全部走异步队列 + fence 回读。再确认卸载是否真的释放(GPU 内存曲线是否回落),并检查是否每帧都在创建/销毁资源(资源池化)。

Q4:HZB 遮挡剔除偶尔漏画(黑块闪烁)?

A4:HZB 必须保守:降采样时取 2×2 的最远深度(max),包围盒测试时按"最坏情况"判定(宁可多画不可漏画)。漏画还常见于:包围盒未做世界矩阵变换、动态物体(门/移动平台)写入了 HZB 但未纳入剔除、或 HZB 分辨率过低导致细长物体误判被遮。


总结

大世界渲染不是某一个技巧,而是**“表示 → 分层 → 剔除 → 批处理 → 流送”**的系统工程:

层次核心技术解决的规模问题
地形表示Heightfield / 体素地形几何从哪来
LOD 管理四叉树 + 屏幕空间误差几何量与距离解耦
GPU 驱动Compute 生成 + 间接绘制CPU 不再逐物体决策
植被绘制实例化 + GPU Culling数十万实例一次绘制
遮挡剔除HZB 层级深度测试屏幕内被挡的几何
流式加载分块 + 异步 IO + 预算内存放不下的问题

实践建议:先在 1 km² 高度场 + 四叉树 LOD 上跑通"地形从哪来、怎么选 LOD";再接入实例化把 1 万棵树变成一次 draw;随后加 compute culling 与 HZB;最后设计分块流送与内存预算。每步用 profiler 验证"瓶颈是否转移",大世界优化是持续迭代的工程而非一次性技巧。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机图形学」更多文章

  1. 光线追踪与混合渲染:BVH、路径追踪与 RTX
  2. 骨骼动画与蒙皮:骨骼层级、动画混合与 GPU 蒙皮
  3. 后处理特效管线:HDR、Bloom、色调映射与抗锯齿