高级篇涵盖了一些Shader的高级用法,例如,如何实现屏幕特效、利用法线和深度缓冲,以及非真实渲染等,同时,我们介绍一些针对移动平台的优化技巧。
第十二章 屏幕后处理效果
这一章将介绍如何在Unity中实现一个基本的屏幕后处理脚本系统,并给出一些基本的屏幕特效的实现团里,如高斯模糊、边缘检测等。
第十三章 使用深度和法线纹理
获取特殊的纹理来实现屏幕特效
第十四章 非真实感渲染
将会给出常见的非真实感渲染的算法,如卡通渲染、素描风格的熏染大概。
第十五章 使用噪音
很多时候噪音是我们实现特效的“救星”。
第十六张 Unity中的渲染优化技术
优化往往是游戏渲染中的重点。
屏幕后处理效果(screen post-processing effects)是游戏中实现屏幕特效常见方法。在本中,我们将学习如何在 Unity中利用渲染纹理来实现各种常见的屏幕后处理效果。在12.1节中,我们首先会解释在Unity中实现屏幕后处理效果的原理,并建立一个基本的屏幕后处理脚本系统。随后在12.2节中,我们会使用这个系统实现一个简单的调整画面亮度、饱和度和对比度的屏幕特效。在12.3节中,我们会接触到图像滤波的概念,并利用Sobel算子在屏幕空间中对图像进行边缘检测,实现描边效果。在此基础上,12.4节将会介绍如何实现一个高斯模糊的屏幕特效。在12.5和12.6节中,我们会分别介绍如何实现Bloom和运动模糊效果。
12.1 建立一个基本的屏幕后处理脚本系统
屏幕后处理,顾名思义,通常指的是在渲染完整个场景得到屏幕图像后,再对这个图像进行一系列操作,实现各种屏幕特效。使用这种技术,可以为游戏画面添加更多的艺术效果,例如景深(Depth of Field)、运动模糊(Motion Blur)等。
因此,想要实现屏幕后处理的基础在于得到渲染后的屏幕图像,即抓取屏幕,而Unity为我们提供了这样一个方便的接口——OnRenderImage函数。它的函数声明如下:MonoBehavior.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是我们使用的材质,这个材质使用的Unity Shader将会进行各种屏幕后处理操作,而src纹理将会被传递给Shader中名为_MainTex的纹理属性。参数pass的默认值为-1,表示将会依次调用Shader内的所有Pass。否则,只会调用给定索引的Pass。
在默认情况下,OnRenderImage函数会在所有的不透明和透明的Pass执行完毕后被调用,以便对场景中所有游戏对象都产生影响。但有时,我们希望在不透明的Pass(即渲染队列小于等于2500的Pass,内置的Background、Geometry和AlphaTest渲染队列均在此范围内)执行完毕后立即调用OnRenderImage函数,从而不对透明物体产生任何影响。此时,我们可以在OnRenderImage函数前添加ImageEffectOpaque属性来实现这样的目的。13.4节展示了这样一个例子,在13.4节中,我们会利用深度和法线纹理进行边缘检测从而实现描边的效果,但我们不希望透明物体也被描边。
因此,要在Unity中实现屏幕后处理效果,过程通常如下:我们首先需要在摄像机中添加一个用于屏幕后处理的脚本。这个脚本中,我们会实现OnRenderImage函数来获取当前屏幕的渲染纹理。然后,在调用Graphics.Blit函数使用特殊的Unity Shadr来对当前屏幕的渲染纹理。然后,再调用Graphics.Bit函数使用特定的Unity Shader来对当前图像进行处理,再把返回的渲染纹理显示到屏幕上。对于一些复杂的屏幕特效,我们可能需要多次调用Graphics.Cilt函数来对上一步的输出结果进行下一步处理。
但是,在进行屏幕后处理之前,我们需要检查一系列条件是否满足,例如当前平台是否支持渲染纹理和屏幕特效是时,是否支持当前使用的Unity Shaader等。为此,我们创建了一个用于屏幕后处理效果的基类,在实现各种屏幕特效时,我们只需要继承该自该基类,再实现派生类中不同的操纵即可。
using UnityEngine;
using System.Collections;
[ExecuteInEditMode]
[RequireComponent (typeof(Camera))]
//首先,所有屏幕后处理效果都需要绑定在摸个摄像机上,并且我们希望在编辑状态下,
//也可以执行该脚本
public class PostEffectsBase : MonoBehaviour {
// 开始时调用
protected void CheckResources() {
bool isSupported = CheckSupport();
if (isSupported == false) {
NotSupported();
}
}
//为了提前检查各种资源和条件是否满足,我们在Start函数中调用CheckResources函数
// 调用CheckResources检查这个平台上的支持
protected bool CheckSupport() {
if (SystemInfo.supportsImageEffects == false || SystemInfo.supportsRenderTextures == false) {
Debug.LogWarning("This platform does not support image effects or render textures.");
return false;
}
return true;
}
// Called when the platform doesn't support this effect
protected void NotSupported() {
enabled = false;
}
protected void Start() {
CheckResources();
}
//一些屏幕特效可以能需要更多的设置,例如设置一些默认值等,可以重载Start、CheckResources或CheckSupprt函数。
// 当需要创造这种效果所使用的材料时
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;
}
//由于每个屏幕后处理效果通常都需要指定一个Shader来创建一个用于处理渲染纹理的材料,
//因此基类中也提供了这样的方法。
//CheckShaderAndCreateMaterial函数接受两个参数,第一个参数指定了该特效需要使用Shader,
//第二个参数则是用于后期处理的材质。该函数首先检查Shader的可用性,检查通过后就返回一个
//使用了该Shader的材质,否则返回null。
}
}
12.2 调整屏幕的亮度、饱和度和对比度
(1)新建场景(Scene_12_2)。
(2)把资源Assets/Textures/Chapter12/Sakura0.jpg拖拽到场景中,将纹理类型设置为Sprite。
(3)新建一个脚本(BrightnessSaturationAndContrast.cs),拖拽到摄像机上。
(4)新建一个Unity Shader。
using UnityEngine;
using System.Collections;
//首先,继承12.1节中创建的类。
public class BrightnessSaturationAndContrast : PostEffectsBase
{
//声明该效果需要的Shader,并根据创建相应的材质。
public Shader briSatConShader;
private Material briSatConMaterial;
public Material material
{
get
{
briSatConMaterial = CheckShaderAndCreateMaterial(briSatConShader, briSatConMaterial);
return briSatConMaterial;
}
}
//上述代码中,briSatConShader是我们指定的Shader,对应了后面实现的CHapter12-BrightnessSaturationAndContrast/
//briSatConShader是创建材质,我们提供了名为mater的材质来访问它,material的get函数调用了基类的
//CheckShaderAndCreateMaterial函数来得到对应的材质。
[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;
//我们还在脚本中提供了调整亮度、饱和度和对比度的参数:
//我们利用Unity 提供的Range属性为每个参数提供适当的变化区间。
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);
}
}
//最后,我们定义OnRenderImage函数来进行真正的特殊处理:
//每当OnRenderImage函数被调通,他会检查材质是否可用。如果可用,就把参数传递函数给材质,
//再调用Graphics.Blit进行处理;否则,直接把原图显示到屏幕上,不做任何处理。
}
Shader "Unity Shaders Book/Chapter 12/Brightness Saturation And Contrast" {
Properties {
//纹理属性
_MainTex ("Base (RGB)", 2D) = "white" {}
_Brightness ("Brightness", Float) = 1
_Saturation("Saturation", Float) = 1
_Contrast("Contrast", Float) = 1
//在12.1中,提到用Graphics.Blit(src,dest,material)将把第一个参数传递给Shader中名为
//_MainTex的属性。因此,我们必须声明一个名为_MainTex的纹理属性。除此之外,我们还声明了用于
//调整亮度、饱和度和对比度的属性。这些值将会由脚本传递而得。事实上,我们可以省略Properties
//中的属性声明,Properties中声明的属性仅仅是为了显示在材质面板上,但对于屏幕特效来说,
//他们使用的材质都是临时创建的,我们也不需要在材质面板上调整参数,而是直接从脚本传递给Unity Shader.
}
//定义用于屏幕后处理的Pass
SubShader {
Pass {
ZTest Always Cull Off ZWrite Off
//屏幕后处理实际上是在场景中绘制了一个与屏幕同宽同高的四边形面片,为了防止它对其他物体
//产生影响,我们需要设置相关的渲染状态。在这里我们关闭了深度写入,是为了防止它“挡住”
//在其后面被渲染的物体。例如,如果当前的OnRenderImage函数在所有不透明的Pass执行完毕后
//立即被调用,不关闭深度写入就会影响后面的透明的Pass,这些状态设置可以认为是用于屏幕后处理的Shadr的“标配”
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;
};
v2f vert(appdata_img v) {
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.uv = v.texcoord;
return o;
}
//在上面的顶点着色器中,我们使用了Unity内置的appdata_img结构体作为顶点着色器的输入,
//在UnityCG.cginc中可以找到该结构体的声明,它只包含了图像处理时必需的顶点坐标额纹理坐标等变量。
//之后,我们实现了用于调整亮度、饱和度和对比度的片元着色器:
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);
}
//首先,我们得到对原屏幕图像(存储在_MainTex中)的采样结果renderTex。然后,利用
//_Brightness属性来调整亮度。亮度的调整非常简单,我们只需要把原颜色乘以亮度系数_Brightness
//即可。然后,我们计算该像素对应的亮度值(luminance),这是通过对每个颜色分量乘以一个特定的
//系数再相加得到的。我们使用该亮度值创建了一个饱和度为0的颜色值,并使用_Saturation属性
//在其和上一步得到的颜色之间进行插值,从而得到希望的饱和度颜色。对比度的处理类似,我们首先
//创建一个对比度为0的颜色值(各分量均为0.5),再使用_Contrast属性在其和上一步得到的
//颜色之间进行插值,从而得到最终的处理结果。
ENDCG
}
}
Fallback Off
}
把Chapter12-BrightnessSaturationAndContrast拖拽到摄像机的BrightnessSaturationAndContrast.cs脚本中的briSatConShader参数中。
12.3 边缘检测
边缘检测是是描边效果的一种实现方法。
原理:利用一些边缘检测算子对图像进行卷积(convolution)操作。
卷积是什么
在图像处理中,卷积操作指的就是使用一个卷积核(kernel)对一张图像中的每个像素进行一系列操作。卷积核通常是一个四方形网格结构(例如2×2、3×3的方形区域),该区域内每个方格都有一个权重值。当对图像中的某个像素进行卷积时,我们会把卷积核的中心放置于该像素上,如图12.4所示,翻转核之后再依次计算核中每个元素和其覆盖的图像像素值的乘积并求和,得到的结果就是该位置的新像素值。
这样的计算过程虽然简单,但可以实现很多常见的图像处理效果,例如图像模糊、边缘检测等。例如,如果我们想要对图像进行均值模糊,可以使用一个3×3的卷积核,核内每个元素的值均为1/9。
常见的边缘检测算子
梯度(gradient):用来表示相邻像素之间的差值。
三种常见的边缘检测算子如上图所示。它们都包含了两个方向的卷积核,分别用于检测水平方向和竖直方向上的边缘信息。在进行边缘检测时,我们需要对每个像素分别进行一次卷积计算,得到两个方向上的梯度值G(x)和G(y),而整体的梯度可按下面的公式计算而得:
G = √G(x)^2 +G(y)^2
由于上述计算包含了开根号操作,出于性能的考虑,我们有时会使用绝对值操作来代替开根号操作:
G =|G(x)|+|G(y)|
当得到梯度G后,我们就可以据此判断哪些像素对应了边缘(梯度值越大,越有可能是边缘点)
Sobel算子进行边缘检测,实现描边效果
(1)新建场景(Scene_12_3)。
(2)将Sakura0.jpg拖拽到场景中。纹理类型设为Sprite。
(3)新建一个脚本(EdgeDetection.cs)。并拖拽到摄像机上。
(4)新建一个Unity Shader(Chapter12-EdgeDetection)。
using UnityEngine;
using System.Collections;
public class EdgeDetection : PostEffectsBase {
public Shader edgeDetectShader;
private Material edgeDetectMaterial = null;
public Material material {
get {
edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
return edgeDetectMaterial;
}
}
//上述代码中,edgeDetectShader是我们指定的Shader,对应了后面将会实现的Chapter12-EdgeDetection。
//提供用于调整边缘强度、描边颜色以及背景颜色的参数:
[Range(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) {
Debug.Log(edgesOnly);
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);
}
}
//每当OnRenderImage函数被调用时,它会检查材质是否可用。如果可用,就把参数传递给材质,再调用
//Graphics.Blit进行处理;否则,直接把原图像显示到屏幕上,不做任何处理。
}
Shader "Unity Shaders Book/Chapter 12/Edge Detection" {
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
#include "UnityCG.cginc"
#pragma vertex vert
#pragma fragment fragSobel
sampler2D _MainTex;
uniform half4 _MainTex_TexelSize;
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;
//声明代码中,我们还声明了一个新的变量_MainTex_TexelSize。xxx_TexelSize是Unity为我们
//提供的访问xxx纹理对应的每个纹素的大小。例如,一张512×512大小的纹理,该值大约为0.001953(即1/512)。
//由于卷积需要对相邻区域内的纹理进行采样,因此我们需要利用_MainTex_TexelSize来计算各个相邻
//区域的纹理坐标。
//在顶点着色器的代码中,我们计算了边缘检测是需要的纹理坐标:
struct v2f {
float4 pos : SV_POSITION;
half2 uv[9] : TEXCOORD0;
};
v2f vert(appdata_img v) {
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, 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;
}
//我们在v2f结构体中定义了一个维数为9的纹理数组,对应了使用Sobel算子采样时需要的9个
//领域纹理坐标。通过把计算采样纹理坐标的代码从片元着色器中转移到顶点着色器中国,可以减少运算。
//提高性能。由于从顶点着色器到片元着色器的插值是线性的,因此这样的转移并不会影响纹理坐标的计算结果。
fixed luminance(fixed4 color) {
return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
}
//我们首先调用Sobel函数计算当前像素的梯度值edge,并利用该值分别计算了背景为原图和纯色下的
//颜色值,然后利用_EdgeOnly在两者之间插值得到最终的像素值。Sobel函数将利用Sobel算子
//对原图进行边缘检测,它的定义如下:
half Sobel(v2f i) {
const half Gx[9] = {-1, 0, 1,
-2, 0, 2,
-1, 0, 1};
const half Gy[9] = {-1, -2, -1,
0, 0, 0,
1, 2, 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;
}
//我们首先定义了水平方向个竖直方向使用的卷积核G(x)和G(y)。接着,我们依次对9个像素进行采样,
//计算他们的亮度值,再与卷积核G(x)和G(y)中对应的权重相乘后,叠加到各自的梯度值上。
//最后,我们从1中减去水平方向和竖直方面的梯度值好绝对值,得到edge。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
}
}
FallBack Off
}