Nanite核心基础- Visibility Buffer Rendering(翻译)

PharaohStory
发布于

最近在实现自己的Nanite, 由于Nanite涉及到了使用VisibilityBuffer, 学习之余顺便翻译本文为广大朋友学习使用。

完成本文阅读之后,你将会有一个清晰的概念,为什么Nanite的Software Rasterization会比传统的Hardware快。你同时也会知道产生Overdraw的原因,以及如何规避。


Maple-Engine Fake-Nanite测试

文章源地址: http://filmicworlds.com/blog/visibility-buffer-rendering-with-material-graphs/

Maple-Engine Fake-Nanite测试: https://www.bilibili.com/video/BV1rc411F7jf/

介绍

在当前的3A游戏开发中,为了追求更好的画面,当前我们使用的模型的精度越来越高,这也意味着三角形的面数的增加。 但是问题在于,随着三角形面数的增加,三角形本身的面积也越来越小,在某些情况下三角形的面积可能小于像素值,因此我们在使用传统的Deferred/Forward 的方式渲染时,可能出现大量的Over-Draw. 从Unreal5的实现上来看,使用VisibilityBuffer方式渲染小三角形的效率远远的高于传统方式。因此通过本文的实践来验证其猜想。

Overview

可见性缓冲区的想法其实非常简单。 渲染第一趟,我们生成一个VisibilityBuffer,它将InstanceId Triangle ID 存储在单个UInt32值中。 然后,您只需使用该 InstanceId Triangle ID  即可获取所需的任何参数。

UInt32 value = InstanceId << BIT_OF_TRIANGLES | TriangleId;

VisibilityBuffer最初是由论文 The Visibility Buffer: A Cache-Friendly Approach to Deferred Shading. Christopher Burns and Warren Hunt. 提出. 除了创造“VisibilityBuffer”这个术语之外,它们还是我能找到的第一个用于存储三角形 ID 和重建插值顶点数据的参考资料。在该论文中,为了处理多种材质,他们将屏幕分割成Tile,并对其中的像素进行分类。 然后,他们将为每种材质渲染一次DrawCall,仅覆盖接触所需像素的Tile。同时VisibilityBuffer也被用来以其他方式优化渲染,例如Deferred Attribute Interpolation for Memory-Efficient Deferred Shading. Cristoph Schied and Carsten Dachsbacher ,他们将问题作为多重采样压缩算法来解决

工业界最新的解决方案来自 Epic Games 的 Unreal 5 中的 Nanite。 他们处理问题的方式与过去不同。因为他们没有使用VisibilityBuffer来替代 GBuffer,而是使用VisibilityBuffer作为优化来更有效地创建 GBuffer。 特别是,GPU 光栅化器在处理小三角形时性能效率低下,因此 Nanite 使用自定义光栅化器来绕过这些瓶颈,正如您在其概述视频中看到的那样 。 请注意,您可以跳到 1:00:45 来快速讨论三角形大小。 UE5 可见性光栅器是仅限 Nanite 的光栅器,因此其他对象会经过标准的Deferred Pass。

理论上来说,使用VisibilityBuffer不需要 GBuffer。 如果您需要法线,您始终可以直接从顶点参数中获取它。 如果您再次需要它,您可以再次获取它。 但实际上, Material Graph已经变得相当复杂。 如果我们需要的只是直接照明,那么我们可以不用 GBuffer。 但由于我们需要每帧多次访问Normal/WorldPos/Material(直接照明、屏幕空间反射、环境探测器等),因此将材质输出存储在 GBuffer 中似乎是一种可行的方法。

在这里提出的变体中,VisibilityBuffer将用于生成 GBuffer,我们将它用于所有三角形。 我们还将使用任意Material Graph来完成此操作,这意味着计算我们自己的偏导数(partial derivatives)。 在进行这些测试时,我试图找到几个问题的答案:

1. 我们可以用Material Graph有效地计算偏导数吗?

  • 换句话说,这种方法到底可行吗? 如果我们无法计算偏导数,那么这种方法就行不通。

2. 对于非常高的三角形数量(每个三角形 1 个像素),可见性方法是否更快?

  • 对于未来的工作负载来说,这种方法是否更快?
  • 如果我们想要达到电影级画质,最终我们需要将所有三角形的大小减小到 1 像素。 背景、人物、草地、道具,一切。

3. 更典型的三角形大小(每个三角形 5-10 像素)怎么样? 那里的可见性方法也更快吗?

  • 对于当前 AAA 游戏工作负载来说,这种方法是否更快?

在下面的测试中,所有三个问题的答案都是:是的! 但需要注意的是,这是一个玩具引擎,而不是真正的 AAA 引擎。

Forward/Deferred/Visibility Overview

首先,我们应该快速概述一下Forward、Deferred和Visibility Rendering。 在Forward中,将在单个像素着色器中计算所有内容,如下所示。

struct Interpolators; // the position, normal, uvs, etc.
struct BrdfData; // normal mapped normal, albedo color, roughness, metalness, etc.
struct LightData; // the output lighting data, usually just a float3

// Pass 0: Render all meshes, output final light color
LightData MainPS(Interpolators interp)
{
  BrdfData  brdfData  = MaterialEval(interp);
  LightData lightData = LightingEval(brdfData);
  return lightData;
}

从根本上讲,每个基于物理的前向着色器都从代码中未包含的隐藏步骤开始:插值顶点。 硬件对顶点数据进行插值,并神奇地为您传递插值后的顶点数据。 当然,硬件并不神奇,但该步骤确实发生在片段着色器代码开始执行之前。 在下一步中,MaterialEval() 函数将采用插值(如 UV、法线和切线)来执行数学和纹理查找,以计算表面材质参数。 这些通常包括法线贴图法线、颜色等。在最后一步中,它会评估每个灯光对每个片段的影响,然后在输出光照结果。

然而,如今游戏中更常见的渲染方法是延迟,使用 GBuffer,分两次渲染。

// Pass 0: Render all meshes, output material data.
BrdfData MaterialPS(Interpolators interp)
{
  BrdfData  brdfData  = MaterialEval(interp);
  return brdfData;
}

// Pass 1: Compute shader (or large quad) to calculate lighting.
LightData LightingCS(float2 screenPos)
{
  BrdfData  brdfData  = FetchMaterial(screenPos);
  LightData lightData = LightingEval(brdfData);
  return lightData;
}

Deferred使用与Forward方法相同的基本步骤。 然而,Deferred Render的光照信息是在单独的Pass(全屏四边形或计算着色器)中进行计算。 好处是:

  1. 最大的优点是保证每个像素的光照恰好运行一次。 光栅化几何体时,MaterialPS() 可能每个像素执行多次,但 LightingCS() 保证只运行一次
  2. 当 MaterialEval() 和 LightingEval() 函数位于同一个着色器中时,它们会使用两者的最坏情况寄存器分配进行编译,而当它们被拆分时,一个通道可以使用比另一个通道更少的寄存器(并实现更好的占用)。
  3. 通过Deferred Rendering,我们可以得到一个 GBuffer,并且可用于其他效果,例如SSR、SSGI、SSAO 和次表面散射等。

明显的缺点是Deferred会增加带宽的使用量。 一般来说,您可以实现的屏幕空间效果和改进的着色器性能大大超过了带宽和内存成本。 当然,如果您绝对需要 MSAA,那么所有这些都将被抛之脑后,但这完全是另一回事了。

Visibility Rendering 采用了一种非常不同的方法。 Visibility Buffer仅保存光栅化之后的TriangleID和InstanceID,而不是光栅化光照颜色(如Forward)或 GBuffer 数据(如Deferred)。 我们还可以存储重心坐标或导数,但在这里我们将使用单个三角形的ID。


// Pass 0: Rasterize all meshes, just output thin visibility
U32 VisibilityPS(U32 drawCallId, U32 triangleId)
{
  return (drawCallId << NUM_TRIANGLE_BITS) | triangleId;
}

// Pass 1: In a CS convert from triangle ID to BRDF data
BrdfData MaterialCS(float2 screenPos)
{
  U32 drawCallId = FetchVisibility() >> NUM_TRIANGLE_BITS;
  U32 triangleId = FetchVisibility() &      TRIANGLE_MASK;

  Interpolators interp    = FetchInterpolators(drawCallId, triangleId);
  BrdfData      brdfData  = MaterialEval(interp);
  return brdfData;
}

// Pass 2: In a CS, fetch BRDF data and calculate lighting
LightData LightingCS(float2 screenPos)
{
  BrdfData      brdfData  = FetchMaterial(screenPos);
  LightData     lightData = LightingEval(brdfData);
  return lightData;
}

请注意,我们对材质和光照步骤有一个单独的Pass,这与大多数现有技术不同。 之前的大多数论文都同时执行这两个步骤,以减少 GBuffer 带宽。 但考虑到材质着色器的复杂性,我的观点是我们需要一个 GBuffer。

但在这种情况下,我们如何处理多种材质,特别是Material Graph? 我们将使用下面的流程图对颜色进行分类。

  1. 第一步,我们渲染全屏VisibilityBuffer。
  2. 遍历每个像素,并计算每种材质使用的像素数。 将结果存储在材料计数缓冲区中。
  3. 执行前缀总和以计算出材料开始。
  4. 再次通过可见性缓冲区,将每个像素的 XY 位置存储在像素 XY 缓冲区中的适当位置。 请注意,像素 XY 缓冲区具有与可见性缓冲区相同数量的元素。
  5. 对于每种材质,运行间接计算着色器来计算 GBuffer 数据。

这就是Pass的排序方式,它允许我们使用不同的生成的 HLSL 代码渲染多个材质图。 然而,在计算 GBuffer 数据时还有一个问题需要解决:偏导数(Partial Derivatives)。

Hardware Partial Derivatives

如果您编写了片段着色器,那么在某些时候您肯定已经编写了一行来读取纹理。


Sampler2D sampler;
Texture2D texture;
...
float2 uv = SomeUv();
float4 value = texture.Sample(sampler,uv);

当您运行此代码时,GPU 将计算出最佳的 mipmap 级别以供读取和过滤数据。 但是它如何计算出正确的 mipmap 级别呢?

其中的关键在于片段着色器通常不会在单个像素上运行。 相反,它们在 2x2 像素组(称为四边形)上运行。 在下面的紫色示例中,三角形覆盖了所有 4 个像素,所有 4 个像素同步运行相同的着色器,并且在纹理读取期间,GPU 将比较 4 个 uv 值以确定 mipmap 级别。 GPU 可以估计偏导数 dx 通过从右侧像素减去左侧像素以及关于 x 的偏导数来实现。 dy 则是从底部减去顶部。 然后它可以根据该差异的 log2 确定适当的级别。 这种方法称为有限差分。

但是,如果一个三角形没有覆盖所有 4 个像素,例如下面的这个三角形仅覆盖 3 个像素,会发生什么情况? 在这种情况下,GPU 会将三角形外推到缺失的像素上,并像平常一样运行它。 实际运行的 3 个像素称为“活动通道”,仅运行以向其他三个像素提供衍生品的 1 个像素称为“辅助通道”。

有关更多信息,请参阅 HLSL Shader Model  6.0  Wave intrinsics 文档。 事实上,有一些内在函数可以在同一个 2x2 四边形中的其他像素之间传递数据。 但是,如果多个三角形重叠同一个 2x2 四边形,会发生什么情况呢?

在此示例中,3 个不同的三角形覆盖 2x2 网格中的示例中心。 首先,绿色三角形覆盖了左上角。 为了渲染这一点,GPU 会将左上方像素渲染为活动通道,而其他三个像素将着色为辅助通道,为一个活动通道提供纹理导数。


接下来,蓝色三角形将有 2 个活动通道和 2 个辅助通道。

最后,红色三角形将有 1 个活动通道和 3 个辅助通道。

当我们有 1 个三角形覆盖四边形中的所有 4 个像素时,片段着色器工作负载如下所示:

但是,当我们有 3 个三角形覆盖四边形中的 4 个像素时,片段着色器工作负载如下所示:

如果我们有 3 个三角形覆盖同一个 2x2 四边形,那么相对于覆盖所有 4 个像素的单个三角形,我们实际上需要执行 3 倍的像素着色器工作。 活动通道除以总通道的比率就是四边形利用率。 紫色四边形的四边形利用率为 100%,但这三个三角形的工作负载的四边形利用率为 33%。 影响GPU利用率的主要因素是什么? 三角形大小。

四边形利用效率

鉴于此,假设我们仅渲染 1 像素的三角形。 即使没有over draw,每个 2x2 四边形也会有 1 个活动通道和 3 个辅助通道。

如果我们要使用 1 像素三角形渲染整个场景,则必须对每个像素执行每个片段着色器 4 次,而不是只执行一次。 Forward和Deferred材质着色器将为每个像素运行 4 次,而Deferred光照和Visibility材质和光照Pass只会为每个像素运行一次。

对于 1 像素大小的三角形,每个像素的着色器函数调用数:

另外一个极端情况,如果我们有很大很大的三角形会发生什么? 在这种情况下就简单多了。 辅助车道的数量将只占渲染的总像素的一小部分,出于本文的目的,我们可以将其称为偶然的。片段着色器大约每个像素运行一次。


大三角形每个像素的着色器函数调用次数大致如下

但这些都是极端情况,但中间会发生什么? 中间就比较复杂了。 传统观点是每个三角形的目标是大约 10 个像素。 10 像素三角形的四边形利用率是多少? 它会因形状而异,但让我们尝试一些并找出答案。 我们将从最简单的 10 像素三角形开始。

乍一看,它看起来确实不错,因为有 10 个活动通道,只有 2 个辅助通道。 但是,该三角形可以通过 4 种可能的方式与 2x2 网格对齐。

如果进行计数,平均而言,最终会在 10 个活动通道中获得 9 个辅助通道。 接下来,我们尝试讨论更加复杂的情况。

在这种形状下,我们平均有 11 个辅助通道和 10 个活动通道。 这是最坏情况的形状


如果我没数错的话,那就是 21 条辅助通道和 10 条活跃通道。 现在,这是一个极端的情况,因为三角形必须完美对齐才能形成这样的形状。 作为合理的估计,如果我们说第一个(青色)和第二个(橙色)三角形同样发生,而第三个(紫色)永远不会发生,那么我们将达到 50% 的四边形利用率。 换句话说,Forward和Deferred材质通道将每像素运行大约 2 次。 Deferred Shading和Visibility 材质和光照将再次为每个像素运行一次。

乍一看,与延迟渲染相比,可见性渲染突然看起来非常引人注目。 对于 10 个像素大小的三角形,延迟材质通道的运行次数必须是可见性材质通道的 2 倍,如果三角形为 1 个像素,则运行次数将变为 4 倍。 然而,Visibility Pass还有额外的工作要做。

插值和解析偏导数

尽管延迟方法依赖于硬件将插值器传递给片段着色器,但我们必须自己获取并插值这些数据。 第一步是获取数据,这相对简单。 g_dcElemData 是绘制调用元素数据,它是一个 StructuredBuffer,其中包含重要的每个实例数据,例如顶点缓冲区的起始位置。


uint3 FetchTriangleIndices(uint dcElemIndex, uint primId)
{
  TriangleIndecis ret = (TriangleIndecis)0;
  uint startIndex = g_dcElemData[dcElemIndex].m_visStart_index_pos_geo_materialId.x;
  return g_visIndexBuffer.Load3(startIndex + 3 * 4 * primId);
}

TrianglePos FetchTrianglePos(uint dcElemIndex, TriangleIndecis triIndices)
{
  uint startPos = g_dcElemData[dcElemIndex].m_visStart_index_pos_geo_materialId.y;
  
  TrianglePos triPos = (TrianglePos)0;
  triPos.m_pos0.xyz = asfloat(g_visPosBuffer.Load3(startPos + 12 * triIndices.m_idx0));
  triPos.m_pos1.xyz = asfloat(g_visPosBuffer.Load3(startPos + 12 * triIndices.m_idx1));
  triPos.m_pos2.xyz = asfloat(g_visPosBuffer.Load3(startPos + 12 * triIndices.m_idx2));
  return triPos;
}

指令并不多,但影响性能的是等待数据即I/O。 获取 UV 和普通数据非常相似,因此我们将在此处跳过列出它。 下一步是计算重心坐标。  论文Deferred Attribute Interpolation for Memory-Efficient Deferred Shading. Cristoph Schied and Carsten Dachsbacher. 在附录 A 中有一个非常方便的公式可供参考。

struct BarycentricDeriv
{
  float3 m_lambda;
  float3 m_ddx;
  float3 m_ddy;
};

BarycentricDeriv CalcFullBary(float4 pt0, float4 pt1, float4 pt2, float2 pixelNdc, float2 winSize)
{
  BarycentricDeriv ret = (BarycentricDeriv)0;

  float3 invW = rcp(float3(pt0.w, pt1.w, pt2.w));

  float2 ndc0 = pt0.xy * invW.x;
  float2 ndc1 = pt1.xy * invW.y;
  float2 ndc2 = pt2.xy * invW.z;

  float invDet = rcp(determinant(float2x2(ndc2 - ndc1, ndc0 - ndc1)));
  ret.m_ddx = float3(ndc1.y - ndc2.y, ndc2.y - ndc0.y, ndc0.y - ndc1.y) * invDet * invW;
  ret.m_ddy = float3(ndc2.x - ndc1.x, ndc0.x - ndc2.x, ndc1.x - ndc0.x) * invDet * invW;
  float ddxSum = dot(ret.m_ddx, float3(1,1,1));
  float ddySum = dot(ret.m_ddy, float3(1,1,1));

  float2 deltaVec = pixelNdc - ndc0;
  float interpInvW = invW.x + deltaVec.x*ddxSum + deltaVec.y*ddySum;
  float interpW = rcp(interpInvW);

  ret.m_lambda.x = interpW * (invW[0] + deltaVec.x*ret.m_ddx.x + deltaVec.y*ret.m_ddy.x);
  ret.m_lambda.y = interpW * (0.0f    + deltaVec.x*ret.m_ddx.y + deltaVec.y*ret.m_ddy.y);
  ret.m_lambda.z = interpW * (0.0f    + deltaVec.x*ret.m_ddx.z + deltaVec.y*ret.m_ddy.z);

  ret.m_ddx *= (2.0f/winSize.x);
  ret.m_ddy *= (2.0f/winSize.y);
  ddxSum    *= (2.0f/winSize.x);
  ddySum    *= (2.0f/winSize.y);

  ret.m_ddy *= -1.0f;
  ddySum    *= -1.0f;

  float interpW_ddx = 1.0f / (interpInvW + ddxSum);
  float interpW_ddy = 1.0f / (interpInvW + ddySum);

  ret.m_ddx = interpW_ddx*(ret.m_lambda*interpInvW + ret.m_ddx) - ret.m_lambda;
  ret.m_ddy = interpW_ddy*(ret.m_lambda*interpInvW + ret.m_ddy) - ret.m_lambda;  

  return ret;
}

输入点位于齐次裁剪坐标(就在 MVP 变换之后)。注意,我们计算了重心 w.r.t.x 和w.r.t.y。 重心坐标 (m_lambda) 通过透视校正插值确定。 最后,重心导数按 2/winSize 缩放,将比例从 NDC 单位(-1 到 1)更改为像素单位。 最后,m_ddy 被翻转,因为 NDC 是从下到上的,而窗口坐标是从上到下的。

一旦找到重心和重心的偏导数,从顶点插入任何属性就很容易了。 给定三个浮点数,该函数返回插值的三元组,即导数。w.r.t.x和w.r.t.y。


float3 InterpolateWithDeriv(BarycentricDeriv deriv, float v0, float v1, float v2)
{
  float3 mergedV = float3(v0, v1, v2);
  float3 ret;
  ret.x = dot(mergedV, deriv.m_lambda);
  ret.y = dot(mergedV, deriv.m_ddx);
  ret.z = dot(mergedV, deriv.m_ddy);
  return ret;
}

最后,我们使用 SampleGrad() 对纹理进行采样,显式传入 uv 导数。

性能测试

为了进行测试,我将单个模型与高度图放在一起,并将其复制到 5x3 网格中。 相机下方还有几个网格投射阴影。 阴影深度通道的效率非常低,因为它会强制渲染大量三角形并获取其深度。 此外,所有Command都在Graphics Queue上运行,以最大限度地减少重叠并获得一致的结果。


主要地面是 5x3 的高度图网格。 它们不是细分的高度图。 相反,有一个生成网格点的预处理步骤,然后像常规网格一样对其进行处理。 我的想法是,我想控制三角形的近似密度,但我也想要一点过度绘制,以与实际用例有一些相似之处。 从这个角度看,绘制调用如下所示:

通过此设置,我们可以保持相机固定,然后更改这些网格的分辨率,并且随着三角形计数的增加,它使我们能够大致了解Forward、Deferred和Visibility之间的性能差异。

对于材质着色器,我想要与游戏实际使用的大致相似的东西。 在测试模型中,通常使用简单的 PBR 纹理来进行快速的颜色、法线、镜面反射查找并直接使用它们。

少量三角形

对于第一个测试,我们将查看大三角形,因此每个网格只是由两个三角形组成的四边形。 这是给您的低视角图,这样就可以看到它有多平坦。

这是三角形 ID 视图。 正如您所看到的,每个绘制调用只是两个三角形。

PrePass:对于前向和延迟渲染,PrePass 仅写入深度。 然而,对于可见性通道,它还写入可见性U32,包括drawCallId和TriangleId。

Material:对于Deferred,当前Pass指的是材质光栅化Pass。 对于Visibility,它指的是计算过程的时间。 当然,对于Forward,总时间需要合并Material和Lighting。

Lighting:在Deferred的情况下,此Pass是一个读取纹理并写入光照的计算着色器。 在VisibilityBuffer中,读取了很多Buffer

VisUtil:此类别指的是Visibility Render中的其他通道。 这包括计算着色器,它计算每种材质的像素数,重新排序Visibility缓冲区,然后在像素着色时将其重新排序回线性缓冲区。

其他:此类别指的是其他一切。 这里的主要通道是阴影Pass、TAA、Motion Vector、色调映射、GUI(没有出现在这些屏幕截图中)和其他。 我实际计算的方法是获取总 GPU 时间并减去所有其他类别。

其他类别有点棘手,因为光栅/计算重叠是组织渲染通道的关键设计决策之一。 但对于此测试,目标是确定不同算法之间的相对代价,而不是最小化最终渲染时间。 所有三种渲染类型的代价大致相似,因此将它们分开分组是有意义的。 Forward/Deferred/Visibility的选择对 TAA 和阴影等其他Pass的代价影响很小。

低密度三角视图性能。

嗯,这个结果很有趣。 在Deferred的情况下,材质着色器时间代价为 1.06 毫秒,Visibility着色器成本在某种程度上完全相同,均为 1.06 毫秒。 此外,Lighing的成本略高了 0.032 毫秒,并且在管理Visibility Pass方面有额外的 0.322 毫秒的开销。 最后,Forward可以稍微更快地计算材质和照明,可能是因为它节省了带宽。

首先,作为免责声明,5x3 四边形几乎是平坦的,并且存在一点 z 冲突,因此 GBuffer 通道中的某些像素可能没有获得正确的 Early-Z,从而导致少量 Overdraw。 但更可能的解释是,该过程主要受带宽限制,因此插值顶点和计算导数的额外 ALU 成本被带宽成本覆盖了。

但是,看看这些数字,获取顶点属性和计算偏导数的额外成本几乎为0, Visibility 光照Pass时间略高,额外的管理通过加起来,但总的来说,这对于下一次测试来说是一个非常令人鼓舞的结果。 此外,VisUtil Pass可能会有所下降。 当前的实现使用缓冲区而不是纹理来计算光照结果,并稍后对数据进行排序。 但显然,将VisibilityBuffer的输出作为UAVs直接存储到 GBuffer 中会更快

中等三角形数量

接下来,让我们尝试中等分辨率视图。 对于高分辨率图像,我们的分辨率为 500x500。 为了使像素小10 倍,我们可以将它们的分辨率设置为 500/sqrt(10)=158。 因此,此中等分辨率设置的网格为 158x158。

当然,还有所有三角形的视图。 当调整相机角度时,我尝试使得每个三角形大约覆盖 10 个像素,但乍一看似乎更接近 8 个。

对于三角形约为 8-10 像素,我们预计任何光栅化Pass的时间成本约为 2 倍。 具体结果如下

作为免责声明,“其他”的变化并不显着,因为该变化是由Shadow Pass驱动的。 阴影通道非常简单,只需将所有几何体渲染到级联和点光源阴影中。 由于几何形状的复杂性不断增加,Shadow Pass的复杂性也随之增加。 然而,这种代价变化对于所有三种渲染类型来说实际上是相同的。因此我们只需要检查三种不同算法的相关Pass:PrePass、Material、Lighting 和 VisUtil。


从这些数字来看,很明显,随着三角形变小,可见性渲染就会领先。 而且数字并不像我想象的那么接近。 我真正注意到的第一件事是 PrePass。 我原本预计渲染Visibility ID 和深度(而不是仅渲染深度)会对性能产生更多影响,但代价差异非常低(0.033 毫秒)。 最大的时间差异性是Foward Pass 和Deferred Material,其长度分别增加了 2.43 倍和 2.78 倍(与之前的第一张图像相比)。 Visibility Material的时间是原始时间的 1.56 倍。

但为什么Visibility Material 比大三角形情况花费的时间更长? 其原因在于,它是在相同数量的像素上运行的相同着色器,缓存是一致性,因此直接增大了Cache 的命中率。 让我们看两个场景。 在左侧,我们有一个 8x8 的像素块,它们被分成两个三角形,而在右侧,8x8 块中的每个像素都指向不同的三角形。

在计算着色器中,所有64个线程都会获取第一个顶点的数据。但在左侧的情况下,GPU只需要为整个8x8块获取2个唯一顶点位置。然而,在右侧的情况下,GPU将需要从64个唯一的内存位置获取数据。除了更差的数据一致性外,它还需要更多的原始带宽来获取数据,因为需要获取的总字节数更多。因此,虽然这些额外的数据获取在第一个测试案例中的大三角形中的成本微不足道,但在这个场景中它们会产生显著的成本。然而,这个成本远远小于由于四边形利用不佳而付出的代价。因此,可见性方法在总体上更快。

大量三角形的情况

最后,测试场景为 500x500,并且我们使得每个三角形覆盖 1 个像素。


Triangle ids 视图

结果看起来会怎么样呢?


从时间线开始,PrePass 成本有所上升,但保持在合理范围内,而写入Visibility U32 的成本仅增加了 15%。 其他Pass的时间也显着上升,但这主要是由阴影Pass的。 其他类别中,Visibility Pass少了 0.21 毫秒,这有点奇怪。通过debug,阴影Pass确实与 PrePass 有一些重叠,因此 PrePass 的额外 0.15 毫秒Cost可能隐藏了阴影通道的 0.15 毫秒,而另外 0.06 毫秒则被与 VisUtil 的其他重叠隐藏 。

但主要区别在于Material和Lighting Cost, 这些数字几乎不言而喻。 与第一次相比,Forward 时间增加了 5.76 倍,Deferred Material 成本增加到第一次的 4.38 倍。 然而,Visibility是第一次的 1.90 倍。

让我们再次屏蔽与渲染算法之间的差异相关的通道。


结论很清楚。 在此测试用例中,一旦三角形密度减少到单个像素,Visibility Render四边形利用率的成本就大大超过了插值顶点属性和分析计算偏导数的额外成本。


结论

回到最初的问题:

我们可以用Material有效地计算偏导数吗?

在这个测试用例中,答案是“是”。 但在一般情况下,更好的答案是“也许”。 在 UV 比例和偏移的简单情况下,生成偏导数所需的额外计算是微不足道的。 我做了一些其他粗略的测试,乍一看,没有任何关键用例会导致足够的性能损失以从根本上改变这个结果。

最常见的情况是纹理输出变成另一个纹理的 UV。 如果我们有一个具有 4 个纹理读取的标准材质,然后添加一个 UV 偏移纹理读取,则前向/延迟材质通道将添加 1 个纹理读取,而可见性材质通道将添加 3 个纹理读取。但是 5 个和 7 个纹理样本之间的差异 不会导致性能悬崖足以彻底改变这个结论。 我希望这两个额外的样本具有最小的成本,因为它们与第一个样本的缓存一致性非常好。

一个更有问题的情况是视差遮挡贴图。 理论上,每个步骤我们需要 3 次纹理读取,而不是 1 次。 但实际上会在每一步中发生如此大的变化吗? 对所有这些使用相同的导数/mip 贴图级别是否可以接受? 乍一看似乎有道理,但我没有验证过。

最后,有限差分法使用标准导数也并非完美。我们有一些问题情况,比如分支和抛弃(discard),这些情况可以通过切换到解析导数来优雅地解决。这在使用在三角形边缘之外的辅助通道时尤为明显。

所以,在这种情况下它是可行的。但对于AAA游戏中的偏导数的一般问题,我的答案是“可能可行,但更倾向于是”。我的结论是解析导数可能是可行的,但这种方法需要进行更多测试,以确保在更复杂的用例下表现良好。

对于非常高的三角形数量(每个三角形一个像素),可见性渲染方法更快吗?

对于非常高的三角形数量,每个像素运行4次,可见性渲染方法是明显的赢家。延迟渲染的成本为6.43毫秒,而可见性渲染为4.34毫秒。在相关Pass中,整体GPU成本减少了32.5%,这是非常显著的。

那对于更典型的三角形大小(每个三角形5-10像素),可见性渲染方法也更快吗?

对于中等数量,在这些测试中,是的,可见性渲染方法也更快。不过,差距较小,分别为3.85毫秒和2.96毫秒。但是,23.1%的减少仍然是显著的。另外,我预期可见性渲染方法在具有大量几何图形和Overdraw的极端情况下会比较平稳,但这只是推测。

6
评论 2
收藏 6
  • bubble
    bubble
    好高级的算法
  • 大佬,我想问一下,在UE5视角下,nanite展示的三角形是物体自带的属性还是摄像机自带的属性呢,如果是物体自带的属性,那在实例化物体的时候会不会占用太多内存?