屏幕后处理效果
1 建立一个基本的屏幕后处理脚本系统
屏幕后处理效果通常指渲染完整个场景得到屏幕图像后,再对这个图像进行一系列操作,实现各种屏幕特效。
抓取屏幕Unity为我们提供了一个方便的接口 OnRenderImage 函数,声明如下
MonoBehaviour.OnRenderImage(RenderTexture src, RenderTexture dest)
OnRenderImage 是渲染流程的最后阶段,如果不调用此方法,则渲染的图像直接显示到屏幕上。如果调用,则
- src:获取到的渲染图像
- dest:绘制的目标纹理,即最终会显示在屏幕上的纹理
注意:调用此方法的脚本需要挂载在含有camera组件的脚本上
在 OnRenderImage 中通常利用 Graphics.Bilt 函数来完成对纹理渲染的处理,它可以将一张纹理绘制到另一张纹理中,可以指定一种材质(材质中挂有 Shader )来实现特殊的效果,它有三种声明
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, RenderTexture dest, int pass = -1);
- src:源纹理,这个参数通常就是当前屏幕的渲染纹理或是上一步处理后得到的渲染纹理,将会被传递给 Shader 中名为 _MainTex 的纹理属性
- dest:目标渲染纹理,如果它的值为 null 就会直接将结果显示在屏幕上
- mat:使用的材质,这个材质使用的 Unity Shader 将会进行各种屏幕后处理操作
- pass:默认值为 -1,表示将会依次调用 Shader 内的所有 Pass,否则只会调用给定索引的 Pass
默认情况下,OnRenderImage 函数会在所有不透明和透明物体执行完毕后立即调用,以便对场景中所有游戏对象都产生影响。但有时,我们希望在不透明的 Pass 执行完毕后立即调用 OnRenderImage 函数,从而不对透明物体产生任何影响。此时可以在 OnRenderImage 函数前添加 ImageEffectOpaque 属性来实现这样的目的。
Unity 中实现屏幕后处理的过程通常如下:
- 在摄像机中添加一个用于屏幕后处理的脚本,实现 OnRenderImage 函数来获取当前屏幕的渲染纹理
- 调用 Graphics.Bilt 函数使用特定的 Unity Shader 来对当前图像进行处理,再把返回的渲染纹理显示到屏幕上。如果是一些复杂的屏幕特效,我们可能多次调用 Graphics.Bilt 函数来队上一步输出结果进行下一步处理
进行屏幕后处理之前,需要检查一系列条件是否满足,例如当前平台是否支持渲染纹理和屏幕特效,是否支持当前使用的 Unity Shader 等。为此,我们创建了一个用于屏幕后处理效果的基类,在实现各种屏幕特效时,我们只需要继承自该基类,再实现派生类中不同的操作即可。
PostEffectsBase.cs 的主要代码如下:
using UnityEngine;
[ExecuteInEditMode]
[RequireComponent(typeof(Camera))] // 首先,所有屏幕后处理效果都需要绑定在某个计算机上,并且我们希望在编辑器模式下也可以执行脚本来查看效果
public class PostEffectsBase : MonoBehaviour
{
private void CheckResources()
{
bool isSupported = CheckSupported();
if (isSupported == false)
{
NotSupported();
}
}
/// <summary>
/// 为了提前检测资源和条件是否满足,我们在Start函数中调用CheckResources函数
/// </summary>
protected bool CheckSupported()
{
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()
{
}
/// <summary>
/// 指定一个Shader来创建一个用于处理渲染纹理的材质
/// </summary>
/// <param name="shader">指定该特效需要使用的Shader</param>
/// <param name="material">用于和后期处理的材质</param>
/// <returns></returns>
protected Material CheckShaderAndCreateMaterial(Shader shader, Material material)
{
if (shader == null)
{
return null;
}
if (shader.isSupported && material && material.shader == shader)
{
return material;
}
if (shader.isSupported == false)
{
return null;
}
else
{
material = new Material(shader);
material.hideFlags = HideFlags.DontSave;
if (material) return material;
else return null;
}
}
#region Unity CallBack
void Start()
{
CheckResources();
}
void Update()
{
}
#endregion
}
2 调整屏幕亮度、饱和度和对比度
新建一个脚本 BrightnessSaturationAndContrast.cs,添加以下代码
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;
public class BrightnessSaturationAndContrast : PostEffectsBase
{
public Shader briSatConShader;
private Material _briSatConMaterial;
public Material material
{
get
{
// briSatConShader是我们指定的Shader,对应了后面将会实现的BrightnessSaturationAndContrast.shader,_briSatConMaterial是创建的材质,我们提供了名为Material的材质来访问它
_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;
/// <summary>
/// 调用时会检查材质是否可用,如果可用就把参数传递给材质,再调用Graphics.Blit进行处理,否则,直接把原图像显示到屏幕上,不做任何处理
/// </summary>
/// <param name="src"></param>
/// <param name="dest"></param>
private 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);
}
}
}
将此脚本挂载到摄像机中,新建一个 Shader 如下
Shader "MyShader/PostProcessing/BrightnessSaturationAndContrast"
{
Properties
{
_MainTex ("Base(RGB)", 2D) = "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;
};
// 进行顶点变换并向片元着色器传递正确的纹理坐标
// 使用内置的appdata_img结构体作为顶点着色器的输入,可以在UnityCG.cginc中找到该结构体的声明,它只包含了图像处理时必需的顶点坐标和纹理坐标等变量
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);
// Apply brightness:利用原颜色 * 亮度系数即可得到亮度
fixed3 finalColor = renderTex.rgb * _Brightness;
// Apply saturation:计算该像素对应的亮度值luminance,使用该亮度值创建一个饱和度为0的颜色值再利用_Saturation进行插值,得到希望饱和度的颜色
fixed luminance = 0.2125 * renderTex.r + 0.7154 * renderTex.g + 0.0721 * renderTex.b;
fixed3 luminaceColor = fixed3(luminance, luminance, luminance);
finalColor = lerp(luminaceColor, finalColor, _Saturation);
// Apply contrast:创建一个对比度为0的颜色值(各分量均为0.5),再利用_Contrast进行插值
fixed3 avgColor = fixed3(0.5, 0.5, 0.5);
finalColor = lerp(avgColor, finalColor, _Contrast);
return fixed4(finalColor, renderTex.a);
}
ENDCG
}
}
Fallback Off
}
将该 shader 拖入摄像机的 BrightnessSaturationAndContrast 控件中的 briSatConShader 中,调整参数 brightness,saturation,contrast 的值即可看到效果。
3 边缘检测
边缘检测时描边效果的一种实现方法,利用一些边缘检测算子对图像进行**卷积(convolution)**操作。
3.1 卷积
卷积操作指使用一个卷积核对一张图像中的每个像素进行一些列操作。卷积核通常是一个四方形网格结构(例如 2$\times 2 , 3 2,3 2,3\times$3 的方形区域),该区域内每个方格都有一个权重值。当对图像中的某个像素进行卷积时,我们会把卷积核的中心放置于该像素上,翻转核之后再依次计算核中每个元素和其覆盖的图像像素值的乘积并求和,得到的结果就是该位置的新像素值。
![](https://img-blog.csdnimg.cn/img_convert/e0443317b6920e0c1d7d01c4a118f486.jpeg)
这样的计算虽然简单,但是可以实现很多常见的图像处理效果,例如图像模糊、边缘检测等。例如想要对图像进行均值模糊,可以使用一个 3$\times$3 的卷积核,核内每个元素的值均为 1/9
3.2 常见的边缘检测算子
卷积操作的关键在于卷积核,用于边缘检测的卷积核应该长什么样呢?
如果相邻像素之间存在差别明显的颜色、亮度、纹理等属性,我们就认为它们之间有一条边界。相邻像素之间的差值可以用**梯度(gradient)**表示。边缘处的梯度绝对值会比价大。基于这样的理解,有几种不同的边缘检测算子被先后提出来。
常见的边缘检测算子如上图所示,它们都包含了两个方向的卷积核,分别用于检测水平方向和竖直方向上的边缘信息。在进行边缘检测时,我们需要对每个像素分别进行一次卷积计算,得到两个方向上的梯度值
G
x
G_x
Gx 和
G
y
G_y
Gy,而整体的梯度可按下面的公式计算而得
G
=
G
x
2
+
G
y
2
G = \sqrt{G_x^2 + G_y^2}
G=Gx2+Gy2
由于上述计算包含了开更号操作,出于性能的考虑,有时会用绝对值操作来代替开更号操作:
G
=
∣
G
z
∣
+
∣
G
y
∣
G = |G_z| + |G_y|
G=∣Gz∣+∣Gy∣
当得到梯度G后,我们就可以据此来判断哪些像素对应了边缘(梯度值越大,约可能是边缘)。
3.3 实现
我们使用 Sobel 算子进行边缘检测,实现描边效果。
新建一个脚本 EdgeDetection.cs,添加以下代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Serialization;
public class EdgeDetection : PostEffectsBase
{
public Shader edgeDetectShader;
private Material _edgeDetectMaterial;
public Material material
{
get
{
// edgeDetectShader是我们指定的Shader,对应了后面将会实现的EdgeDetection.shader,_briSatConMaterial是创建的材质,我们提供了名为Material的材质来访问它
_edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, _edgeDetectMaterial);
return _edgeDetectMaterial;
}
}
[Range(0.0f, 1.0f)] public float edgesOnly = 0.0f; // 边缘线浅强度
public Color edgeColor = Color.black; // 描边颜色
public Color backgroundColor = Color.white; // 背景颜色
/// <summary>
/// 调用时会检查材质是否可用,如果可用就把参数传递给材质,再调用Graphics.Blit进行处理,否则,直接把原图像显示到屏幕上,不做任何处理
/// </summary>
/// <param name="src"></param>
/// <param name="dest"></param>
private void OnRenderImage(RenderTexture src, RenderTexture dest)
{
if (material != null)
{
material.SetFloat("_EdgeOnly", edgesOnly);
material.SetColor("_EdgeColor", edgeColor);
material.SetColor("_BackgroundColor", backgroundColor);
Graphics.Blit(src, dest, material);
}
else
{
Graphics.Blit(src, dest);
}
}
}
将此脚本挂在到摄像机中,并新建以下 Shader
Shader "MyShader/PostProcessing/EdgeDetection"
{
Properties
{
_MainTex ("Base(RGB)", 2D) = "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
#pragma vertex vert
#pragma fragment fragSobel
#include "UnityCG.cginc"
sampler2D _MainTex;
half4 _MainTex_TexelSize; // xxx_TexelSize是Unity为我们提供的xxx的像素尺寸大小,值为:Vector4(1/width, 1/height, width, height)
// 例如一张512x512大小的纹理,该值大约为0.001953(1/512)
// 由于卷积需要对相邻区域内的纹理进行采样,因此我们需要利用_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;
// 计算在顶点着色器中计算边缘检测时需要的邻域纹理坐标,即计算像素及其周围8个像素的纹理坐标,对应了使用Sobel算子采样时需要的9个邻域纹理坐标。
// 通过把计算采用纹理坐标的代码从片元着色器中转移到顶点着色器,可以减少运算,提高性能。
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;
}
fixed4 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, -2, -1, 0, 0, 0, 1, 2, 1};
const half Gy[9] = {-1, 0, 1, -2, 0, 2, -1, 0, 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); // edge越小,梯度越大,表明该位置越可能是一个边缘点
return edge;
}
fixed4 fragSobel (v2f i) : SV_Target
{
half edge = Sobel(i); // edge越小,表明该位置越可能是一个边缘点,进行插值时该值就约接近_EdgeColor
fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[4]), edge); // 计算背景为原图下的颜色值
fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge); // 计算纯色下的颜色值(_BackgroundColor相当于整体的一个色调)
return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
}
ENDCG
}
}
Fallback Off
}
将此 Shader 拖拽到 EdgeDetection 中的 edgeDetectShader 中,调节参数实现描边效果
4 高斯模糊
模糊的实现有很多种方法,例如均值模糊和中值模糊。
- 均值模糊:使用了卷积操作,使用的卷积核中的各个元素值都想等,且相加等于 1,也就是说,卷积后得到的像素值是其邻域内各个像素的平均值
- 中值模糊:选择邻域内对所有像素排序后的中值替换掉原颜
- 高斯模糊:本节中将介绍此方法
4.1 高斯滤波
高斯滤波同样利用了卷积计算,它使用的卷积核名为高斯核。高斯核是一个正方形大小的滤波核,其中每个元素的计算都是基于下面的高斯方程
G
(
x
,
y
)
=
1
2
π
σ
2
e
−
x
2
+
y
2
2
σ
2
G(x, y) = \frac{1}{2\pi\sigma^2}e^{-\frac{x^2 + y^2}{2\sigma^2}}
G(x,y)=2πσ21e−2σ2x2+y2
其中,
σ
\sigma
σ 是标准方差(一般取值为 1 ),x 和 y 分别对应了当前位置到卷积核中心的整数距离。要构建一个高斯核,我们只需要计算高斯核中各个位置对应的高斯值。为了保证滤波后的图像不会变暗,我们需要对高斯核中的权重进行归一化,即让每个权重除以所有权重的和,这样可以保证所有权重的和为 1。因此,高斯函数中 e 前面的系数实际不会对结果有任何影响。
高斯方程很好的模拟了邻域每个像素对当前处理像素的影像程度——距离越近,影响越大。高斯核的维数越高,模糊度越大。使用一个 N × \times ×N 的高斯核对图像进行卷积滤波,就需要 N × \times ×N × \times ×W × \times ×H (W和H分别是图像的宽和高)次纹理采样。当 N 的大小不断增加时,采样此时会变得非常巨大。
观察高斯核可以发现,高斯核是一个对称矩阵。于是,我们可以把这个二维高斯函数拆分为两个一维函数。也就是说,我们可以使用两个一维的高斯核先后对图像进行滤波,它们得到的结果和直接使用二维高斯核是一样的,但采样次数变为了 2 × \times ×N × \times ×W × \times ×H。进一步观察可以发现,两个一维高斯核中包含了很多重复的权重。对于一个大小为 5 的一维高斯核,我们实际只需要记录 3 个权重值即可。
我们将会使用上述 5$\times$5 的高斯核对原图像进行高斯模糊。我们将先后调用两个 Pass,第一个 Pass 将会使用竖直方向的一维高斯核对图像进行滤波,第二个 Pass 再使用水平方向的一维高斯核对图像进行滤波,得到最终图像。实现中还会利用图像缩放来进一步提升性能,并通过调整高斯滤波的应用次数来控制模糊程度(次数越多约模糊)。
4.2 实现
新建一个脚本 GaussianBlur.cs,添加以下代码
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
public class GaussianBlur : PostEffectsBase
{
public Shader gaussianBlurShader;
private Material _gaussianBlurMaterial;
public Material material
{
get
{
// gaussianBlurShader是我们指定的Shader,对应了后面将会实现的GaussianBlur.shader,_gaussianBlurMaterial是创建的材质,我们提供了名为Material的材质来访问它
_gaussianBlurMaterial = CheckShaderAndCreateMaterial(gaussianBlurShader, _gaussianBlurMaterial);
return _gaussianBlurMaterial;
}
}
[Range(0, 4)] public int iterations = 3; // 高斯模糊迭代次数
[Range(0.2f, 3.0f)] public float blurSpread = 0.6f; // 模糊范围,对应GaussianBlur.shader中的_BlurSize,越大取得的采样点间距越大
[Range(1, 8)] public int downSample = 2; // 缩放系数
#region Edition1: just apply blur
// /// <summary>
// /// Edition1: just apply blur
// /// 调用时会检查材质是否可用,如果可用就把参数传递给材质,再调用Graphics.Blit进行处理,否则,直接把原图像显示到屏幕上,不做任何处理
// /// </summary>
// /// <param name="src"></param>
// /// <param name="dest"></param>
// private void OnRenderImage(RenderTexture src, RenderTexture dest)
// {
// if (material != null)
// {
// int rtW = src.width;
// int rtH = src.height;
// // 由于高斯模糊需要调用两个Pass,我们需要使用一块中间缓存来存储第一个Pass执行完毕后得到的模糊结果
// RenderTexture buffer = RenderTexture.GetTemporary(rtW, rtH, 0); // 分配一块与屏幕图像大小相同的缓冲区
//
// // Render the vertical pass
// Graphics.Blit(src, buffer, material, 0); // 使用Shader中的第一个Pass对src进行处理,并将结果存储在buffer中
// // Render the horizontal pass
// Graphics.Blit(buffer, dest, material, 1); // 使用Shader中的第二个Pass对上一次处理的结果buffer进行处理,并将结果存储在dest中,返回最终的屏幕图像
//
// RenderTexture.ReleaseTemporary(buffer); // 释放之前分配的内存
// }
// else
// {
// Graphics.Blit(src, dest);
// }
// }
#endregion
#region Edition2: Scale the render Texture
// /// <summary>
// /// Edition2: Scale the render Texture
// /// 调用时会检查材质是否可用,如果可用就把参数传递给材质,再调用Graphics.Blit进行处理,否则,直接把原图像显示到屏幕上,不做任何处理
// /// </summary>
// /// <param name="src"></param>
// /// <param name="dest"></param>
// private void OnRenderImage(RenderTexture src, RenderTexture dest)
// {
// // 利用缩放对图像进行降采样,从而减少需要处理的像素个数,提高性能
// if (material != null)
// {
// // 声明缓冲区大小时,使用了小于原屏幕分辨率的尺寸,并将该临时渲染纹理的滤波模式设置为双线性
// // 这样,在调用第一个pass时,我们需要处理的像素个数就是原来的几分之一,适当的降采样不仅可以提高性能,还可以得到更好的模糊效果
// int rtW = src.width/downSample;
// int rtH = src.height/downSample;
// // 由于高斯模糊需要调用两个Pass,我们需要使用一块中间缓存来存储第一个Pass执行完毕后得到的模糊结果
// RenderTexture buffer = RenderTexture.GetTemporary(rtW, rtH, 0); // 分配一块缓冲区
// buffer.filterMode = FilterMode.Bilinear;
//
// // Render the vertical pass
// Graphics.Blit(src, buffer, material, 0); // 使用Shader中的第一个Pass对src进行处理,并将结果存储在buffer中
// // Render the horizontal pass
// Graphics.Blit(buffer, dest, material, 1); // 使用Shader中的第二个Pass对上一次处理的结果buffer进行处理,并将结果存储在dest中,返回最终的屏幕图像
//
// RenderTexture.ReleaseTemporary(buffer); // 释放之前分配的内存
// }
// else
// {
// Graphics.Blit(src, dest);
// }
// }
#endregion
#region Edition3: use iteration for larger blur
/// <summary>
/// Edition3: use iteration for larger blur
/// 调用时会检查材质是否可用,如果可用就把参数传递给材质,再调用Graphics.Blit进行处理,否则,直接把原图像显示到屏幕上,不做任何处理
/// </summary>
/// <param name="src"></param>
/// <param name="dest"></param>
private void OnRenderImage(RenderTexture src, RenderTexture dest)
{
// 利用缩放对图像进行降采样,从而减少需要处理的像素个数,提高性能
if (material != null)
{
// 声明缓冲区大小时,使用了小于原屏幕分辨率的尺寸,并将该临时渲染纹理的滤波模式设置为双线性
// 这样,在调用第一个pass时,我们需要处理的像素个数就是原来的几分之一,适当的降采样不仅可以提高性能,还可以得到更好的模糊效果
int rtW = src.width/downSample;
int rtH = src.height/downSample;
// 由于高斯模糊需要调用两个Pass,我们需要使用一块中间缓存来存储第一个Pass执行完毕后得到的模糊结果
RenderTexture buffer0 = RenderTexture.GetTemporary(rtW, rtH, 0); // 分配一块缓冲区
buffer0.filterMode = FilterMode.Bilinear;
Graphics.Blit(src, buffer0); // 将源纹理渲染到buffer0中
for (int i = 0; i < iterations; i++)
{
// 将buffer0作为最终渲染目标
// 1.将buffer0作为源纹理,渲染第一个pass到buffer1中,清空buffer0,将buffer1赋值给buffer0(此时buffer0中保存了上一步的渲染结果)
// 2.buffer0作为上一步渲染的结果源纹理,再次渲染第二个的pass到buffer1中,再次清空buffer0,将buffer1赋值给buffer0(此时buffer0中保存了上一步的渲染结果)
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);
}
}
#endregion
}
将此脚本挂在到摄像机中,并新建以下 Shader
Shader "MyShader/PostProcessing/GaussianBlur"
{
Properties
{
_MainTex ("Base(RGB)", 2D) = "white" {}
_BlurSize ("Blur Size", Float) = 1.0
}
SubShader
{
// CGINCLUDE类似于C++中头文件的功能,由于高斯模糊需要定义两个Pass,但它们使用的片元着色器代码完全相同,使用CGINCLUDE可以避免我们编写两个完全一样的frag函数
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;
// 只进行垂直方向的偏移,定义了一组高斯采样地方邻与纹理坐标,利用和_BlurSize相乘控制采样距离。
// _BlurSize越大,模糊程度越高,但是采样数不会受到影响,但同时。过大的_BlurSize会造成虚影。
// 将计算采样纹理坐标的代码转移到顶点着色器中,可以减少运算,提高性能
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 * 1.0) * _BlurSize;
return o;
}
v2f vertBlurHorizontal(appdata_img v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
half2 uv = v.texcoord;
// 只进行水平方向的偏移,定义了一组高斯采样地方邻与纹理坐标,利用和_BlurSize相乘控制采样距离。
// _BlurSize越大,模糊程度越高,但是采样数不会受到影响,但同时。过大的_BlurSize会造成虚影。
// 将计算采样纹理坐标的代码转移到顶点着色器中,可以减少运算,提高性能
o.uv[0] = uv;
o.uv[1] = uv + float2(_MainTex_TexelSize.y * 1.0, 0.0) * _BlurSize;
o.uv[2] = uv - float2(_MainTex_TexelSize.y * 1.0, 0.0) * _BlurSize;
o.uv[3] = uv + float2(_MainTex_TexelSize.y * 2.0, 0.0) * _BlurSize;
o.uv[4] = uv - float2(_MainTex_TexelSize.y * 1.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++)
{
// 声明各个领域像素对应的weight,然后将结果值sum初始化为当前像素值乘以它的权重值
// 根据对称性进行两次迭代,每次迭代包含两次纹理采样,并把像素值和权重值相乘后的结果叠加到sum中,最后返回滤波结果sum
sum += tex2D(_MainTex, i.uv[it * 2 - 1]).rgb * weight[it];
sum += tex2D(_MainTex, i.uv[it * 2]).rgb * weight[it];
}
// 上述操作即一个1x5矩阵乘以一个5x3矩阵
// 1x5矩阵:[0.0545, 0.2442, 0.4026, 0.2442, 0.0545]
// 5x3矩阵:每一行都是对应每个点的颜色值
// 最后叠加得到一个1x3矩阵的rgb值
return fixed4(sum, 1.0);
}
ENDCG
ZTest Always Cull Off ZWrite Off
Pass
{
// 为Pass使用NAME语义定义他们的名字,由于高斯模糊是常见的图像处理操作,很多屏幕特效都是建立在它的基础上的。
// 为Pass定义名字,可以在其他Shader中直接通过它们的名字来使用该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 Off
}
将此 Shader 拖拽到 GaussianBlur 中的 gaussianBlurShader 中,调节参数实现描边效果
5 Bloom效果
Bloom 特效是游戏中常见的一种屏幕效果。这种特效可以模拟真实摄像机的一种图像效果,让画面中较亮的区域”扩散”到周围的区域中,造成一种朦胧的效果。
Bloom 的实现原理非常简单:
- 根据一个阈值提取出图像中的较亮区域,把它们存储在一张渲染纹理中
- 利用高斯模糊对这张渲染纹理进行模糊处理,模拟光纤扩散的效果
- 将处理后的结果与原图像进行混合,得到最终效果。
新建一个脚本 Bloom.cs,添加以下代码
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Bloom : PostEffectsBase
{
public Shader bloomShader;
private Material _bloomMaterial;
public Material material
{
get
{
// bloomShader是我们指定的Shader,对应了后面将会实现的Bloom.shader,_bloomMaterial是创建的材质,我们提供了名为Material的材质来访问它
_bloomMaterial = CheckShaderAndCreateMaterial(bloomShader, _bloomMaterial);
return _bloomMaterial;
}
}
[Range(0, 4)] public int iterations = 3;
[Range(0.2f, 3.0f)] public float blurSpread = 0.6f;
[Range(1, 8)] public int downSample = 2;
[Range(0.0f, 4.0f)] public float luminanceThreshold = 0.6f;
/// <summary>
/// 与高斯模糊代码基本一致
/// 调用时会检查材质是否可用,如果可用就把参数传递给材质,再调用Graphics.Blit进行处理,否则,直接把原图像显示到屏幕上,不做任何处理
/// </summary>
/// <param name="src"></param>
/// <param name="dest"></param>
private void OnRenderImage(RenderTexture src, RenderTexture dest)
{
// 利用缩放对图像进行降采样,从而减少需要处理的像素个数,提高性能
if (material != null)
{
material.SetFloat("_LuminanceThreshold", luminanceThreshold);
// 声明缓冲区大小时,使用了小于原屏幕分辨率的尺寸,并将该临时渲染纹理的滤波模式设置为双线性
// 这样,在调用第一个pass时,我们需要处理的像素个数就是原来的几分之一,适当的降采样不仅可以提高性能,还可以得到更好的模糊效果
int rtW = src.width/downSample;
int rtH = src.height/downSample;
// 由于高斯模糊需要调用两个Pass,我们需要使用一块中间缓存来存储第一个Pass执行完毕后得到的模糊结果
RenderTexture buffer0 = RenderTexture.GetTemporary(rtW, rtH, 0); // 分配一块缓冲区
buffer0.filterMode = FilterMode.Bilinear;
// 1.pass0提取处图像中的较亮区域,存储在buffer0中
Graphics.Blit(src, buffer0, material, 0); // 调用第0个pass,将源纹理经过material处理后的结果渲染进buffer0中
// 2.pass1和pass2分别进行纵向和横向的高斯模糊,模糊后的较亮区域会存储在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, 1); // 调用第1个pass,将buffer0经过material处理后的结果渲染进buffer1中
RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);
Graphics.Blit(buffer0, buffer1, material, 2); // 调用第2个pass,再将buffer0经过material处理后的结果渲染进buffer1中
RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
}
// 3.把buffer0传递给材质中的_Bloom纹理属性,并使用最后一个pass来进行最后的混合,将结果存储在目标渲染纹理dest中
material.SetTexture("_Bloom", buffer0); // 将buffer0设置为_Bloom纹理
Graphics.Blit(src, dest, material, 3); // 将源纹理通过第3个pass渲染到dest中
RenderTexture.ReleaseTemporary(buffer0);
}
else
{
Graphics.Blit(src, dest);
}
}
}
将此脚本挂在到摄像机中,并新建以下 Shader
Shader "MyShader/PostProcessing/GaussianBlur"
{
Properties
{
_MainTex ("Base(RGB)", 2D) = "white" {}
_BlurSize ("Blur Size", Float) = 1.0
}
SubShader
{
// CGINCLUDE类似于C++中头文件的功能,由于高斯模糊需要定义两个Pass,但它们使用的片元着色器代码完全相同,使用CGINCLUDE可以避免我们编写两个完全一样的frag函数
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;
// 只进行垂直方向的偏移,定义了一组高斯采样地方邻与纹理坐标,利用和_BlurSize相乘控制采样距离。
// _BlurSize越大,模糊程度越高,但是采样数不会受到影响,但同时。过大的_BlurSize会造成虚影。
// 将计算采样纹理坐标的代码转移到顶点着色器中,可以减少运算,提高性能
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 * 1.0) * _BlurSize;
return o;
}
v2f vertBlurHorizontal(appdata_img v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
half2 uv = v.texcoord;
// 只进行水平方向的偏移,定义了一组高斯采样地方邻与纹理坐标,利用和_BlurSize相乘控制采样距离。
// _BlurSize越大,模糊程度越高,但是采样数不会受到影响,但同时。过大的_BlurSize会造成虚影。
// 将计算采样纹理坐标的代码转移到顶点着色器中,可以减少运算,提高性能
o.uv[0] = uv;
o.uv[1] = uv + float2(_MainTex_TexelSize.y * 1.0, 0.0) * _BlurSize;
o.uv[2] = uv - float2(_MainTex_TexelSize.y * 1.0, 0.0) * _BlurSize;
o.uv[3] = uv + float2(_MainTex_TexelSize.y * 2.0, 0.0) * _BlurSize;
o.uv[4] = uv - float2(_MainTex_TexelSize.y * 1.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++)
{
// 声明各个领域像素对应的weight,然后将结果值sum初始化为当前像素值乘以它的权重值
// 根据对称性进行两次迭代,每次迭代包含两次纹理采样,并把像素值和权重值相乘后的结果叠加到sum中,最后返回滤波结果sum
sum += tex2D(_MainTex, i.uv[it * 2 - 1]).rgb * weight[it];
sum += tex2D(_MainTex, i.uv[it * 2]).rgb * weight[it];
}
// 上述操作即一个1x5矩阵乘以一个5x3矩阵
// 1x5矩阵:[0.0545, 0.2442, 0.4026, 0.2442, 0.0545]
// 5x3矩阵:每一行都是对应每个点的颜色值
// 最后叠加得到一个1x3矩阵的rgb值
return fixed4(sum, 1.0);
}
ENDCG
ZTest Always Cull Off ZWrite Off
Pass
{
// 为Pass使用NAME语义定义他们的名字,由于高斯模糊是常见的图像处理操作,很多屏幕特效都是建立在它的基础上的。
// 为Pass定义名字,可以在其他Shader中直接通过它们的名字来使用该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 Off
}
将此 Shader 拖拽到 Bloom 中的 bloomShader 中,调节参数实现 Bloom 效果
6 运动模糊
运动模糊是真实世界中的摄像机的一种效果。摄像机曝光时如果拍摄场景发生了变化,就会产生模糊的画面。由于在计算机生成的图像中不存在在曝光这一物理现象,渲染出来的图像往往都是棱角分明,缺少运动模糊。
运动模糊的实现方法有以下两种:
- 累积缓存(accumulation buffer):混合多张连续图像。当物体快速移动产生多张图像后,我们取它们之间的平均值作为最后的运动模糊图像。这种暴力的方法对性能消耗很大,想要获取多张帧图像意味着我们需要在同一帧里渲染多次场景。
- 速度缓存(velocity buffer):这个缓存中存储了各个像素当前的运动速度,然后利用该值来觉得模糊的方向和大小。
我们将使用**累积缓存(accumulation buffer)**的方法来实现运动模糊。我们不需要在一帧中把场景渲染多次,但需要保存之前的渲染结果,不断把当前的渲染图像叠加到之前的渲染图像中,从而产生一种运动轨迹的视觉效果。这种方法与原始的利用累计缓存的方法相比性能更好,但模糊效果可能略有影响。
创建一个脚本 MotionBlur.cs,代码如下:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class MotionBlur : PostEffectsBase
{
public Shader motionBlurShader;
private Material _motionBlurMaterial;
public Material material
{
get
{
// motionBlurShader是我们指定的Shader,对应了后面将会实现的MotionBlur.shader,_motionBlurMaterial是创建的材质,我们提供了名为Material的材质来访问它
_motionBlurMaterial = CheckShaderAndCreateMaterial(motionBlurShader, _motionBlurMaterial);
return _motionBlurMaterial;
}
}
[Range(0.0f, 0.9f)] public float blurAmount = 0.5f; // blurAmount的值越大,运动的拖尾效果就越明显。为了防止拖尾效果完全替代当前帧的渲染结果,我们把它的值截取在0.0-0.9范围内
private RenderTexture _accumulationTexture; // 定义一个RenderTexture类型的变量,保存之前图像叠加的结果
private void OnDisable()
{
DestroyImmediate(_accumulationTexture);
}
/// <summary>
/// 调用时会检查材质是否可用,如果可用就把参数传递给材质,再调用Graphics.Blit进行处理,否则,直接把原图像显示到屏幕上,不做任何处理
/// </summary>
/// <param name="src"></param>
/// <param name="dest"></param>
private void OnRenderImage(RenderTexture src, RenderTexture dest)
{
if (material != null)
{
// 创建_accumulationTexture
if (_accumulationTexture == null || _accumulationTexture.width != src.width || _accumulationTexture.height != src.height)
{
// 判断_accumulationTexture是否满足条件,包括是否为空和分辨率是否满足,如果不满足就重新生成一个并把源纹理渲染进_accumulationTexture
DestroyImmediate(_accumulationTexture);
_accumulationTexture = new RenderTexture(src.width, src.height, 0);
// 由于我们会自己控制该变量的销毁,因此可以把它的hideFlags设置为HideAndDontSave,这意味着这个变量不会显示在Hierarchy中,也不会保存在场景中
_accumulationTexture.hideFlags = HideFlags.HideAndDontSave;
Graphics.Blit(src, _accumulationTexture);
}
// MarkRestoreExpected表明需要进行一个渲染纹理的恢复操作,恢复操作(restore operation)发生在渲染到纹理而该纹理又没有被提前清空或销毁的情况下
// 我们每次调用OnRenderImage是需要把当前的帧图像和_accumulationTexture中的图像混合, _accumulationTexture纹理不需要提前清空,因为它保存了我们之前的混合结果
// _accumulationTexture.MarkRestoreExpected();
material.SetFloat("_BlurAmount", 1.0f - blurAmount);
Graphics.Blit(src, _accumulationTexture, material);
Graphics.Blit(_accumulationTexture, dest);
}
else
{
Graphics.Blit(src, dest);
}
}
}
将此脚本挂在到摄像机中,并新建以下 Shader
Shader "MyShader/PostProcessing/MotionBlur"
{
Properties
{
_MainTex ("Base(RGB)", 2D) = "white" {}
_BlurAmount ("Blur Amount", Float) = 1.0
}
SubShader
{
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _MainTex;
fixed _BlurAmount;
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 fragRGB(v2f i) : SV_Target
{
// RGB版本的Shader对当前图像进行采样,并将其A通道的值设置为_BlurAmount,以便在后面混合时使用它的透明度进行混合
return fixed4(tex2D(_MainTex, i.uv).rgb, _BlurAmount);
}
half4 fragA(v2f i) : SV_Target
{
// 直接返回采样结果
// 维护渲染纹理的透明通道值,不让其受到混合时使用的透明度值的影响
return tex2D(_MainTex, i.uv);
}
ENDCG
ZTest Always Cull Off Zwrite Off
Pass
{
Blend SrcAlpha OneMinusSrcAlpha
ColorMask RGB
CGPROGRAM
#pragma vertex vert
#pragma fragment fragRGB
ENDCG
}
Pass
{
Blend One Zero
ColorMask A
CGPROGRAM
#pragma vertex vert
#pragma fragment fragA
ENDCG
}
}
Fallback Off
}
将此 Shader 拖拽到 MotionBlur 中的 motionBlurShader 中,调节参数实现运动模糊效果