Chapter 16: GPU Parallel Procedural Generation
GPU Parallel Procedural Generation 讨论的是把程序化规则直接放到 GPU 上执行,并让生成结果停留在 GPU buffer 中,随后进入绘制、剔除、LOD、调试和性能分析路径。读完本章后,读者应能把一个程序化地形、草地、城市构件或粒子化网格生成器拆成输入参数、dispatch 规模、线程映射、输出 buffer、同步边界、间接绘制参数和可视化证据。
本章使用一个贯穿材料:一套草地 chunk 生成器。CPU 只提交每个 chunk 的世界坐标、密度、随机种子、风场参数和最大容量;GPU 在 compute shader 中为每个候选点生成草叶顶点、索引、包围信息和间接绘制参数。这个例子足够小,可以在一章内讲清楚;它也足够接近真实工程,因为变量输出数量、streaming、chunk 生命周期、atomic 计数、间接绘制和调试可视化都会出现。
GPU 并行生成的核心判断是:生成规则是否能被拆成大量相互独立或弱依赖的小任务,输出是否能写入 GPU 端可管理的数据结构,后续渲染是否能直接消费这些结果。只要其中任意一环没有边界,程序化生成就会从“并行构造几何”退化成“CPU 发起大量零散更新”。本章的目标是建立一条稳定检查顺序:先确定任务粒度,再确定输出布局,再确定同步和绘制接入点,最后用工具证据定位成本。
16.1 并行生成几何数据的理论基础
并行生成几何数据的第一步是把“生成一个场景”的大任务改写成一组可并行的小任务。在草地 chunk 生成器中,场景可以拆成 chunk,chunk 可以拆成候选采样点,候选点可以根据密度、坡度、遮挡、随机种子决定是否生成草叶。GPU 适合执行这种结构,因为每个 work item 可以处理一个候选点或一小组候选点,thread group 负责一段连续区域,输出写入共享的 GPU buffer。
这里需要先固定三个对象。work item 是 compute shader 的最小逻辑执行单位,它通常处理一个候选元素,例如一个草叶候选点。thread group 是一批 work item 的组织单元,它让同组线程共享局部内存、同步点和相邻数据。GPU buffer 是生成结果的承载位置,它可能保存顶点、索引、可见实例、统计信息或间接绘制参数。三者的关系决定了生成算法是否能被 GPU 稳定执行。
草地 chunk 的最小映射可以写成:一个 thread group 对应一个 chunk 内的一段候选点;一个 work item 读取一个候选点的种子和局部坐标;如果规则通过,它写出一片草叶需要的顶点和索引。固定数量输出最简单,例如每个候选点都生成 4 个顶点和 6 个索引。真实场景常见的是变量输出,例如坡度过大时生成 0 片草,湿度高时生成 2 到 3 片草,这会引入 append buffer、prefix sum 或 compaction。
下面的图只描述一帧内 GPU 生成草地 chunk 的主路径。它的边界是几何生成和绘制接入,不展开材质光照和后处理。
这条路径的关键点是数据停留在 GPU 侧。CPU 提供的是少量参数,生成规则在 GPU 上展开,绘制命令读取 GPU 已经写好的 buffer。这样设计的收益来自两处:CPU 提交量跟 chunk 数量和参数规模相关,GPU 工作量跟实际候选点和生成数量相关。调试时也要按这个分界追踪,因为 CPU 看到的是参数和 dispatch,GPU capture 看到的是 buffer 写入、counter 增长和 draw indirect 参数。
下面的简化 compute shader 展示一个候选点到顶点 buffer 的固定数量写入。这个示例先展示最直接的数据映射,后续小节再处理变量数量输出。
struct ChunkParams {
float3 origin;
float spacing;
uint seed;
uint candidateCount;
};
struct GrassVertex {
float3 position;
float2 uv;
uint chunkId;
};
StructuredBuffer<ChunkParams> Chunks : register(t0);
RWStructuredBuffer<GrassVertex> OutVertices : register(u0);
[numthreads(64, 1, 1)]
void GenerateGrass(uint3 globalId : SV_DispatchThreadID)
{
uint candidateId = globalId.x;
uint chunkId = candidateId / 4096;
uint localId = candidateId % 4096;
ChunkParams chunk = Chunks[chunkId];
if (localId >= chunk.candidateCount) {
return;
}
float2 grid = float2(localId % 64, localId / 64);
float3 basePosition = chunk.origin + float3(grid.x, 0.0, grid.y) * chunk.spacing;
uint vertexBase = candidateId * 4;
GrassVertex v0;
v0.position = basePosition;
v0.uv = float2(0.0, 0.0);
v0.chunkId = chunkId;
GrassVertex v1;
v1.position = basePosition + float3(0.0, 0.8, 0.0);
v1.uv = float2(0.0, 1.0);
v1.chunkId = chunkId;
GrassVertex v2;
v2.position = basePosition + float3(0.1, 0.0, 0.0);
v2.uv = float2(1.0, 0.0);
v2.chunkId = chunkId;
GrassVertex v3;
v3.position = basePosition + float3(0.1, 0.8, 0.0);
v3.uv = float2(1.0, 1.0);
v3.chunkId = chunkId;
OutVertices[vertexBase + 0] = v0;
OutVertices[vertexBase + 1] = v1;
OutVertices[vertexBase + 2] = v2;
OutVertices[vertexBase + 3] = v3;
}
这个示例里的输出地址由 candidateId * 4 直接计算,所以所有线程都能独立写入,不需要争抢同一个写入位置。它适合生成数量固定、每个候选点输出大小相同的几何。它的边界也很清楚:大量候选点被地形规则剔除时,buffer 会出现空洞;不同候选点输出数量变化时,固定地址会浪费空间;需要按可见元素连续绘制时,还要引入压缩或间接参数生成。
把任务映射到 GPU 时,dispatch 规模必须来自真实候选数量。假设每个 chunk 有 4096 个候选点,当前帧有 128 个活跃 chunk,group size 为 64,那么 dispatch 的 work item 总数为 128 * 4096,group 数为 ceil(128 * 4096 / 64)。这个计算看似简单,但它决定了 shader 中的边界判断、buffer 容量、计时器读数和 capture 中看到的线程规模。生成错误经常来自这条链条断裂:dispatch 发得太大,shader 边界判断缺失,输出 buffer 容量又按理想数量估计,最终表现为越界写入、草叶闪烁或间接绘制数量异常。
因此,并行生成的理论基础可以收束成一个工程判断:一个生成任务能放到 GPU 上,前提是它能被拆成稳定粒度的 work item,输出地址或输出分配策略明确,生成结果能被后续渲染阶段按资源语义消费。草地 chunk 的例子中,候选点是粒度,chunk 是空间组织,vertex/index/counter buffer 是结果承载,draw indirect 是消费入口。
16.2 高效生成算法与数据结构
当每个候选点的输出数量发生变化时,固定地址写入会迅速失去效率。草地生成器中的密度贴图、地形坡度、风场、相机距离和遮挡都会让某些候选点输出 0 片草,让某些候选点输出多片草。高效生成算法要解决两个问题:如何为变量输出分配连续空间,如何把生成出的有效元素交给后续绘制路径。
最常用的数据结构是 append buffer。append buffer 提供一个隐藏或显式的计数器,线程通过原子递增获取写入位置。草地生成器中,每个通过规则检查的候选点都可以 append 一个草叶描述,后续 shader 再把草叶描述展开成顶点,或者直接 append 完整顶点。append 的优点是表达直接,输出天然连续;成本来自 atomic 竞争、容量上限和计数器管理。密度集中在少数 chunk 时,多个线程会频繁更新同一个计数器,计数器所在 cache line 会成为热点。
consume buffer 适合表达 GPU 端任务队列。一个 shader 可以 append 需要细分的 tile,另一个 shader 再 consume 这些 tile 继续生成。草地例子里,consume 的收益有限;在城市程序化生成中,它可以让“道路生成后产生地块任务,地块任务再产生建筑任务”这种链式规则停留在 GPU。它的边界是任务顺序和调试复杂度,队列中的元素数量、写入阶段和读取阶段必须通过 marker、counter 和 validation buffer 记录下来。
prefix sum 用于为每个元素计算连续输出偏移。流程通常分成三段:先为每个候选点计算输出数量,再对数量数组做 prefix sum,最后让每个候选点根据自己的 offset 写入最终 buffer。它比 append 更适合高吞吐稳定生成,因为写入地址由扫描结果确定,后续 compaction 也更清楚。成本是多次 dispatch、临时 buffer 和跨 dispatch 同步。草地生成器中,如果每个候选点可能生成 0 到 3 片草,prefix sum 可以把所有有效草叶紧凑排列,并同时得到总草叶数量。
compaction 是把有效元素搬到连续区域的过程。它常跟 visibility、LOD、culling 一起出现。草地 chunk 中,第一轮 shader 可以只判断哪些候选点可生成,写出 flag 和数量;第二轮 prefix sum 生成 offset;第三轮 shader 把有效草叶写入紧凑 buffer。这样做以后,后续 vertex shader 或 draw indirect 读取的是连续有效数据,减少无效顶点处理,也让 capture 中的 buffer 内容更容易检查。
indirect draw 把绘制参数放入 GPU buffer。生成 shader 写出顶点数量、实例数量、起始位置等参数,图形队列读取这些参数发起绘制。不同图形 API 的参数布局和资源状态名称不同,但工程语义一致:GPU 生成几何数量,GPU 生成绘制参数,CPU 只提交一个或少量间接绘制命令。草地 chunk 生成器中,counter buffer 的草叶数量可以转换成顶点数和索引数,写入 indirect args buffer;渲染阶段根据这个 buffer 绘制当前帧有效草地。
spatial partition 给变量生成提供空间边界。没有空间划分时,append buffer 的容量只能按整场景最坏情况估计,调试时也很难定位哪个区域生成异常。按 chunk、grid、cluster 或 hash cell 划分以后,每个空间单元可以拥有自己的计数、容量、包围盒和调试颜色。草地例子中,每个 chunk 可以记录候选数量、有效草叶数量、最大容量、溢出次数和 LOD 层级。热力图显示这些数据后,生成密度异常会直接落到空间位置。
下表把本章涉及的数据结构放到同一组判断维度中,便于迁移到其他程序化几何任务。
| 数据结构 | 解决的问题 | 主要成本 | 适用判断 |
|---|---|---|---|
| append buffer | 变量数量输出的连续写入 | atomic 竞争、容量上限、counter 重置 | 生成规则简单,输出数量变化明显,调试阶段需要快速验证 |
| consume buffer | GPU 端任务队列 | 队列顺序、阶段同步、工具观察难度 | 生成规则有多级展开,下一阶段任务来自上一阶段结果 |
| prefix sum | 为变量输出计算稳定 offset | 多次 dispatch、临时 buffer、barrier | 输出规模大,写入需要连续,后续绘制和压缩需要稳定索引 |
| compaction | 清理无效元素并形成连续结果 | 额外读写带宽 | 可见性、LOD、规则过滤导致候选大量失效 |
| indirect draw | GPU 生成绘制参数 | 参数布局、资源状态转换、调试边界 | CPU 不读取生成数量,绘制数量由 GPU 结果决定 |
| spatial partition | 限定生成范围和容量 | 分区元数据、chunk 管理 | 大场景、streaming、局部更新和可视化调试 |
这些结构经常组合使用。草地生成器的稳定版本通常是:按 chunk 组织候选点,第一轮生成每个候选点的数量,prefix sum 计算 offset,第二轮写入紧凑的草叶描述,第三轮写入 indirect draw args。append buffer 适合早期原型和低成本变体;prefix sum 加 compaction 适合规模变大后的稳定路径;spatial partition 贯穿整个流程,用来控制容量、streaming 和调试。
选择数据结构时,应先看输出数量是否固定,再看是否需要连续 buffer,再看 CPU 是否需要读取结果。固定输出走直接地址写入;变量输出先考虑 append 或 prefix sum;后续绘制如果能用 indirect draw,就把计数留在 GPU 侧;大场景必须把所有计数和容量绑定到空间单元。这个顺序比单独记住某个结构名称更可靠,因为它从输出形态和消费路径推出数据结构。
16.3 Compute Shader 驱动 Procedural Generation
Compute Shader 驱动的程序化生成,需要把输入参数、dispatch 规模、barrier、输出 buffer 和渲染接入点固定成一条可重复执行的 frame path。草地生成器的输入参数包括 chunk 列表、每个 chunk 的随机种子、密度贴图索引、LOD 参数、风场参数和最大容量。输出包括草叶描述 buffer、顶点或实例 buffer、每个 chunk 的统计 buffer、全局 counter buffer 和 indirect draw args buffer。
这里的 barrier 要分成两个层级理解。shader 内部的 group barrier 只约束同一个 thread group 内的共享内存和线程进度,例如一组线程共同计算局部 prefix sum。图形 API 层的 resource barrier 或 pipeline barrier 约束不同 dispatch、不同队列、compute 写入和 draw 读取之间的可见性,例如 compute shader 写完 vertex buffer 后,图形管线才能把它作为 vertex input 或 shader resource 读取。把这两个层级混在一起,会导致调试时误判:shader 内部同步正确,跨 pass 资源状态仍可能错误。
一帧内的稳定顺序可以写成下面这组步骤。列表中的每一步都对应一个可观察对象,便于在 capture 工具中核对。
- CPU 更新活跃 chunk 参数,只上传 seed、origin、LOD、密度范围和容量上限。
- GPU 清零 counter buffer、chunk statistics buffer 和 indirect args buffer。
- Compute shader 根据 chunk 参数生成候选数量、有效标记或草叶描述。
- 变量输出路径执行 prefix sum 和 compaction,形成连续输出 buffer。
- Compute shader 根据生成数量写入 indirect draw args。
- API 层插入资源状态转换,让 compute 写入结果对后续绘制可见。
- 图形队列执行 indirect draw,读取 vertex/index/instance buffer 和 draw args。
- 调试路径读取 validation buffer 或显示 heatmap,检查每个 chunk 的生成数量和溢出情况。
这个顺序强调的是资源流,而非某个 API 的具体函数名。Direct3D 12、Vulkan、Metal、OpenGL Compute 都有各自的资源绑定、同步和间接绘制接口;本章关心的稳定判断是同一件事:写入者是谁,读取者是谁,中间需要哪种可见性保证,draw args 的布局是否和当前 API profile 对齐。
下面的简化代码展示变量输出路径中的 append 写法。它把每个通过规则的候选点写成一个草叶实例描述,后续绘制阶段可以用 instance data 展开 billboard 或小片 mesh。
struct GrassInstance {
float3 basePosition;
float height;
float width;
uint chunkId;
uint seed;
};
StructuredBuffer<ChunkParams> Chunks : register(t0);
AppendStructuredBuffer<GrassInstance> OutInstances : register(u0);
RWStructuredBuffer<uint> ChunkCounts : register(u1);
bool PassDensityRule(float3 position, uint seed);
float Random01(uint seed, uint salt);
[numthreads(64, 1, 1)]
void GenerateGrassInstances(uint3 globalId : SV_DispatchThreadID)
{
uint candidateId = globalId.x;
uint chunkId = candidateId / 4096;
uint localId = candidateId % 4096;
ChunkParams chunk = Chunks[chunkId];
if (localId >= chunk.candidateCount) {
return;
}
float2 grid = float2(localId % 64, localId / 64);
float3 basePosition = chunk.origin + float3(grid.x, 0.0, grid.y) * chunk.spacing;
if (PassDensityRule(basePosition, chunk.seed + localId)) {
GrassInstance instance;
instance.basePosition = basePosition;
instance.height = lerp(0.4, 1.2, Random01(chunk.seed, localId));
instance.width = 0.08;
instance.chunkId = chunkId;
instance.seed = chunk.seed + localId;
OutInstances.Append(instance);
InterlockedAdd(ChunkCounts[chunkId], 1);
}
}
这段代码的关键路径有三处。OutInstances.Append 负责变量数量输出,ChunkCounts 负责每个 chunk 的统计和热力图,PassDensityRule 把程序化规则转成每个候选点的局部判断。它适合原型和中等规模场景。规模继续增大后,Append 和 InterlockedAdd 的原子竞争会成为观察重点,prefix sum 和 per-chunk 局部计数可以降低热点。
Compute shader 接入渲染管线时,还要决定生成的是顶点、索引、实例描述还是中间参数。生成完整顶点最直接,但写入带宽大,风场和相机相关变化会导致每帧重复生成。生成实例描述更紧凑,vertex shader 可以按实例展开草叶四边形,适合草地、碎石、树叶和小型装饰物。生成中间参数适合更复杂的几何,例如道路、建筑立面或带 LOD 的程序化 mesh,它让后续 compute pass 或 mesh processing pass 继续展开。
输出 buffer 的生命周期也要固定。每帧重建适合依赖相机、风场、时间和临时规则的结果;持久缓存适合静态地形、固定建筑、长期存在的 chunk。草地例子中,实例描述可以按 chunk 缓存,风摆动留给 vertex shader;密度贴图或地形数据变化时只重新生成受影响 chunk。这样可以把每帧生成成本从“整场景候选点”缩小到“活跃变化 chunk”。
Compute shader 驱动程序化生成的可复用检查顺序是:输入参数是否足够小且稳定,dispatch 规模是否由候选数量推出,输出 buffer 是否有容量和统计,跨 pass barrier 是否覆盖写入到读取,draw args 是否由 GPU 结果生成,调试 buffer 是否能说明每个 chunk 的数量。沿着这条顺序检查,可以把视觉错误定位到参数、线程映射、写入分配、同步或绘制消费中的某一段。
16.4 可扩展场景与多线程处理
当场景从几十个 chunk 扩展到几千个 chunk,生成系统的主问题会从“shader 能否生成几何”转成“哪些 chunk 在当前帧参与生成、哪些结果可以复用、CPU 和 GPU 如何并行推进”。可扩展程序化场景需要 chunk、streaming、async compute、多队列提交和 CPU 任务系统共同工作。它们的共同目标是让生成成本跟可见区域、变化区域和预算相关,而非跟整个世界规模线性绑定。
chunk 是大场景的基本空间单元。一个 chunk 应记录世界包围盒、随机种子、输入资源句柄、生成状态、容量预算、当前 LOD、GPU buffer offset 和调试统计。草地场景中,chunk 可以覆盖一个固定地面网格;城市场景中,chunk 可以覆盖一个街区;地形场景中,chunk 可以对应一个 quadtree tile。chunk 的存在让 CPU 能组织 streaming,让 GPU 能按空间批量生成,也让调试热力图有稳定坐标。
streaming 负责让 chunk 在靠近相机、离开视野、输入资源变化或 LOD 切换时进入不同状态。下面的状态图描述一块草地 chunk 的生命周期。它的边界是几何生成资源生命周期,不包含贴图下载和材质编译。
这张图的核心是状态转移必须有资源动作。CPUReady 表示 CPU 端元数据已经分配;UploadQueued 表示参数上传已经进入本帧任务;GPUQueued 表示生成 dispatch 已经提交;Resident 表示 GPU buffer 中有可绘制结果;Dirty 表示输入变化导致结果需要刷新;EvictQueued 表示该 chunk 的 buffer offset 可以回收。只记录一个布尔值“loaded”会掩盖跨帧同步和资源回收问题。
CPU 任务系统适合承担粗粒度决策。它可以根据相机、预算、地形版本号和编辑器操作筛选活跃 chunk,排序 streaming 优先级,准备参数 buffer,合并上传请求,提交 frame graph 节点。GPU 适合承担高吞吐生成、可见性过滤、数量统计、间接参数构造和局部压缩。两者的边界应围绕数据规模划分:CPU 处理少量元数据和调度决策,GPU 处理大量候选点和 buffer 写入。
async compute 可以把生成 pass 放到计算队列上,与部分图形工作重叠。它适合两类情况:生成 pass 消耗较多计算资源但读取的 render target 较少;图形队列当前存在阴影、后处理或上一帧呈现等待等可重叠区间。它也有清晰成本:队列间同步、资源 ownership 转换、调试复杂度和潜在的 compute/graphics 单元竞争。草地例子中,远处 chunk 的生成可以提前放在 async compute 队列,本帧近处草地绘制继续使用已 resident 的结果。
多队列提交的判断不能停留在“使用 async compute”。真正需要检查的是依赖图。草地生成读取 chunk params、density map 和 terrain height buffer,写入 instance buffer、statistics buffer 和 indirect args。绘制 pass 读取 instance buffer 和 indirect args。如果生成和绘制同帧发生,队列之间需要明确同步;如果生成结果用于下一帧,当前帧可以少一次紧急等待。许多引擎会把大场景程序化生成设计成跨帧收敛:近处 chunk 优先生成,远处 chunk 分批补齐,超出预算的 chunk 使用上一帧结果或低 LOD 代理。
可扩展场景还要处理容量。全局 append buffer 简单,但容量预算和错误定位都比较粗。per-chunk capacity 更容易控制,每个 chunk 记录自己的最大实例数、当前实例数和溢出次数。溢出处理应有稳定策略,例如降低密度、切换 LOD、截断远处装饰物或延迟到下一帧生成。调试视图中,溢出 chunk 用颜色标出,配合 validation buffer 记录 chunkId、期望数量、实际写入数量和容量上限。
大场景中的可复用组织方式可以总结为:CPU 用任务系统维护 chunk 状态和预算,GPU 用 compute pass 执行候选点生成和压缩,frame graph 记录资源依赖,多队列只在依赖允许时引入,streaming 以跨帧收敛方式更新 resident 结果。草地 chunk 生成器扩展到森林、城市或岩石散布时,这套组织仍然成立,因为它围绕空间单元、资源生命周期和可见预算展开。
16.5 性能优化与可视化调试
GPU 并行程序化生成的性能调试要把“生成慢”拆成可观察成本。草地生成器中,可能的瓶颈包括 dispatch 数量过多、候选点过密、atomic 竞争、prefix sum 多轮读写、buffer 带宽、间接绘制参数错误、队列同步等待、生成结果导致 overdraw 上升。优化前必须先定位哪一段占用帧时间,否则减少 shader 指令可能完全触达不到真正瓶颈。
GPU markers 是最基础的定位手段。每个 pass 应有稳定命名,例如 Grass_ResetCounters、Grass_GenerateCandidates、Grass_PrefixSum、Grass_CompactInstances、Grass_WriteDrawArgs、Grass_DrawIndirect。marker 的作用是把 frame capture 中的命令分段,让计时器和资源查看都能落到具体 pass。没有 marker 时,程序化生成通常会显示为一串匿名 dispatch,调试者很难判断哪个 dispatch 对应哪类数据。
计时器回答“哪段 GPU 工作占用时间”。它应和 marker 一起使用,并同时记录输入规模。单独的 0.7 ms 没有足够信息;Grass_GenerateCandidates: 0.7 ms, activeChunks=96, candidates=393216, generatedInstances=148902 才能解释成本。性能回归分析也要绑定这些规模参数,因为生成时间上升可能来自候选点增加,也可能来自同样候选数下 atomic 竞争或缓存行为变差。
validation buffer 回答“生成结果是否符合预期”。草地生成器可以为每个 chunk 写出 candidateCount、acceptedCount、capacity、overflowCount、lod 和 errorCode。这些数据可以在开发模式下异步读回 CPU,也可以直接作为 debug overlay 的输入。读回 CPU 适合离线验证和自动测试;GPU 端 overlay 适合实时观察。为了减少扰动,validation buffer 应保持小规模,并只在调试构建或指定帧启用。
heatmap 回答“空间上哪里异常”。把每个 chunk 的生成数量、溢出次数、LOD 或耗时映射成颜色,直接覆盖在场景上。草地例子中,某些区域突然变红,说明该区域候选数量或 accepted count 过高;某些区域没有颜色,说明 streaming 状态、dispatch 范围或 draw args 可能断裂。heatmap 的价值在于它把 buffer 统计和视觉结果放到同一个空间坐标里,调试者不需要在表格和画面之间反复猜测。
capture 工具用于复盘资源状态和命令顺序。RenderDoc、Nsight、Xcode GPU tools 或平台自带调试器都可以用来检查 dispatch 规模、绑定资源、buffer 内容、barrier、draw indirect 参数和 GPU timing。工具结论应对应一个具体问题:buffer 是否越界,counter 是否清零,draw args 是否写对,compute 写入是否对 draw 可见,某个 pass 是否存在同步等待。工具名称本身不构成结论,截图也需要回到资源路径解释。
优化顺序可以按成本来源展开。候选点过多时,先用 spatial partition、LOD 和 density budget 减少输入规模。atomic 热点明显时,把全局 counter 改成 per-chunk counter,或用局部 prefix sum 合并写入。带宽过高时,减少完整顶点写入,改成实例描述、压缩属性或延迟展开。同步等待明显时,把生成结果改成下一帧消费,或把远处 chunk 放入跨帧队列。draw indirect 数量异常时,先检查 counter reset、args layout 和资源状态转换。
可视化调试还要覆盖失败情况。草地闪烁可能来自随机种子随帧变化、chunk offset 被过早回收、counter 清零时机错误或 draw args 读取了上一帧数据。局部消失可能来自 dispatch 范围、chunk 状态机、capacity 截断或资源 barrier。过度密集可能来自 density map 采样空间错误、LOD 切换条件错误或 prefix sum 输入数量错误。每一种视觉症状都要落到可检查数据:chunk id、候选数量、accepted count、buffer offset、资源状态和 indirect args。
本章贯穿的草地 chunk 生成器最终形成一条完整判断链:CPU 准备少量 chunk 参数,GPU 把候选点映射到 work item,变量输出通过 append 或 prefix sum 进入紧凑 buffer,indirect draw 消费 GPU 生成的绘制参数,大场景通过 chunk state 和 streaming 控制资源生命周期,性能问题通过 marker、timer、validation buffer、heatmap 和 capture 证据定位。掌握这条链以后,读者可以把同样方法迁移到程序化岩石、建筑立面、道路、植被、粒子化碎片和大规模实例场景。
最小自检任务
设计一个 GPU 草地生成路径:场景有 64 个活跃 chunk,每个 chunk 有 4096 个候选点,每个候选点根据密度贴图可能生成 0 到 2 片草。CPU 不读取生成数量,绘制阶段需要直接使用 GPU 生成结果。请给出 thread group / work item 映射、核心 buffer、dispatch 顺序、barrier 位置、indirect draw 接入点和调试证据。
答案要点
work item 可以映射到一个候选点,thread group 可以使用 64 或 128 个线程处理连续候选点,总 dispatch 规模由 64 * 4096 个候选点推出。输入 buffer 至少包含 chunk 参数、密度贴图句柄或索引、随机种子和 LOD 参数;输出 buffer 至少包含草叶实例或顶点数据、每个 chunk 的统计信息、全局计数或 prefix sum 临时数据、indirect draw args。
变量输出可以用 append buffer 快速实现,也可以用“数量统计 → prefix sum → compaction”的三段流程形成连续实例 buffer。规模较大时,per-chunk 计数和容量更容易定位溢出,draw args 由最终实例数量生成。compute 写完实例 buffer 和 draw args 后,需要插入 API 层资源状态转换,让图形绘制阶段能读取这些 buffer。绘制阶段使用 indirect draw,CPU 只提交绘制命令,不读取 GPU 生成数量。
调试证据应覆盖三个层级:GPU marker 和 timer 标出 reset、generate、prefix/compact、write args、draw 的耗时;validation buffer 记录每个 chunk 的候选数量、accepted count、capacity 和 overflow;heatmap 把 accepted count 或 overflow 显示到世界空间。capture 工具用于检查资源绑定、counter 清零、barrier、draw args 布局和 buffer 内容。发现画面闪烁时,优先检查随机种子稳定性、chunk offset 生命周期、counter reset 时机和跨 pass 资源可见性。
本章知识点总结
- 并行粒度:GPU 生成几何前要把场景规则拆成 work item、thread group 和空间单元。
- 输出布局:固定数量输出可以直接计算地址,变量数量输出需要 append、prefix sum 或 compaction。
- 空间分区:chunk 让生成容量、streaming、LOD、调试热力图和资源回收都有稳定边界。
- Append Buffer:append 适合变量输出原型和中等规模路径,主要观察 counter、容量和 atomic 热点。
- Prefix Sum:prefix sum 通过数量扫描计算连续 offset,适合大规模稳定输出和后续压缩。
- 间接绘制:indirect draw 让 GPU 生成绘制参数,CPU 提交量与生成数量解耦。
- Shader 输入:Compute shader 输入应保持小而稳定,通常包含 chunk 参数、seed、LOD、密度和资源索引。
- 同步边界:shader 内部 group barrier 和 API 层 resource barrier 分属不同层级,调试时要分开检查。
- 生命周期:可扩展场景需要记录 chunk 从元数据分配、参数上传、GPU 生成到 resident 和回收的状态。
- Async Compute:async compute 的收益取决于依赖图和可重叠区间,同时要计算队列同步成本。
- 性能证据:marker、timer、规模参数和 capture 共同定位生成 pass 的真实成本来源。
- 可视化调试:validation buffer 和 heatmap 可以把计数、溢出、LOD 和空间位置连接起来。
- 迁移方法:同一条参数、dispatch、buffer、barrier、draw args、debug 证据链可以迁移到植被、城市和碎片生成。