动机和贝塞尔曲线相关的背景知识

动机当然是要在UI上绘制一个贝塞尔曲线的形状了,想做的效果大概就和虚幻引擎蓝图连接节点的线差不多了。这里要绘制的是一种较为特殊的贝塞尔曲线,它的两个端点的切线是水平的,且拥有旋转对称的特性。

我们想要绘制的S形曲线是三阶的贝塞尔曲线。三阶贝塞尔曲线有四个控制点\(P_0, P_1, P_2, P_3\),对于一个从0到1的变量\(t\),贝塞尔曲线的做法是对这四个点按照顺序以\(t\)做插值生成三个新的点,然后对这三个点按照顺序以\(t\)做插值生成新的两个点,再对这两个点以t做插值生成最后的点,当t在0到1中变化时,这个点的轨迹就构成了贝塞尔曲线。贝塞尔曲线上的点可以用四个控制点和\(t\)来表示: $$ P_{bezier} = P_0 \cdot (1 - t)^3 + 3 P_1 \cdot (1 - t)^2 \cdot t + 3 P_2 \cdot (1 - t) \cdot t^2 + P_3 \cdot t^3 $$

如果初始四个点分别是(0.0, 1.0), (d, 1.0), (1.0 - d, 0.0),和(1.0, 0.0)的话,也就是我们所要绘制的特殊的贝塞尔曲线,可以算出贝塞尔曲线的坐标为 $$ P_{bezier} = ((3 t - 9t ^ 2 + 6t^3) \cdot d + 3t^2 - 2t^3, 1 - 3t^2 + 2t^3) $$ 但是即使得到了贝塞尔曲线的参数方程,想要将其表达成\(f(x)\)的形式仍然是相当困难的。Alan Wolfe在他的博客中提到了一种一维贝塞尔曲线,也是一种贝塞尔曲线的特殊情况,四个控制点在水平方向上等距排开,这样子贝塞尔曲线的参数方程的水平分量就刚好是\(t\),它的竖直分量也就是我们需要的\(f(x)\)。唯一美中不足的是,能轻易得到\(f(x)\)的一维贝赛尔曲线,往往是一个“躺倒”的贝赛尔曲线,感官上看上去是横着的,Shadertoy上有相关的演示。但这种一维贝塞尔曲线又有一种特殊情况,也就是前两个控制点的竖直高度相等,后两个控制点的竖直高度也相等,这时这种特殊的一维贝塞尔曲线就是我们耳熟能详的smoothstep曲线了(数学真奇妙啊)。可惜smoothstep不能满足我们随意控制曲线形状的需求,只能另求他法。

事实上我们想要确保绘制出的贝塞尔曲线是等宽的,也不能只使用曲线的\(f(x)\),这样只能确保其在竖直方向是等宽的。需要整体等宽,等价于需要知道平面上每一个点到贝塞尔曲线的最近距离(也就是我们之前提到过的距离场Distance Field了。要求任意贝塞尔曲线的距离场,不是一个特别简单的事情,需要牛顿迭代法等数学方法,但是好在Shadertoy上的用户NinjaKoala已经帮我们把这个问题解决了,而且无私的把源代码分享给了我们。本博客中使用的距离场是NinjaKoala给出的一种大致的距离场,在距离贝塞尔曲线较远时会有一些偏差,但较近的距离基本上没什么问题。

一些其他的需求

已经基本知道该怎么绘制贝塞尔曲线了,但是我们还需要考虑到在使用Shader时候的一些需求。我们要考虑的不仅仅是调整颜色、宽度、阴影等参数,而是这个Shader将要如何放到屏幕上。

正常来说策划会指定两个顶点,说在这两个顶点之间画一条贝赛尔曲线,这时程序会给我们两个屏幕空间坐标(在Unity中就是RectTransform的坐标了),根据这两个坐标使用Image这个组件,在屏幕上用我们写的Shader的材质渲染一个Quad。

但是事情并没有这么简单,Quad的顶点是贝塞尔曲线的两个端点的话,一个等宽的贝赛尔曲线就会超出Quad的范围,更不要说还有一段偏移和模糊的阴影了。因此我们要做是在绘制给定四个顶点的Quad时,在顶点着色器中将其顶点向外偏移一定的数量,让整条贝塞尔曲线和其阴影都能落在扩大后的Quad的范围中。

值得一提的是,本来消耗很高的软阴影(需要很多次采样),在获得了距离场之后,可以通过普通的lerp或者Smoothstep直接生成,也是可喜可贺的一件事。

具体的实施步骤

  1. Canvas中创建一个Image组件,挂上我们的材质球和DrawBezierCurve脚本。
  2. DrawBezierCurve脚本中获取Image组件的像素单位的宽和高(其实只要高就行了),将这个值传给Shader。
  3. 顶点着色器中,首先需要将Quad上方的两个顶点往上移半个贝塞尔曲线的宽度个像素heightA = _Width * 0.5,然后要将Quad下方的两个顶点往下移阴影偏移加半个阴影宽度个像素heightC = _ShadowOffset + _ShadowWidth * 0.5。在uv的时候要确保偏移之后的uv在原Quad范围内仍是0-1之间的。这一步对最终的效果至关重要!
  4. 片元着色器中,使用ddx(uv.x)ddy(uv.y),可以计算出贝塞尔曲线的以像素为单位的距离场(也能用C#脚本传入的Quad的高和宽来算,不过应该是ddx比较方便)。使用smoothstep就能绘制出抗锯齿的贝塞尔曲线,或是模糊的阴影了。

DrawBezierCurve.cs

Update的时候要记得拿到对应的材质的引用。

using UnityEngine;
using UnityEngine.UI;

[ExecuteInEditMode]
[RequireComponent(typeof(Image))]
[RequireComponent(typeof(RectTransform))]
public class DrawBezierCurve : MonoBehaviour
{
    public Image image;
    public RectTransform rectTransform;

    private Material material;

    private void OnEnable()
    {
        image = GetComponent<Image>();
        material = new Material(image.material);
        image.material = material;
        rectTransform = GetComponent<RectTransform>();
    }

    private void Update()
    {
        Vector2 bottomLeftCorner = rectTransform.anchoredPosition + rectTransform.rect.min;
        Vector2 topRightCorner = rectTransform.anchoredPosition + rectTransform.rect.max;
        Vector4 anchorPos = new Vector4(bottomLeftCorner.x, bottomLeftCorner.y, topRightCorner.x, topRightCorner.y);
        material.SetVector("_AnchorPos", anchorPos);
    }
}

BezierShader.shader

贝塞尔的距离场来自Cubic bezier approx distance 2

我还增加了选项,在显示一条横线时关闭BEZIERCURVE就能使用效率更高的横线的距离场了(要注意绘制横线时,RectTransformHeight的值不能设置成0,最好设置成一个如0.01的小数),同时也添加了水平翻转的选项。

贝塞尔曲线和阴影混合之后再参加透明度混合,会给贝塞尔曲线带上一条浅浅的阴影颜色的描边,效果还算不错,这里就暂时不修改了。但是如果不想要这个描边的话。需要预先计算出混合后的颜色值和透明度值。如果用\((c_1, a_1)\)、\((c_2, a_2)\)、\((c_0)\)来分别表示曲线和阴影的颜色值、透明度值和透明度混合时目标的颜色的话(从前往后的顺序应该是曲线\((c_1, a_1)\),阴影\((c_2, a_2)\)和透明度混合目标\((c_0)\)),正确的透明度混合的颜色应该是\(c_1 a_1 + c_2 a_2 (1 - a_1) + c_0 (1 - a_1) (1 - a_2)\),所以可以设置透明度混合模式为Blend One SrcAlpha,然后将输出的颜色设置成float4(c1 * a1 + c2 * a2 * (1 - a1), (1 - a1) * (1 - a2)),这样混合后的颜色就是完美的正常的透明度混合了。

//Based on https://www.shadertoy.com/view/3lsSzS

Shader "zznewclear13/BezierShader"
{
    Properties
    {
        [HideInInspector] _MainTex ("Main Texture", 2D) = "white" {}
        [Header(Marcos)]
        [Toggle(BEZIERCURVE)] _BezierCurve("Bezier Curve", float) = 1
        [Toggle(FLIP)] _Flip ("Flip", float) = 0

        [Header(Curve Settings)]
        _Color ("Color", Color) = (1.0, 1.0, 1.0, 1.0)
        _Curve ("Curve", range(0, 1)) = 0.2
        _Width ("Pixel Width", float) = 5
        [Header(Shadow Settings)]
        _ShadowColor ("Shadow Color", color) = (0.0, 0.0, 0.0, 0.5)
        _ShadowOffset ("Shadow Offset", float) = 30
        _ShadowWidth ("Shadow Width", float) = 20
    }

    HLSLINCLUDE
    #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

    #pragma multi_compile_local _ FLIP
    #pragma multi_compile_local _ BEZIERCURVE

    sampler2D _MainTex;
    CBUFFER_START(UnityPerMaterial)
        float4 _Color;
        float _Curve;
        float _Width;

        float4 _ShadowColor;
        float _ShadowOffset;
        float _ShadowWidth;
    CBUFFER_END
    float4 _AnchorPos;

    static float2 VertexOffsets[] = 
    {
        float2(0, -1),
        float2(1, 0),
        float2(1, 0),
        float2(0, -1),
    };

    struct Attributes
    {
        uint vertexID       : SV_VERTEXID;
        float4 positionOS   : POSITION;
        float2 texcoord     : TEXCOORD0;
    };

    struct Varyings
    {
        float4 positionCS   : SV_POSITION;
        float2 uv           : TEXCOORD0;
    };

    Varyings Vert(Attributes input)
    {
        Varyings output = (Varyings)0;
        float4 positionOS = input.positionOS;
        float heightA = _Width * 0.5;
        float heightB = _AnchorPos.w - _AnchorPos.y;
        float heightC = _ShadowOffset + _ShadowWidth * 0.5;

        positionOS.y += VertexOffsets[input.vertexID].x * heightA + VertexOffsets[input.vertexID].y * heightC;
        VertexPositionInputs vertexInput = GetVertexPositionInputs(positionOS);
        output.positionCS = vertexInput.positionCS;

        //0-1 -> -(heightC) / heightB, (heightA + heightB + heightC) / heightB
        float newCoordY = (heightA + heightB + heightC) / heightB * input.texcoord.y - (heightC) / heightB ;
        output.uv = float2(input.texcoord.x, newCoordY);
        return output;
    }

    float cubic_bezier_normal_iteration(float t, float2 a0, float2 a1, float2 a2, float2 a3)
    {
        //factor should be positive
        //it decreases the step size when lowered.
        //Lowering the factor and increasing iterations increases the area in which
        //the iteration converges, but this is quite costly
        const float factor=1.;
        
        //horner's method
        float2 a_2=a2+t*a3;
        float2 a_1=a1+t*a_2;
        float2 b_2=a_2+t*a3;

        float2 uv_to_p=a0+t*a_1;
        float2 tang=a_1+t*b_2;

        float l_tang=dot(tang,tang);
        return t-factor*dot(tang,uv_to_p)/l_tang;
    }

    float cubic_bezier_dis_approx_sq(float2 uv, float2 p0, float2 p1, float2 p2, float2 p3)
    {
        float2 a3 = (-p0 + 3. * p1 - 3. * p2 + p3);
        float2 a2 = (3. * p0 - 6. * p1 + 3. * p2);
        float2 a1 = (-3. * p0 + 3. * p1);
        float2 a0 = p0 - uv;

        float d0 = 1e38;

        float t0=0.;
        float t;

        const int num_iterations=3;
        const int num_start_params=3;

        for(int i=0;i<num_start_params;i++)
        {
            t=t0;
            for(int j=0;j<num_iterations;j++)
            {
                t=cubic_bezier_normal_iteration(t,a0,a1,a2,a3);
            }
            t=clamp(t,0.,1.);
            float2 uv_to_p=((a3*t+a2)*t+a1)*t+a0;
            d0=min(d0,dot(uv_to_p,uv_to_p));

            t0+=1./float(num_start_params-1);
        }

        return d0;
    }

    float cubic_bezier_dis_approx(float2 uv, float2 p0, float2 p1, float2 p2, float2 p3)
    {
        return sqrt(cubic_bezier_dis_approx_sq(uv,p0,p1,p2,p3));
    }

    float4 Frag(Varyings input) : SV_TARGET
    {
        float ddxX = ddx(input.uv.x);
        float ddyY = ddy(input.uv.y);
        float2 scale = float2(rcp(ddxX), rcp(ddyY));

        float2 coord = input.uv;
#if FLIP
        coord.x = 1.0 - coord.x;
#endif

#if BEZIERCURVE
        float2 p0=float2(0.0, 1.0) * scale;
        float2 p1=float2(_Curve, 1.0) * scale;
        float2 p2=float2(1.0 - _Curve, 0.0) * scale;
        float2 p3=float2(1.0, 0.0) * scale;

        float dist = cubic_bezier_dis_approx(coord * scale,p0,p1,p2,p3);
        float widthVal = _Width * 0.5;
        float alpha = smoothstep(widthVal + 1.0, widthVal, dist);

        float shadowDist = cubic_bezier_dis_approx(coord * scale + float2(0.0, _ShadowOffset),p0,p1,p2,p3);
        float shadowWidthVal = _ShadowWidth * 0.5;
        float shadowAlpha = smoothstep(shadowWidthVal + 1.0, 0.0, shadowDist);

#else
        float dist = abs((1.0 - coord.y) * scale.y);
        float widthVal = _Width * 0.5;
        float alpha = smoothstep(widthVal + 1.0, widthVal, dist);

        float shadowDist = abs((1.0 - coord.y) * scale.y - _ShadowOffset);
        float shadowWidthVal = _ShadowWidth * 0.5;
        float shadowAlpha = smoothstep(shadowWidthVal + 1.0, 0.0, shadowDist);
#endif

        float3 color = lerp(_ShadowColor.rgb, _Color.rgb, alpha * _Color.a);
        float finalAlpha = max(alpha * _Color.a, shadowAlpha * _ShadowColor.a);

        float4 returnColor = float4(color, finalAlpha);
        //returnColor = float4(input.uv, 0.0, 1.0);
        return returnColor;
    }

    ENDHLSL

    SubShader
    {
        Tags{ "Queue" = "Transparent" "RenderType" = "Transparent" }

        pass
        {
            Cull Back
            ZTest LEqual
            ZWrite Off
            Blend SrcAlpha OneMinusSrcAlpha
            HLSLPROGRAM
            #pragma vertex Vert
            #pragma fragment Frag
            ENDHLSL
        }
    }
}

最后的思考

数学真奇妙啊!也很感谢Shadertoy上用户们的无私的奉献!但是微积分对我来说还是太难了,是不是应该找个机会补一补呢。。。最后就是我感觉对fwidth抗锯齿的理解又加深了!