屏幕后处理(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=∣Gx?∣+∣Gy?∣ 边缘检测脚本:
using UnityEngine;
public class EdgeDetect : PostEffectBase
{
public Shader EDShader;
private Material EDMat;
public Material material
{
get
{
EDMat = CheckShaderCreateMat(EDShader, EDMat);
return EDMat;
}
}
int edgeIntID = Shader.PropertyToID("_EdgeInt");
int edgeColID = Shader.PropertyToID("_EdgeCol");
int bgColID = Shader.PropertyToID("_BgCol");
[Range(0.0f, 1.0f)]
public float edgeInt = 0.0f; // 边缘覆盖强度
public Color edgeCol = Color.black; // 边缘色
public Color bgCol = Color.white; // 背景色
void OnRenderImage(RenderTexture src, RenderTexture dest)
{
if(material != null)
{
//材质赋值
material.SetFloat(edgeIntID, edgeInt);
material.SetColor(edgeColID, edgeCol);
material.SetColor(bgColID, bgCol);
Graphics.Blit(src, dest, material);
}
else Graphics.Blit(src, dest);
}
}
边缘覆盖强度:为0时显示正常画面与边缘,为1时仅显示边缘遮罩。边缘色:调整边缘的颜色。背景色:边缘遮罩的背景颜色。
边缘检测Shader:
Shader "PostEffect/EdgeDetect"
{
Properties
{
_MainTex ("基底色", 2D) = "white" {}
_EdgeInt ("边缘强度", Range(0.0, 1.0)) = 0.0
_EdgeCol ("边缘色", Color) = (0.0, 0.0, 0.0, 1.0)
_BgCol ("背景色", Color) = (1.0, 1.0, 1.0, 1.0)
}
SubShader
{
Pass
{
ZTest Always
Cull Off
ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _MainTex_TexelSize; // 单个像素占总屏幕大小
float _EdgeInt;
float4 _EdgeCol;
float4 _BgCol;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv[9] : TEXCOORD0; // 分别对应某像素与其四周共9个像素的uv信息
};
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
float2 uv = v.uv;
// 获取3x3卷积的9个像素的uv信息
o.uv[0] = uv + _MainTex_TexelSize.xy * float2(-1, -1);
o.uv[1] = uv + _MainTex_TexelSize.xy * float2(0, -1);
o.uv[2] = uv + _MainTex_TexelSize.xy * float2(1, -1);
o.uv[3] = uv + _MainTex_TexelSize.xy * float2(-1, 0);
o.uv[4] = uv + _MainTex_TexelSize.xy * float2(0, 0);
o.uv[5] = uv + _MainTex_TexelSize.xy * float2(1, 0);
o.uv[6] = uv + _MainTex_TexelSize.xy * float2(-1, 1);
o.uv[7] = uv + _MainTex_TexelSize.xy * float2(0, 1);
o.uv[8] = uv + _MainTex_TexelSize.xy * float2(1, 1);
return o;
}
float4 frag (v2f i) : SV_Target
{
// 贴图采样
float4 var_MainTex = tex2D(_MainTex, i.uv[4]);
float edge = Sobel(i);
float4 withEdgeCol = lerp(_EdgeCol, var_MainTex, edge);
float4 onlyEdgeCol = lerp(_EdgeCol, _BgCol, edge);
// 返回值
return lerp(withEdgeCol, onlyEdgeCol, _EdgeInt);
}
ENDCG
}
}
}
_MainTex_TexelSize代表相邻像素间的纹理坐标偏移量,如屏幕比例为1920x1080,这个值的x分量为1/1920,y分量为1/1080。需要9个UV信息,分别对应了3x3卷积的9个像素所在的位置。
片断着色器中计算了两个颜色:有基底色的边缘检测结果和没有基底色的边缘遮罩。
Sobel算法:
float Sobel(v2f i) // Sobel卷积边缘检测 输出边缘遮罩
{
const float Gx[9] =
{
-1, -2, -1,
0, 0, 0,
1, 2, 1
};
const float Gy[9] =
{
-1, 0, 1,
-2, 0, 2,
-1, 0, 1
};
float texColor;
float edgeX = 0;
float edgeY = 0;
for(int j = 0; j < 9; j++)
{
// 获取像素明度,边缘位置变亮
texColor = Luminance(tex2D(_MainTex, i.uv[j]));
edgeX += texColor * Gx[j];
edgeY += texColor * Gy[j];
}
return (1 - abs(edgeX) - abs(edgeY));
}
最终结果(原图 粉色线框 边缘遮罩 黄色背景色与蓝色线框)
高斯模糊后处理
与边缘检测类似,模糊同样会用到卷积运算,如中值模糊(选择卷积中值替换)和均值模糊(选择卷积平均值替换)。而高斯模糊是一种更高级的模糊效果。高斯模糊将正态分布引入图形学中,距离卷积核中心越远的像素对卷积的影响越小。
G
(
x
,
y
)
=
1
2
π
σ
2
e
x
2
+
y
2
2
σ
2
G(x,y)=\frac{1}{2\piσ^2}e^{\frac{x^2+y^2}{2σ^2}}
G(x,y)=2πσ21?e2σ2x2+y2? σ为标准方差,一般为1,xy对应像素位置到卷积核中心的整数距离。在实际情况下模糊距离越远对像素的影响越小。
在方差为1的情况下,5x5卷积核权重:
但是这样的计算过于昂贵,如果屏幕有1920x1080个像素,就需要进行5x5x1920x1080次采样,需要进行简化。可以由正态分布的性质知道,这个5x5卷积核是中心对称的,距离卷积核中心距离相同的像素权重相同。所以可以直接对应行相加简化成为两个1x5的行/列卷积核:
然后只需要存储不重复的三个权重数据就能达到最好性能了。
高斯模糊脚本:
using UnityEngine;
public class GaussianBlur : PostEffectBase
{
public Shader GaussianBlurShader;
private Material GaussianBlurMat;
public Material material
{
get
{
GaussianBlurMat = CheckShaderCreateMat(GaussianBlurShader, GaussianBlurMat);
return GaussianBlurMat;
}
}
int blurSizeID = Shader.PropertyToID("_BlurSize"); // 模糊像素尺寸
[Range(0, 4)]
public int iteration = 3; // 模糊迭代次数
[Range(0.2f, 3.0f)]
public float spread = 0.5f; // 模糊范围
[Range(1,8)]
public int sample = 2; // 缩放系数(采样大小)
void OnRenderImage(RenderTexture src, RenderTexture dest)
{
if(material != null)
{
int rtW = src.width / sample;
int rtH = src.height / sample;
// 建立渲染缓存器
RenderTexture bufferA = RenderTexture.GetTemporary(rtW, rtH, 0);
bufferA.filterMode = FilterMode.Bilinear; // 双线性
Graphics.Blit(src, bufferA);
for(int i=0; i<iteration; i++) // 叠加模糊次数 bufferA用来存储;bufferB用来模糊
{
material.SetFloat(blurSizeID, 1.0f + i * spread);
RenderTexture bufferB = RenderTexture.GetTemporary(rtW, rtH, 0);
Graphics.Blit(bufferA, bufferB, material, 0); // 渲染纵向
RenderTexture.ReleaseTemporary(bufferA);
bufferA = bufferB;
bufferB = RenderTexture.GetTemporary(rtW, rtH, 0);
Graphics.Blit(bufferA, bufferB, material, 1); // 渲染横向
RenderTexture.ReleaseTemporary(bufferA);
bufferA = bufferB;
}
Graphics.Blit(bufferA, dest);
RenderTexture.ReleaseTemporary(bufferA);
}
else
{
Graphics.Blit(src, dest);
}
}
}
重点:
- 建立rt缓存器(RenderTextureBuffer)操作:主要用于在一个OnRenderImage函数内对帧进行暂存,以便进行接下来的操作。新建缓存器的字段为:RenderTexture.GetTemporary;释放缓存器的操作为:RenderTexture.ReleaseTemporary
- filterMode操作:用来调整帧图像的过滤器模式,对缩放后的帧像素进行填充。point:点采样模式,寻找最近的像素进行填充,性能好,不抗锯齿;Bilinear:双线性模式,采用最近的四个像素做线性插值,过度平滑,但依然有锯齿。Trilinear:三线性采样模式,较好解决抗锯齿,但处理速度满。
- 迭代操作:使用循环语句对帧图像进行多次同样的操作,以达到目标效果,开销会随迭代次数上升。
高斯模糊Shader:
Shader "PostEffect/GaussianBlur"
{
Properties
{
_MainTex ("基底色", 2D) = "white" {}
_BlurSize("模糊像素尺寸", Float) = 1.0
}
SubShader
{
ZTest Always
Cull Off
ZWrite Off
CGINCLUDE // 代码块(头文件)
sampler2D _MainTex;
float4 _MainTex_TexelSize;
float _BlurSize;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv[5] : TEXCOORD0;
};
v2f vertGaussianBlurVertical(appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
float2 uv = v.uv;
// 纵向采样距离:[4][2][0][1][3]
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 vertGaussianBlurHorizontal(appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
float2 uv = v.uv;
// 横向采样距离:[4][2][0][1][3]
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;
}
float4 fragGaussianBlur(v2f i) : SV_Target
{
// [0.0545] [0.2442] [0.4026] [0.2442] [0.0545]
float weight[3] = {0.4026, 0.2442, 0.0545};
// 按权重计算像素颜色
float3 sum = tex2D(_MainTex, i.uv[0]).rgb * weight[0]
+ tex2D(_MainTex, i.uv[1]).rgb * weight[1]
+ tex2D(_MainTex, i.uv[2]).rgb * weight[1]
+ tex2D(_MainTex, i.uv[3]).rgb * weight[2]
+ tex2D(_MainTex, i.uv[4]).rgb * weight[2];
return float4(sum, 1.0);
}
ENDCG
Pass
{
// 纵向模糊
NAME "GAUSSIAN_BLUR_VERTICAL"
CGPROGRAM
// 调用代码块内的函数
#pragma vertex vertGaussianBlurVertical
#pragma fragment fragGaussianBlur
ENDCG
}
Pass
{
// 横向模糊
NAME "GAUSSIAN_BLUR_HORIZONTAL"
CGPROGRAM
// 调用代码块内的函数
#pragma vertex vertGaussianBlurHorizontal
#pragma fragment fragGaussianBlur
ENDCG
}
}
}
重点:
- CGINCLUDE代表预先定义一部分Cg代码块。这里的代码块类似于头文件,作用到所有Pass中,相当于将这些代码复制到每个Pass内,Pass内只需要调用需要的函数即可。CGINCLUDE解决了代码重复问题。
- 可以看到CGINCLUDE中一共定义了两个顶点着色器和一个片元着色器,横向高斯模糊与纵向高斯模糊共用一个片元着色器。顶点着色器中输出的uv信息有5个值,代表像素左右或上下一条直线上的5个像素,使用这5个像素进行卷积,对于屏幕外的像素则使用最近像素填充,凑齐5像素。
- 片元着色器中的sum用来统一收集横/纵5个像素的颜色;将其与权重相乘以免大于原颜色。
最终结果(原图 迭代增加 模糊范围增加 缩放比例增加)
Bloom效果后处理
Bloom效果的原理:按照给定阈值提取渲染帧中的较亮部分,单独对这些较亮部分进行高斯模糊,然后将处理后的图像与原图像进行混合。
Bloom脚本:(修改自高斯模糊)
public class Bloom : PostEffectBase
{
public Shader BloomShader;
private Material BloomrMat;
public Material material
{
get
{
BloomrMat = CheckShaderCreateMat(BloomShader, BloomrMat);
return BloomrMat;
}
}
int blurSizeID = Shader.PropertyToID("_BlurSize"); // 模糊范围
int lumThresholdID = Shader.PropertyToID("_LumTreshold"); // 明度阈值
int BloomTexID = Shader.PropertyToID("_BloomTex"); // Bloom结果贴图
[Range(0, 4)]
public int iteration = 3; // 模糊迭代次数
[Range(0.2f, 5.0f)]
public float spread = 0.5f; // 模糊范围
[Range(1,8)]
public int sample = 2; // 缩放系数(采样大小)
[Range(0, 4)]
public float lumThreshold; // Bloom明度阈值
//考虑迭代的模糊
void OnRenderImage(RenderTexture src, RenderTexture dest)
{
if(material != null)
{
material.SetFloat(lumThresholdID, lumThreshold);
int rtW = src.width / sample;
int rtH = src.height / sample;
// 建立渲染缓存器
RenderTexture bufferA = RenderTexture.GetTemporary(rtW, rtH, 0);
bufferA.filterMode = FilterMode.Bilinear; // 双线性
Graphics.Blit(src, bufferA, material, 0); // 获取高明度区域
for(int i=0; i<iteration; i++) // 叠加模糊次数 bufferA用来存储;bufferB用来模糊
{
material.SetFloat(blurSizeID, 1.0f + i * spread);
RenderTexture bufferB = RenderTexture.GetTemporary(rtW, rtH, 0);
Graphics.Blit(bufferA, bufferB, material, 1); // 渲染纵向
RenderTexture.ReleaseTemporary(bufferA);
bufferA = bufferB;
bufferB = RenderTexture.GetTemporary(rtW, rtH, 0);
Graphics.Blit(bufferA, bufferB, material, 2); // 渲染横向
RenderTexture.ReleaseTemporary(bufferA);
bufferA = bufferB;
}
material.SetTexture(BloomTexID, bufferA); // 给定bloom结果
Graphics.Blit(src, dest, material, 3); // 进行最后处理
RenderTexture.ReleaseTemporary(bufferA);
}
else
{
Graphics.Blit(src, dest);
}
}
}
重点:
- 可以看到在OnRenderImage中进行了四个pass的处理,其中第二和第三个pass均为高斯模糊。第一个pass目的是使用shader截取明度较高的区域,方便进行高斯模糊处理;第四个pass的目的是使用shader将bloom结果与原图像进行混合,实现bloom效果。
- 要注意bloom结果与渲染结果是不同的,bloom结果仅代表将明度图进行高斯模糊后的结果,渲染结果才是最终输出图像。
- 进行最后混合时,是将src图像传入material的第四个pass进行混合(此时shader已拥有bloom结果图像)。
Bloom效果着色器:
Shader "PostEffect/Bloom"
{
Properties
{
_MainTex ("基底色", 2D) = "white" {}
_BloomTex("Bloom结果", 2D) = "black" {}
_BlurSize("模糊尺寸", float) = 1.0
_LumTreshold("高明度范围", float) = 3
}
SubShader
{
CGINCLUDE // 代码块(头文件)
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _MainTex_TexelSize;
sampler2D _BloomTex;
float _BlurSize;
float _LumTreshold;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
};
v2f vertExtractBright(appdata v) // 顶点着色 提取明亮区域
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
float4 fragExtractBright(v2f i) : SV_Target // 片元着色 提取明亮区域
{
float4 var_MainTex = tex2D(_MainTex, i.uv);
float lum = Luminance(var_MainTex) - _LumTreshold;
return (var_MainTex * lum);
}
float4 fragBloom(v2f i) : SV_Target // 片元着色 Bloom混合
{
return tex2D(_MainTex, i.uv) + tex2D(_BloomTex, i.uv);
}
ENDCG
ZTest Always
Cull Off
ZWrite Off
Pass
{
// 提取明度
Name "BLOOM_EXTRACT_BRIGHT"
CGPROGRAM
#pragma vertex vertExtractBright
#pragma fragment fragExtractBright
ENDCG
}
UsePass "PostEffect/GaussianBlur/GAUSSIAN_BLUR_VERTICAL"
UsePass "PostEffect/GaussianBlur/GAUSSIAN_BLUR_HORIZONTAL"
Pass
{
// Bloom混合
Name "BLOOM_BLEND"
CGPROGRAM
#pragma vertex vertBloom
#pragma fragment fragBloom
ENDCG
}
}
}
重点:
- 共有四个pass,第二和第三个pass直接引用高斯模糊中使用过的pass,可以节省代码体积。
- 提取明亮区域的算法:使用Luminance函数提取图像的明度(HDR可大于1),然后减去阈值,得到bloom中发亮部位。
- 使用UsePass指令可以快速获取其他Shader中的Pass,其依赖也会一并调用。
最终效果:(原图 Bloom开启)
运动模糊后处理
实际生活中的运动模糊来源于摄像机的一种光现象,当摄像机镜头或镜头内的事物移动时进行拍摄,会在镜头上留下曝光的残影。游戏中模拟这个现象的方法:将这一帧的图像与前一帧的图像进行混合。
运动模糊脚本:
using UnityEngine;
public class MotionBlur : PostEffectBase
{
public Shader MotionBlurShader;
private Material MotionBlurMat;
public Material material
{
get
{
MotionBlurMat = CheckShaderCreateMat(MotionBlurShader, MotionBlurMat);
return MotionBlurMat;
}
}
[Range(0.0f, 1.0f)]
public float blurAmount = 0.5f; // 拖尾强度
private RenderTexture accumulationTex; // 上一帧图像
int blurAmountID = Shader.PropertyToID("_BlurAmount");
void OnDisable() // 性能考虑
{
DestroyImmediate(accumulationTex);
}
void OnRenderImage(RenderTexture src, RenderTexture dest)
{
if(material != null)
{
// 创建运动模糊图像
if(accumulationTex == null || accumulationTex.width != src.width || accumulationTex.height != src.height)
{
DestroyImmediate(accumulationTex);
accumulationTex = new RenderTexture(src.width, src.height, 0);
accumulationTex.hideFlags = HideFlags.HideAndDontSave; // 不显示且不保存
Graphics.Blit(src, accumulationTex); // 赋值
}
// 代表预期将进行RenderTexture的恢复操作 代价高昂
accumulationTex.MarkRestoreExpected();
material.SetFloat(blurAmountID, 1.0f - blurAmount);
Graphics.Blit(src, accumulationTex, material);
Graphics.Blit(accumulationTex, dest);
}
else
{
Graphics.Blit(src, dest);
}
}
}
重点:
- 1.0f - blurAmount代表混合系数,作为新旧帧的混合比例(A通道值)进入shader。
- RenderTexture.MarkRestoreExpected()方法意为告诉编辑器我希望RenderTexture被恢复。当上一帧OnRenderImage结束时,会自动释放全部使用过的内存,而运动模糊的进行则需要上一帧的RenderTexture,即在下一帧调用已经被删除的来自上一帧的RenderTexture时编辑器会自动恢复上一帧RenderTexture并报错,这个方法告诉编辑器不要报错。
- 恢复上一帧RenderTexture变量操作的开销较大。
- material的作用是将下一帧的图像按照混合系数混合到上一帧的图像上。
运动模糊shader:
Shader "PostEffect/MotionBlur"
{
Properties
{
_MainTex ("基底色", 2D) = "white" {}
_BlurAmount ("混合系数", Float) = 0.5
}
SubShader
{
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _MainTex_ST;
float _BlurAmount;
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 fragRGB(v2f i) : SV_Target
{
// 混合系数为新帧混合到旧帧上的A通道值
return float4(tex2D(_MainTex, i.uv).rgb, _BlurAmount);
}
float4 fragA(v2f i) : SV_Target
{
return tex2D(_MainTex, i.uv);
}
ENDCG
ZTest Always
Cull Off
ZWrite Off
Pass
{
// 对RGB值进行混合
Blend SrcAlpha OneMinusSrcAlpha
ColorMask RGB
CGPROGRAM
#pragma vertex vert
#pragma fragment fragRGB
ENDCG
}
Pass
{
// 对A值进行混合
Blend One Zero
ColorMask A
CGPROGRAM
#pragma vertex vert
#pragma fragment fragA
ENDCG
}
}
}
重点:
- 有两个Pass,分别用来对RGB通道和A通道进行混合,我们希望运动模糊只对RGB值进行模糊,而A值则使用新帧的A值。
- 对RGB值的模糊需要使用到BlurAmount作为A值,A值越高,模糊越弱。
深度纹理
深度纹理(DepthTexture)描述了屏幕上的像素当前绘制的图像距离摄像机的距离信息。现实中有很多需要用深度去理解的现象,比如一些半透明物体越厚,其颜色越深;水越深越看不清水底的东西等。在图形学中,使用深度纹理来对这些现象进行还原。
我们知道在进行渲染时,图元会经过观察变换从观察空间变换到裁剪空间、经过投影变换从裁剪空间变换到屏幕空间,最后经过齐次除法/透视除法获得图元的归一化设备坐标NDC。这个坐标的Z分量便是深度,OpenGL中范围是[-1,1]即Zndc为-1的像素的深度与近裁剪平面的深度相同,+1则为远裁剪平面。DX中范围是[0,1]。
得到NDC的z分量后,因为它是被存储到纹理中的,所以是非线性的值,但我们一般使用的深度值为线性的,下一步操作是将其转换为线性深度值,即观察空间下的深度值Zview。Unity将这个过程封装为LinerarEyeDepth和Linear01Depth,且不用区分OpenGL和DirectX。
深度纹理使用Zndc而不使用Zview的原因:方便硬件进行三角形遍历时进行透视校正插值。三角形遍历完全是由硬件来实现的,这无疑会增加硬件实现的复杂度。所以深度纹理保存Zndc会好一点,虽然在Shader中使用时需要将其转换为Zview。
生成深度纹理
在Unity前向渲染中,引擎额外使用了一个Pass获取,叫做ShadowCaster,这个Pass同样用作渲染Shadow阴影
延迟渲染的深度纹理只需要从G-Buffer中拿就行了,不需要像前向渲染那样再额外渲染一遍场景,不会增加任何DrawCall。因为无论我们是否需要深度纹理,延迟渲染都会生成一张深度纹理到G-Buffer中。
需要注意的是,不论是前向渲染还是延迟渲染,Unity只会将渲染队列为2500以下(即Background、Geometry、AlphaTest)的物体渲染到深度纹理中。
G-Buffer的内容:
- RT0,格式为RGBA32,RGB存储漫反射颜色,A通道不使用
- RT1,格式为RGBA32,RGB存储高光反射颜色,A通道存储高光反射的指数
- RT2,格式为RGBA2101010,RGB存储法线,A通道不使用
- RT3,格式为RGB32或RGBAHalf,存储自发光、lightmap、反射探针
- Depth,深度缓冲、模板缓冲
在代码中实现深度纹理的获取:
脚本:
using UnityEngine;
public class GetDepthTexture : PostEffectBase
{
public Shader DepthShader;
private Material DepthMat;
public Material material
{
get
{
DepthMat = CheckShaderCreateMat(DepthShader, DepthMat);
return DepthMat;
}
}
private void Awake()
{
Camera camera = GetComponent<Camera>();
// 要求摄像机输出深度纹理给shader
camera.depthTextureMode = DepthTextureMode.Depth;
}
private void OnRenderImage(RenderTexture src, RenderTexture dest)
{
if(DepthMat != null)
{
Graphics.Blit(src, dest, DepthMat);
}
else Graphics.Blit(src, dest);
}
}
Shader:
Shader "PostEffect/Depth"
{
Properties {}
SubShader
{
Tags {"RenderType" = "Opaque"}
Pass
{
ZTest Always
Cull Off
ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
// 获取深度纹理
sampler2D _CameraDepthTexture;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 pos : SV_POSITION;
};
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
// 深度纹理屏幕采样方法
float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
// 映射到01范围内(OpenGL与DirectX收束)
d = Linear01Depth(d);
return float4(d, d, d, 1.0);
}
ENDCG
}
}
Fallback "Diffuse"
}
深度纹理:近裁剪平面的深度为0,远裁剪平面的深度为1。
使用深度纹理制作扫描效果:
采样深度纹理
有时我们希望深度纹理不仅被用于屏幕后处理,还可以运用到物体渲染中,如水体渲染就需要通过获取视口空间下水底到水面的距离,从而得到水深信息,然后用这个信息lerp水的颜色和透明度。
首先,我们需要将水面的RenderType改为Transparent,毕竟水是半透明物,而且深度纹理不会记录渲染队列在Transparent之后的图元的深度,保证我们能够得到水面的深度。然后需要从摄像机生成深度纹理,上面已经提及,但如果生成的深度纹理只需要用于采样,不需要参与后处理时,可以简化为:
using UnityEngine;
[ExecuteInEditMode]
[RequireComponent(typeof(Camera))]
public class DepthTex : MonoBehaviour
{
private void Awake()
{
Camera cam = GetComponent<Camera>();
cam.depthTextureMode = DepthTextureMode.Depth;
}
}
然后在shader中获取深度图并采样:
sampler2D _CameraDepthTexture; // 相机深度图
v2f vert (appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.proj = ComputeScreenPos(o.pos); // 计算用于执行屏幕空间贴图纹理采样的纹理坐标
COMPUTE_EYEDEPTH(o.proj.z); // 顶点的屏幕空间深度
o.uv = v.uv;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
// 贴图采样
half4 projUV = UNITY_PROJ_COORD(i.proj); // 此宏返回一个适合投影纹理读取的纹理坐标
half depth = saturate((LinearEyeDepth(tex2Dproj(_CameraDepthTexture, projUV)) - i.proj.z) / _DepthRange);
fixed4 WaterCol = lerp(_SurfaceCol, _DeepCol, depth);
return WaterCol;
}
projUV为屏幕空间坐标UV,减去projUV.z的目的是让深度不受到摄像机距离的影响,只由_DepthRange把控。LinearEyeDepth函数负责将深度纹理采样结果转换到视口空间下。
|