文章目录
Unity-shader学习笔记(八)
18 屏幕后处理效果
屏幕后处理效果是游戏中实现屏幕特效的常见方法。
18.1 建立一个基本的屏幕后处理脚本系统
屏幕后处理,指的是在渲染玩整个场景得到屏幕图像后,再对这个图象进行一系列操作,实现各种屏幕特效。
通过这种技术,可以为游戏画面添加更多的艺术效果,例如景深(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是我们使用的材质,其使用的shader将会进行各种屏幕后处理操作,而src纹理将会被传递给shader中名为_MainTex的纹理属性。
参数pass的默认值为-1,表示将会依次调用shader内所有的P啊水水,否则只会调用给定索引的Pass。
在默认情况下,OnRenderImage函数会在所有不透明和透明的Pass执行完毕后被调用,这样就可以对场景中所有游戏对象都产生影响。有时候你也可能会
在执行完不透明的Pass(渲染队列小于2500的Pass,包括Background、Geometry、AlphaTest)就立即执行OnRenderImage函数,以避免对透明物体产生影响。此时要做的就是在OnRenderImage函数前添加ImageEffectOpaque属性来实现这样的目的。
因此,要在Unity中实现屏幕后处理效果的过程通常是:首先在摄像机中添加一个用于屏幕后处理的脚本。在这个脚本中,我们会实现OnRenderImage函数来获取当前屏幕的渲染纹理。然后再调用Graphics.Blit函数使用特定的shader来对当前图像进行处理,再把返回的渲染纹理显示到屏幕上。对于复杂的纹理,我们可能需要多次调用Graphics.Blit。
但是,并不是任何情况都能使用屏幕后处理技术,这取决于当前平台是否支持渲染纹理和屏幕特效、是否支持当前使用的UnityShader等。所以创建一个用于屏幕后处理效果的基类:
①首先,所有屏幕后处理效果都需要绑定在某个摄像机上,并且我们希望在编辑器状态下也能够执行该脚本来查看状态
[ExecuteInEditMode]
[RequireComponent (typeof(camera))]
public class PostEffectsBase : MonaBehaviour{
...;
}
②在Start函数中调用CheckResource函数,以检测各种资源和条件是否满足
protected void CheckResources(){
bool isSupported = CheckSupport();
if(isSupported == false){
NotSupported();
}
}
protected bool CheckSupport(){
if(SystemInfo.supportsImagEffects == false || SystemInfo.supportsRenderTextures == false){
Debug.LogWarning("This platform does not support image effects or render texture.");
return false;
}
return true;
}
protected void NotSupported(){
enabled = false;
}
protected void Start(){
CheckResources();
}
SystemInfo是一个访问硬件设备信息的类,类中的属性都只是只读属性;supportsImagEffects是个bool类型函数,用于判断是否支持图形特效;supportsRenderTextures同样是个bool类型函数,用于判断是否支持渲染纹理。
③因为每个屏幕后处理效果通常都需要指定一个Shader来创建一个用于处理渲染纹理的材质,因此这个基类也要提供这样的方法
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;
}
}
HideFlags类是一个枚举类,用于控制Object对象的销毁方式及其在检视面板中的可视性。DontSave的作用是在新场景中保留对象,但不保留其子类对象。isSupported是一个只读函数,用于判断这个shader是否可以在用户终端上运行。
18.2 调整屏幕亮度、饱和度和对比度
需要同时有控制材质的shader和控制屏幕显示的C#脚本
(1)控制屏幕显示的C#脚本
①创建脚本,并将脚本原本继承的MonoBehaviour更改为我们上面写的,命名为PostEffectsBase;
②声明该效果需要的Shader,并据此创建相应的材质:
public Shader briSatConShader;
private Material briSatConMaterial;
public Material material {
get{
briSatConShader = 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;
提供初始值,并固定其变化区间。
④定义OnRenderImage函数来进行真正的特效处理
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);
}
}
(2)控制材质的shader
①在Properties中声明属性
Properties {
_MainTex ("Base (RGB)", 2D) = "white" {}
_Brightness ("Brightness", Float) = 1
_Saturation("Saturation", Float) = 1
_Contrast("Contrast", Float) = 1
}
声明的属性要与OnRenderImage函数中的一致,后三个属性的值会由脚本而得。
②定义用于屏幕后处理的Pass标签
SubShader {
Pass {
ZTest Always Cull Off ZWrite Off
屏幕后处理实际上是在场景中绘制了一个与屏幕痛快通告的四边形面片,为了防止它对其他物体产生影响,我们需要设置相关的渲染状态。这里我们选择的是关闭深度测试的深度写入,是为了防止它挡住在其后面被渲染的物体。这个基本上是屏幕后处理的标配。
③屏幕特效的顶点着色器一般都很简单,只需要进行必需的顶点变换。
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);
}
⑤这里我们需要关闭shader的Fallback,即:
Fallback Off
18.3 边缘检测
边缘检测的原理是利用一些边缘检测算子对图像进行卷积操作,没错,就是深度学习/图像处理里面的那个卷积。
18.3.1 什么是卷积
在图像处理中,卷积操作指的是使用一个卷积核对一张图像中的每个像素进行一系列操作。卷积核通常是一个四方形网格结构,该区域内的每一个放个都有一个权重值。当对某个像素进行卷积时,我们将卷积核的中心放置于该像素上,翻转核之后再依次计算核中每个元素和其覆盖的图像像素值的乘积并求和,得到的结果就是该位置的新像素。
18.3.2 常见的边缘检测算子
边缘检测算子,也就是我们前面所说的卷积核,它就是用于检测边缘/那条边的。我们先想想,边应该是怎样的?或者说我们是怎样确定一个物体是有边的?
我在一张黑纸上画了一个白色的正方向,你可以马上指出在正方形和其余黑色部分的交界处就是一条边,你是怎么确定的?根据颜色。
我们也是这样,根据相邻像素之间存在差别明显的颜色、亮度、纹理等属性,来确定那条边。我们也将这种相邻像素之间的差值用梯度来表示,那么边界处的梯度值肯定是最大。
常见的边缘检测算子有三种:
①Roberts:
KaTeX parse error: Undefined control sequence: \matrix at position 16: G_{x} = \left[\̲m̲a̲t̲r̲i̲x̲{ -1 & 0\\ 0 & …
②Prewitt:
KaTeX parse error: Undefined control sequence: \matrix at position 16: G_{x} = \left[\̲m̲a̲t̲r̲i̲x̲{ -1 & -1 & -1\…
③Sobel:
KaTeX parse error: Undefined control sequence: \matrix at position 16: G_{x} = \left[\̲m̲a̲t̲r̲i̲x̲{ -1 & -2 & -1\…
由于梯度是一个矢量,有方向,也有大小,所以我们就不能只对水平方向或垂直方向进行卷积计算梯度值,当算出各方向的梯度值后,再进行:
G = G x 2 + G y 2 G = \sqrt{G_{x}^2+G_{y}^2} G=Gx2+Gy2
但由于开方操作会对性能造成一定影响,我们就会使用绝对值操作来代替开根号:
G = ∣ G x ∣ + ∣ G y ∣ G = |G_{x}|+|G_{y}| G=∣Gx∣+∣Gy∣
18.3.3 实现
同样,我们需要一个shader和一个C#脚本
(1)用于边缘检测的C#脚本
①依然继承于PostEffectsBase
public class EdgeDetection : PostEffectsBase{
...;
}
②声明该效果需要的Shader,并据此创建相应的材质
public Shader edgeDetectShader;
private Material edgeDetectMaterial = null;
public Material material {
get {
edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
return edgeDetectMaterial;
}
}
③在脚本中提供应用于调整边缘线强度、描边颜色以及背景颜色的参数
[Rnage(0.0f, 1.0f)]
public float edgesOnly = 0.0f;
public Color edgeColor = Color.black;
public Color backgroundColor = Color.white;
当edgesOnly值为0时,边缘将会叠加在原渲染图像上;edgesOnly值为1时,则会只显示边缘,不显示原渲染图象。其中,背景颜色由backgroundColor指定,边缘颜色由edgeColor指定。
④定义OnRenderImage函数来进行真正的特效:
void OnRenderImage (RenderTexture src, RenderTexture dest){
if (material != null){
material.SetFloat("_EdgeOnly", edgeOnly);
material.SetFloat("_EdgeColor", edgeColor);
material.SetFloat("_BackgroundColor", backgroundColor);
Graphics.Blit(src, dest, material);
}
else{
Graphics.Blit(src, dest);
}
}
(2)shader部分
①在Properties中声明需要的属性
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)
}
②为屏幕后处理的Pass设置相关的渲染状态
SubShader {
Pass{
ZTest Always Cull Off ZWrite Off
......;
}
}
③声明对应的变量
sampler2D _MainTex;
half4 _MainTex_TexelSize;
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;
新声明的_MainTex_TexelSize变量是为了访问 _MainTex对应的每个纹素的大小。xxx_TexelSize是Unity为客户提供的访问xxx纹理对应的每个纹素的大小。
④在顶点着色器中计算边缘检测时需要的纹理坐标
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;
}
我们将采用Sobel算子进行,所以我们就在顶点着色器的输出结构体中定义了一个维数为9的纹理数组。在片元着色器中也是可以进行行纹理坐标的采样的,但后果就是运算增加,性能降低。
⑤重要的片元着色器
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);
}
我们首先调用Sobel函数计算当前像素的梯度值edge,并利用该值分别计算背景为原图和纯色下的颜色值,然后利用_EdgeOnly在两者之间插值得到最终的像素值。Sobel函数将利用Sobel算子对原图进行边缘检测。