RhinoFreak 发表于 2022-1-19 09:35

UnityShader精要笔记十五 屏幕后处理效果

本文继续对《UnityShader入门精要》——冯乐乐 第十二章 屏幕后处理效果 进行学习
参考第12章 屏幕后处理效果

屏幕后处理效果(screen post-procesing effects ) 是游戏中实现屏幕特效的常见方法。在本章中,我们将学习如何在Unity 中利用渲染纹理来实现各种常见的屏幕后处理效果。
在12.1 节中,我们首先会解释在Unity 中实现屏幕后处理效果的原理,并建立一个基本的屏幕后处理脚本系统。随后在12.2 节中,我们会使用这个系统实现一个简单的调整画面亮度、饱和度和对比度的屏幕特效。在12.3 节中,我们会接触到图像滤波的概念,并利用Sobel 算子在屏幕空间中对图像进行边缘检测, 实现描边效果。在此基础上,12.4 节将会介绍如何实现一个高斯模糊的屏幕特效。在12.5和12.6 节中,我们会分别介绍如何实现Bloom 和运动模糊效果。
一、建立一个基本的屏幕后处理脚本系统


屏幕后处理,顾名思义,通常指的是在渲染完整个场景得到屏幕图像后,再对这个图像进行一系列操作,实现各种屏幕特效。使用这种技术,可以为游戏画面添加更多的艺术效果,例如景深( Depth of Field )、运动模糊( Motion Blur )等。
因此,想要实现屏幕后处理的基础在于得到渲染后的屏幕图像,即抓取屏幕,而Unity 为我们提供了这样一个方便的接口一一OnRenderlmage 函数。它的函数声明如下:
MonoBehaviour.OnRenderImage(RenderTexture src, RenderTexture dest)
当我们在脚本中声明此函数后, Unity 会把当前渲染得到的图像存储在第一个参数对应的源渲染纹理中,通过函数中的一系列操作后,再把目标渲染纹理,即第二个参数对应的渲染纹理显示到屏幕上。在OnRenderlmage 函数中,我们通常是利用 Graphics.Blit函数来完成对渲染纹理的处理。它有3 种函数声明:
public static void Blit(Texture src, RenderTexture dest);public static void Blit(Texture src, RenderTexture dest, Material mat, int pass= -1);public static void Blit(Texture src, Material mat, int pass= -1);
参数src 对应了源纹理,在屏幕后处理技术中,这个参数通常就是当前屏幕的渲染纹理或是上一步处理后得到的渲染纹理。参数dest 是目标渲染纹理,如果它的值为null 就会直接将结果显示在屏幕上。参数mat 是我们使用的材质,这个材质使用的Unity Shader 将会进行各种屏幕后处理操作,而src 纹理将会被传递给Shader 中名为 _MainTex 的纹理属性。参数pass 的默认值为 -1 ,表示将会依次调用Shader 内的所有Pass。否则,只会调用给定索引的Pass 。
1.ImageEffectOpaque 属性


在默认情况下, OnRenderlmage 函数会在所有的不透明和透明的Pass 执行完毕后被调用,以便对场景中所有游戏对象都产生影响。但有时,我们希望在不透明的Pass (即渲染队列小于等于2500 的Pass,内置的Background 、Geometry 和AlphaTest 渲染队列均在此范围内)执行完毕后立即调用OnRenderlmage 函数,从而不对透明物体产生任何影响。此时,我们可以OnReoderlmage
函数前添加ImageEffectOpaque 属性来实现这样的目的。13.4 节展示了这样一个例子,在13.4 节中,我们会利用深度和法线纹理进行边缘检测从而实现描边的效果,但我们不希望透明物体也被描边。
2.过程


要在Unity 中实现屏幕后处理效果, 过程通常如下:我们首先需要在摄像中添加一个用于屏幕后处理的脚本。在这个脚本中,我们会实现OnRenderlmage 函数来获取当前屏幕的渲染纹理。然后,再调用Graphics.Blit 函数使用特定的Unity Shader 来对当前图像进行处理,再把返回的渲染纹理显示到屏幕上。对于一些复杂的屏幕特效,我们可能需要多次调用Graphics.Blit 函数来对上一步的输出结果进行下一步处理。

但是,在进行屏幕后处理之前,我们需要检查一系列条件是否满足,例如当前平台是否支持渲染纹理和屏幕特效,是否支持当前使用的Unity Shader 等。为此,我们创建了一个用于屏幕后处理效果的基类,在实现各种屏幕特效时,我们只需要继承自该基类,再实现派生类中不同的操作即可。读者可在本书资源的Assets/Scripts/Chapter12/PostEffectsBase.cs 中找到该脚本。
3.PostEffectsBase.cs 代码

using UnityEngine;using System.Collections;public class PostEffectsBase : MonoBehaviour {    // Called when start    protected void CheckResources() {      bool isSupported = CheckSupport();                if (isSupported == false) {            NotSupported();      }    }    // Called in CheckResources to check support on this platform    protected bool CheckSupport() {      if (SystemInfo.supportsImageEffects == false) {            Debug.LogWarning("This platform does not support image effects.");            return false;      }                return true;    }    // Called when the platform doesn't support this effect    protected void NotSupported() {      enabled = false;    }      protected void Start() {      CheckResources();    }    // Called when need to create the material used by this effect    protected Material CheckShaderAndCreateMaterial(Shader shader, Material material) {      if (shader == null) {            return null;      }                if (shader.isSupported && material && material.shader == shader)            return material;                if (!shader.isSupported) {            return null;      }      else {            material = new Material(shader);            material.hideFlags = HideFlags.DontSave;            if (material)                return material;            else               return null;      }    }}
参考Unity 编辑器扩展一 常用属性

//挂载该类的对象,必须要有Animator组件
二、调整屏幕的亮度、饱和度和对比度


图12.1 左图:原效果。右图:调整了亮度(值为1.2)、饱和度(值为1.6)和对比度(值为1.2)后的效果

1.BrightnessSaturationAndContrast


新建一个脚本。在本书资源中,该脚本名为BrightnessSaturationAndContrast.cs . 把该脚本拖曳到摄像机上
using UnityEngine;using System.Collections;public class BrightnessSaturationAndContrast : PostEffectsBase {    public Shader briSatConShader;    private Material briSatConMaterial;    public Material material {          get {            briSatConMaterial = CheckShaderAndCreateMaterial(briSatConShader, briSatConMaterial);            return briSatConMaterial;      }      }        public float brightness = 1.0f;        public float saturation = 1.0f;        public float contrast = 1.0f;    void OnRenderImage(RenderTexture src, RenderTexture dest) {      if (material != null) {            material.SetFloat("_Brightness", brightness);            material.SetFloat("_Saturation", saturation);            material.SetFloat("_Contrast", contrast);            Graphics.Blit(src, dest, material);      } else {            Graphics.Blit(src, dest);      }    }}
每当OnRenderlmage 函数被调用时, 它会检查材质是否可用。如果可用,就把参数传递给材质,再调用Graphics.Blit 进行处理; 否则, 直接把原图像显示到屏幕上,不做任何处理。
2.Chapter12-BrightnessSaturationAndContrast.shader

    Properties {      _MainTex ("Base (RGB)", 2D) = "white" {}      _Brightness ("Brightness", Float) = 1      _Saturation("Saturation", Float) = 1      _Contrast("Contrast", Float) = 1    }
在12.1 节中,我们提到Graphics.Blit(src, dest, material)将把第一个参数传递给Shader 中名为 _MainTex 的属性。因此,我们必须声明一个名为 _MainTex 的纹理属性。除此之外,我们还声明了用于调整亮度、饱和度和对比度的属性。这些值将会由脚本传递而得。事实上,我们可以省略 Properties 中的属性声明, Properties 中声明的属性仅仅是为了显示在材质面板中,但对于屏幕特效来说,它们使用的材质都是临时创建的,我们也不需要在材质面板上调整参数,而是直接从脚本传递给Unity Shader。
SubShader {      Pass {            ZTest Always Cull Off ZWrite Off
屏幕后处理实际上是在场景中绘制了一个与屏幕同宽同高的四边形面片,为了防止它对其他物体产生影响, 我们需要设置相关的渲染状态。在这里,我们关闭了深度写入, 是为了防止它“挡住” 在其后面被渲染的物体。例如,如果当前的OnRenderlmage 函数在所有不透明的Pass 执行完毕后立即被调用,不关闭深度写入就会影响后面透明的Pass 的渲染。这些状态设置可以认为是用于屏幕后处理的Shader 的“标配”。
struct v2f {    float4 pos : SV_POSITION;    half2 uv: TEXCOORD0;};          v2f vert(appdata_img v) {    v2f o;                o.pos = mul(UNITY_MATRIX_MVP, v.vertex);                o.uv = v.texcoord;                         return o;}
在上面的顶点着色器中,我们使用了Unity 内置的appdata_img 结构体作为顶点着色器的输入,读者可以在UnityCG.cginc 中找到该结构体的声明, 它只包含了图像处理时必需的顶点坐标和纹理坐标等变量。
fixed4 frag(v2f i) : SV_Target {    fixed4 renderTex = tex2D(_MainTex, i.uv);                // Apply brightness    fixed3 finalColor = renderTex.rgb * _Brightness;            // Apply saturation    fixed luminance = 0.2125 * renderTex.r + 0.7154 * renderTex.g + 0.0721 * renderTex.b;    fixed3 luminanceColor = fixed3(luminance, luminance, luminance);    finalColor = lerp(luminanceColor, finalColor, _Saturation);            // Apply contrast    fixed3 avgColor = fixed3(0.5, 0.5, 0.5);    finalColor = lerp(avgColor, finalColor, _Contrast);            return fixed4(finalColor, renderTex.a);}
首先,我们得到对原屏幕图像〈存储在 _MainTex 中)的采样结果renderTex 。然后,利用 _Brightness 属性来调整亮度。亮度的调整非常简单,我们只需要把原颜色乘以亮度系数 _Brightness即可。然后, 我们计算该像素对应的亮度值( Luminance),这是通过对每个颜色分量乘以一个特定的系数再相加得到的。我们使用该亮度值创建了一个饱和度为0 的颜色值, 并使用 _Saturation属性在其和上一步得到的颜色之间进行插值,从而得到希望的饱和度颜色。对比度的处理类似,我们首先创建一个对比度为0 的颜色值〈各分量均为0.5 ),再使用 _Contrast 属性在其和上一步得到的颜色之间进行插值,从而得到最终的处理结果。
3.《UnityShader入门精要》学习笔记——第十二章——屏幕后处理效果


为什么half luminance = 0.2152 * renderTex.r + 0.7154 *renderTex.g + 0.0721 * renderTex.b;因为人眼对于RGB中G的亮度感知最明显,B的亮度感知最弱


左:正常RGB;右:左图的明度模式

三、边缘检测


在12.2 节中,我们己经学习了如何实现一个简单的屏幕后处理效果。在本节中,我们会学习一个常见的屏幕后处理效果一一边缘检测。边缘检测是描边效果的一种实现方法,在本节结束后,我们可以得到类似图12.3 中的效果。

图12.3 左图:12.2节得到的结果。右图:进行边缘检测后的效果

边缘检测的原理是利用一些边缘检测算子对图像进行 卷积( convolution )操作,我们首先来了解什么是卷积。

参考CNN 入门讲解:什么是卷积(Convolution)?
1.什么是卷积


在图像处理中,卷积操作指的就是使用一个卷积核(kernel) 对一张图像中的每个像素进行一系列操作。卷积核通常是一个四方形网格结构(例如2×2、3 ×3 的方形区域),该区域内每个方格都有一个权重值。当对图像中的某个像素进行卷积时,我们会把卷积核的中心放置于该像素上,如图12.4 所示,翻转核之后再依次计算核中每个元素和其覆盖的图像像素值的乘积并求和,得到的结果就是该位置的新像素值。


图12.4 卷积核与卷积。使用一个3×3大小的卷积核对一张5×5大小的图像进行卷积操作,当计算图中红色方块对应的像素的卷积结果时,我们首先把卷积核的中心放置在该像素位置,翻转核之后再依次计算核中每个元素和其覆盖的图像像素值的乘积并求和,得到新的像素值

这样的计算过程虽然简单,但可以实现很多常见的图像处理效果,例如图像模糊、边缘检测等。例如,如果我们想要对图像进行均值模糊,可以使用一个3x3的卷积核,核内每个元素的值均为1/9。
2.常见的边缘检测算子


卷积操作的神奇之处在于选择的卷积核。那么,用于边缘检测的卷积核(也被称为边缘检测算子〉应该长什么样呢?在回答这个问题前,我们可以首先回想一下边到底是如何形成的。如果相邻像素之间存在差别明显的颜色、亮度、纹理等属性,我们就会认为它们之间应该有一条边界。这种相邻像素之间的差值可以用梯度( gradient )来表示,可以想象得到,边缘处的梯度绝对值会比较大。基于这样的理解,有几种不同的边缘检测算子被先后提出来。


图12.5 三种常见的边缘检测算子

3 种常见的边缘检测算子如图12.5 所示,它们都包含了两个方向的卷积核,分别用于检测水平方向和竖直方向上的边缘信息。在进行边缘检测时,我们需要对每个像素分别进行一次卷积计算,得到两个方向上的梯度值Gx 和Gy,而整体的梯度可按下面的公式计算而得:


image.png

由于上述计算包含了开根号操作,出于性能的考虑,我们有时会使用绝对值操作来代替开根号操作:


image.png

当得到梯度G后,我们就可以据此来判断哪些像素对应了边缘(梯度值越大,越有可能是边缘点)。3.实现


本节将会使用Sobel 算子进行边缘检测,实现描边效果。具体代码分析见原书。

需要注意的是,本节实现的边缘检测仅仅利用了屏幕颜色信息,而在实际应用中,物体的纹理、阴影等信息均会影响边缘检测的结果,使得结果包含许多非预期的描边。为了得到更加准确的边缘信息,我们往往会在屏幕的深度纹理和法线纹理上进行边缘检测。我们将会在13.4节中实现这种方法。
四、高斯模糊


在12.3 节中,我们学习了卷积的概念,并利用卷积实现了一个简单的边缘检测效果。在本节中,我们将学习卷积的另一个常见应用一一高斯模糊。

模糊的实现有很多方法,例如均值模糊和中值模糊。均值模糊同样使用了卷积操作,它使用的卷积核中的各个元素值都相等,且相加等于1 ,也就是说,卷积后得到的像素值是其邻域内各个像素值的平均值。而中值模糊则是选择邻域内对所有像素排序后的中值替换掉原颜色。一个更高级的模糊方法是高斯模糊。在学习完本节后,我们可以得到类似图12.7 中的效果。


图12.7 左图:原效果。右图:高斯模糊后的效果

1.高斯滤波


高斯模糊同样利用了卷积计算,它使用的卷积核名为高斯核。高斯核是一个正方形大小的滤波核,其中每个元素的计算都是基于下面的高斯方程:


image.png

其中,σ是标准方差(一般取值为1), x 和y 分别对应了当前位置到卷积核中心的整数距离。要构建一个高斯核,我们只需要计算高斯核中各个位置对应的高斯值。为了保证滤波后的图像不会变暗,我们需要对高斯核中的权重进行归一化,即让每个权重除以所有权重的和,这样可以保证所有权重的和为1 。因此, 高斯函数中е 前面的系数实际不会对结果有任何影响。图12.8 显示了一个标准方差为1 的5x5 大小的高斯核。

高斯方程很好地模拟了邻域每个像素对当前处理像素的影响程度一一距离越近,影响越大。高斯核的维数越高,模糊程度越大。使用一个NxN的高斯核对图像进行卷积滤波,就需要NxNxWxH(W 和H 分别是图像的宽和高〉次纹理来样。当N 的大小不断增加时,采样次数会变得非常巨大。

幸运的是,我们可以把这个二维高斯函数拆分成两个一维函数。也就是说,我们可以使用两个一维的高斯核〈图12.8 中的右图〉先后对图像进行滤波,它们得到的结果和直接使用二维高斯核是一样的,但采样次数只需要2xNxWxH。我们可以进一步观察到,两个一维高斯核中包含了很多重复的权重。对于一个大小为5 的一维高斯核,我们实际只需要记录3 个权重值即可。


图12.8 一个5×5大小的高斯核。左图显示了标准方差为1的高斯核的权重分布。我们可以把这个二维高斯核拆分成两个一维的高斯核(右图)

在本节,我们将会使用上述5x5的高斯核对原图像进行高斯模糊。我们将先后调用两个Pass,第一个Pass 将会使用竖直方向的一维高斯核对图像进行滤波,第二个Pass 再使用水平方向的一维高斯核对图像进行滤波,得到最终的目标图像。在实现中,我们还将利用图像缩放来进一步提高性能,并通过调整高斯滤波的应用次数来控制模糊程度(次数越多,图像越模糊〉。
2.实现


具体代码分析见原书。
五、Bloom效果


Bloom 特效是游戏中常见的一种屏幕效果。这种特效可以模拟真实摄像机的一种图像效果,它让画面中较亮的区域“扩散”到周围的区域中,造成一种朦胧的效果。图12.9 给出了动画短片《大象之梦》(英文名: Elephants Dream )中的一个Bloom 效果。


图12.9 动画短片《大象之梦》中的Bloom效果。光线透过门扩散到了周围较暗的区域中

本节将会实现一个基本的Bloom 特效,在学习完本节后, 我们可以得到类似图12.10 中的效果。


图12.10 左图:原效果。右图:Bloom处理后的效果

Bloom 的实现原理非常简单: 我们首先根据一个阀值提取出图像中的较亮区域, 把它们存储 在一张渲染纹理中,再利用高斯模糊对这张渲染纹理进行模糊处理,模拟光线扩散的效果, 最后 再将其和原图像进行混合, 得到最终的效果。
1.实现


具体代码分析见原书。
六、运动模糊


运动模糊是真实世界中的摄像机的一种效果。如果在摄像机曝光时,拍摄场景发生了变化,就会产生模糊的画面。运动模糊在我们的日常生活中是非常常见的,只要留心观察,就可以发现无论是体育报道还是各个电影里,都有运动模糊的身影。运动模糊效果可以让物体运动看起来更加真实平滑,但在计算机产生的图像中,由于不存在曝光这一物理现象,渲染出来的图像往往都棱角分明,缺少运动模糊。在一些诸如赛车类型的游戏中, 为画面添加运动模糊是一种常见的处理方法。在这一节中,我们将学习如何在屏幕后处理中实现运动模糊的效果。在本节结束后,我们将得到类似图12.11 中的效果。


图12.11 左图:原效果。右图:应用运动模糊后的效果

运动模糊的实现有多种方法。一种实现方法是利用一块累积缓存( accumulation buffer )来混合多张连续的图像。当物体快速移动产生多张图像后,我们取它们之间的平均值作为最后的运动模糊图像。然而,这种暴力的方法对性能的消耗很大,因为想要获取多张帧图像往往意味着我们需要在同一帧里渲染多次场景。

另一种应用广泛的方法是创建和使用速度缓存( velocity buffer),这个缓存中存储了各个像素当前的运动速度,然后利用该值来决定模糊的方向和大小。

在本节中,我们将使用类似上述第一种方法的实现来模拟运动模糊的效果。我们不需要在一帧中把场最渲染多次,但需要保存之前的渲染结果,不断把当前的渲染图像叠加到之前的渲染图像中,从而产生一种运动轨迹的视觉效果。这种方法与原始的利用累计缓存的方法相比性能更好,但模糊效果可能会略有影响。
1.实现


具体代码分析见原书。

本节是对运动模糊的一种简单实现。我们混合了连续帧之间的图像, 这样得到一张具有模糊拖尾的图像。然而,当物体运动速度过快时,这种方法可能会造成单独的帧图像变得可见。在第13 章中, 我们会学习如何利用深度纹理重建速度来模拟运动模糊效果。
七、扩展阅读


本章介绍了如何在Unity 中利用渲染纹理实现屏幕后处理效果,并且介绍了几种常见的屏幕特效的实现方法。这些效果都使用了图像处理中的一些算法,以达到特定的图像效果。除了本章介绍的这些效果外,读者可以在Unity 的Image Effect
( http://docs.unity3d.com/Manual/compImageEffects.html )包中找到更多特效的实现。在GPU Gems 系列
(http://developer.nvidia.com/gpugems/GPUGems )中, 也介绍了许多基于图像处理的渲染技术。例如, 《GPU Gems 3》 的第27章, 介绍了一种景深效果的实现方法。除此之外,读者也可以在Unity 的资源商店和其他网络资源中找到许多出色的屏幕特效。
页: [1]
查看完整版本: UnityShader精要笔记十五 屏幕后处理效果