屏幕后处理效果 (screen post-processing effects)是游戏中实现屏幕特效的常见方法.
建立一个基本的屏幕后处理脚本系统
屏幕后处理,指的是在渲染完整个场景得到屏幕图像后,再对这个图像进行一系列操作,实现各种屏幕特效.
这种技术可以为游戏添加很多的艺术效果.,例如景深(Depth of Field), 运动模糊(Motion Blur)等.
因此,想要实现屏幕后处理的基础在于得到渲染后的屏幕图像,即抓取屏幕,而Unity为我们提供了这样一个方便的接口:OnRenderImage函数 :
MonoBehaviour.OnRenderImage (RenderTexture src, RenderTexture dest)
当我们在脚本中声明此函数后,Unity会把当前渲染得到的图像存储在第一个参数对应的源渲染纹理中,通过函数中的一系列操作后,再把目标渲染纹理,即第二个参数对应的渲染纹理显示到屏幕上.
在OnRenderImage函数中,我们通常是利用Graphics.Blit函数 来完成对渲染纹理的处理.
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.
在默认情况下,OnRenderImage函数会在所有的不透明和透明的Pass执行完毕后被调用,一遍对场景中所有游戏对象产生影响.但有时,我们希望在不透明Pass执行完毕后就立即调用OnRenderImage函数,从而不对透明物体产生任何影响.此时,我们可以在OnRenderImage函数前添加ImageEffectOpaque属性来实现这样的目的.(将在13.4节遇到)
因此,要在Unity中实现屏幕后处理效果,过程通常如下:
首先 我们需要在摄像机中添加一个用于屏幕后处理的脚本.
在这个脚本中,我们会实现OnRenderImage函数来获取当前屏幕的渲染纹理.
然后 再调用Graphics.Blit函数使用特定的UnityShader来对当前图像进行处理,再把返回的渲染纹理显示到屏幕上.
对于一些复杂的屏幕特效,我们可能需要多次调用Graphics.Blit函数来对上一步的输出结果进行下一步处理…
但是,在进行屏幕后处理之前,我们需要检查一系列条件是否满足,例如当前平台是否支持渲染纹理和屏幕特效,是否支持当前使用的Unity Shader等.
为此,我们创建了一个用于屏幕后处理效果的基类,在实现各种屏幕特效时,我们只需要继承自该积累,在实现派生类中不同的操作即可.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 using UnityEngine;using System.Collections;[ExecuteInEditMode ] [RequireComponent (typeof(Camera)) ] public class PostEffectsBase : MonoBehaviour { protected void CheckResources () { bool isSupported = CheckSupport(); if (isSupported == false ) { NotSupported(); } } protected bool CheckSupport () { if (SystemInfo.supportsImageEffects == false || SystemInfo.supportsRenderTextures == false ) { Debug.LogWarning("This platform does not support image effects or render textures." ); return false ; } return true ; } protected void NotSupported () { enabled = false ; } protected void Start () { CheckResources(); } 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 ; } } }
在下面一节我们就会看到如何继承PostEffect.cs来创建一个简单的用于调整屏幕的亮度,饱和度和对比度的特效脚本.
调整屏幕的亮度,饱和度和对比度.
新建一个脚本,名BrightnessSaturationAndContrast.cs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 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; } } [Range(0.0f, 3.0f) ] public float brightness = 1.0f ; [Range(0.0f, 3.0f) ] public float saturation = 1.0f ; [Range(0.0f, 3.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); } } }
每当OnrenderI函数被调用时,它会检查材质是否可用.如果可用,就把参数传递给材质,再调用Graphics.Blit进行处理;
否则,直接把原图像显示到屏幕上,不做任何处理.
之后是Shader的部分
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 Shader "Unity Shaders Book/Chapter 12/Brightness Saturation And Contrast" { Properties { _MainTex ("Base (RGB)" , 2 D) = "white" {} _Brightness ("Brightness" , Float) = 1 _Saturation("Saturation" , Float) = 1 _Contrast("Contrast" , Float) = 1 } SubShader { Pass { ZTest Always Cull Off ZWrite Off CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" sampler2D _MainTex; half _Brightness; half _Saturation; half _Contrast; struct v2f { float4 pos : SV_POSITION; half2 uv: TEXCOORD0; }; v2f vert (appdata_img v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.uv = v.texcoord; return o; } fixed4 frag (v2f i) : SV_Target { fixed4 renderTex = tex2D(_MainTex, i.uv); fixed3 finalColor = renderTex.rgb * _Brightness; 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); fixed3 avgColor = fixed3(0.5 , 0.5 , 0.5 ); finalColor = lerp(avgColor, finalColor, _Contrast); return fixed4(finalColor, renderTex.a); } ENDCG } } Fallback Off }
边缘检测
边缘检测的原理是利用一些边缘检测算子对图像进行卷积 (convolution)操作.
什么是卷积
在图像处理中,卷积指的是使用一个卷积核 (kernel)对一张图片中的每个像素进行一些列操作.
卷积核通常是一个四方形网格结构,该区域的每个方格都有一个权重值 .当对图像中的某个像素进行卷积时,我们会把卷积核的中心置于该像素上.
如图所示,翻转核之后再以此计算何种每个元素和其覆盖的图像的像素值的乘积并求和,得到的结果就是该位置的新像素值.
这样的操作虽然简单,但可以实现很多常见的图像处理效果,如图像模糊,边缘检测等.
例如,如果我们想多图像进行均值模糊,可以使用一个3x3的卷积核,核内每个元素的值均为1/9.
常见的边缘检测算子
如果相邻像素之间存在差别明显的颜色,亮度,纹理等属性,我们会认为它们之间应该有一条边界.
这种相邻像素之间的插值可以用梯度 (gradient)来表示,可以想象得到,边缘处的梯度绝对值会比较大.
基于这样的理解,有几种不同的边缘检测算子(即用于边缘检测的卷积核)被先后提出来.
卷积操作的神奇之处在于卷积核.
它们都包含两个方向的卷积核,分别用于检测水平方向和竖直方向上的边缘信息.
在进行边缘检测时,我们需要对每个像素分别进行一次卷积计算,得到两个方向上的梯度值G(x)和G(y),而整体的梯度可按下面的公式计算而得:
G = sqrt(G(x)**2 + G(y)**2)
由于开根号操作比较复杂,出于性能考虑,我们有时候会使用绝对值操作来代替开根号操作:
G = |G(x)| + |G(y)|
得到梯度G后,我们就可以据此来判断哪些像素对应了边缘:梯度越大,越有可能是边缘.
实践: Sobel边缘检测算子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 Shader "Unity Shaders Book/Chapter 12/Edge Detection" { Properties { _MainTex ("Base (RGB)" , 2 D) = "white" {} _EdgeOnly ("Edge Only" , Float) = 1.0 _EdgeColor ("Edge Color" , Color) = (0 , 0 , 0 , 1 ) _BackgroundColor ("Background Color" , Color) = (1 , 1 , 1 , 1 ) } SubShader { Pass { ZTest Always Cull Off ZWrite Off CGPROGRAM #include "UnityCG.cginc" #pragma vertex vert #pragma fragment frag sampler2D _MainTex; uniform half4 _MainTex_TexelSize; fixed _EdgeOnly; fixed4 _EdgeColor; fixed4 _BackgroundColor; struct v2f { float4 pos : SV_POSITION; half2 uv[9 ] : TEXCOORD0; }; v2f vert (appdata_img v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); half2 uv = v.texcoord; o.uv[0 ] = uv + _MainTex_TexelSize.xy * half2(-1 , -1 ); o.uv[1 ] = uv + _MainTex_TexelSize.xy * half2(0 , -1 ); o.uv[2 ] = uv + _MainTex_TexelSize.xy * half2(1 , -1 ); o.uv[3 ] = uv + _MainTex_TexelSize.xy * half2(-1 , 0 ); o.uv[4 ] = uv + _MainTex_TexelSize.xy * half2(0 , 0 ); o.uv[5 ] = uv + _MainTex_TexelSize.xy * half2(1 , 0 ); o.uv[6 ] = uv + _MainTex_TexelSize.xy * half2(-1 , 1 ); o.uv[7 ] = uv + _MainTex_TexelSize.xy * half2(0 , 1 ); o.uv[8 ] = uv + _MainTex_TexelSize.xy * half2(1 , 1 ); return o; } fixed luminance (fixed4 color) { return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b; } half Sobel (v2f i) { const half Gx[9 ] = {-1 , 0 , 1 , -2 , 0 , 2 , -1 , 0 , 1 }; const half Gy[9 ] = {-1 , -2 , -1 , 0 , 0 , 0 , 1 , 2 , 1 }; half texColor; half edgeX = 0 ; half edgeY = 0 ; for (int it = 0 ; it < 9 ; it++) { texColor = luminance(tex2D(_MainTex, i.uv[it])); edgeX += texColor * Gx[it]; edgeY += texColor * Gy[it]; } half edge = 1 - abs (edgeX) - abs (edgeY); return edge; } fixed4 frag (v2f i) : SV_Target { half edge = Sobel(i); fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[4 ]), edge); fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge); return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly); } ENDCG } } FallBack Off }
需要注意的是,本例中的边缘检测仅仅利用了屏幕的颜色信息,而实际应用中,物体的纹理,阴影等信息均会影响边缘检测的结果,使得结果包含许多非预期的描边.
为了得到更准确的边缘嘻嘻,我们往往会在屏幕的深度纹理和法线纹理上进行边缘检测.(将会在13章实现)
高斯模糊
模糊的实现有许多种方法,例如均值模糊和中值模糊.
均值模糊同样使用了卷积操作,它只用的卷积核的各个元素值都相等,且相加等于1.
也就是说,卷积后得到的像素值是其邻域中的各个像素的平均值.
而中值模糊是选择邻域内对应所有像素排序后的中值替换掉原有的颜色.
一个更高级的模糊方法是高斯模糊.
高斯滤波
高斯模糊同样使用了卷积计算,它使用的卷积核名为高斯核.
高斯核是一个正方形大小的滤波核,其中每个元素的计算都是基于下面的高斯方程:
其中,打不出来的那个是标准方差(一般取值为1),x,y分别对应了当前位置到卷积核中心的整数距离.
要构建一个高斯核,我们只需要计算高斯核中各个位置对应的高斯值.
为了保证滤波后的图像不会变暗,我们需要对高斯核中的权重进行归一化,即让每个权重除以所有权重的和,这样可以保证所有权重的和为1.
因此,高斯函数中e前面的系数实际不会对结果有任何影响.
如图显示的是一个标准方差为1的5x5大小的高斯核
高斯方程很好的模拟了邻域每个像素对当前处理像素的影响程度–距离越近,影响越大.
高斯核维度越高,模糊程度越大.采用一个NxN的高斯核对图像进行卷积滤波,就需要NxNxWxH(W和H分别是图像的宽和高)次纹理采样.当N的大小不断增加时,采样的次数会变得非常巨大.
幸运的是,我们可以吧这个二维高斯函数拆分成两个一维函数.也就是说,我们可以使用两个一维的高斯核进行滤波,它们得到的结果跟直接使用二维高斯核进行滤波是一样的,但采样次数只需要2xNxWxH.
我们可以观察到,两个一维高斯核中包含很多重复的权重,实际上我们只需要计算三个权重即可.
实践: 高斯模糊
我们使用一个5x5的高斯核对原图进行高斯模糊.
我们将先后调用两个Pass,第一个Pass将会使用竖直方向的一维高斯核对图像进行滤波,第二个Pass再使用水平方向的一维高斯核对图像进行滤波,得到最终图像.
我们还将利用图像缩放来进一步提高性能,并通过调整高斯滤波的次数来控制模糊程度(次数越多,图像越模糊)
绑定在摄像头的C#代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 using UnityEngine;using System.Collections;public class GaussianBlur : PostEffectsBase { public Shader gaussianBlurShader; private Material gaussianBlurMaterial = null ; public Material material { get { gaussianBlurMaterial = CheckShaderAndCreateMaterial(gaussianBlurShader, gaussianBlurMaterial); return gaussianBlurMaterial; } } [Range(0, 4) ] public int iterations = 3 ; [Range(0.2f, 3.0f) ] public float blurSpread = 0.6f ; [Range(1, 8) ] public int downSample = 2 ; void OnRenderImage (RenderTexture src, RenderTexture dest ) { if (material != null ) { int rtW = src.width/downSample; int rtH = src.height/downSample; RenderTexture buffer0 = RenderTexture.GetTemporary(rtW, rtH, 0 ); buffer0.filterMode = FilterMode.Bilinear; Graphics.Blit(src, buffer0); for (int i = 0 ; i < iterations; i++) { material.SetFloat("_BlurSize" , 1.0f + i * blurSpread); RenderTexture buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0 ); Graphics.Blit(buffer0, buffer1, material, 0 ); RenderTexture.ReleaseTemporary(buffer0); buffer0 = buffer1; buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0 ); Graphics.Blit(buffer0, buffer1, material, 1 ); RenderTexture.ReleaseTemporary(buffer0); buffer0 = buffer1; } Graphics.Blit(buffer0, dest); RenderTexture.ReleaseTemporary(buffer0); } else { Graphics.Blit(src, dest); } } }
Shader代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 Shader "Unity Shaders Book/Chapter 12/Gaussian Blur" { Properties { _MainTex ("Base (RGB)" , 2 D) = "white" {} _BlurSize ("Blur Size" , Float) = 1.0 } SubShader { CGINCLUDE #include "UnityCG.cginc" sampler2D _MainTex; half4 _MainTex_TexelSize; float _BlurSize; struct v2f { float4 pos : SV_POSITION; half2 uv[5 ]: TEXCOORD0; }; v2f vertBlurVertical (appdata_img v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); half2 uv = v.texcoord; o.uv[0 ] = uv; o.uv[1 ] = uv + float2(0.0 , _MainTex_TexelSize.y * 1.0 ) * _BlurSize; o.uv[2 ] = uv - float2(0.0 , _MainTex_TexelSize.y * 1.0 ) * _BlurSize; o.uv[3 ] = uv + float2(0.0 , _MainTex_TexelSize.y * 2.0 ) * _BlurSize; o.uv[4 ] = uv - float2(0.0 , _MainTex_TexelSize.y * 2.0 ) * _BlurSize; return o; } v2f vertBlurHorizontal (appdata_img v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); half2 uv = v.texcoord; o.uv[0 ] = uv; o.uv[1 ] = uv + float2(_MainTex_TexelSize.x * 1.0 , 0.0 ) * _BlurSize; o.uv[2 ] = uv - float2(_MainTex_TexelSize.x * 1.0 , 0.0 ) * _BlurSize; o.uv[3 ] = uv + float2(_MainTex_TexelSize.x * 2.0 , 0.0 ) * _BlurSize; o.uv[4 ] = uv - float2(_MainTex_TexelSize.x * 2.0 , 0.0 ) * _BlurSize; return o; } fixed4 fragBlur (v2f i) : SV_Target { float weight[3 ] = {0.4026 , 0.2442 , 0.0545 }; fixed3 sum = tex2D(_MainTex, i.uv[0 ]).rgb * weight[0 ]; for (int it = 1 ; it < 3 ; it++) { sum += tex2D(_MainTex, i.uv[it*2 -1 ]).rgb * weight[it]; sum += tex2D(_MainTex, i.uv[it*2 ]).rgb * weight[it]; } return fixed4(sum, 1.0 ); } ENDCG ZTest Always Cull Off ZWrite Off Pass { NAME "GAUSSIAN_BLUR_VERTICAL" CGPROGRAM #pragma vertex vertBlurVertical #pragma fragment fragBlur ENDCG } Pass { NAME "GAUSSIAN_BLUR_HORIZONTAL" CGPROGRAM #pragma vertex vertBlurHorizontal #pragma fragment fragBlur ENDCG } } FallBack "Diffuse" }
Bloom效果
Bloom是游戏中常见的一种屏幕效果,这种特效可以模拟真实摄像机的一种图像效果,它让画面中较亮的区域"扩散"到周围的区域,造成一种朦胧的感觉.
实现原理: 我们首先根据一个阈值提取出图像中较亮的区域,把他们存储在一张纹理中,再利用高斯模糊对这张渲染纹理进行模糊处理,模拟光线扩散的效果,最后再将其和原图像进行混合,得到最终效果.