Skip to main content

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 bufferGPU 端任务队列队列顺序、阶段同步、工具观察难度生成规则有多级展开,下一阶段任务来自上一阶段结果
prefix sum为变量输出计算稳定 offset多次 dispatch、临时 buffer、barrier输出规模大,写入需要连续,后续绘制和压缩需要稳定索引
compaction清理无效元素并形成连续结果额外读写带宽可见性、LOD、规则过滤导致候选大量失效
indirect drawGPU 生成绘制参数参数布局、资源状态转换、调试边界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 工具中核对。

  1. CPU 更新活跃 chunk 参数,只上传 seed、origin、LOD、密度范围和容量上限。
  2. GPU 清零 counter buffer、chunk statistics buffer 和 indirect args buffer。
  3. Compute shader 根据 chunk 参数生成候选数量、有效标记或草叶描述。
  4. 变量输出路径执行 prefix sum 和 compaction,形成连续输出 buffer。
  5. Compute shader 根据生成数量写入 indirect draw args。
  6. API 层插入资源状态转换,让 compute 写入结果对后续绘制可见。
  7. 图形队列执行 indirect draw,读取 vertex/index/instance buffer 和 draw args。
  8. 调试路径读取 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 把程序化规则转成每个候选点的局部判断。它适合原型和中等规模场景。规模继续增大后,AppendInterlockedAdd 的原子竞争会成为观察重点,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_ResetCountersGrass_GenerateCandidatesGrass_PrefixSumGrass_CompactInstancesGrass_WriteDrawArgsGrass_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 写出 candidateCountacceptedCountcapacityoverflowCountloderrorCode。这些数据可以在开发模式下异步读回 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 证据链可以迁移到植被、城市和碎片生成。