漸變(Gradient)填色可說是極為常見的視覺表達方式, Jony Ive 更在 iOS 7 的設計中將漸變推到了人生巔峰(咦),而其中最基本的不外乎三種:線性漸變(Linear Gradient)、輻射漸變(Radial Gradient)、以及角度漸變(Angle Gradient),為了能有效在畫面中呈現漸變的美感又不失效能不佔空間不佔記憶體同時滿足首席設計師的最高需求,我們要來應用 Shader 作出最簡單實用的漸變效果。

Common Gradients

在這個範例中,我們主要是要將漸變呈現在一個 1×1 的 Quad 四邊形和 UV 座標,接著就可以透過縮放(Scale)將 Quad 調整成你需要的尺寸。

Quad

但我們同時又必須要能夠支援多個顏色與控制點如下圖 Photoshop Gradient Editor(只有頭尾兩色怎麼夠你家的設計師用?你會被他白眼吧!):

Quad


透過陣列把多個顏色傳遞至 Shader 中

為了支援多個顏色與控制點,首要條件就是要能把相關資訊傳遞至 Shader 中,很不幸的 Unity Shader 的屬性目前並不支援陣列,但還好可以透過 Material 的 API 來設定陣列值:

CS:

var mat = SOME_MATERIAL;
mat.SetFloatArray ("_GradientPosition", new float [] {0f, 0.2f, 0.5f, 0.8f, 1f});
mat.SetFloatArray ("_GradientColors", new Color [] {Color.red, Color.green, Color.blue, Color.white, Color.black});

Shader:

fixed4 _GradientColors[5];
float _GradientPosition[5];

所以這部分我們現是透過外部 Component 在執行階段(Runtime)才將顏色資訊傳遞進去,我們就先來實做一個 Component 設定顏色資訊:

[ExecuteInEditMode]
public class FDGradient : MonoBehaviour {

    const int MAX_COLORS = 5;

    public Material GradientMat;
    public int ColorCount;
    public Color [] Colors = new Color [MAX_COLORS];
    public float [] Positions = new float [MAX_COLORS];

    protected virtual void Awake () {

        UpdateGradient ();
    }

    protected virtual void UpdateGradient () {

        if (GradientMat != null) {

            GradientMat.SetColorArray ("_GradientColors", Colors);
            GradientMat.SetFloatArray ("_GradientPosition", Positions);
            GradientMat.SetFloat ("_ColorCount", ColorCount);
        }
    }

#if UNITY_EDITOR

    protected virtual void OnValidate () {

        UpdateGradient ();
    }

#endif
}

這邊主要有三個參數需要傳遞至 Shader:GradientColorsGradientPositions、以及 ColorCount

由於在 Shader 中只能使用靜態陣列,所以我們必須要告訴 Shader 總共有幾個顏色,意思就是我們必須在 Shader 中準備好足夠的陣列空間來滿足需求。

這部分當然就是可以依照需求去做調整,在這邊我們就給他最多到五個顏色好了:

float _ColorCount;
fixed4 GradientColors[5];
float _GradientPosition[5];

這樣子我們就可以成功的將多個顏色傳遞至 Shader 中使用囉。

覺得實在不夠優雅硬要使用動態陣列

如果你覺得使用固定的陣列實在是沒有彈性又不優雅,當然還有一個 Hack 的方法,那就是把資訊儲存成二維紋理(Texture2D)的格式,再透過 sampler 在 Shader 中取用,但缺點就是你要使用 sampler 去取值。


顏色取樣(Sampling)

接下來我們要實作取樣顏色的函式 sampleGradientColor,我們將函式寫在另外一個獨立的檔案 cginc-gradient-helper.cginc 中,以方便重複使用。這部分我們是使用靜態陣列的解法來進行,需要將 positions、colors、color_count 傳遞進函式, t 代表的是在顏色上的某點位置,回傳的就是這個多顏色設置在 position 為 t 時的顏色值:

#ifndef CGINC_GRADIENT_HELPER_INCLUDED
#define CGINC_GRADIENT_HELPER_INCLUDED

inline fixed4 sampleGradientColor(fixed4 colors [5], float positions [5], int count, float t)
{
    for (int p = 0; p < count; p++) {

        if (t <= positions [p]) {   

            if (p == 0) {   

                return colors [0];   

            } else {   

                return lerp(colors [p - 1], colors [p], (t - positions [p - 1]) / (positions [p] - positions [p - 1])); 
            }   

        } else if (p == count - 1) {   
            return colors [p]; 
        } 
    }   
    return fixed4(0,0,0,0); 
} 

#endif

這樣子前置作業就算結束了,接著總算來要進行一個實做漸變本體的動作。


線性漸變

接下我們要利用上面準備好的東西來實作一個可轉角的線性漸變,我們總共需要建立三個資源 FDGradientLinear.cs、shader-gradient-linear.shader、以及 gradient-linear.mat。FDGradientLinear 繼承自 FDGradient,另外需要新增一個 Angle (這邊我們使用角度非徑度)屬性當作漸變的轉角值,並在 UpdateGradient 中寫進 Shader 中:

[ExecuteInEditMode] public class FDGradientLinear : FDGradient {

    public float Angle = 0;   

    protected override void UpdateGradient () { 
        base.UpdateGradient ();   
        if (GradientMat != null) {   
            GradientMat.SetFloat ("_Angle", Angle); 
        } 
    } 
}

同時準備好 gradient-linear.shader 架構,只要再把 frag 中的填色邏輯完成就行了:

Shader "Unlit/Linear Gradient"
{
    Properties
    {
        _ColorCount ("Number of Colors", float) = 5
        _Angle ("Angle", float) = 0
    }
    SubShader
    {
        Tags { 

            "Queue"="Transparent" "IgnoreProjector"="True" "RenderType"="Transparent"
        }

        Cull Off
        LOD 100

        ZWrite Off
        Blend SrcAlpha OneMinusSrcAlpha

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            // make fog work
            #pragma multi_compile_fog

            #include "UnityCG.cginc"
            #include "cginc-gradient-helper.cginc"

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

            struct v2f
            {
                UNITY_FOG_COORDS(1)
                float4 vertex : SV_POSITION;
                float2 texcoord : TEXCOORD0;
            };

            v2f vert (appdata v)
            {
                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.texcoord = v.texcoord;
                return o;
            }

            float _ColorCount;
            float _Angle;
            fixed4 _GradientColors[5];
            float _GradientPosition[5];

            fixed4 frag (v2f i) : SV_Target
            {
                << Linear Gradient >>
            }

            ENDCG
        }
    }
}

簡單來說: > 我們只要取 uv 中任意一個維度來當作顏色取樣點,再搭配旋轉轉換就可以做出有旋轉角度的線性漸變。

1. 利用 v 維度來製作一個垂直的線性漸變:

這部分其實非常簡單,我們只要透過 i.texcoord.y 把 v 值和必要的顏色資訊傳進 sampleGradientColor 就可以了,如下圖:

fixed4 frag (v2f i) : SV_Target
{
    return sampleGradientColor (_GradientColors, _GradientPosition, _ColorCount, i.texcoord.y);
}

我們選定三個顏色當作範例,分別位置在 0、0.5、與 1:

Linear Gradient

一個最簡單的線性漸變就此完成了!同理假設我們想要的是一個水平的線性漸變,我們只要將傳入的 i.texcoord.y 改為 i.texcoord.x 就可了。

2. 透過旋轉轉換給予漸變轉角:

接下來為了完整線性漸變的實用性,我們要來讓它支援轉角,我們在 cginc-gradient-helper.cginc 檔案裡新增一個函式 rotateUV 以及需要用到的常數 DEGREE2RADIAN

#ifndef DEGREE_2_RADIAN
#define DEGREE_2_RADIAN 0.01745329252
#endif

inline float2 rotateUV (fixed2 uv, float rotation)
{
    float sinX = sin (rotation);
    float cosX = cos (rotation);
    float2x2 rotationMatrix = float2x2( cosX, -sinX, sinX, cosX);
    return mul (uv - fixed2 (0.5, 0.5), rotationMatrix) + fixed2 (0.5, 0.5);
}

可以看到我們先將輸入的 uv 位移了 (-0.5, -0.5),透過旋轉矩陣轉動了 rotation 角度,再位移 (0.5, 0.5),這是因為 Quad 的中心點 uv 其實是 (0.5, 0.5),如果沒有這個步驟就會發現渲染出來的轉角跟你想的不太一樣,為了要能夠對 Quad 的中心點旋轉,所以需要特別修正。

準備好 rotateUV 了以後我們就可以開心地使用了:

fixed4 frag (v2f i) : SV_Target
{
    float2 rotatedUV = rotateUV(i.texcoord, _Angle * DEGREE_2_RADIAN);
    return sampleGradientColor (_GradientColors, _GradientPosition, _ColorCount, rotatedUV.y);
}

我們測試一下 45 度角:

Linear Gradient with angle

基本上這樣線性漸變就算是完成了,已經可以滿足大部分的使用需求,但一但你將 scale 中的 xy 值設定不一樣的時候,你就會開始發現一些小問題,例如下圖,我們將 scale 設定為 (4, 1, 1),你會發現原先設定的 45 度角實際上渲染出的角度會變小,是因為我們使用的是 uv 座標而不是實際上頂點的相對位置,但若是你不在乎設定的角度對於渲染結果的實際意義的話,你也可以直接使用不理會。

Linear gradient scaled

3. 找出正確的角度

如果你覺得這樣的設定值實在不直覺,我們就需要再做一些調整,比較有效率的方式是在 Angle 傳遞至 Shader 前先計算出正確的角度:

[ExecuteInEditMode]
public class FDGradientLinear : FDGradient {

    public float Angle = 0;

    protected override void UpdateGradient () {
        base.UpdateGradient ();

        if (GradientMat != null) {

            if (this.transform.localScale.x == this.transform.localScale.y) {

                GradientMat.SetFloat ("_Angle", Angle);

            } else {

                float adjustedAngle = Mathf.Atan (Mathf.Tan (Angle * Mathf.Deg2Rad) * this.transform.localScale.x / this.transform.localScale.y) * Mathf.Rad2Deg;
                GradientMat.SetFloat ("_Angle", adjustedAngle);
            }
        }
    }
}

至於怎麼計算的,就推導一下幾何關係吧(懶),下圖為修正後的角度:

Linear gradient adjustment

關於角度這部分有各種修正的方法,可以依照實際需求去調整,如果需要更有彈性和即時性的話,也可以把修正的邏輯寫在 Shader frag 中,但缺點就是會損失 GPU 效能,如下:

fixed4 frag (v2f i) : SV_Target
{
    float2 scale = float2 (unity_ObjectToWorld [0][0], unity_ObjectToWorld [1][1]);
    float adjustedAngle = atan(tan(_Angle * DEGREE_2_RADIAN) * scale.x / scale.y);

    return sampleGradientColor (_GradientColors, _GradientPosition, _ColorCount, rotateUV(i.texcoord, adjustedAngle).y);
}

但有差異的是 unity_ObjectToWorld 值取出的 scale 是最後 (包含父物件)scale 的比例,而在 FDGradientLinear 中的修正是使用 localScale,所以情境上也不太依樣。


輻射漸變

輻射漸變相對比較單純一點,我們一樣需要建立三個資源 FDGradientRadial.cs、shader-gradient-radial.shader、以及 gradient-radial.mat。FDGradientRadial 繼承自 FDGradient,另外需要新增 Center (輻射圓心)以及 Aspect(橢圓的寬高比例)屬性,並在 UpdateGradient 中寫進 Shader 中:

[ExecuteInEditMode]
public class FDGradientRadial : FDGradient {

    public Vector2 Center = new Vector (0.5f, 0.5f);
    public Vector2 Aspect = Vector2.one;

    protected override void UpdateGradient () {
        base.UpdateGradient ();

        if (GradientMat != null) {

            GradientMat.SetFloat ("_CenterX", Center.x);
            GradientMat.SetFloat ("_CenterY", Center.y);
            GradientMat.SetFloat ("_AspectX", Aspect.x);
            GradientMat.SetFloat ("_AspectY", Aspect.y);
        }
    }
}

我們若先不管 Scale xy 的比例話,填色的方法如下:

fixed4 frag (v2f i) : SV_Target
{
    float2 uv = i.texcoord;
    float2 d = float2((_CenterX - uv.x) * _AspectX, (_CenterY - uv.y) * _AspectY);

    return sampleGradientColor (_GradientColors, _GradientPosition, _ColorCount, sqrt(d.x*d.x + d.y*d.y) * 2);
}

原則上就是透過 uv 到指定 Center 的距離來取樣漸變顏色,結果如下圖(一樣選三個顏色)

Radial gradient

不同的 Aspect 設定值:

Radial gradient

至於 Quad Scale 對於渲染結果的影響,就沒有需要特別去修正了,只要搭配調整 Aspect 就可以了。


角度漸變

角度漸變跟輻射漸變有點雷同,我們一樣需要建立三個資源 FDGradientAngle.cs、shader-gradient-angle.shader、以及 gradient-angle.mat。FDGradientAngle 繼承自 FDGradient,另外需要新增 Center (圓心)屬性,並在 UpdateGradient 中寫進 Shader 中:

[ExecuteInEditMode]
public class FDGradientAngle : FDGradient {

    public Vector2 Center = new Vector2 (0.5f, 0.5f);

    protected override void UpdateGradient () {
        base.UpdateGradient ();

        if (GradientMat != null) {

            GradientMat.SetFloat ("_CenterX", Center.x);
            GradientMat.SetFloat ("_CenterY", Center.y);
        }
    }
}

並新增算出角度的函式 angleOf 在 cginc-gradient-helper.cginc 以及需要的常數 DOUBLE_PI、INVERSE_DOUBLE_PI:

#ifndef DOUBLE_PI
#define DOUBLE_PI 6.2831853072
#endif

#ifndef INVERSE_DOUBLE_PI
#define INVERSE_DOUBLE_PI 0.1591549431
#endif

inline float angleOf (float2 vec)
{
    if (vec.x == 0 && vec.y == 0) return 0;

    float d = sqrt (vec.x*vec.x + vec.y*vec.y);

    if (vec.y > 0) {

        return acos(vec.x / d);

    } else {

        return DOUBLE_PI - acos(vec.x / d);
    }
}

原則上就是算出 uv 對於 Center 的角度正規化後 (除以 2 倍 PI)去取樣漸變顏色,特別需要注意的是這邊有針對 Scale 的 xy 比例去修正不同的 Scale 造成的變形:

fixed4 frag (v2f i) : SV_Target
{
    float2 uv = i.texcoord;
    float2 scale = float2 (unity_ObjectToWorld [0][0], unity_ObjectToWorld [1][1]);

    float2 d = float2((_CenterX - uv.x) * scale.x / scale.y, _CenterY - uv.y);
    float angle = angleOf (d);

    return sampleGradientColor (_GradientColors, _GradientPosition, _ColorCount, angle * INVERSE_DOUBLE_PI);
}

讓我們看看結果:

Angle gradient

這樣子角度的漸變填色也完成囉!


結語

漸層填色非常實用,但同時也是一個很耗效能的東西,要做一個通用型的漸層 Shader 比較困難,通常還是要依據不同的使用情境跟需求來客製修改,重點是要能夠了解漸層的基本要素,而其中不外乎就是 sampleGradientColor 取樣的概念了。

Github Repository

unity-fdgradient

參考文獻

[1] Image via Nullfy, CC Licensed.