Unity Shader学习记录(十)

Unity Shader学习记录(十)

  前文提到的屏幕后处理特效只是一类在渲染完成后的帧画面基础上做二次处理的特效,虽然在大部分情况下它们是可用而且足够高效的,但更多的情况下我们不仅需要当前的帧画面,还需要场景的深度和法线信息。
  一个典型的例子是边缘检测,前文中使用Sobel算子进行卷积运算来检测边缘其实并不精确,因为颜色的变化有时候并不说明真正的物体边缘,而且光照效果也会影响到边缘检测。此时如果在深度和法线信息的基础上进行边缘检测运算,其精确度会提高很多,因为深度和法线信息里没有颜色,也没有光照。
  Unity为这两种信息分别提供了对应的纹理参数,也就是将深度信息渲染到一张深度纹理中,法线信息同理;想要在Shader中使用这些纹理信息就必须事先声明。

如何获取深度和法线纹理

  在真正获取这两种纹理之前,首先看看它们的基本原理是什么。
  所谓深度纹理,顾名思义就是一张渲染纹理,它的样子和平常用于模型渲染的贴图没什么两样,但它的内容并不是一系列的颜色像素,它的每一个像素值都是一个高精度的深度值,直接对应当前渲染帧里的这一点上的深度。
  那么问题来了,这个深度值怎么得到的?这个流程其实就是引擎渲染画面的过程中的一部分,包括顶点变换以及归一化;简单来说,一个模型要被渲染到屏幕上,首先要对它的顶点做变换,也就是模型空间变换到齐次裁剪空间,Shader的顶点计算函数中常见的一行代码

o.pos = UnityObjectToClipPos(v.vertex);

就是用来干这个的。
  在这个变换的最后一步会有一个投影矩阵用于计算映射到齐次裁剪空间后的坐标值,对于透视型摄像机而言这个投影矩阵会是非线性的,而正交型摄像机的投影矩阵是线性的,这也就直接关系到最后的深度纹理是何种情况。
  当变换完成之后,深度纹理就能很轻松地得到了,只要保留齐次裁剪空间下每个顶点坐标的Z分量即可,由于此时的Z分量落在[-1,1]范围内,因此用一个公式对其进行再次映射。

d = 0.5 z + 0.5

  最后得到的所有 d 值就组成了深度纹理。
  由此可见,要得到深度纹理必须对场景做一次坐标变换,那么要得到它实际上就可以有两种方法,第一种是最简单粗暴的,单独用一个Pass来进行运算并得到深度纹理;另一种方法则是直接使用深度缓存,因为Untiy在渲染过程中会主动保存深度缓存用于后续的阴影和透明渲染。
  与此同时,需要注意的是,直接使用深度缓存作为深度纹理是有前提条件的,通常来说在延迟渲染路径中使用深度缓存是毫无问题的,但其它的路径,尤其是前向渲染路径下就不一定了。
  因此当无法获取到深度缓存时,还是要使用单独的Pass来得到深度纹理,在这个Pass中Unity会自动选择那些渲染类型标签(即SubShader内的RenderType)为Opaque的物体,判断它们的渲染队列是否小于等于2500,如果满足条件则将物体渲染到深度和法线纹理中。
  所以,正确设置渲染类型标签是使用深度和法线纹理的基础。
  在这之后,要获取深度和法线纹理就很简单了,只要通过脚本给摄像机设置一项属性即可

camera.depthTextureMode = DepthTextureMode.Depth;

  设置好这个属性后,就可以在Shader中通过声明_CameraDepthTexture变量来访问它。
  同理,还可以设置该属性为其它值来得到不同的结果,甚至使用组合参数。

camera.depthTextureMode = DepthTextureMode.DepthNormals; // 获取深度法线纹理
camera.depthTextureMode = DepthTextureMode.Depth | DepthTextureMode.DepthNormals; // 同时产生深度和深度法线纹理

  接着,在Shader中就可以对深度和法线纹理进行采样来获得信息了。绝大多数情况下,这个采样过程只需要简单地使用tex2D函数即可,但有些其他平台会有不同的需求,因此Unity提供了统一方案的宏SAMPLE_DEPTH_TEXTURE,使用方法如下

float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);

  但是,注意一点,当摄像机是透视类型时,这样采样得到的深度值是非线性的,如果直接参与计算可能无法得到想要的结果,因此在使用前要将采样结果变换到线性空间下,而这个过程只需要倒推顶点变换过程即可。
  倒推结果是一个用深度值 d 来表达 z v i e w 的公式。

z v i e w = 1 N F N F d + 1 N

  公式中的 N F 分别指摄像机视锥体的深度范围上界和下界, d 毫无疑问就是深度值,而结果是视角空间下的深度值,因为视角空间是线性的,因此此时得到的深度值就是线性的。
  当然了,如果想得到视锥体范围不是[N,F]而是[0,1]范围的深度值,那就将上文的公式整体除以F即可
z 01 = 1 N F N d + F N

  实际使用中并不需要真的进行这些繁杂的运算,Unity提供了两个辅助函数给开发者使用,一个是LinearEyeDepth,看名字就知道这是计算视角空间下的深度值;另一个是Linear01Depth,得到一个[0,1]范围的线性深度值。
  此外如果设置了摄像机的depthTextureMode为DepthNormals,此时深度和法线信息会被记录到同一张纹理上,那么采样结果就需要进行解码,Untiy也提供了对应的解码函数DecodeDepthNormal,在UnityCG.cginc中定义。

float4 packedTex = tex2D(_CameraDepthNormalsTexture, i.uv);
float depth;
float3 normal;
DecodeDepthNormal(packedTex, out depth, out normal);

  一般而言深度和法线纹理是不可见的,因为它们总是在游戏运行过程中生成,如果想要看到它们方便调试,可以打开Unity提供的帧调试器,定位到生成深度和法线纹理的事件即可看到。当然了,此时看到的深度值都是非线性的,如果要查看线性的深度值也可以自行在片元处理函数里将其输出为颜色,这样一来屏幕上就能看见线性空间下的深度和法线纹理了。

另一种运动模糊

  在前面的文章里提到过运动模糊这种特效,通过简单的屏幕后处理可以达到一个近似的效果,但那种混合多张屏幕图像的方法既不高效,效果也差强人意。
  另一种提到的技术是速度缓冲,也就是把所有物体的速度缓存到一张纹理中,这张纹理的每个像素储存着画面上每个像素点的运动速度,然后利用这张纹理去计算模糊的大小和方向。
  如何生成速度缓冲是一个重点,比较直白的方法是让场景中每个物体自己将自己的速度渲染到缓冲纹理里,但这样一来必须要修改每个物体的Shdaer,为其添加计算速度的代码并输出到指定纹理中。
  另外一种方法是通过比较前后两帧的裁剪空间坐标差来得到具体点的速度,这种方法就不需要修改所有物体的Shader,而且可以在一次屏幕后处理中完成,缺点很明显,就是在片元计算中使用了矩阵运算,影响效率。
  接着还是老样子,先挂摄像机脚本

public class MotionBlurWithDepthTexture : PostEffectBase {
    public Shader motionBlurShader;
    private Material motionBlurMaterial;
    public Material material {
        get {
            motionBlurMaterial = CheckShaderAndCreateMaterial(motionBlurShader, motionBlurMaterial);
            return motionBlurMaterial;
        }
    }

    [Range(0f, 1.0f)]
    public float blurSize = 0.5f;
    private Camera myCamera;
    public Camera _camera {
        get {
            if(myCamera == null) {
                myCamera = GetComponent<Camera>();
            }
            return myCamera;
        }
    }
    private Matrix4x4 previousViewProjectionMatrix;

    void OnEnable() {
        _camera.depthTextureMode |= DepthTextureMode.Depth;
    }

    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输入了两个矩阵,第一个是前一帧的视角投影矩阵,第二个是当前帧的视角投影矩阵的逆,这两个矩阵的作用在于通过它们可以在Shader里计算得到片元的世界坐标,而将前后两帧的世界坐标都计算出来之后,便可以得到速度参数了。
  接着是Shader

Shader "Hidden/MotionBlurWithDepth" {
    Properties {
        _MainTex ("Texture", 2D) = "white" {}
        _BlurSize ("Blur Size", Float) = 1.0
    }
    SubShader {
        CGINCLUDE

        sampler2D _MainTex;
        half4 _MainTex_TexelSize;
        sampler2D _CameraDepthTexture;
        float4x4 _CurrentViewProjectionInverseMatrix;
        float4x4 _PreviousViewProjectionMatrix;
        half _BlurSize;

        struct appdata {
            float4 vertex : POSITION;
            float2 texcoord : TEXCOORD0;
        };

        struct v2f {
            float4 pos : SV_POSITION;
            half2 uv : TEXCOORD0;
            half2 uv_depth : TEXCOORD1;
        };

        v2f vert(appdata v) {
            v2f o;
            o.pos = UnityObjectToClipPos(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 - _MainTex_TexelSize.y;
            }
            #endif
            return o;
        }

        fixed4 frag(v2f i) : SV_TARGET {
            float d = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth);
            float4 H = float4(i.uv.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.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 {
            Cull Off ZWrite Off ZTest Always
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            ENDCG
        }
    }
    Fallback Off
}

  可以看到片元计算函数中,就是通过应用前后两帧的矩阵参数,计算得到了速度,并据此改变了片元计算结果的颜色值。实测中要求把脚本挂在摄像机对象上,赋予Shader,然后让摄像机运动起来即可看到动态模糊的效果;如果只是物体在动,则不会有任何特效。

全局雾效

  雾效是一种很常见的游戏场景控制手段,它的核心意义在于修正因为裁剪而导致的远方物体消失不见的瑕疵,以此来达到既能降低性能开销,又能一定程度上保证视觉完整性。比如说基于距离阈值的雾效,在玩家镜头方向上超过一定距离后就会呈现灰白色的迷雾效果,看不到远处,自然也就看不到那些因为距离裁剪导致物体消失不见的情况了。
  Unity内置了雾效相关的功能,可以产生基于距离的线性或者指数性雾效,然而如果要在自定义Shader中使用内置雾效就必须按照要求启用雾效

#pragma multi_compile_fog
// 然后使用如下的内置宏
UNITY_FOG_COORDS
UNITY_TRANSFER_FOG
UNITY_APPLY_FOG

  这样做的缺点在于无法灵活自定义,每个物体的Shader都必须按照这样的要求添加代码,而且也无法实现任何个性化的效果,比如基于高度的雾效等。
  而要实现灵活自定义的雾效,屏幕后处理是一种不错的选择,要做到这一点显然有一个关键问题需要解决,那就是如何在屏幕后处理时得到每个像素实际在世界空间下的位置信息,毕竟屏幕后处理运行时渲染已经完成了,无法再去获取顶点信息。
  前文中的运动模糊处理过程其实已经有了这个功能相关的代码,但那样的计算方法涉及到在片元计算函数中的矩阵乘法,效率受影响;因此有一种更加高效的方法可以用来重建世界坐标,这种方法是找到图像空间下的视锥体射线(也就是从摄像机出发指向某个像素的射线),对它进行插值,由于这条射线保存着指定像素在世界空间下指向摄像机的方向,因此得到这个方向信息和将其和视角空间下的深度信息相乘,加上摄像机坐标即可还原该点的世界坐标。
  用公式表达如下

P w o r l d = P o s C a m + ( D e p t h V R a y )

  其中,摄像机坐标可以通过Unity内置变量得到,深度值通过深度纹理计算得到,因此核心问题落在如何计算射线的方向和距离上。
  这条射线其实来源于对近裁剪平面的四个角的某个特定向量的插值,这四个向量表达了四个角到摄像机的方向和距离信息,可以通过摄像机的近裁剪平面距离,视场角(FOV)和横纵比计算得出,方法如下

H h a l f = N e a r × t a n ( F O V 2 )

V t o T o p = c a m e r a . u p × H h a l f

V t o R i g h t = c a m e r a . r i g h t × H h a l f a s p e c t

  其中 N e a r 是近裁剪平面的距离
  有了这两个向量后,便可以计算出四个角相对于摄像机的方向了。

T L = c a m e r a . f o r w a r d N e a r + V t o T o p V t o R i g h t

T R = c a m e r a . f o r w a r d N e a r + V t o T o p + V t o R i g h t

B L = c a m e r a . f o r w a r d N e a r V t o T o p V t o R i g h t

B R = c a m e r a . f o r w a r d N e a r V t o T o p + V t o R i g h t

  值得注意的是,这里计算得到的四个向量不但有摄像机到四个角的方向,也包含了摄像机到四个角的距离。然而因为深度纹理中储存的深度值并非指定像素点到摄像机的距离,而是在z方向上的距离,因此无法直接将深度值与四个角的方向向量相乘来得到偏移量,需要一次转化。
  转化的原理也很简单,以TL向量为例,根据相似三角形原理,在TL射线上,像素的深度值与该像素到摄像机的实际距离的比值等于近裁剪平面的距离和TL向量模的比值。

d e p t h d i s t = N e a r | T L |

  变换可得

d i s t = | T L | N e a r × d e p t h

  又因为四个点对称,四个向量的模是相等的,可以得到最终结果

R a y T L = T L | N e a r | , R a y T R = T R | N e a r |

R a y B L = B L | N e a r | , R a y B R = B R | N e a r |

  考虑到屏幕后处理的原理就是使用特定的材质去渲染一个刚好填充屏幕的四边形面,这个四边形的四个顶点就对应着近裁剪平面的四个角,因此只要把上面的计算结果传递到顶点着色器,顶点着色器根据当前位置选择向量,再将其输出,在片元计算函数里就可以根据之前提到的公式重建世界坐标了。
  进入代码,首先依然还是在摄像机上挂载脚本

public class FogWithDepth : PostEffectBase {
    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;
    }

    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 - toRight - toTop;
            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);
        }
    }
}

  脚本中做了不少计算,其中最重要的莫过于准备好四个顶点的射线表达了,首先通过摄像机组件拿到视场角FOV以及近裁剪平面near,还有摄像机的视口宽高比aspect。通过这些数据计算得到了两个辅助向量,指向右方和上方的向量,随后便可以利用辅助向量计算渲染结果那四个顶点的射线表达了。
  计算过程如同前文公式所示,通过运用摄像机的forward向量以及近裁剪平面的距离near以及两个辅助向量计算出四个顶点的射线表达,随后将其存入预先定义好的四阶矩阵里,和其它参数一同送入Shader中即可。
  使用的Shader代码如下

Shader "Hidden/FogWithDepth" {
    Properties {
        _MainTex ("Texture", 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;
        sampler2D _CameraDepthTexture;
        half _FogDensity;
        fixed4 _FogColor;
        float _FogStart;
        float _FogEnd;

        struct appdata {
            float4 vertex : POSITION;
            float2 texcoord : TEXCOORD0;
        };

        struct v2f {
            float4 pos : SV_POSITION;
            half2 uv : TEXCOORD0;
            half2 uv_depth : TEXCOORD1;
            float4 interpolatedRay : TEXCOORD2;
        };

        v2f vert(appdata v) {
            v2f o;
            o.pos = UnityObjectToClipPos(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;
        }

        fixed4 frag(v2f i) : SV_TARGET {
            float linearDepth = LinearEyeDepth(SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv_depth));
            float3 worldPos = _WorldSpaceCameraPos + linearDepth * i.interpolatedRay.xyz;
            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;
        }

        ENDCG

        Pass {
            // No culling or depth
            Cull Off ZWrite Off ZTest Always
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            ENDCG
        }
    }
    Fallback Off
}

  Shader的计算相对简单,在顶点计算函数里首先判定了当前顶点所处区域,也就是这个顶点最终是投影到了哪个区域,根据这个来决定顶点对应的射线。进入片元计算函数后,首先使用LinearEyeDepth函数配合深度纹理采样宏得到了线性的深度值,之后根据摄像机的世界空间位置,深度值以及片元对应射线还原指定片元的世界坐标。
  当世界坐标还原出来后,引入设置好的雾效起止位置,并与世界坐标的y轴坐标进行比例运算,得到指定点的颜色插值信息,最后通过lerp进行一次插值即可。
  这个示例的目的是实现按照高度渲染的雾效,运行起来后可以看到按照设定的高度出现雾效,低于雾效起始高度的部分不可见,这也就是为何片元计算中使用世界坐标的y轴数值来进行插值计算,如果使用其它轴则会改变雾效出现的方向。

基于深度纹理的边缘检测

  在过去提到的边缘检测方法里使用的是最直接的卷积算子处理图像,这样的方法在检测边缘方面有不可忽视的缺陷,那就是颜色信息并不一定就表示了边缘的存在,或者说人们认知中的边缘的存在,就像示例中的花卉图,卷积算子会将花蕊乃至花瓣上的纹理都判断为边缘予以强调,这是违反正常认知的。
  究其原因其实就是颜色信息的干扰性过大,一张图片的颜色断崖式变化并不表示那里一定就是边缘,而要获得更为精确的边缘信息,深度信息才是更加合适的处理对象。
  下面就尝试使用Roberts算子来处理深度纹理以期得到更为精准的边缘信息,Roberts算子其实就是在计算2X2范围内,左上角和右下角像素的差值乘以右上角和左下角的差值,在实际的边缘检测中会检查这个计算结果,超过阈值时便认为存在一条边缘。
  首先还是挂上脚本

public class EdgeDetectNormalsAndDepth : PostEffectBase {
    public Shader edgeDetectShader;
    private Material edgeDetectMaterial;
    public Material material {
        get {
            edgeDetectMaterial = CheckShaderAndCreateMaterial(edgeDetectShader, edgeDetectMaterial);
            return edgeDetectMaterial;
        }
    }

    [Range(0f, 1f)]
    public float edgeOnly = 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", edgeOnly);
            material.SetColor("_EdgeColor", edgeColor);
            material.SetColor("_BackgroundColor", backgroundColor);
            material.SetFloat("_SampleDistance", sampleDistance);
            material.SetVector("_Sensitivity", new Vector4(sensitivityNormals, sensitivityDepth, 0f,0f));
            Graphics.Blit(src, dest, material);
        } else {
            Graphics.Blit(src, dest);
        }
    }
}

  脚本中需要注意的地方有摄像机的模式,不同于以往仅使用深度纹理,这次需要同时使用深度和法线纹理;还有深度和法线信息的敏感度,该值越大则检测的范围越大,在平滑表面附近产生的边缘效果也就越大。
  挂上脚本后需要一个Shader

Shader "Hidden/EdgeDetectNormalAndDepthShader" {
    Properties {
        _MainTex ("Texture", 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)
    }
    SubShader {
        CGINCLUDE
        #include "UnityCG.cginc"
        sampler2D _MainTex;
        half4 _MainTex_TexelSize;
        fixed _EdgeOnly;
        fixed4 _EdgeColor;
        fixed4 _BackgroundColor;
        float _SampleDistance;
        half4 _Sensitivity;
        sampler2D _CameraDepthNormalsTexture;

        struct appdata {
            float4 vertex : POSITION;
            float2 texcoord : TEXCOORD0;
        };

        struct v2f {
            float4 pos : SV_POSITION;
            half2 uv[5] : TEXCOORD0;
        };

        v2f vert(appdata 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
            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;
            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 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;
            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 {
            // No culling or depth
            Cull Off ZWrite Off ZTest Always
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment fragRobertsCrossDepthAndNormal
            ENDCG
        }
    }
    Fallback Off
}

  注意Shader中的顶点计算函数里,通过对UV应用Roberts算子来得到所需的数值和符号,进入片元计算后使用自定义函数来计算最终比较值,CheckSame函数返回1或者0说明当前检测的样本和算子中心是否属于同一个表面,换言之是否位于边缘两侧。
  在CheckSame函数里可以看到敏感度的使用方法,当敏感度变大时,法线和深度值的差异会被放大,反之则会缩小;对于固定阈值的情况而言,敏感度太大会导致连续变化的表面被识别为边缘,敏感度太小则会造成无法找到一些变化相对较小的边缘,因此需要调整合适的敏感度。
  挂好脚本,设置Shader,调整合适的敏感度,大部分时候设置为1就可以了;运行项目就可以看到识别出来的边缘。需要注意的是,Unity对深度和法线纹理的处理方式是不同的,深度纹理通过投射阴影的Pass获取,因此如果场景中物体使用的Shader既没有自身投射阴影,也没有在Fallback中设置可以投射阴影的Shader,那么这个物体将不会有深度纹理信息,边缘检测也就无法进行;法线纹理则会在Unity底层的一个ShaderPass中自动生成法线缓冲并得到法线纹理相关信息。

猜你喜欢

转载自blog.csdn.net/soul900524/article/details/80523020