第13章 使用深度和法线纹理

在第12 章中,我们学习的屏幕后处理效果都只是在屏幕颜色图像上进行各种操作来实现的。然而,很多时候我们不仅需要当前屏幕的颜色信息,还希望得到深度和法线信息。例如,在进行边缘检测时,直接利用颜色信息会使检测到的边缘信息受物体纹理和光照等外部因素的影响,得到很多我们不需要的边缘点。一种更好的方法是,我们可以在深度纹理和法线纹理上进行边缘检测,这些图像不会受纹理和光照的影响,而仅仅保存了当前渲染物体的模型信息,通过这样的方式检测出来的边缘更加可靠。

在本章中,我们将学习如何在Unity 中获取深度纹理和法线纹理来实现特定的屏幕后处理效果。

在13.1 节中,我们首先会学习如何在Unity 中获取这两种纹理。

在13.2 节中,我们会利用深度纹理来计算摄像机的移动速度,实现摄像机的运动模糊效果。

在13.3 节中,我们会学习如何利用深度纹理来重建屏幕像素在世界空间中的位置,从而模拟屏幕雾效。

13.4 节会再次学习边缘检测的另一种实现,即利用深度和法线纹理进行边缘检测。

13.1 获取深度和法线纹理

虽然在Unity 里获取深度和法线纹理的代码非常简单,但是我们有必要在这之前首先了解它们背后的实现原理。

13.1.1 背后的原理

深度纹理实际就是一张渲染纹理,只不过它里面存储的像素值不是颜色值,而是一个高精度的深度值。由于被存储在一张纹理中,深度纹理里的深度值范围是[0, 1],而且通常是非线性分布的。那么,这些深度值是从哪里得到的呢?要回答这个问题,我们需要回顾在第4 章学过的顶点变换的过程。总体来说,这些深度值来自于顶点变换后得到的归一化的设备坐标( Normalized Device Coordinates , NDC )。回顾一下,一个模型要想最终被绘制在屏幕上,需要把它的顶点从模型空间变换到齐次裁剪坐标系下,这是通过在顶点着色器中乘以MVP 变换矩阵得到的。在变换的最后一步,我们需要使用一个投影矩阵来变换顶点,当我们使用的是透视投影类型的摄像机时,这个投影矩阵就是非线性的,具体过程可回顾4.6.7 小节。
图13.1 显示了4.6.7 小节中给出的Unity 中透视投影对顶点的变换过程。图13.1 中最左侧的图显示了投影变换前,即观察空间下视锥体的结构及相应的顶点位置,中间的图显示了应用透视裁剪矩阵后的变换结果,即顶点着色器阶段输出的顶点变换结果,最右侧的图则是底层硬件进行了透视除法后得到的归一化的设备坐标。需要注意的是,这里的投影过程是建立在Unity 对坐标系的假定上的,也就是说,我们针对的是观察空间为右手坐标系,使用列矩阵在矩阵右侧进行相乘,且变换到NDC 后z 分量范围将在[-1, 1]之间的情况。而在类似DirectX 这样的图形接口中,变换后z 分量范围将在[0, 1]之间。如果需要在其他图形接口下实现本章的类似效果, 需要对一些计算参数做出相应变化。关于变换时使用的矩阵运算, 读者可以参考4.6.7 小节。

图13.2 显示了在使用正交摄像机时投影变换的过程。同样,变换后会得到一个范围为[-1, 1] 的立方体。正交投影使用的变换矩阵是线性的。
在得到NDC 后,深度纹理中的像素值就可以很方便地计算得到了,这些深度值就对应了NDC 中顶点坐标的z 分量的值。由于NDC 中z 分量的范围在[-1, 1],为了让这些值能够存储在一张图像中,我们需要使用下面的公式对其进行映射:

其中, d 对应了深度纹理中的像素值, Zndc 对应了NDC 坐标中的z 分量的值。
那么Unity 是怎么得到这样一张深度纹理的呢?在Unity 中,深度纹理可以直接来自于真正的深度缓存,也可以是由一个单独的Pass 渲染而得,这取决于使用的渲染路径和硬件。通常来讲,当使用延迟渲染路径(包括遗留的延迟渲染路径)时,深度纹理理所当然可以访问到,因为延迟渲染会把这些信息渲染到G-buffer 中。而当无法直接获取深度缓存时,深度和法线纹理是通过一个单独的Pass 渲染而得的。具体实现是, Unity 会使用着色器替换( Shader Replacement )技术选择那些渲染类型〈即SubShader 的RenderType 标签)为Opaque 的物体,判断它们使用的渲染队列是否小于等于2 500 (内置的Background 、Geometry 和AlphaTest 渲染队列均在此范围内),如果满足条件,就把它渲染到深度和法线纹理中。因此,要想让物体能够出现在深度和法线纹理中,就必须在Shader 中设置正确的RenderType 标签。
在Unity 中,我们可以选择让一个摄像机生成一张深度纹理或是一张深度+法线纹理。当选择前者,即只需要一张单独的深度纹理时, Unity 会直接获取深度缓存或是按之前讲到的着色器替换技术,选取需要的不透明物体,并使用它投射阴影时使用的Pass (即LightMode 被设置为ShadowCaster 的Pass,详见9.4 节)来得到深度纹理。如果Shader 中不包含这样一个Pass,那么这个物体就不会出现在深度纹理中(当然,它也不能向其他物体投射阴影)。深度纹理的精度通常是24 位或16 位,这取决于使用的深度缓存的精度。如果选择生成一张深度+法线纹理, Unity 会创建一张和屏幕分辨率相同、精度为32 位〈每个通道为8 位)的纹理,其中观察空间下的法线信息会被编码进纹理的R 和G 通道,而深度信息会被编码进B 和A 通道。法线信息的获取在延迟渲染中是可以非常容易就得到的, Unity 只需要合并深度和法线缓存即可。而在前向渲染中,默认情况下是不会创建法线缓存的,因此Unity 底层使用了一个单独的Pass 把整个场景再次渲染一遍来完成。这个Pass 被包含在Unity 内置的一个Unity Shader 中,我们可以在内置的
builtin_shaders-xxx/DefaultResources/Camera-DepthNormalTexture.shader 文件中找到这个用于渲染深度和法线信息的Pass。

13.1.2 如何获取

在Unity 中,获取深度纹理是非常简单的,我们只需要告诉Unity:“嘿,把深度纹理给我!”然后再在Shader 中直接访问特定的纹理属性即可。这个与Unity 沟通的过程是通过在脚本中设置摄像机的depthTextureMode 来完成的,例如我们可以通过下面的代码来获取深度纹理:
	camera.depthTextureMode = DepthTextureMode.Depth;
  
    
    
    
    
一旦设置好了上面的摄像机模式后,我们就可以在Shader 中通过声明 _CameraDepthTexture变量来访问它。这个过程非常简单,但我们需要知道这两行代码的背后, Unity 为我们做了许多工作(见13.1.1 节〉。
同理,如果想要获取深度+法线纹理,我们只需要在代码中这样设置:
	camera.depthTextureMode = DepthTextureMode.DepthNormals;
  
    
    
    
    
然后在Shader 中通过声明 _CameraDepthNormalsTexture 变量来访问它。
我们还可以组合这些模式,让一个摄像机同时产生一张深度和深度+法线纹理:

  
    
    
    
    
  1. camera.depthTextureMode |= DepthTextureMode.Depth;
  2. camera.depthTextureMode |= DepthTextureMode.DepthNormals;
在Unity 5 中,我们还可以在摄像机的Camera 组件上看到当前摄像机是否需要渲染深度或深度+法线纹理。当在Shader 中访问到深度纹理 _CameraDepthTexture 后,我们就可以使用当前像素的纹理坐标对它进行采样。绝大多数情况下,我们直接使用tex2D 函数采样即可,但在某些平台(例如PS3 和PSP2 )上,我们需要一些特殊处理。Unity 为我们提供了一个统一的宏
SAMPLE_DEPTH_TEXTURE,用来处理这些由于平台差异造成的问题。而我们只需要在Shader中使用
SAMPLE_DEPTH_TEXTURE 宏对深度纹理进行采样,例如:
	float d = SAMPLE_DEPTH_TEXTURE(_CarneraDepthTexture, 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(_CarneraDepthTexture, UNITY_PROJ_COORD(i.scrPos));
  
    
    
    
    
其中, i.scrPos 是在顶点着色器中通过调用ComputeScreenPos(o.pos)得到的屏幕坐标。上述这些宏的定义,读者可以在Unity 内置的HLSLSupport.cginc 文件中找到。
当通过纹理采样得到深度值后,这些深度值往往是非线性的,这种非线性来自于透视投影使用的裁剪矩阵。然而,在我们的计算过程中通常是需要线性的深度值,也就是说,我们需要把投影后的深度值变换到线性空间下,例如视角空间下的深度值。那么,我们应该如何进行这个转换呢?实际上,我们只需要倒推顶点变换的过程即可。下面我们以透视投影为例,推导如何由深度纹理中的深度信息计算得到视角空间下的深度值。

幸运的是, Unity 提供了两个辅助函数来为我们进行上述的计算过程一一LinearEyeDepth 和 Linear01Depth。 LinearEyeDepth 负责把深度纹理的采样结果转换到视角空间下的深度值,也就是我们上面得到的Zview。而Linear01Depth 则会返回一个范围在[0, 1]的线性深度值,也就是我们上面得到的Z 01 。这两个函数内部使用了内置的 _ZBufferParams 变量来得到远近裁剪平面的距离。
如果我们需要获取深度+法线纹理,可以直接使用tex2D 函数对 _CameraDepthNormalsTexture 进行采样,得到里面存储的深度和法线信息。Unity 提供了辅助函数来为我们对这个采样结果进行解码,从而得到深度值和法线方向。这个函数DecodeDepthNorrnal, 它在UnityCG.cginc 里被定义:

  
    
    
    
    
  1. inline void DecodeDepthNormal( float4 enc, out float depth, out float3 normal)
  2. {
  3. depth = DecodeFloatRG (enc.zw);
  4. normal= DecodeViewNormalStereo(enc);
  5. }
DecodeDepthNormal 的第一个参数是对深度+法线纹理的采样结果,这个采样结果是Unity 对深度和法线信息编码后的结果, 它的xy 分量存储的是视角空间下的法线信息, 而深度信息被编码进了zw 分量。通过调用DecodeDepthNormal 函数对采样结果解码后,我们就可以得到解码后的深度值和法线。这个深度值是范围在[0, 1]的线性深度值(这与单独的深度纹理中存储的深度值不同〉,而得到的法线则是视角空间下的法线方向。同样, 我们也可以通过调用DecodeFloatRG 和 DecodeViewNormaLStereo 来解码深度+法线纹理中的深度和法线信息。
至此,我们已经学会了如何在Unity 里获取及使用深度和法线纹理。下面, 我们会学习如何使用它们实现各种屏幕特效。

13.1.3 查看深度和法线纹理

很多时候, 我们希望可以查看生成的深度和法线纹理,以便对Shader 进行调试。Unity 5 提供了一个方便的方法来查看摄像机生成的深度和法线纹理, 这个方法就是利用帧调试器( Frame Debugger)。图13.3 显示了使用帧调试器查看到的深度纹理和深度+法线纹理。
使用帧调试器查看到的深度纹理是非线性空间的深度值,而深度+法线纹理都是由Unity 编码后的结果。有时,显示出线性空间下的深度信息或解码后的法线方向会更加有用。此时,我们可以自行在片元着色器中输出转换或解码后的深度和法线值, 如图13.4 所示。输出代码非常简单,我们可以使用类似下面的代码来输出线性深度值:

  
    
    
    
    
  1. float depth= SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
  2. float linearDepth = LinearOlDepth(depth);
  3. return fixed4(linearDepth, linearDepth, linearDepth, 1.0);
或是输出法线方向:

  
    
    
    
    
  1. fixed3 normal = DecodeViewNormalStereo(tex2D( _CameraDepthNormalsTexture, i.uv).xy);
  2. return fixed4 (normal * 0.5 + 0.5, 1.0);
在查看深度纹理时,读者得到的画面有可能几乎是全黑或全白的。这时候读者可以把摄像机的远裁剪平面的距离( Unity 默认为1000 )调小, 使视锥体的范围刚好覆盖场景的所在区域。这是因为,由于投影变换时需要覆盖从近裁剪平面到远裁剪平面的所有深度区域, 当远裁剪平面的 距离过大时, 会导致离摄像机较近的距离被映射到非常小的深度值,如果场景是一个封闭的区域 (如图13.4 所示〉, 那么这就会导致画面看起来几乎是全黑的。相反, 如果场景是一个开放区域, 且物体离摄像机的距离较远, 就会导致画面儿乎是全白的。

13.2 再谈运动模糊

在12.6 节中,我们学习了如何通过混合多张屏幕图像来模拟运动模糊的效果。但是,另一种应用更加广泛的技术则是使用速度映射图。速度映射图中存储了每个像素的速度,然后使用这个速度来决定模糊的方向和大小。速度缓冲的生成有多种方法,一种方法是把场景中所有物体的速度渲染到一张纹理中。但这种方法的缺点在于需要修改场景中所有物体的Shader 代码,使其添加计算速度的代码并输出到一个渲染纹理中。
《GPU Gems3》在第27 章(http:http.developer.nvidia.com/GPUGems3/gpugems3_ch27.html) 中介绍了一种生成速度映射图的方法。这种方法利用深度纹理在片元着色器中为每个像素计算其在世界空间下的位置,这是通过使用当前的视角*投影矩阵的逆矩阵对NDC 下的顶点坐标进行变换得到的。当得到世界空间中的顶点坐标后,我们使用前一帧的视角*投影矩阵对其进行变换,得到该位置在前一帧中的NDC 坐标。然后,我们计算前一帧和当前帧的位置差,生成该像素的速度。这种方法的优点是可以在一个屏幕后处理步骤中完成整个效果的模拟,但缺点是需要在片元着色器中进行两次矩阵乘法的操作,对性能有所影响。
为了使用深度纹理模拟运动模糊,我们需要进行如下准备工作。
( 1)新建一个场景。在本书资源中, 该场景名为Scene_13_2 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window → Lighting → Skybox 中去掉场景中的天空盒子。
( 2 ) 我们需要搭建一个测试运动模糊的场景。在本书资源的实现中,我们构建了一个包含3 面墙的房间,并放置了4 个立方体,它们都使用了我们在9.5 节中创建的标准材质。同时, 我们把本书资源中的Translating.cs 脚本拖曳给摄像机,让其在场景中不断运动。
(3)新建一个脚本。在本书资源中,该脚本名为MotionBlurWithDepthTexture.cs。把该脚本拖曳到摄像机上。
( 4 )新建一个Unity Shader。在本书资源中, 该Shader 名为Chapter13-MotionBlurWithDepthTexture。
我们首先来编写MotionBlurWithDepthTexture.cs 脚本。打开该脚本,并进行如下修改。
(1)首先, 继承12.1 节中创建的基类:
public class MotionBlurWithDepthTexture : PostEffectsBase {
  
    
    
    
    
( 2 )声明该效果需要的Shader,并据此创建相应的材质:

  
    
    
    
    
  1. public Shader motionBlurShader;
  2. private Material motionBlurMaterial = null;
  3. public Material material {
  4. get {
  5. motionBlurMaterial = CheckShaderAndCreateMaterial(motionBlurShader, motionBlurMaterial);
  6. return motionBlurMaterial;
  7. }
  8. }
(3)定义运动模糊时模糊图像使用的大小:

  
    
    
    
    
  1. [Range( 0.0f, 1.0f)]
  2. public float blurSize = 0.5f;
( 4)由于本节需要得到摄像机的视角和投影矩阵,我们需要定义一个Camera 类型的变量,以获取该脚本所在的摄像机组件:

  
    
    
    
    
  1. private Camera myCamera;
  2. public Camera camera {
  3. get {
  4. if (myCamera == null) {
  5. myCamera = GetComponent();
  6. }
  7. return myCamera;
  8. }
  9. }
( 5 )我们还需要定义一个变量来保存上一帧摄像机的视角*投影矩阵:
	private Matrix4x4 previousViewProjectionMatrix;
  
    
    
    
    
( 6 )由于本例需要获取摄像机的深度纹理, 我们在脚本的OnEnable 函数中设置摄像机的状态:

  
    
    
    
    
  1. void OnEnable() {
  2. camera.depthTextureMode |= DepthTextureMode.Depth;
(7)最后, 我们实现了OnRenderlmage 函数:

  
    
    
    
    
  1. void OnRenderImage (RenderTexture src, RenderTexture dest) {
  2. if (material != null) {
  3. material.SetFloat( "_BlurSize", blurSize);
  4. material.SetMatrix( "_PreviousViewProjectionMatrix", previousViewProjectionMatrix);
  5. Matrix4x4 currentViewProjectionMatrix = camera.projectionMatrix * camera.worldToCameraMatrix;
  6. Matrix4x4 currentViewProjectionInverseMatrix = currentViewProjectionMatrix.inverse;
  7. material.SetMatrix( "_CurrentViewProjectionInverseMatrix", currentViewProjectionInverseMatrix);
  8. previousViewProjectionMatrix = currentViewProjectionMatrix;
  9. Graphics.Blit (src, dest, material);
  10. } else {
  11. Graphics.Blit(src, dest);
  12. }
  13. }
上面的OnRenderlmage 函数很简单, 我们首先需要计算和传递运动模糊使用的各个属性。本例需要使用两个变换矩阵一一前一帧的视角*投影矩阵以及当前帧的视角*投影矩阵的逆矩阵。因此,我们通过调用camera.worldToCameraMtrix 和camera.projectionMatrix 来分别得到当前摄像机的视角矩阵和投影矩阵。对它们相乘后取逆, 得到当前帧的视角*投影矩阵的逆矩阵,并传递给材质。然后, 我们把取逆前的结果存储在previousViewProjectionMatrix 变量中,以便在下一帧时传递给材质的
 _PreviousViewProjectionMatrix 属性。
下面, 我们来实现Shader 的部分。打开Chapter13-MotionBiurWithDepthTexture,进行如下修改。
( 1)我们首先需要声明本例使用的各个属性:

  
    
    
    
    
  1. Properties {
  2. _MainTex ( "Base (RGB)", 2D) = "white" {}
  3. _BlurSize ( "Blur Size", Float) = 1.0
  4. }
_MainTex 对应了输入的渲染纹理, _BlurSize 是模糊图像时使用的参数。我们注意到,虽然在脚本里设置了材质的
_PreviousViewProjectionMatrix 和 _CurrentViewProjectionInverseMatrix 属性,但并没有在Properties 块中声明它们。这是因为Unity 没有提供矩阵类型的属性, 但我们仍然可以在CG 代码块中定义这些矩阵, 并从脚本中设置它们。
( 2 )在本节中, 我们使用CGINCLUDE 来组织代码。我们在SubShader 块中利用CGINCLUDE 和 ENDCG 语义来定义一系列代码:

  
    
    
    
    
  1. SubShader {
  2. CGINCLUDE
  3. ...
  4. ENDCG
  5. ...
(3)声明代码中需要使用的各个变量:

  
    
    
    
    
  1. sampler2D _MainTex;
  2. half4 _MainTex_TexelSize;
  3. sampler2D _CameraDepthTexture;
  4. float4x4 _CurrentViewProjectionInverseMatrix;
  5. float4x4 _PreviousViewProjectionMatrix;
  6. half _BlurSize;
在上面的代码中, 除了定义在Propertity声明的 _MainTex 和 _BlurSize 属性, 我们还声明了其他三个变量。_CameraDepthTexture 是Unity 传递给我们的深度纹理,而 _CurrentViewProjectionlnverseMatrix 和 _PreviousViewProjectionMatrix 是由脚本传递而来的矩阵。除此之外,我们还声明了 _MainTex_TexelSize 变量,它对应了主纹理的纹素大小,我们需要使用该变量来对深度纹理的采
样坐标进行平台差异化处理(详见5.6.1 节〉。
( 4 )顶点着色器的代码和之前使用多次的代码基本一致,只是增加了专门用于对深度纹理采样的纹理坐标变量:

  
    
    
    
    
  1. struct v2f {
  2. float4 pos : SV_POSITION;
  3. half2 uv : TEXCOORD0;
  4. half2 uv_depth : TEXCOORD1;
  5. };
  6. v2f vert(appdata_img v) {
  7. v2f o;
  8. o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
  9. o.uv = v.texcoord;
  10. o.uv_depth = v.texcoord;
  11. #if UNITY_UV_STARTS_AT_TOP
  12. if (_MainTex_TexelSize.y < 0)
  13. o.uv_depth.y = 1 - o.uv_depth.y;
  14. #endif
  15. return o;
  16. }
由于在本例中,我们需要同时处理多张渲染纹理,因此在DirectX 这样的平台上,我们需要处理平台差异导致的图像翻转问题。在上面的代码中,我们对深度纹理的采样坐标进行了平台差异化处理,以便在类似DirectX 的平台上,在开启了抗锯齿的情况下仍然可以得到正确的结果。
( 5 )片元着色器是算法的重点所在:

  
    
    
    
    
  1. fixed4 frag(v2f i) : SV_Target {
  2. // Get the depth buffer value at this pixel.
  3. float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);
  4. // H is the viewport position at this pixel in the range -1 to 1.
  5. float4 H = float4(i.uv.x * 2 - 1, i.uv.y * 2 - 1, d * 2 - 1, 1);
  6. // Transform by the view-projection inverse.
  7. float4 D = mul(_CurrentViewProjectionInverseMatrix, H);
  8. // Divide by w to get the world position.
  9. float4 worldPos = D / D.w;
  10. // Current viewport position
  11. float4 currentPos = H;
  12. // Use the world position, and transform by the previous view-projection matrix.
  13. float4 previousPos = mul(_PreviousViewProjectionMatrix, worldPos);
  14. // Convert to nonhomogeneous points [-1,1] by dividing by w.
  15. previousPos /= previousPos.w;
  16. // Use this frame's position and last frame's to compute the pixel velocity.
  17. float2 velocity = (currentPos.xy - previousPos.xy)/ 2.0f;
  18. float2 uv = i.uv;
  19. float4 c = tex2D(_MainTex, uv);
  20. uv += velocity * _BlurSize;
  21. for ( int it = 1; it < 3; it++, uv += velocity * _BlurSize) {
  22. float4 currentColor = tex2D(_MainTex, uv);
  23. c += currentColor;
  24. }
  25. c /= 3;
  26. return fixed4(c.rgb, 1.0);
  27. }
我们首先需要利用深度纹理和当前帧的视角*投影矩阵的逆矩阵来求得该像素在世界空间下的坐标。过程开始于对深度纹理的采样,我们使用内置的SAMPLE_DEPTH_TEXTURE 宏和纹理坐标对深度纹理进行采样, 得到了深度值d。由13.1.2 节可知, d 是由NDC 下的坐标映射而来的。我们想要构建像素的NDC 坐标H, 就需要把这个深度值重新映射回NDC。这个映射很简单,只需要使用原映射的反函数即可,即d * 2 - 1 。同样, NDC 的 xy 分量可以由像素的纹理坐标映射而来( NDC 下的xyz 分量范围均为
[ -1, 1] )。当得到NDC 下的坐标H 后,我们就可以使用当前帧的视角*投影矩阵的逆矩阵对其进行变换,并把结果值除以它的w 分量来得到世界空间下的坐标表示worldPos。
一旦得到了世界空间下的坐标,我们就可以使用前一帧的视角*投影矩阵对它进行变换, 得到前一帧在NDC 下的坐 previousPos。然后,我们计算前一帧和当前帧在屏幕空间下的位置差,得到该像素的速度velocity 。
当得到该像素的速度后,我们就可以使用该速度值对它的邻域像素进行采样,相加后取平均值得到一个模糊的效果。采样时我们还使用了 _BlurSize 来控制采样距离。
( 6 )然后,我们定义了运动模糊所需的Pass:

  
    
    
    
    
  1. Pass {
  2. ZTest Always Cull Off ZWrite Off
  3. CGPROGRAM
  4. #pragma vertex vert
  5. #pragma fragment frag
  6. ENDCG
  7. }
(7)最后,我们关闭了shader 的Fallback:
	FallBack Off
  
    
    
    
    
完成后返回编辑器,并把Chapter13-MotionBlurWithDepthTexture 拖曳到摄像机的MotionBlurWithDepthTexture.cs 脚本中的 motionBlurShader 参数中。当然,我们可以在 MotionBlurWithDepthTexture.cs 的脚本面版中将motionBlurShader 参数的默认值设置为Chapter13-MotionBlurWithDepthTexture , 这样就不需要以后使用时每次都手动拖曳了。
本节实现的运动模糊适用于场景静止、摄像机快速运动的情况,这是因为我们在计算时只考虑了摄像机的运动。因此,如果读在把本节中的代码应用到一个物体快速运动而摄像机静止的场景,会发现不会产生任何运动模糊效果。如果我们想要对快速移动的物体产生运动模糊的效果,就需要生成更加精确的速度映射图。读者可以在Unity 自带的lmageEffect 包中找到更多的运动模糊的实现方法。
本节选择在片元着色器中使用逆矩阵来重建每个像素在世界空间下的位置。但是,这种做法往往会影响性能,在13.3 节中,我们会介绍一种更快速的由深度纹理重建世界坐标的方法。

13.3 全局雾效

雾效(Fog )是游戏里经常使用的一种效果。Unity 内置的雾效可以产生基于距离的线性或指数雾效。然而,要想在自己编写的顶点/片元着色器中实现这些雾效,我们需要在Shader 中添加 #pragma multi_compile_fog 指令,同时还需要使用相关的内置宏,例如UNITY_FOG_COORDS, UNITY_TRANSFER_FOG 和 UNITY_APPLY_FOG 等。这种方法的缺点在于,我们不仅需要为场景中所有物体添加相关的渲染代码,而且能够实现的效果也非常有限。当我们需要对雾效进行一些个性化操作时,例如使用基于高度的雾效等,仅仅使用Unity 内置的雾效就变得不再可行。
在本节中,我们将会学习一种基于屏幕后处理的全局雾效的实现。使用这种方法,我们不需要更改场景内渲染的物体所使用的Shader代码,而仅仅依靠一次屏幕后处理的步骤即可。这种方法的自由性很高,我们可以方便地模拟各种雾效,例如均匀的雾效、基于距离的线性/指数雾效、基于高度的雾效等。在学习完本节后,我们可以得到类似图13.5 中的效果。

基于屏幕后处理的全局第效的关键是,根据深度纹理来重建每个像素在世界空间下的位置。尽管在13.2 节中,我们在模拟运动模糊时已经实现了这个要求,即构建出当前像素的NDC 坐标,再通过当前摄像机的视角*投影矩阵的逆矩阵来得到世界空间下的像索坐标,但是,这样的实现需要在片元着色器中进行矩阵乘法的操作,而这通常会影响游戏性能。在本节中,我们将会学习一个快速从深度纹理中重建世界坐标的方法。这种方法首先对图像空间下的视锥体射线(从摄像机出发,指向图像上的某点的射线〉进行插值,这条射线存储了该像素在世界空间下到摄像机的方向信息。然后,我们把该射线和线性化后的视角空间下的深度值相乘,再加上摄像机的世界位置,就可以得到该像素在世界空间下的位置。当我们得到世界坐标后,就可以轻松地使用各个公式来模拟全局雾效了。

13.3.1 重建世界坐标

在开始动手写代码之前,我们首先来了解如何从深度纹理中重建世界坐标。我们知道,坐标系中的一个顶点坐标可以通过它相对于另一个顶点坐标的偏移量来求得。重建像素的世界坐标也是基于这样的思想。我们只需要知道摄像机在世界空间下的位置,以及世界空间下该像素相对于摄像机的偏移量,把它们相加就可以得到该像素的世界坐标。整个过程可以使用下面的代码来表示:

其中, _WorldSpaceCameraPos 是摄像机在世界空间下的位置,这可以由Unity 的内置变量直接访问得到。而
linearDepth * interpolatedRay 则可以计算得到该像素相对于摄像机的偏移量, linearDepth 是由深度纹理得到的线性深度值, interpolatedRay 是由顶点着色器输出并插值后得到的射线,它不仅包含了该像素到摄像机的方向,也包含了距离信息。linearDepth 的获取我们己经在13.1.2 节中详细解释过了,因此,本节着重解释 interpolatedRay 的求法。
interpolatedRay 来源于对近裁剪平面的4 个角的某个特定向量的插值,这4 个向量包含了它们到摄像机的方向和距离信息,我们可以利用摄像机的近裁剪平面距离、FOV、横纵比计算而得。图13.6显示了计算时使用的一些辅助向量。为了方便计算,我们可以先计算两个向量——toTop 和 toRight, 它们是起点位于近裁剪平面中心、分别指向摄像机正上方和正右方的向量。它们的计算公式如下:

注意,上面求得的4 个向量不仅包含了方向信息,它们的模对应了4 个点到摄像机的空间距离。由于我们得到的线性深度值并非是点到摄像机的欧式距离,而是在z 方向上的距离,因此,我们不能直接使用深度值和4 个角的单位方向的乘积来计算它们到摄像机的偏移量,如图13.7 所示。想要把深度值转换成到摄像机的欧式距离也很简单,我们以TL 点为例,根据相似三角形原理, TL 所在的射线上,像素的深度值和它到摄像机的实际距离的比等于近裁剪平面的距离和TL向量的模的比,即



屏幕后处理的原理是使用特定的材质去渲染一个刚好填充整个屏幕的四边形面片。这个四边形面片的4 个顶点就对应了近裁剪平面的4 个角。因此,我们可以把上面的计算结果传递给顶点着色器,顶点着色器根据当前的位置选择它所对应的向量,然后再将其输出,经插值后传递给片元着色器得到interpoIatedRay,我们就可以直接利用本节一开始提到的公式重建该像素在世界空间下的位置了。

13.3.2 雾的计算

在简单的雾效实现中,我们需要计算一个雾效系数 f,作为混合原始颜色和雾的颜色的混合系数:

13.3.3 实现

为了在Unity 中实现基于屏幕后处理的雾效,我们需要进行如下准备工作。
(1)新建一个场景。在本书资源中,该场景名为Scene_13_3 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window -> Lighting -> Skybox 中去掉场景中的天空盒子。
(2)我们需要搭建一个测试雾效的场景。在本书资源的实现中,我们构建了一个包含3 面墙的房间,并放置了两个立方体和两个球体,它们都使用了我们在9.5 节中创建的标准材质。同时,我们把本书资源中的Translating.cs 脚本拖曳给摄像机,让其在场景中不断运动。
(3)新建一个脚本。在本书资源中,该脚本名为FogWithDepthTexture.cs . 把该脚本拖曳到摄像机上。
(4)新建-个Unity Shader。在本书资源中,该Shader 名为Chapterl3-FogWithDepthTexture 。
我们首先来编写FogWithDepthTexture.cs 脚本。打开该脚本,并进行如下修改。
(1)首先,继承12.1 节中创建的基类:
public class FogWithDepthTexture : PostEffectsBase {
  
    
    
    
    
(2 )声明该效果需要的Shader,并据此创建相应的材质:

  
    
    
    
    
  1. public Shader fogShader;
  2. private Material fogMaterial = null;
  3. public Material material {
  4. get {
  5. fogMaterial = CheckShaderAndCreateMaterial(fogShader, fogMaterial);
  6. return fogMaterial;
  7. }
  8. }
(3 )在本节中,我们需要获取摄像机的相关参数,如近裁剪平面的距离、FOV 等,同时还需要获取摄像机在世界空间下的前方、上方和右方等方向,因此我们用两个变量存储摄像机的Camera 组件和Transform 组件:

  
    
    
    
    
  1. private Camera myCamera;
  2. public Camera camera {
  3. get {
  4. if (myCamera == null) {
  5. myCamera = GetComponent();
  6. }
  7. return myCamera;
  8. }
  9. }
  10. private Transform myCameraTransform;
  11. public Transform cameraTransform {
  12. get {
  13. if (myCameraTransform == null) {
  14. myCameraTransform = camera.transform;
  15. }
  16. return myCameraTransform;
  17. }
  18. }
(4 )定义模拟雾效时使用的各个参数:

  
    
    
    
    
  1. [Range( 0.0f, 3.0f)]
  2. public float fogDensity = 1.0f;
  3. public Color fogColor = Color.white;
  4. public float fogStart = 0.0f;
  5. public float fogEnd = 2.0f;
fogDensity 用于控制雾的浓度, fogColor 用于控制雾的颜色。我们使用的雾效模拟函数是基于高度的,因此参数fogStart 用于控制雾效的起始高度, fogEnd 用于控制雾效的终止高度。
( 5 )由于本例需要获取摄像机的深度纹理,我们在脚本的OnEnable 函数中设置摄像机的相应状态:

  
    
    
    
    
  1. void OnEnable() {
  2. camera.depthTextureMode |= DepthTextureMode.Depth;
  3. }
( 6 )最后, 我们实现了OnRenderlmage 函数:

  
    
    
    
    
  1. void OnRenderImage (RenderTexture src, RenderTexture dest) {
  2. if (material != null) {
  3. Matrix4x4 frustumCorners = Matrix4x4.identity;
  4. float fov = camera.fieldOfView;
  5. float near = camera.nearClipPlane;
  6. float aspect = camera.aspect;
  7. float halfHeight = near * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);
  8. Vector3 toRight = cameraTransform.right * halfHeight * aspect;
  9. Vector3 toTop = cameraTransform.up * halfHeight;
  10. Vector3 topLeft = cameraTransform.forward * near + toTop - toRight;
  11. float scale = topLeft.magnitude / near;
  12. topLeft.Normalize();
  13. topLeft *= scale;
  14. Vector3 topRight = cameraTransform.forward * near + toRight + toTop;
  15. topRight.Normalize();
  16. topRight *= scale;
  17. Vector3 bottomLeft = cameraTransform.forward * near - toTop - toRight;
  18. bottomLeft.Normalize();
  19. bottomLeft *= scale;
  20. Vector3 bottomRight = cameraTransform.forward * near + toRight - toTop;
  21. bottomRight.Normalize();
  22. bottomRight *= scale;
  23. frustumCorners.SetRow( 0, bottomLeft);
  24. frustumCorners.SetRow( 1, bottomRight);
  25. frustumCorners.SetRow( 2, topRight);
  26. frustumCorners.SetRow( 3, topLeft);
  27. material.SetMatrix( "_FrustumCornersRay", frustumCorners);
  28. material.SetFloat( "_FogDensity", fogDensity);
  29. material.SetColor( "_FogColor", fogColor);
  30. material.SetFloat( "_FogStart", fogStart);
  31. material.SetFloat( "_FogEnd", fogEnd);
  32. Graphics.Blit (src, dest, material);
  33. } else {
  34. Graphics.Blit(src, dest);
  35. }
  36. }
OnRenderlmage 首先计算了近裁剪平面的四个角对应的向量, 并把它们存储在一个矩阵类型的变量(frustumCorners)中。计算过程我们已经在13.3.1 节中详细解释过了,代码只是套用了之前讲过的公式而己。我们按一定顺序把这四个方向存储到了frustumCorners 不同的行中,这个顺序是非常重要的,因为这决定了我们在顶点着色器中使用哪一行作为该点的待插值向量。随后,我们把结果和其他参数传递给材质,并调用Graphics.Blit (src, dest, material)把渲染结果显示在屏幕上。
下面,我们来实现Shader 的部分。打开Chapter13-FogWithDepthTexture,进行如下修改。
(1)我们首先需要声明本例使用的各个属性:

  
    
    
    
    
  1. Properties {
  2. _MainTex ( "Base (RGB)", 2D) = "white" {}
  3. _FogDensity ( "Fog Density", Float) = 1.0
  4. _FogColor ( "Fog Color", Color) = ( 1, 1, 1, 1)
  5. _FogStart ( "Fog Start", Float) = 0.0
  6. _FogEnd ( "Fog End", Float) = 1.0
  7. }
( 2 )在本节中, 我们使用CGINCLUDE 来组织代码。我们在SubShader 块中利用CGINCLUDE 和 ENDCG 语义来定义一系列代码:

  
    
    
    
    
  1. SubShader {
  2. CGINCLUDE
  3. ...
  4. ENDCG
  5. ...
(3)声明代码中需要使用的各个变量:

  
    
    
    
    
  1. float4x4 _FrustumCornersRay;
  2. sampler2D _MainTex;
  3. half4 _MainTex_TexelSize;
  4. sampler2D _CameraDepthTexture;
  5. half _FogDensity;
  6. fixed4 _FogColor;
  7. float _FogStart;
  8. float _FogEnd;
_FrustumCornersRay 虽然没有在Properties 中声明, 但仍可由脚本传递给Shader。除了 Properties 中声明的各个属性,我们还声明了深度纹理 _CameraDepthTexture, Unity 会在背后把得到的深度纹理传递给该值。
( 4)定义顶点着色器:

  
    
    
    
    
  1. struct v2f {
  2. float4 pos : SV_POSITION;
  3. half2 uv : TEXCOORD0;
  4. half2 uv_depth : TEXCOORD1;
  5. float4 interpolatedRay : TEXCOORD2;
  6. };
  7. v2f vert(appdata_img v) {
  8. v2f o;
  9. o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
  10. o.uv = v.texcoord;
  11. o.uv_depth = v.texcoord;
  12. #if UNITY_UV_STARTS_AT_TOP
  13. if (_MainTex_TexelSize.y < 0)
  14. o.uv_depth.y = 1 - o.uv_depth.y;
  15. #endif
  16. int index = 0;
  17. if (v.texcoord.x < 0.5 && v.texcoord.y < 0.5) {
  18. index = 0;
  19. } else if (v.texcoord.x > 0.5 && v.texcoord.y < 0.5) {
  20. index = 1;
  21. } else if (v.texcoord.x > 0.5 && v.texcoord.y > 0.5) {
  22. index = 2;
  23. } else {
  24. index = 3;
  25. }
  26. #if UNITY_UV_STARTS_AT_TOP
  27. if (_MainTex_TexelSize.y < 0)
  28. index = 3 - index;
  29. #endif
  30. o.interpolatedRay = _FrustumCornersRay[index];
  31. return o;
  32. }
在v2f 结构体中,我们除了定义顶点位置、屏幕图像和深度纹理的纹理坐标外,还定义了 interpolatedRay 变量存储插值后的像素向量。在顶点着色器中,我们对深度纹理的采样坐标进行了平台差异化处理。更重要的是,我们要决定该点对应了4 个角中的哪个角。我们采用的方法是判断它的纹理坐标。我们知道,在Unity 中,纹理坐标的(0, 0)点对应了左下角,而(1, 1)点对应了右上角。我们据此来判断该顶点对应的索引,这个对应关系和我们在脚本中对 frustumCorners 的赋值顺序是一致的。实际上,不同平台的纹理坐标不一定是满足上面的条件的,例如DirectX 和 Metal 这样的平台,左上角对应了(0, 0)点,但大多数情况下Unity 会把这些平台下的屏幕图像进行翻转,因此我们仍然可以利用这个条件。但如果在类似DirectX 的平台上开启了抗锯齿, Unity就不会进行这个翻转。为了此时仍然可以得到相应顶点位置的索引值,我们对索引值也进行了平台差异化处理(详见5.6.1 节〉,以便在必要时也对索引值进行翻转。最后,我们使用索引值来获取 _FrustumCornersRay 中对应的行作为该顶点的interpolatedRay 值。
尽管我们这里使用了很多判断语句,但由于屏幕后处理所用的模型是一个四边形网格,只包含4 个顶点,因此这些操作不会对性能造成很大影响。
(5)我们定义了片元着色器来产生雾效:

  
    
    
    
    
  1. fixed4 frag(v2f i) : SV_Target {
  2. float linearDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth));
  3. float3 worldPos = _WorldSpaceCameraPos + linearDepth * i.interpolatedRay.xyz;
  4. float fogDensity = (_FogEnd - worldPos.y) / (_FogEnd - _FogStart);
  5. fogDensity = saturate(fogDensity * _FogDensity);
  6. fixed4 finalColor = tex2D(_MainTex, i.uv);
  7. finalColor.rgb = lerp(finalColor.rgb, _FogColor.rgb, fogDensity);
  8. return finalColor;
  9. }
首先,我们需要重建该像素在世界空间中的位置。为此,我们首先使用SAMPLE_DEPTH_TEXTURE对深度纹理进行采样,再使用LinearEyeDepth 得到视角空间下的线性深度值。之后,与interpolatedRay 相乘后再和世界空间下的摄像机位置相加,即可得到世界空间下的位置。
得到世界坐标后,模拟雾效就变得非常容易。在本例中,我们选择实现基于高度的雾效模拟,计算公式可参见13.3.2 节。我们根据材质属性 _FogEnd 和 _FogStart 计算当前的像素高度 worldPos.y 对应的雾效系数 fogDensity,再和参数 _FogDensity 相乘后,利用saturate 函数截取到[0, 1]范围内,作为最后的雾效系数。然后,我们使用该系数将雾的颜色和原始颜色进行、混合后返回。读者也可以使用不同的公式来实现其他种类的雾效。
(6)随后,我们定义了雾效渲染所需的Pass :

  
    
    
    
    
  1. Pass {
  2. ZTest Always Cull Off ZWrite Off
  3. CGPROGRAM
  4. #pragma vertex vert
  5. #pragma fragment frag
  6. ENDCG
  7. }
(7 )最后,我们关闭了Shader 的Fallback:
	FallBack Off
  
    
    
    
    
完成后返回编辑器,并把Chapter13-FogWithDepthTexture 拖曳到摄像机的FogWithDepthTexture.cs脚本中的fogShader 参数中。当然,我们可以在FogWithDepthTexture.cs 的脚本面板中将fogShader 参数的默认值设置为Chapter13-FogWithDepthTexture,这样就不需要以后使用时每次都手动拖曳了。
本节介绍的使用深度纹理重建像素的世界坐标的方法是非常有用的。但需要注意的是,这里的实现是基于摄像机的投影类型是透视投影的前提下。如果需要在正交投影的情况下重建世界坐标,需要使用不同的公式,但请读者相信,这个过程不会比透视投影的情况更加复杂。有兴趣的读者可以尝试自行推导,或参考这篇博客
( http://www.derschmale.com/2014/03/19/reconstructing-positions-from-the-depth-buffer-pt-2-perspective-and-orthographic-general-case/)来实现。

13.4 再谈边缘检测

在12.3 节中,我们曾介绍如何使用Sobel 算子对屏幕图像进行边缘检测, 实现描边的效果。但是,这种直接利用颜色信息进行边缘检测的方法会产生很多我们不希望得到的边缘线,如图13.8 所示。
可以看出,物体的纹理、阴影等位置也被描上黑边,而这往往不是我们希望看到的。在本节中,我们将学习如何在深度和法线纹理上进行边缘检测,这些图像不会受纹理和光照的影响,而仅仅保存了当前渲染物体的模型信息,通过这样的方式检测出来的边缘更加可靠。在学习完本节后,我们可以得到类似图13.9 中的效果。

与12.3 节使用Sobel 算子不同,本节将使用Roberts 算子来进行边缘检测。它使用的卷积核如图13.10 所示。
Roberts 算子的本质就是计算左上角和右下角的差值,乘以右上角和左下角的差值,作为评估边缘的依据。在下面的实现中,我们也会按这样的方式,取对角方向的深度或法线值,比较它们之间的差值,如果超过某个阀值(可由参数控制),就认为它们之间存在一条边。
首先,我们需要进行如下准备工作。
( 1)新建一个场景。在本书资源中,该场景名为Scene_13_4 。在Unity 5.2 中,默认情况下场景将包含一个摄像机和一个平行光,并且使用了内置的天空盒子。在Window→ Lighting→ Skybox 中去掉场景中的天空盒子。
( 2 )我们需要搭建一个测试雾效的场景。在本书资源的实现中,我们构建了一个包含3 面墙的房间,并放置了两个立方体和两个球体,它们都使用了我们在9.5 节中创建的标准材质。同时,我们把本书资源中的Translating.cs 脚本拖曳给摄像机,让其在场景中不断运动。
( 3)新建一个脚本。在本书资源中,该脚本名为EdgeDetectNormalsAndDepth.cs。把该脚本拖曳到摄像机上。
( 4) 新建一个Unity Shader。在本书资源中,该Shader 名为Chapter13-EdgeDetectNormalAndDepth。
我们首先来编写EdgeDetecNormalsAndDepth.cs 脚本。该脚本与12.3 节中实现的EdgeDetection.cs脚本几乎完全一样,只是添加了一些新的属性。为了完整性,我们再次说明对该脚本进行的修改。
(1)首先,继承12.1 节中创建的基类:
	public class EdgeDetectNormalsAndDepth : PostEffectsBase {
  
    
    
    
    
( 2 ) 声明该效果需要的Shader , 并据此创建相应的材质:

  
    
    
    
    
  1. public Shader edgeDetectShader;
  2. private Material edgeDetectMaterial = null;
  3. public Material material {
  4. get {
  5. edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
  6. return edgeDetectMaterial;
  7. }
  8. }
(3 )在脚本中提供了调整边缘线强度描边颜色以及背景颜色的参数。同时添加了控制采样距离以及对深度和法线进行边缘检测时的灵敏度参数:

  
    
    
    
    
  1. [Range( 0.0f, 1.0f)]
  2. public float edgesOnly = 0.0f;
  3. public Color edgeColor = Color.black;
  4. public Color backgroundColor = Color.white;
  5. public float sampleDistance = 1.0f;
  6. public float sensitivityDepth = 1.0f;
  7. public float sensitivityNormals = 1.0f;
sampleDistance 用于控制对深度+法线纹理采样时,使用的采样距离。从视觉上来看,sampleDistance 值越大,描边越宽。sensitivityDepth 和sensitivityNormals 将会影响当邻域的深度值或法线值相差多少时,会被认为存在一条边界。如果把灵敏度调得很大,那么可能即使是深度或法线上很小的变化也会形成一条边。
( 4 )由于本例需要获取摄像机的深度+法线纹理,我们在脚本的OnEnable 函数中设置摄像机的相应状态:

  
    
    
    
    
  1. void OnEnable() {
  2. GetComponent().depthTextureMode |= DepthTextureMode.DepthNormals;
  3. }
( 5 ) 实现OnRenderlmage 函数,把各个参数传递给材质:

  
    
    
    
    
  1. [ImageEffectOpaque]
  2. void OnRenderImage (RenderTexture src, RenderTexture dest) {
  3. if (material != null) {
  4. material.SetFloat( "_EdgeOnly", edgesOnly);
  5. material.SetColor( "_EdgeColor", edgeColor);
  6. material.SetColor( "_BackgroundColor", backgroundColor);
  7. material.SetFloat( "_SampleDistance", sampleDistance);
  8. material.SetVector( "_Sensitivity", new Vector4(sensitivityNormals, sensitivityDepth, 0.0f, 0.0f));
  9. Graphics.Blit(src, dest, material);
  10. } else {
  11. Graphics.Blit(src, dest);
  12. }
  13. }
需要注意的是,这里我们为OnRenderlmage 函数添加了[ImageEffectOpaque]属性。我们曾在12.1节中提到过该属性的含义。在默认情况下,OnRenderlmage 函数会在所有的不透明和透明的Pass 执行完毕后被调用,以便对场最中所有游戏对象都产生影响。但有时,我们希望在不透明的Pass (即渲染队列小于等于2 500 的Pass,内置的Background、Geometry 和AlphaTest 渲染队列均在此范围内)执行完毕后立即调用该函数,而不对透明物体(渲染队列为Transparent 的Pass )产生影响,此时,我们可以在OnRenderlmage 函数前添加ImageEffectOpaque 属性来实现这样的目的。在本例中,我们只希望对不透明物体迸行描边,而不希望透明物体也被描边, 因此需要添加该属性。
下面,我们来实现Shader 的部分。打开Chapter13-EdgeDetectNormalAndDep血,进行如下修改。
(1)我们首先需要声明本例使用的各个属性:

  
    
    
    
    
  1. Properties {
  2. _MainTex ( "Base (RGB)", 2D) = "white" {}
  3. _EdgeOnly ( "Edge Only", Float) = 1.0
  4. _EdgeColor ( "Edge Color", Color) = ( 0, 0, 0, 1)
  5. _BackgroundColor ( "Background Color", Color) = ( 1, 1, 1, 1)
  6. _SampleDistance ( "Sample Distance", Float) = 1.0
  7. _Sensitivity ( "Sensitivity", Vector) = ( 1, 1, 1, 1)
  8. }
其中,_Sensitivity 的xy 分量分别对应了法线和深度的检测灵敏度, zw 分量则没有实际用途。
( 2 )在本节中,我们使用CGINCLUDE 来组织代码。我们在SubShader 块中利用CGINCLUDE 和 ENDCG 语义来定义一系列代码:

  
    
    
    
    
  1. SubShader {
  2. CG INCLUDE
  3. ...
  4. ENDCG
  5. ...
(3)为了在代码中访问各个属性,我们需要在CG 代码块中声明对应的变量:

  
    
    
    
    
  1. sampler2D _MainTex;
  2. half4 _MainTex_TexelSize;
  3. fixed _EdgeOnly;
  4. fixed4 _EdgeColor;
  5. fixed4 _BackgroundColor;
  6. float _SampleDistance;
  7. half4 _Sensitivity;
  8. sampler2D _CameraDepthNormalsTexture;
在上面的代码中,我们声明了需要获取的深度+法线纹理 _CameraDepthNormalsTexture。由于我们需要对邻域像素进行纹理采样,所以还声明了存储纹素大小的变量 _MainTex_TexelSize 。
( 4 )定义顶点着色器:

  
    
    
    
    
  1. struct v2f {
  2. float4 pos : SV_POSITION;
  3. half2 uv[ 5]: TEXCOORD0;
  4. };
  5. v2f vert(appdata_img v) {
  6. v2f o;
  7. o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
  8. half2 uv = v.texcoord;
  9. o.uv[ 0] = uv;
  10. #if UNITY_UV_STARTS_AT_TOP
  11. if (_MainTex_TexelSize.y < 0)
  12. uv.y = 1 - uv.y;
  13. #endif
  14. o.uv[ 1] = uv + _MainTex_TexelSize.xy * half2( 1, 1) * _SampleDistance;
  15. o.uv[ 2] = uv + _MainTex_TexelSize.xy * half2( -1, -1) * _SampleDistance;
  16. o.uv[ 3] = uv + _MainTex_TexelSize.xy * half2( -1, 1) * _SampleDistance;
  17. o.uv[ 4] = uv + _MainTex_TexelSize.xy * half2( 1, -1) * _SampleDistance;
  18. return o;
  19. }
我们在v2f 结构体中定义了一个维数为5 的纹理坐标数组。这个数组的第一个坐标存储了屏幕颜色图像的采样纹理。我们对深度纹理的采样坐标进行了平台差异化处理, 在必要情况下对它的竖直方向进行了翻转。数组中剩余的 4 个坐标则存储了使用Roberts 算子时需要采样的纹理坐标, 我们还使用了 _SampleDistance 来控制采样距离。通过把计算采样纹理坐标的代码从片元着色器中转移到顶点着色器中, 可以减少运算, 提高性能。由于从顶点着色器到片元着色器的插值是线性的, 因此这样的转移并不会影响纹理坐标的计算结果。
( 5 )然后,我们定义了片元着色器:

  
    
    
    
    
  1. fixed4 fragRobertsCrossDepthAndNormal(v2f i) : SV_Target {
  2. half4 sample1 = tex2D(_CameraDepthNormalsTexture, i.uv[ 1]);
  3. half4 sample2 = tex2D(_CameraDepthNormalsTexture, i.uv[ 2]);
  4. half4 sample3 = tex2D(_CameraDepthNormalsTexture, i.uv[ 3]);
  5. half4 sample4 = tex2D(_CameraDepthNormalsTexture, i.uv[ 4]);
  6. half edge = 1.0;
  7. edge *= CheckSame(sample1, sample2);
  8. edge *= CheckSame(sample3, sample4);
  9. fixed4 withEdgeColor = lerp(_EdgeColor, tex2D(_MainTex, i.uv[ 0]), edge);
  10. fixed4 onlyEdgeColor = lerp(_EdgeColor, _BackgroundColor, edge);
  11. return lerp(withEdgeColor, onlyEdgeColor, _EdgeOnly);
  12. }
我们首先使用4 个纹理坐标对深度+法线纹理进行采样,再调用CheckSame 函数来分别计算对角线上两个纹理值的差值。CheckSame 函数的返回值要么是0,要么是1,返回0 时表明这两点之间存在一条边界,反之则返回1 。它的定义如下:

  
    
    
    
    
  1. half CheckSame(half4 center, half4 sample) {
  2. half2 centerNormal = center.xy;
  3. float centerDepth = DecodeFloatRG(center.zw);
  4. half2 sampleNormal = sample.xy;
  5. float sampleDepth = DecodeFloatRG(sample.zw);
  6. // difference in normals
  7. // do not bother decoding normals - there's no need here
  8. half2 diffNormal = abs(centerNormal - sampleNormal) * _Sensitivity.x;
  9. int isSameNormal = (diffNormal.x + diffNormal.y) < 0.1;
  10. // difference in depth
  11. float diffDepth = abs(centerDepth - sampleDepth) * _Sensitivity.y;
  12. // scale the required threshold by the distance
  13. int isSameDepth = diffDepth < 0.1 * centerDepth;
  14. // return:
  15. // 1 - if normals and depth are similar enough
  16. // 0 - otherwise
  17. return isSameNormal * isSameDepth ? 1.0 : 0.0;
  18. }
CheckSame 首先对输入参数进行处理,得到两个采样点的法线和深度值。值得注意的是,这里我们并没有解码得到真正的法线值,而是直接使用了 xy 分量。这是因为我们只需要比较两个采样值之间的差异度,而并不需要知道它们真正的法线值。然后,我们把两个采样点的对应值相减并取绝对值,再乘以灵敏度参数,把差异值的每个分量相加再和一个阀值比较,如果它们的和小于阀值,则返回1,说明差异不明显,不存在一条边界;否则返回0。最后,我们把法线和深度的检查结果相乘,作为组合后的返回值。
当通过CheckSame 函数得到边缘信息后,片元着色器就利用该值进行颜色混合,这和12.3节中的步骤一致。
(6)然后,我们定义了边缘检测需要使用的Pass:

  
    
    
    
    
  1. Pass {
  2. ZTest Always Cull Off ZWrite Off
  3. CGPROGRAM
  4. #pragma vertex vert
  5. #pragma fragment fragRobertsCrossDepthAndNormal
  6. ENDCG
  7. }
(7)最后,我们关闭了该Shader 的Fallback:
	FallBack Off
  
    
    
    
    
完成后返回编辑器,并把Chapter13-EdgeDetectNormalAndDepth 拖曳到摄像机的EdgeDetectNormalsAndDepth.cs 脚本中的edgeDetectShader 参数中。当然,我们可以在EdgeDetectlNormaIsAndDepth.cs 的脚本面板中将edgeDetectShader 参数的默认值设置为Chapter13-EdgeDetectNormaIAndDepth,这样就不需要以后使用时每次都手动拖曳了。
本节实现的描边效果是基于整个屏幕空间进行的,也就是说,场景内的所有物体都会被添加描边效果。但有时,我们希望只对特定的物体进行描边,例如当玩家选中场景中的某个物体后,我们想要在该物体周围添加一层描边效果。这时,我们可以使用Unity 提供的Graphics.DrawMesh 或 Graphics.DrawMeshNow 函数把需要描边的物体再次渲染一遍(在所有不透明物体渲染完毕之后),然
后再使用本节提到的边缘检测算法计算深度或法线纹理中每个像素的梯度值,判断它们是否小于某个阀值,如果是,就在Shader 中使用clip() 函数将该像素剔除掉,从而显示出原来的物体颜色。

13.5 扩展阅读

在本章中,我们介绍了如何使用深度和法线纹理实现诸如全局雾效、边缘检测等效果。尽管我们只使用了深度和法线纹理,但实际上我们可以在Unity 中创建任何需要的缓存纹理。这可以通过使用Unity 的着色器替换( Shader Replacement )功能(即调用Camera.RenderWithShader(shader, replacementTag)函数)把整个场景再次渲染一遍来得到,而在很多时候,这实际也是Unity 创建深度和法线纹理时使用的方法。
深度和法线纹理在屏幕特效的实现中往往扮演了重要的角色。许多特殊的屏幕效果都需要依靠这两种纹理的帮助。Unity 曾在2011 年的SIGGRAPH (计算图形学的顶级会议〉上做了一个关于使用深度纹理实现各种特效的演讲
(http://blogs.unity3d.com/2011/09/08/special-effects-with-depth-talk-at-siggraph/ )。在这个演讲中, Unity 的工作人员解释了如何利用深度纹理来实现特定物体的描边、角色护盾、相交线的高光模拟等效果。在Unity 的 Image Effect
 ( http://docs.unity3d.com/Manual/comp-ImageEffects.html )包中,读者也可以找到一些传统的使用深度纹理实现屏幕特效的例子,例如屏幕空间的环境遮挡(Screen Space Ambient Occlusion, SSAO )等效果。



































你可能感兴趣的:(Unity,Shader入门精要(,冯乐乐))