Shader 学习笔记:描边

好久没写博客了,最近在整毕业论文,码的字也比较多,但是博客想起来好久没碰过了。在整毕设的时候,尝试着解决了一些自己很久没有完成的心病,也做了一些效果来玩一玩,这次把这个东西记一下,以后有需求可以随时拿起来用。所以这篇博客不会深入探讨一个东西从前到后是如何推导出来的(那样写一般都要花小半个星期,之前旋转那一篇博客花了我一个月),这篇文章主要写一写自己在做项目的时候的一些思路和坑。

今天的博客记一下关于后处理的描边方法,说起描边还是比较简答的,在杂记那一篇中,记录了根据模型本身来构建描边的方法, 虽然特别特别水,而且对于模型法线来说要求贼高。在我的毕设中,使用了MagicalVoxel做的建模(因为本人太懒了,3Dmax不想用,就想着偷懒了)。这个建模软件做的东西效果还是挺好的,特别简单,我都是一边听相声一边做,超级安逸。

做描边的时候的思考:

但是这样的后果就是,如果我需要做一些描边的时候,就超级难搞,例如我用MagicalVoxel做出的一个锅子是这样的:

这个效果在MagicalVoxel中效果还不错,但是放在Unity中就会出现很多问题,且不说MagicalVoxel导出的Vox文件的贴图文件保存的为顶点色,和一般的模型的贴图的方式不同,所以在采样的时候会就出现颜色问题。而且MagicalVoxel导出后必须自己界定模型中心,所以每个模型都要自己整一下,做个空物体来表示。最最最最蛋疼的是,如果对于这类型的模型描边的时候,直接使用模型顶点外拓效果会非常差,例如上面这个锅子,使用法线外拓后的描边然后再用模板测试相减后效果为(我这里描边是绿色):

如果改用offset对纯色模型描边的话,效果和上面差不多,而且还要差一些。

所以对于MagicalVoxel这样法线方向单一的模型来说,对于单个模型来进行描边着色是做不了的。一般都是使用后处理进行描边。后处理的描边卷积我在模糊那一篇的最后地方写过一个描边效果,对于场景中的效果根据卷积核来判断一个模型的颜色然后界定描边。这里就不重复了,对于使用MagicalVoxel建模的项目中,如果场景中存在如下的模型:

如果我们使用普通的颜色描边卷积核来做画面的描边效果,那么对于上图来说效果是这样的:

现在看起来这个还算堪用,只能说是稍微和我想要的效果沾点边。但是由于基于颜色,很多时候指不定会在屏幕上画出啥脏乱差的效果。

使用深度纹理和法线纹理进行描边

对于我的项目来说,我在尝试描边的时候曾经想过做出类似于MagicalVoxel的描边,MagicalVoxel中,如果开启了左下角的Grid描边是这样的:

我在用MagicalVoxel的时候,感觉这个效果对于方块模型来说还是挺炫酷的,因为每次看起来感觉层次比较分明。我摸了几天,发现这里的这种描边可以使用屏幕法线纹理和屏幕深度纹理来采样叠加来模拟这个效果。放在Unity中具体来说是这样的:

这样的效果看起来还行 ,而且特别简单,只需要一丢丢代码就能实现这个效果,只需要将深度贴图的描边结果和法线贴图描边的结果结合起来就行。

Shader 代码:

Shader "Custom/LineNormal"
{
    Properties
    {
        _MainTex("Texture",2D)="white"{}
        _EdgeColor("EdgeColor",Color)=(1,1,1,1)
        _NoneEdgeColor("NoneEdgeColor",Color)=(1,1,1,1)
        _SampleRange("SampleRange",float)=1.0
        _NormalDiffThreshold("NormalDiffThreshold",float)=1.0
    }

    CGINCLUDE
    #include "UnityCG.cginc"

    struct VertexData
    {
        float4 vertex:POSITION;
        float2 uv:TEXCOORD0;
    };

    struct VertexToFragment
    {
        float4 pos:SV_POSITION;
        float2 uv[9]:TEXCOORD0; 
    };

    sampler2D _MainTex;
    float4 _MainTex_TexelSize;

    sampler2D _CameraDepthNormalsTexture;
    sampler2D _CameraDepthTexture;
    float4 _NoneEdgeColor;
    float4 _EdgeColor;
    float _SampleRange;
    float _NormalDiffThreshold;

    VertexToFragment myVertex(VertexData v)
    {
        VertexToFragment VToF;
        VToF.pos=UnityObjectToClipPos(v.vertex);
        VToF.uv[0]=v.uv+float2(-1,1)*_MainTex_TexelSize*_SampleRange;
        VToF.uv[1]=v.uv+float2(0,1)*_MainTex_TexelSize*_SampleRange;
        VToF.uv[2]=v.uv+float2(1,1)*_MainTex_TexelSize*_SampleRange;
        VToF.uv[3]=v.uv+float2(-1,0)*_MainTex_TexelSize*_SampleRange;
        VToF.uv[4]=v.uv;
        VToF.uv[5]=v.uv+float2(1,0)*_MainTex_TexelSize*_SampleRange;
        VToF.uv[6]=v.uv+float2(-1,-1)*_MainTex_TexelSize*_SampleRange;
        VToF.uv[7]=v.uv+float2(0,-1)*_MainTex_TexelSize*_SampleRange;
        VToF.uv[8]=v.uv+float2(1,-1)*_MainTex_TexelSize*_SampleRange;
        return VToF;
    }

    float CheckEdge(fixed4 a,fixed4 b)
    {
        float2 normalDiff=abs(a.xy-b.xy);
        return (normalDiff.x+normalDiff.y)<_NormalDiffThreshold;
    }

    fixed4 myFragmentDepth(VertexToFragment VToF):SV_TARGET
    {
        fixed4 getColor=tex2D(_MainTex,VToF.uv[4]);
        fixed4 edge1=tex2D(_CameraDepthTexture,VToF.uv[0]);
        fixed4 edge2=tex2D(_CameraDepthTexture,VToF.uv[1]);
        fixed4 edge3=tex2D(_CameraDepthTexture,VToF.uv[2]);
        fixed4 edge4=tex2D(_CameraDepthTexture,VToF.uv[3]);
        fixed4 edge5=tex2D(_CameraDepthTexture,VToF.uv[5]);
        fixed4 edge6=tex2D(_CameraDepthTexture,VToF.uv[6]);
        fixed4 edge7=tex2D(_CameraDepthTexture,VToF.uv[7]);
        fixed4 edge8=tex2D(_CameraDepthTexture,VToF.uv[8]);

        float result=1.0;
        result*=CheckEdge(edge1,edge8);
        result*=CheckEdge(edge2,edge7);
        result*=CheckEdge(edge3,edge6);
        result*=CheckEdge(edge4,edge5);
        return lerp(_EdgeColor,getColor,result);

    }

    fixed4 myFragment(VertexToFragment VToF):SV_TARGET
    {
        fixed4 getColor=tex2D(_MainTex,VToF.uv[4]);
        fixed4 edge1=tex2D(_CameraDepthNormalsTexture,VToF.uv[0]);
        fixed4 edge2=tex2D(_CameraDepthNormalsTexture,VToF.uv[1]);
        fixed4 edge3=tex2D(_CameraDepthNormalsTexture,VToF.uv[2]);
        fixed4 edge4=tex2D(_CameraDepthNormalsTexture,VToF.uv[3]);
        fixed4 edge5=tex2D(_CameraDepthNormalsTexture,VToF.uv[5]);
        fixed4 edge6=tex2D(_CameraDepthNormalsTexture,VToF.uv[6]);
        fixed4 edge7=tex2D(_CameraDepthNormalsTexture,VToF.uv[7]);
        fixed4 edge8=tex2D(_CameraDepthNormalsTexture,VToF.uv[8]);
        float result=1.0;
        result*=CheckEdge(edge1,edge8);
        result*=CheckEdge(edge2,edge7);
        result*=CheckEdge(edge3,edge6);
        result*=CheckEdge(edge4,edge5);
        return lerp(_EdgeColor,getColor,result);
    }
    ENDCG

    SubShader
    {
        pass
        {
            CGPROGRAM
            #pragma vertex myVertex
            #pragma fragment myFragment
            ENDCG
        }
        pass
        {
            CGPROGRAM
            #pragma vertex myVertex
            #pragma fragment myFragmentDepth
            ENDCG
        }
    }
}

在后处理脚本中,只需要将两个Pass相加即可:

        Material.SetFloat("_edgeOnly", EdgeOnly);
        Material.SetColor("_EdgeColor", EdgeColor);
        Material.SetColor("_NoneEdgeColor", NoneEdgeColor);
        Material.SetFloat("_SampleRange", SampleRange);
        Material.SetFloat("_NormalDiffThreshold", NormalDiffThreshold);
        RenderTexture RT1 = RenderTexture.GetTemporary(src.width, src.height, 0);
        Graphics.Blit(src, RT1, Material, 0);
        Graphics.Blit(RT1, dst, Material, 1);
        RenderTexture.ReleaseTemporary(RT1);

最终就可以输出效果:

这个效果还是够用的,但是论文导师说这样看起来画面太脏,然后说丑。我想着没办法,只好重新想描边的方法。

使用命令缓冲进行描边

在我的项目中,后处理有如下的需求:

  • 效果方面:场景中随机随时生成不同物体,在它们生成的第一刻开始的每一帧都要渲染出描边。不同的物体描边颜色存在区别,并且将它们同时渲染在屏幕上。描边效果完全操控。
  • 效率方面:由于物体数量完全不可预计,所以每个描边效果所耗费的性能不能过多,由于场景中随机生成物品的数量很多。所以不能通过提高摄像机的方法来提高描边。

按照这个需求,上面的那些描边效果都统统不能用,因为它们都是针对屏幕纹理来描边。在屏幕纹理上,我完全不能区分出每一个物体,然后根据物体来描边。所以我想出来的方法是,对场景中每个物体都单独渲染一张纯色贴图,然后将该贴图进行后处理然后贴在屏幕上。无非就是以下步骤:

  1. 获得每个物体的颜色,使用一个纯色Shader渲染物体的渲染器。输出一张贴图。
  2. 将该贴图进行操作,包括高斯模糊或者扩张颜色范围。
  3. 将扩张后贴图减去原图,得到轮廓。
  4. 将轮廓粘在屏幕上。

我当时想到这里的时候有点手足无措,因为如果要将一个物体单独为它渲染一张贴图, 我第一时间想到的就是为一个物体单独设置一个相机来渲染,但是这样的后果就是,我场景里物体越多,需要的相机越多,而且如果是根据layer来区分一个相机的渲染目标的话,每个相机都需要一个独立的层级,每个物体都需要一个独立的层级。而且为了将这些相机与主相机适配,所以它们的Transform参数都应该同步。。。。这样看起来,及其麻烦而且费力不讨好,所以我最开始构想的时候想到这里就打住了,因为这样做还不如不做,最终性能就不断往上面叠,而且效果还可能很差。之后就没想过再做描边这个事情。

之后在网上看了一些命令缓冲的文章,又让我对描边起了想法。命令缓冲可以指定用一种材质渲染一个渲染器,将渲染好的图像输出一张RenderTexture,这无疑是很好的。每个命令缓冲都和一张RenderTexture存在一一对应的关系,用字典可以轻松的描述它们,在我的项目中,当某个物体生成时:

    public void Add(ICon getICon)
    {
        CommandBuffer newBuffer = new CommandBuffer();
        RenderTexture newTexture = RenderTexture.GetTemporary(Screen.width, Screen.height, 0);
        newBuffer.SetRenderTarget(newTexture);
        newBuffer.ClearRenderTarget(true, true, Color.black);
        Material newMaterial = new Material(ColorShader);
        Color materialColor;
        TestIConPool.ColorDictionary.TryGetValue(getICon.returnNumOfDic(), out materialColor);
        newMaterial.SetColor("_OutLineColor", materialColor * 10f);
        newBuffer.DrawRenderer(getICon.myRenderer, newMaterial);
        BufferDic.Add(newBuffer, newTexture);
        IConBufferDic.Add(getICon, newBuffer);
    }

其中 TestIConPool指的是物体颜色池,物体将会根据类型索引从池子里拿出对应的颜色出来;ColorShader仅仅是非常简单的输出一张纯色模型的Shader;BufferDic就是存放缓冲与渲染纹理的字典,类型是<CommandBuffer, RenderTexture>。同时,还将ICon(即项目中物品这个类型的抽象子类)与缓冲保存在一起形成一个字典,该字典的类型为<ICon, CommandBuffer>。保存这个字典的原因是,当场景销毁这个物品时,也需要将对应的Buffer和纹理清除:

    public static void Remove(ICon removeICon)
    {
        CommandBuffer removebuffer;
        IConBufferDic.TryGetValue(removeICon, out removebuffer);
        IConBufferDic.Remove(removeICon);
        RenderTexture getRT;
        BufferDic.TryGetValue(removebuffer, out getRT);
        BufferDic.Remove(removebuffer);
        removebuffer.Release();
        removebuffer = null;
        RenderTexture.ReleaseTemporary(getRT);
        getRT = null;
    }

在我的项目中,清除一个物品的情况很多,但是每个情况都要依赖于这个脚本的实例的话就会很麻烦,所以Remove是一个静态方法,以便于其他脚本调用。

软描边

一般描边分为两种,一种是硬描边,第二种是软描边(也有些游戏里描边是先硬然后逐渐软的那种)。这两种描边无非是采样的时候稍微修改一下就好了。软描边非常简单,只需要高斯模糊+纹理叠加Pass+纹理相减Pass就可以了。Shader很简单:

Shader "Custom/OutLineShader"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _OutLineSize("OutLineSize",int)=4
        _OutLineTexture("OutLineTexture",2D)="white"{}
        _ObjectTexture("BlurTexture",2D)="white"{}
        _OutLineColor("OutLineColor",Color)=(1,1,1,1)
    }
    CGINCLUDE
    float _OutLineSize;
    sampler2D _MainTex;
    float4 _MainTex_TexelSize;
    sampler2D _ObjectTexture;
    sampler2D _OutLineTexture;
    float4 _OutLineColor;
    
    struct VertexData
    {
        float4 vertex:POSITION;
        float2 uv:TEXCOORD0;
    };
    struct VertexToFragmentBlur
    {
        float4 pos:SV_POSITION;
        float2 uv[5]:TEXCOORD0;
    };

    VertexToFragmentBlur vertexBlur(VertexData v)
    {

        VertexToFragmentBlur VToFB;
        VToFB.pos=UnityObjectToClipPos(v.vertex);
 
        VToFB.uv[0]=v.uv;
        VToFB.uv[1]=float2(1,0)*_MainTex_TexelSize.xy*_OutLineSize;
        VToFB.uv[2]=float2(2,0)*_MainTex_TexelSize.xy*_OutLineSize;
        VToFB.uv[3]=float2(0,1)*_MainTex_TexelSize.xy*_OutLineSize;
        VToFB.uv[4]=float2(0,2)*_MainTex_TexelSize.xy*_OutLineSize;
 
        return VToFB;
    }

    fixed4 fragmentBlur(VertexToFragmentBlur VToFB):SV_TARGET
    {
        float weight[3]={0.4026,0.2442,0.0545};
        fixed4 getColor=tex2D(_MainTex,VToFB.uv[0])*weight[0];
 
        getColor+=tex2D(_MainTex,VToFB.uv[0]+VToFB.uv[1])*weight[1];
        getColor+=tex2D(_MainTex,VToFB.uv[0]-VToFB.uv[1])*weight[1];
        
        getColor+=tex2D(_MainTex,VToFB.uv[0]+VToFB.uv[2])*weight[2];
        getColor+=tex2D(_MainTex,VToFB.uv[0]-VToFB.uv[2])*weight[2];
 
        getColor+=tex2D(_MainTex,VToFB.uv[0]+VToFB.uv[3])*weight[1];
        getColor+=tex2D(_MainTex,VToFB.uv[0]-VToFB.uv[3])*weight[1];
 
        getColor+=tex2D(_MainTex,VToFB.uv[0]+VToFB.uv[4])*weight[2];
        getColor+=tex2D(_MainTex,VToFB.uv[0]-VToFB.uv[4])*weight[2];
 
        return getColor*0.626;
    }

    struct VertexToFragment
    {
        float4 pos:SV_POSITION;
        float2 uv:TEXCOORD0;
    };

    VertexToFragment myVertex(VertexData v)
    {
        VertexToFragment VToF;
        VToF.pos=UnityObjectToClipPos(v.vertex);
        VToF.uv=v.uv;
        return VToF;
    }

    fixed4 FragmentRemove(VertexToFragment VToF):SV_TARGET
    {
        float3 BlurColor= tex2D(_MainTex,VToF.uv);
        float3 objectColor=tex2D(_ObjectTexture,VToF.uv);
        float3 finalColor=BlurColor-objectColor;
        return fixed4(finalColor,1.0);
    }

    fixed4 FragmentAdd(VertexToFragment VToF):SV_TARGET
    {
        fixed4 screen = tex2D(_MainTex, VToF.uv);
        fixed4 outLine=tex2D(_OutLineTexture,VToF.uv);
        screen.rgb+=outLine.rgb;
        //fixed4 final=screen*(1-all(outLine))+_OutLineColor*any(outLine.rgb);
        return screen;
    }


    ENDCG

    SubShader
    {
        Cull Off ZWrite Off ZTest Always
        pass
        {//0
            CGPROGRAM
            #include "UnityCG.cginc"
            #pragma vertex vertexBlur
            #pragma fragment fragmentBlur
            ENDCG
        }

        pass
        {//1
            CGPROGRAM
            #include "UnityCG.cginc"
            #pragma vertex myVertex
            #pragma fragment FragmentRemove
            ENDCG
        }

        pass
        {//2
            CGPROGRAM
            #include "UnityCG.cginc"
            #pragma vertex myVertex
            #pragma fragment FragmentAdd
            ENDCG
        }
    }
}

这里只是多了两个片元着色器,将两个颜色相加的逻辑在这里而已。真正关键的代码在于后处理脚本中。这个时候上文中的字典的功能开始起作用了。如果对于每个纯色贴图都要同样的进行一遍高斯模糊,这样的效率是极低的。所以需要将所有物体的纯色贴图合并成一张,然后集体高斯模糊,然后再一个个剔除原来的贴图颜色。这种情况下,字典发挥了它的功用,即:

            RenderTexture RTA = RenderTexture.GetTemporary(src.width, src.height, 0);
            RenderTexture RTB = RenderTexture.GetTemporary(src.width, src.height, 0);
            foreach (KeyValuePair<CommandBuffer, RenderTexture> pair in BufferDic)
            {
                Graphics.ExecuteCommandBuffer(pair.Key);
                GetMaterial.SetTexture("_OutLineTexture", pair.Value);
                Graphics.Blit(RTA, RTB, GetMaterial, 2);
                Graphics.Blit(RTB, RTA);
            }
            RenderTexture bufferA = RenderTexture.GetTemporary(src.width, src.height, 0);
            RenderTexture bufferB = RenderTexture.GetTemporary(src.width, src.height, 0);

            GetMaterial.SetFloat("_OutLineSize", outLineSize);
            Graphics.Blit(RTA, bufferA, GetMaterial, 0);
            Graphics.Blit(bufferA, bufferB, GetMaterial, 0);
            for (int t = 0; t < BlurSize; t++)
            {
                Graphics.Blit(bufferB, bufferA, GetMaterial, 0);
                Graphics.Blit(bufferA, bufferB, GetMaterial, 0);
            }
            //Graphics.Blit(bufferB, RTA);

            foreach (KeyValuePair<CommandBuffer, RenderTexture> pair in BufferDic)
            {
                GetMaterial.SetTexture("_ObjectTexture", pair.Value);
                Graphics.Blit(bufferB, RTA, GetMaterial, 1);
                Graphics.Blit(RTA, bufferB);
            }

            GetMaterial.SetTexture("_OutLineTexture", bufferB);
            Graphics.Blit(src, dst, GetMaterial, 2);

            RenderTexture.ReleaseTemporary(bufferA);
            RenderTexture.ReleaseTemporary(bufferB);
            RenderTexture.ReleaseTemporary(RTA);
            RenderTexture.ReleaseTemporary(RTB);
            RTA.Release();
            RTB.Release();

其中,ExecuteCommandBuffer为渲染目标缓冲的方法,渲染后该命令缓冲对应的RT才会存在图像,由于两个值都存在同一个字典里,遍历字典的时候就可以很轻松的拿到它们,即pair.key和pair.value。然后将它们合并后进行高斯模糊的反复渲染工作。渲染好后,再次遍历字典,挨个将对应的贴图掏空,最终粘在屏幕上。即如下图所示:

屏幕原图:

其中除了主角那个垃圾车以外,其他的物品根据各自在颜色池的索引渲染出来的颜色集合在一起后(即第一次字典遍历以后)的效果是:

将这张图统一高斯模糊,然后进行镂空(即第二次字典遍历以后)后,效果是这样的:

然后就可以非常简单的粘在屏幕上:

关于这个方法的两个坑:

1. 将多张贴图合并到一起,最好使用两张临时贴图而仅是一章。因为不能骑驴找驴,需要两张图类似于左右手一样工作,即:

                Graphics.Blit(RTA, RTB, GetMaterial, 2);
                Graphics.Blit(RTB, RTA);

2. 由于RenderTexture本身不能手动使用New实例化,都是使用GetTemporary来获得一张临时的纹理。这样的方法很类似于从对象池里拿出但不拷贝的值。这样的方法节省了内存,但后果就是RenderTexture随时保存了一组临时纹理,当拿到它时,很可能是之前其他代码用过后不管的颜色,或者是一些垃圾颜色。常用ReleaseTemporary来进行销毁,但是这样的销毁似乎只是清除了引用,而不是销毁了里面的颜色。

我在测试该功能的时候,常常发生了这样的情况:

这种情况就是上一帧的一些RenderTexture没有正确清除颜色所造成的, ReleaseTemporary并不能将RT的硬件资源卸载。下一次拿到的RT很可能就是上一次的并未清除的RT的引用,这就导致颜色持久留在了屏幕上,形成了这样的拖尾。对于这种非托管资源来说,正确的卸载方式是:

            RTA.Release();
            RTB.Release();

硬描边

您也看到了,单纯的软描边在一些物体上不是那么明显,感觉有点若隐若现。所以最终我采用了硬描边。硬描边看起来更难,实际上更简单一些,只需要单纯的外扩就行,连反复渲染的步骤都免了(亏得我之间做出了软描边想要改成硬描边的时候想那么久)。

首先,将采样的时候的权重都改为1,即单纯的获得外面的颜色就好:

然后让这个BlurPass的后处理代码删掉反复渲染(不关其实也没事):

            RenderTexture RTA = RenderTexture.GetTemporary(src.width, src.height, 0);
            RenderTexture RTB = RenderTexture.GetTemporary(src.width, src.height, 0);
            foreach (KeyValuePair<CommandBuffer, RenderTexture> pair in BufferDic)
            {
                Graphics.ExecuteCommandBuffer(pair.Key);
                GetMaterial.SetTexture("_OutLineTexture", pair.Value);
                Graphics.Blit(RTA, RTB, GetMaterial, 2);
                Graphics.Blit(RTB, RTA);
            }

            RenderTexture bufferA = RenderTexture.GetTemporary(src.width, src.height, 0);
            RenderTexture bufferB = RenderTexture.GetTemporary(src.width, src.height, 0);

            GetMaterial.SetFloat("_OutLineSize", outLineSize);
            Graphics.Blit(RTA, bufferA, GetMaterial, 0);
            Graphics.Blit(bufferA, bufferB, GetMaterial, 0);

            foreach (KeyValuePair<CommandBuffer, RenderTexture> pair in BufferDic)
            {
                GetMaterial.SetTexture("_ObjectTexture", pair.Value);
                Graphics.Blit(bufferB, RTA, GetMaterial, 1);
                Graphics.Blit(RTA, bufferB);
            }

            GetMaterial.SetTexture("_OutLineTexture", bufferB);
            Graphics.Blit(src, dst, GetMaterial, 2);

            RenderTexture.ReleaseTemporary(bufferA);
            RenderTexture.ReleaseTemporary(bufferB);
            RenderTexture.ReleaseTemporary(RTA);
            RenderTexture.ReleaseTemporary(RTB);
            RTA.Release();
            RTB.Release();
        }

然后调整OutLineSize就可以了,最终输出的样式为:

        我个人觉得还行,还是挺满意的。做这个描边大约花了两三天,前前后后反复改了好多地方,但是其实最大的感受不是XX功能很强大,XX方法很好用这种比较套路化的想法,而是感觉,一个效果,单纯的做出来和应用到项目往往中间存在很大的隔阂。优秀的游戏程序员不仅仅是做出漂亮的效果,还要让这个效果能用上,能用好。

后记:关于边缘检测的一些问题

        这篇续文写于一年后,去年写完这篇文章我便去考研了,所以一年都没有碰这些东西,说实话自己也忘了不少了,最近才慢慢捡起来,偶尔在一本关于shader中的书翻到了关于边缘检测的内容,在原本的这篇博客中,只是用命令缓冲完成了关于具体物体的描边,而关于全局的描边其实是效果不好的,如果回头去看那张图会发现,很多细节的描边都没有表达出来,当时自己看到的时候其实是不以为意的,因为我当时把这些问题都归咎于深度贴图或者法线贴图不精确所导致的,所以后面在寻找解决方案的时候,也是仅仅局限于“如何获得更精确的深度贴图”这个问题上。但是这两天看完了那本书上的代码,反复揣摩了一下,自己出现了不少问题,所以这篇“续文”也主要集中在这些问题上。

        回头看上文关于“使用深度贴图和法线贴图进行描边”的内容,发现自己有一个重大错误,即:没有区分深度图像素深度与当前屏幕像素深度的采样区别。它们的区别是:

  • 深度图像素深度获得:1.将当前深度图采样,获得当前像素在裁剪空间(即裁剪后)的深度值(假设为t)。2.将该值使用Linear01Depth函数,获得该深度图像素在视角空间中的深度值。
  • 当前屏幕像素深度获得:即为视角空间下的Z轴分量的负数。也等价于裁剪空间的W分量的值。

        但是我在上文中的代码中并没有体现这一点,这个问题也在下文中的代码中修正,即在每个像素采样的时候都顺带采用了Linear函数来线性化深度。并且,如果有某位网友看过这篇文章并借鉴了,我很对不起。我在上文中遇见的最重要的问题就是这个,当然也有很多问题和心得,首先我把修正后的代码贴出来,然后在慢慢讲问题:

Shader "Hidden/BlogEdgeShader"
{
    Properties
    {
        _MainTex("Texture",2D)="white"{}
        _EdgeColor("EdgeColor",Color)=(1,1,1,1)
        _SampleRange("SampleRange",float)=1.0
        _NormalDiffThreshold("NormalDiffThreshold",float)=1.0
    }
 
    CGINCLUDE
    #include "UnityCG.cginc"
 
    struct VertexData
    {
        float4 vertex:POSITION;
        float2 uv:TEXCOORD0;
    };
 
    struct VertexToFragment
    {
        float4 pos:SV_POSITION;
        float2 uv[9]:TEXCOORD0; 
    };
 
    sampler2D _MainTex;
    float4 _MainTex_TexelSize;
 
    sampler2D _CameraDepthNormalsTexture;
    sampler2D _CameraDepthTexture;
    float4 _EdgeColor;
    float _SampleRange;
    float _NormalDiffThreshold;
 
    VertexToFragment myVertex(VertexData v)
    {
        VertexToFragment VToF;
        VToF.pos=UnityObjectToClipPos(v.vertex);
        VToF.uv[0]=v.uv+float2(-1,1)*_MainTex_TexelSize*_SampleRange;
        VToF.uv[1]=v.uv+float2(0,1)*_MainTex_TexelSize*_SampleRange;
        VToF.uv[2]=v.uv+float2(1,1)*_MainTex_TexelSize*_SampleRange;
        VToF.uv[3]=v.uv+float2(-1,0)*_MainTex_TexelSize*_SampleRange;
        VToF.uv[4]=v.uv;
        VToF.uv[5]=v.uv+float2(1,0)*_MainTex_TexelSize*_SampleRange;
        VToF.uv[6]=v.uv+float2(-1,-1)*_MainTex_TexelSize*_SampleRange;
        VToF.uv[7]=v.uv+float2(0,-1)*_MainTex_TexelSize*_SampleRange;
        VToF.uv[8]=v.uv+float2(1,-1)*_MainTex_TexelSize*_SampleRange;
        return VToF;
    }
 
    float CheckNormalEdge(fixed3 a,fixed3 b)
    {
        float2 normalDiff=abs(a.xy-b.xy);
        return (normalDiff.x+normalDiff.y)<_NormalDiffThreshold;
    }

    float CheckNormalEdgeNew(fixed3 a,fixed3 b)
    {
        return 1.0-dot(normalize(a),normalize(b))<_NormalDiffThreshold;
    }

    float CheckDepthEdge(fixed a,fixed b)
    {
        return abs(a-b)<0.15*b;
    }
 
    fixed4 myFragmentDepth(VertexToFragment VToF):SV_TARGET
    {
        fixed4 getColor=tex2D(_MainTex,VToF.uv[4]);
        fixed edge0=tex2D(_CameraDepthTexture,VToF.uv[0]);
        float getEdge0=Linear01Depth(edge0);    

        fixed edge1=tex2D(_CameraDepthTexture,VToF.uv[1]);
        float getEdge1=Linear01Depth(edge1);  

        fixed edge2=tex2D(_CameraDepthTexture,VToF.uv[2]);
        float getEdge2=Linear01Depth(edge2);  

        fixed edge3=tex2D(_CameraDepthTexture,VToF.uv[3]);
        float getEdge3=Linear01Depth(edge3);  

        fixed edge4=tex2D(_CameraDepthTexture,VToF.uv[4]);
        float getEdge4=Linear01Depth(edge4);  

        fixed edge5=tex2D(_CameraDepthTexture,VToF.uv[5]);
        float getEdge5=Linear01Depth(edge5);  

        fixed edge6=tex2D(_CameraDepthTexture,VToF.uv[6]);
        float getEdge6=Linear01Depth(edge6);  

        fixed edge7=tex2D(_CameraDepthTexture,VToF.uv[7]);
        float getEdge7=Linear01Depth(edge7);  

        fixed edge8=tex2D(_CameraDepthTexture,VToF.uv[8]);
        float getEdge8=Linear01Depth(edge8);  
 
        float result=1.0;
        result*=CheckDepthEdge(getEdge0,getEdge4);
        result*=CheckDepthEdge(getEdge1,getEdge4);
        result*=CheckDepthEdge(getEdge2,getEdge4);
        result*=CheckDepthEdge(getEdge3,getEdge4);
        
        result*=CheckDepthEdge(getEdge5,getEdge4);
        result*=CheckDepthEdge(getEdge6,getEdge4);
        result*=CheckDepthEdge(getEdge7,getEdge4);
        result*=CheckDepthEdge(getEdge8,getEdge4);
        return lerp(_EdgeColor,getColor,result);
 
    }
 
    fixed4 myFragment(VertexToFragment VToF):SV_TARGET
    {
        fixed4 getColor=tex2D(_MainTex,VToF.uv[4]);
        fixed4 edge0=tex2D(_CameraDepthNormalsTexture,VToF.uv[0])*2.0-1.0;
        fixed4 edge1=tex2D(_CameraDepthNormalsTexture,VToF.uv[1])*2.0-1.0;
        fixed4 edge2=tex2D(_CameraDepthNormalsTexture,VToF.uv[2])*2.0-1.0;
        fixed4 edge3=tex2D(_CameraDepthNormalsTexture,VToF.uv[3])*2.0-1.0;
        fixed4 edge4=tex2D(_CameraDepthNormalsTexture,VToF.uv[4])*2.0-1.0;
        fixed4 edge5=tex2D(_CameraDepthNormalsTexture,VToF.uv[5])*2.0-1.0;
        fixed4 edge6=tex2D(_CameraDepthNormalsTexture,VToF.uv[6])*2.0-1.0;
        fixed4 edge7=tex2D(_CameraDepthNormalsTexture,VToF.uv[7])*2.0-1.0;
        fixed4 edge8=tex2D(_CameraDepthNormalsTexture,VToF.uv[8])*2.0-1.0;
        float result=1.0;
        
        result*=CheckNormalEdgeNew(edge0,edge4);
        result*=CheckNormalEdgeNew(edge2,edge4);
        result*=CheckNormalEdgeNew(edge3,edge4);
        result*=CheckNormalEdgeNew(edge1,edge4);
        result*=CheckNormalEdgeNew(edge5,edge4);
        result*=CheckNormalEdgeNew(edge6,edge4);
        result*=CheckNormalEdgeNew(edge7,edge4);
        result*=CheckNormalEdgeNew(edge8,edge4);

        return lerp(_EdgeColor,getColor,result);
    }
    ENDCG
 
    SubShader
    {
        pass
        {
            CGPROGRAM
            #pragma vertex myVertex
            #pragma fragment myFragment
            ENDCG
        }
        pass
        {
            CGPROGRAM
            #pragma vertex myVertex
            #pragma fragment myFragmentDepth
            ENDCG
        }
    }
}

后处理的C#代码没有变化,然后删去了一些屁用没有的变量,这篇代码解决了前文很多问题与暗坑,我一个个分析:

1. 关于采样的对比像素

        在不同的像素的深度或者法线贴图的像素进行比对时,不能胡乱的两两配对,稍微好一点的,可以用与中心点对称的像素进行比对,而最好的方法是,将临近的像素(本文中采样了临近的九个像素)都与中心点进行比对,这样的效果最为平均。

2.关于边缘检测的值类型

        首先我们可以知道,深度图采样以后,结果值是一个数字,法线贴图采样以后,得出的是一个颜色。从这个角度来看,两者理应使用不同的深度检测方法,并且,在我前文写过的《纹理》这篇博客的里也写过,当法线贴图采样以后,得出的颜色处于[-1,1]之间,需要进行映射使之介于像素所在的[0,1]之间,即

Normal=tex2Dpixel*2-1

在代码中很长的一段*2-1都体现了这一点。只有这样处理过后才能采样到正确的像素法线。而同样的,对于线性深度值的获得过程中,也需要注意,即应当使用Linear01Depth函数而不是LinearEyeDepth函数,否则最终获得的值很可能不正确。

3.关于边缘检测的算法:

        上文说了深度值和法线值是不同类型,对于它们之间的处理也会有区别。

深度值的边缘检测:其遵循一个方法

        权重值=abs(a-b)<_既定值A*b

其中,a为指定的临近像素的深度值,b为中心点像素的深度值,既定值A可以自己手动定义,一般为0.1左右(如果既定值越大,则深度判断越细腻,但是过大就会丢失判断,如果过小,在一些临近值上会出现颜色统一覆盖一个片区的情况,一般的经验值经过测试为0.12到0.15之间),其返回的权重值代表了当前像素与临近像素的深度差的权重,我们可以用这个权重代表它是否处于深度的边缘。

法线的边缘检测:由于法线是一个颜色(也可以认为是一个向量),我们也知道,两个向量之间的线性差别可以用点乘来表达,用其夹角的cos值来表示法线的区别,所以我们可以这样来表达法线的差别:

        权重值=1.0-dot(normalize(a),normalize(b))<_既定值B;

其中a为临近像素的法线,b为中心像素法线,并且两个方法的既定值也是不一致的(这里用了A和B区分)。之所以使用1减去点乘的结果,是为了把既定值B固定在1以内以便于更好的寻找合适的值。注意,法线的归一化是必须的,否则就会出现如下情况:

这就是法线不一致所导致的显示错误。

还有一种情况,如果将法线贴图看成某一个或两个颜色值,用和深度一样的检测方法(即最开始使用的方法)的话,也可能导致不正确,在较远的为位置容易出现丢失,如下图:

 而使用了点乘的正确方法以后,在远处的效果是这样的:

 然后再附上边缘检测的效果图:

终于重新回来写博客了,感觉不错。

猜你喜欢

转载自blog.csdn.net/qq_38601621/article/details/105534366