来源:Mesh Shader 学习笔记 + Nanite 硬件光栅源码阅读
引擎版本:UE 5.8
面向读者:UE 图形程序员


Mesh shader 用一个像 compute shader 那样协作的线程组,替掉了 IA + VS(以及 GS):线程组自己取几何、自己变换,最后往三个并排的输出数组——顶点、索引、逐图元属性——写出一个 meshlet,直接交给固定功能光栅器。

Nanite 的硬件光栅入口 HWRasterizeMS 是这套模型现成的活教材。UE 5.8 里它固定 32 线程一组,一组处理的不是整个 cluster,而是 cluster 里的一个顶点复用批次(≤32 个三角形、≤32 个唯一顶点);visibility buffer 和 VSM 需要的逐三角形信息全部走逐图元属性,顶点变换则复用一份跑在 mesh shader 里的「顶点着色器」CommonRasterizerVS。

目录

  1. 传统几何管线卡在哪
  2. Mesh Shader 的执行模型
  3. 三个输出数组
  4. Nanite 何时走硬件光栅
  5. 从可见 cluster 到 DispatchMesh
  6. HWRasterizeMS 逐段拆解
  7. HWRasterizePS:片元落到 VSM 物理页
  8. 三条路径对照
  9. 坑点速记
  10. 参考链接

一、传统几何管线卡在哪

Vertex Buffer + Index Buffer
        │
  [Input Assembler]        固定功能:按索引逐个取顶点喂给 VS
        │
  [Vertex Shader]          一个顶点进,一个顶点出
        │
  [Hull / Domain / Geometry Shader](可选)
        │
  [Rasterizer]             固定功能:三角形 setup、覆盖、插值
        │
  [Pixel Shader]

对 GPU-driven 渲染来说,光栅器之前的这半段处处受限:

环节限制后果
Input Assembler只认「顶点缓冲 + 索引缓冲」的固定格式压缩存储、由 GPU 自己决定画哪些的几何(比如 Nanite 的 cluster page)喂不进去
Vertex Shader1 进 1 出,看不到邻居,不能增删几何共享顶点的复用只能指望硬件 post-transform cache,shader 自己控制不了
Geometry Shader能增删几何,但输出必须保序落地,放大量又不可预测实现上要额外缓冲,性能差,热路径基本不用
整条前端线程之间没有 groupshared,也没有同步解码、去重、剔除这类需要协作的工作做不了

二、Mesh Shader 的执行模型

2.1 能直接喂光栅器的 compute shader

[Amplification Shader](可选)  决定启动多少个 mesh 线程组,可以先做粗剔除
        │  DispatchMesh
[Mesh Shader]                   一个线程组协作产出一个 meshlet
        │
[Rasterizer] → [Pixel Shader]   与传统管线相同
  • 线程组语义:[numthreads(X, Y, Z)],有 groupshared,可以 GroupMemoryBarrierWithGroupSync(),和 compute shader 一样能协作。
  • 没有 IA:输入只有线程 ID,外加 Amplification shader 传下来的 payload。从哪个 buffer、按什么格式取几何,压缩与否,甚至程序化生成,全由 shader 决定。
  • 线程与输出元素没有隐式对应:一个线程可以写多个顶点和图元,也可以一个都不写,负载怎么分由作者安排。

2.2 D3D12 的硬上限

项上限
线程组大小 X*Y*Z≤ 128
每组输出顶点≤ 256
每组输出图元≤ 256
groupshared≤ 28 KB(compute shader 为 32 KB)
输出属性顶点属性行 + 图元属性行合计 ≤ 32 个 4 分量行,两类属性不能打包进同一行;按字节还要满足 vert_attr_bytes × ALIGN32(n_verts) + prim_attr_bytes × ALIGN32(n_prims) ≤ 32 KB
DispatchMesh每一维 < 64k,三维乘积 ≤ 2²²

2.3 心智模型

一个 mesh shader 线程组 = 一个 meshlet = 一次自带顶点缓冲和索引缓冲的迷你 indexed draw。这份 VB / IB 由线程组在片上现造,造完直接交给光栅器。

Nanite 的几何本来就按 cluster 存储,上限是 NANITE_MAX_CLUSTER_TRIANGLES = 128、NANITE_MAX_CLUSTER_VERTICES = 256(NaniteDefinitions.h:21-26),正好落在 D3D12 的输出上限之内,天然就是 meshlet 的形状。不过 5.8 并没有采用「一组 = 一个 cluster」,第五、六节会看到它把 cluster 进一步切成了更小的批次。


三、三个输出数组

Mesh shader 不像 VS 那样 return 一个顶点,而是往三个线程组共享的输出数组里写。UE 在平台头文件里用宏把它们包了一层:

// Engine/Shaders/Public/Platform/D3D/D3DCommon.ush:90(节选)
#if PLATFORM_SUPPORTS_MESH_SHADERS_TIER0
	#define MESH_SHADER_TRIANGLE_ATTRIBUTES(InThreadsX) [numthreads(InThreadsX, 1, 1)][outputtopology("triangle")]
	// ...
	#define MESH_SHADER_VERTEX_EXPORT(VertexType, InMaxVertexCount) out vertices VertexType OutVertices[InMaxVertexCount]
	#define MESH_SHADER_TRIANGLE_EXPORT(InMaxTriangleCount) out indices uint3 OutTriangles[InMaxTriangleCount]
	// ...
	#define MESH_SHADER_WRITE_VERTEX(InVertexIndex, Value) OutVertices[InVertexIndex] = Value;
	#define MESH_SHADER_WRITE_TRIANGLE(InTriangleIndex, Value) OutTriangles[InTriangleIndex] = Value;
	// ...
#endif
 
#if PLATFORM_SUPPORTS_MESH_SHADERS_TIER1
	#define MESH_SHADER_PRIMITIVE_EXPORT(PrimitiveType, InMaxPrimitiveCount) out primitives PrimitiveType OutPrimitives[InMaxPrimitiveCount]
 
	#define MESH_SHADER_WRITE_PRIMITIVE(InPrimitiveIndex, Value) OutPrimitives[InPrimitiveIndex] = Value;
#endif
数组传统管线里的对应物装的是什么
OutVertices[]VS 的输出(post-transform 顶点)每项一个 VSOut,必须含 SV_Position,外加要插值给 PS 的量
OutTriangles[]Index Buffer每项一个 uint3,引用的是本组 OutVertices 的槽号,不是网格里的原始顶点号
OutPrimitives[]没有对应物每个三角形一份常量属性,PS 直接读

图 1:一个线程组导出两个共享一条边的三角形。cluster 本地顶点号先压成连续槽号,索引数组引用槽号,逐图元属性每个三角形一份。

3.1 先声明数量:SetMeshOutputCounts

写任何输出数组之前,线程组必须先调用 SM 6.5 的内建函数 SetMeshOutputCounts(NumVertices, NumPrimitives),声明本次实际导出多少顶点和图元。数组声明的长度只是容量,这里给出的才是有效数量。规范对它的约束:

  • 每个 shader 只能调用一次,而且必须支配(dominate)所有对输出数组的写入;
  • 只采用第一个活跃线程传入的值;
  • 在发散控制流里调用,或者写入超出声明数量的槽位,都是未定义行为,硬件不负责裁掉;
  • 始终没调用,等于这个线程组什么都不输出。

❗ 规范明确允许「在 group-uniform 条件下先 return,再调用 SetMeshOutputCounts」,Nanite 却在源码里写明这样做会出 corruption,而且编译器不报错:

// Engine/Shaders/Private/Nanite/NaniteRasterizer.usf:3056
	// NOTE: Doing a simple early out here doesn't work. Likely because divergent control
	// flow is not allowed around SetMeshOutputCounts, even if the condition is uniform for
	// the group. The compiler succeeds but corruption occurs.

所以 HWRasterizeMS 从不提前返回:所有取数逻辑都包在 if (bValidIndex) 里,无效的线程组照样走到 SetMeshOutputCounts(0, 0)。

3.2 顶点和索引为什么分开

为了顶点复用。一个被 6 个三角形共享的顶点,在 OutVertices 里只变换、只导出一次,被索引引用 6 次;如果每个三角形独立吐 3 个顶点,同一个顶点就要变换 6 次,占 6 份导出带宽。

3.3 逐图元属性解决了什么

传统管线想给「每个三角形」挂一份数据,只能复制到它的 3 个顶点上,或者动用 GS。Mesh shader 原生支持逐图元导出:PS 按语义名匹配输入,逐图元属性在一个三角形内恒定,插值修饰符对它既不需要、也不起作用。Nanite 把 visibility buffer 的逐三角形 ID PixelValue、ViewId,以及 VSM 的 mip、页表偏移、页矩形都放在这里,布局见 §6.5。

❗ PrimitiveAttributesPacked 刻意用 uint4,并标了 nointerpolation(NaniteRasterizer.usf:2476-2484)。源码注释写明用 uint4 是为了防止编译器把逐顶点和逐图元属性错误地打包到一起——规范本来就禁止两类属性共用一行;nointerpolation 在 mesh shader 路径里不起作用,它服务的是 VS 回退路径,那里同一个结构作为普通顶点输出传给 PS。

3.4 UE 的 Tier0 / Tier1

UE 把 mesh shader 能力分成两档:PLATFORM_SUPPORTS_MESH_SHADERS_TIER0 提供顶点和索引导出,TIER1 再加上逐图元导出。这是 UE 自己的划分——D3D12 只有 D3D12_MESH_SHADER_TIER_1 一档,D3D12 RHI 在该能力成立时把两档一起置位:

// Engine/Source/Runtime/D3D12RHI/Private/D3D12Adapter.cpp:1372
GRHISupportsMeshShadersTier0 = GRHISupportsMeshShadersTier1 = (D3D12Caps7.MeshShaderTier >= D3D12_MESH_SHADER_TIER_1);

Nanite 的 mesh shader 路径要求 Tier1,UseMeshShader() 里的注释就是 “We require tier1 support to utilize primitive attributes”(NaniteShared.cpp:138)。


四、Nanite 何时走硬件光栅

4.1 按 cluster 分流

软件光栅(compute shader)处理像素级的小三角形更快,三角形一大,固定功能光栅器更划算。分流在 cluster 剔除阶段按 cluster 决定:

// Engine/Shaders/Private/Nanite/NaniteClusterCulling.usf:301
	if (RenderFlags & NANITE_RENDER_FLAG_FORCE_HW_RASTER)
	{
		bUseHWRaster = true;
	}
	else
	{
		float HWEdgeScale = InstanceData.NonUniformScale.w * Bounds.NodeMaxDeformScale;
		bUseHWRaster |= ProjectedEdgeScale < HWEdgeScale * abs( EdgeLength ) * NaniteView.LODScaleHW; // TODO: EdgeLength shouldn't have sign
	}

LODScaleHW = ViewToPixels / MinPixelsPerEdgeHW(NaniteShared.cpp:200),这个不等式的含义是「cluster 的边长(乘上实例的最大缩放)投影到屏幕超过 r.Nanite.MinPixelsPerEdgeHW 个像素」。之后的分桶阶段还会按材质再改一次判定:

条件位置结果
投影边长超过 r.Nanite.MinPixelsPerEdgeHWNaniteClusterCulling.usf:308HW
cluster 需要裁剪(Cull.bNeedsClipping)NaniteClusterCulling.usf:860HW
材质需要有限差分(!bNoDerivativeOps)NaniteRasterBinning.usf:421强制 HW:SW 光栅的一条 lane 处理的是任意一个三角形,算不了导数
半透明材质NaniteRasterBinning.usf:427强制 HW
可编程像素材质,且平台最小 wave 宽度 < 32NaniteRasterBinning.usf:415强制 HW
位移(displacement)、体素、曲线NaniteRasterBinning.usf:440-457强制 SW

4.2 选哪条硬件路径

GetRasterHardwarePath(NaniteShared.cpp:158)按顺序挑:

  1. Mesh shader:UseMeshShader() 成立,即平台 DDPI 声明支持 Tier1、NaniteMeshShadersSupported 通过、r.Nanite.MeshShaderRasterization 打开、运行时 GRHISupportsMeshShadersTier1 为真;如果开了 r.AllowGlobalClipPlane,平台还必须支持 mesh shader 输出 clip distance。再细分三种:平台最大 MS 线程组为 32 时走 MeshShaderNV;平台要求不 wrap 的派发参数时走 MeshShader;其余走 MeshShaderWrapped,D3D12 属于这一种。
  2. Primitive shader:GRHISupportsPrimitiveShaders 且 r.Nanite.PrimShaderRasterization 打开,只在支持 primitive shader 的平台上存在。
  3. Vertex shader:以上都不满足时,回退到 HWRasterizeVS + DrawPrimitiveIndirect。

4.3 相关开关

CVar默认作用
r.Nanite.MeshShaderRasterization1条件满足时用 mesh shader 做 HW 光栅
r.Nanite.PrimShaderRasterization1条件满足时用 primitive shader 做 HW 光栅
r.Nanite.MinPixelsPerEdgeHW32三角形边长超过多少像素开始走 HW 光栅
r.AllowGlobalClipPlane—打开后,若平台不支持 MS 输出 clip distance,mesh shader 路径被禁用

PC 上把 r.Nanite.MeshShaderRasterization 设为 0 就会落到 VS 路径,可以拿来对比两条路径的开销;前提是平台没有剥离 Nanite 的 VS 排列,否则 GetRasterHardwarePath 里的 checkf 会直接报致命错误(NaniteShared.cpp:185)。


五、从可见 cluster 到 DispatchMesh

HW 光栅要启动多少个线程组只有 GPU 知道,所以只能间接派发。可见 cluster 进光栅之前先经过一轮 GPU 分桶,全部在 FRenderer::AddPass_Binning(NaniteCullRaster.cpp:4895)里,RDG 事件依次是:

RasterGroupInit → RasterBinCount → RasterGroupReserve → (RasterGroupDepthBlock) → RasterBinScatter → RasterBinFinalize

先按 raster group 计数、分配区间,再把工作项 scatter 进去,最后写间接参数。关键在于:一条 HW 工作项对应的是一个顶点复用批次,而不是一个 cluster。

图 2:cluster 在离线阶段切成顶点复用批次;每帧分桶时每个 HW 批次写一条 RasterBinData,批次总数写进 DispatchMesh 参数,一个线程组消费一个批次。

5.1 批次在离线阶段切好

NaniteBuilder 在编码阶段对每个 cluster 的每个材质段贪心切分,唯一顶点数或三角形数任一到 32 就开新批次,只记录每批的三角形数:

// Engine/Source/Developer/NaniteBuilder/Private/Encode/NaniteEncodeVertReuseBatch.cpp:88(节选)
static void BuildVertReuseBatches(FCluster& Cluster)
{
	for (FMaterialRange& MaterialRange : Cluster.MaterialRanges)
	{
		// ...
		const uint32 MaxBatchVerts = 32;
		const uint32 MaxBatchTris = 32;
		// ...
		for (uint32 TriIndex = MaterialRange.RangeStart; TriIndex < TriIndexEnd; ++TriIndex)
		{
			// ...
			// If adding this tri to the current batch will result in too many unique verts, start a new batch
			const uint32 NumNewUniqueVerts = uint32(!Bit0) + uint32(!Bit1) + uint32(!Bit2);
			if (NumUniqueVerts + NumNewUniqueVerts > MaxBatchVerts)
			{
				check(NumTris > 0);
				MaterialRange.BatchTriCounts.Add(uint8(NumTris));
				// ...(清空计数与掩码,这个三角形留给下一批)
				--TriIndex;
				continue;
			}
			// ...
			++NumTris;
 
			if (NumTris == MaxBatchTris)
			{
				MaterialRange.BatchTriCounts.Add(uint8(NumTris));
				// ...
			}
		}
		// ...
	}
}

每批的三角形数以 5 bit(存 count - 1)写进 cluster 数据:材质段不超过 3 个时内联在 cluster 的打包数据里,否则放进页数据,运行时由 DecodeVertReuseBatchInfo(NaniteRasterBinning.usf:292)解出。

5.2 运行时:HW cluster 拆成批次写进 raster group

// Engine/Shaders/Private/Nanite/NaniteRasterBinning.usf:433(节选)
#if FORCE_BATCHING
	const bool bForceBatching = true;
#else
	const bool bForceBatching = false;
#endif
	bool bUseBatch = !bSoftware && bUsePrimOrMeshShader && (bForceBatching || MaterialFlags.bVertexProgrammable || MaterialFlags.bTranslucent);
	// ...
	BatchCount = CondMask(bUseBatch, BatchCount, 1u);
	// ...
	for (uint BatchIndex = 0; BatchIndex < BatchCount; ++BatchIndex)
	{
		if (bUseBatch)
		{
			uint BatchTriCount = DecodeVertReuseBatchInfo(Cluster, BatchInfoOffset, BatchIndex, bBatchInfoInlined);
			RangeEnd = RangeStart + BatchTriCount;
		}
 
		const uint RangeNum = RangeEnd - RangeStart;
 
		OutRasterBinData[GroupClusterMapping + BatchIndex].x = ClusterIndex;
		OutRasterBinData[GroupClusterMapping + BatchIndex].y = (RasterBin << 16) | (RangeStart << 8) | RangeNum;
		RangeStart = RangeEnd;
	}

FORCE_BATCHING 由 C++ 按平台决定:不支持 primitive shader 的平台一律为 1(NaniteCullRaster.cpp:1199)。所以在 PC 上只要走 mesh shader,每个 HW cluster 的每个材质段都会拆成批次,每个批次一条 RasterBinData:.x 是可见 cluster 索引,.y 打包了 raster bin、三角形起点和三角形数;raster group 的 HWCount 累加的也是批次数。

5.3 间接参数与 64k 限制

// Engine/Shaders/Private/Nanite/NaniteRasterBinning.usf:205
		if (FinalizeMode == 0 && bMeshShader) // Wrapping
		{
#if !PLATFORM_REQUIRES_UNWRAPPED_MESH_SHADER_ARGS
			// Need to span the launch size across X,Y,Z so any single dimension does not exceed the 64k
			// limit as per https://microsoft.github.io/DirectX-Specs/d3d/MeshShader.html#dispatchmesh-api
			// The product of X*Y*Z can be 2^22, so we convert 1D->3D->1D to work around this limitation.
			const uint DimensionLimit = (1u << 16u) - 1u;
 
			const uint3 GroupCount = GetGroupCountWrapped(ClampedHWClusterCount, DimensionLimit.xx);
 
			// HW: ThreadGroupCountXYZ
			OutRasterGroupArgsSWHW[Offset + 4u] = GroupCount.x;
			OutRasterGroupArgsSWHW[Offset + 5u] = GroupCount.y;
			OutRasterGroupArgsSWHW[Offset + 6u] = GroupCount.z;
#else
			OutRasterGroupArgsSWHW[Offset + 4u] = ClampedHWClusterCount;
#endif
		}

每个 raster group 占 8 个 uint 的参数(NANITE_RASTERIZER_ARG_COUNT):第 0 个是 SW 工作项数,第 4~6 个是 DispatchMesh 的三维线程组数,所以 C++ 侧的偏移是 +16 字节:

// Engine/Source/Runtime/Renderer/Private/Nanite/NaniteCullRaster.cpp:3661
						if (IsMeshShaderRasterPath(HardwarePath))
						{
							RHICmdList.DispatchIndirectMeshShader(Parameters.IndirectArgs->GetIndirectRHICallBuffer(), Group.IndirectOffset + 16);
						}
						else
						{
							RHICmdList.DrawPrimitiveIndirect(Parameters.IndirectArgs->GetIndirectRHICallBuffer(), Group.IndirectOffset + 16);
						}

一次 DispatchIndirectMeshShader 覆盖一个 raster group 里全部 view 的全部 HW 批次。shader 里的 GetUnWrappedDispatchGroupId 把三维组号还原成一维,wrap 之后多出来的组靠 bValidIndex 输出空 meshlet。

❗ DispatchMesh 的线程组数是 HW 批次数,不是可见 cluster 数。一个满 128 三角形的 cluster 至少对应 4 个线程组,在 PIX / RenderDoc 里看 HW Rasterize (Triangles) 下的调用时要按批次理解。


六、HWRasterizeMS 逐段拆解

6.1 编译配置:32 线程的批次模式

// Engine/Source/Runtime/Renderer/Private/Nanite/NaniteCullRaster.cpp:2038(FHWRasterizeMS::ModifyCompilationEnvironment)
		OutEnvironment.SetDefine(TEXT("NANITE_MESH_SHADER"), 1);
		OutEnvironment.SetDefine(TEXT("NANITE_HW_COUNTER_INDEX"), 4); // Mesh and primitive shaders use an index of 4 instead of 5
		OutEnvironment.SetDefine(TEXT("NANITE_MULTI_VIEW"), 1);
 
		const uint32 MSThreadGroupSize = 32;
 
		OutEnvironment.SetDefine(TEXT("NANITE_VERT_REUSE_BATCH"), 1);
		OutEnvironment.SetDefine(TEXT("NANITE_MESH_SHADER_TG_SIZE"), MSThreadGroupSize);
		OutEnvironment.CompilerFlags.Add(CFLAG_Wave32);

入口签名于是展开成 [numthreads(32, 1, 1)][outputtopology("triangle")],三个输出数组各 32 项:

// Engine/Shaders/Private/Nanite/NaniteRasterizer.usf:3023
MESH_SHADER_TRIANGLE_ATTRIBUTES(NANITE_MESH_SHADER_TG_SIZE)
void HWRasterizeMS(
	uint GroupThreadID : SV_GroupThreadID,
	uint3 GroupID : SV_GroupID,
#if WORKGRAPH_NODE
	DispatchNodeInputRecord<FShaderBundleNodeRecord> InputRecord,
#endif
#if NANITE_VERT_REUSE_BATCH
	MESH_SHADER_VERTEX_EXPORT(VSOut, 32),
	MESH_SHADER_TRIANGLE_EXPORT(32),
	MESH_SHADER_PRIMITIVE_EXPORT(PrimitiveAttributesPacked, 32)
#else
	MESH_SHADER_VERTEX_EXPORT(VSOut, 256),
	MESH_SHADER_TRIANGLE_EXPORT(128),
	MESH_SHADER_PRIMITIVE_EXPORT(PrimitiveAttributesPacked, 128)
#endif
)

❗ 源码里还留着 #else 分支的 256 / 128 声明,以及函数末尾 #if NANITE_MESH_SHADER_TG_SIZE == 128 的「每条 lane 再写一个 +128 顶点」,但 FHWRasterizeMS 编译时写死了上面的配置,这两段不会进入任何 mesh shader 排列。照着它们去读,会误以为一组处理整个 cluster、每条 lane 负责两个顶点。同一入口还会编成 work graph 的 mesh 节点(FHWRasterizeWGMS,NaniteCullRaster.cpp:2084),编译配置相同。

线程与几何的映射:

维度映射
1 个线程组1 个顶点复用批次(≤32 三角形、≤32 唯一顶点)
导出三角形时的 1 条 lane1 个三角形:TriIndex = TriRange.Start + GroupThreadID
导出顶点时的 1 条 lane批内第 GroupThreadID 个唯一顶点
1 个 wave整个线程组(CFLAG_Wave32),去重可以全靠 wave intrinsic 完成

6.2 取活儿:从 GroupID 到 FClusterRef

// Engine/Shaders/Private/Nanite/NaniteRasterizer.usf:3041(节选)
	bool bValidIndex = true;
	// ...
	// Avoid overflowing the 64k limit on single dimension of SV_GroupID
	uint DrawClusterIndex = GetUnWrappedDispatchGroupId(GroupID);
	BRANCH
	if (GroupID.y > 0 || GroupID.z > 0)
	{
		// Due to wrapping, the visible index can be out of range
		bValidIndex = (DrawClusterIndex < RasterGroupMeta[GetRasterGroup()].HWCount);
	}
	// ...
	FClusterRef ClusterRef = FetchClusterRef<true>(DrawClusterIndex);
	// ...
	BRANCH
	if (bValidIndex)
	{
		TriRange = ClusterRef.TriRange;
 
		VisibleCluster = GetVisibleCluster(ClusterRef.VisibleIndex, VIRTUAL_TEXTURE_TARGET);
		GetNaniteMaterialSceneData(VisibleCluster, PrimitiveData, InstanceData);
		NaniteView = GetNaniteView(VisibleCluster.ViewId);
		bReverseWindingOrder = ReverseWindingOrder(NaniteView, PrimitiveData, InstanceData);
		// ...
		Cluster = GetCluster(VisibleCluster.PageIndex, VisibleCluster.ClusterIndex, NANITE_CLUSTER_TYPE_TRIANGLES);
 
		TriIndex = TriRange.Start + GroupThreadID;
 
		bool bTriValid = GroupThreadID < TriRange.Num;
 
		if (bTriValid)
		{
			VertIndexes = DecodeTriangleIndices(Cluster, TriIndex);
		
			if( bReverseWindingOrder )
				VertIndexes.yz = VertIndexes.zy;
		}
 
	#if NANITE_VERT_REUSE_BATCH
		DeduplicateVertIndexes(VertIndexes, GroupThreadID, bTriValid, NumUniqueVerts, LaneVertIndex, VertIndexes);
	#else
		LaneVertIndex = GroupThreadID;
		NumUniqueVerts = Cluster.NumVerts;
	#endif
	}
  • FetchClusterRef<true>(NaniteRasterizer.usf:155)读 RasterBinData,解出 VisibleIndex、TriRange.Start(8 bit)、TriRange.Num(8 bit)和 RasterBin(高 16 bit)。
  • GetVisibleCluster 取回的只是一条打包记录(ViewId、PageIndex、ClusterIndex、剔除 flag、VSM 的 vPage 等)。几何始终留在 ClusterPageData 里,GetCluster 只解出 cluster 头,顶点和索引都是按需从页数据里解码。
  • ReverseWindingOrder(NaniteRasterizer.usf:221)在实例变换行列式为负(奇数个缩放分量为负,即镜像)时为真,primitive 开了反向剔除时再取反一次;为真时交换 yz 保持绕序。

6.3 批内顶点去重

// Engine/Shaders/Private/Nanite/NaniteRasterizationCommon.ush:147(节选:只保留 wave 宽度 ≥ 32 的分支)
void DeduplicateVertIndexes( uint3 VertIndexes, uint GroupThreadIndex, bool bTriValid, out uint OutNumUniqueVerts, out uint OutLaneVertIndex, out uint3 OutCornerLaneIndexes )
{
	// ① 以批内最小顶点号为基准
	uint BaseVertIndex = WaveActiveMin(bTriValid ? VertIndexes.x : 0xFFFFFFFFu);	// VertIndexes.x is always smallest
 
	// ② 每条 lane 把自己三角形的 3 个顶点标进 64 bit 掩码
	uint2 TriangleVertMask = 0;
	if( bTriValid )
	{
		VertIndexes -= BaseVertIndex;
 
		UNROLL
		for (uint i = 0; i < 3; i++)
		{
			bool bDstLow = VertIndexes[i] < 32;
			uint DstMask = 1u << ( VertIndexes[i] & 31 );
 
			if( bDstLow )
				TriangleVertMask.x |= DstMask;
			else
				TriangleVertMask.y |= DstMask;
		}
	}
 
	// ③ 整个 wave 求并集,得到本批用到的顶点集合
	uint2 UsedVertMask = uint2(0,0);
	UsedVertMask.x = WaveActiveBitOr(TriangleVertMask.x);
	UsedVertMask.y = WaveActiveBitOr(TriangleVertMask.y);
 
	// ④ 角 → 槽号:掩码里比它低的 1 的个数
	OutCornerLaneIndexes.x = MaskedBitCount(UsedVertMask, VertIndexes.x);
	OutCornerLaneIndexes.y = MaskedBitCount(UsedVertMask, VertIndexes.y);
	OutCornerLaneIndexes.z = MaskedBitCount(UsedVertMask, VertIndexes.z);
 
	// ⑤ lane → 顶点:第 i 条 lane 负责掩码里的第 i 个 1
	OutLaneVertIndex = FindNthSetBit(UsedVertMask, GroupThreadIndex) + BaseVertIndex;
	OutNumUniqueVerts = CountBits(UsedVertMask);
}
  • VertIndexes.x 一定是三个顶点里最小的:Nanite 的三角形索引按 (base, base+a, base+b)(a、b > 0)的形式压缩存储,HWRasterizePS 恢复重心坐标顺序时也用到了这个性质(NaniteRasterizer.usf:3397)。
  • 调用处把第三个输出参数直接写回 VertIndexes(NaniteRasterizer.usf:3116),此后三角形的三个角存的就是槽号。
  • 两个方向用的是同一套编号:角 → 槽号数「掩码里比它低的 1」,lane → 顶点取「掩码里第 i 个 1」。第 i 条 lane 变换的顶点正好落在槽 i,索引数组和顶点数组天然对齐。
  • ⚠️ 64 bit 掩码之所以装得下,靠的是 builder 的约束:每个三角形引用的顶点号与此前出现过的最大顶点号相差不到 32(NANITE_CONSTRAINED_CLUSTER_CACHE_SIZE,由 NaniteEncodeConstrain.cpp:330 起的 check 校验),批内新增的唯一顶点又不超过 32,批内顶点号的跨度因而落在 64 以内。
  • wave 宽度不足 32 的硬件上,函数退回用 groupshared 求最小值和并集。

6.4 声明数量,三类导出

// Engine/Shaders/Private/Nanite/NaniteRasterizer.usf:3123
	SetMeshOutputCounts(NumUniqueVerts, TriRange.Num);
 
	BRANCH
	if (bValidIndex)
	{
		uint PrimExportIndex = GroupThreadID;
		if (PrimExportIndex < TriRange.Num)
		{
			MESH_SHADER_WRITE_TRIANGLE(PrimExportIndex, VertIndexes);
 
			const uint PixelValue = PackVisPixelX(ClusterRef.VisibleIndex, TriIndex);
			PrimitiveAttributes Attributes = MakePrimitiveAttributes(NaniteView, VisibleCluster, PixelValue, bReverseWindingOrder, MaterialCBIndex);
			PrimitiveAttributesPacked AttributesPacked = PackPrimitiveAttributes(Attributes);
			MESH_SHADER_WRITE_PRIMITIVE(PrimExportIndex, AttributesPacked);
		}
 
		uint VertExportIndex = GroupThreadID;
		if (VertExportIndex < Cluster.NumVerts)
		{
			VSOut VertexOutput = CommonRasterizerVS(NaniteView, PrimitiveData, InstanceData, VisibleCluster, Cluster, LaneVertIndex, 0u, bReverseWindingOrder, MaterialCBIndex, ClusterRef.RasterBin);
			MESH_SHADER_WRITE_VERTEX(VertExportIndex, VertexOutput);
		}
 
	#if NANITE_MESH_SHADER_TG_SIZE == 128
		// ...(TG = 32 时不编译)
	#endif
	}
  • 三角形:VertIndexes 此时已是槽号,直接写进 OutTriangles。
  • 图元:PixelValue = PackVisPixelX(VisibleIndex, TriIndex) = ((VisibleIndex + 1) << 8) | TriIndex(NaniteDataDecode.ush:890)。低 8 位是 cluster 内三角形号,高 24 位存 VisibleIndex + 1,读回时减 1(UnpackVisPixelX,:895),值 0 因而不会和 0 号 cluster 混淆。它连同 view、VSM 信息一起打包写进 OutPrimitives。
  • 顶点:第 i 条 lane 用 LaneVertIndex(批内第 i 个唯一顶点的 cluster 本地号)调用 CommonRasterizerVS,结果写进槽 i。第 7 个参数 PixelValue 传 0u:mesh shader 排列下 VSOut 根本没有 PrimitivePacked 成员(NaniteRasterizer.usf:2494),CommonRasterizerVS 只在 !NANITE_MESH_SHADER 时才用这个参数(:2659)。VS 回退路径正相反,PixelValue 逐顶点传入(:3213),每个角各带一份。

6.5 逐图元属性的布局

PackPrimitiveAttributes(NaniteRasterizer.usf:2525)把 PrimitiveAttributes 压进一个 uint4,VSM 排列下:

分量内容
xPixelValue
yViewId(bit 0-15)· bSwapVW(bit 16)· MipLevel(bit 18-22)· ArrayIndex(bit 23 起)
zLevelOffset:目标 VSM 这一级 mip 在页表里的偏移
wvPage.x(13 bit)· vPage.y(13 bit)· 窗口宽高方向的页数差 vPageDelta(各 3 bit)

非 VSM 排列时 z / w 改存 16 bit 打包的 ViewRect;bindless 且材质像素可编程时,还会多一个 uint4 MaterialCBIndex。

6.6 CommonRasterizerVS:跑在 mesh shader 里的顶点着色器

// Engine/Shaders/Private/Nanite/NaniteRasterizer.usf:2617(节选)
VSOut CommonRasterizerVS(FNaniteView NaniteView, FPrimitiveSceneData PrimitiveData, FInstanceSceneData InstanceData, FVisibleCluster VisibleCluster, FCluster Cluster, uint VertIndex, uint PixelValue, bool bReverseWindingOrder, uint MaterialCBIndex, uint RasterBin)
{
	VSOut Out;
 
	FNanitePostDeformVertex InputVert = FetchAndDeformLocalNaniteVertex(PrimitiveData, InstanceData, GetInstanceViewData(InstanceData.InstanceId, NaniteView.SceneRendererPrimaryViewId), Cluster, VisibleCluster, VertIndex, NANITE_NUM_TEXCOORDS_TO_DECODE_HW_VS);
 
	float3 WorldPositionOffset = 0.0f;
	// ...(初始化 FMaterialShader)
	BRANCH
	if ( (VisibleCluster.Flags & NANITE_CULLING_FLAG_ENABLE_WPO) != 0 )
	{
		WorldPositionOffset = MaterialShader.EvaluateWorldPositionOffset();
	}
 
	float3 PointTranslatedWorld = DFTransformLocalToTranslatedWorld(InputVert.Position, InstanceData.LocalToWorld, NaniteView.PreViewTranslation).xyz + WorldPositionOffset;
	MaterialShader.ApplyFirstPersonTransform(PointTranslatedWorld);
	float4 PointClip = mul( float4( PointTranslatedWorld, 1 ), NaniteView.TranslatedWorldToClip );
	float2 UnwarpedClip = PointClip.xy;
 
#if VIRTUAL_TEXTURE_TARGET
	PointClip.xy = NaniteView.ClipSpaceScaleOffset.xy * PointClip.xy + NaniteView.ClipSpaceScaleOffset.zw * PointClip.w;
 
	// Offset 0,0 to be at vPage for a 0, VSM_PAGE_SIZE * VSM_RASTER_WINDOW_PAGES viewport.
	PointClip.xy += PointClip.w * ( float2(-2, 2) / VSM_RASTER_WINDOW_PAGES ) * VisibleCluster.vPage;
#else
	PointClip.xy = NaniteView.ClipSpaceScaleOffset.xy * PointClip.xy + NaniteView.ClipSpaceScaleOffset.zw * PointClip.w;
#endif
 
#if !NANITE_MESH_SHADER
	Out.PrimitivePacked = PackPrimitiveAttributes(MakePrimitiveAttributes(NaniteView, VisibleCluster, PixelValue, bReverseWindingOrder, MaterialCBIndex));
#endif
	// ...(TexCoords、Material Cache)
 
#if !PIXELSHADER
	Out.Position = PointClip;
 
#if STORE_CLIP_IN_INTERPOLATOR
	Out.Clip.xy = UnwarpedClip;
	Out.Clip.zw = PointClip.zw;
#endif
 
	const bool bNearClip = ((NaniteView.Flags & NANITE_VIEW_FLAG_NEAR_CLIP) != 0u);
	if (!bNearClip)
	{
		// Shader workaround to avoid HW depth clipping. Should be replaced with rasterizer state ideally.
		Out.Position.z = 0.5f * Out.Position.w;
	}
#endif
	// ...
	return Out;
}

它干的就是传统 VS 的活:从 ClusterPageData 解码顶点并做形变、算 WPO、变换到裁剪空间。VSM 分支的两行把顶点挪进 HW 光栅窗口,这是整条 VSM HW 路径里最容易看糊涂的地方。

图 3:VSM 的 HW 光栅窗口。剔除阶段把 cluster 覆盖的页矩形按 4×4 页切窗口;VS 把窗口平移到 512×512 视口原点;PS 加回 ViewRect.xy 得到虚拟纹素,再经页表落到物理页。

VSM 的 HW 光栅并不直接画 16k × 16k 的虚拟阴影图,视口只有 VSM_PAGE_SIZE × VSM_RASTER_WINDOW_PAGES = 128 × 4 = 512 像素见方(NaniteCullRaster.cpp:6105):

  1. 剔除阶段切窗口:HW cluster 覆盖的页矩形按 4×4 页一格切开,格子里只要有需要渲染的页,就单独发射一个可见 cluster,vPage / vPageEnd 记录格内有效页的包围框(NaniteClusterCulling.usf:890-935)。
  2. VS 平移:ClipSpaceScaleOffset 先把整张虚拟阴影图的裁剪空间放大到窗口尺度,倍率为 VirtualMaxResolutionXY / (PageSize × RasterWindowPages),mip 0 时为 16384 / 512 = 32(VirtualShadowMapArray.cpp:5147);再按 vPage 平移,让窗口左上角对准视口原点。
  3. PS 还原:PixelPos += ViewRect.xy(ViewRect.xy = vPage × VSM_PAGE_SIZE)把窗口内坐标还原成虚拟纹素坐标,然后才查页表(第七节)。

VS 阶段完全不碰物理页,虚拟到物理的重定向留给 PS 逐像素做。同一个 cluster 跨多个窗口时会被发射多次,顶点也跟着重复变换——批内去重管不到跨批次、跨窗口、跨 view 的重复。

VSOut 里装什么取决于排列。VSM 深度 pass、非可编程材质时只有两项:SV_Position,以及 Clip(TEXCOORD0,xy 是窗口平移前的裁剪坐标,zw 是裁剪空间的 z、w)。可编程像素材质再加 TexCoords 和重心坐标相关的量(按平台能力选 VertexID、SV_Barycentrics 或显式导出),开了全局裁剪平面还有 SV_ClipDistance(NaniteRasterizer.usf:2451-2523)。

末尾的 near clip 分支也值得一看。方向光 clipmap 的 VSM 视图 flag 起始就是 0(VirtualShadowMapArray.cpp:4964),局部光默认打开 NANITE_VIEW_FLAG_NEAR_CLIP(:5056)。不开 near clip 时 Position.z 被固定成 0.5 × w 来绕开硬件深度裁剪,真正写入的深度取自插值的 Clip.zw,写入前还要 saturate(NaniteWritePixel.ush:82)——近平面前方的遮挡物被压到最近深度,照样投影。


七、HWRasterizePS:片元落到 VSM 物理页

// Engine/Shaders/Private/Nanite/NaniteRasterizer.usf:3246(节选)
#if NANITE_MESH_SHADER	
MESH_SHADING_COMPATIBLE_PS_ENTRY_POINT
#endif
void HWRasterizePS(VSOut In
#if NANITE_MESH_SHADER	
	, PrimitiveAttributesPacked PrimitivePacked
#endif
#if MATERIAL_TWOSIDED
	, bool bFrontFace : SV_IsFrontFace
#endif
)
{
	PrimitiveAttributes Primitive;
#if NANITE_MESH_SHADER
	Primitive = UnpackPrimitiveAttributes(PrimitivePacked);
#else
	Primitive = UnpackPrimitiveAttributes(In.PrimitivePacked);
#endif
	// ...
#if NANITE_HW_RASTER_INTERPOLATE_DEPTH
	// ...
	float4 SvPosition = float4(In.Position.xy, In.Clip.z / In.Clip.w, In.Clip.w);
#else
	float4 SvPosition = In.Position;
#endif
 
	uint2 PixelPos = (uint2)SvPosition.xy;
 
#if VIRTUAL_TEXTURE_TARGET
	// ...(用 In.Clip.xy 重建窗口平移前的 SvPosition,供材质求值)
	PixelPos += Primitive.ViewRect.xy;
	if (all(PixelPos < Primitive.ViewRect.zw))
#else
	// In multi-view mode every view has its own scissor, so we have to scissor manually.
	if( all( (PixelPos >= Primitive.ViewRect.xy) & (PixelPos < Primitive.ViewRect.zw) ) )
#endif
	{
		// ...
		FVisBufferPixel Pixel = CreateVisBufferPixel( PixelPos, PixelValue, SvPosition.z );
		// ...
	#if VIRTUAL_TEXTURE_TARGET
		// ...
		if( !VirtualToPhysicalTexelForRendering( FVirtualSMLevelOffset::Unpack(LevelOffset), MipLevel, Pixel.Position, Pixel.PhysicalPosition.xy ) )
		{
			// Not committed or should not be rendered into
			return;
		}
 
		Pixel.PhysicalPosition.z = ArrayIndex;
	#endif
		// ...
	#if ENABLE_EARLY_Z_TEST
		BRANCH
		if( !QuadActiveAnyTrue( Pixel.EarlyDepthTest() ) )
		{
			return;
		}
	#endif
		// ...(可编程材质:Masked / PDO 求值)
		BRANCH
		if (MaterialMask >= 0)
		{
			Pixel.Write();
		}
	}
}

按执行顺序:

  1. 取逐图元属性:mesh shader 排列下它是 PS 的独立输入参数,VS 排列下从 VSOut 里取。D3D 上 MESH_SHADING_PER_PRIMITIVE_ATTRIBUTE 和 MESH_SHADING_COMPATIBLE_PS_ENTRY_POINT 都是空宏(Platform.ush:1551-1556);Vulkan 上前者展开成 PerPrimitiveEXT 修饰,后者给 PS 入口声明 SPV_EXT_mesh_shader 扩展和 capability(VulkanCommon.ush:141-142)。
  2. 深度来源:深度 pass(NANITE_HW_RASTER_INTERPOLATE_DEPTH = DEPTH_ONLY)用插值的 Clip.z / Clip.w,xy 仍取 SV_Position。源码注释给的理由:手动插值比让硬件准备 SV_Position 寄存器快得多,但在极细长的三角形上某些硬件有精度问题;折中方案是 xy 始终用 SV_Position 保证命中正确的像素,深度只在 HW 光栅更重的阴影 pass 里走插值,visibility buffer pass 连深度也用 SV_Position。
  3. 还原虚拟纹素 + 手动 scissor:VSM 下 PixelPos += ViewRect.xy,只需判上界;非 VSM 的多 view 模式下每个 view 有自己的 scissor,同样得在 shader 里判。
  4. 页表重定向:VirtualToPhysicalTexelForRendering(VirtualShadowMapPageAccessCommon.ush:316)用 VirtualTexelAddress >> VSM_LOG2_PAGE_SIZE 查页表,物理页号 × 128 再加页内偏移;页没有提交或本帧不该渲染就直接 return,不写深度。PhysicalPosition.z 选物理页池的数组层:静态缓存层或动态层。
  5. 软件 early-Z:只存在于可编程像素材质的排列(ENABLE_EARLY_Z_TEST = NANITE_PIXEL_PROGRAMMABLE,NaniteRasterizationCommon.ush:33),为的是在跑 Masked / PDO 材质之前先剔掉被挡住的像素。QuadActiveAnyTrue 让整个 quad 都没通过时才返回,材质求值用到的导数不受影响。
  6. 原子写深度:FVisBufferPixel::Write()。
// Engine/Shaders/Private/Nanite/NaniteWritePixel.ush:79
	void Write()
	{
		// When near clipping is disabled we need to move the geometry 
		Depth = saturate( Depth );
	
		const uint DepthInt = asuint( Depth );
 
#if VIRTUAL_TEXTURE_TARGET
		InterlockedMax( OutDepthBufferArray[ PhysicalPosition ], DepthInt );
#else
		WritePixel( OutVisBuffer64, Value, Position, DepthInt );
 
	#if VISUALIZE
		WritePixel( OutDbgBuffer64, VisualizeValues.x, Position, DepthInt );
	#endif
#endif	// VIRTUAL_TEXTURE_TARGET
	}

深度 saturate 之后是非负 float,按 uint 比较和按 float 比较同序,于是直接 asuint 做整数原子 max;reverse-Z 下值越大越近,InterlockedMax 本身就是深度测试。非 VSM 的 visibility buffer 写 64 bit:PackUlongType(uint2(PixelValue, DepthInt)) 在 D3D 上把深度放在高 32 位(D3DCommon.ush:122),一次 ImageInterlockedMaxUInt64 同时完成深度测试和三角形 ID 写入。


八、三条路径对照

传统 VS 管线Nanite VS 回退:HWRasterizeVSNanite MS:HWRasterizeMS
派发CPU 发 DrawIndexedDrawPrimitiveIndirect:每个 instance 固定 128 × 3 = 384 个顶点(NaniteDataDecode.ush:1183),InstanceCount = HW cluster 数DispatchIndirectMeshShader:线程组数 = HW 批次数
取几何IA 读 VB / IB空 vertex declaration;SV_InstanceID → FClusterRef,SV_VertexID / 3 → 三角形,余数 → 角SV_GroupID → FClusterRef,lane → 三角形 / 唯一顶点
顶点复用硬件 post-transform cache没有:每个角单独变换一次批内去重,每个唯一顶点变换一次
逐三角形数据复制到顶点,或用 GSPrimitiveAttributesPacked 作为顶点输出,每个角各带一份OutPrimitives,每个三角形一份
多余的工作—TriRange 之外的三角形也要跑 VS,输出 (0,0,0,1) 退化掉SetMeshOutputCounts 只声明有效数量
拓扑数量CPU 决定固定 384 顶点 / instance运行时由 shader 决定

VS 回退路径同样没有绑定 VB / IB(mesh shader 路径不设 vertex declaration,VS 路径用空的,NaniteCullRaster.cpp:3460),所以 mesh shader 相对它的收益不在「bindless」,而在三件事:原生逐图元属性、批内顶点去重、只导出有效几何。支持 primitive shader 的平台还有第三条硬件路径:同一个 HWRasterizeVS 编成 primitive shader(CFLAG_VertexToPrimitiveShader,NaniteCullRaster.cpp:1891),以 point list 拓扑派发,PC 的 D3D12 上不存在这条路径。

一句话概括 mesh shader:把取几何、变换顶点、定义拓扑、挂逐图元属性全揽进一个协作式线程组,产出一个 meshlet 的顶点数组 + 索引数组 + 逐图元数组,SetMeshOutputCounts 声明有效数量后交给固定功能光栅器。


九、坑点速记

  • SetMeshOutputCounts 之前不要提前 return,哪怕条件对整个线程组 uniform、规范也允许;Nanite 实测会 corruption,改用「无效组声明 0 个输出」的写法(§3.1)。
  • 5.8 的 HWRasterizeMS 是 32 线程的批次模式;源码里的 256 / 128 输出声明和 VertExportIndex += 128 分支不会被编译(§6.1)。
  • DispatchMesh 的线程组数 = HW 批次数,不是可见 cluster 数;wrap 之后多出来的组靠 bValidIndex 输出空 meshlet(§5.3)。
  • 顶点复用只在批内生效,同一顶点跨批次、跨 VSM 窗口、跨 view 都会重复变换(§6.3、§6.6)。
  • 逐图元属性用 uint4 与逐顶点属性隔开,防止编译器把两类属性打包进同一行(§3.3)。
  • 深度 pass 的深度来自插值的 Clip.zw;没有 near clip 的视图里 SV_Position.z 被固定为 0.5 × w,不能当深度用(§6.6、§七)。
  • PS 里的软件 early-Z 只存在于可编程像素材质的排列(§七)。
  • PixelValue 的高 24 位是 VisibleIndex + 1,低 8 位是三角形号,解包时记得减 1(§6.4)。

十、参考链接

规范与官方文档

🔗 DirectX-Specs:Mesh Shader — D3D12 mesh / amplification shader 规范:线程组与输出上限、SetMeshOutputCounts 的约束、DispatchMesh 的维度限制、逐图元属性与 PS 的匹配规则
🔗 Coming to DirectX 12 — Mesh Shaders and Amplification Shaders: Reinventing the Geometry Pipeline(Microsoft DirectX Blog,2019) — D3D12 引入 mesh / amplification shader 的动机与管线概览
🔗 Nanite Virtualized Geometry in Unreal Engine — Nanite 官方文档
🔗 Virtual Shadow Maps in Unreal Engine — VSM 官方文档,含 Nanite 几何渲染进 VSM 的 pass 结构

演讲与文章

🔗 Introduction to Turing Mesh Shaders(NVIDIA,2018) — meshlet 与 task / mesh 两级管线的入门讲解
🔗 A Deep Dive into Nanite Virtualized Geometry(Karis 等,SIGGRAPH 2021) — Nanite cluster、软硬件光栅分工与 visibility buffer 的设计出处

引擎源码索引(UE 5.8,相对路径)

主题相对路径关键符号
Mesh shader 宏(D3D)Engine/Shaders/Public/Platform/D3D/D3DCommon.ushMESH_SHADER_* (:90-110) · PackUlongType (:122)
逐图元属性修饰宏Engine/Shaders/Public/Platform.ush、Engine/Shaders/Public/Platform/Vulkan/VulkanCommon.ushMESH_SHADING_PER_PRIMITIVE_ATTRIBUTE (:1551 / :141)
HW 光栅 shaderEngine/Shaders/Private/Nanite/NaniteRasterizer.usfFetchClusterRef (:155) · ReverseWindingOrder (:221) · PrimitiveAttributesPacked (:2476) · VSOut (:2488) · CommonRasterizerVS (:2617) · HWRasterizeMS (:3024) · HWRasterizeVS (:3161) · HWRasterizePS (:3249)
批内去重、early-Z 开关Engine/Shaders/Private/Nanite/NaniteRasterizationCommon.ushENABLE_EARLY_Z_TEST (:33) · DeduplicateVertIndexes (:147)
深度 / visibility buffer 写入Engine/Shaders/Private/Nanite/NaniteWritePixel.ushWritePixel (:20) · FVisBufferPixel::Write (:79)
PixelValue 打包Engine/Shaders/Private/Nanite/NaniteDataDecode.ushPackVisPixelX (:890) · UnpackVisPixelX (:895)
分桶与间接参数Engine/Shaders/Private/Nanite/NaniteRasterBinning.usfRasterGroupFinalize (:182) · DecodeVertReuseBatchInfo (:292) · ExportRasterGroup (:406)
HW / SW 分流、VSM 窗口Engine/Shaders/Private/Nanite/NaniteClusterCulling.usfSmallEnoughToDraw (:287) · 窗口循环 (:890)
VSM 页表Engine/Shaders/Private/VirtualShadowMaps/VirtualShadowMapPageAccessCommon.ushVirtualToPhysicalTexelForRendering (:316)
VSM 常量Engine/Shaders/Shared/VirtualShadowMapDefinitions.hVSM_PAGE_SIZE (:14) · VSM_RASTER_WINDOW_PAGES (:21)
Nanite 常量Engine/Shaders/Shared/NaniteDefinitions.hNANITE_CONSTRAINED_CLUSTER_CACHE_SIZE (:19) · NANITE_MAX_CLUSTER_* (:21-26) · NANITE_RASTERIZER_ARG_COUNT (:292)
shader 配置与派发Engine/Source/Runtime/Renderer/Private/Nanite/NaniteCullRaster.cppr.Nanite.MinPixelsPerEdgeHW (:140) · FHWRasterizeMS (:2030) · DispatchIndirectMeshShader (:3663) · AddPass_Binning (:4895)
路径选择与 CVarEngine/Source/Runtime/Renderer/Private/Nanite/NaniteShared.cppr.Nanite.MeshShaderRasterization (:102) · UseMeshShader (:121) · GetRasterHardwarePath (:158)
批次构建Engine/Source/Developer/NaniteBuilder/Private/Encode/NaniteEncodeVertReuseBatch.cppBuildVertReuseBatches (:88)
索引局部性约束Engine/Source/Developer/NaniteBuilder/Private/Encode/NaniteEncodeConstrain.cppVerifyClusterConstraints (:316)
D3D12 能力探测Engine/Source/Runtime/D3D12RHI/Private/D3D12Adapter.cppGRHISupportsMeshShadersTier0/1 (:1372)
VSM 视图参数Engine/Source/Runtime/Renderer/Private/VirtualShadowMaps/VirtualShadowMapArray.cppclipmap 视图 flag (:4964) · 局部光视图 flag (:5056) · ClipSpaceScale (:5147)