2635404973 发表于 2023-5-16 14:03:35

[转载]在shader中实现五种描边方法

## 前言

本文为自己的一个学习笔记,以原理为主,每种方法之后都会给出对应完整的代码。

## 轮廓线渲染方法一览

在RTR3中,作者分成了5种类型(这在《Unity Shader入门精要》的P289页有讲):

* 基于观察角度和表面法线 通过视角方向和表面法线点乘结果来得到轮廓线信息。 简单快速,但局限性大。
* 过程式几何轮廓线渲染。 核心是两个Pass:第一个Pass只渲染背面并且让轮廓可见(比如通过顶点外扩);第二个Pass正常渲染正面。 快速有效,适应于大多数表面平滑的模型,但不适合立方体等平整模型。
* 基于图像处理。 可以适用于任何种类的模型。但是一些深度和法线变化很小的轮廓无法检测出来,如桌子上一张纸。
* 基于轮廓边检测。 检查这条边相邻的两个三角面片是否满足:(n0·v > 0) ≠ (n1·v > 0)。这里n0和n1分别表示两个相邻三角面片的法向,v是从视角到该边上任意顶点的方向。本质是检查相邻两个三角是否一个面向视角,另一个背向视角。 可以控制轮廓线的风格渲染。缺点是轮廓是逐帧单独提取的,帧与帧之间会出现跳跃性。
* 混合上述方法。 例如,首先找到轮廓线,把模型和轮廓边渲染到纹理中,再使用图像处理识别轮廓线,并在图像空间进行风格化渲染。

这里我分别使用**基于观察角度和表面法线** 、 **模板测试** 、 **过程式几何轮廓线** 、**基于图像处理(屏幕后处理)** 的方法对一个简单场景做了实现。最后简单使用了 **SDF** 的方法去进行描边实现。 由于关注的是轮廓线的渲染方法,故这里我尽量采用最小实现,也就没有考虑光照的效果了。

原模型(Blender中经典的猴头):

![](https://pic3.zhimg.com/80/v2-cc18298f8543a667f7554b4c808d053e_720w.webp)

不带描边不带纹理:

![](https://pic2.zhimg.com/80/v2-d2df2a2b9c16ffa5f6aadf6204a1cf55_720w.webp)


## 基于观察角度和表面法线

原理就是之前说的那样,这里直接放效果和完整代码,使用一个参数 _Outline 去控制轮廓线的粗细。

实现效果:

![](https://pic2.zhimg.com/80/v2-1425926a8917a881f680651b46fc11bd_720w.webp)

完整代码:


```cpp
Shader "Hbh Shader/View Outline Shading"
{
    Properties
    {
      _Outline ("Outline", Range(0, 1)) = 0.1
    }
    SubShader
    {
      Pass
      {
            Cull Back

            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            float _Outline;

            struct v2f
            {
                float4 pos : SV_POSITION;
                fixed4 color : COLOR;
            };

            v2f vert (appdata_base v)
            {   
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                float3 ObjViewDir = normalize(ObjSpaceViewDir(v.vertex));
                float3 normal = normalize(v.normal);
                float factor = step(_Outline, dot(normal, ObjViewDir));
                o.color = float4(1, 1, 1, 1) * factor;
                return o;
            }

            float4 frag(v2f i) : SV_Target
            {
                return i.color;
            }

            ENDCG
      }
    }
}
```


## 模板测试描边

实现原理:

首先,模板测试发生在逐片元操作阶段:

![](https://pic3.zhimg.com/80/v2-6ed51f4990018b25001a7d5b0f12f152_720w.webp)

![](https://pic4.zhimg.com/80/v2-76c96b82831a05d470ff809ebba26e8b_720w.webp)


我们这里在第一个pass中正常渲染,把每个片元的参考值 Ref 都设置为1,Comp Always 总是通过模板测试, 并且 Pass Replace (不写的话默认是 Pass Keep),即把当前的 Ref 写入模板缓冲。

第二个Pass中,我们把每个顶点按法线方向去进行一个扩张。这里我们选择先把顶点和法线变换到视角空间下,是为了让描边可以在观察空间达到最好的效果。随后设置法线的z分量,对其归一化后再将顶点沿其方向扩张,得到扩张后的顶点坐标。对法线的处理是为了尽可能避免背面扩张后的顶点挡住正面的面片。最后,我们把顶点从视角空间变换到裁剪空间。

在这个Pass中,我们同样把每个片元的参考值 Ref 都设置为1,Comp NotEqual 即只有当前参考值 Ref 和当前模板缓冲区的值不相等的时候才去渲染片元。注意到,Unity的模板缓冲区的默认值是0,因此在外轮廓线之内的片元,我们在第一个Pass中写入到模板缓冲区的值为1,因此第二次Pass中相等,就不会去选择渲染;而外轮廓线向外扩张出来的顶点所形成的那些片元,由于第一个Pass并未渲染,模板缓冲区的值为0,因此不相等,就会按第二个Pass的方法得到结果。

实现效果:

![](https://pic2.zhimg.com/80/v2-4dd7247e1a083eff2b7ca23450e4e075_720w.webp)

完整代码:


```cpp
Shader "Hbh Shader/Stencil Outline Shading"
{
    Properties
    {
      _Outline ("Outline", Range(0, 1)) = 0.1
      _OutlineColor ("Outline Color", Color) = (0, 0, 0, 1)
    }
    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 a2v
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
            };

            v2f vert (a2v v)
            {
                v2f o;

                float4 pos = mul(UNITY_MATRIX_MV, v.vertex);
                float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
                normal.z = -0.5;
                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
      }
    }
}
```

## 过程式几何轮廓线渲染

实现原理:

其实就是把前面的模板测试换成了剔除操作。正常渲染的时候剔除背面渲染正面,第二次顶点扩张之后剔除正面渲染背面,这样渲染背面时由于顶点外扩的那一部分就将被我们所看见,而原来的部分则由于是背面且不透明所以不会被看见,形成轮廓线渲染原理。因此从原理上也能看出,这里得到的轮廓线不单单是外轮廓线。

实现效果:

![](https://pic1.zhimg.com/80/v2-2cdc807ef7f2f1363443af5230bf8efc_720w.webp)

完整代码:


```cpp
Shader "Hbh Shader/Cull Outline Shading"
{
    Properties
    {
      _Outline ("Outline", Range(0, 1)) = 0.1
      _OutlineColor ("Outline Color", Color) = (0, 0, 0, 1)
    }
    SubShader
    {
      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
      {
            Cull Front

            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            float _Outline;
            fixed4 _OutlineColor;

            struct a2v
            {
                float4 vertex : POSITION;
                float3 normal : NORMAL;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
            };

            v2f vert (a2v v)
            {
                v2f o;

                float4 pos = mul(UNITY_MATRIX_MV, v.vertex);
                float3 normal = mul((float3x3)UNITY_MATRIX_IT_MV, v.normal);
                normal.z = -0.5;
                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
      }
    }
}
```

## 边缘检测

这种方法其实是用屏幕后处理效果去实现的(也就是基于图像处理)。

屏幕后处理,通常指的是在渲染完整个场景得到屏幕图像后再对这个图像进行一系列操作实现各种特效。这里实现的原理其实是使用特定的材质去渲染一个可以刚好填充整个屏幕的四边形面片。

而边缘检测的原理其实就是用一个特定的卷积核去对一张图像卷积,得到梯度值,再根据梯度值的大小去判断是否为边界。

![](https://pic2.zhimg.com/80/v2-37a0ea2ced8415b71cff9239b8951335_720w.webp)

实现效果:

![](https://pic4.zhimg.com/80/v2-c6c58d702f4c195fb9c81eab5f0b33e3_720w.webp)


具体代码可以看《Unity Shader入门精要》源码,场景为Scene_12_3:

[https://github.com/candycat1992/Unity_Shaders_Book**github.com/candycat1992/Unity_Shaders_Book**](https://link.zhihu.com/?target=https%3A//github.com/candycat1992/Unity_Shaders_Book)

## SDF方法

关于SDF我在之前的文章中有过分析:

[何博航:Signed Distance Field与Multi-channel signed distance field**54 赞同 · 3 评论**文章![](https://pic3.zhimg.com/v2-0f25bbca0e66d5c4033ce9542029bf62_180x120.jpg)](https://zhuanlan.zhihu.com/p/398656596)

之前也在UE4中实现过,但是还是刚接触Unity Shader没几天,对shaderlab还不熟悉。这里主要参考了前辈的文章,在其基础上稍作修改:

[拳四郎:Signed Distance Field**567 赞同 · 28 评论**文章![](https://pic4.zhimg.com/v2-82b73bfa648279f074e99a2183509ccf_180x120.jpg)](https://zhuanlan.zhihu.com/p/26217154)

描边结果:

![](https://pic3.zhimg.com/80/v2-4c848c994ba49e963de8a674910f427e_720w.webp)

原理其实很简单,这里的圆是在shader中根据SDF值绘制的。SDF值在边界处接近0,于是我们就通过SDF的fwidth值与当前像素的SDF值去判断,因为fwidth为相邻像素的SDF差值和,那么必然很小。所以判断的结果用于lerp,就可以检测哪里的SDF值接近0,亦即检测到轮廓。而aa也是简单地用smoothstep处理就好。

给出完整代码:

```cpp
Shader "OutlineShader/sdfOutline"
{
    Properties
    {
      _Color ("Color", Color) = (1, 1, 1, 1)
      _BackgroundColor ("BackgroundColor", Color) = (0, 0, 0, 1)
    }
    SubShader
    {
      Pass
      {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            float sdfCircle(float2 coord, float2 center, float radius)
            {
                float2 offset = coord - center;
                return sqrt((offset.x * offset.x) + (offset.y * offset.y)) - radius;
            }

            float4 render(float d, float3 color, float stroke)
            {
                float anti = fwidth(d) * 1.0;
                float4 colorLayer = float4(color, 1.0 - smoothstep(-anti, anti, d));
                bool flag = step(0.000001, stroke);
                float4 strokeLayer = float4(float3(0.05, 0.05, 0.05), 1.0 - smoothstep(-anti, anti, d - stroke));
                return float4(lerp(strokeLayer.rgb, colorLayer.rgb, colorLayer.a), strokeLayer.a) * flag + colorLayer * (1 - flag);
            }

            struct appdata
            {
                float4 vertex : POSITION;
            };

            struct v2f
            {
                float4 pos : SV_POSITION;
                float4 screenPos : TEXCOORD0;
            };

            fixed4 _Color;
            fixed4 _BackgroundColor;

            v2f vert (appdata v)
            {
                v2f o;
                o.pos = UnityObjectToClipPos(v.vertex);
                o.screenPos = ComputeScreenPos(o.pos);
                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                float2 pixelPos = (i.screenPos.xy / i.screenPos.w) * _ScreenParams.xy;
                float a = sdfCircle(pixelPos, float2(0.5, 0.5) * _ScreenParams.xy, 100);
                float4 layer1 = render(a, _Color, fwidth(a) * 2.0);
                return lerp(_BackgroundColor, layer1, layer1.a);
            }
            ENDCG
      }
    }
}
```

## 关于基于轮廓边检测的方法

再来回顾一下之前所述的原理:

检查这条边相邻的两个三角面片是否满足:(n0·v > 0) ≠ (n1·v > 0)。这里n0和n1分别表示两个相邻三角面片的法向,v是从视角到该边上任意顶点的方向。本质是检查相邻两个三角是否一个面向视角,另一个背向视角。

于是这里我想到用几何着色器去做,但是不知道怎么获得相邻的三角面片,在OpenGL中有 GL_LINES_ADJACENCY 去得到线段以及相邻顶点,就正好四个顶点两个相邻面片,从而可以去处理。但是Unity Shader中我没有找到怎么做。但是在谷歌中搜索出了一个解决方法: (https://link.zhihu.com/?target=https%3A//forum.unity.com/threads/does-unity-support-triangleadj-in-geometry-shaders.930306/)

先给他的链接,还没来得及细看:

[https://github.com/Milun/unity-solidwire-shader/blob/master/Assets/Shaders/SolidWire.shader**github.com/Milun/unity-solidwire-shader/blob/master/Assets/Shaders/SolidWire.shader**](https://link.zhihu.com/?target=https%3A//github.com/Milun/unity-solidwire-shader/blob/master/Assets/Shaders/SolidWire.shader)

### 关于可选顶点着色器

之前我整理了渲染管线:

[何博航:渲染管线与渲染路径详解**77 赞同 · 8 评论**文章![](https://pic2.zhimg.com/v2-56f5eb2011dcd6a5791897ec65c3ef7d_180x120.jpg)](https://zhuanlan.zhihu.com/p/408238134)

但是关于曲面细分着色器和几何着色器没有详细说明,这里补充一下:

![](https://pic3.zhimg.com/80/v2-1c45c51518b25d03bee9bc8cd3b60ba2_720w.webp)

如上图,曲面细分又分为:Hull shader 、Tessellation Primitive Generator 、 Domain shader,这些名称在不同的API中可能不一样。

对于曲面细分着色器,输入是Patch,可以看成是多个顶点的集合,包含每个顶点的属性。功能是可以将图元细分。输出为细分后的顶点。

对于几何着色器,输入为渲染图元,输出则为一个或者多个图元,同时还要定义输出的最大顶点数,并且输出的图元需要自己构建(顺序很重要)。在知乎上找到一个最简单的入门:(https://zhuanlan.zhihu.com/p/141036227)

不过还是比较推荐may佬的百人计划:

[【技术美术百人计划】图形 3.3 曲面细分与几何着色器 大规模草渲染_哔哩哔哩_bilibili**www.bilibili.com/video/BV1XX4y1A7Ns?p=2**![](https://pic3.zhimg.com/v2-28e7827096c8e847b28cf0e6fa06b372_180x120.jpg)](https://link.zhihu.com/?target=https%3A//www.bilibili.com/video/BV1XX4y1A7Ns%3Fp%3D2)


文章来源:[在shader中实现五种描边方法 - 知乎 (zhihu.com)](https://zhuanlan.zhihu.com/p/410710318)
页: [1]
查看完整版本: [转载]在shader中实现五种描边方法