使用深度和法线纹理
获取深度和法线纹理
背后原理
深度纹理实际就是一张渲染纹理,只不过它里面存储的像素值不是颜色值而是一个高精度的深度值。由于被存储在一张纹理中,深度纹理的深度值范围是[0,1],而且通常是非线性分布的。
这些深度值是从哪里得到的呢?
总体来说,这些深度值来自于顶点变换后得到的归一化的设备坐标(Normalized Device Coordinates,NDC)。回顾一下,一个模型要想最终被绘制在屏幕上,需要把它的顶点从模型空间变换到变换到齐次裁剪坐标系下,这是通过在顶点着色器中乘以MVP变换矩阵得到的。在变换的最后一步,我们需要使用一个投影矩阵来变换顶点,当我们使用的是透视投影类型的摄像机时,这个投影矩阵就是非线性的。
下图显示了Unity中透视投影对顶点变换的过程:
图中显示了投影变换前,即观察空间下视椎体的结构及相应的顶点位置,中间的图显示了应用透视裁剪矩阵后的变换结果,即顶点着色器阶段输出的顶点变换结果,最右侧的图则是底层硬件进行了透视除法后得到的归一化的设备坐标。
需要注意的是,这里的投影过程是建立在Unity对坐标系的假定上的,也就是说,我们针对的是观察空间为右手坐标系,使用列矩阵在矩阵右侧进行相乘,且变换到NDC后z分量范围在[-1,1]之间的情况。而且类似DirectX这样的图形接口中,变换后z分量范围将在[0,1]之间。如果需要在其它图形接口下实现本章类似的效果,需要对一些计算参数作出相应的变化。
上图显示了在使用正交摄像机时投影变换的过程。同样,变换后会得到一个范围为[-1,1]的立方体。正交投影使用的变换矩阵是线性的。
在得到NDC后,深度纹理中的像素值就可以很方便的计算得到了,这些深度值就对应了NDC中顶点坐标的z分量的值。由于NDC中z分量的范围在[-1,1],为了让这些值能够存储在一张图像中,我们需要使用下面的公式对其进行映射:
d = 0.5 · znad + 0.5
其中,d对应了深度纹理中的像素值,Zndc对应了NDC坐标中的z分量的值。
那么Unity是怎么得到这样一张深度纹理的呢?
在Unity中,深度纹理可以直接来自于真正的深度缓存,也可以是由一个单独的Pass渲染而得,这取决于使用的渲染路径和硬件。
- 通常来讲,当使用延迟渲染路径(包括遗留的延迟渲染路径时),深度纹理理所当然可以访问到,因为延迟渲染会把这些信息渲染到G-buffer中。
- 当无法直接获取深度缓存时,深度和法线纹理是通过一个单独的Pass渲染而得的。具体实现是,Unity会使用着色器替换(Shader Replacement)技术选择那些渲染类型(即SubShader的RenderType标签)为Opaque的物体,判断它们使用的渲染队列是否小于等于2500(内置的Background、Geometry和AlphaTest渲染队列均在此范围内),如果满足条件,就把它渲染到深度和法线纹理中。因此,要想让物体能够出现在深度和法线纹理中,就必须在Shader中设置正确的RenderType标签。
在Unity中,我们可以选择让一个摄像机生成一张深度纹理或是一张深度纹理+法线纹理。
-
当选择前者,即只需要一张单独的深度纹理时,Unity会直接获取深度缓存或是按之前讲到的着色器替换技术,选取需要的不透明物体,并使用它投射阴影时使用的Pass(即LightMode被设置为ShadowCaster的Pass)来得到深度纹理。
如果Shader中不包含这样一个Pass,那么这个物体就不会出现在深度纹理中(当然,它也不能向其它物体投射阴影)。深度纹理的精度通常是24位或16位,这取决于深度缓存的精度。
-
如果选择生成一张深度+法线纹理,Unity会创建一张和屏幕分辨率相同,精度为32位(每个通道为8位)的纹理,其中观察空间下的法线信息会被编码进纹理的R和G通道,而深度信息会被编码进B和A通道。
- 法线信息的获取在延迟渲染中是可以非常容易得到的,Unity只需要合并深度和法线缓存即可。
- 而在前向渲染中,默认情况下是不会创建法线缓存的,因此Unity底层使用了一个单独的Pass把整个场景再次渲染一遍来完成。
如何获取
获取纹理
我们可以通过下面的代码来获取深度纹理:
camera.depthTextureMode=DepthTextureMode.Depth;
一旦设置好了上面的摄像机模式后,我们就可以在Shader中通过声明_CameraDepthTexture变量来访问它。这个过程非常简单,但我们需要知道这两行代码的背后,Unity为我们做了很多工作。(上一节背后的原理)
同理,如果想要获取深度+法线纹理,我们只需要在代码中这样设置:
camera.depthTextureMode=DepthTextureMode.DepthNormals;
我们还可以组合这些模式,让一个摄像机同时产生一张深度和深度+法线纹理:
camera.depthTextureMode|=DepthTextureMode.Depth;
camera.depthTextureMode|=DepthTextureMode.DepthNormals;
采样
在Unity5中,我们还可以在摄像机的Camera组件上看到当前摄像机是否需要渲染深度或深度+法线纹理。当在Shader中访问到深度纹理_CameraDepthTexture后,我们就可以使用当前像素的纹理坐标对它进行采样。绝大多数情况下,我们使用tex2D函数采样即可,但在某些平台(例如PS3和PS2)上,我们需要一些特殊处理。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.srcPos ));
其中,i.srcPos是在顶点着色器中通过调用ComputeScreenPos(o.pos)得到的屏幕坐标。
上述这些宏的定义,读者可以在Unity内置的HLSLSupport.cginc文件中找到。
线性转换
当通过纹理采样得到深度值后,这些深度值往往是非线性的,这种非线性来自于投射投影使用的裁剪矩阵。然而,我们在计算过程中通常是需要线性的深度值,也就是说,我们需要把投影后的深度值变换到线性空间下,例如视角空间下的深度值。那么,我们应该如何进行这个转换呢?
幸运的是,Unity提供了两个辅助函数来为我们进行上述的计算过程——LinearEyeDepth和Linear01Depth。LinerEyeDepth负责把深度纹理的采样结果转换到视角空间下的深度值。而Linear01Depth则会返回一个范围在[0,1]的线性深度值。这两个函数内部使用了内置的_ZBufferParams变量来得到远近裁剪平面的距离。
如果我们需要获取深度+法线纹理,则可以直接使用tex2D函数对CameraDepthNormalsTexture进行采样,得到里面存储的深度和法线信息。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分量。通过调用DecodeDepthNormal函数对采样结果解码后,我们就可以得到解码后的深度值和法线。这个深度值是范围在[0,1]的线性深度值(这与单独的深度纹理中存储的深度值不同),而得到的法线则是视角空间下的法线方向。同样,我们也可以通过调用DecodeFloatRG和DecodeViewNormalStereo来解码深度+法线纹理中的深度和法线信息。
再谈运动模糊
上一章节,我们学习了如何通过混合多张屏幕图像来模拟运动模糊的效果。但是,另一种应用更加广泛的技术则是使用速度映射图。速度映射图中存储了每个像素的速度,然后使用这个速度来决定模糊的方向和大小。速度缓冲的生成有多种方法:
- 一种方法是把场景中所有物体的速度渲染到一张纹理中。但这种方法的缺点在于需要修改场景中所有物体的Shader 代码,使其添加计算速度的代码并输出到一个渲染纹理中。
- 另一种方法利用深度纹理在片元着色器中为每个像素计算其在世界空间下的位置,这是通过使用当前的视角*投影矩阵的逆矩阵对NDC下的顶点坐标进行变换得到的。当得到世界空间中的顶点坐标后,我们使用前一帧的视角*投影矩阵对其进行变换,得到该位置在前一帧中的NDC坐标。然后,我们计算前一帧和当前帧的位置差,生成该像素的速度。这种方法的优点是可以在一个屏幕后处理步骤中完成整个效果的模拟,但缺点是需要在片元着色器中进行两次矩阵乘法的操作,对性能有所影响。
实现
- 新建一个脚本,名为MotionBlurWithDepthTexture.cs添加到摄像机上。
- 在脚本中做以下几点修改:
- 定义一个变量来保存上一帧摄像机的视角*投影矩阵:
private Matrix4x4 previousViewProjectionMatrix;
- 在OnRenderImage函数中先需要计算和传递运动模糊中使用的各个属性。本例需要使用两个变换矩阵——前一帧的视角投影矩阵以及当前帧的视角投影矩阵的逆矩阵。因此,我们通过调用
camera.worldToCameraMatrix
和camera.projectionMatrix
来分别得到当前摄像机的视角矩阵和投影矩阵。对它们相乘后取逆,得到当前帧的视角投影矩阵的逆矩阵*,并传递给材质。然后,我们把取逆前的结果存储在previousViewProjectionMatrix
变量中,以便在下一帧时传递给材质的_PreviousViewProjectionMatrix
属性。
- 定义一个变量来保存上一帧摄像机的视角*投影矩阵:
- 新建UnityShader。Shader的重点在片元着色器中。
- 首先我们通过SAMPLE_DEPTH_TEXTURE宏对深度纹理进行采样,获得该像素的得到了深度值d
- 由前面可知d是由NDC下的坐标映射而来的。我们想要构建像素的NDC坐标H,就需要把这个深度值重新映射回NDC。这个映射很简单,只需要使用原映射的反函数即可,即d2-1。同样NDC的xy分量可以由像素的纹理坐标映射而来(NDC下的xyz分量范围均为[-1,1])。
- 当得到NDC下的坐标H后,我们就可以使用当前帧的视角投影矩阵的逆矩阵对其进行变换,并把结果值除以它的w分量来得到世界空间下的坐标worldPos。
- 一旦得到世界空间下的坐标,我们就可以使用前一帧的视角投影矩阵对它进行变换,得到前一帧在NDC下的坐标previousPos。
- 然后,我们计算前一帧和当前帧在屏幕空间下的位置差,得到该像素的速度velocity。
- 当得到该像素的速度后,我们就可以使用该速度值对它的邻域像素进行采样,相加后取平均值得到一个模糊的效果。采样时我们还是用了_BlurSize来控制采样距离。
具体代码实现见下文
全局雾效
雾效(Fog)是游戏里常见的一种效果。Unity内置的雾效可以产生基于距离的线性或指数雾效。
Unity内置雾效
想要在自己编写的顶点/片元着色器中实现这些雾效,我们需要在Shader中添加#pragma multi_compile_fog指令,同时还要使用相关内置宏,例如UNITY_FOG_COORDS、UNITY_TRANSFER_FOG和UNITY_APPLY_FOG等。
这种方法的缺点在于,我们不仅需要为场景中的所有物体添加相关的渲染代码,而且能够实现的效果也非常有限。当我们需要对雾效进行一些个性化操作时,例如基于高度的雾效等,仅仅使用Unity内置的雾效就变得不再可行。
屏幕后处理全局雾效
在本节中,我们将会学习一种基于屏幕后处理的全局雾效的实现。使用这种方法,我们不需要更改场景内渲染物体所使用的Shader代码,而仅仅依靠一次屏幕后处理的步骤即可。这种方法的自由行很高,我们可以方便的模拟各种雾效。例如均匀的雾效、基于距离的线性/指数雾效、基于高度的雾效等。在学习完本节后,我们可以得到类似下图的效果。
基于屏幕后处理的全局雾效的关键是,根据深度纹理来重建每个像素在世界空间下的位置。
尽管在前面,我们在模拟运动模糊时已经实现了这个要求,即构建出当前像素的NDC坐标,再通过当前摄像机的视角投影矩阵的逆矩阵来得到世界空间下的像素坐标,但是这样的实现需要在片元着色器中进行矩阵乘法的操作,而这通常会影响游戏性能。
在本节中,我们将会学习一个快速从深度纹理中重建世界坐标的方法。这种方法首先对图像空间下的视椎体射线(从摄像机出发,指向图像上某点的射线)进行插值,这条射线存储了该像素在世界空间下到摄像机的方向信息。然后,我们把该射线和线性化后的视角空间下的深度值相乘,再加上摄像机的世界位置,就可以得到该像素在世界空间下的位置。当我们得到世界坐标后,就可以轻松地使用各个公式来模拟全局雾效了。
重建世界坐标
在开始动手写代码之前,我们首先来了解如何从深度纹理中重建世界坐标。我们知道,坐标系中的一个顶点坐标可以通过它相对于另一个顶点坐标的偏移量来求得。重建像素的世界坐标也是基于这样的思想,我们只需要知道摄像机在世界空间下的位置,以及世界空间下该像素相对于摄像机的偏移量,把它们相加就可以得到该像素的世界坐标。整个过程可以使用下面的代码来表示:
float4 worldPos=_WorldSpaceCameraPos+linearDepth*interpolatedRay;
_WorldSpaceCameraPos是摄像机在世界空间下的位置,这可以由Unity的内置变量直接访问得到。
-
linearDepth*interpolatedRay则可以计算得到该像素相对于摄像机的偏移量
- linearDepth是由深度纹理得到的线性深度值,
- interpolatedRay是由顶点着色器输出并插值后得到的射线,它不仅包含了该像素到摄像机的方向,也包含了距离信息。
linearDepth的获取我们已经在前面详细解释过了,本节着重解释interpolatedRay的求法。
interpolatedRay来源于对近裁剪平面的4个角的某个特定向量的插值,这4个向量包含了它们到摄像机的方向和距离信息,我们可以利用摄像机的近裁剪平面距离、FOV、横纵比计算而得。下图显示了计算时使用的一些辅助向量。
为了方便计算,我们可以先计算两个向量——toTop和toRight,它们是起点位于近裁剪面中心、分别指向摄像机正上方和正右方的向量。它们的计算公式如下:
其中,Near是近裁剪平面的距离,FOV是竖直方向的视角范围,camera.up、camera.right分别对应了摄像机的正上方和正右方。
当得到这两个辅助向量后,我们就可以计算四个角相对于摄像机的方向了。我们以左上角为例(见上图中的TL点),它的计算公式如下:
读者可以依靠基本的矢量运算验证上面的结果。同理,其它3个角的计算也是类似的:
注意,上面求得的4个向量不仅包含了方向信息,它们的模对应了4个点到摄像机的空间距离。由于我们得到的线性深度值并非是点到摄像机的欧氏距离,而是在z方向的距离。因此我们不能直接使用深度值和4个角的单位方向的乘积来计算它们到摄像机的偏移量,如下图所示:
想要把深度值转换成到摄像机的欧氏距离也很简单,我们以TL点为例,根据相似三角形原理,TL所在的射线上,像素的深度值和它到摄像机的实际距离的比等于近裁剪平面的距离和TL向量的模的比,即:
由此可得,我们需要的TL距离摄像机的欧式距离dist:
由于4个点相互对称,因此其他3个向量的模和TL相等,即我们可以使用同一个因子和单位向量相乘,得到它们对应的向量值:
屏幕后处理的原理是使用特定的材质去渲染一个刚好填充整个屏幕的四边形面片。这个四边形面片的4个顶点就对应了近裁剪平面的4个角。因此,我们可以把上面的计算结果传递给顶点着色器,顶点着色器根据当前的位置选择它所对应的的向量,然后再将其输出,经插值后传递给片元着色器得到interpolatedRay,我们就可以直接利用本节一开始提到的公式重建该像素在世界空间下的位置了。
雾的计算
在简单的雾效实现中,我们需要计算一个雾效系数f,作为混合原始颜色和雾的颜色的混合系数:
float3 afterFog = f * fogColor + ( 1-f ) * origColor;
这个雾效系数f有很多计算方法。在Unity内置的雾效实现中,支持3种雾的计算方式——线性(Linear)、指数(Exponential)以及指数的平方(Exponential Square的)。当给定距离z后,f的计算公式分别如下:
Linear:
dmin和dmax分别表示受雾影响的最小距离和最大距离。
Exponential:
d是控制雾的浓度的参数。
Exponential Squared:
d是控制雾的浓度的参数。
在本节中,我们将使用类似线性雾的计算方式,计算基于高度的雾效。具体方法是,当给定一点在世界空间下的高度y后,f的计算公式为:
Hstart和Hend分别表示受雾影响的起始高度和终止高度。
实现
新建一个脚本,名为FogWithDepthTexture.cs添加到摄像机上。
-
在脚本中做以下几点修改:
- 在本节中,我们需要获取摄像机的相关参数,例如近裁剪平面的距离、FOV等,同时还需要获取摄像机在世界空间下的前方、上方和右方等方向,因此我们用两个变量存储摄像机的Camera和Transform组件
- 定义模拟雾效时使用的各个参数:ogDensity用于控制雾的浓度,fogColor用于控制雾的颜色。我们使用的雾效模拟函数是基于高度的,因此fogStart用于控制雾效的起始高度,fogEnd用于控制雾效的终止高度。
- 最后,我们实现了OnRenderImage函数:OnRenderImage首先计算了近裁剪平面的4个角对应的向量,并把它们存储在一个矩阵类型的变量(frustumCorners)中。计算过程我们在前面已经解释过了,代码只是套用之前讲过的公式而已。我们按一定顺序把这4个方向存储到了frustumCorners不同的行中,这个顺序是非常重要的,因为这决定了我们在顶点着色器中使用哪一行作为该点的待插值向量。随后我们把结果和其它参数传递给材质,并调用Graphics.Blit (src, dest, material)把渲染结果显示到屏幕上。
-
新建UnityShader。Shader的重点在片元着色器中。
声明代码中需要使用的各个变量:_FrustumCornersRay虽然没有在Properties中声明,但仍可以由脚本传递给Shader。除了Properties中声明的各个属性,我们还声明了_CameraDepthTexture,Unity会在背后把得到的深度纹理传递给该值。
在v2f结构体中,我们除了定义顶点位置、屏幕图像和深度纹理的纹理坐标外,还定义了interpolatedRay变量存储插值后的像素向量。
-
在顶点着色器中,我们对深度纹理的采样坐标进行了平台差异化处理。更重要的是,我们要决定该点对应了4个角中的那个角。我们采用的方法是判断它的纹理坐标。
不同平台的纹理坐标不一定是满足上述条件的,例如DirectX和Metal这样的平台,左上角对应了(0,0)点,但大多数情况下,Unity会把这些平台下的屏幕图像进行翻转,因此,我们仍然可以利用这个条件。但如果在类似DirectX的平台上开启了抗锯齿,Unity就不会进行这个翻转。
为了此时仍然可以得到相应顶点位置的索引值,我们对索引值也进行了平台差异化处理,以便在必要时也对索引值进行翻转。最后我们使用索引值来获取_FrustumCornersRay中对应的行作为该顶点的interpolatedRay值。
-
在片元着色器中
- 首先,我们需要重建该像素在世界空间中的位置。为此我们首先使用SAMPLE_DEPTH_TEXTURE对深度纹理进行采样,再使用LinearEyeDepth得到视角空间下的线性深度值。之后与interpolatedRay相乘后再和世界空间下的摄像机位置相加,即可得到世界空间下的位置。
- 得到世界坐标后,模拟雾效就变得非常容易。在本例中,我们选择实现基于高度的雾效模拟。我们根据材质属性_FogEnd和_FogStart计算当前的像素高度worldPo.y对应的雾效系数fogDensity,再和参数_FogDensity相乘后,利用saturate函数截取到[0,1]范围内,作为最后的雾效系数。
- 最后,我们使用该系数将雾的颜色和原始颜色进行混合后返回。
具体代码实现见下文。
本节介绍的使用深度纹理重建像素的世界坐标的方法是非常有用的。但需要注意的是,这里的实现是基于摄像机的投影类型是透视投影的前提下。如果需要在正交投影的情况下重建世界坐标需要使用不同的公式。
再谈边缘检测
在前面,我们曾介绍如何使用Sobel算子对屏幕进行边缘检测,实现描边的效果。但是,这种直接利用颜色信息进行边缘检测的方法会产生很多我们不希望得到的边缘线,如下图所示:
在本节中,我们将学习如何在深度和法线纹理上进行边缘检测,这些图像不会受纹理和光照的影响,而仅仅保存了当前渲染物体的模型信息,通过这种方式检测出来的边缘更加可靠。学习完本节后,我们可以得到类似下图的效果:
与前面使用的Sobel算子不同,本节将使用Roberts算子来进行边缘检测。它使用的卷积核如下图所示:
Roberts算子的本质就是计算左上角和右下角的差值,右上角和左下角的差值,作为评估边缘的依据。在下面的实现中,我们也会按这样的方式,取对角方向的深度或法线值,比较它们之间的差值,如果超过某个阈值(可由参数控制),就认为他们之间存在一条边。
实现
新建一个脚本,名为EdgeDetectNormalsAndDepth.cs添加到摄像机上。
-
在脚本中提供了调整边缘线强度描边颜色以及背景颜色的参数。同时添加了控制采样距离以及对深度和法线进行边缘检测时的灵敏度参数:
- sampleDistance用于控制对深度+法线纹理采样时,使用的采样距离。从视觉上来看,sampleDistance值越大,描边越宽。
- sensitivityDepth 和sensitivityNormals将会影响当邻域的深度值或法线值相差多少时,会被认为存在一条边界。如果把灵敏度跳得很大,那么可能即使是深度或法线上很小的变化也会形成一条边。
这里我们为OnRenderImage函数添加了[ImageEffectOpaque]属性。我们希望在不透明的Pass(即渲染队列小于等于2500的Pass,内置的Background、Geometry和AlphaTest渲染队列均在此范围内)执行完毕后立即调用该函数,而不对透明物体(渲染队列为Transparent的Pass)产生影响,该属性帮助我们实现了这样目的
-
新建UnityShader。在Shader我们做了以下的主要修改。
-
我们在v2f结构体中定义一个维数为5的纹理坐标数组。这个数组的第一个坐标存储了屏幕颜色图像的采样纹理。数组中剩余的4个坐标则存储了使用Roberts算子时需要采样的纹理坐标,我们还使用了_SampleDistance来控制采样距离。
通过计算采样纹理坐标的代码从片元着色器中转移到顶点着色器中,可以减少运算,提高性能。由于从顶点着色器到片元着色器的插值是线性的,因此这样的转移并不会影响纹理坐标的计算结果。
在片元着色器中,我们首先使用4个纹理坐标对深度+法线纹理进行采样,再调用CheckSame函数来分别计算对角线上两个纹理值的差值。当通过CheckSame函数得到边缘信息后,片元着色器就会利用该值进行颜色混合。
-
在CheckSame函数中,首先对输入参数进行处理,得到两个采样点的法线和深度值。值得注意的是,这里我们并没有解码得到真正的法线值,而是直接使用了xy分量。这是因为我们只需要比较两个采样值之间的差异度,而不需要知道他们真正的法线值。
然后,我们把两个采样点的对应值相减并取绝对值,再乘以灵敏度参数,把差异值的每个分量相加再和一个阈值比较,如果他们的和小于阈值,则返回1,说明差异不明显,不存在一条边界;否则,返回0。最后我们把法线和深度的检查结果相乘,作为组合后的返回值。
-
具体代码实现见下文。
特定物体描边
本节实现的描边效果是基于整个屏幕空间进行的,也就是说,场景内的所有物体都会被添加描边效果。但有时,我们希望只对特定的物体进行描边,例如当玩家选中场景中的某个物体后,我们想要在该物体周围添加一层描边效果。这时我们可以使用Unity提供的Graphics.DrawMesh或Graphics.DrawMeshNow函数把需要描边的物体再渲染一遍(在所有不透明物体渲染完毕后),然后再使用本节提到的边缘检测算法计算深度或法线纹理中每个像素的梯度值,判断它们是否小于某个阈值,如果是,就在Shader中使用clip()函数将该像素剔除掉,从而显示出原来物体的颜色。
代码实现
运动模糊
using UnityEngine;
using System.Collections;
public class MotionBlurWithDepthTexture : PostEffectsBase
{
public Shader motionBlurShader;
private Material motionBlurMaterial = null;
public Material material
{
get
{
motionBlurMaterial = CheckShaderAndCreateMaterial(motionBlurShader, motionBlurMaterial);
return motionBlurMaterial;
}
}
private Camera myCamera;
public new Camera camera
{
get
{
if (myCamera == null)
{
myCamera = GetComponent();
}
return myCamera;
}
}
// 模糊大小
[Range(0.0f, 1.0f)]
public float blurSize = 0.5f;
/// 前一帧摄像机视角*投影矩阵
private Matrix4x4 previousViewProjectionMatrix;
void OnEnable()
{
// 设置摄像机的状态 = 产生深度纹理
camera.depthTextureMode |= DepthTextureMode.Depth;
// 当前帧的视角*投影矩阵
previousViewProjectionMatrix = camera.projectionMatrix * camera.worldToCameraMatrix;
}
void OnRenderImage(RenderTexture src, RenderTexture dest)
{
if (material != null)
{
material.SetFloat("_BlurSize", blurSize);
// 传递前一帧的视角*投影矩阵
material.SetMatrix("_PreviousViewProjectionMatrix", previousViewProjectionMatrix);
// 当前帧的视角*投影矩阵、其逆矩阵
Matrix4x4 currentViewProjectionMatrix = camera.projectionMatrix * camera.worldToCameraMatrix;
Matrix4x4 currentViewProjectionInverseMatrix = currentViewProjectionMatrix.inverse;
// 传递当前帧的视角*投影矩阵的逆矩阵
material.SetMatrix("_CurrentViewProjectionInverseMatrix", currentViewProjectionInverseMatrix);
previousViewProjectionMatrix = currentViewProjectionMatrix;
Graphics.Blit(src, dest, material);
}
else
{
Graphics.Blit(src, dest);
}
}
}
Shader "Unity Shaders Book/Chapter 13/Motion Blur With Depth Texture"
{
Properties
{
_MainTex ("Base (RGB)", 2D) = "white" { }
// 模糊大小
_BlurSize ("Blur Size", Float) = 1.0
}
SubShader
{
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _MainTex;
half4 _MainTex_TexelSize;
// 深度纹理
sampler2D _CameraDepthTexture;
// 当前帧的视角*投影矩阵的逆矩阵
float4x4 _CurrentViewProjectionInverseMatrix;
// 前一帧的视角*投影矩阵
float4x4 _PreviousViewProjectionMatrix;
half _BlurSize;
struct v2f
{
float4 pos: SV_POSITION;
half2 uv: TEXCOORD0;
half2 uv_depth: TEXCOORD1;
};
v2f vert(appdata_img v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
o.uv_depth = v.texcoord;
// 检查DirectX平台
#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y < 0)
o.uv_depth.y = 1 - o.uv_depth.y;
#endif
return o;
}
fixed4 frag(v2f i): SV_Target
{
// 获取该像素处的深度缓冲值。使用SAMPLE_DEPTH_TEXTURE宏对深度纹理进行采样
float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);
#if defined(UNITY_REVERSED_Z)
d = 1.0 - d;
#endif
// 计算得到NDC下的坐标H,通过原映射的反函数得到NDC,即d2-1。NDC的xy分量可以由像素的纹理坐标映射而来。
float4 H = float4(i.uv.x * 2 - 1, i.uv.y * 2 - 1, d * 2 - 1, 1);
// 使用当前帧的视角投影矩阵的逆矩阵对其进行变换
float4 D = mul(_CurrentViewProjectionInverseMatrix, H);
// 除以分量w得到世界空间下的位置
float4 worldPos = D / D.w;
// 当前帧在屏幕空间下的位置
float4 currentPos = H;
// 使用世界空间下坐标,并通过上一帧的视角*投影矩阵进行转换。
float4 previousPos = mul(_PreviousViewProjectionMatrix, worldPos);
// 前一帧在屏幕空间下的坐标。
previousPos /= previousPos.w;
// 计算速度 使用该帧的位置和最后一帧的位置来计算像素速度。
float2 velocity = (currentPos.xy - previousPos.xy) / 2.0;
float2 uv = i.uv;
float4 c = tex2D(_MainTex, uv);
uv += velocity * _BlurSize;
// 使用速度值,对该像素邻域像素进行采样叠加,最后取平均值
for (int it = 1; it < 3; it ++, uv += velocity * _BlurSize)
{
float4 currentColor = tex2D(_MainTex, uv);
c += currentColor;
}
c /= 3;
return fixed4(c.rgb, 1.0);
}
ENDCG
Pass
{
ZTest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
ENDCG
}
}
FallBack Off
}
全局雾效
using UnityEngine;
using System.Collections;
public class FogWithDepthTexture : PostEffectsBase
{
public Shader fogShader;
private Material fogMaterial = null;
public Material material
{
get
{
fogMaterial = CheckShaderAndCreateMaterial(fogShader, fogMaterial);
return fogMaterial;
}
}
// 用于存储相机Camera组件
private Camera myCamera;
public new Camera camera
{
get
{
if (myCamera == null)
{
myCamera = GetComponent();
}
return myCamera;
}
}
// 用于存储相机Transform组件
private Transform myCameraTransform;
public Transform cameraTransform
{
get
{
if (myCameraTransform == null)
{
myCameraTransform = camera.transform;
}
return myCameraTransform;
}
}
/// 雾效浓度
[Range(0.0f, 3.0f)]
public float fogDensity = 1.0f;
/// 雾效颜色
public Color fogColor = Color.white;
/// 雾效起始高度
public float fogStart = 0.0f;
/// 雾效终止高度
public float fogEnd = 2.0f;
void OnEnable()
{
// 设置摄像机的状态 = 产生深度纹理
camera.depthTextureMode |= DepthTextureMode.Depth;
}
void OnRenderImage(RenderTexture src, RenderTexture dest)
{
if (material != null)
{
Matrix4x4 frustumCorners = Matrix4x4.identity;
// FOV是相机的竖直方向的视角范围
float fov = camera.fieldOfView;
// Near是相机的近裁剪平面的距离
float near = camera.nearClipPlane;
// Aspect是相机的宽高比(宽度除以高度)
float aspect = camera.aspect;
// 近裁剪面中心
float halfHeight = near * Mathf.Tan(fov * 0.5f * Mathf.Deg2Rad);
// 相机正右方
Vector3 toRight = cameraTransform.right * halfHeight * aspect;
// 相机正上方
Vector3 toTop = cameraTransform.up * halfHeight;
// 左上角相对于相机的方向
Vector3 topLeft = cameraTransform.forward * near + toTop - toRight;
float scale = topLeft.magnitude / near;
topLeft.Normalize();
// 左上角距离摄像机的欧式距离dist
topLeft *= scale;
Vector3 topRight = cameraTransform.forward * near + toRight + toTop;
topRight.Normalize();
topRight *= scale;
Vector3 bottomLeft = cameraTransform.forward * near - toTop - toRight;
bottomLeft.Normalize();
bottomLeft *= scale;
Vector3 bottomRight = cameraTransform.forward * near + toRight - toTop;
bottomRight.Normalize();
bottomRight *= scale;
// 将近裁剪平面的四个角对应的向量存储到矩阵类型的变量中(存入顺序是非常重要的)
frustumCorners.SetRow(0, bottomLeft);
frustumCorners.SetRow(1, bottomRight);
frustumCorners.SetRow(2, topRight);
frustumCorners.SetRow(3, topLeft);
// 将参数写入材质
material.SetMatrix("_FrustumCornersRay", frustumCorners);
material.SetFloat("_FogDensity", fogDensity);
material.SetColor("_FogColor", fogColor);
material.SetFloat("_FogStart", fogStart);
material.SetFloat("_FogEnd", fogEnd);
Graphics.Blit(src, dest, material);
}
else
{
Graphics.Blit(src, dest);
}
}
}
Shader "Unity Shaders Book/Chapter 13/Fog With Depth Texture"
{
Properties
{
_MainTex ("Base (RGB)", 2D) = "white" { }
// 雾效浓度
_FogDensity ("Fog Density", Float) = 1.0
// 雾效颜色
_FogColor ("Fog Color", Color) = (1, 1, 1, 1)
// 雾效起始高度
_FogStart ("Fog Start", Float) = 0.0
// 雾效终止高度
_FogEnd ("Fog End", Float) = 1.0
}
SubShader
{
CGINCLUDE
#include "UnityCG.cginc"
// 近裁剪平面的四个角对应的向量
float4x4 _FrustumCornersRay;
sampler2D _MainTex;
half4 _MainTex_TexelSize;
// 深度纹理,用于Unity背后传递该值
sampler2D _CameraDepthTexture;
half _FogDensity;
fixed4 _FogColor;
float _FogStart;
float _FogEnd;
struct v2f
{
float4 pos: SV_POSITION;
half2 uv: TEXCOORD0;
half2 uv_depth: TEXCOORD1;
// 用于存储插值后的像素向量
float4 interpolatedRay: TEXCOORD2;
};
v2f vert(appdata_img v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
o.uv_depth = v.texcoord;
// 检测DirectX平台
#if UNITY_UV_STARTS_AT_TOP
// 检测Unity是否已自动翻转了坐标
if (_MainTex_TexelSize.y < 0)
o.uv_depth.y = 1 - o.uv_depth.y;
#endif
int index = 0;
if(v.texcoord.x < 0.5 && v.texcoord.y < 0.5)
{
index = 0;
}
else if(v.texcoord.x > 0.5 && v.texcoord.y < 0.5)
{
index = 1;
}
else if(v.texcoord.x > 0.5 && v.texcoord.y > 0.5)
{
index = 2;
}
else
{
index = 3;
}
// 检测DirectX平台
#if UNITY_UV_STARTS_AT_TOP
// 检测Unity是否已自动翻转了坐标
if (_MainTex_TexelSize.y < 0)
index = 3 - index;
#endif
// 使用索引值获取四个顶点变量中对应顶点作为插值后的像素向量
o.interpolatedRay = _FrustumCornersRay[index];
return o;
}
fixed4 frag(v2f i): SV_Target
{
// 视角空间下的线性深度值 LinearEyeDepth:线性转换 SAMPLE_DEPTH_TEXTURE:深度纹理采样
float linearDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth));
// 世界空间下的深度值 _WorldSpaceCameraPos:世界空间下相机位置
float3 worldPos = _WorldSpaceCameraPos + linearDepth * i.interpolatedRay.xyz;
// 高度雾效系数 = (终止高度 - 当前像素高度)/(终止高度 - 起始高度)
float fogDensity = (_FogEnd - worldPos.y) / (_FogEnd - _FogStart);
// 计算浓度 saturate:截取到0-1
fogDensity = saturate(fogDensity * _FogDensity);
// 原始颜色
fixed4 finalColor = tex2D(_MainTex, i.uv);
// 插值原始颜色,与雾效颜色,使用雾效系数作为参数
finalColor.rgb = lerp(finalColor.rgb, _FogColor.rgb, fogDensity);
return finalColor;
}
ENDCG
Pass
{
ZTest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
ENDCG
}
}
FallBack Off
}
边缘检测
using UnityEngine;
using System.Collections;
public class EdgeDetectNormalsAndDepth : PostEffectsBase
{
public Shader edgeDetectShader;
private Material edgeDetectMaterial = null;
public Material material
{
get
{
edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
return edgeDetectMaterial;
}
}
// 描边强度
[Range(0.0f, 1.0f)]
public float edgesOnly = 0.0f;
// 描边颜色
public Color edgeColor = Color.black;
// 背景色
public Color backgroundColor = Color.white;
// 用于控制深度+发现纹理采样时,使用的采样距离。视觉上值越大,描边越宽
public float sampleDistance = 1.0f;
// 用于控制判定描边的深度差,值越大,越容易出现描边
public float sensitivityDepth = 1.0f;
// 用于控制判断描边的法线差,值越大,越容易出现描边
public float sensitivityNormals = 1.0f;
void OnEnable()
{
// 设置相机状态 = 获取深度和发现
GetComponent().depthTextureMode |= DepthTextureMode.DepthNormals;
}
[ImageEffectOpaque]// 声明函数会在所有的不透明的Pass执行完毕后立即调用(避免透明物体的影响)
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);
}
}
}
Shader "Unity Shaders Book/Chapter 13/Edge Detection Normals And Depth"
{
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
// xy对应了法线和深度检测的灵敏度
_Sensitivity ("Sensitivity", Vector) = (1, 1, 1, 1)
}
SubShader
{
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _MainTex;
half4 _MainTex_TexelSize;
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;
float _SampleDistance;
half4 _Sensitivity;
// 用于获取深度和法线的纹理
sampler2D _CameraDepthNormalsTexture;
struct v2f
{
float4 pos: SV_POSITION;
// 第一个坐标存储了屏幕颜色图像的采样纹理
// 后面四个坐标存储了使用Roberts算子时需要采样的纹理坐标。
half2 uv[5]: TEXCOORD0;
};
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;
}
half CheckSame(half4 center, half4 sample)
{
// 获取法线近似值
half2 centerNormal = center.xy;
// DecodeFloatRG:解码RG颜色到float
float centerDepth = DecodeFloatRG(center.zw);
half2 sampleNormal = sample.xy;
float sampleDepth = DecodeFloatRG(sample.zw);
// 法线差异 = 两个采样点对应值相减后乘以灵敏度
half2 diffNormal = abs(centerNormal - sampleNormal) * _Sensitivity.x;
// 将分量相加与阈值比较来判断是否存在边界
int isSameNormal = (diffNormal.x + diffNormal.y) < 0.1;
// 深度差异 = 两个采样点对应值相减后乘以灵敏度
float diffDepth = abs(centerDepth - sampleDepth) * _Sensitivity.y;
// 将深度差异与阈值比较得到是否存在边界结果
int isSameDepth = diffDepth < 0.1 * centerDepth;
// 将深度与法线结果相乘得到最终结果
// return:
// 1 - 如果法线和深度足够相似,即不存在边界
// 0 - 否则
return isSameNormal * isSameDepth ? 1.0: 0.0;
}
fixed4 fragRobertsCrossDepthAndNormal(v2f i): SV_Target
{
// 使用纹理坐标,对深度+法线纹理进行采样
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;
// 计算对角线两个纹理的插值 CheckSame:返回0或1
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);
}
ENDCG
Pass
{
ZTest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment fragRobertsCrossDepthAndNormal
ENDCG
}
}
FallBack Off
}