中山大学 数据科学与计算机学院 软件工程数字媒体 洪鹏圳
前言
在大三上学期,我曾经上过一门课叫做数字图像处理,万万没想到在研究NPR的时候他居然能够派上用场,看来DIP和CV还是有所交集的。众所周知,图像处理是对一张图像进行像素级的处理,那么我们如何在游戏开发中应用图像处理的方法呢?那就要引入屏幕后处理技术了。
屏幕后处理
屏幕后处理简介
屏幕后处理效果(screen post-processing effects),顾名思义,通常指的是在渲染完整个场景得到屏幕图像后,再对这个图像进行一系列操作,实现各种屏幕特效。使用这种技术,可以为游戏画面添加更多的艺术效果,例如景深(Depth of Field)、运动模糊(Motion Blur)等。
在Unity中使用屏幕后处理技术
因此,想要实现屏幕后处理的基础在于得到渲染后的屏幕图像,即抓取屏幕,而Unity为我们提供了这样一个方便的接口——OnRenderImage函数。他的函数声明如下:
MonoBehaviour.OnRenderImage (RenderTexture src, RenderTexture dest)
当我们在脚本中声明此函数后,Unity会把当前渲染得到的图像存储在第一个参数对应的源渲染纹理中,通过函数中的一系列操作后,再把目标渲染纹理,即第二个参数对应的渲染纹理显示到屏幕上。在OnRenderImage函数中,我们通常是利用Graphics.Blit函数来完成对渲染纹理的处理。它有3种函数声明:
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的纹理属性(所以在Shader中主纹理必须声明为_MainTex)。参数pass的默认值为-1,表示将会一次调用Shader内的所有Pass。否则,只会调用给定索引的Pass。
在默认情况下,OnRenderImage函数会在所有的不透明和透明的Pass执行完毕后被调用,以便对场景中所有游戏对象都产生影响。但有时,我们希望在不透明的Pass(即渲染队列小于等于2500的Pass,内置的Background、Geometry和AlphaTest渲染队列均在此范围内)执行完毕后立即调用OnRenderImage函数,从而不对透明物体产生任何影响。此时,我们可以在OnRenderImage函数前添加ImageEffectOpaque属性来实现这样的目的。
因此,要在Unity中实现屏幕后处理效果,过程通常如下:我们首先需要在摄像中添加一个用于屏幕后处理的脚本。在这个脚本中,我们会实现OnRenderImage函数来获取当前屏幕的渲染纹理。然后,再调用Graphics.Blit函数使用特定的Unity Shader来对当前图像进行处理,再把返回的渲染纹理显示到屏幕上。对于一些复杂的屏幕特效,我们可能需要多次调用Graphics.Blit函数来对上一步的输出结果进行下一步处理。
但是,在进行屏幕后处理之前,我们需要检查一系列条件是否满足,例如当前平台是否支持渲染纹理和屏幕特效,是否支持当前使用的Unity Shader等。为此,我们创建了一个用于屏幕后处理效果的基类,在实现各种屏幕特效时,我们只需要继承该基类,再实现派生类中不同的操作即可。我们将该基类声明为PostEffectBase.cs,其主要代码如下:
(1)首先,所有屏幕后处理效果都需要绑定在某个摄像机上,并且我们希望在编辑器状态下也可以执行该脚本来查看效果:
[ExecuteInEditMode]
[RequireComponent (typeof(Camera))]
public class PostEffectsBase : MonoBehaviour {
(2)为了提前检查各种资源和条件是否满足,我们在Start函数中调用CheckResources函数:
//检查各种资源和条件是否满足
protected void CheckResource()
{
bool isSupported = CheckSupport();
if(isSupported == false)
{
NotSupported();
}
}
protected bool CheckSupport()
{
if(SystemInfo.supportsImageEffects == false || SystemInfo.supportsRenderTextures == false)
{
Debug.LogWarning("This platform does not support image effect or render textures!");
return false;
}
return true;
}
protected void NotSupported()
{
this.enabled = false;//令当前脚本失效
}
protected void start()
{
CheckResource();
}
一些屏幕特效可能需要更多的设置,例如设置一些默认值等,可以重载Start、CheckResources或CheckSupport函数。
(3)对于每个屏幕后处理效果通常都需要指定一个Shader来创建一个处理渲染纹理的Material,因此基类中也提供了这样的方法:
//创建用于处理渲染纹理的材质
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;
}
}
}
CheckShaderAndCreateMaterial函数接受两个参数,第一个参数指定了该特效需要使用的Shader,第二个参数则是用于后期处理的材质。该函数首先检查Shader的可用性,检查通过后就返回第一个使用了该Shader的材质,否则返回null。
基于屏幕颜色的边缘检测
边缘检测的原理
边缘检测的原理是利用一些边缘检测算子对图像进行卷积(convolution)操作。那么什么是卷积呢?
在图像处理中,卷积操作指的就是使用一个卷积核(kernel)对一张图像中的每个像素进行一系列操作。卷积核通常是一个四方形网格结构(例如2 * 2、3 * 3的方形区域),该区域内每个方格都有一个权重值。当对图像中的某个像素进行卷积时,我们会把卷积核的中心放置于该像素上,如图12.4所示,翻转核之后再依次计算核中每个元素和其覆盖的图像像素值的乘积并求和,得到的而结果就是该位置的新像素值。
这样的计算过程虽然简单,但可以实现很多常见的图像处理效果,例如图像模糊、边缘检测等。比方说我们现在想要对一副图像进行均值模糊,那我们可以使用一个3*3的卷积核,核内每个元素的均值均为1/9。
常见的边缘检测算子
卷积操作的神奇之处在于选择的卷积核。那么,用于边缘检测的卷积核(也被称为边缘检测算子)应该长什么样呢?在回答这个问题前,我们可以首先回想一下边到底是如何形成的。如果相邻像素之间存在差别明显的颜色、亮度、纹理等属性,我们就会认为它们之间应该有一条边界。这种相邻像素之间的差值可以用梯度(gradient)来表示,可以想象得到,边缘处的梯度绝对值会比较大。基于这样的理解,有几种不同的边缘检测算子被先后提出来。
3种常见的边缘检测算子如图12.5所示,它们都包含了两个方向的卷积核,分别用于检测水平方向和竖直方向上的边缘信息。在进行边缘检测时,我们需要对每个像素分别进行一次卷积计算,得到两个方向上的梯度值Gx和Gy,而整体的梯度可按下面的公式计算而得:
由于上述计算包含了开根号操作,出于性能的考虑,我们有时会使用绝对值操作来代替开根号操作:
当得到梯度G后,我们就可以据此来判断哪些像素对应了边缘(梯度值越大,越有可能是边缘点)。
实现
首先我们实现挂载在摄像机上的屏幕后处理脚本:
(1)我们先声明一个EdgeDetection的脚本继承之前提到的PostEffectsBase基类:
public class EdgeDetection : PostEffectsBase {
(2)接着声明该效果需要的Shader,并据此创建相应的材质:
public Shader edgeDetectShader;
private Material edgeDetectMaterial = null;
public Material material
{
get
{
edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
return edgeDetectMaterial;
}
}
(3)在脚本中提供用于调整边缘线强度、描边颜色以及背景颜色的参数:
[Range(0.0f, 1.0f)]
public float edgesOnly = 0.0f;//边缘线强度,当值为0边缘将会叠加在原渲染图像上;当值为1时,则会只显示边缘,不显示原渲染图像。
public Color edgeColor = Color.black;//描边颜色
public Color backgroundColor = Color.white;//背景颜色
当edgesOnly值为0时,边缘将会叠加在原渲染图像上;当edgeOnly值为1时,则只会显示边缘,不显示渲染图像,此时的背景颜色由backgroundColor指定,边缘颜色由edgeColor指定。
(4)最后,我们定义OnRenderImage函数来进行真正的特效处理:
private void OnRenderImage(RenderTexture source, RenderTexture destination)
{
if (material != null)
{
material.SetFloat("_EdgeOnly", edgesOnly);
material.SetColor("_EdgeColor", edgeColor);
material.SetColor("_BackgroundColor", backgroundColor);
Graphics.Blit(source, destination, material);
}
else
{
Graphics.Blit(source, destination);
}
}
每当OnRenderImage函数被调用时,它会检查材质是否可用。如果可用,就把参数传递给材质,再调用Graphics.Blit进行处理;否则,直接把原图像显示到屏幕上,不做任何处理。
然后,我们来实现Shader部分:
(1)我们首先需要声明要使用的各个属性:
_MainTex ("Albedo (RGB)", 2D) = "white" {}
_EdgeOnly ("Edge Only", Float) = 1.0
_EdgeColor ("Edge Color", Color) = (0,0,0,1)
_BackgroundColor ("Backgroud Color", Color) = (1,1,1,1)
其中_MainTex对应了输入的渲染纹理,且名字必须为_MainTex
(2)定义用于屏幕后处理的Pass,设置相关的渲染状态:
SubShader {
Pass{
ZTest Always Cull Off ZWrite Off
因为屏幕后处理实际上是在场景中绘制了一个与屏幕同宽同高的四边形面片,为了防止它对其他物体产生影响,我们需要设置相关的渲染状态。在这里,我们关闭了深度写入,是为了防止它“挡住”在其后面被渲染的物体。例如,如果当前的OnRenderImage函数在所有不透明的Pass执行完毕后立即被调用,不关闭深度写入就会影响后面透明的Pass的渲染。这些状态设置可以认为是用于屏幕后处理的Shader的“标配”。
(3)为了在代码中访问各个属性,我们需要在CG代码块中声明对应的变量:
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来计算各个相邻区域的纹理坐标。
(4)在顶点着色器的代码中,我们计算了边缘检测时需要的纹理坐标:
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;
//计算周围9个像素点的纹理坐标
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个邻域纹理坐标。通过计算采样纹理坐标的代码从片元着色器转移到顶点着色器中,可以减少运算,提高性能。由于从顶点着色器到片元着色器的插值是线性的,因此这样的转移并不会影响纹理坐标的计算结果。
(5)因为边缘检测所检测的值是纹素的灰度值,因此我们需要声明一个将RGB转成灰度值的函数:
//计算灰度值
fixed luminance(fixed4 color) {
return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
}
接着我们声明边缘检测函数Sobel函数,其利用Sobel算子对原图进行边缘检测,它的定义如下:
//Sobel函数
half Sobel(v2f i) {
//Sobel算子
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;
}
我们首先定义了水平方向和竖直方向使用的卷积核Gx和Gy。接着,我们依次对9个像素进行采样,计算它们的亮(灰)度值,再与卷积核Gx和Gy中对应的权重相乘后,叠加到各自的梯度值上。最后,我们从1中减去水平方向和竖直方向的梯度值的绝对值,得到edge。edge值越小(即梯度值之和越大),表面该位置越可能是一个边缘点。
最后,我们定义片元着色器:
//片段着色器
fixed4 fragSobel(v2f i) : SV_Target{
half edge = Sobel(i);//计算梯度值,edge越小该片段越有可能是个边缘点
fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[4]), edge);//用edge进行原图颜色插值,edge为0时渲染边
fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);//用edge进行只显示边图颜色插值
return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);//由_EdgeOnly决定用带边原图还是只显示边图
}
我们首先调用Sobel函数计算当前像素的梯度值edge,并利用该值分别计算了背景为原图和纯色下的颜色值,然后利用_EdgeOnly在两者之间插值得到最终的像素值。
当然,我们还需要关闭该Shader的FallBack:
FallBack Off
运行效果
我们拿这张图片来看看边缘检测的效果,这是原图:
这是添加了边缘的原图:
这是只显示边缘的效果:
可以看到用Sobel进行边缘检测的效果还是不错的。
但是,我们上面所实现的边缘检测仅仅利用了屏幕颜色信息,而在实际应用中,物体的纹理、阴影等信息均会影响边缘检测的结果,使得结果包含许多非预期的描边。比方说下面是一个添加了光照和模型纹理的场景:
在使用了我们上面所实现的边缘检测后,其效果如下:
可以看到,这种直接利用颜色信息进行边缘检测的方法会产生很多我们不希望得到的边缘线(比如说图中物体的纹理、阴影等位置也被描上黑边)。
为了得到更加准确的边缘信息,我们往往会在屏幕的深度纹理和法线纹理上进行边缘检测,这些得到的结果将不受纹理和光照的影响,而仅仅保存了当前渲染物体的模型信息,通过这样的方式检测出来的边缘更加可靠。
基于深度纹理和法线纹理的边缘检测
在Unity中获取深度纹理和法线纹理
在Unity中,获取深度纹理是非常简单的,我们只需要告诉Unity:“嘿,把深度纹理给我!”,然后再在Shader中直接访问特定的纹理属性即可。这个与Unity沟通的过程是通过在脚本中设置摄像机的depthTextrueMode来完成的。例如我们可以通过下面的代码来获取深度纹理:
camera.depthTextureMode = DepthTextrueMode.Depth;
一旦设置好了上面的摄像机模式后,我们就可以在Shader中通过声明_CameraDepthTexture变量来访问它。这个过程非常简单,但我们需要知道这两行代码的背后,Unity为我们做了许多工作(从深度缓存或者一个单独的Pass中得到深度纹理)。
同理,如果想要获取深度+法线纹理,我们只需要在代码中这样设置:
camera.depthTextureMode = DepthTextrueMode.DepthNormals;
然后在Shader中通过声明_CameraDepthNormalsTexture变量来访问它。
我们还可以组合这些模式,让一个摄像机同时产生一张深度和深度+法线纹理:
camera.depthTextureMode |= DepthTextureMode.Depth;
camera.depthTextureMode |= DepthTextureMode.DepthNormals;
在Unity5以上的版本中,我们还可以在摄像机的Camera组件上看到当前摄像机是否需要渲染深度或深度+法线纹理。当在Shader中访问到深度纹理_CameraDepthTexture后,我们就可以使用当前像素的纹理坐标对它进行采样。绝大多数情况下,我们直接使用tex2D函数采样即可,但在某些平台(例如PS3和PSP2)上,我们需要一些特殊处理。Unity为我们提供了一个统一的宏SAMPLE_DEPTH_TEXTURE,用来处理这些由平台差异造成的问题。而我们只需要在Shader中使用SAMPLE_DEPTH_TEXTURE宏对深度纹理进行采样,例如:
float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
其中,i.uv是一个float2类型的变量,对应了当前像素的纹理坐标。类似的宏还有SAMPLE_DEPTH_TEXTURE_PROJ和SAMPLE_DEPTH_TEXTURE_LOD。SAMPLE_DEPTH_TEXTURE_PROJ宏同样接受两个参数——深度纹理和一个float3或float4的纹理坐标,它的内部使用了tex2Dproj这样的函数进行投影纹理采样,纹理坐标的前两个分量首先会除以最后一个分量,再进行纹理采样。如果提供了第四个分量,还会进行比较,通常用于阴影的实现中。SAMPLE_DEPTH_TEXTURE_PROJ的第二个参数通常是由顶点着色器输出插值而得到的屏幕坐标,例如:
float d = SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture,Unity_PROJ_COORD(i.scrPos));
其中,i.scrPos是在顶点着色器中通过调用ComputeScreenPos(o.pos)得到的屏幕坐标。上述这些宏的定义,读者可以在Unity内置的HLSLSupport.cginc文件中找到。
当通过纹理采样得到深度值后,这些深度值往往是非线性的,这种非线性来自于透视投影使用的裁剪矩阵(正交投影使用的裁剪矩阵计算得到的深度值是线性的)。然而,在我们的计算过程中通常是需要线性的深度值,也就是说,我们需要把投影后的深度值变换到线性空间下,例如视角空间下的深度值。那么,我们应该如何进行这个转换呢?实际上,我们只需要倒退顶点变换的过程即可。具体的计算过程大家可以参考《Unity Shader入门精要第13章》,这里我就不再详述。因为Unity提供了两个辅助函数来为我们进行上诉的计算过程:
- LinearEyeDepth:负责把深度纹理的采样结果转换到视角空间下的深度值,其范围为[Near,Far]
- Linear01Depth:返回一个范围在[0,1]的线性深度值,即把LinearEyeDepth返回的结果除以Far。
这两个函数内部使用了内置的_ZBufferParams变量来得到远近裁剪平面的距离(Far-Near)。
如果我们需要获取深度+法线纹理存储的值,可以直接使用tex2D函数对_CameraDepthNormalsTexture进行采样,得到里面存储的深度和法线信息,但是这些信息是深度值和法线方向所编码成的RGB值,我们还要进行解码。Unity提供了辅助函数来为我们对这个采样结果进行解码,从而得到深度值和法线方向。这个函数是DecodeDepthNormal,它在UnityCG.cginc里被定义:
inline void DecodeDepthNormal(float4 enc, out float depth,out float3 normal){
depth = DecodeFloatRG(enc.zw);
normal = DecodeViewNormalStereo(enc);
}
DecodeDepthNormal的第一个参数是对深度+法线纹理的采样结果,这个采样结果是Unity对深度和法线信息编码后的结果,它的xy分量存储的是编码后的视角空间下的法线信息,而深度信息被编码进了zw分量。通过DecodeFloatRG来解码得到单通道的深度值,DecodeViewNoramalStereo来解码得到三通道的法线方向。通过DecodeDepthNormal函数得到的深度值是范围在[0,1]的线性深度值(这与单独的深度纹理中存储的深度值不同,深度纹理的深度值是非线性的,而解码得到的是线性的),得到的法线方向则是视角空间下的法线方向。
使用深度+法线纹理进行边缘检测
在上面我们已经介绍了如何在Unity中获取深度纹理和法线纹理,那么我们该怎么通过这两个纹理来进行不受纹理纹理和光照影响的边缘检测呢?
在上面的边缘检测中,我们使用了Sobel算子作为边缘检测算子,现在我们将使用Roberts算子来进行边缘检测。它使用的卷积核如图13.10所示:
Roberts算子的本质就是计算左上角和右下角的差值,乘以右上角和左下角的差值,作为评估边缘的依据。在接下来的实现中,我们也会取对角方向的深度或法线值,比较它们之间的差值,如果超过某个阈值(可由参数控制),就认为它们之间存在一条边。
首先我们来实现边缘检测脚本EdgeDetectNormalsAndDepth.cs:
(1)让EdgeDetectNormalsAndDepth.cs继承基类PostEffectBase.cs:
public class EdgeDetectNormalsAndDepths : PostEffectsBase {
(2)声明该效果需要的Shader,并据此创建相应的材质:
//声明该效果需要的Shader,并据此创建相应的材质
public Shader edgeDetectShader;
private Material edgeDetectMaterial = null;
public Material material
{
get
{
edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
return edgeDetectMaterial;
}
}
(3)在脚本中提供了调整边缘线强度、描边颜色以及背景颜色的参数。同时添加了控制采样距离以及对深度和法线进行边缘检测时的灵敏度参数:
//设置可调参数
[Range(0.0f, 1.0f)]
public float edgesOnly = 0.0f;//边缘线强度,当值为0边缘将会叠加在原渲染图像上;当值为1时,则会只显示边缘,不显示原渲染图像。
public Color edgeColor = Color.black;//描边颜色
public Color backgroundColor = Color.white;//背景颜色
public float sampleDistance = 1.0f;//用于控制对深度+法线纹理采样时,采用的采样距离。从视觉上来看,sampleDistance值越大,描边越宽
//深度灵敏度和法线灵敏度,决定了当领域的深度值或法线值相差多少时,会被认为存在一条边界。
public float sensitivityDepth = 1.0f;//深度灵敏度
public float sensitivityNormals = 1.0f;//法线灵敏度
其中,sampleDistance用于控制对深度+法线纹理采样时,使用的采样距离。从视觉上来看,sampleDistance值越大,描边越宽。sensitivityDepth和sensitivityNormals将会影响当邻域的深度值或法线值相差多少时,会被认为存在一条边界。如果把灵敏度调得很大,那么可能即使是深度或法线上很小的变化也会形成一条边。
(4)接着我们在脚本的OnEnable函数中设置摄像机的相应状态,使得我们能够获取摄像机的深度+法线纹理:
//设置摄像机获取深度+法线纹理
private void OnEnable()
{
GetComponent<Camera>().depthTextureMode |= DepthTextureMode.DepthNormals;
}
(5)实现OnRenderImage函数,把各个参数传递给材质:
[ImageEffectOpaque] //只对不透明物体进行描边,不对透明物体描边
void OnRenderImage(RenderTexture src, RenderTexture dest)
{
if (material != null)
{
material.SetFloat("_EdgeOnly", edgesOnly);
material.SetColor("_EdgeColor", edgeColor);
material.SetColor("_BackgroundColor", backgroundColor);
material.SetFloat("_SampleDistance", sampleDistance);
material.SetVector("_Sensitivity", new Vector4(sensitivityNormals, sensitivityDepth, 0.0f, 0.0f));
Graphics.Blit(src, dest, material);
}
else
{
Graphics.Blit(src, dest);
}
}
需要注意的是,这里我们为OnRenderImage函数添加了[ImageEffectOpaque]属性。这是因为,在默认情况下,OnRenderImage函数会在所有的不透明和透明的Pass执行完毕后被调用,以便对场景中所有游戏对象都产生影响。但有时,我们希望在不透明的Pass(即渲染队列小于等于2500的Pass,内置的Background、Geometry和AlphaTest渲染队列均在此范围内)执行完毕后立即调用该函数,而不对透明物体(渲染队列为Transparent的Pass)产生影响,此时,我们可以在OnRenderImage函数前添加ImageEffectOpaque属性来实现这样的目的。在本例中,我们只希望对不透明物体进行描边,而不希望透明物体也被描边,因此添加该属性。
接着,我们来实现边缘检测的Shader部分:
(1)首先,我们需要声明边缘检测所要用到的各个属性:
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)
_SampleDistance("Sample Distance", Float) = 1.0
_Sensitivity("Sensitivity", Vector) = (1, 1, 1, 1)//其中xy分量保存法线和深度检测灵敏度,zw分量没用
}
其中,_Sensitivity的xy分量分别对应了法线和深度的检测灵敏度,zw分量则没有实际用途。
(2)我们使用CGINCLUDE来组织代码,以避免着色器代码重复使用带来的不便。我们在SubShader块中利用CGINCLUDE和ENDCG语义来定义一系列代码:
SubShader{
CGINCLUDE
...
ENDCG
...
}
(3)为了在代码中访问各个属性,我们需要在CG代码块中声明对应的变量:
sampler2D _MainTex;
half4 _MainTex_TexelSize;//纹素大小
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;
float _SampleDistance;
half4 _Sensitivity;
sampler2D _CameraDepthNormalsTexture;//深度+法线纹理
在上面的代码中,我们声明了需要获取的深度+法线纹理_CameraDepthNormalsTexture。由于我们需要对邻域像素进行纹理采样,所以还声明了纹素大小的变量_MainTex_TexelSize。
(4)接着定义顶点着色器:
//顶点着色器输出
struct v2f {
float4 pos : SV_POSITION;
half2 uv[5]: TEXCOORD0;//第一个元素保存屏幕空间纹理坐标,其他四个保存Robert算子所需的四个领域纹理坐标
};
//顶点着色器
v2f vert(appdata_img v) {
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
half2 uv = v.texcoord;
o.uv[0] = uv;
//平台差异化处理
#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y < 0)
uv.y = 1 - uv.y;
#endif
//通过_SampleDistance控制采样距离
o.uv[1] = uv + _MainTex_TexelSize.xy * half2(1, 1) * _SampleDistance;
o.uv[2] = uv + _MainTex_TexelSize.xy * half2(-1, -1) * _SampleDistance;
o.uv[3] = uv + _MainTex_TexelSize.xy * half2(-1, 1) * _SampleDistance;
o.uv[4] = uv + _MainTex_TexelSize.xy * half2(1, -1) * _SampleDistance;
return o;
}
我们在v2f结构体中定义了一个维数为5的纹理坐标数组。这个数组第一个元素保存了屏幕颜色图像的采样纹理。我们对深度纹理的采样坐标进行了平台差异化处理(OpenGL和D3D的屏幕空间坐标的y轴方向相反),在必要情况下对它的竖直方向进行了翻转。数组中剩余的4个坐标则存储了使用Roberts算子时需要采样的纹理坐标,我们还使用了_SampleDistance来控制采样距离。通过把计算采样纹理坐标的代码从片元着色器中转移到顶点着色器中,可以减少运算,提高性能。由于从顶点着色器到片元着色器的插值是线性的,因此这样的转移并不会影响纹理坐标的计算结果。
(5)接着我们声明一个计算对角线上两个纹理值的差值的函数CheckSame,返回值为0表明这两点之间存在一条边界,反之返回1:
//计算对角线上两个纹理值的差值的函数,返回值为0表明这两点之间存在一条边界,反之返回1
half CheckSame(half4 center, half4 sample) {
//获取两个采样点的法线和深度值
half2 centerNormal = center.xy;
float centerDepth = DecodeFloatRG(center.zw);
half2 sampleNormal = sample.xy;
float sampleDepth = DecodeFloatRG(sample.zw);
// difference in normals
// do not bother decoding normals - there's no need here
half2 diffNormal = abs(centerNormal - sampleNormal) * _Sensitivity.x;
int isSameNormal = (diffNormal.x + diffNormal.y) < 0.1;//如果小于0.1,说明差异不明显,不存在一条边界,返回1
// difference in depth
float diffDepth = abs(centerDepth - sampleDepth) * _Sensitivity.y;
// scale the required threshold by the distance
int isSameDepth = diffDepth < 0.1 * centerDepth;
// return:
// 1 - if normals and depth are similar enough
// 0 - otherwise
return isSameNormal * isSameDepth ? 1.0 : 0.0;
}
CheckSame首先对输入参数进行处理,得到两个两个采样点的法线和深度值。值得注意的是,这里我们并没有解码得到真正的法线方向,而是直接使用了xy分量。这是因为我们只需要比较两个采样值之间的差异度,而并不需要知道它们真正的法线值。然后,我们把两个采样点的对应值相减并取绝对值,再乘以灵敏度参数,把差异值的每个分量相加再和一个阈值比较,如果它们的和小于阈值,则返回1,说明差异值不明显,不存在一条边界;否则返回0。最后,我们把法线和深度检查结果相乘,作为组合后的返回值。
然后我们声明片元着色器:
//片段着色器
fixed4 fragRobertsCrossDepthAndNormal(v2f i) : SV_Target{
//使用4个纹理坐标对深度+法线纹理进行采样
half4 sample1 = tex2D(_CameraDepthNormalsTexture, i.uv[1]);
half4 sample2 = tex2D(_CameraDepthNormalsTexture, i.uv[2]);
half4 sample3 = tex2D(_CameraDepthNormalsTexture, i.uv[3]);
half4 sample4 = tex2D(_CameraDepthNormalsTexture, i.uv[4]);
half edge = 1.0;
edge *= CheckSame(sample1, sample2);
edge *= CheckSame(sample3, sample4);
fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[0]), edge);
fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);
return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
}
我们首先使用4个纹理坐标对深度+法线纹理进行采样,再调用CheckSame函数来分别计算对角线上两个纹理的差值。接着相乘两条对角线上的差值得到边缘信息edge,再结合_EdgeOnly进行颜色混合绘制边缘。
(6)最后,我们定义了边缘检测需要使用的Pass:
//定义边缘检测的Pass
Pass {
ZTest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment fragRobertsCrossDepthAndNormal
ENDCG
}
接着关闭该Shader的Fallback:
FallBack Off
运行效果
基于深度+法线纹理的边缘检测效果如下:
可以看到模型纹理和阴影处并没有出现黑色描边,效果比基于屏幕颜色的边缘检测要好很多!
不过,我们实现的描边效果是基于整个屏幕空间进行的,也就是说,场景内的所有物体都会被添加描边效果。但有时,我们希望只对特定的物体进行描边,例如当玩家选中场景中的某个物体后,我们想要在该物体周围添加一层描边效果。这时,我们可以使用Unity提供的Graphics.DrawMesh或Graphics.DrawMeshNow函数把需要描边的物体再次渲染一遍(在所有不透明物体渲染完毕之后),然后再使用上面提到的边缘检测算法计算深度或法线纹理中每个像素的梯度值,判断它们是否小于某个阈值,如果是,就在Shader中使用clip()函数将该像素剔除掉,从而显示出原来物体的颜色,否则绘制描边颜色[2]。
Reference
- [1] 冯乐乐. 《Unity Shader入门精要》
- [2] Unity-Edge Detect Effect Normals
结语
本博客的代码和资源均可在我的github上下载,别忘了点颗Star哟!