Unity可编程渲染管线系列(十一)后处理(全屏特效)

2020-08-17 16:00:13 浏览数 (1)

本文重点: 创建后处理栈资产 使用渲染纹理(render textures) 绘制全屏三角形 应用多步模糊效果和基于深度的条纹。 逐相机配置栈

这是涵盖Unity的可脚本化渲染管道的教程系列的第11部分。它涵盖了后处理堆栈的创建。

本教程是CatLikeCoding系列的一部分,原文地址见文章底部。“原创”标识意为原创翻译而非原创教程。

本教程使用Unity 2018.4.4f1制作。

(弄乱图像)

1 后处理栈(Post-Processing Stack)

除了渲染构成场景一部分的几何图形之外,还可以随后更改生成的图像。这用于应用全屏效果,例如环境光遮挡,光晕,颜色渐变和景深。通常,多个后处理步骤按特定顺序应用,该顺序是通过一个或多个资产或组件配置的,共同形成一个后处理堆栈。Unity具有此类堆栈的多种实现。

在本教程中,我们将创建一个自己的简单后处理堆栈,并具有两个效果以供实际使用。你可以扩展它以支持更有用的效果,或者更改方法,以便可以连接到现有解决方案。

1.1 Asset

我们将引入MyPostProcessingStack资产类型来控制后处理。给它一个公共的Render方法,并带有一个CommandBuffer参数,它可以用来执行其工作。这个想法是堆栈将用命令填充缓冲区,但是执行和清除缓冲区是管道的责任。最初,只需记录调用堆栈的方法即可。

为我们的堆栈创建资产。它还没有任何配置选项,但是我们稍后再添加。

1.2 默认栈

要使用堆栈,MyPipeline需要对其进行引用。给它一个字段来跟踪默认堆栈,该堆栈是通过其构造函数设置的。

也给MyPipelineAsset一个默认堆栈的配置选项,以便它可以将其传递给管道实例。

将我们的单个堆栈资产设置为默认值。

(分配默认的栈)

1.3 渲染栈

要隔离堆栈的渲染,请向MyPipeline添加专用于后处理效果的命令缓冲区。如果存在默认堆栈,请使用缓冲区渲染它,然后执行并清除缓冲区。后处理发生在常规渲染完成后,因此在Render中调用DrawDefaultPipeline之后。

此时,堆栈应该能记录到每帧渲染时都会被调用。

2 渲染目标

要更改渲染的图像,我们必须先读取它。使之成为可能的最简单,最可靠的方法是将管道渲染为纹理。到现在为止,我们一直渲染到摄影机的目标是帧缓冲区。但也可以是渲染纹理,例如在渲染反射探针的面的时候。选中后,Unity还会始终为场景窗口及其小型相机预览渲染纹理。

2.1 渲染到纹理

在清除渲染目标之前,如果有堆栈,我们必须获取临时渲染纹理。这次,我们将使用CommandBuffer.GetTemporaryRT通过摄影机缓冲区安排纹理的获取。这种方法要求我们提供着色器属性ID,以及纹理的宽度和高度,应与相机的像素尺寸匹配。我们使用_CameraColorTexture作为着色器属性名称。

这将使我们的纹理绑定到提供的ID上。接下来,我们需要使其成为渲染目标。这是通过以ID为参数调用相机缓冲区上的SetRenderTarget来完成的。该ID有一个RenderTargetIdentifier,但假定它是着色器属性ID,则从int隐式转换为该类型。另外,我们可以指定加载和存储操作。假设我们正在使用单个相机,因此不必担心纹理的初始状态,因为接下来我们将对其进行清除。

如果需要,我们必须在后处理之后释放渲染纹理。这是通过在具有相同ID的相机缓冲区上调用ReleaseTemporaryRT来完成的。严格来说这不是必须的,因为一旦相机完成渲染,缓冲区所声明的纹理应自动释放,但是最好尽快进行明确清理。

我们可以缓存RenderTargetIdentifier以便重用吗?

是的,这样转换仅发生一次,因此效率更高。但是,在本教程中我不使用。

2.2 Blitting

此时,我们的场景似乎不再被渲染,因为我们正在渲染到纹理而不是相机的目标。为了解决这个问题,使用MyPostProcessingStack.Render将纹理的内容复制到最终目标。可以通过使用源ID和目标ID作为参数在缓冲区上调用Blit来实现。为此,添加相机纹理的颜色ID作为参数,并使用BuiltinRenderTextureType.CameraTarget作为目标,该目标也将隐式转换为RenderTargetIdentifier。

blit是什么意思? 它来自旧的位边界块传输例程名称BitBLT,简称为blit。

在MyPipeline.Render中添加颜色纹理ID参数。

我们再次看到结果,但是天空盒被绘制在它之前渲染的所有东西之上,因此只有透明对象保持可见。发生这种情况是因为我们没有使用深度缓冲区。可以通过向GetTemporaryRT添加另一个参数来指定深度使用的位数来响应深度缓冲区。默认情况下为零,这将禁用深度缓冲区。我们用24重新激活它。

为什么是24位? 另一个选项是16位,但是我们希望对深度值使用尽可能高的精度,即24位。有时,深度缓冲区的精度列为32,但是额外的8位用于模板缓冲区,而不是深度。你可以指定32,但其作用与24相同。

现在,我们的场景似乎照常渲染。但是,检查帧调试器将显示已添加了另一个步骤。后处理命令缓冲区的嵌套执行会自动采样。在其作用域内,blit动作列为“Draw Dynamic”。

2.3 隔离深度纹理

一些后期处理效果依赖于深度信息,深度信息必须通过从深度缓冲区读取来获取。为了使之成为可能,我们必须使用自己的ID将深度信息显式呈现给纹理,为此我们将使用_CameraDepthTexture。获得深度纹理的方法与颜色纹理的方法相同,只是必须使用不同的纹理格式。这要求我们再次调用GetTemporaryRT,并带有两个额外的参数。首先是过滤器模式,应该是默认的FilterMode.Point,然后是RenderTextureFormat.Depth。颜色纹理的深度位应设置回零,这是默认值,但让我们明确一点。

接下来,我们必须调用SetRenderTarget的变体,该变体允许我们使用其自身的load和store操作指定一个单独的深度缓冲区。

将深度的ID也传递到堆栈,完成后释放深度纹理。

将所需的参数添加到MyPostProcessingStack.Render。之后,应该再次将场景渲染为正常。

现在还可以使用深度纹理作为blit的来源,它将显示原始深度信息而不是颜色。其结果取决于图形API。

(原始深度)

3 全屏三角形

Blit纹理基本上与渲染常规几何体相同。通过使用着色器渲染全屏四边形来完成此操作,该着色器根据其屏幕空间位置对纹理进行采样。通过检查帧调试器中的“Dynamic Draw”条目,可以看到一些提示。颜色纹理已分配给_MainTex,并且使用四个顶点和索引。

因此,Blit渲染了一个由两个三角形组成的四边形。此方法可行,但可以通过使用覆盖整个屏幕的单个三角形来以更有效的方式完成。这样做的明显好处是将顶点和索引减少到三个。但是,更重要的区别是,它消除了四边形的两个三角形相交处的对角线。由于GPU将片段并行地分成小块,因此某些片段最终会沿着三角形的边缘浪费掉。由于四边形有两个三角形,沿对角线的片段块会渲染两次,因此效率低下。除此之外,渲染单个三角形可以具有更好的本地缓存。

(冗余块渲染,比较夸张)

尽管四边形和单个三角形之间的性能差异可能很小,但这个对当今的标准方法使用全屏三角形来说,已经足够了,因此我们也使用它。但是,Unity对此没有标准的blit方法,我们必须自己创建一个。

3.1 Mesh

第一步是创建三角形。通过MyPostProcessingStack中的静态Mesh字段对其进行跟踪,并在需要时通过静态InitializeStatic方法创建它,该方法在Render的开头调用。

网格需要三个顶点和一个三角形。我们将直接在剪辑空间中绘制它,因此我们可以跳过矩阵乘法并忽略Z维度。这意味着屏幕的中心是原点,并且XY坐标在边缘处为-1或1。Y轴的方向取决于平台,但这与三角形无关紧要。要创建全屏三角形,可以使用顶点

(相对于剪辑空间的三角形)

3.2 着色

第二步是编写着色器以复制纹理。为此创建一个“Hidden/My Pipeline/PostEffectStack”着色器,该过程不会执行剔除并且会忽略深度,但仅执行一次。让它使用CopyPassVertex和CopyPassFragment函数,我们将在单独的PostEffectStack.hlsl包含文件中定义它们。

着色器代码很短。我们只需要顶点位置,而不必进行变换。除此之外,我们还将输出每个顶点的UV坐标,即将XY坐标减半加?。我们使用每个片段的纹理进行采样。可以直接对_CameraColorTexture进行采样,所以开始吧。

让MyPostProcessingStack跟踪使用此着色器的静态材质。Shader.Find是获取它的最简单方法。

这始终在编辑器中有效,但如果不包含着色器,则构建将失败。我们可以通过将其添加到“Graphics ”项目设置中的“Always Included Shaders”数组中来强制执行此操作。还有其他方法可以确保包含着色器,但这是需要最少代码量的方法。

(始终包括后处理着色器)

3.3 绘制

现在,我们可以通过调用CommandBuffer.DrawMesh而不是Blit来复制颜色纹理。至少,我们需要指定网格,转换矩阵和要使用的材质。由于我们不变换顶点,所以任何矩阵都可以。

但是Blit不仅可以绘制四边形。它还设置渲染目标。我们现在必须自己做。

现在,我们用自己的三角形渲染最终结果,你可以通过帧调试器进行验证。现在,draw call列变为“Draw Mesh”,并且仅使用三个顶点且不使用矩阵。结果看起来不错,但它看起来可能颠倒了。发生这种情况是因为Unity在某些情况下会进行垂直翻转以获得一致的结果。例如,当不使用OpenGL时,场景视图窗口和小型相机预览将被翻转。

我们的着色器可以通过检查_ProjectionParams向量的X分量来检测是否发生翻转,该向量是在管道调用SetupCameraProperties时设置的。如果它是负数,那么我们应该翻转V坐标。

3.4 可变源纹理

CommandBuffer.Blit可以与任何源纹理一起使用。通过将其绑定到_MainTex着色器属性来完成此操作。我们可以通过在MyPostProcessingStack.Render中绘制三角形之前调用CommandBuffer.SetGlobalTexture来执行相同的操作。

然后调整着色器,使其对_MainTexture而不是_CameraColorTexture进行采样。这样,我们的堆栈不再需要知道管道使用哪个着色器属性。

4 模糊

要查看实际的后处理堆栈,让我们创建一个简单的模糊效果。

4.1 着色器

我们将所有后处理效果的代码放在同一着色器中,并对每一个使用不同的通道。这样,可以重复使用着色器文件中的代码,而只需要处理一种材质。首先将HLSL文件中的CopyPassVertex重命名为DefaultPassVertex,因为它是一个简单的顶点程序,可以用于多种效果。然后添加一个BlurPassFragment,最初是CopyPassFragment的副本。

然后调整着色器文件以匹配,添加第二通道以进行模糊处理。将剔除和深度配置上移到子着色器级别,这样我们就不必重复该代码。可以通过将其包含在HLSLINCLUDE块中来共享include指令。

现在,我们可以在MyPostProcessingStack.Render中选择模糊通道,方法是将1作为第四个参数添加。所需的第三个参数是submesh索引,该索引始终为零。为了更清楚地显示我们正在渲染的通道,请在MyPostProcessingStack中为复制和模糊通道定义一个Pass枚举。

4.2 过滤(Filtering)

模糊是通过对图像进行滤波来完成的,这意味着对每个渲染片段采样并组合源纹理的多个像素。为此,向HLSL文件添加BlurSample函数,该函数具有原始UV坐标的参数以及单独的U和V偏移。偏移量以像素为单位定义。我们可以使用U和V坐标的相关屏幕空间导数将偏移量转换为UV空间。首先对源纹理进行采样而没有任何偏移。由于效果以像素比例起作用,因此通过增加游戏窗口的比例因子最容易看到。

(×10比例的未修改图像)

最简单的模糊操作是2×2框式滤镜,它平均四个像素块。我们可以通过四次采样来做到这一点,但是我们也可以通过在四个像素的角偏移UV坐标两个像素半个像素一次来做到这一点。然后,双线性纹理过滤将为我们进行平均处理。

(2X2的 box filter)

但是,默认的滤镜模式是点,它会钳位到最近的像素,因此当前仅移动图像。我们必须更改MyPipeline.Render,以便它对颜色纹理使用双线性过滤。仅当不在像素中心采样时,此更改才重要。

但是,默认的滤镜模式是点,它会钳位到最近的像素,因此当前仅移动图像。我们必须更改MyPipeline.Render,以便它对颜色纹理使用双线性过滤。仅当不在像素中心采样时,此更改才重要。

(应用 2X2的 box filter)

虽然这会使图像模糊,但由于偏移,它也会稍微移动一点。可以通过以下方式消除方向偏差:对所有四个对角线方向的偏移量进行四次采样,然后对其求平均。由于这是最后的渲染步骤,因此不需要Alpha通道,可以将其设置为1。这样我们就可以避免计算Alpha通道的平均值。

(平均化采样)

这覆盖了3×3像素区域,其中有2×2个采样重叠,这意味着靠近中心的像素对最终颜色的贡献更大。此操作称为3×3tent过滤器。

(3×3 tent filter)

4.3 模糊两次

放大时,模糊效果可能看起来很强,但是缩小时,效果却很微妙,而在高分辨率下渲染时,效果几乎不明显。我们可以通过进一步增加滤镜区域来增强效果,但这也会使通过变得更加复杂。另一种方法是保留我们拥有的过滤器,但会不止一次应用它。例如,执行第二次模糊通过会将滤镜大小增加到5×5。来做吧。

首先,将单个blit的所有代码放入单独的Blit方法中,以便我们可以重用它。它的参数是命令缓冲区,源和目标ID,以及通道。

现在,我们可以在“Render”中进行两次blit操作,但是无法将颜色纹理从blit变为自身。结果将是不确定的,并且因平台而异。因此,我们必须获得一个临时的渲染纹理来存储中间结果。为了能够创建此纹理,我们必须添加宽度和高度作为参数。

在MyPipeline.Render中提供宽度和高度。

(模糊两次)

4.4 可配置模糊

两次模糊产生较柔和的结果,但在高分辨率下仍然不明显。为了使其脱颖而出,我们将不得不添加更多的通道。让通过向MyPostProcessingStack添加模糊强度滑块来使其可配置。

将模糊移动到单独的“Blur ”方法。仅当强度为正时才在“Render ”中调用它,否则执行常规复制。

让我们从强度大于1时总是模糊两次开始。如果没有,我们就可以将单个模糊直接对准相机目标。

循环可以从任何强度开始做,在循环中执行两次模糊,直到最多保留两个通道。在该循环内,可以在使用临时纹理和原始颜色纹理作为渲染目标之间进行切换。

在仅模糊一次的特殊情况下,我们可以避免获得临时纹理。

(模糊强度为5)

通过在帧调试器的Blur条目下将其所有DrawCall分组,在Blur方法中开始和结束嵌采样本来结束模糊效果。

(帧调试器里的模糊)

5 使用深度缓存

如前所述,某些后处理效果取决于深度缓冲区。我们将提供一个示例,说明如何通过添加效果来绘制线条以指示深度。

5.1 深度条纹

将片段函数添加到HLSL文件中以绘制深度条纹。从采样深度开始,通过_MainTex进行采样。可以使用SAMPLE_DEPTH_TEXTURE宏使其适用于所有平台。

我们需要世界空间深度,它是到附近位置的距离,而不是相机位置,可以通过LinearEyeDepth函数找到。除了原始深度,它还需要_ZBufferParams,这是SetupCameraProperties设置的另一个向量。

根据深度绘制平滑条纹的最简单方法 dd是用

。结果不是很漂亮,但足以说明已使用深度信息。

向着色器添加一个用于深度条纹的通道。

将通道添加到MyPostProcessingStack中的枚举,然后在渲染器中对其进行深度着色。在模糊之前执行此操作,但是将模糊强度设置为零以将其禁用。

(深度条纹)

5.2 混合深度和颜色

我们可以将条纹化转为原始图像,来取代完全替换原始图像。这要求我们使用两个源纹理。可以直接使用_CameraDepthTexture,但继续保持堆栈,让它不知道管道如何精确呈现深度,然后将其绑定到_DepthTex以与_MainTex一起使用。同样,为了保持模糊效果,我们必须渲染颜色纹理,这需要临时纹理和额外的副本。将所有代码放在单独的DepthStripes方法中,该方法将“draws ”分组在“Depth Stripes”下。

然后调整DepthStripesPassFragment,以便对颜色纹理和深度纹理进行采样,并将颜色与条纹相乘。

(给深度条纹上色)

5.3 跳过天空盒

条纹将应用于所有物体,包括天空盒。但是天空框不会渲染到深度缓冲区,这意味着它最终会以最大的深度值结束。但是,结果不稳定,如果可以看到很多天空,那么在照相机移动过程中,很大一部分窗口可能会闪烁得很厉害。所以,最好不要修改天空。默认的原始深度值为0或1,具体取决于深度缓冲区是否反转(对于非OpenGL平台就是这种情况)。如果是,则定义了UNITY_REVERSED_Z,我们可以用来检查片段是否具有有效深度。如果不是,请返回原始颜色。

5.4 仅不透明的后处理

除天空盒外,透明几何也不会写入深度缓冲区。因此,条纹将基于其背后的内容而应用于透明表面的上层。景深等效果的行为方式相同。对于某些效果,最好不要将它们完全应用于透明对象。这可以通过在透明几何图形之前对其进行渲染,使其成为不透明后的预透明效果来实现。

通过将MyPostProcessingStack.Render分为两种方法,我们可以使深度条纹仅影响不透明的几何:RenderAfterOpaque和RenderAfterTransparent。前者初始化并进行条纹处理,而后者进行模糊处理。

MyPipeline.Render现在还必须使用适当的方法在绘制天空盒后直接调用堆栈。

我们还需要确保在渲染不透明的后期处理效果之后正确设置了渲染目标。再次设置颜色和深度目标,这一次我们要确保它们已加载。

(不透明几何图形后绘制深度条纹。)

5.5 可选条纹

因为深度条纹只是一个测试,所以让我们通过向MyPostProcessingStack添加一个切换使其成为可选。

(深度条纹开启)

6 逐相机后处理

当前,启用后处理的唯一方法是配置默认堆栈,该堆栈将应用于所有相机。这不仅包括主摄像机和场景摄像机,还包括用于渲染反射探针的摄像机以及你可能使用的任何其他摄像机。因此,默认栈仅适用于那些些需要应用于所有相机的效果。但通常,大多数后处理效果仅应用于主相机。另外,可能会有多个摄像机,每个摄像机需要不同的效果。因此,让我们可以为每个摄像机选择一个栈。

6.1 相机配置

我们无法将配置选项添加到现有的Camera组件。但可以做的是创建一个包含额外选项的新组件类型。将其命名为MyPipelineCamera,要求它连接到具有Camera组件的游戏对象上,并添加一个可配置的后处理堆栈字段。添加一个公共的getter属性来检索堆栈。

将此组件连接到主摄像机并为其分配堆栈。然后可以将管道资产的默认堆栈设置为无。

(带有堆栈的额外相机组件)

为了使这项工作有效,MyPipeline.Render现在必须从用于渲染的摄像机中获取MyPipelineCamera组件。如果组件存在,请使用其堆栈作为活动堆栈,而不是默认堆栈。

6.2 场景摄像机

现在,我们可以为场景中的每个摄像机选择一个后处理堆栈,但是我们无法直接控制用于渲染场景窗口的摄像机。可以做的是将ImageEffectAllowedInSceneView属性附加到MyPipelineCamera。

尽管具有属性名称,但它不适用于特定的图像效果。Unity会简单地将活动的主摄像机的所有具有此属性的组件复制到场景摄像机。因此,要使这项工作有效,相机必须具有MainCamera标签。

(相机标签设置为main)

下一章介绍,图像质量。

0 人点赞