xiaozongpeng 发表于 2022-8-17 17:11

【UnityShader】描边Outline(2)

前言:

本章介绍一些笔者总结的描边算法。
以下所有测试基于Unity的BuildIn管线。
一、基于物体的描边

至今,大家听到最多的描边方式,基本上是“法线外扩+2Pass渲染”,虽然也似乎也确实是现今的通用做法,但在详细讲解这种方法之前,我还是想把其他方法过一遍,权当增长一下见识。
1. 基于观察角度和表面法线

从直觉来说,当我们观察一个物体时,物体的“边缘法线”与我们视线方向接近90度夹角。此方法正是基于这种认知——“通过视角方向和表面法线点乘结果来得到轮廓线信息”。
在顶点着色器中:
      v2f vert(appdata_base v)
      {
            v2f o;
            o.pos = UnityObjectToClipPos(v.vertex); //mvp变换
            o.viewDir = normalize(ObjSpaceViewDir(v.vertex)); //模型空间下,顶点与视线的方向
            o.normal = v.normal; //记录法线信息
            return o;
      }在片元着色器中:
      float4 frag(v2f i) : SV_TARGET
      {
            //step : 当 _Outline <= dot 时,输出1,否则0
            half factor = step(_Outline, dot(i.viewDir, normalize(i.normal)));
            return factor * fixed4(1,1,1,1);
      }搭建场景,看看结果:



借用一下经典时尚之bunny model

可以看见,对于简单模型,效果还行。但是对于复杂模型,模型内部存在的“凹凸”也会贡献边缘,局限性很大。
2. 模板测试描边

核心思想是,我们先在第一个Pass中正常地渲染一次模型,但同时写入模板值。
Stencil
{
   Ref 1
   Comp Always
   Pass Replace
}在第二个Pass中,在模型沿着法线外扩一定距离,且设置同样的模板值,但是把“比较方式”设置为“NotEqual”。
Stencil
{
    Ref 1
    Comp NotEqual
}Comp NotEqual 即只有当前参考值 Ref 和当前模板缓冲区的值不相等的时候才去渲染片元。注意到,Unity的模板缓冲区的默认值是0,因此在外轮廓线之内的片元,我们在第一个Pass中写入到模板缓冲区的值为1,因此第二次Pass中相等,就不会去选择渲染;而外轮廓线向外扩张出来的顶点所形成的那些片元,由于第一个Pass并未渲染,模板缓冲区的值为0,因此不相等,就会按第二个Pass的方法得到结果。完整代码如下:
Shader "Unlit/StencilOutline"
{
    Properties
    {
      _Outline ("Outline", Range(0,1)) = 0.1
      _OutlineColor("Outline Color", Color) = (0,0,0,0)
    }
    SubShader
    {
      Pass
      {
            Stencil //模板测试设置
            {
                Ref 1
                Comp Always
                Pass Replace
            }
            
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            float4 vert(float4 v : POSITION) : SV_POSITION
            {
                return UnityObjectToClipPos(v);
            }
            float4 frag() : SV_TARGET
            {
                return float4(1,1,1,1); //直接渲染白色
            }
            ENDCG
      }
      Pass
      {
            Stencil //模板测试设置
            {
                Ref 1
                Comp NotEqual
            }
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag   

            float _Outline;
            fixed4 _OutlineColor;
            
            struct appdata
            {
                float4 pos : POSITION;
                float3 normal : NORMAL;
            };
            struct v2f
            {
                float4 pos : SV_POSITION;
            };

            v2f vert(appdata v)
            {
                v2f o;

                float4 pos = mul(UNITY_MATRIX_MV, v.pos); //计算观察空间坐标
                float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal); //法线转到观察空间
                normal.z = -0.5; //trick:手动把法线z分量设为负值(其实是View空间的z正方向)
                //尽可能避免背面扩张后的顶点挡住正面的面片
                pos = pos + float4(normalize(normal), 0) * _Outline; //观察空间下,进行法线外扩
                o.pos = mul(UNITY_MATRIX_P, pos); //变换到裁切空间

                return o;
            }
            float4 frag(v2f i) : SV_TARGET
            {
                return float4(_OutlineColor.rgb, 1);
            }
            ENDCG
      }
    }
}
结果长这样:



模板测试+法线外扩

值得注意的是,这种方式依赖于“模板测试”,如果不同的物体写进了相同的模板缓冲区,那么结果可想而知的会是这样:



球体与兔子发生重叠,交叉部分模板测试失败

当然,我们不能说这种结果是“错误”的,有些时候,这种效果正是我们想要的也说不定呢。
3. Back facing描边法

此方法就是正儿八经的2个pass+正/背面剔除实现的描边。
第一个Pass正常渲染与背面剔除
Pass
      {
            Cull Back            
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            float4 vert(float4 v : POSITION) : SV_POSITION
            {
                return UnityObjectToClipPos(v);
            }
            float4 frag() : SV_TARGET
            {
                return float4(1,1,1,1);
            }
            ENDCG
      }第二个Pass使用正面剔除,顶点沿法线外扩
      Pass
      {
            Cull Front
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag   

            float _Outline;
            fixed4 _OutlineColor;
            
            struct appdata
            {
                float4 pos : POSITION;
                float3 normal : NORMAL;
            };
            struct v2f
            {
                float4 pos : SV_POSITION;
            };

            v2f vert(appdata v)
            {
                v2f o;
                float3 pos = v.pos.xyz + v.normal * _Outline; //直接在模型空间外扩
                o.pos = UnityObjectToClipPos(float4(pos,1));
                return o;
            }
            float4 frag(v2f i) : SV_TARGET
            {
                return float4(_OutlineColor.rgb, 1);
            }
            ENDCG
      }结果长这样:



Outline = 0.036

如果是一般的描边,到此就差不多了,但有没有什么办法实现更好的效果呢?
3.1 在NDC空间进行外扩

如果我们把摄像机拉近,可以看见描边变粗:



拉近场景摄像机,描边数值未改变,但是变粗

这是因为描边的宽度现在是相对世界空间不变的,这相机拉近后,显示就会变粗。我们期望无论摄像机拉近拉远,描边的粗细都能不变。要解决这个问题,可以通过将法线外扩的大小调整为使用NDC空间的距离进行外扩修改顶点着色器:
v2f vert(appdata v)
{
    v2f o;
    float4 pos = UnityObjectToClipPos(v.pos);
    float3 viewNormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal.xyz);
    float3 clipNormal = normalize(TransformViewToProjection(viewNormal.xyz)) * pos.w;//将法线变换到裁切空间
    pos.xy += 0.1 * _Outline * clipNormal .xy; //手动缩小一点系数
    o.pos = pos;
    return o;
}


NDC空间外扩, Outline = 0.07



拉近摄像机,宽度不变

这里值得说一下,说是在“NDC”空间进行外扩,实际上我们是把法线转换到裁切空间后,手动乘以pos.w系数进行计算。
为什么这样做?是因为顶点着色器输出裁切空间坐标后,进入片元着色器之前,渲染管线会自动进行透视除法与插值。
我们希望得到的是显示在屏幕上的固定宽度的轮廓线,那么顶点向外延伸的距离应该是ndc空间下的固定距离,而不是投影空间下的固定距离。于是我在投影空间下做计算的时候只要将轮廓线宽度乘上w值,再后续的计算中,管线会将坐标值除以w,得到的仍然是人为设定的轮廓线宽度。3.2 修正屏幕长宽比

当我们把描边宽度调大一些,可以看见:



描边不均等

这是因为Game视图设置的测试屏幕是FullHD 16:9的分辨率,从NDC空间到屏幕空间的过程中,被拉伸了:
ScreenX = NDCx * pixelWidth/2 + pixelWidth/2;
ScreenY = NDCy * pixelHeight/2 + pixelHeight/2;我们需要根据屏幕长宽比手动修正一下坐标:
float4 pos = UnityObjectToClipPos(v.pos);
float3 viewNormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal.xyz);
float3 ndcNormal = normalize(TransformViewToProjection(viewNormal.xyz)) * pos.w;//将法线变换到NDC空间
float aspect = _ScreenParams.y / _ScreenParams.x; //计算屏幕长宽比,_ScreenParams为unity内置变量
ndcNormal.x *= aspect; //进行等比缩放
pos.xy += 0.1 * _Outline * ndcNormal.xy;
o.pos = pos;


好耶,正常了

3.3 平均法线

只要提到法线外扩描边,就不得不提“平均法线”。



未平均法线的结果

原因很好理解:模型默认的法线都是垂直于表面,当有转角时,两个垂直方向外扩,自然会出现断边。
我们使用一个临时工具,平滑一下法线,把新的法线信息写入切线通道:
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;

public class SmoothNormalTool
{
   
    public static void WriteAverageNormalToTangentToos()
    {
      var meshFilter = Selection.activeGameObject.GetComponent<MeshFilter>();
      var mesh = meshFilter.sharedMesh;
      WirteAverageNormalToTangent(mesh);
    }
    private static void WirteAverageNormalToTangent(Mesh mesh)
    {
      var averageNormalHash = new Dictionary<Vector3, Vector3>();
      for (var j = 0; j < mesh.vertexCount; j++)
      {
            if (!averageNormalHash.ContainsKey(mesh.vertices))
            {
                averageNormalHash.Add(mesh.vertices, mesh.normals);
            }
            else
            {
                averageNormalHash] =
                  (averageNormalHash] + mesh.normals).normalized;
            }
      }
      var averageNormals = new Vector3;
      for (var j = 0; j < mesh.vertexCount; j++)
      {
            averageNormals = averageNormalHash];
      }

      var tangents = new Vector4;
      for (var j = 0; j < mesh.vertexCount; j++)
      {
            tangents = new Vector4(averageNormals.x, averageNormals.y, averageNormals.z, 0);
      }
      mesh.tangents = tangents;
    }
}
核心思想就是,如果有一个点被多个面共用,且有新的法线信息,则简单地把新的法线与原本法线相加后归一化。
选择模型,使用工具更新法线后,修改shader代码:
struct appdata
{
    float4 pos : POSITION;
    float3 normal : NORMAL;
    float3 tangent : TANGENT; //增加切线插值器
};
...
float3 viewNormal = mul((float3x3)UNITY_MATRIX_IT_MV, v.tangent.xyz);//使用切线信息再看看结果:



平均法线后的结果

值得注意的是,现在我们的处理手段只是非常简单朴素的方法,实际生产中,还需制定一定的工作流和规范(比如还可以通过顶点色控制法线的颜色与粗细等等)。但核心思想就是:对法线进行一定的处理。
二、基于后处理的描边

同样的,这是一种基于SS(Screen Space)的技术,使用已经渲染的场景图像,通过特定手段来计算出描边。此方法也在《UnityShader入门精要》12章提及。
1. 基本概念与定义

那么问题来了,当我们在说“描边”“边缘”时,实际上是在说什么?



7、9、11号像素与a、d、f算边缘吗?

像素1~6号算边缘吗?像素13、16、20呢?直观来说,两个像素的颜色差值过大时,我们会认为存在“边缘”。
基于上述认知,实际在处理图像时,我们认为“边缘(Edge) 是指图像局部特性的不连续性,灰度或结构等信息的突变处称之为边缘。”
例如,灰度级的突变、颜色的突变,、纹理结构的突变等。边缘是一个区域的结束,也是另一个区域的开始,利用该特征可以分割图像。这些概念实际上是CV(Computer Vision)(计算机视觉)的研究范畴,我们拾人牙慧,拿过来用用。
至此,问题变成了——如何计算像素与其临近像素差值?
通用做法是“卷积”。
在图像处理中,卷积操作指使用一个卷积核对一张图像中的每个像素进行一系列操作。可以用来实现图像模糊、边缘检测等效果。那么什么是“卷积核”呢?
通常时一个四方形网格结构,该区域内每个方格都有一个权重值,进行卷积操作时,把卷积核的中心位置放置于该像素上,翻转核之后再依次计算核中每个元素和其覆盖的图像像素值的乘积并求和,得到的结果就是该位置的新像素值。

通俗来讲,我们使用一个“网”,去收集、计算某个中心点像素周围的信息,得到的这个结果,可以是新的像素值(模糊),也可以表示中心像素与周围像素的差值(边缘),我们把这个过程称之为“卷积”。
2. Sobel算子

不同的卷积核会直观地影响卷积结果。在这里,我们使用“Sobel”卷积核(也称之为Sobel算子)。



不同卷积核(算子)的大小与数值

【Gx】与【Gy】分别代表了横向梯度权重与纵向梯度权重,以此计算一个像素与周围像素的梯度信息,梯度值的绝对值越大,则表明越在边缘。
梯度的本意是一个向量(矢量),表示某一函数在该点处的方向导数沿着该方向取得最大值,即函数在该点处沿着该方向(此梯度的方向)变化最快,变化率最大(为该梯度的模)。一般的,直接把“梯度”理解成两个数的差值即可。
3. 代码与示例

有了基本概念之后,我们的Shader长这样:
Shader "Custom/MyOutline"
{
    Properties
    {
      _MainTex ("Texture", 2D) = "white" {} //渲染纹理
      _EdgeColor("Edge Color", color) = (0,0,0,1) //描边颜色
      _EdgeThreshold("EdgeThreshold", float) = 0.1 //描边阈值
    }
    SubShader
    {
      CGINCLUDE

      #include "UnityCG.cginc"
      sampler2D _MainTex;

      uniform half4 _MainTex_TexelSize;

      fixed4 _EdgeColor;
      half _EdgeThreshold;
      half _EdgeOnly;

      struct v2f
      {
            float4 pos : SV_POSITION;
            half2 uv : TEXCOORD0; //声明Sobel算子插值器
      };

      v2f vert(appdata_img v)
      {
            v2f o;
            o.pos = UnityObjectToClipPos(v.vertex);
            half2 uv = v.texcoord;

            //定义周围像素的uv值
            o.uv = uv + _MainTex_TexelSize.xy * half2(-1,-1);
            o.uv = uv + _MainTex_TexelSize.xy * half2(0,-1);
            o.uv = uv + _MainTex_TexelSize.xy * half2(1,-1);
            o.uv = uv + _MainTex_TexelSize.xy * half2(-1,0);
            o.uv = uv + _MainTex_TexelSize.xy * half2(0,0);
            o.uv = uv + _MainTex_TexelSize.xy * half2(1,0);
            o.uv = uv + _MainTex_TexelSize.xy * half2(-1,1);
            o.uv = uv + _MainTex_TexelSize.xy * half2(0,1);
            o.uv = uv + _MainTex_TexelSize.xy * half2(1,1);

            return o;
      }
      //计算灰度值
      fixed luminance(fixed4 color)
      {
            return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
      }
      //使用sobel算子进行卷积计算
      half Sobel(v2f i)
      {
            //定义sobel 算子
            const half Gx = {-1,0,1,
                                                                -2,0,2,
                                                                -1,0,1};
                        const half Gy = {-1, -2, -1,
                                                                0,0,0,
                                                                1,2,1};
            half texColor;
            half edgeX = 0;
            half edgeY = 0;
            for(int it = 0; it < 9; it++)
            {
                //uv采样
                texColor = luminance(tex2D(_MainTex, i.uv));
                //累积权重
                edgeX += texColor * Gx;
                edgeY += texColor * Gy;
            }
            //ref: https://zhuanlan.zhihu.com/p/532483809
            half edge = 1 - abs(edgeX) - abs(edgeY);
            return edge;                     
      }
      fixed4 frag(v2f i) : SV_TARGET
      {
            half edge = Sobel(i); //进行sobel卷积
            edge = saturate(edge + _EdgeThreshold); //阈值offset

            fixed4 withEdge = lerp(_EdgeColor, tex2D(_MainTex, i.uv), edge);
            fixed4 onlyEdge = lerp(_EdgeColor, fixed4(1,1,1,1), edge);
            fixed4 finalCol = lerp(withEdge , onlyEdge, _EdgeOnly);
            return finalCol;
      }
      ENDCG
      Pass
      {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            ENDCG
      }
    }
}
后处理C#代码长这样:
using UnityEngine;


public class PostOutline : MonoBehaviour
{
    public Shader shader;
    protected Material material;
    public Color EdgeColor;
   
    public float EdgeThreshold;
   
    public float edgeOnly;

    private void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
      if (shader != null && material == null)
      {
            material = new Material(shader);
      }
      if (material != null)
      {
            material.SetColor("_EdgeColor", EdgeColor);
            material.SetFloat("_EdgeThreshold", EdgeThreshold);
            material.SetFloat("_EdgeOnly", edgeOnly);
            Graphics.Blit(source, destination, material);
      }
      else
            Graphics.Blit(source, destination);
    }
}
把C#挂载到摄像机,指定shader,我们可以看见:



左:Game视图,右:Scene视图, EdgeThreshold = 0.355

值得注意的是,卷积算法认为“图像灰度突变即为边缘”,这种情况也会发生在光影的“明暗交界线”上,
我们调小“EdgeThreshold”,结果变成了:



EdgeThreshold = 0

可以看见,由于球体表面的光照信息过于光滑,相近的表面也能有不同的像素值,这导致了灰度变化而被认为是“边缘”。
解决办法可以是修改阈值,认为【Edge】值大于一定量才是“边缘”,也可以修改球体的Shader,减少光照变化。但无论如何,这种后处理描边的局限性总是存在的,需要酌情使用。
三、 扩展阅读

网易游学:描边技术总览和常见商业游戏中的描边方案(上)
网易游学:描边技术总览和常见商业游戏中的描边方案(下)
https://github.com/candycat1992/Unity_Shaders_Book/blob/master/Assets/Shaders/Chapter12/Chapter12-EdgeDetection.shader
四、 小结

其实关于描边这部分,还有一点想写的东西,无奈实在是太多了,写不动了。
只能说咱也是拾人牙慧,我非常建议去看看这些引用的文章,大佬们是真的写得好。
是我太膨胀了_(:з」∠)_。
参考


[*]^多种描边算法https://blog.csdn.net/weixin_47652005/article/details/120300175
[*]^从0开始的卡渲描边https://zhuanlan.zhihu.com/p/109101851
[*]^NDC外扩解析https://zhuanlan.zhihu.com/p/95986273
[*]^边缘定义https://blog.csdn.net/qq_47391835/article/details/123696861
[*]^卷积与卷积核https://zhuanlan.zhihu.com/p/532483809
页: [1]
查看完整版本: 【UnityShader】描边Outline(2)