Unity
Shader

UnityのフラグメントシェーダでSpriteの縁取り

Unityで2Dゲームを開発する際、Spriteを縁取りしたいと思う場面があっていろいろと調べてみました。
頂点シェーダでテクスチャを拡大する方法はいくつか出てくるのですが、その手法ではSprite内部の透明領域をうまく縁取りできないようです。

そこで、ピクセルシェーダ (フラグメントシェーダ) だけで縁取りができるようなShaderを作ってみました。
(↓こんな感じになります)

とりあえずShaderを作る

Shaderなんて使ったことがないよ!という人も多いかもしれませんので、Shaderの基本的な設定方法を含めて説明します。
(私自身Shaderはほとんど初めてですので、備忘録を兼ねて。)

① Project 上で右クリック → "Create" → "Shader" → "Standerd Surface Shader" を作成
② Project 上で右クリック → "Create" → "Material" を作成

③ 縁取りしたい Sprite の Import Settings を開き、 Mesh Type を Full Rect にする。
setsumei_1.png

Mesh Type が Tight だと、Shaderをどう記述しても元画像の透明部分を描画してくれません。

④ 最初に作成した Shader を開き、内容を以下のものに丸ごと書き換える。
最上段の「NewSurfaceShader」はプルダウンメニューに表示される名前です。好きな名前にしてください。

NewSurfaceShader.shader
Shader "Custom/NewSurfaceShader" {
    Properties{
        _Color("Main Color", Color) = (1,1,1,0.5)
        _MainTex("Texture", 2D) = "white" { }
        _Width("Tex Width", Float) = 200.0
        _Height("Tex Height", Float) = 200.0
        _Thick("Line Thickness", Int) = 2
    }


        SubShader{
        Tags{
        "Queue" = "Transparent"
        "IgnoreProjector" = "True"
        "RenderType" = "Transparent"
        "PreviewType" = "Plane"
        "CanUseSpriteAtlas" = "False"
    }
        Pass{


        Blend SrcAlpha OneMinusSrcAlpha
        CGPROGRAM

#pragma vertex vert
#pragma fragment frag
#pragma target 3.0

        fixed4 _Color;
    sampler2D _MainTex;
    float _Width;
    float _Height;
    int _Thick;

    struct vin {
        float4 vertex: POSITION;
        float4 texcoord : TEXCOORD0;
    };
    struct v2f {
        float2 uv : TEXCOORD0;
        float4 pos : SV_POSITION;
    };

    v2f vert(vin v)
    {
        v2f o;
        o.pos = UnityObjectToClipPos(v.vertex);
        o.uv = v.texcoord;
        return o;
    }

    fixed4 frag(v2f i) : SV_Target
    {
        float4 texcol = tex2D(_MainTex, i.uv);

        // このピクセルの周囲の透明度の最大値を調べる
        float alphaMax = 0.0f;
        for (int x = -_Thick; x <= _Thick; ++x)
            for (int y = -_Thick; y <= _Thick; ++y)
            {
                float alpha = tex2D(_MainTex, i.uv + float2(x / _Width, y / _Height)).a;
                if (alpha > alphaMax)
                    alphaMax = alpha;
            }

        // このピクセルが透明なら、周囲の透明度の最大値で塗る。
        if (texcol.a < 0.5)
            return float4(0, 0, 0, alphaMax);
        else
            return texcol;
    }
        ENDCG
    }
    }
}

⑤ 作成した Material の Inspector 上で、Shaderを先ほど作成したものに設定する(Customの中にあります)
setsumei_1.png

⑥ Sprite を Hierarchy 上に設置し、 作成した Material を適用する
⑦ そのまま Material の Component を展開し、Tex Width と Height に元画像の大きさを入力する
setsumei_3.png
画像の大きさを自動で設定する方法が思いつきませんでした…Scriptを使うしかないのかな。

⑧ 完了!Material Component の Line Thickness の値を弄ったりしてみてください
setsumei_3.png

Shader の説明

フラグメントシェーダの中身を説明します。

NewSurfaceShader.shader
fixed4 frag(v2f i) : SV_Target
{
    float4 texcol = tex2D(_MainTex, i.uv);

    // このピクセルの周囲の透明度の最大値を調べる
    float alphaMax = 0.0f;
    for (int x = -_Thick; x <= _Thick; ++x)
        for (int y = -_Thick; y <= _Thick; ++y)
        {
            float alpha = tex2D(_MainTex, i.uv + float2(x / _Width, y / _Height)).a;
            if (alpha > alphaMax)
                alphaMax = alpha;
        }

    // このピクセルが透明なら、周囲の透明度の最大値で塗る。
    if (texcol.a < 0.5)
        return float4(0, 0, 0, alphaMax);
    else
        return texcol;
}

説明とは言うものの、処理はいたって単純です。

まず_Thick の値に従ってfor文を回し、周囲のピクセルにおける透明度の最大値を得ます。
そして自身のピクセルが透明、かつ周囲に不透明ピクセルがあるならば黒色を返し、
自身のピクセルが不透明ならば元画像のピクセルをそのまま返します。

描画速度を調べてみる

Googleで検索してみると、「ピクセルシェーダで縁取りはできるか?」との問いに「リアルタイムでは重すぎて無理」とするやり取りが見られました。

というわけでどのくらいの描画速度になるのか調べてみました。
以下の表は、ゲーム上で表示したSpriteの枚数と、70fpsを維持できる線の厚さ(_Thick)の最大値をまとめたものです。
(画面上での画像の大きさは約700x700、グラフィックボードは GeForce GTX750 Ti です)

Sprite枚数 厚さ最大
1 21
2 15
3 12
4 10
8 7
16 4
32 3
64 2
115 1

画像の表示サイズにもよりますが、厚さ1ピクセルで縁取りをするならば114枚くらいまでなら70fpsで描画できるようです。

全てのキャラを縁取りしようと思ったら確かにこれはあまり軽いとは言えないないかもしれません。
特定のオブジェクトを選択した場合などに用途を限定する必要がありそうです。

角を丸くしたい、色もつけたい!

先ほどのコードではfor文の回し方が原始的で、縁を大きくすると形状が四角くなってしまいます。
縁が丸く拡大するようにして、ついでに色も付けてしまいましょう。

(縁の厚さ20で比較)

下が修正後のコードです。ほとんど同じですが、「// ※※」 のついた行が変更されています。(全6行)

NewSurfaceShader.shader
Shader "Custom/NewSurfaceShader" {
    Properties{
        _Color("Main Color", Color) = (1,1,1,0.5)
        _MainTex("Texture", 2D) = "white" { }
    _Width("Tex Width", Float) = 200.0
        _Height("Tex Height", Float) = 200.0
        _Thick("Line Thickness", Int) = 2
        _LineColor("Line Color",Color) = (0,0,0,1) // ※※
    }


        SubShader{
        Tags{
        "Queue" = "Transparent"
        "IgnoreProjector" = "True"
        "RenderType" = "Transparent"
        "PreviewType" = "Plane"
        "CanUseSpriteAtlas" = "False"
    }
        Pass{


        Blend SrcAlpha OneMinusSrcAlpha
        CGPROGRAM

#pragma vertex vert
#pragma fragment frag
#pragma target 3.0

        fixed4 _Color;
    sampler2D _MainTex;
    float _Width;
    float _Height;
    int _Thick;
    fixed4 _LineColor; // ※※

    struct vin {
        float4 vertex: POSITION;
        float4 texcoord : TEXCOORD0;
    };
    struct v2f {
        float2 uv : TEXCOORD0;
        float4 pos : SV_POSITION;
    };

    v2f vert(vin v)
    {
        v2f o;
        o.pos = UnityObjectToClipPos(v.vertex);
        o.uv = v.texcoord;
        return o;
    }

    fixed4 frag(v2f i) : SV_Target
    {
        float4 texcol = tex2D(_MainTex, i.uv);
        float rangeSq = _Thick*_Thick; // ※※

        // このピクセルの周囲の透明度の最大値を調べる
        float alphaMax = 0.0f;
        for (int x = -_Thick; x <= _Thick; ++x)
            for (int y = -_Thick; y <= _Thick; ++y)
            {
                float alpha = tex2D(_MainTex, i.uv + float2(x / _Width, y / _Height)).a;
                if (alpha > 0.5 && x*x + y*y <= rangeSq) // ※※
                    alphaMax = 1; // ※※
            }

        // このピクセルが透明なら、周囲の透明度の最大値で塗る。
        if (texcol.a < 0.5)
            return float4(_LineColor.xyz, alphaMax); // ※※
        else
            return texcol;
    }
        ENDCG
    }
    }
}

for文の中で参照するピクセルへの距離を計算し、制限を加えております。

このShaderで先ほど同じく、70fpsを維持できる条件を調べてみます。
(丸)のものが変更後のShaderでの厚さ最大値で、(角)は先ほどの数値を比較用に載せました。

Sprite枚数 厚さ最大(丸) ←厚さ最大(角)
1 19 21
2 14 15
3 11 12
4 9 10
8 6 7
16 3 4
32 2 3
64 1 2
87(最大) 1
115(最大) 1

輪郭の厚さ1だと87枚で70fpsになりました。(厚さが1ならば角を丸くする意味はないですが……)
ピクセルごとに距離計算を加えても処理速度はほとんど変わらないようです。

最後に

比較はしておりませんが、頂点シェーダを使用する手法に比べると確かに描画速度の面で見劣りするようです。
一方で、距離に応じて透明度を変えたりといった表現をしたいならばピクセルシェーダを用いたほうが便利だと思われます。
何かの参考になれば幸いです。

・ShaderのTagsなどの設定の参考にさせていただきました → http://ina-amagami.hatenablog.jp/entry/2017/04/23/181457
・画像はいらすとやさん → https://www.irasutoya.com/