GPU Gems 3 · Chapter 1. Generating Complex Procedural Terrains Using the GPU

用密度场、Marching Cubes 和 GPU 流式输出,把程序化地形从高度图扩展到洞穴、拱门与可回收的三维 terrain blocks。

学习目标

  • 能解释 density function 的正负如何把 3D 空间变成 marching cubes 的边界,并说明为什么 32³ 个 cell 需要 33³ 个角点采样
  • 能修改 Procedural Terrain Lab 的生成方法、noise frequency、amplitude 与 warp,预测洞穴、细节和 block 工作量的变化
  • 能回答:为什么从 Method 1 增加到 Method 3 的 pass 反而可能更快,以及什么时候应该选择 stream output、空 block 查询和共享 vertex/index pool

先别把山看成一张高度图

如果只给每个地面坐标一个高度,地形就像一张只能向上起伏的床单:它可以有山谷,却很难有悬空拱门或穿山洞穴。本章把问题换成“空间里的每一点是实体还是空气”,于是表面可以在三维空间自由弯折。

你会看到一条完整的 GPU 管线:先计算密度,再用 marching cubes 把体素边界变成三角形,最后把可见的 cubic block 放进可回收的缓冲区。重点不是记住某张 lookup table,而是能根据中间数据判断瓶颈在哪里。

从“地图高度”升级为“空间里的实体”density functionworld position → densityvoxel volume3D texture · 33³ cornersMarching Cubescase table → trianglesterrain blockspool · cull · draw无限世界 = 许多可回收的 cubic blocks远处或空的 block 可以驱逐;镜头附近的 block 才需要继续生成正密度是实体,负密度是空气,零等值面就是地形表面

1. density function:先定义实体,再提取表面

把每个世界坐标带入 density function:结果为正,就把这一点想成岩石;结果为负,就把它想成空气;结果等于零的地方才是地形表面。这个约定很实用,因为“洞穴”不需要额外的洞穴网格,只要让洞穴内部的密度变成负值即可。

一个 block 可以划成 32×32×32 个小立方体。每个小立方体需要读取八个角点的密度符号,因此沿一个方向要取 33 个采样点,整个密度 volume 是 33×33×33。这是“cell 数量”和“corner 数量”最容易混淆的地方。

八个角点的正负,决定一个 cell 的三角形one voxel cell+++++ = solid · − = emptycase index1010 0101₂→ case 165 → lookup table符号变化的边上插值,得到 0–5 个三角形

Marching cubes 只需先看八个角点的正负:全空或全实心时不输出三角形,其他情况用 case index 查表。表面顶点落在发生符号变化的十二条边上,并按密度比例插值,所以表面不会被限制在 voxel 的中心。

2. block pipeline:让 GPU 只处理值得看的体块

地形不是一次性生成一张无穷大的网格,而是由许多 cubic block 拼起来。相机移动时,系统把 block 按 front-to-back 排序,先寻找可见且接近镜头的 block;远处、被遮挡或没有表面的 block 可以被跳过,已有的 vertex buffer 也可以回收到 pool。

一个实用的顺序是:density pass 把世界坐标写入三维纹理;后续 pass 读取体素并分类;生成阶段把三角形顶点 stream output 到 buffer;最后只绘制仍在视野内的 block。空 block 的 stream-out query 可以告诉队列“这次没有几何体”,从而避免给它分配或保留过多资源。

block 生命周期:只让值得画的体块占用缓冲区可见性队列front-to-back sort密度 passpixel shader → 3D texturestream outputquery empty blocksvertex pooldraw near blocks空 / 远 blockevict or skip近处、有表面的 block保留 buffer,按深度先后绘制

密度 pass 也不必为每个 z slice 启动一次独立 draw。可以把 33 个二维 slice 当成 instanced draw 的实例,由 pixel shader 根据 instance id 写入 3D render-target array。这样,世界坐标、噪声采样与 block 偏移在 GPU 端保持一致,CPU 只需管理 block 生命周期。

3. 三种生成方法:为什么多 pass 可能更快

Method 1 让 geometry shader 遍历 voxel,直接为每个 cell 输出最多 15 个顶点。它的控制流简单,但输出上限很宽:绝大多数空 cell 不产出任何东西,少数复杂 case 却会让 geometry stage 承担大量重复的边顶点。

Method 2 把工作拆开:先输出非空 cell 的 marker,再只为 marker 生成顶点。这样 geometry shader 处理的记录更窄,stream-out query 还能快速跳过完全没有 marker 的 block。官方示例在当时的 GeForce 8800 测试设置中报告约 6.6144 blocks/s 的量级差异;这里把它当作架构比较的证据,而不是今天硬件的承诺。

Method 3 再把顶点和索引分开:列出非空 cell,列出需要的 vertex,生成并 splat vertex id,最后生成 index。相邻 cell 共享边界顶点,减少了重复写入;同一测试设置下,报告约 260 blocks/s 的量级。代价是更多 pass、额外的中间 buffer 和更复杂的同步关系。

把“输出太宽”拆成更窄的中间列表Method 1每个 voxel 直接输出 0–15 个顶点约 6.6 blocks/sMethod 2先列出非空 cell,再生成顶点约 144 blocks/sMethod 3共享 vertex + index,少重复约 260 blocks/s示意:输出列表越窄,后续 geometry work 越可控
三种方法共享同一条“密度 → 表面”主线1density33³ samples2classifycase 0–2553emitmarker / vertex4reuseindex pool额外的 pass 可以把 geometry shader 的瓶颈搬到更窄的输出上Method 1 直接发完整顶点;Method 2 先发非空 marker;Method 3 再共享 vertex / index播放或单步观察:不是 pass 越少越快,而是每个 pass 是否减少了后续工作

第 1 / 4 步 · 写入 33³ 的密度 volume

每一步都对应一个可检查的中间数据,而不是黑盒“生成完成”。

下面的伪代码刻意只保留“先分类,再输出”的边界:它把 GPU 阶段的职责写清楚,具体 case table 仍应从数据文件或常量 buffer 读取。

// 伪代码:每个 cell 只在有符号变化时产生 marker
uint caseIndex = 0u;
for (uint corner = 0u; corner < 8u; ++corner) {
  if (density[corner] > 0.0) {
    caseIndex |= 1u << corner;
  }
}
 
if (caseIndex != 0u && caseIndex != 255u) {
  streamOut(markerBuffer, cellId, caseIndex);
}

4. density 设计:频率定细节,warp 改变拓扑

地形的“大山脉”可以来自低频噪声,多组更高频的 octaves 负责岩石细节。把坐标映射到 noise volume 时,频率越高,单位空间内的符号变化越密;amplitude 越高,密度越容易跨过零值,局部表面会更丰富。

洞穴和拱门不是把一条 2D 高度曲线抬高或压低,而是让密度场发生空间 warp:低频的扭曲把本来平滑的等值面弯成隧道,高频项再为墙面补纹理。要注意,warp 加强的是拓扑可能性;最后是否出现可见洞穴仍取决于零等值面、block 采样和镜头位置。

低频定形,高频加纹理,warp 打开洞穴world-space density slicelow frequencyoctaveswarpzero isosurfacecaves / arches / overhangs不是给高度加噪声,而是改写三维边界

调整密度函数:先预测,再观察

当前可检查信号

density / generation passes3
marker / vertex records100
active block ratio54%
cave tendency36%
vertex reuse18%

先列非空 cell,缩窄后续输出;marker 数量仍随频率和 warp 上升。

把 frequency 加倍会增加细节与 case 变化;把 warp 加大才更可能打开洞穴。

在 Lab 中先把 warp 调到低值,观察连续山坡;再逐步提高它,预测什么时候会出现负密度的通道。切换 Method 3 后,marker/vertex records 可能下降,但 pass 数会上升;这正是“中间数据变窄”而不是“魔法加速”。

5. texturing 与 LOD:几何连续后,外观也要稳定

单一的 top-down UV 在洞穴墙壁上容易拉伸,triplanar texturing 则按法线混合三个方向的投影。地面材质可以再由高度、条带和程序噪声决定,但应把“材质分类”和“几何是否生成”分开:颜色变化不应偷偷改变 zero isosurface。

外观和细节预算也要跟着几何边界走triplanar texturing按法线权重混合 X / Y / Z 三个投影level of detailnear · finefar · coarse避免远处 block 付近处的几何成本LOD 过渡仍需处理重叠和 z-fighting

LOD 让远处 block 使用更粗的网格,近处 block 保留更多采样。相邻级别重叠时需要处理 z-fighting 和接缝;可以给较粗级别一个小的负密度偏置,或在边界显式约束采样。碰撞查询也可复用 density function:先判断符号区间,再在边界附近插值或二分定位;梯度可由六个邻域采样近似。

三步验收:从密度定义到可回收地形

分步1 / 3

第一步:把密度和 case 说清楚

先区分 32³ 个 voxel cell 与 33³ 个 corner samples,再用八个符号得到 case index。只要能解释 zero isosurface 为什么是表面,就不会把高度图或材质图误当成几何生成。

八个角点的正负,决定一个 cell 的三角形one voxel cell+++++ = solid · − = emptycase index1010 0101₂→ case 165 → lookup table符号变化的边上插值,得到 0–5 个三角形
从“地图高度”升级为“空间里的实体”density functionworld position → densityvoxel volume3D texture · 33³ cornersMarching Cubescase table → trianglesterrain blockspool · cull · draw无限世界 = 许多可回收的 cubic blocks远处或空的 block 可以驱逐;镜头附近的 block 才需要继续生成正密度是实体,负密度是空气,零等值面就是地形表面

本章小结

  • density function 把“实体/空气”变成可采样的三维标量场,零值等值面就是表面。
  • marching cubes 用八角点的 case index 查表,并在符号变化的边上插值;32³ 个 cell 需要 33³ 个 corner samples。
  • Method 1、2、3 逐步缩窄输出并共享顶点;更多 pass 可能换来更少的 geometry 与带宽工作。
  • noise frequency/amplitude 控制细节和穿零概率,warp 让洞穴、拱门与 overhang 成为可能。
  • stream output、空 block 查询、triplanar texturing 和 level of detail 共同构成可交互的 block 生命周期。

练习

问题 1|检查采样边界。 一个 terrain block 有 32×32×32 个 voxel cell。为什么密度 pass 不能只写 32×32×32 个样本?如果只想让一个 cell 判断表面,需要读取哪些数据?

问题 2|修改 Demo 代码。 在 Procedural Terrain Lab 中新增一个 warp 上限保护:当 warp 高于阈值时显示“可能出现洞穴”,但不要直接保证一定生成洞穴。应修改哪一个派生量,才能让提示与 density 的解释一致?

问题 3|做生成方法选型。 一个场景里大多数 block 是空的,少数近处 block 有复杂洞穴。你会从 Method 1、2、3 中如何选?哪些信号应放进 profiling 表?

名词解释

本章出现的专业名词,用大白话再讲一遍。

density function
voxel
marching cubes
case index
stream output
triplanar texturing
level of detail

讨论

评论区加载中…