屏幕后处理(ScreenPostProcessing) 是游戏中实现屏幕特效的常见方法。屏幕后处理通常需要两部分构成:屏幕后处理脚本系统和屏幕后处理渲染系统
- 屏幕后处理脚本系统:通常情况下需要将一个屏幕后处理脚本挂载到活动摄像机上,从而对渲染到屏幕上的图像进行采样,并存储为一张纹理,供之后的屏幕后处理进行二次加工;另外需要对获取的纹理发号施令,规定它使用什么材质进行后处理。
- 屏幕后处理渲染系统:通常情况下需要准备一个用于后处理的Shader,实例化为材质,承接从脚本系统发出的屏幕纹理(会自动输出为材质的_MainTex贴图)并进行后处理。在后处理Shader中,Shader对整体画面而非某个模型/特效进行渲染,从而控制画面整体的美术效果。
简单屏幕后处理
因为屏幕后处理首先会需要从屏幕上抓取画面,然后传递给材质渲染,这一套操作基本对所有屏幕后处理是通用的,所以我们选择先建立屏幕后处理脚本基类。我们希望后处理效果在编辑模式下也能正常使用,并且能够自动使用给定的shader创建材质。
using UnityEngine;
[ExecuteInEditMode] // 编辑状态激活
[RequireComponent (typeof(Camera))] // 必须挂载到Camera对象下
public class PostEffectBase : MonoBehaviour
{
protected void CheckResources() // 检测资源是否支持后处理
{
bool isSupported = CheckSupport();
if(!isSupported)
{
enabled = false;
}
}
protected bool CheckSupport()
{
// 判断什么情况下不支持后处理
return true;
}
// 检查shader并创建后处理材质
protected Material CheckShaderAndCreateMaterial(Shader shader, Material material)
{
if(!shader) return null; // 无shader不创建
// material已挂载同shader
if(shader.isSupported && material && material.shader == shader) return material;
if(!shader.isSupported) return null; // shader不支持
else // 创建material
{
material = new Material(shader);
material.hideFlags = HideFlags.DontSave; // 对象不保存
if(material) return material;
else return null;
}
}
}
为什么需要使用给定的Shader自动设置材质,而不直接将设置好Shader的材质指定给脚本?大部分游戏都有调整画面的选项,而这些选项背后所对应的就是为游戏中各种模型进行着色的着色器选项,开发者在Unity中自然可以方便的对每个材质进行细致调整,而到了游戏中,玩家想要调整画面选项时就不可能直接找到某个模型的材质然后调整它,此时就需要脚本给玩家准备游戏内调整这些参数的接口(虽然玩家在游戏中看到这些晦涩难懂的开关会摸不着头脑,但这些只是渲染中的冰山一角),并使用脚本来控制这些材质。
如果材质中和脚本中都能够控制参数,难免会出现控制权限问题,即不知道脚本给的参数是正确的还是材质给的参数是正确的,所以对于渲染自由度高的物体我会选择减少使用固定好的材质,转而使用游戏中可控性更高的脚本去实时产生材质。
调整屏幕色调的后处理
在拥有基类的基础上,可以对摄像机输出的图像进行一些后处理了,首先调整亮度(Brightness)、饱和度(Saturation)、对比度(Contrast)。新建后处理脚本继承自后处理基类:
using UnityEngine;
// 亮度饱和度对比度后处理
public class BrightnessSaturationContrast : PostEffectBase
{
public Shader BSCShader; // Shader的声明
private Material BSCMat; // 创建材质
public Material material // 访问材质的接口
{
get
{
BSCMat = CheckShaderCreateMat(BSCShader, BSCMat);
return BSCMat;
}
}
// 获取Shader变量的ID
int brightnessID = Shader.PropertyToID("_Brightness");
int saturationID = Shader.PropertyToID("_Saturation");
int contrastID = Shader.PropertyToID("_Contrast");
[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(brightnessID, brightness);
material.SetFloat(saturationID, saturation);
material.SetFloat(contrastID, contrast);
Graphics.Blit(src, dest, material);
}
else
{
Graphics.Blit(src, dest);
}
}
}
有两个新的函数:
void OnRenderImage(RenderTexture src, RenderTexture dest);
void Graphics.Blit(RenderTexture src,RenderTexture dest, Material material, int pass);
- RenderTexture变量存储一张贴图,代表一帧图像。
- OnRenderImage函数会在每一帧渲染时调用,它的参数src代表从摄像机上截下的一帧未经过后处理的图像,而dest代表这一帧经过该函数处理后将会输出的图像。
- Graphics.Blit函数声明即执行,它会将src图像作为_MainTex参数传递给material,经过材质的pass通道处理后,新得到的图像赋值给dest。如果pass缺省或等于-1,则代表依次执行material的所有通道。如果material缺省则不执行任何渲染,直接将src的值赋予dest。
完毕后将这个脚本挂载到摄像机上。开始编写后处理的Shader。
屏幕后处理的shader比较简单,顶点着色中不需要很多参数,只需要输出最基本的裁剪空间坐标、uv坐标即可。片断着色则需要对输出的贴图进行亮度、饱和度、对比度处理:
Shader "PostEffect/BSC"
{
Properties
{
_MainTex ("基底色", 2D) = "white" {}
_Brightness ("亮度", Range(0.0, 2.0)) = 1.0
_Saturation ("对比度", Range(0.0, 2.0)) = 1.0
_Contrast ("饱和度", Range(0.0, 2.0)) = 1.0
}
SubShader
{
Pass
{
ZTest Always
Cull Off
ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _MainTex;
float _Brightness;
float _Saturation;
float _Contrast;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
};
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
float4 frag (v2f i) : SV_Target
{
// 贴图采样
float4 var_MainTex = tex2D(_MainTex, i.uv);
// 亮度
float3 finalRGB = var_MainTex.rgb * _Brightness;
// 饱和度
float luminance = 0.30 * var_MainTex.r + 0.59 * var_MainTex.g + 0.11 * var_MainTex.b;
float3 lumCol = float3(luminance, luminance, luminance);
finalRGB = lerp(lumCol, finalRGB, _Saturation);
// 对比度
float3 avgCol = float3(0.5, 0.5, 0.5);
finalRGB = lerp(avgCol, finalRGB, _Contrast);
// 返回值
return float4(finalRGB, var_MainTex.a);
}
ENDCG
}
}
}
Pass内需要的三个命令是屏幕后处理的标准配置,防止在后处理之后渲染的物体不能正常进行渲染,如半透明物体。
- 亮度:直接将原颜色乘以亮度值即可。
- 饱和度:根据305911像素明度计算法(可修改)获取像素明度,然后在明度图与基底色间使用饱和度值进行lerp。
- 对比度:设定最低对比度时图像为纯灰,然后将纯灰图与基底色使用对比度图进行lerp。
完成后将Shader赋给脚本声明。最终效果(原图 高亮度 高饱和度 高对比度):
边缘检测后处理
在边缘检测前,需要引入一个概念:卷积。卷积操作指的是使用一个卷积核对一张图像的每一个像素进行一系列操作。卷积核一般大小为2x2像素、3x3像素、5x5像素,卷积核的中心放置于待处理的像素位置,其余像素按照像素值与权值乘积结果求和,最终结果为中心像素的新像素值。
卷积计算能够实现常见的屏幕后处理效果,如边缘检测、图像模糊等。
边的形成:当两个像素之间梯度(颜色差距、纹理差距、亮度差距)过大时,可以看作这两个像素间有边。从这个性质出发,我们选择使用Sobel边缘检测算子:
对每个像素进行卷积计算,得到两个方向上的梯度值Gx和Gy,整体梯度:
G = ∣ G x ∣ + ∣ G y ∣ G=|G_x|+|G_y| G=∣G