追求宽阔的轮廓:GPU轮廓渲染的探索

2020-07-21 06:29:25

在我参与的几乎每一个项目中,都会有人在某个时候创作出一些这样的平面设计概念艺术:

我通常的反应是叹息,然后开始解释为什么我们不能做这样的大纲。或者至少开始与他们讨论我们需要对资产管道进行什么样的深度修改,才能实现这一点。

当然,我的最新项目也没什么不同。但这一次,在我开始我的脚本之前,我有一个想法,那就是Photoshop可以做的事情与实时的限制相比有什么不同。

这是一个关于试图绘制非常宽的轮廓以进行实时渲染的故事。试图过度设计和优化“糟糕的”暴力方式,看看我能以多快的速度获得它。最后,我终于让步并实现了我从一开始就故意忽略的方法。

将轮廓添加到任何网格的最古老和最常用的方法之一是反转外壳线方法。自从3D渲染成为一件事以来,这一点可能已经被使用了很久。只需渲染网格两次,第二个版本翻转过来,稍微大一点。喷气式对讲机就是这项技术的一个很好的早期例子。但它今天仍然在使用,效果很好,就像Arc system Works最近推出的任何3D格斗游戏一样。

实现稍微大一点的网格的方法多种多样,但最常见的方法是通过顶点法线将顶点移出。对于非常老的游戏来说,这是在游戏之外完成的,只有两个网格,但现在最常用的是着色器。

这种基于网格的轮廓风格的伟大之处在于它非常便宜,相对通用,并且可以做非常宽范围的轮廓宽度。

任何基于网格的轮廓系统的问题是,它需要一些非常刻意的内容设置。如果没有它,您可以很容易地得到分裂的边缘、洞和其他工件。多年来,我曾在几个项目中做过这项工作。我知道如果我们需要的话,我可以求助于它,但我从来没有对结果完全满意,即使在内容管道和资产处理上花费了大量资源。

即使有了额外的工作,也基本上不可能得到一致的、像素完美的轮廓。如果您试图匹配非实时工具的干净质量,那么这不是一个适用于任意网格的解决方案。就这样,我开始走进这个兔子洞。

如果我今天编写一个完全暴力的轮廓着色器,会发生什么?在我的电脑当前运行的2080Super速度快得离谱的情况下,在它对帧率产生明显影响之前,我可以获得多宽的轮廓?

看起来挺不错的!事实上,它基本上与Photoshop的轮廓完全匹配。而且它远没有我担心的那么慢。

下面是此代码所做工作的基本概要。我用纯白着色器将目标网格(或一组网格)渲染为全屏灰度渲染纹理。然后,我使用着色器将其渲染回主目标,该着色器天真地采样某个像素范围内的所有纹理元素,取最大值。

这个贵吗?绝对一点儿没错!。但是轮廓,我认为用通常的强力轮廓着色器标准来说是相当宽的,还不够昂贵,不足以对帧速率产生明显的影响。至少在我的RTX 2080Super和1920x1080的原型上不是这样的,这个原型只需要不到4ms的时间就可以渲染其他所有东西。

该图显示了整个效果的毫秒成本,包括渲染初始轮廓渲染纹理,以及将blit恢复到主帧缓冲区。

不过,走得太远很快就会变得昂贵。这是因为着色器正在执行(2*像素半径+1)²采样,并对屏幕上的每个像素执行此操作。这条图形线非常接近于典型的O(n²)二次曲线,这是有意义的。在20像素的情况下,它几乎是10ms,所以我没有测试超过这一点。有一次我不小心把半径设置到了80,我的GPU被锁定了。

对于一次使用一个或两个唯一轮廓的情况,最大5-6个像素半径几乎是合理的。但这并不是一个非常宽泛的轮廓。当然,在概念艺术中没有接近轮廓宽度的地方。

一个显而易见的解决方案就是不要在全屏幕上这样做。但是如何限制应用效果的像素呢?看似显而易见的答案是“将其限制在距网格一定距离内”。但这并不像听起来那么容易。轮廓着色器本身就是这样做的代码,我尽量避免在任何地方都这样做!

幸运的是,至少有一种简单的方法可以排除网格的内部。使用模具缓冲区渲染原始网格仅用于标记内部像素。然后,不要在那里渲染轮廓着色器。这还有一个额外的好处,就是在使用MSAA时可以使其准确地复合。

但这并不总是屏幕上很大的一部分,除非相机真的放大到很近。这意味着当被勾勒轮廓的物体很小或不在屏幕上时,它比在屏幕上大的时候要昂贵得多。我需要一些方法来更多地限制它。

由于各种原因,CPU侧网格边界通常比实际网格大得多。因此,虽然我可以很容易地计算网格渲染器边界的矩形屏幕覆盖率,但大多数情况下,这几乎是全屏覆盖。扩展网格本身有我上面提到的问题,所以不能保证任何顶点扩展实际上覆盖了蛮力轮廓将会覆盖的所有区域。

我可以根据顶点位置计算出我自己的矩形屏幕空间界限。但是我希望这能与蒙皮网格一起工作,当使用GPU蒙皮时,这会使事情变得非常复杂。CPU不知道顶点的位置,也没有一种干净的方法将顶点位置传递给计算着色器。我还可以对剪影渲染纹理中的每个纹理元素进行采样,以找到最小和最大界限。这两种技术都不是特别快,而且我仍然只有一个矩形区域,轮廓将被限制在其中。

然而,我意识到我可以在这里滥用MIP映射来有效地找到轮廓的相对边界,尽管分辨率要低得多。因此,我为渲染纹理生成MIP贴图,然后对表示轮廓半径的MIP贴图级别进行采样。它可以呈现到模具上,以创建您确实希望呈现轮廓的位置的蒙版。

我实际上选择了一个比轮廓宽度小一的MIP级别,然后在蒙版着色器中做一个可变宽度的纹理轮廓来扩展它。这有助于将覆盖范围限制在比完整的MIP级别步骤略高一点的范围内。特别是由于MIP贴图的分辨率较粗,与实际轮廓相比,扩展最终被夸大了。

我们现在变得更快了。这大大降低了大纲的成本。这使得半径超过3个像素的轮廓的帧时间成本降低了50%,而在20个像素的轮廓上,帧时间成本降低了近75%。但我们还能做更多。

当使用MIP贴图渲染纹理来查找大致的外部轮廓时,我意外地搞错了我的数学,最终采样了错误的MIP级别,并意识到它也可以用于计算近似的内线面积。这比外部遮罩稍微复杂一些,因为我需要将其限制为只覆盖轮廓圆内部的MIP贴图。所以我也使用了一个着色器来做一些额外的纹理样本来做一个4个样本的MIP贴图级别的轮廓,以填充更多的内部。这类似于外部遮罩的1纹理轮廓扩展,但它必须适合半径的内部,而不是外部,因此它必须更加保守。我不会对某个宽度以下的轮廓执行此过程,因为它并不比当时要替换的着色器便宜多少。

这只对10像素半径以上的轮廓有真正的帮助。对于低个位数的轮廓,它增加了高达50%的成本,所以我关闭了任何低于这一点的东西。

在这一点上,我已经做了几乎所有我能做的就是廉价地限制暴力轮廓着色器运行所需的区域。但实际上,我觉得我的注意力需要转向轮廓着色器本身。

在原始着色器中,我对正方形区域的纹理像素进行采样,然后将采样乘以到边缘的距离,得到一个抗锯齿圆。我尝试的第一件事是添加一个分支,以完全跳过半径之外的样本。令人惊讶的是,这起作用了!这并不是很大的性能提升,也许快了10%,但这是一件了不起的事情。

所以我已经跳过了样本盒内半径之外的任何样本位置。所以我采取了额外的步骤,如果最大采样数已经等于1,我就再增加一个分支退出。当没有任何模板蒙版运行时,整体效果会稍微慢一些,因为任何不在网格范围内的像素都会付出额外分支的代价而没有任何好处。这将消除那些在可能更快的范围内的像素所获得的任何可能的好处。

但是随着模具优化已经将着色器运行的区域减少到大部分靠近网格的区域,这个问题就消失了!所以我又把它加回去了,…。结果并没有更快。不管跳过额外的样本会有什么好处,都不能弥补分支机构的成本。

我的猜测是,为什么这样做总体上没有帮助,其实并不是分支机构本身的成本,尽管这是其中的一部分,而是它造成的高度分歧。简而言之,由于每个像素的着色器调用将在不同的点处停止,GPU无法有效地利用此优化。GPU批量运行多个像素着色器,该批中所有调用的成本都与最昂贵的像素一样高。因此,虽然有些像素可能要便宜得多,但他们仍在等待更昂贵的像素完成。结果恰好不期而遇。

在我做内线遮罩之前,我尝试了一种不同的内线策略。我尝试调整着色器中采样的MIP级别,以使用所需的最小MIP贴图。我读过无数关于使用MIP级别和屏幕空间环境光遮挡来优化性能的文章。SSAO的想法是样品离中心越远,样品的MIP水平就越小。这降低了对内存带宽的要求,并且在质量损失很小的情况下工作得很好!

对于大纲来说,它需要是相反的。对轮廓边缘的顶部MIP进行采样,并降低距离边缘越远的MIP级别。

这要慢得多。慢了几个数量级。为什么?因为我完全是在敲打纹理缓存。强力方法意味着大多数纹理读取都在重复使用缓存中已有的数据。在样本之间切换MIP级别会使缓存失效。它适用于SSAO,因为它使用稀疏采样,并且不像此轮廓着色器所需的那样进行详尽的搜索。

由于我现在使用MIP贴图近似过程来渲染线的内部,因此我在着色器中添加了一个非常基本的测试,以跳过我认为该过程将保证覆盖的任何采样。这在技术上更快,与跳过半径外的采样相比,具有类似或更好的收益。但就像内填充模板一样,它只是在较大的半径时速度更快,主要是在太慢而没有用处的范围内。此外,由于内部填充模板仅运行在10像素半径以上,这意味着需要在两个版本的轮廓着色器之间切换,以便它不会跳过内部采样,或者使现有分支更加复杂。更复杂的分支使一切变慢,完全失去了使用它的大部分好处。并使用变体…。嗯,这个收获不够大,不值得这么麻烦,所以我把它搁置了。

原始的强力着色器是两个循环和一个分支,用于在圆之外的采样上进行早期输出。我决定研究使用单循环并根据单循环索引计算UV偏移的方法。这方面的数学计算非常简单,并且对于像翻转书着色器这样将时间转换为地图集位置的东西来说是很常见的。令人惊讶的是,就像采样不同的MIP地图一样,这也要慢得多。较小的额外算术运算单元成本最终比仅仅保持额外循环的成本高出约3倍。

我也试过使用样本图案,比如SSAO或BOKEH景深效果,其中使用泊松圆盘或放松/球化的正方形。这也要慢一些。可能是由于纹理缓存抖动、过度采样以及额外的ALU成本的组合造成的。当使用略微较低的采样计数以避免过度采样时,它还附带了瑕疵。

我考虑过使用Morton Z指令之类的东西,但在这一点上我有点放弃了。我尝试的每一样东西最终都比仅仅逐行采样整个正方形并提前抛出跳过的样品要昂贵得多。

在这一点上,我对这个方法的效果相当满意。我可以得到半径约20像素的轮廓,持续时间不到1.7ms!比我们已经在做的其他后处理时间要短。而且对于一次只需要一个或两个物体轮廓的原型来说,速度已经足够快了。

它仍然大致是一条O(n²)二次曲线,但与原始的未优化版本相比明显变平了。

不幸的是,在更宽的轮廓上,基于MIP贴图的蒙版开始显示其弱点,导致曲线的一致性大大降低。

因此,这种方法的极限是在32像素半径左右。一个稍微复杂一点、宽度更宽的面具很可能会以胜利告终。但这已经是一段非常复杂的代码,有很多传球,所以我不想在上面花费太多时间。相反,我决定将其与我过去使用的其他方法进行比较。记住,我知道暴力方法不一定是性能上最好的方法,只是质量上的。这一切都是一个实验,看它是否能足够快地被用来代替另一种更常见的方法。

一种众所周知的处理轮廓的方法是不像暴力方法那样进行详尽的搜索,而是进行可分离的高斯模糊。问题是,我这个高度复杂的,呃,优化的蛮力是不是更快了?

在10像素或更低的情况下,在大部分范围内,优化的强力方法速度更快。但不是很有意义。半径越大,模糊效果明显越快。然而,这并不像我最初担心的小于20像素的半径范围那么大。事实上,只有当你进入超过15像素的半径范围时,模糊方法才会变得非常明显地更好。我原以为会出现一个拐点,在那里模糊会更快结束,但我希望它是小的个位数半径,而不是超过10个像素宽。从技术上讲,基于1像素模糊的轮廓速度更快,但原始的蛮力在这个大小下比任何一个都要快。

基于模糊的方法的主要优点是它的O(N)。成本随半径线性增加!

还有更多的优化可以在这里进行。对于模糊实现来说,没有一种简单的方法来限制模糊运行的区域,所以它又回到了全屏运行。这使得它与优化的蛮力相比处于劣势。我尝试了一些设置,试图重用主缓冲区中的现有模具缓冲区,但Unity并没有让这件事变得容易,所以我从来没有让它工作过。我还尝试创建带有深度缓冲区的临时渲染目标,以绘制模具蒙版。这样做的额外成本最终完全消除了它增加的任何好处,并最终使模糊的整体速度比优化的蛮力更慢!即使对于前几个像素半径,也比未优化的蛮力慢。

我可以将区域限制在网格的近似矩形屏幕边界内。但更容易的优化是在模糊之前对图像进行下采样。这使得这项技术比任何一种暴力超过最初几个像素范围的速度都要快得多。根据您想要进行多少下采样,您可以将较宽轮廓的成本大致减半或更好。我也在计算着色器中的模糊内核,我可能会通过事先在c#中计算来进一步优化模糊。

但我不会那么做的。这实际上是一个测试,看看我是否可以比高斯模糊轮廓实现更快地获得暴力力量方法,而不是看看我可以得到多好的优化后者。

薄的、锐利的或小的特征可能会消失或褪色,轮廓不那么精确,正确的抗锯齿也更难。前两个是因为它是模糊的。最后一个问题是因为您必须使用可分离的高斯模糊,这会导致非线性衰减,这是一个模糊。要知道可变宽度的边缘要锐化多少是很困难的。添加向下采样以提高性能会使上述所有问题变得更糟。我以前就知道这些,这就是为什么我一开始就避免这样做的原因。

如果你想要一个模糊的光芒,那就太棒了!如果你想要一个宽的轮廓,并且可以进行四舍五入,那就太好了!就像我前面提到的,我以前用过这个,对于这些用例来说,它是快速有效的。

如果你想要与Photoshop的轮廓质量相抗衡,并且在薄边上效果良好的东西,那么它远远比不上蛮力。

在这一点上,我仍然对优化的蛮力方法感到足够高兴,我认为就是这样。更糟糕的情况是,我可以换成高斯模糊更宽的线条,然后在剩下的时间里回到暴力。

我在推特上发布了一些关于我的旅程,认为我已经做了我能做的一切。

我了解跳跃泛洪算法已经有一段时间了,并且有一些使用其他人实现结果的经验。我对它的质量印象不深,认为它不适合我的用例。主要是因为我认为它不能很好地满足我自己强加的处理抗锯齿启动缓冲区的要求。

现实是,我只是不明白它是如何运作得足够好的,我很害怕它。幸运的是,我决定试着花时间去理解它。这些链接起到了帮助作用。

这对于宽轮廓来说要快得多,并且看起来和蛮力一样好。

一旦我更好地理解了这个系统,我就明白了这一点。但这仍然感觉像是一个相当复杂的系统,有多次昂贵的通行证。当然,在某个点上,优化的暴力和模糊效果会更快,对吧?

好的,在1像素时,未优化的强力比所有选项都要快。但在每隔一个半径,跳跃洪水上升的速度更快。高度手动优化的单像素轮廓着色器,其价格可能几乎是动态强力着色器的一半。但这里的目标不是一个像素的轮廓。

JFA的真正魔力在于它是多么便宜,轮廓真的宽得离谱。

是的,这是一个超过2000像素半径的图表。全屏1080p轮廓不到1毫秒。即使是8k分辨率的全屏轮廓也可能略高于1ms,忽略了内存带宽增加带来的额外成本(提示:这将远远超过1ms)。与暴力方法(O(n²)或模糊方法O(N))不同,跳跃泛洪是O(⌈Log₂n⌉)。这意味着轮廓的成本对于每2像素半径的幂是恒定的。像高斯模糊一样,我没有做任何限制,除了我做了多少跳跃洪水通过,所以这总是全屏工作,它仍然是这么快。

我之前提到过,我认为JFA不会很好地满足我自己提出的支持反走样的要求。在某种程度上,这仍然是一个问题,但我找到了一种方法来支持反走样和暴力。在处理薄边或锐边时,它实际上比暴力更好,当然也比高斯模糊更好。

在我开始之前,我想谈谈更多关于日本足协的事情。

.