前言

来上点难度 💪
想不清楚就写写画画,没有什么比写下来更让人思路清晰了


效果

圆角边框效果,支持改变边框的颜色、粗细、曲度

👉展示备份(国内节点)
👇效果展示(科学上网)


代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
Shader "Unlit/AloeaShader06"
{
    Properties
    {
        _Radius ("BorderRadius", Range(0.01, 1)) = 0.1
        _Border ("BorderWidth", Range(0, 1)) = 0.1
        _Color ("BorderColor", Color) = (1,1,1,1)

    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            float _Radius;
            float _Border;
            float4 _Color;

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

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {  
                float split = 1 / _Radius;
                float2 coords = i.uv;
                coords.x *= split;
                float2 pointOnLineSeg = float2( clamp(coords.x, 0.5, split - 0.5), 0.5 ); // 中心线
                float sdf = distance(coords, pointOnLineSeg) * 2 ; // 所有点距离中心线距离
                clip(- (sdf - 1)); // 裁剪
                float borderSdf = sdf + _Border;
                float pd = fwidth(borderSdf);
                return saturate((borderSdf - 1) / pd )* _Color;
            }
            ENDCG
        }
    }
}


效果调试

整体思路

  1. 裁剪掉半径外的部分
  2. 找到平面的一条中线,按照所有点到中线距离超过半径r1来算出需要裁掉的部分
  3. 在上一步的基础上,再减去一个内层半径r2的图形,得到圆角边框


步骤一:
画出中线,假设矩形均分为 6 段,第一段和最后一段是画圆角的位置

1
2
3
4
5
6
7
8
fixed4 frag (v2f i) : SV_Target
{
float2 coords = i.uv;
// 坐标 x 的范围从 [0,1] 改为 [0,6],划分为 6 段
  coords.x *= 6;
  // 中线的 x 范围通过 clamp 函数限制在 [0.5,5.5],y 则始终为 0.5
  float2 pointOnLineSeg = float2( clamp(coords.x, 0.5, 5.5), 0.5 );
}

步骤二:
算出 uv 上所有点到中线的距离

1
2
3
4
5
6
7
8
9
fixed4 frag (v2f i) : SV_Target
{  
float2 coords = i.uv;
coords.x *= 6;
float2 pointOnLineSeg = float2( clamp(coords.x, 0.5, 5.5), 0.5);
// distance 函数算两个向量的距离
float sdf = distance(coords, pointOnLineSeg);
return float4(sdf.xxx, 1);
}

这个时候圆角内的点到中线的距离数值范围在 [0, 0.5], 超出的部分最大达到 √ (0.5^2 + 0.5^2)。我们的目标就是裁剪掉这个部分。


1
2
// 把 sdf 乘以 2,数值范围改到 [0,1] 更方便操作和观察
float sdf = distance(coords, pointOnLineSeg) * 2;

如下图,更清楚地观察我们要裁剪的部分(纯白色,大于1的部分)


步骤三:
裁剪掉圆角外侧

1
2
3
4
5
6
7
8
9
10
fixed4 frag (v2f i) : SV_Target
{  
float2 coords = i.uv;
coords.x *= 6;
float2 pointOnLineSeg = float2( clamp(coords.x, 0.5, 5.5), 0.5);
float sdf = distance(coords, pointOnLineSeg) * 2;
// clip 会丢弃小于 0 的部分,因为目前圆角内所有点的 sdf 为 [0,1]
clip(sdf - 1);
return float4(sdf.xxx, 1);
}

ok,能看到准确得裁剪出来了。sdf - 1 让圆角内的点都因为小于 0 被丢弃了,我们把它翻过来就是了。

1
clip(- (sdf - 1));

步骤四:
画内圈,边框的宽度先取 0.2 测试

1
2
3
4
5
6
7
8
9
10
11
fixed4 frag (v2f i) : SV_Target
{  
float2 coords = i.uv;
coords.x *= 6;
float2 pointOnLineSeg = float2( clamp(coords.x, 0.5, 5.5), 0.5);
float sdf = distance(coords, pointOnLineSeg) * 2;
clip(- (sdf - 1));
// 计算方式是一样的,直接加上差值 0.2 就行
float borderSdf = sdf + 0.2;
return float4(borderSdf.xxx, 1);
}

现在所有点到中线的距离范围在 [0,1.2],可以观察到其中 [1,1.2] 的部分就是我们的目标边框


步骤五:
得到边框

1
2
3
4
5
6
7
8
9
10
11
fixed4 frag (v2f i) : SV_Target
{  
float2 coords = i.uv;
coords.x *= 6;
float2 pointOnLineSeg = float2( clamp(coords.x, 0.5, 5.5), 0.5);
float sdf = distance(coords, pointOnLineSeg) * 2;
clip(- (sdf - 1));
float borderSdf = sdf + 0.2;
// 比较 1 和 borderSdf ,大于 1 则为 1 ,小于 1 则为 0
return float4(step(1, borderSdf).xxx, 1);
}

步骤六:
接入参数作为变量,包括圆角角度、边框宽度、边框颜色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fixed4 frag (v2f i) : SV_Target
{  
// 测试假定分为 6 段,现在改为输入参数
float split = 1 / _Radius;
float2 coords = i.uv;
coords.x *= split;
float2 pointOnLineSeg = float2( clamp(coords.x, 0.5, split - 0.5), 0.5);
float sdf = distance(coords, pointOnLineSeg) * 2;
clip(- (sdf - 1));
// 边框宽度
float borderSdf = sdf + _Border;
// 边框颜色
return float4(step(1, borderSdf).xxx, 1) * _Color;
}

步骤七:
优化边缘锯齿

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fixed4 frag (v2f i) : SV_Target
{  
float split = 1 / _Radius;
float2 coords = i.uv;
coords.x *= split;
float2 pointOnLineSeg = float2( clamp(coords.x, 0.5, split - 0.5), 0.5);
float sdf = distance(coords, pointOnLineSeg) * 2;
clip(- (sdf - 1));
float borderSdf = sdf + _Border;
// 利用 fwidth 得到屏幕空间的色彩变化率,数值范围在 [0,max] ( max 具体看屏幕,通常为 1 )
float pd = fwidth(borderSdf);
// 用 saturate 夹紧数值范围 [0,1],同样达到之前用 step 的效果
return saturate((borderSdf - 1) / pd )* _Color;
}

知识点

函数 clamp

clamp 函数用于将一个值限制在一个指定的范围内。它接受三个参数,第一个是要限制的值,第二个是范围的下限,第三个是范围的上限。如果值小于下限,函数返回下限,如果值大于上限,函数返回上限,否则返回原始值。例如,clamp(x,0.5,1.5) 将 x 限制在 0.5 到 1.5 之间。

1
2
3
4
void Unity_Clamp_float4(float4 In, float4 Min, float4 Max, out float4 Out)
{
Out = clamp(In, Min, Max);
}

函数 saturate

简化版的 clamp,指定限制范围为 [0, 1] 。

saturate 函数用于将一个值限制在 0 到 1 之间。它接受一个参数,即要限制的值。如果该值小于 0,则返回 0,如果该值大于 1,则返回 1,否则返回原始值。

1
2
3
4
void Unity_Saturate_float4(float4 In, out float4 Out)
{
Out = saturate(In);
}

函数 distance

distance 函数用于计算两个点之间的距离。它接受两个参数,即两个点的坐标。distance 函数实际上是计算两个点之间的欧几里得距离,即勾股定理。

1
2
3
4
void Unity_Distance_float4(float4 A, float4 B, out float Out)
{
Out = distance(A, B);
}

注意这个函数和 length 的区别,length 的传入是一个向量,计算其长度;distance 则是传入两个向量,计算它们之间的距离。在使用中有些场景可以相互替换用。

1
2
float dist = length(vec1 - vec2);
float len = distance(vec, vec3(0, 0, 0));

函数 clip

clip 函数用于在特定条件下舍弃像素片段。它接受一个参数,即一个浮点数,如果该参数小于等于 0,则该像素片段将被舍弃。


函数 step

相当于一个判断赋值的缩写/语法糖,step(edge, in) 等于 !!(in >= edge),返回结果为 0 或1。

step 函数用于根据一个阈值将一个值转换为 0 或 1 。它接受两个参数,第一个是要比较的值,第二个是阈值。如果值小于等于阈值,则函数返回 0 ,否则返回1。

1
2
3
4
void Unity_Step_float4(float4 Edge, float4 In, out float4 Out)
{
Out = step(Edge, In);
}

函数 fwidth

相当于每个用户的屏幕空间中当前像素的变化率,数值越大相邻像素间差距越大,表现为数学上的变化率。返回的数值范围上限取决于物理屏幕的分辨率,通常为 1,下限则为0 (比如严格纯色的场景)。下次可以合着抗锯齿一起单独讲一篇,通常也是用在各种边缘处理中。

fwidth 函数用于计算函数在屏幕空间中的变化率。它接受一个参数,即要计算的函数值。fwidth 函数实际上是对 dx 和 dy 函数的绝对值之和的计算,其中 dx 函数计算函数在 x 方向上的变化率,dy 函数计算函数在 y 方向上的变化率。

fwidth(x) = abs(ddx(x)) + abs(ddy(x))。


总结

  1. 还是回到核心思路,找到中线按照距离算出要裁剪掉的部分,后面就比较顺了。
  2. 涉及到的函数比较多,包括处理数值范围的 clampsaturateclip,判断语法糖 step,处理边缘锯齿的变化率导数 fwidth
  3. 整个效果还有改进的空间,包括抗锯齿的细节上,也包括圆角的设计策略上。