使用深度和法线纹理
屏幕后处理技术 很多信息受光照影响和别的影响。在一些操作计算时不时很准确。比较边缘检测。另外一种更好的办法就是利用深度和法线信息可以准确的得到边缘信息。
获取深度和法线纹理
这里主要是说背后的实现原理
深度纹理的深度值范围在【0,1】,而且是非线性分布的。 这些深度值来自顶点变换后得到的归一化设备坐标(Normalized Device Coordinates NDC)。回顾这个过程:一个模型要想最终被绘制在屏幕上,需要把顶点从模型空间变换到其次裁剪坐标系。在顶点着色器中乘以MVP变换矩阵得到的。在变换的最后一步,我们需要使用一个投影矩阵来变换顶点。当我们使用的是透视投影类型的摄像机时,这个投影矩阵就是非线性的。具体看4.6.7小节。
图13.1 显示了4.6.7 小节中给出的Unity 中透视投影对顶点的变换过程。图13.1 中最左侧的图显示了投影变换前,即观察空间下视锥体的结构及相应的顶点位置,中间的图显示了应用透视裁剪矩阵后的变换结果,即顶点着色器阶段输出的顶点变换结果,最右侧的图则是底层硬件进行了透视除法后得到的归一化的设备坐标。
image
上图显示了使用正交摄像机时投影变换的过程。同样变换后得到一个范围为【-1,1】的立方体。正交投影使用的变换矩阵是线性的。
在得到NDC后,深度纹理中的像素值就可以很方便的计算得到了,深度值对应了NDC中的顶点坐标Z分量的值。由于其值是在【-1,1】之间,需要一个公式对其映射,原来就用过类似的。乘以一半在加0.5。
image
其中, 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。
这样看 实时获得法线纹理还是一个很耗时的操作
如何获取
Unity中,获取深度纹理 在脚本中设置摄像机的depthTextureMode来完成的
camera.depthTextureMode = DepthTextureMode.Depth;
一旦设置好了上面的摄像机模式后,我们就可以在Shader 中通过声明 _CameraDepthTexture变量来访问它。这个过程非常简单,但我们需要知道这两行代码的背后, Unity 为我们做了许多工作 上一节说明了。
同样的 要想获得深度+法线纹理,脚本中设置
camera.depthTextureMode = DepthTextureMode.DepthNormals;
然后在Shader 中通过声明 _CameraDepthNormalsTexture 变量来访问它。
我们还可以组合这些模式,让一个摄像机同时产生一张深度和深度+法线纹理:
camera.depthTextureMode |= DepthTextureMode.Depth;
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 文件中找到。
当通过纹理采样得到深度值后,这些深度值往往是非线性的,这种非线性来自于透视投影使用的裁剪矩阵。然而,在我们的计算过程中通常是需要线性的深度值,也就是说,我们需要把投影后的深度值变换到线性空间下,例如视角空间下的深度值。那么,我们应该如何进行这个转换呢?实际上,我们只需要倒推顶点变换的过程即可。下面我们以透视投影为例,推导如何由深度纹理中的深度信息计算得到视角空间下的深度值。
image
Unity 提供了两个辅助函数来为我们进行上述的计算过程一一LinearEyeDepth 和 Linear01Depth。 LinearEyeDepth 负责把深度纹理的采样结果转换到视角空间下的深度值,也就是我们上面得到的Zview。而Linear01Depth 则会返回一个范围在[0, 1]的线性深度值,也就是我们上面得到的Z01。这两个函数内部使用了内置的 _ZBufferParams 变量来得到远近裁剪平面的距离。
如果我们需要获取深度+法线纹理,可以直接使用tex2D 函数对 _CameraDepthNormalsTexture 进行采样,得到里面存储的深度和法线信息。Unity 提供了辅助函数来为我们对这个采样结果进行解码,从而得到深度值和法线方向。这个函数DecodeDepthNorrnal, 它在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 来解码深度+法线纹理中的深度和法线信息。
至此,我们已经学会了如何在Unity 里获取及使用深度和法线纹理。下面, 我们会学习如何使用它们实现各种屏幕特效。
查看深度和法线纹理
利用Frame Debugger 可以查看到深度纹理和深度+法线纹理
image
调试器查看到的深度纹理是非线性的深度值,而深度+法线纹理都是由Unity编码后的结果。有时,显示出线性空间下的深度信息或解码后的法线方向会更有用。我们可以自行在片元着色器中输出转换或解码后的深度和法线值。
float depth= SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
float linearDepth = LinearOlDepth(depth);
return fixed4(linearDepth, linearDepth, linearDepth, 1.0);
---------------------
//或是输出法线方向
fixed3 normal = DecodeViewNormalStereo(tex2D( _CameraDepthNormalsTexture, i.uv).xy);
return fixed4 (normal * 0.5 + 0.5, 1.0);
在查看深度纹理时,读者得到的画面有可能几乎是全黑或全白的。这时候读者可以把摄像机的远裁剪平面的距离( Unity 默认为1000 )调小, 使视锥体的范围刚好覆盖场景的所在区域。这是因为,由于投影变换时需要覆盖从近裁剪平面到远裁剪平面的所有深度区域, 当远裁剪平面的距离过大时, 会导致离摄像机较近的距离被映射到非常小的深度值,如果场景是一个封闭的区域(如图13.4 所示〉, 那么这就会导致画面看起来几乎是全黑的。相反, 如果场景是一个开放区域,且物体离摄像机的距离较远, 就会导致画面儿乎是全白的。
image
再谈运动模糊
上一章中,我们实现的运动模糊是通过 混合多张屏幕图像来模拟运动模糊的效果。但另一种应用更加广泛是使用速度映射图。速度映射图中存储了每个像素的速度,然后使用这个速度来决定模糊的方向和大小。速度缓冲的生成有多种方法,
一种:方法是把场景中所有物体的速度渲染到一张纹理中。但这种方法的缺点在于需要修改场景中所有物体的Shader 代码,使其添加计算速度的代码并输出到一个渲染纹理中。
第二种:《GPU Gems3》在第27章(http:http.developer.nvidia.com/GPUGems3/gpugems3_ch27.html) 中介绍了一种生成速度映射图的方法。这种方法利用深度纹理在片元着色器中为每个像素计算其在世界空间下的位置,这是通过使用当前的视角*投影矩阵的逆矩阵对NDC 下的顶点坐标进行变换得到的。当得到世界空间中的顶点坐标后,我们使用前一帧的视角*投影矩阵对其进行变换,得到该位置在前一帧中的NDC 坐标。然后,我们计算前一帧和当前帧的位置差,生成该像素的速度。这种方法的优点是可以在一个屏幕后处理步骤中完成整个效果的模拟,但缺点是需要在片元着色器中进行两次矩阵乘法的操作,对性能有所影响。
代码实现 这还是一种屏幕后处理技术 还是需要C#支持
public class MotionBlurWithDepthTexture :PostEffectsBase{
public Shader motionBlurShader;
private Material motionBlurMaterial =null;
public Material material{
get{
motionBlurMaterial = CheckShaderAndCreateMaterial(motionBlurShader, motionBlurMaterial);
return motionBlurMaterial;
}
}
[Range(0.0f, 1.0f)]
public float blurSize = 0.5f;
//需要得到摄像机的视角和投影矩阵 ,需要定义一个Camera的变量,以获取脚本所在的摄像机
private Camera myCamera;
public Camera camera{
get{
if(myCamera == null){
myCamera = GetComponent<Camera>();
}
return myCamera;
}
}
//定义一变量来保存上一帧的摄像机的视角*投影矩阵
//这个比较重要 原来的通过外部传入的
private Matrix4x4 previousViewProjectionMatrix;
//由于 需要摄像机的深度纹理,在OnEnable中设置摄像机状态
void OnEnable(){
camera.depthTextureMode |= DepthTextureMode.Depth;
// 实现OnRenderImage 函数
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);
}
}
}
在OnRenderImage函数中,需要计算和传递运动模糊使用的各个属性。这里需要两个变换矩阵,1.前一帧的视角投影矩阵 2.当前帧的 视角投影矩阵的逆矩阵。 因此,我们通过调用camera.worldToCameraMtrix 和 camera.projectionMatrix 来分别得到当前摄像机的视角矩阵和投影矩阵。对它们相乘后取逆、得到当前帧的视角投影矩阵的逆矩阵*。
shader部分代码
Properties{
_MainTex("Base (RGB)", 2D) = "white" {}
_BlurSize("Blur Size", Float) =1.0 //模糊图像使用的参数
}
这里虽然没有定义 矩阵的属性,没有_PreviousViewProjectionMatrix 和 _CurrentViewProjectionInverseMatrix 属性,这是因为Unity 没有提供矩阵类型的属性,但我们仍可以在CG中定义这些矩阵,并从脚本中设置他们
使用CGINCLUDE 来组织代码。在SubShader中利用CGINCLUDE和ENDCG来定义代码
SubShader{
CGINCLUDE
····
ENDCG
····
}
声明需要使用的变量 这里就包括了刚刚的 矩阵属性
sampler2D _MainTex;
half4 _MainTex_TexelSize;
sampler2D _CameraDepthTexture; //unity传递给我们的深度纹理
float4x4 _CurrentViewProjectionInverseMatrix;
float4x4 _PreviousViewProjectionMatrix;
half _BlurSize;
我们声明了 _MainTex_TexelSize 变量,它对应了主纹理的纹素大小,我们需要使用该变量来对深度纹理的采样坐标进行平台差异化处理(详见5.6.1 节〉。
顶点着色器代码
struct v2f {
float4 pos : SV_POSITION;
half2 uv : TEXCOORD0;
half2 uv_depth : TEXCOORD1;
};
v2f vert(appdata_img v){
v2f o;
o.pos = mul(UNITY_MATRIX_MVP, v.vertex);
o.uv = v.texcoord;
o.uv_depth = v.texcoord;
#if UNITY_UV_STARTS_AT_TOP
if(_MainTex_TexelSize.y<0)
o.uv_depth.y =1-o.uv_depth.y;
#endif
return o;
}
我们需要同时处理多张渲染纹理,因此在DirectX 这样的平台上,我们需要处理平台差异导致的图像翻转问题。在上面的代码中,我们对深度纹理的采样坐标进行了平台差异化处理,以便在类似DirectX 的平台上,在开启了抗锯齿的情况下仍然可以得到正确的结果。
片元着色器
fixed4 frag(v2f i): SV_Target{
float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture,i.uv_depth);
float4 H = float4(i.ux.x *2 -1 ,i.uv.y*2-1, d*2-1,1);
float4 D = mul(_CurrentViewProjectionInverseMatrix, H);
float4 worldPos = D/ D.w;
float4 currentPos =H;
float4 previousPos = mul(_PreviousViewProjectionMatrix, worldPos);
previousPos /=previousPos.w;
float2 velocity = (currentPos.xy -previousPos.xy) /2.0f;
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);
}
1.利用深度纹理 和当前帧的 视角*投影矩阵的逆矩阵来求得该像素在世界空间下的坐标 这个计算过程: 对深度纹理的采样,我们使用内置的SAMPLE_DEPTH_TEXTURE宏 和纹理坐标对深度纹理进行采样。得到深度值 d;
- d 是由NDC下的坐标映射而来的。我们想要构建像素的NDC坐标H,需要把深度值重新映射会NDC。计算很简单,只需要使用原映射的反函数即可。即 d2 -1。同样的NDC的xy分量可以由像素的纹理坐标映射而来(NDC的xyz分量范围均为【-1,1】)。
3.当得到NDC下的坐标H后,我们就可以使用当前帧 的 视角投影矩阵的逆矩阵对其进行变换,并把结果值除以它的w分量来得到世界空间下的坐标表示worldPos。
4.得到了世界空间下的坐标,就可以使用前一帧的视角*投影矩阵对它进行变换。得到前一帧在NDC下的previousPos。
5.计算前一帧和当前帧在屏幕空间下的位置差,得到该像素的速度velocity
6.在得到该像素的速度后,我们就可以使用该速度对它的邻域像素进行采样。 - 相加后取平均值得到一个模糊的效果。 采样时我们可以用_BlurSize来控制采样距离。
我们定义了 运动模糊所需的Pass
Pass{
ZTest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
ENDCG
}
FallBack Off
本节实现的运动模糊适用于场景静止、摄像机快速运动的情况,这是因为我们在计算时只考虑了摄像机的运动。因此,如果读在把本节中的代码应用到一个物体快速运动而摄像机静止的场景,会发现不会产生任何运动模糊效果。如果我们想要对快速移动的物体产生运动模糊的效果,就需要生成更加精确的速度映射图。读者可以在Unity 自带的lmageEffect 包中找到更多的运动模糊的实现方法。
本节选择在片元着色器中使用逆矩阵来重建每个像素在世界空间下的位置。但是,这种做法往往会影响性能。下面,我们会介绍一种更快速的由深度纹理重建世界坐标的方法。
全局雾效
Unity 内置的雾效 可以产生基于距离的线性或指数的雾效。要想在自己的 shader中实现雾效,需要在Shader中添加#pragma multi_compile_fog 指令,同时还需要使用相关的内置宏如UNITY_FOG_COORDS, UNITY_TRANSFER_FOG 和 UNITY_APPLY_FOG 等。这种方法的缺点在于,我们不仅需要为场景中所有物体添加相关的渲染代码,而且能够实现的效果也非常有限。当我们需要对雾效进行一些个性化操作时,例如使用基于高度的雾效等,仅仅使用Unity 内置的雾效就变得不再可行。
屏幕后处理技术来实现雾效就可以一次实现这样的效果,自由性很高。
基于屏幕后处理的全局雾效关键是,根据深度纹理来重建每个像素在世界空间下的位置。上一节已经实现了这个需求——构建出当前的NDC坐标,通过摄像机 视角投影矩阵的逆矩阵来得到世界空间下的像索坐标, 但这样的实现需要在片元着色中进行矩阵乘法的计算,很影响性能。
这里介绍一个快速的方法:对图像空间下的视锥体射线(从摄像机出发,指向图像的某点的射线)进行插值,这条射线存储了 该像素在世界空间下到摄像机的方向信息。
然后,我们吧该射线和线性化后的视角空间下的深度值相乘,在加上摄像机的世界位置,就可得到该像素在世界空间下的位置。我们得到世界坐标后,就可以轻松的用各个公式来实现雾效了*。
重建世界坐标
坐标系中一个顶点的坐标可以通过它相对于另一个顶点的坐标偏移量来得到。重建世界坐标也是基于这个思想,我们只需要知道摄像机在世界空间下的位置,以及世界空间下该像素相对于摄像机的偏移量,把它们相加就可以得到该像素的世界坐标系。
_WorldSpaceCameraPos 是摄像机在世界空间下的位置,这可以由Unity 的内置变量直接访问得到。而linearDepth * interpolatedRay 则可以计算得到该像素相对于摄像机的偏移量linearDepth 是由深度纹理得到的线性深度值, interpolatedRay 是由顶点着色器输出并插值后得到的射线,它不仅包含了该像素到摄像机的方向,也包含了距离信息。
interpolatedRay 来源于对近裁剪平面的4个角的某个特定向量的插值,这4个向量包含了它们到摄像机的方向和距离信息,我们可以利用摄像机的近裁剪平面距离、FOV、横纵比计算而得。 下图显示了计算时使用的一些辅助向量,为了计算方便,可以先计算两个向量——toTop和toRight,它们是起点位于近裁剪平面中心,分别指向摄像机正上方和正右方的向量。公式
image
上面求得的4个向量 不仅包含了方向信息,它们的模对应了4个点到摄像机的空间距离。(向量的运算特性)由于我们得到的线性深度值并非是点到摄像机的欧式距离,是z轴的距离,所以不能直接使用深度值和4个角的单位方向的乘积来计算它们到相机的偏移量。(深度信息在z轴)
不过转换也简单,把深度值转换到摄像机的欧式距离也简单,以TL点为例,根据相似三角形原理,TL所在的射线上,像素的深度值和它到摄像机的实际距离的比等于近裁剪面的距离和TL向量模的比 这个自己一画图就明白了。
image
image
屏幕后处理的原理是使用特定的材质去渲染一个刚好填充整个屏幕的四边形面片。这4个顶点就对应了今裁剪面的4个角。因此,我们可以把上面的计算结果传递给顶点着色器,顶点着色器根据当前的位置选择它对应的向量,然后再输出,经插值后传递给片元着色器得到interpolatedRay。
雾的计算
简单的雾效实现中,我们需要计算一个雾效系数f,作为混合原始颜色和雾的颜色的混合系数:
image
实现
首先基础 基类 ,里面实现和前面的类似,主要就是传递给shader 必要的参数
在本节中,我们需要获取摄像机的相关参数,如近裁剪平面的距离、FOV 等,同时还需要获取摄像机在世界空间下的前方、上方和右方等方向,因此我们用两个变量存储摄像机的Camera 组件和Transform 组件:
public class FogWithDepthTexture:PostEffectsBase{
public Shader fogShader;
private Material fogMaterial = null;
public Material material{
get{
fogMaterial = CheckShaderAndCreateMaterial(fogShader,fogMaterial);
return fogmaterial;
}
}
private Camera myCamera;
public Camera camera {
get {
if (myCamera == null) {
myCamera = GetComponent<Camera>();
}
return myCamera;
}
}
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;
}
}
实现OnRenderImage函数 这里用上一节的计算公式来得到深度坐标
void OnRenderImage (RenderTexture src, RenderTexture dest) {
if (material != null) {
Matrix4x4 frustumCorners = Matrix4x4.identity;
float fov = camera.fieldOfView;
float near = camera.nearClipPlane;
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();
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);
}
}
按一定顺序把这四个方向存储到了frustumCorners 不同的行中,这个顺序是非常重要的,因为这决定了我们在顶点着色器中使用哪一行作为该点的待插值向量。随后,我们把结果和其他参数传递给材质,并调用Graphics.Blit (src, dest, material)把渲染结果显示在屏幕上。
接下来 实现shader 代码
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
}
使用 CGINCLUDE来组织代码。我们在SubShader 中利用CGINCLUDE 和ENDCG来定义代码
SubShader{
CGINCLUDE
···
ENDCG
···
}
声明 需要使用的变量
float4x4 _FrustumCornersRay;
sampler2D _MainTex;
half4 _MainTex_TexelSize;
sampler2D _CameraDepthTexture;
half _FogDensity;
fixed4 _FogColor;
float _FogStart;
float _FogEnd;
_FrustumCornersRay 虽然没有在Properties 中声明, 但仍可由脚本传递给Shader。除了 Properties 中声明的各个属性,我们还声明了深度纹理 _CameraDepthTexture, Unity 会在背后把得到的深度纹理传递给该值。
顶点着色器
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 = mul(UNITY_MATRIX_MVP, v.vertex);
o.uv = v.texcoord;
o.uv_depth = v.texcoord;
#if UNITY_UV_STARTS_AT_TOP
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;
}
#if UNITY_UV_STARTS_AT_TOP
if(_MainTex_TexelSize.y<0)
index = 3-index;
#endif
o.interpolatedRay = _FrustumCornersRay[index];
return o;
}
我们除了定义顶点位置,屏幕图像和深度纹理的纹理坐标外, 还定义了interpolatedRay 变量存储插值后的像素向量。在顶点着色器中,我们对深度纹理的采样坐标进行平台差异化处理。更重要的是,我们要决定该点对应了4个角的哪个角。用的方法是判断他的纹理坐标。在Unity中,纹理坐标的(0,0)点对应了左下角,而(1,1)点对应了右上角。我们根据此来判断顶点对应的索引,这个对应关系和我们在脚本中对frustumCorners 的赋值顺序是一致的。不同平台的纹理坐标不一定是满足上面的条件,例如DirectX和Metal这样的平台。左上角对应了(0,0)点,但大多数情况下Unity会把这些平台的屏幕图像进行翻转,因此这个条件还是可以利用,。但DirectX平台开启了抗锯齿,Unity就不会翻转。为了此事仍然可以得到对应顶点位置的索引值。对索引值也进行了平台差异化处理,以便在必要对索引值进行翻转。最后,用索引值来获取_FrustumCornersRay中对应的行 作为顶点的interpolatedRay值。
虽然判断语句,但屏幕后处理所用的模型是一个四边形网格,只包含4个顶点,所用对性能影响不大。所以判断对性能还影响挺大。
片元着色器
fixed4 frag (v2f i) :SV_Target{
float linearDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth));
float3 worldPos = _WorldSpaceCameraPos + linearDepth *i.interpolatedRay.xya;
float fogDensity = (_FogEnd- worldPos.y) / (_FogEnd - _FogStart);
fogDensity = saturate(fogDensity *_FogDensity);
fixed4 finalColor = tex2D(_MainTex, i.uv);
finalColor.rgb = lerp(finalColor.rgb, _FogColor.rgb, fogDensity);
return finalColor;
}
我们要重建该像素在世界空间中的位置。
1.首先使用SAMPLE_DEPTH_TEXTURE 对深度纹理进行采样,
2.在使用LinearEyeDepth 得到视角空间下的线性深度值。
- 与interpolatedRay 相乘后再和世界空间下的摄像机位置相加。便得到世界空间下的位置
4.已知世界坐标后,雾效就容易了,选择基于高度的雾效模拟,
5.我们根据材质属性 _FogEnd 和 _FogStart 计算当前的像素高度 worldPos.y 对应的雾效系数 fogDensity,再和参数 _FogDensity 相乘后,利用saturate 函数截取到[0, 1]范围内,作为最后的雾效系数。
6.用该系数将雾的颜色和原始颜色进行混合 然后返回
定义所需要的Pass 两个着色器可以复用
Pass{
ZTest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
ENDCG
}
边缘检测
上一章 有介绍用上Sobel算子进行图像的边缘检测。这种有弊端 直接用颜色有时候边缘计算不是很准。有可能会描边变多
将学习如何在深度和法线纹理上进行边缘检测。不受纹理和光照的影响,仅仅保存当前渲染物体的模型信息,这样得到的边缘更可靠
image
上一章用Sobel 算子,这一节使用Roberts算子来进行边缘检测。它使用卷积核如图所示
image
Roberts 算子的本质就是计算左上角和右下角的差值,乘以右上角和左下角的差值,作为评估边缘的依据。在下面的实现中,我们也会按这样的方式,取对角方向的深度或法线值,比较它们之间的差值,如果超过某个阀值(可由参数控制),就认为它们之间存在一条边。
c#带
pulic class EdgeDetectNormalsAndDepth: PostEffectsBase{
public Shader edgeDetectShader;
private Material edgeDetectMaterial = null;
public Material material{
get{
edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
return edgeDetechMaterial;
}
}
[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<Camera>().depthTextureMode |= DepthTextureMode.DepthNormals;
}//需要获取摄像机的深度 +法线纹理
[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);
}
}
}
里我们为OnRenderlmage 函数添加了[ImageEffectOpaque]属性。我们曾在12.1节中提到过该属性的含义。在默认情况下,OnRenderlmage 函数会在所有的不透明和透明的Pass 执行完毕后被调用,以便对场最中所有游戏对象都产生影响。但有时,我们希望在不透明的Pass (即渲染队列小于等于2 500 的Pass,内置的Background、Geometry 和AlphaTest 渲染队列均在此范围内)执行完毕后立即调用该函数,而不对透明物体(渲染队列为Transparent 的Pass )产生影响,此时,我们可以在OnRenderlmage 函数前添加ImageEffectOpaque 属性来实现这样的目的。在本例中,我们只希望对不透明物体迸行描边,而不希望透明物体也被描边, 因此需要添加该属性。
Shader 代码
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 分量对应了法线和深度的检测灵敏度 上面的 c#代码也说明了。 zw分量没有实际用途
}
为了访问属性,在CG代码块中声明变量
sampler2D _MainTex;
half4 _MainTex_TexelSize;
fixed _EdgeOnly;
fixed4 _EdgeColor;
fixed4 _BackgroundColor;
float _SampleDistance;
half4 _Sensitivity;
sampler2D _CameraDepthNormalsTexture;
声明了 需要获取的深度+ 法线纹理 _CameraDepthNormalsTexture, 我们需要对邻域像素进行纹理采样,还存储了纹素大小的变量 _MainTex_TexelSize
定义顶点着色器
struct v2f{
float4 pos : SV_POSITION;
half2 uv[5]: TEXCOORD0;
};
v2f vert (appdata_img v){
v2f o;
o.pos = mul (UNITY_MATRIX_MVP, 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
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 的纹理坐标数组。这个数组的第一个坐标 存储了屏幕颜色图像的采样纹理。我们对深度纹理的采样坐标进行了平台差异。必要情况下对他的竖直方向进行了翻转。数组中剩余的4个坐标则存储了使用Roberts算子时需要采样的纹理坐标
我们还使用了 _SampleDistance 来控制采样距离。 通过把计算采样纹理坐标的代码 从片元着色器中转移到顶点着色器中,可以提高性能。由于从顶点到片元的插值是线性的,不影响纹理坐标的的计算结果。
定义 片元着色器
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;
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 函数来分别计算对角线上两个纹理值的差值。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;
// 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。最后,我们把法线和深度的检查结果相乘,作为组合后的返回值。
当通过CheckSame 函数得到边缘信息后,片元着色器就利用该值进行颜色混合
定义了检测边缘需要使用的Pass
Pass {
ZTest Always Cull Off ZWrite Off
CGPROGRAM
#pragma vertex vert
#pragma fragment fragRobertsCrossDepthAndNormal
ENDCG
}
本节实现的描边效果是基于整个屏幕空间进行的,也就是说,场景内的所有物体都会被添加描边效果。但有时,我们希望只对特定的物体进行描边,例如当玩家选中场景中的某个物体后,我们想要在该物体周围添加一层描边效果。这时,我们可以使用Unity 提供的Graphics.DrawMesh 或 Graphics.DrawMeshNow 函数把需要描边的物体再次渲染一遍(在所有不透明物体渲染完毕之后),然
后再使用本节提到的边缘检测算法计算深度或法线纹理中每个像素的梯度值,判断它们是否小于某个阀值,如果是,就在Shader 中使用clip() 函数将该像素剔除掉,从而显示出原来的物体颜色。