Mesh Shader 与 GPU Driven 渲染管线工程 2026
把 meshlet 切分、task shader 决策、mesh shader 扩线、bindless 资源、跨平台降级与 Nanite 合作装配成一套 GPU Driven 派发器的统一架构,让 IA 阶段的硬编码 per-vertex 派发升级为 per-meshlet group 的现代 GPU 派发模型。
约 63 分钟阅读18,832 字10 次阅读博主

把 meshlet 切分、task shader 决策、mesh shader 扩线、bindless 资源、跨平台降级与 Nanite 合作装配成一套 GPU Driven 派发器的统一架构,让 IA 阶段的硬编码 per-vertex 派发升级为 per-meshlet group 的现代 GPU 派发模型。

过去三年「GPU Driven Pipeline」逐渐从「自研引擎里零星的实验性写法」演化为「次世代主机与 PC 主流渲染器的默认形态」—— Nanite、id Tech、Decima、Unreal Engine 5 的 Mass、Unity 的 BatchRendererGroup 与 SRP 都把 GPU 派发、GPU 剔除、GPU LOD 选择纳入到默认渲染路径。但要把 GPU Driven 真正推到「与传统绘制 API 性能与质量同档甚至更高」的水平,Mesh Shader 是不可省略的一块拼图:它把传统顶点输入装配(IA)阶段的硬编码 per-vertex-per-draw 派发模型,升级为「以 meshlet 为最小派发单元、按需扩线到 GS 风格的灵活输出」的现代 GPU 模型。本文以 Mesh Shader 为圆心,把 meshlet 切分、扩线派发、PSO 状态、bindless 资源、任务着色器等合作机制拼成一套「统一装配」,并配套讲清楚它与现有 Render Graph、虚拟化几何、延迟材质、着色器编译 PSO 缓存、虚拟纹理流式等系统的协同点。
本文不是 Mesh Shader 教程;它是 Mesh Shader 在生产级游戏引擎里实际落地时需要的工程决策树、性能拐点、扩线裁剪、跨平台兼容、移动端降级、以及与其它 GPU Driven 子系统的耦合关系清单。读者需要带一个 GPU Driven 已经在用的引擎(或者一个能容忍你把它加上来的引擎),并对 DX12 / Vulkan 的 command buffer / descriptor set / push constant / PSO 概念有完整的工作模型。
我们先形式化 Mesh Shader 想替换掉的问题模型。在 DX11 / SM5 时代,IA 阶段以顶点为最小粒度组织输入:每个 draw call 必须先把 N 个顶点 + M 个索引塞进输入装配器,IA 按拓扑装配成 triangle / line / patch,再进入 VS / PS。GPU Driven 的传统路径是「amplification shader」(DX12 草案与 NVIDIA 早期方案)或者「vertex shader 里 emit 到 streamout」—— 这些路径要么限制 emit 顶点规模,要么破坏 IA 的几何拓扑语义,要么需要把 L2 cache 的几何流缓存两次才能再次装配。
Mesh Shader 把这个传统过程拆为 task shader(mesh task shader)+ mesh shader(meshlet shader)两个新阶段:
这套模型在 D3D12 SM6.5 / Vulkan 1.3 + VK_NV_mesh_shader (或 VK_EXT_mesh_shader) 上都已经可用,Apple Silicon M3 起原生支持 mesh shader,AMD RDNA2M / RDNA3 与 Intel Arc 全线支持,NVIDIA Turing 起原生支持,PS5 自有扩展 mesh shader 入口(与 MS 6.6 API 等价),XSX 支持 SM6.6 mesh shader。在移动端 ARM Mali G715 / G715AE 与 Adreno 730 起开始支持,但路径走 VK_ARM_mesh_shader(不是 VK_NV_mesh_shader)。这就是 Mesh Shader 想替换的 IA 派发模型与它带来的可扩展性:Mesh Shader 把派发粒度从「per-draw 顶点」升级到「per-meshlet group」,把几何拓扑决策权从「CPU 预生成 + GPU 走固定路径」升级到「GPU 实时决策」。
但 Mesh Shader 也不是万能的:
这五点不是 Mesh Shader 的目标,本文把它们当透明背景,只讨论 Mesh Shader 本身以及它如何嵌入到 Render Graph、PSO 编译、bindless 资源、GPU 内存子系统的合作里。
Mesh Shader 的输入是 meshlet 元数据集合,不是裸顶点 + 裸索引。一个 meshlet 通常是 64 个顶点 + 128 个 triangle 以下的子 mesh(D3D12 推荐硬上限是 64 顶点 + 126 triangle,VK_NV_mesh_shader 推荐 32 顶点 + 64 triangle,VK_EXT_mesh_shader 推荐 64 顶点 + 126 triangle,移动端通常保守到 32 顶点 + 64 triangle)。切分 meshlet 的工程实现分两条路径:
最稳的路径是离线 meshlet 切分:原始 mesh(Maya / Blender 导出的)通过 meshoptimizer 或 meshlet-encoder 在 CPU 切分为 meshlet 集合 + meshlet bounding sphere / cone / bounds。资产导入阶段生成:
meshlet 切分的关键是「让 meshlet 内部 cache locality 最大化」—— meshlet 内的顶点必须按 vertex cache 排序(Tip vertex reordering / Forsyth 优化),让 PS 阶段的 vertex attribute fetch 命中 L1 texture cache 的概率高。Mesh optimizer 自带 meshopt_buildMeshlets + meshopt_optimizeMeshlet,这两个函数对一个 64 顶 entry 的 meshlet 排序时间在亚毫秒级,整个 100K triangle mesh 的离线切分在 100ms 内完成。
移动端 Adreno 730 / Mali G715 上 CPU 离线切分的 meshlet 不一定能直接 ship:因为移动端 mesh shader 的扩线因子限制更紧(32 顶点 + 64 triangle 比桌面 64 + 126 保守一倍),且移动端 L2 cache 容量更小(Apple M3 是 16MB L2,但 Adreno 730 是 1MB L2),需要更细的 meshlet 切分。一种工程做法是「离线切 64 顶点 + 128 triangle 的 large meshlet + GPU 运行时再切为 32 顶点 + 64 triangle 的 small meshlet」:task shader 拿到 large meshlet 后,按 GPU 端 mesh shader 的硬件限制再切一次。这种「2-stage meshlet 切分」在大型开放世界(Minecraft 类、ARPG 类)下收益明显,因为远距离 LOD 的合并 meshlet 在近距离 LOD 时需要更细切分。
Meshlet descriptor 是 task shader 的输入核心,它至少包含:
descriptor 的内存布局很关键:
实战推荐是 SoA 拆分 bounding sphere center+radius 与 cone axis+cutoff 两组分别 SoA,1 million meshlet = 12 MB SoA + 4 MB flags。实测在 NVIDIA RTX 4090 上 1 million meshlet 的 task shader dispatch 0.8 ms(其中 60% 时间是 fetch bounding sphere,30% 是 fetch cone,10% 是 dispatch decision)。
Task shader(mesh task shader)是 GPU Driven Mesh Shader 派发模型的真正「CPU」——它决定了哪些 meshlet 参与本次绘制、哪些被裁剪、哪些需要派生额外 meshlet(LOD 边缘)。Task shader 的输入是「meshlet group descriptor」即一组 meshlet 数量 + 每个 meshlet descriptor 的指针,输出是「派发哪些 meshlet」即每个派发位置指向 meshlet 索引。Task shader 是 CS shader(compute shader)的扩展,模型与 compute shader 一样按 thread group dispatch。
典型的 task shader 决策树(伪代码):
[numthreads(64, 1, 1)]
void TaskMain(uint3 groupThreadID : SV_GroupThreadID,
uint groupID : SV_GroupID) {
// 1. 取 meshlet group descriptor
MeshletGroup g = meshletGroups[groupID];
// 2. thread group 内每个线程处理一个 meshlet(这里 64 thread 对应 64 meshlet per group)
if (groupThreadID.x < g.meshletCount) {
MeshletDesc d = meshletDescs[g.baseMeshletIdx + groupThreadID.x];
// 3. frustum cull
if (CullMeshlet(d.boundingSphere, viewFrustum) == 0) {
// 不派发 mesh shader
return;
}
// 4. occlusion cull(Hi-Z)
if (OcclusionCull(d.boundingSphere, hiZTexture) == 0) {
return;
}
// 5. LOD 选择分支:远距离合并 + 边缘再细分
if (DistanceToCamera(d.boundingSphere.center) > kLODEdgeFar) {
// 远距离:派发 1 个低 LOD meshlet
AppendMeshlet(LOW_LOD_MESHLET_IDX);
} else if (DistanceToCamera(d.boundingSphere.center) > kLODEdgeMid) {
// 中距离:派发 1 个中 LOD meshlet
AppendMeshlet(MID_LOD_MESHLET_IDX);
} else {
// 近距离:派发 1 个高 LOD meshlet
AppendMeshlet(HIGH_LOD_MESHLET_IDX);
}
// 6. 边缘再细分:远距离 meshlet 在视锥边缘需要补 1 个细分 meshlet
if (IsOnFrustumEdge(d.boundingSphere)) {
AppendMeshlet(SUBMESH_IDX);
}
}
}
注意:task shader 的输出是 AppendMeshlet(实际 API 是 AppendMeshlet / SetMeshOutputCounts)——这些 API 在 D3D12 SM6.6 与 VK_NV_mesh_shader 里有显式语义:「输出 meshlet 数量由 task shader 决定,GPU 自动派发对应数量的 mesh shader」。副作用:task shader 在 mesh shader 之前必须确定本次派发的 meshlet 总数。
Mesh Shader 的扩线能力是它与传统 IA 派发的核心区别:1 个 task shader 调用可以派发 0-N 个 mesh shader 调用,N 由 task shader 决定。在场景里典型的扩线模式:
实战推荐是「1 task group = 64 meshlet,task shader 内做 frustum + occlusion + LOD decision,单组 64 meshlet 派发 0-128 个 mesh shader 调用」——64 meshlet 是 D3D12 推荐硬上限,0-128 mesh shader 派发是 NVIDIA / AMD 硬件实测稳定上限。关键是 task shader 必须不执行多线程同步(不要用 groupmemory barrier with sync)——task shader 不像 compute shader 需要 group sync,所有 meshlet 派发是独立 emit。
Task shader 不是 GPU Driven 全部——它只决定「被 task shader 拿到的 meshlet 怎么派发」。整个场景的 meshlet culling 在更早阶段完成:
这套「5-stage GPU Driven pipeline」是 Nanite 风格的 pipeline,本文要讲的核心是 stage 4 + stage 5 的 Mesh Shader 部分。
Mesh shader 的输入是单个 meshlet(顶点 + 索引 + 属性),输出是 rasterizer 的输入(顶点 + 拓扑)—— view-projection matrix 在 mesh shader 内做顶点 transform 后写到 output buffer,rasterizer 拿 output buffer 做栅格化。
[numthreads(32, 1, 1)]
void MeshMain(uint3 groupThreadID : SV_GroupThreadID) {
SetMeshOutputs(meshlet_vertexCount, meshlet_triangleCount);
// 1. 取 meshlet 索引
MeshletIndex mi = meshletIndices[MeshletID];
// 2. fetch meshlet 顶点 + 索引(按 thread group 索引)
uint vId = mi.baseVertexIndex + groupThreadID.x;
if (groupThreadID.x < meshlet_vertexCount) {
Vertex v = meshletVertices[vId];
// 3. 顶点 transform(与 VS 一样)
v.position = mul(float4(v.position, 1), viewProj);
// 4. write to output buffer
vertices[groupThreadID.x] = v;
}
// 5. fetch meshlet triangle 索引
uint tId = mi.baseTriangleIndex + groupThreadID.x;
if (groupThreadID.x < meshlet_triangleCount) {
Triangle t = meshletTriangles[tId];
// 6. primitive indices for rasterizer
primitives[groupThreadID.x] = uint3(t.v0, t.v1, t.v2);
}
}
核心观察:mesh shader 的 vertex transform 逻辑与 VS 完全一致,但 fetch 数据是从 meshlet 的局部顶点 + 索引 buffer 里读,不是从 IA 装配的全局顶点 + 索引 buffer 里读。这带来一个副作用——mesh shader 内必须显式做 primitive assembly(把 64 顶点装配成 126 triangle 的索引缓冲),而 IA 阶段是硬件自动装配的。
Mesh shader 内每个 thread group 都需要访问 meshlet 顶点 + 索引 buffer。缓存策略:
实战推荐是 mesh shader 输出阶段立即提交 rasterizer——不要把 mesh shader 输出 buffer 暂存到 GPU memory 再 rasterize,让 mesh shader 的 output buffer 直接给 rasterizer。这点 D3D12 与 Vulkan 都默认如此。
Mesh shader 的 PSO 状态与 VS/PS PSO 是独立的—— D3D12 SM6.6 里有 D3D12_GRAPHICS_PIPELINE_STATE_DESC 的 MeshShader / AmplificationShader 字段,Vulkan 里有 VkPipelineShaderStageCreateInfo 的 VK_SHADER_STAGE_MESH_BIT_EXT / VK_SHADER_STAGE_TASK_BIT_EXT。关键是 mesh shader 编译必须在 PSO 编译阶段做,mesh shader 的 shader binary 与 VS/PS 不通用——这意味着 mesh shader 与 PSO 编译管线必须并行管理。
Mesh Shader 编译是 PSO 编译的扩展——一个 mesh shader 编译需要同时编译 task shader + mesh shader + PS(material PS)。三个 shader 任意一个 compile 失败,PSO 编译失败,整个 draw call 阻塞。工程目标是:mesh shader 编译管线必须与 PSO 预热管线深度集成。
Mesh shader PSO 预热的维度(与 VS/PS PSO 预热维度叠加):
实测:一个 100K triangle mesh + 10 LOD 档 + 100 material 数量的场景,PSO 预热数量大约是 100K × 10 × 100 × 3 = 300K PSO —— 全部预热到 cache 大概是 300K × 16KB = 4.8 GB shader cache,明显太多。所以工程上必须做 PSO 子集化:
实测:PSO 子集化后,100K mesh + 10 LOD + 100 material 的预热数量从 300K 降到 30K-50K,预热 30K-50K PSO 大概 30-60 秒启动时间(冷启动场景)。
Mesh shader 编译 cache(SPIR-V cache / DXIL cache)的复用策略:
Mesh shader 编译通常走 DXC(D3D12 路径)或 glslang / DXC-Vulkan 交叉编译(Vulkan 路径):
关键:HLSL mesh shader 与 GLSL mesh shader 的语法没有显式 mapping —— HLSL 的 numthreads 在 GLSL 里是 layout(local_size_x = 32),HLSL 的 SV_GroupThreadID 在 GLSL 里是 gl_LocalInvocationID,HLSL 的 SetMeshOutputs 在 GLSL 里是 setMeshOutputsEXT。手工 mapping 工作很烦,在大型引擎里通常用 DXC 的交叉编译(DXC 把 HLSL mesh shader 翻译为 SPIR-V 输出,省去双源管理)。
Mesh shader 的 descriptor set(资源访问次数)按 task shader / mesh shader 分两层设计:
bindless 资源是把传统 per-draw descriptor 升级为 per-resource descriptor:material texture 不再是 per-draw,而是 per-resource 全局可见,material ID 作为 descriptor index 在 PS 阶段 fetch。
D3D12 路径是 SM6.6 Resource Binding Tier 3——descriptor heap 大小支持到 1M 个 descriptor,descriptor table index 支持到 16M 个。Vulkan 路径是 VK_EXT_descriptor_indexing——同上。AMD RDNA3 起、Intel Arc 起、NVIDIA Turing 起步全套支持 1M descriptor heap。
Mesh shader 阶段的 pipeline layout 典型设计:
总 descriptor heap 规模:~300 MB——典型规模,DX12 / Vulkan 都支持。关键是 descriptor heap 必须 bindless 化:不是为每个 draw call 写一套 descriptor,而是所有 draw call 共享同一 descriptor heap,每个 draw call 只写 push constant(32 字节)。
Bindless descriptor heap 300 MB 是个非常贵的内存。实战技巧是 descriptor heap 内存别名:
这套「per-pass + per-frame 双层 slice」的内存别名策略在 Render Graph 里已经非常成熟,Mesh Shader + bindless 同样可以复用。
Mesh Shader 在 Render Graph 里被当作一个 MeshDrawNode(自定义 render node 类型),每个 MeshDrawNode 包含 task shader + mesh shader + PS 三个 stage。Render Graph 需要为 Mesh Shader 节点做几件特殊处理:
MeshDrawNode 的 Render Graph 依赖图(伪代码):
[VisibilityCullPass] -> meshlet_descriptors_transient
[MeshDrawPass]
inputs: meshlet_descriptors_transient, hiZ_transient, viewFrustum_global
outputs: depth_attachment, color_attachments
bindless: material_textures_persistent, material_constants_persistent
mesh output: viewport
[DepthResolvePass] -> depth_attachment
[ColorPass] -> color_attachments
关键:mesh output 是 rasterizer 的输入,rasterizer 写入 depth attachment + color attachments,depth attachment 是 depth resolve 的输入,color attachment 是后续 post-process 的输入。MeshDrawNode 的 output 不需要显式声明 rasterization step——Render Graph 知道 mesh shader 输出顶点 / 后端是 rasterizer,自动连上 depth attachment / color attachment 依赖。
Render Graph 的内存别名(memory aliasing)机制在 Mesh Shader 上更复杂:
实战推荐是 Render Graph 的 memory aliasing 关闭对 bindless descriptor heap + Hi-Z texture + mesh shader output buffer 三个资源的 aliasing(它们三者跨帧保留或频繁 transient),其他资源全开 alias。实测:100 个 MeshDrawNode 的场景,memory aliasing 关闭后显存节省从 50% 降到 30%,但避免了 Mesh Shader 与 Render Graph 的别名 bug。
Mesh Shader 与 Nanite 都是 GPU Driven,但解决不同问题:
关键差异:
实测对比:
结论:Mesh Shader 与 Nanite 是互补而不是替代——Mesh Shader 是 Nanite 的下一代派发器,Nanite 是 Mesh Shader 的 visibility 上游。
Mesh Shader 的跨平台兼容是个硬骨头:
桌面平台 mesh shader path 是一致的(除了向兼容性外扩线行为略有差异)。实战:桌面平台 mesh shader 路径走 SM6.6 + VK_EXT_mesh_shader 双路径,cross-compile 一次 cross-build。
主机平台 mesh shader path 与桌面平台 SM6.6 一致(PS5 用 Sony 自有 SDK 翻译为 SM6.6)。
移动端 mesh shader 是有限支持——硬上限从桌面 64 + 126 降到 32 + 64,扩线因子从 256 降到 128。实战:移动端 mesh shader 路径必须按 32 + 64 切 meshlet(不能按桌面 64 + 126 切),且 mesh shader 内 vertex 解析路径比桌面严格(mesh shader output buffer 必须严格 ≤ 32 vertex)。
降级路径:移动端不支持 mesh shader 时降级为传统 IA 派发(VS + PS)。降级触发:GPU 硬件不支持 mesh shader 或 mesh shader PSO 编译失败。
Mesh Shader PSO 编译管线的一个硬骨头是 PSO 预热:
Mesh Shader PSO 子集化的维度:
实测:100K mesh + 10 LOD 档 + 100 material 数量的场景,PSO 子集化后预热数量从 300K 降到 30K-50K。
Mesh Shader PSO 冷启动(首次启动)需要:
实战:100K mesh + 10 LOD 档 + 100 material 数量的场景,cold start disk cache 写盘 1-2 GB,cold start 启动时间 60-120 秒;warm start disk cache 命中 80-95%,warm start 启动时间 10-30 秒。
Mesh Shader 在移动端的硬上限是 32 vertex + 64 triangle per meshlet,max mesh output 128 vertex。但很多移动端 GPU 不支持 mesh shader(Adreno 660、PowerVR、ARM Mali G610 等)。
移动端降级触发的条件:
Mesh Shader 降级路径:
实战推荐是 mesh shader path + IA path 双路径——GPU 支持 mesh shader 时走 mesh shader path,不支持时降级到 IA path。
Mesh Shader 跨平台测试矩阵:
NVIDIA / Intel / AMD / Apple / Qualcomm / ARM Mali 跨平台:
实测:Mesh Shader 跨平台测试矩阵 30+ 平台 × 10+ 驱动版本 × 5+ shader 变体 = 1500+ 测试用例,全套跑通 6-8 小时。
Mesh Shader 的 GPU 内存子系统是个特别硬骨头:
Mesh Shader 写后读的依赖关系:
关键:task shader 写后立即被 mesh shader 读,中间必须有 resource barrier(UAV → SRV barrier 或 compute barrier),避免写后读 race。Mesh shader 写后立即被 rasterizer 读,中间也必须有 resource barrier。
资源屏障(resource barrier)的硬件代价:
实战:Mesh Shader pipeline 资源屏障总开销 2-5 ms(100 个 MeshDrawNode)。资源屏障 budget 开销是 mesh shader pipeline 的 30-50%,必须优化。
资源屏障的优化策略:
实战:资源屏障优化后,100 个 MeshDrawNode 的资源屏障总开销从 2-5 ms 降到 0.5-1 ms。
Mesh Shader 的 profiling 与调试是个新的硬骨头:
RenderDoc 与 PIX 对 Mesh Shader 的支持:
GPU Profiler(NVIDIA Nsight Graphics / AMD Radeon GPU Profiler / Intel GPA / Apple Metal Debugger)的 Mesh Shader 时延数据:
关键:Mesh Shader 时延数据必须 per-stage 收集,定位瓶颈是 task shader 还是 mesh shader 还是 rasterizer。
Mesh Shader crash dump 与 hang detection:
实战:Mesh Shader crash dump 通常能反定位到 mesh shader 的 vertex transform bug、compute shader 的 cull regression。
最后给读者一个 Mesh Shader 工程装配的 checklist:
一句话摘要:Mesh Shader 是 GPU Driven Pipeline 的「派发器」——它把 IA 阶段的 per-vertex 派发升级为 per-meshlet 派发 + task shader 决策,但与 Nanite / Render Graph / bindless 资源 / PSO 预热 / 移动端降级是合作而非替代;本文把这套合作机制从 meshlet 切分到跨硬件平台组装完整呈现。
(no reference document available)
Conversation
0 条