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,而是能根据中间数据判断瓶颈在哪里。
1. density function:先定义实体,再提取表面
↡把世界坐标映射到一个带符号数的函数;正值表示实体内部,负值表示空气,零值构成待提取的表面。把每个世界坐标带入 density function:结果为正,就把这一点想成岩石;结果为负,就把它想成空气;结果等于零的地方才是地形表面。这个约定很实用,因为“洞穴”不需要额外的洞穴网格,只要让洞穴内部的密度变成负值即可。
一个 block 可以划成 32×32×32 个小立方体。每个小立方体需要读取八个角点的密度符号,因此沿一个方向要取 33 个采样点,整个密度 volume 是 33×33×33。这是“cell 数量”和“corner 数量”最容易混淆的地方。
Marching cubes 只需先看八个角点的正负:全空或全实心时不输出三角形,其他情况用 case index 查表。表面顶点落在发生符号变化的十二条边上,并按密度比例插值,所以表面不会被限制在 voxel 的中心。
2. block pipeline:让 GPU 只处理值得看的体块
地形不是一次性生成一张无穷大的网格,而是由许多 cubic block 拼起来。相机移动时,系统把 block 按 front-to-back 排序,先寻找可见且接近镜头的 block;远处、被遮挡或没有表面的 block 可以被跳过,已有的 vertex buffer 也可以回收到 pool。
↡把 geometry shader 产生的顶点直接写入后续可绑定的 vertex buffer,而不是先回到 CPU。一个实用的顺序是:density pass 把世界坐标写入三维纹理;后续 pass 读取体素并分类;生成阶段把三角形顶点 stream output 到 buffer;最后只绘制仍在视野内的 block。空 block 的 stream-out query 可以告诉队列“这次没有几何体”,从而避免给它分配或保留过多资源。
密度 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.6、144 blocks/s 的量级差异;这里把它当作架构比较的证据,而不是今天硬件的承诺。
Method 3 再把顶点和索引分开:列出非空 cell,列出需要的 vertex,生成并 splat vertex id,最后生成 index。相邻 cell 共享边界顶点,减少了重复写入;同一测试设置下,报告约 260 blocks/s 的量级。代价是更多 pass、额外的中间 buffer 和更复杂的同步关系。
第 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 采样和镜头位置。
调整密度函数:先预测,再观察
当前可检查信号
先列非空 cell,缩窄后续输出;marker 数量仍随频率和 warp 上升。
把 frequency 加倍会增加细节与 case 变化;把 warp 加大才更可能打开洞穴。
在 Lab 中先把 warp 调到低值,观察连续山坡;再逐步提高它,预测什么时候会出现负密度的通道。切换 Method 3 后,marker/vertex records 可能下降,但 pass 数会上升;这正是“中间数据变窄”而不是“魔法加速”。
5. texturing 与 LOD:几何连续后,外观也要稳定
↡从 X、Y、Z 三个方向投影材质,并按表面法线权重混合,避免单一平面投影在洞壁和陡坡上拉伸。单一的 top-down UV 在洞穴墙壁上容易拉伸,triplanar texturing 则按法线混合三个方向的投影。地面材质可以再由高度、条带和程序噪声决定,但应把“材质分类”和“几何是否生成”分开:颜色变化不应偷偷改变 zero isosurface。
↡根据屏幕占用或与相机的距离,为同一 terrain block 选择不同网格细节级别的策略。LOD 让远处 block 使用更粗的网格,近处 block 保留更多采样。相邻级别重叠时需要处理 z-fighting 和接缝;可以给较粗级别一个小的负密度偏置,或在边界显式约束采样。碰撞查询也可复用 density function:先判断符号区间,再在边界附近插值或二分定位;梯度可由六个邻域采样近似。
三步验收:从密度定义到可回收地形
第一步:把密度和 case 说清楚
先区分 32³ 个 voxel cell 与 33³ 个 corner samples,再用八个符号得到 case index。只要能解释 zero isosurface 为什么是表面,就不会把高度图或材质图误当成几何生成。
本章小结
- 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