My Nanite: 从零开始实现Nanite风格的网格渲染器

虚幻引擎5里最吸引人的新特性莫过于Nanite。它可以自动的生成和无缝切换网格LOD,处理海量三角形并且让游戏保持一个较高的帧率。

最近一个月,我在引擎中实现了Nanite中的大部分特性,包括无缝的DAG Cluster LOD切换,两次HZB剔除,基于MeshShader的Visibility流程等。一些用处较小的特性(软光栅化)、一些和引擎其他功能耦合比较重的特性(IO和压缩)则跳过。而Nanite中的Material Passes,则被替换成更加现代化的、基于Bindless的实现。本文介绍了实现过程中遇到的一些主要技术细节。

最终视频:https://www.bilibili.com/video/BV19THMedEuN

网格预处理

与新出的Mesh Shader管线的渲染流程相同,Nanite将临近的128个三角形组织成Cluster(或者称为Meshlet),每个网格都切割为多个Cluster。

LOD在Cluster Group级别上构建,Nanite描述了一种名为DAG Cluster Group LOD的层次结构,具体细节请查看:

https://www.youtube.com/watch?v=eviSykqSUUw

SylvesterHesp和jglrxavpok分别有一篇很棒的文章讲述了DAG Cluster Group LOD的构建流程。

https://blog.traverseresearch.nl/creating-a-directed-acyclic-graph-from-a-mesh-1329e57286e5

https://jglrxavpok.github.io/2024/01/19/recreating-nanite-lod-generation.html

本文的DAG Cluster Group LOD处理流程在jglrxavpok提到的流程基础上修改而来:

Cluster(Meshlet)划分、网格简化使用MeshOptimizer库,Cluster Group划分使用Metis库。

需要注意的是:网格简化时,输入的三角形列表要求拓扑连续,否则网格无法继续简化下去。

网格的三角形一般拓扑连续,但某些特殊的原因下(UV镜像,美术要卡硬边法线等),要分裂出额外的顶点,分裂出来的顶点会卡住MeshOptimizer的网格简化,导致分裂边附近的三角形将无法继续简化下去。

Image

这种情况属于正常现象。

还有一种在Blender中被称为Loose Geometry的网格,在编辑模式中拖拽其中一个三角形,可以看到它根本和其他三角形无任何链接关系。(下图左侧为Loose Geometry,右侧为修复后的网格)

Image

MeshOptimizer的网格简化对这种网格无能为力,不过修复起来也很快,仅需在编辑模式下选中所有的Loose Geometry,然后在网格级别让它们按照距离做融合即可。

Image

构建DAG Cluster Group LOD的几大步骤:

  1. 使用meshoptimizer,按每128个三角形一个Cluster的做法,将原始网格切割为N个Cluster。
  2. 使用Metis将相邻的2 - 4个Cluster划分为一个Cluster Group。
  3. 合并Cluster Group里面的所有Cluster的三角形,使用meshoptimizer简化合并得到的网格。(简化过程中锁住边界,简化率为50%的三角形)
  4. 使用meshoptimizer,按每128个三角形一个Cluster的做法,将简化后的网格切割为N/2个Cluster,若此时的Cluster数目 > 2,跳到第二步。

第二步中需要到对Cluster列表构建空间上的临接关系图表:

以每个Cluster作为图里的一个顶点,图里的边则是两个相邻Cluster之间三角形连接边(可能存在多条边)。

如下图所示,Cluster #0 与 Cluster #1 临接,它们之间的连接边(红色三角形边)有多个。

Image

关键点在于找到每条三角形边周围有哪些Meshlet(Vert),每个Meshlet(Vert)拥有哪些边,可以用两个Map表示,构建伪代码如下:

map<edge, set<meshlet>> edge2meshlets;
map<meshlet, set<edge>> meshlet2edges;

for (meshlet : meshlets)
{
    for (triangle : meshlet.triangles)
    {
        for (triangle_edge : triangle.edges)
        {
            uint v0 = hash_pos(triangle_edge.pos[0]);
            uint v1 = hash_pos(triangle_edge.pos[1]);
            
            // Edge为无向边,与顶点顺序无关,这样才能作为两个三角形的共享边
            edge.p0 = min(v0, v1);
            edge.p1 = max(v0, v1);
     
            edge2meshlets[edge].insert(meshlet);
			meshlet2edges[meshletIndex].insert(edge);
        }
    }
}

接下来将其转化为Metis要求的xadj和adjncy数组格式:

// xadj和adjncy的具体含义:
for (uint vert_a = 0; vert_a < vert_count; vert_a ++) // vert is meshlet
{
    for (uint adjVert = xadj[vert_a]; adjVert < xadj[vert_a + 1]; adjVert++)
    {
        uint vert_b = adjncy[adjVert];
        // Now get edge: (vert_a, vert_b)
    }
}

// 构建xadj和adjncy
vector<uint> adjncy;
vector<uint> xadj;
for (meshlet : meshlets)
{
    offset = adjncy.size();
	xadj.push_back(offset); // 新顶点
    
    for (edge : meshlet2edges[meshlet]) // 遍历meshlet里的所有边
    {
        for (adjMeshlet : edge2meshlets[edge])
        {
            if(adjMeshlet != meshlet) // 连接到其他顶点
            {
                if (not found adjMeshlet between adjncy[offset] and adjncy.end)
                {
                    adjncy.add(adjMeshlet);
                }
            }
        }
    }
}
xadj.push_back(adjncy.size()); // 结束边界

此时即可调用METIS_PartGraphKway函数做分组划分了。

在第三步中,网格时使用meshopt_SimplifyLockBorder标记让网格简化过程中保留边界。

使用meshopt_SimplifyErrorAbsolute标记确保网格的简化误差在世界空间内,target_error需要乘上meshopt_simplifyScale的结果转换到世界空间。(每次调用meshopt_simplify后,默认得到是相对于上一个层级网格的相对误差,如果连续生成多级LOD,那么还要小心处理它们的单位转换,直接使用绝对坐标误差标记可以省掉这个繁琐的转换问题。)

LOD选择

meshopt_simplify使用类似Quadric Error Metrics简化算法,得到的Error大致能代表简化后的顶点与原表面的距离。

将以Cluster Group网格中心为原点,Error为半径的误差小球投影到屏幕空间上,若其半径小于一个像素,此时可以认为切换为当前LOD是无损的。

Nanite还为每个Cluster Group存储了其父节点的Error(Parent Error),将Parent Error小球也投影到屏幕上,与ClusterGroup本身的Error一起搭配,即可唯一确定应该使用哪个级别LOD。

Image

我不太清除Nanite的ParentError/ClusterError投影方式是否有做啥特殊处理,用刚刚描述的做法,是无法确保ParentError永远大于ClusterError。

Image

如图所示,parent因为是多个Cluster集合而成,所以parent网格中心可能位于某个Cluster的网格中心之后,在近大远小的原则下,如果parent的error半径没有增长太多,很有可能出现projected parent error < projected cluster error的情况。

但即使如此,Nanite描述的公式依旧生效,出现projected parent error < projected cluster error的情况时,该LOD级别被剔除,我们直接使用下一级别的LOD即可。

此时能确保LOD的position是无缝切换,但是无法让法线和光照也无缝切换(仅考虑了位置相关的Error)

Image

上图中左侧为Unlit BaseColor,右侧为法线光照,可以发现左侧可以无缝切换,但是右侧光照明显突变。

幸运的是MeshOptimizer也支持属性权重的LOD简化(meshopt_simplifyWithAttributes),将UV/法线/切线的w分量也加入到网格的简化考虑因素中可以大大减少这种问题:

constexpr uint32 kAttributeCount = 9;
static const float attributeWeights[kAttributeCount] =
{
    0.05f, 0.05f, // uv
    0.5f, 0.5f, 0.5f, // normal
    0.001f, 0.001f, 0.001f, 0.05f // tangent, .w is sign, weight bigger.
};
meshopt_simplifyWithAttributes(...attributeWeights);

BVH加速遍历

Nanite这种DAG组织形式,导致切出来的Cluster非常多,如何快速遍历也是一个问题。Nanite提出使用BVH来加速遍历。

根据前面的描述,如果一个Cluster Group,若它的Projected Parent Error <= 一个像素,那么则可以将其剔除。

把前面切出来的所有的Cluster Group Parent Error小球都放在世界空间中,构建BVH树。

Image

如上图所示,(蓝色球)父节点的Sphere Projected永远都比它的子节点(蓝色球内的红色球)的Sphere Projected半径要大,所以若父节点的Projected Sphere Radius <= 一个像素,那么子节点的Parent Error是必定小于一个像素的,可以直接剔除。

但比起直接遍历全部的Cluster Group,在GPU上遍历一颗BVH属实怎么写都很慢。

Nanite使用一种Persistent Thread + Parallel Queue的GPU编程技巧来均衡BVH的遍历性能:申请足够多的线程,在一次大循环中不停的检视BVH Node和做Cluster可见性剔除,这里大概分享下我的实现思路。(原始实现)

globallycoherent RWStructuredBuffer<int> rwBVHUncheckedNodeCount; // 待检查的BVH节点数

// 待消费的BVH Noded队列,不可用的Node用~0填充,.x为object id, .y为bvh node id.
globallycoherent RWStructuredBuffer<uint2> rwNode; 
globallycoherent RWStructuredBuffer<int>   rwProduce;
// Instance Culling会填充RootNode, rwProduce

// 已经消费的BVH Node数目,默认为0
globallycoherent RWStructuredBuffer<int>   rwConsume; 

[64, 1, 1]
void main()
{
    uint bvhNodeCountNeedCheck;
    uint consumeBVHNodeId = ~0;
    
	while (true)
	{
        uint objectId = ~0;
    	uint nodeId   = ~0;
        
        // 检查中断条件
        InterlockedMax(rwBVHUncheckedNodeCount, 0, bvhNodeCountNeedCheck);
        bool bAllBVHNodeChecked = (bvhNodeCountNeedCheck == 0);
        if (bAllBVHNodeChecked) { break; }
         
        if (consumeBVHNodeId == ~0)
        {
            InterlockedAdd(rwConsume, 1, consumeBVHNodeId);
        }
        
        if (consumeBVHNodeId < kMaxNodeCount)
        {
            uint2 cmd = ~0;
            InterlockedExchange(rwNode[consumeBVHNodeId].x, ~0, cmd.x);
            InterlockedExchange(rwNode[consumeBVHNodeId].y, ~0, cmd.y);

            if (any(cmd != ~0))
            {
                // 自旋直到当前节点全部信息可用
                while (cmd.x == ~0) 
                { InterlockedExchange(rwNode[consumeBVHNodeId].x, ~0, cmd.x); }
                while (cmd.y == ~0) 
                { InterlockedExchange(rwNode[consumeBVHNodeId].y, ~0, cmd.y); }
                
                objectId = cmd.x;
                nodeId   = cmd.y;
                
                // 当前节点被消费,重置便于下次循环再次申请。
                consumeBVHNodeId  = ~0;
            }
        }

        if (objectId != ~0 && nodeId != ~0)
        {
            node = load(objectid, nodeid);
            bool bVisibile = culled(node);
            
            // 若不可见直接拒绝全部子节点
            InterlockedAdd(rwBVHUncheckedNodeCount, bVisibile ? -1 : -node.bvhNodeCount);
            
            if (bVisibile)
            {
                for (child : node.children)
                {
                    // 生成新的节点供其他线程消费
					InterlockedAdd(rwProduce, 1, i);
                    InterlockedExchange(rwNode[i].x, child.objectId);
					InterlockedExchange(rwNode[i].y, child.nodeId);
                }
                
                for (meshletGroup : node.meshletGroups)
                {
                    // 剔除和存储node里的meshletGroups.
                }
            }
        }
        else
        {
            // 空载...
        }
	}
}

这里需要小心使用各种原子操作,不然很容易就会死锁。

优化性能

(版本一)

均衡每个BVH子节点数目,默认每个父节点有8个子节点。

一个线程组中仅在第一个线程做Consume操作。如下图为例:第一次仅 Thread #0 有BVH Node需要计算:

Image

若其可见,则生产出8个子节点,我们存到GroupShared内存里,下次循环中分配给8个线程:

Image

再次可见的子节点最多生产出64个,正好占满全部的线程:

Image

此时,如果再次可见的子节点溢出64个,才需要写入到Device内存中,让其它空转的线程组也发动起来。

这样能减少大量的原子操作和Device内存通讯,增加不少性能。

(版本二)

此时仍会有负载不均衡的情况,在没有BVH Node消费时,线程在空转。

增加一个Meshlet Culling的队列,我们不会在Node遍历时做计算繁重的Meshlet剔除,而是直接放到Device内存里让其它空转的线程来消费:

// 待消费的Meshlet队列,默认~0填充,.x为object id, .y为meshlet id.
globallycoherent RWStructuredBuffer<uint2> rwNode; 
globallycoherent RWStructuredBuffer<int>   rwProduce;
globallycoherent RWStructuredBuffer<int>   rwConsume; 

// ...

if (objectId != ~0 && nodeId != ~0)
{
    if (bVisibile)
    {
		// ...
		
        for (meshletGroup : node.meshletGroups)
        {
            // 生成新的Meshlet供其他线程消费
            InterlockedAdd(rwProduce, 1, i);
            InterlockedExchange(rwNode[i].x, meshletGroup.objectId);
            InterlockedExchange(rwNode[i].y, meshletGroup.meshletid);
        }
    }
}
else
{
	// 消费Meshlet Node并做剔除计算。
	InterlockedAdd(rwConsume, 1, consumeMeshletId);
	
	// Load and compute visibility...
}

基本消除了大部分空载。

这里可以用一些Wave指令来减少原子操作,比如仅在第一个Lane做消费操作,再把id广播到其它Lane。

虽然做了这么多操作,但性能说实话一般233(Bistro场景,3070Ti):

Image

两次HZB剔除

与传统的HZB剔除(延迟一帧回读可见性)不同,第一次使用Prev HZB做剔除时,一定要用上一帧的MVP矩阵:

Image

这样可以避免相机靠近物体时,Prev HZB无法挡住不断变大的物体,Stage#0额外多画了一些不必要的网格。

Tips#0: HZB构建使用AMD SPD相同的技巧,在一次Pass中完成全部级别的Mip。

Tips#1: 如果没有使用Invert Z,并且HZB的格式是16Bit的,需要手动对32Bit的深度做Ceil,才能保证结果正确:

Image

Visbility Buffer

与Nanite相同,使用Mesh Shader做Visibility Buffer生成,这样可以减少不必要的Buffer Write/Store,同时也不用维护一个未知大小的Index Buffer和Vertex Buffer。

在Mesh Shader中可以做一些三角形级别的剔除:

Back face剔除:

// Olano97: Triangle Scan Conversion using 2D Homogeneous Coordinates
bCulled = determinant(float3x3(positionHS_0.xyw, positionHS_1.xyw, positionHS_2.xyw)) <= 0;

Near plane剔除:

bCulled = (positionHS_0.w <= 0 && positionHS_1.w<= 0 && positionHS_2.w <= 0);

视锥剔除:

// Now cast to screen space.
const float2 uv_0 = positionHS_0.xy / abs(positionHS_0.w) * float2(0.5, -0.5) + 0.5;
const float2 uv_1 = positionHS_1.xy / abs(positionHS_1.w) * float2(0.5, -0.5) + 0.5;
const float2 uv_2 = positionHS_2.xy / abs(positionHS_2.w) * float2(0.5, -0.5) + 0.5; 

const float2 maxUv = max(uv_0, max(uv_1, uv_2));
const float2 minUv = min(uv_0, min(uv_1, uv_2));

if (!bCulled)
{
    bCulled = any(minUv >= 1) || any(maxUv <= 0);
}

小三角形剔除(没有位于任意一个着色中心点):

if (!bCulled)
{
    const float2 maxScreenPosition = maxUv * perView.renderDimension.xy;
    const float2 minScreenPosition = minUv * perView.renderDimension.xy;
    bCulled = any(round(minScreenPosition) == round(maxScreenPosition));
}

Visibility Buffer仅需存储 8Bit的三角形Id, 24 Bit的ClusterId,共计花费32Bit。

Material Pass (Emit GBuffer)

按照8x8大小划分材质Tile,逐材质注入GBuffer。

使用变体隔开不同的材质,每个材质一次Dispatch,这样能最大化计算时的UAV Overlap。

由于仅存储了VisibilityBuffer,此时需要重新加载三角形计算其重心坐标与偏导数,手动插值:

struct Barycentrics
{
    float3 interpolation;
    float3 ddx;
    float3 ddy;
};

// From Unreal Engine 5 Nanite.
// Improved perspective correct barycentric coordinates and partial derivatives using screen derivatives.
Barycentrics calculateTriangleBarycentrics(
    float2 PixelClip, 
    float4 PointClip0, 
    float4 PointClip1, 
    float4 PointClip2, 
    float2 ViewInvSize)
{
	Barycentrics barycentrics;

	const float3 RcpW = rcp(float3(PointClip0.w, PointClip1.w, PointClip2.w));
	const float3 Pos0 = PointClip0.xyz * RcpW.x;
	const float3 Pos1 = PointClip1.xyz * RcpW.y;
	const float3 Pos2 = PointClip2.xyz * RcpW.z;

	const float3 Pos120X = float3(Pos1.x, Pos2.x, Pos0.x);
	const float3 Pos120Y = float3(Pos1.y, Pos2.y, Pos0.y);
	const float3 Pos201X = float3(Pos2.x, Pos0.x, Pos1.x);
	const float3 Pos201Y = float3(Pos2.y, Pos0.y, Pos1.y);

	const float3 C_dx = Pos201Y - Pos120Y;
	const float3 C_dy = Pos120X - Pos201X;

	const float3 C = C_dx * (PixelClip.x - Pos120X) + C_dy * (PixelClip.y - Pos120Y);	
	const float3 G = C * RcpW;
	const float H = dot(C, RcpW);
	const float RcpH = rcp(H);

	barycentrics.interpolation = G * RcpH;

	const float3 G_dx = C_dx * RcpW;
	const float3 G_dy = C_dy * RcpW;
	const float H_dx = dot(C_dx, RcpW);
	const float H_dy = dot(C_dy, RcpW);

	barycentrics.ddx = (G_dx * H - G * H_dx) * (RcpH * RcpH) * ( 2.0f * ViewInvSize.x);
	barycentrics.ddy = (G_dy * H - G * H_dy) * (RcpH * RcpH) * (-2.0f * ViewInvSize.y);

	return barycentrics;
}

// Usage:
/*
float2 meshUv = 
	triangleInfo.uv[0] * barycentric.x + 
	triangleInfo.uv[1] * barycentric.y + 
	triangleInfo.uv[2] * barycentric.z;
	
float2 meshUv_ddx = 
	triangleInfo.uv[0] * ddx.x + 
	triangleInfo.uv[1] * ddx.y + 
	triangleInfo.uv[2] * ddx.z;
	
float2 meshUv_ddy = 
	triangleInfo.uv[0] * ddy.x + 
	triangleInfo.uv[1] * ddy.y + 
	triangleInfo.uv[2] * ddy.z;
	
baseColor = texture.SampleGrad(sampler, meshUv, meshUv_ddx, meshUv_ddy);
*/

优化性能

可视化场景中的三角形:

Image

一种直观的想法是:

有些Wave内使用同一个三角形,因此,加载三角形时是否可以复用其它Lane的三角形信息,减少带宽消耗?

TriangleMiscInfo triangleInfo;

#if ENABLE_WAVE_LOCAL_SHUFFLE
const uint currentLaneIndex = WaveGetLaneIndex();
uint targetLaneIndex;

uint activeMask = WaveActiveBallot(true).x;
while (activeMask != 0)
{
    targetLaneIndex = firstbitlow(activeMask);
    if (targetLaneIndex >= currentLaneIndex)
    {
        break; // Reach edge. 
    }

    // WaveReadLaneAt in loop never work.
    const uint targetPackId = sharedPackId[sharedIdOffset + targetLaneIndex];
    if (targetPackId == packId)
    {
        break; // Found reuse.
    }

    // Step next.
    activeMask ^= (1U << targetLaneIndex);
}

[branch]
if (currentLaneIndex == targetLaneIndex)
{
    // Only Active lane load triangle info.
    getTriangleMiscInfo(...);
}

[unroll(3)] 
for (uint i = 0; i < 3; i ++)
{
    triangleInfo.uv[i] = WaveReadLaneAt(triangleInfo.uv[i], targetLaneIndex);
    triangleInfo.p[i]  = WaveReadLaneAt(triangleInfo.p[i],  targetLaneIndex);
    triangleInfo.t[i]  = WaveReadLaneAt(triangleInfo.t[i],  targetLaneIndex);
    triangleInfo.bi[i] = WaveReadLaneAt(triangleInfo.bi[i], targetLaneIndex);
    triangleInfo.n[i]  = WaveReadLaneAt(triangleInfo.n[i],  targetLaneIndex);
}
#else 
getTriangleMiscInfo(...);
#endif

用红色代表加载并计算三角形的Lane,绿色代表复用的Lane,可视化如下:

Image

大部分像素都是可复用优化的。此时统计性能比较(Bistro ~4k 3070Ti):

Image

性能反而更慢了。

Image

虽然用branch选择性的加载三角形减少了整体的带宽消耗,但Wave整体执行速度取决于最慢的Lane什么时候完成三角形加载,在增加了Reuse后,最慢的Lane原本的任务并没有减少,还多了一些Additional Work。

另外一种想法是:

一条线程处理屏幕上连续的2x2像素的任务,缓存三角形信息,仅在需要加载的时候才加载三角形:

Image

这样做有个好处是每条Lane都大概率能复用2-3次三角形,综合到Wave最好情况能在2x2像素内仅加载一次三角形。

除此之外,还可以在lane需要加载三角形时从WaveFirstActiveLane抽奖:

Image

可视化每条线程的三角形加载数目如下:大部分线程在2x2范围内仅加载一次三角形。

Image

性能(Bistro ~4k 3070Ti):

Image

最终效果

无缝LOD过渡:

Image

Meshlet LOD过渡可视化:

Image

三角形过渡可视化:

Image

© - 2024 · 月光下的旅行。 禁止转载