200+篇教程总入口,欢迎收藏:
放牛的星星:[教程汇总+持续更新]Unity从入门到入坟——收藏这一篇就够了zhuanlan.zhihu.com本文重点内容:
1、创建简单的post-FX栈
2、修改渲染后的图像
3、需要的时候完成后处理的呈现
4、制作Bloom的效果
这是关于创建自定义脚本渲染管道的教程系列的第11部分。它增加了对后处理的支持,目前只支持bloom。
本教程是CatLikeCoding系列的一部分,原文地址见文章底部。
本教程使用Unity 2019.4.4f1制作。
1 Post-FX Stack
大多数情况下,渲染的图像不会按原样显示。图像经过了后期处理,并获得了各种效果(简称FX)。常见的FX包括光晕,颜色分级,景深,运动模糊和色调映射。这些FX作为堆栈应用,有指定的顺序,一个在另一个之上。在本教程中,我们将创建一个简单的post-FX栈,该栈最初仅支持Bloom。
1.1 设置资产
一个项目可能需要多个post-FX栈配置,因此我们首先创建一个PostFXSettings资产类型来存储一个栈的设置。
在本教程中,我们将使用单个栈,方法是在RP上通过向CustomRenderPipelineAsset添加配置选项将其提供给RP,然后将其传递给RP的构造函数。
然后,CustomRenderPipeline必须追踪FX设置,并将它们与其他设置一起在渲染过程中传递给相机渲染器。
CameraRenderer.Render最初对设置不执行任何操作,因为我们还没有栈。
现在我们可以创建一个空的post-FX Setting资产,并将其分配给管道资产。
1.2 栈对象
我们将为栈使用和Lighting、Shadows相同的方法。为它创建一个类,该类有Buffer,Context,Camera和post-FX settings,并使用公共Setup方法对其进行初始化。
接下来,添加一个公共属性以指示栈是否处于活动状态,只有在有设置的情况下,情况才如此。想法是,如果未提供设置,则应跳过后处理。
最后,我们需要一个公共的Render方法来渲染栈。通过使用适当的着色器简单地绘制一个覆盖整个图像的矩形,即可对整个图像应用效果。现在我们没有着色器,因此我们只需要复制到目前为止渲染的任何内容到相机的帧缓冲区即可。这可以通过在命令缓冲区上调用Blit,并将源和目标的标识符传递给Blit来完成。这些标识符可以以多种格式提供。我们使用整数作为源,并为其添加一个参数,并使用BuiltinRenderTextureType.CameraTarget作为目标。然后执行并清除缓冲区。
本案例中,我们不需要手动开始和结束缓冲区样本,因为我们可以完全替换目标位置,因此不需要调用ClearRenderTarget。
1.3 使用栈
现在,CameraRenderer需要一个栈实例,并在Render中调用它的Setup,就像它对其Lighting对象所做的一样。
到目前为止,我们始终直接渲染到摄像机的帧缓冲区,该缓冲区既可以用于显示,也可以用于配置的渲染纹理。我们没有直接控制权,只能写入它们。因此,要为活动栈提供源纹理,我们需要使用渲染纹理作为相机的中间帧缓冲区。获取一个并将其设置为渲染目标的方法类似于阴影贴图,只是我们将使用RenderTextureFormat.Default格式。在清除渲染目标之前执行此操作。
如果我们有一个活动栈,还可以添加一个Cleanup方法来释放纹理。也可以将lighting的clearup移到那里。
在render的末尾,submitting之前调用clearup。如果栈处于活动状态,则在此之前直接渲染栈。
此时,结果看起来应该没有什么不同,但是增加了一个额外的绘制步骤,从中间帧复制到最终帧缓冲区。它在帧调试器中列为Draw Dynamic。
1.4 强制清除
当绘制到中间帧缓冲区时,我们的渲染器会填充有任意数据的纹理。帧调试器处于活动状态时,你可以看到此信息。Unity确保帧调试器在每个帧的开始都获得一个清理后的帧缓冲区,但是当渲染到我们自己的纹理时,我们会避开它。通常,这会导致我们在前一帧的结果之上进行绘制,但这并不能一定保证。摄像机的清除标志设置为天空盒还是纯色都没关系,因为我们保证可以完全覆盖以前的数据。但是其他两个选项不起作用。为防止出现随机结果,除非使用天空盒,否则当栈处于活动状态时,请始终清除深度并清除颜色。
请注意,这会使得无法在不使用后FX堆栈的情况下,清除之前在另一个像机渲染结果上进行渲染。有许多解决方法,但这超出了本教程的范围。
1.5 Gizmos
目前,我们正在同时绘制所有gizmos,但是在FX前或者后渲染的控件之间存在一些区别。因此,让我们将DrawGizmos方法一分为二。
然后我们可以在正确的时间在Render中绘制它们。
请注意,当3D图标用于Gizmos时,当栈处于活动状态时,它们将不再被对象遮挡。发生这种情况是因为场景窗口依赖于我们没有使用的原始帧缓冲区的深度数据。之后,我们将结合post FX i来介绍深度。
1.6 自定义画法
我们当前使用的Blit方法会绘制一个四边形网格(两个三角形),该网格覆盖了整个屏幕空间。但是我们只画一个三角形就可以得到相同的结果,工作量少了一点。我们甚至不需要将单个三角形的网格发送到GPU,可以按程序生成它。
这有显著的区别吗?
这样做的明显好处是将顶点从六个减少到三个。但是,更重要的区别是,它消除了四边形的两个三角形相交处的对角线。由于GPU将片元并行地分成小块,因此某些片元最终会沿三角形的边缘造成浪费。由于四边形有两个三角形,沿对角线的片元块将渲染两次,因此效率低下。除此之外,渲染单个三角形可以具有更好的本地缓存一致性。
在我们的RP的Shaders文件夹中创建一个PostFXStackPasses.hlsl文件。我们将栈中的所有Pass放入其中。我在其中定义的第一件事是Varyings结构,该结构仅需要包含剪辑空间位置和屏幕空间UV坐标。
接下来,创建一个默认的顶点Pass,仅将顶点标识符作为参数。这是具有SV_VertexID语义的无符号整数uint。使用ID生成顶点位置和UV坐标。X坐标为-1,-1、3。Y坐标为-1、3,-1。要使可见的UV坐标覆盖0–1范围,请对U使用0、0、2,对V使用0、2、0。
添加片元Pass并进行简单的复制,使其最初返回UV坐标以用于调试。
在同一文件夹中创建一个附带的着色器文件。所有Pass均不使用任何剔除并忽略深度,因此我们可以将这些指令直接放在Subshader块中。我们也总是包含Common和PostFXStackPasses文件。现在唯一的途径就是使用我们创建的顶点和片元函数进行复制。我们还可以使用Name指令为其命名,这在将同一着色器中的多个Pass组合在一起时非常方便,因为帧调试器会将其用作遍历标签,而不是数字。最后,将其菜单项放在Hidden文件夹下,以便在为材质选择着色器时不显示该菜单项。
简单地通过其设置将着色器手动链接到我们的栈上。
但是渲染时我们需要材质,因此添加一个公共属性,可以使用该属性直接从设置资产中获取材质。我们将根据需要创建它,并将其设置为隐藏而不保存在项目中。同样,由于材质是按需创建的,因此无法与资产一起序列化。
由于通过名称而不是数字来寻址Pass很方便,因此可以在PostFXStack中创建一个Pass枚举,最初只包含Copy Pass。
现在我们可以定义自己的Draw方法。给它两个RenderTargetIdentifier参数以指示应该从何处绘制到何处,以及一个pass参数。在其中,通过_PostFXSource纹理使源可用,像以前一样将目标用作渲染目标,然后绘制三角形。我们通过使用未使用的矩阵,栈材质和pass作为参数调用缓冲区上的DrawProcedural来做到这一点。之后又有两个需要解决的问题。首先是我们要绘制的形状,即MeshTopology.Triangles。第二个是我们想要多少个顶点,单个三角形是三个。
最后,用我们自己的方法替换对Blit的调用。
1.7 不要总是应用 FX
现在,我们应该看到场景窗口中出现了屏幕空间UV坐标。游戏窗口中也可以看到。刷新一次后,还可以在材质预览中,甚至在反射探针中看到。
我们的想法是将后FX应用于适当的相机,而不是其他任何东西。可以通过检查PostFXStack.Setup中是否有Game或Scene摄像机来强制执行此操作。如果不是,我们将设置设为null,这将停用该相机的栈。
除此之外,还可以通过其工具栏中的效果下拉菜单在场景窗口中切换后处理。可以同时打开多个场景窗口,可以单独启用或禁用后期效果。为了支持此功能,请使用ApplySceneViewState方法为PostFXStack创建一个编辑器局部类,该方法在构建中不执行任何操作。它的编辑器版本检查我们是否正在处理场景摄像机,如果当前绘制的场景视图的状态禁用了图像效果,则禁用栈。
在Setup结束时调用此方法。
1.8 拷贝
通过使复制过程返回源颜色来完成栈。为此创建一个GetSource函数,进行采样。我们将始终使用线性钳位采样器,以便我们可以明确声明它。
我们最终将原始图像取回来了,但是在某些情况下,通常是在场景窗口中,它是颠倒的。这取决于图形API以及源和目标的类型。发生这种情况是因为某些图形API的纹理V坐标从顶部开始,而另一些图形API的纹理V坐标从底部开始。Unity通常会隐藏它,但是在涉及渲染纹理的所有情况下都不能这样做。幸运的是,Unity指示是否需要通过_ProjectionParams向量的X分量进行手动翻转,该向量应在UnityInput中定义。
如果值为负,我们需要翻转DefaultPassVertex中的V坐标。
2 Bloom
Bloom 的post效果用于使物体发光。这是物理学的基础,但是典型的Bloom效果是艺术而非现实的。不真实的发光非常明显,也因此可以很好地证明了我们的后FX栈有效。在下一个教程中,讨论HDR渲染时,我们将看到更加逼真的Bloom。现在,我们的目标是完成LDR光晕发光效果。
2.1 Bloom金字塔
Bloom表示颜色的散射,可以通过模糊图像来完成。明亮的像素会渗入相邻的较暗像素,因此看起来会发光。使纹理模糊的最简单,最快的方法是将其复制到宽度和高度一半的另一个纹理中。Copy Pass的每个样本最终在四个源像素之间进行采样。通过双线性滤波,可以平均2×2像素的块。
此操作执行一次只会模糊一点。因此,我们需要重复此过程,逐渐降低采样率直至达到所需的水平,从而有效地构建纹理金字塔。
我们需要跟踪栈中的纹理,但是有多少层取决于金字塔中有多少层,而这又取决于源图像的大小。让我们在PostFXStack中最多定义16个级别,这足以将65,536×65,526的纹理一直缩小到单个像素。
为了跟踪金字塔中的纹理,我们需要纹理标识符。我们将使用属性名称_BloomPyramid0,_BloomPyramid1,依此类推。但是,我们不要明确地写下所有这十六个名称。相反,我们将在构造函数方法中获取标识符,并且仅跟踪第一个标识符。之所以可行,是因为Shader.PropertyToID只是简单地按照请求新属性名称的顺序顺序分配标识符。我们只需要确保立即请求所有标识符,因为数字在应用程序会话中是固定的,无论是在编辑器中还是在构建中。
现在创建一个DoBloom方法,该方法将bloom效果应用于给定源标识符。首先将摄像机的像素宽度和高度减半,然后选择默认的渲染纹理格式。最初,我们将从源复制到金字塔中的第一个纹理。追踪那些标识符。
然后循环遍历所有金字塔级别。每次迭代都首先检查一个级别是否会退化。如果是,我们到此为止。如果未获得新的渲染纹理,请复制到该纹理,使其成为新的源,增加目标,然后再次将尺寸减半。在循环外部声明循环迭代器变量,稍后我们将需要它。
金字塔完成后,将最终结果复制到摄像机目标。然后递减迭代器并向后循环,释放我们要求的所有纹理。
现在,我们可以使用Bloom效果替换Render中的简单Copy。
2.2 可配置的Bloom
我们现在的模糊次数太多了,但最终结果几乎是一致的。你可以通过框架调试器检查中间步骤。但这些步骤作为结束似乎也没有什么问题,因此让我们可以尽早停止。
我们可以通过两种方式做到这一点。首先,我们可以限制模糊迭代的次数。其次,我们可以将缩小比例限制设置为更高的值。通过在PostFXSettings内添加带有它们选项的BloomSettings配置结构来支持这两种方法。通过getter属性使其公开可用。
让PostFXStack.DoBloom使用这些设置来限制自己。
2.3 高斯过滤
使用小型2×2滤波器进行下采样会产生非常块状的结果。通过使用更大的滤波器内核(例如大约9×9高斯滤波器),可以大大提高效果。如果我们将其与双线性下采样相结合,我们会将其有效倍增至18×18。这就是Universal RP和HDRP发挥作用的原因。
尽管此操作混合了81个样本,但它是可分离的,这意味着可以将其分为水平和垂直Pass,将单个行或列混合为九个样本。因此,我们只需要采样18次,但是每次迭代需要绘制两次。
可分离的过滤器如何工作?
这是一个可以用对称行向量乘以其转置来创建的过滤器。
让我们从水平Pass开始。在PostFXStackPasses中为其创建一个新的BloomHorizontalPassFragment函数。它累积了以当前UV坐标为中心的九个样本行。我们还将同时下采样,因此每个偏移步长都是源纹理像素宽度的两倍。从左侧开始的样本权重为0.01621622、0.05405405、0.12162162、0.19459459,然后为中心的权重为0.22702703,另一侧的权重相反。
这些权重从何而来?
权重是从Pascal三角形得出的。对于适当的9×9高斯滤波器,我们选择三角形的第9行,即1 8 28 56 70 56 28 81。但是,这使得在滤波器边缘的样本贡献太弱而无法察觉, 因此,我们向下移至第十三行并切掉其边缘,得出66 220 495 792 924 792 495 220220。这些数字的总和为4070,因此将每个数字除以得出最终权重。
还要为其添加一个Pass到PostFXStack着色器。我将其放在Copy Pass的上方,以使其保持字母顺序。
再次以相同的顺序为其添加一个条目到PostFXStack.Pass枚举。
现在,在DoBloom中进行下采样时,可以使用Bloom-horizontal pass。
限制,结果显然是水平拉伸的,但是看起来很有希望。我们可以通过复制BloomHorizontalPassFragment,重命名并从行切换到列来创建垂直通道。我们在第一个Pass中进行了下采样,但是这次我们保持相同的大小以完成高斯滤波,因此纹理像素大小的偏移量不应增加一倍。
也添加Pass和枚举项。从现在开始,我将不再显示这些步骤。
现在,我们需要在每个金字塔等级的中间增加一个步骤,为此,我们还需要保留纹理标识符。可以通过简单地将PostFXStack构造函数中的循环限制加倍来实现。由于我们还没有引入其他着色器属性名称,因此标识符将全部按顺序排列,否则将需要重新启动Unity。
现在,在DoBloom中,目标标识符必须从每个下采样步骤开始,增加一个,然后增加两个。然后可以在中间放置纹理。水平绘制到中间,然后垂直绘制直到达到目标。我们还需要释放其他纹理,这是最简单的方法,即从上一个金字塔源向后工作。
现在,我们的下采样滤波已经完成,并且看起来比简单的双线性滤波要好得多,但需要更多的纹理样本。幸运的是,通过使用双线性滤波以适当的偏移量在高斯采样点之间进行采样,我们可以减少一些采样量。这样可以将9个采样减少到5个。我们可以在BloomVerticalPassFragment中使用此技巧。偏移在两个方向上分别为3.23076923和1.38461538,权重为0.07027027和0.31621622。
我们不能在BloomHorizontalPassFragment中执行此操作,因为我们已经在该Pass中使用了双线性过滤来进行下采样。其九个样本中的每个样本平均2×2源像素。
2.4 叠加模糊
使用bloom金字塔的顶部作为最终图像产生的统一的混合,看起来并不像任何发光的东西。我们可以通过逐步向上采样,再向下采样金字塔,在一张图像中累积所有的层次来得到想要的结果。
我们可以使用添加混合来组合两个图像,但是让我们对所有通道使用相同的混合模式,而不是添加第二个源纹理。在PostFXStack中声明它的标识符。
然后,在完成DoBloom中的金字塔后,不再直接执行最终的Draw。相反,释放用于上一次迭代的水平绘制的纹理,并将目标设置为用于水平绘制的纹理低一层。
当循环返回时,我们将在相反的方向上再次绘制每个迭代,并将每个级别的结果作为第二个来源。这只能发挥第一次的作用,因此我们需要提前停止一步。之后,以原始图像作为辅助来源绘制到最终目标上。
为了使它起作用,我们需要使用第二个源可用于着色器通道。
并引入一个新的bloom组合通道,以采样并添加两个纹理。和以前一样,我只展示片元程序代码,而不显示新的着色器通道或新的枚举项。
上采样时使用新的Pass。
我们终于有了一个看起来一切都在发光的效果。但是我们的新方法只有在至少有两次迭代的情况下才有效。如果最终只执行一次迭代,则应该跳过整个上采样阶段,而只需要释放用于第一次水平Pass的纹理。
如果我们最终完全跳过bloom,我们就需要中止并执行一个Copy来替代。
2.5 三线性上采样
尽管高斯滤波器会产生平滑的结果,但在上采样时我们仍会执行双线性滤波,这可能会使辉光看起来像块状。这在原始图像中的收缩较高的地方(尤其是在运动时)尤为明显。
我们可以通过切换到双三次过滤来消除这些失真。对此没有硬件支持,但是我们可以使用在Core RP Library的Filtering include文件中定义的SampleTexture2DBicubic函数。通过传递纹理和采样器状态,UV坐标以及交换了尺寸对的纹理像素尺寸矢量,使用它来创建自己的GetSourceBicubic函数。除此之外,它还具有一个用于最大纹理坐标的参数,该参数仅为1,其后是另一个未使用的参数,该参数仅为零。
在bloom-combine传递中使用新功能,因此我们使用双三次滤波来上采样。
三线性采样产生更好的结果,但是需要四个加权的纹理样本或一个样本。因此,让我们通过着色器布尔值将其设为可选。这对应于Universal RP和HDRP的High Quality光晕切换。
为其添加一个切换选项到PostFXSettings.BloomSettings。
在开始进行上采样之前,将其传递给PostFXStack.DoBloom中的GPU。
2.6 减半分辨率
由于所有纹理采样和绘制,Bloom可能需要大量时间才能生成。降低成本的一种简单方法是以一半的分辨率生成它。由于效果很柔和,所以我们可以避免这种情况。这将改变效果的外观,因为我们实际上是在跳过第一次迭代。
首先,在决定跳过bloom时,我们应该提前一步考虑。降低限制加倍为初始检查。
其次,我们需要为将要用作新起点的一半大小的图像声明纹理。它不是Bloom金字塔的一部分,因此我们将为其声明新的标识符。我们将其用于预过滤步骤,因此请适当命名。
返回DoBloom,将源复制到预过滤纹理,并将其用于金字塔的开始,同时将宽度和高度也减半。上金字塔后,我们不需要预过滤纹理,因此可以在那时释放它。
2.7 阈值
Bloom通常在艺术上用于仅使某些东西发光,但是我们的效果目前适用于所有对象,不管它有多亮。尽管从物理上讲没有意义,但是我们可以通过引入亮度阈值来限制影响效果的因素。
我们不能突然消除效果中的颜色,因为这会在预期会逐渐过渡的地方引入清晰的边界。相反,我们将颜色乘以一个权重
其中b为其亮度,t 为配置阈值。我们将使用最大的颜色的RGB通道为b。当阈值为0时,结果总是1,这将保持颜色不变。随着门槛的增加,体重曲线会向下弯曲,在b <= t 处为零。由于这条曲线的形状,它被称为膝盖曲线。
该曲线在某个角度处达到零,这意味着尽管过渡过程比夹具更平滑,但仍存在一个陡峭的截止点。这就是为什么它也被称为硬膝盖的原因。我们可以通过改变重量来控制膝盖的形状
并且K就是膝盖,设置为0~1的滑动区间。
让我们将阈值和拐点滑块添加到PostFXSettings.BloomSettings中。我们将配置的阈值视为伽玛值,因为它在视觉上更直观,因此在将其发送到GPU时,必须将其转换为线性空间。我们将其设为开放式,即使阈值大于零将在此时消除所有颜色,因为我们仅限于LDR。
我们将通过一个名为_BloomThreshold的向量将阈值发送到GPU。在PostFXStack中为其声明标识符。
我们可以计算权重函数的常数部分,并将其放入向量的四个分量中,以使着色器更简单:
我们将在新的预过滤器通道中使用它,该通道将替换DoBloom中的初始复制通道,从而在将图像大小减半的同时将阈值应用于2×2像素的平均值。
将阈值向量和一个将其应用于颜色的函数添加到PostFXShaderPasses,然后是使用它的新的Pass函数。
2.8 强度
我们通过添加强度滑块来控制光晕的整体强度来结束本教程。我们不会给它一个限制,因此可以根据需要将整个图像放大。
如果强度设置为零,我们可以跳过光晕,因此请在DoBloom开始时进行检查。
否则,使用_BloomIntensity的新标识符将强度传递给GPU。我们将在合并过程中使用它来加权低分辨率图像,因此我们不需要创建额外的Pass。对于所有绘制(将最终绘制除去到相机目标),将其设置为1。
现在,我们只需要将BloomCombinePassFragment中的低分辨率颜色乘以强度即可。
下一章,介绍HDR。
本文翻译自 Jasper Flick的系列教程