屏幕特效是一种很常见的平面特效,不同于直接在三维渲染中做出的特效,屏幕特效仅针对已经渲染完成的屏幕显示,通俗点说就是一张和屏幕大小相当的图片。这种针对已经渲染完成的屏幕图片进行特效处理的行为被称作“屏幕后处理”。
实际使用场景中的屏幕后处理类型繁多,不同的效果各有各的针对,能良好地使用这种后处理能极大地增加游戏内容表现力,而随之带来的则是性能消耗的上升。现代3D游戏的图形图像设置选项中都会有“后处理”相关选项,该选项直接影响游戏的视觉效果和运行性能,其重要性可见一斑。
Unity中有一套用于实现屏幕后处理的机制,其核心是RenderTexture和Shader,因此开发者只需要按照Unity给出的这套标准实现所需的效果便可。
Untiy中,屏幕后处理基本都是针对Camera组件的,该组件负责将自己“看到”的场景进行渲染并且输出到指定目标。因此,最简单直接的后处理就是在Camera对象上挂载一个脚本,并且在脚本中重写OnRenderImage方法。
其中OnRenderImage方法的原型如下
MonoBehaviour.OnRenderImage(RenderTexture src, RenderTexture dest);
很显然,这个方法会将src经过处理后输出到dest,那么这个输出过程如何完成呢?Unity的Graphics类提供了静态方法Blit可以帮助完成这个过程,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);
从这个函数的原型可以看出,Unity允许在渲染纹理过程中使用材质,这里的材质往往是Unity提供的一种稍微有些特别的材质,它所用的Shader与众不同。
首先这个Shader从外部接受了必要的参数,包括纹理贴图;其次这个Shader跟着OnRenderImage函数的调用时间来,换言之这个Shader是在在所有的透明或者不透明的渲染Pass执行完之后再执行的,因此它的深度写入必须关闭。
在正式进入相关Shader的编写之前,需要先确定一个脚本,它包含了屏幕后处理脚本所需的基本方法,同时也预留了接口给不同的后处理脚本使用。
[ExecuteInEditMode]
[RequireComponent(typeof(Camera))]
public class PostEffectBase : MonoBehaviour {
// Use this for initialization
private void Start () {
CheckResources();
init();
}
// Update is called once per frame
private void Update () {
execute();
}
protected void CheckResources() {
bool isSupported = CheckSupport();
if(!isSupported) {
NotSupported();
}
}
// 检查当前系统是否支持后处理
// 需要注意的是,新版本里SystemInfo.supportsRenderTextures已经固定为true
protected bool CheckSupport() {
if(!SystemInfo.supportsImageEffects || !SystemInfo.supportsRenderTextures) {
Debug.LogWarning("This platform does not support image effects or render textures.");
return false;
}
return true;
}
// 如果不支持,则禁用该脚本
protected void NotSupported() {
enabled = false;
}
// 检查并生成材质
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;
}
}
}
protected virtual void init() { }
protected virtual void execute() { }
}
如上就是一个简单的基类,它包含了后处理重要的一段代码,即根据传入的Shader来生成Material,后处理的材质并不是直接创建在Asset中的,而是脚本运行过程中自行生成的。
有了这个基类,下面就开始解析几种常见的后处理。
亮度,饱和度和对比度是非常常见的图像调整参数,几乎所有的图片处理工具都会提供调整这三个数字的地方,而在Unity中想要做到这一点,使用后处理机制是再合适不过了。
在进入代码部分之前,首先要分析这三个参数都和什么东西有关,它们是怎么起效的。
先看亮度Brightness,这个参数的含义很直白,就是图片的明亮程度,它起效的方式也很直接,只要将置顶像素点的RGB分量等比例放大即可,实际上拿出调色板工具就能看出,无论什么颜色,只要RGB分量值按比例变大,那么颜色就会变得明亮;如果按比例缩小,则颜色变得昏暗。
然后是饱和度,这个概念比较复杂,一般而言可以简单认为它是度量一个颜色偏离其灰度颜色的程度,饱和度越高,则该颜色距离原灰度颜色越远,也就越“鲜艳”。大部分时候饱和度是个针对视觉的概念,尤其是在计算中,经常需要依赖经验公式。
最后的对比度,顾名思义它是图片中各个不同颜色之间的“区分程度”,这个值越高,则不同颜色之间的区分度越大,直观的表现就是暗的部分更暗,亮的部分更亮。
知道了这三个概念都是怎么回事后,接下来着手进行代码编写。
第一步,需要一个挂载到Camera对象上的脚本。
public class BrightSaturateAdnContrast : PostEffectBase {
public Shader briSatConShader;
private Material briSatConMaterial;
public Material material {
get {
briSatConMaterial = CheckShaderAndCreateMaterial(briSatConShader, briSatConMaterial);
return briSatConMaterial;
}
}
[Range(0f, 3f)]
public float brightness = 1.0f;
[Range(0f, 3f)]
public float saturation = 1.0f;
[Range(0f, 3f)]
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);
}
}
}
继承了之前编写的通用基类后事情就简单了,要求配置一个Shader,然后生成一个Material,最后在OnRenderImage方法中为材质传入参数,用Blit方法渲染图像。
所需的Shader如下,需要注意的是,如果手动创建,在Unity中的Shader次级菜单下选择ImageEffectShader能节约一些改写的时间。
Shader "Hidden/BSCShader" {
Properties {
_MainTex ("Texture", 2D) = "white" {}
_Brightness ("Brightness", Float) = 1
_Saturation ("Saturation", Float) = 1
_Contrast ("Contrast", Float) = 1
}
SubShader {
// No culling or depth
Cull Off ZWrite Off ZTest Always
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _MainTex;
half _Brightness;
half _Saturation;
half _Contrast;
struct appdata {
float4 vertex : POSITION;
float2 texcoord : TEXCOORD0;
};
struct v2f {
float2 uv : TEXCOORD0;
float4 pos : SV_POSITION;
};
v2f vert (appdata 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);
// Brightness
fixed3 finalColor = renderTex.rgb * _Brightness;
// 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);
// Contrast
fixed3 avgColor = fixed3(0.5, 0.5, 0.5);
finalColor = lerp(avgColor, finalColor, _Contrast);
return fixed4(finalColor, renderTex.a);
}
ENDCG
}
}
}
注意到开头的Shader路径,这是直接创建ImageEffectShader的好处之一,因为后处理Shader往往并不需要用在材质上,因此放入隐藏菜单可以避免它出现在材质设置选项中,如果不是创建的ImageEffectShader,那么开发者必须自己修改这些代码。
SubShader中的第一行,三个设置项可以说是后处理Shader的标配,关闭剔除,关闭深度写入,开启深度测试。
之后的重头戏在片元处理函数里,可以看到亮度处理直接将颜色的RGB通道乘以亮度参数,饱和度处理使用了灰度计算的经验公式,即通过该公式计算得到的灰度值最接近人们“认为”正确的颜色灰度。
最后的对比度则利用了简单的lerp方法,当对比度超过1时会按照一定的插值距离进行向后插值,达到提升对比度的效果;而如果对比度小于1则会逐渐靠近一个固定的灰色。
至此,一个简单的调整渲染图像的亮度,饱和度和对比度的工具就可以使用了。
边缘检测总能给人一种高深莫测的感觉,但实际上它的基本概念非常简单,常见的边缘检测都是基于“卷积”算法的,它们的原理都一样,使用特定的卷积算子来得到期望的结果。
针对“卷积”这个概念,有很多专业的讲解,因此在这里只提一点简单的解释。所谓卷积计算一般指使用一个“卷积核”对图像中的每个像素点进行一系列操作,而卷积核往往是一个正方形网格,2X2或者3X3乃至5X5都可以,网格中每个单元格都有一个权重值,卷积运算时将把每个格子覆盖的点的数值与格子的权重相乘并相加,最后的结果放入网格中心位置(对于偶数边长网格而言是靠近中心的位置)。
通过这样的卷积计算,能实现很多常见的效果,比如边缘检测,比如高斯模糊。
那么回到边缘检测,所谓的边缘其实就是相邻像素的颜色变化较大的地方,也就是梯度值较大,对于这种情况有几种常用的卷积核可以选择,比如2X2的Roberts,或者3X3的Sobel等。
以Sobel为例,它有X和Y两个方向的卷积核,因此计算时需要同时计算两个坐标轴的数值。
首先还是编写脚本。
public class EdgeDetection : PostEffectBase {
public Shader edgeDetectShader;
private Material edgeDetectMaterial;
public Material material {
get {
edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
return edgeDetectMaterial;
}
}
[Range(0f, 1f)]
public float edgeOnly = 0.0f;
public Color edgeColor = Color.black;
public Color backgroundColor = Color.white;
void OnRenderImage(RenderTexture src, RenderTexture dest) {
if(material != null) {
material.SetFloat("_EdgeOnly", edgeOnly);
material.SetColor("_EdgeColor", edgeColor);
material.SetColor("_BackgroundColor", backgroundColor);
Graphics.Blit(src, dest, material);
} else {
Graphics.Blit(src, dest);
}
}
}
参数很简单,第一个参数表示是否显示原图,设置为1时原图变为纯色背景,只留下边缘;第二个参数是边缘颜色,第三个则是背景颜色。
需要的Shader如下。
Shader "Hidden/EdgeDetectShader" {
Properties {
_MainTex ("Texture", 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 {
// No culling or depth
Cull Off ZWrite Off ZTest Always
Pass {
CGPROGRAM
#pragma vertex vert
#pragma fragment fragSobel
#include "UnityCG.cginc"
sampler2D _MainTex;
half4 _MainTex_TexelSize;
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;
struct appdata {
float4 vertex : POSITION;
float2 texcoord : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
half2 uv[9] : TEXCOORD0;
};
v2f vert (appdata 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, -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);
return edge;
}
fixed4 fragSobel (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
}
}
}
可以看到,为了进行卷积计算,在顶点计算函数中根据当前纹理坐标生成了九个不同的坐标,刚好对应Sobel卷积核所需的3X3网格。
之后的重点就在Sobel函数里,它针对每个坐标进行采样,将原纹理颜色转化为纯灰度颜色,随后应用Sobel算子,得到的结果作为当前像素是否属于边缘的“权值”或者“可信度”。
将脚本挂载并且配置Shader后就能看到边缘检测的结果了,需要注意的是,Sobel算子检测到的边缘并不“纯粹”,实际上将EdgeOnly设置为1后就能看到,很多本来不是边缘的地方也有轻微的边缘效果,这主要是源自于图像本身的复杂性和Sobel算子的局限性。
模糊也是非常常见的一类图像效果,高斯模糊更是名声在外,这种模糊的效果比平均模糊好,而且开销可以随意控制,因此是重要的模糊方式。
高斯模糊也是基于卷积的运算,它使用的是高斯算子,也称高斯核,运算过程又称为“高斯滤波”,事实上模糊操作就是一种滤波操作,尤其是和平面傅里叶变换联系起来之后。
高斯核并不是唯一的,它是通过一个计算公式产生出来的,其公式如下。
其中 σ 为标准差,一般会取值为1, x 和 y 分别对应当前位置到卷积核中心的距离,必须为整数。同时为了防止计算结果的图像变暗,需要对高斯核的权重值进行归一化处理,保证所有权重值的和为1。
事实上,高斯核很好地描述了一个像素的邻居对它的影响程度,离得越近,影响越大。
高斯核的使用中往往不应该直接按照正常卷积的计算方法来使用,这是因为高斯滤波计算的消耗与高斯核的大小有密切关系,过大的高斯核会导致消耗快速增长,复杂度几乎是几何速度上升。
因此可以考虑将高斯核拆成两个一维的核,由于卷积计算的特点,适当构造的两个一维核就能完成整个卷积计算,此时消耗将大幅下降。
接下来,还是先编写脚本。
public class GaussianBlur : PostEffectBase {
public Shader gaussianBlurShader;
private Material gaussianBlurMaterial;
public Material material {
get {
gaussianBlurMaterial = CheckShaderAndCreateMaterial(gaussianBlurShader, gaussianBlurMaterial);
return gaussianBlurMaterial;
}
}
[Range(0, 4)]
public int iteration = 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 < iteration; i++) {
material.SetFloat("_BlurSize", 1.0f + i * blurSpread);
RenderTexture buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);
// Vertical Pass
Graphics.Blit(buffer0, buffer1, material, 0);
RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);
// Horizontal Pass
Graphics.Blit(buffer0, buffer1, material, 1);
RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
}
Graphics.Blit(buffer0, dest);
RenderTexture.ReleaseTemporary(buffer0);
} else {
Graphics.Blit(src, dest);
}
}
}
注意到参数中的downSample,这个参数表示采样的最小单位,换言之如果这个值大,那么采样时就会将多个像素认作一个像素进行采样,为此在脚本中进行了预处理,直接根据downSample的数值将原图缩小,处理完之后再放大。
整个高斯模糊过程涉及多次重新渲染,而且至少需要两个Pass,分别对水平和垂直两个方向进行卷积,由此可见其性能消耗之大。
脚本准备完成后,Shader的代码如下。
Shader "Hidden/GaussianBlurShader" {
Properties {
_MainTex ("Texture", 2D) = "white" {}
_BlurSize ("Blur Size", FLoat) = 1.0
}
SubShader {
CGINCLUDE
sampler2D _MainTex;
half4 _MainTex_TexelSize;
float _BlurSize;
struct appdata {
float4 vertex : POSITION;
float2 texcoord : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
half2 uv[5] : TEXCOORD0;
};
v2f vertBlurVertical(appdata 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 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.x * 1.0) * _BlurSize;
o.uv[2] = uv - float2(0.0, _MainTex_TexelSize.x * 1.0) * _BlurSize;
o.uv[3] = uv + float2(0.0, _MainTex_TexelSize.x * 2.0) * _BlurSize;
o.uv[4] = uv - float2(0.0, _MainTex_TexelSize.x * 2.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
// No culling or depth
Cull Off ZWrite Off ZTest Always
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
}
}
}
注意到代码中的CGINCLUDE标签,从名字就看得出来这是用来编写类似C语言头文件那种能被引用的代码的,在这里之所以要用这个标签,主要是因为高斯模糊需要两个Pass,而它们的片元计算函数一模一样,所以用这种方法缩减代码量。
片元计算中的高斯核只有三个值,这也是一种简化,实际上按照之前分析的,用两个一维算子替代原来的5X5高斯核后,还能发现它们的内容实际上都一样,只是应用的Pass不同,所以直接合并为一个,又因为数值有重复,因此最后缩减到三个值即可完成计算。
挂上脚本,配置Shader,然后就能看到高斯模糊的效果了。
所谓Bloom是一种常见的光影特效,它的视觉特点是让画面中比较亮的部分“扩散”开来,侵入到本来不被直接照亮的地方,因此也被称为“全屏泛光”。这种特效的原理其实很简单,只要提取出画面里“发亮”的部分,然后对这张采样图做高斯模糊模拟散射效果,最后合并到原图里就可以了。
为此需要借助前文提到的高斯模糊Shader,并且在进行模糊之前还要预先处理。
挂载的脚本如下
public class BloomEffect : PostEffectBase {
public Shader bloomShader;
private Material bloomMaterial;
public Material material {
get {
bloomMaterial = CheckShaderAndCreateMaterial(bloomShader, bloomMaterial);
return bloomMaterial;
}
}
[Range(0, 4)]
public int iteration = 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;
void OnRenderImage(RenderTexture src, RenderTexture dest) {
if(material != null) {
material.SetFloat("_LuminanceThreshold", luminanceThreshold);
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, material, 0);
for(int i = 0; i < iteration; i++) {
material.SetFloat("_BlurSize", 1.0f + i * blurSpread);
RenderTexture buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);
// Vertical Pass
Graphics.Blit(buffer0, buffer1, material, 1);
RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
buffer1 = RenderTexture.GetTemporary(rtW, rtH, 0);
// Horizontal Pass
Graphics.Blit(buffer0, buffer1, material, 2);
RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
}
material.SetTexture("_Bloom", buffer0);
Graphics.Blit(src, dest, material, 3);
RenderTexture.ReleaseTemporary(buffer0);
} else {
Graphics.Blit(src, dest);
}
}
}
可以看到这部分脚本和前文高斯模糊的脚本十分相似,多出一个参数,用来指定“明亮区域”的阈值,凡是亮度超过这个阈值的地方就被认为是“明亮”的。此外在OnRenderImage方法中,不同于高斯模糊的两个Pass,这里用到了四个Pass,首先把明亮区域抠出来放到缓存里,然后对缓存下来的“明亮区域”做高斯模糊,最后把模糊好的“明亮区域”作为贴图输入Shader,调用第四个Pass进行渲染。
因此Shader部分如下
Shader "Hidden/BloomShader" {
Properties {
_MainTex ("Texture", 2D) = "white" {}
_Bloom ("Bloom", 2D) = "black" {}
_BlurSize ("Blur Size", FLoat) = 1.0
_LuminanceThreshold ("Luminance Threshold", Float) = 0.5
}
SubShader {
CGINCLUDE
sampler2D _MainTex;
half4 _MainTex_TexelSize;
sampler2D _Bloom;
float _BlurSize;
float _LuminanceThreshold;
struct appdata {
float4 vertex : POSITION;
float2 texcoord : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
half2 uv : TEXCOORD0;
};
v2f vertExtractBright(appdata v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
return o;
}
fixed luminance(fixed4 color) {
return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
}
fixed4 fragExtractBright(v2f i) : SV_TARGET {
fixed4 c = tex2D(_MainTex, i.uv);
fixed val = clamp(luminance(c) - _LuminanceThreshold, 0.0, 1.0);
return c*val;
}
struct v2fBloom {
float4 pos : SV_POSITION;
half4 uv : TEXCOORD0;
};
v2fBloom vertBloom(appdata v) {
v2fBloom o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv.xy = v.texcoord;
o.uv.zw = v.texcoord;
#if UNITY_UV_STARTS_AT_TOP
if(_MainTex_TexelSize.y < 0.0) {
o.uv.w = 1.0 - o.uv.w;
}
#endif
return o;
}
fixed4 fragBloom(v2fBloom i) : SV_TARGET {
return tex2D(_MainTex, i.uv.xy) + tex2D(_Bloom, i.uv.zw);
}
ENDCG
// No culling or depth
Cull Off ZWrite Off ZTest Always
Pass {
CGPROGRAM
#pragma vertex vertExtractBright
#pragma fragment fragExtractBright
ENDCG
}
UsePass "Hidden/GaussianBlurShader/GAUSSIAN_BLUR_VERTICAL"
UsePass "Hidden/GaussianBlurShader/GAUSSIAN_BLUR_HORIZONTAL"
Pass {
CGPROGRAM
#pragma vertex vertBloom
#pragma fragment fragBloom
ENDCG
}
}
}
可以看到Shader中只编写了两个Pass的代码,第一个Pass负责“抠图”,使用明亮阈值把“明亮区域”找出来并渲染;最后一个Pass负责“合并”,将原图和模糊处理后的“明亮区域”进行直接合并。
高斯模糊的部分直接调用了前文编写的Shader中的Pass,而这也就是前文中为Pass命名的主要原因,方便代码复用。
挂好脚本,设置Shader,之后就能在图像上看到全屏泛光的效果了,通过调整脚本的参数可以改变泛光的情况。
运动模糊严格来说属于镜头特效,它是由于被拍摄物体运动速度过快以至于快门打开时间内物体已经运动了一段距离,导致拍摄结果出现拖影和模糊。这种特效通常用来表现速度感,而且也能让物体的运动更加真实和平滑,因为现实中的运动模糊无处不在,但计算机渲染时却根本没有这种效果。
要得到运动模糊的特效有很多方法,比如积累缓存,连续渲染多帧图片并计算均值放入显示缓存中,这样一来不运动的地方就不会有什么特效,但运动的物体会因为均值计算而产生类似拖影的效果。可这种连续渲染多帧的方法对性能消耗很大,而另一种相对高效的方法则是使用“速度缓存”,不缓存多帧图像而是缓存当前时刻每个像素的运动方向和速度,然后利用这个值来决定模糊的情况。
下面的代码采用了第一种“积累缓存”的方法。
挂载的脚本如下
public class MotionBlur : PostEffectBase {
public Shader motionBlurShader;
private Material motionBlurMaterial;
public Material material {
get {
motionBlurMaterial = CheckShaderAndCreateMaterial(motionBlurShader, motionBlurMaterial);
return motionBlurMaterial;
}
}
[Range(0f, 0.9f)]
public float blurAmount = 0.5f;
private RenderTexture accumulationTexture;
void OnDisable() {
DestroyImmediate(accumulationTexture);
}
void OnRenderImage(RenderTexture src, RenderTexture dest) {
if(material != null) {
if(accumulationTexture == null || accumulationTexture.width != src.width || accumulationTexture.height != src.height) {
DestroyImmediate(accumulationTexture);
accumulationTexture = new RenderTexture(src.width, src.height, 0);
accumulationTexture.hideFlags = HideFlags.HideAndDontSave;
Graphics.Blit(src, accumulationTexture);
}
accumulationTexture.MarkRestoreExpected();
material.SetFloat("_BlurAmount", 1.0f - blurAmount);
Graphics.Blit(src, accumulationTexture, material);
Graphics.Blit(accumulationTexture, dest);
} else {
Graphics.Blit(src, dest);
}
}
}
运动模糊的脚本不复杂,总共就一个可调参数,表示模糊的程度,在“积累缓存”这种计算方法下,过大的模糊程度可能造成单帧完整图像的出现,因此一个相对较好的模糊值很重要。
在OnRenderImage方法中,首先通过当前帧创建了一个积累缓存,然后通过标记其为“需要恢复”的纹理,这样一来每次渲染时都会恢复之前混合好图像,此时再通过Shader把当前帧叠加上去并且将积累缓存显示出来即可。
所用的Shader如下
Shader "Hidden/MotionBlurShader" {
Properties {
_MainTex ("Texture", 2D) = "white" {}
_BlurAmount ("Blur Amount", Float) = 1.0
}
SubShader {
CGINCLUDE
sampler2D _MainTex;
fixed _BlurAmount;
struct appdata {
float4 vertex : POSITION;
float2 texcoord : TEXCOORD0;
};
struct v2f {
float4 pos : SV_POSITION;
half2 uv : TEXCOORD0;
};
v2f vert (appdata v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
return o;
}
fixed4 fragRGB(v2f i) : SV_TARGET {
return fixed4(tex2D(_MainTex, i.uv).rgb, _BlurAmount);
}
fixed4 fragA(v2f i) : SV_TARGET {
return tex2D(_MainTex, i.uv);
}
ENDCG
Cull Off ZWrite Off ZTest Always
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
}
}
}
渲染过程使用两个Pass,分别针对图像的RGB通道和Alpha通道,之所以要分开进行是因为混合颜色时使用了传入参数作为Alpha值,这不应该影响到真正结果的透明度,因此Alpha通道的Pass里直接就返回了纹理采样结果,不做任何处理。
之后挂上脚本,配置Shader,在场景中设置几个物体并给定动画,点击运行即可看到效果。
关于后处理的Shader暂告一段落,但其实后处理还有很多很多细节内容,而且这些后处理对于游戏画面的提升有可能是非常可观的,因此后处理是相当重要的特效处理,必须认证对待。
后面会解析关于深度纹理和法线纹理的使用。