土屋つかさの技術ブログは今か無しか

土屋つかさが主にプログラミングについて語るブログです。

#unity 深掘りTextMeshPro:SDFフォントデータによる文字描画実装(第2回:シェーダーコード)

初めに

 第1回ではSDFフォントのデータを使って文字描画を実現するメカニズムについて解説しました。
someiyoshino.info
 今回は実際に文字描画を行うシェーダーコードのロジックを解説します。SDFフォントのデータを元に、文字にアウトラインを設定したり、アルファブレンドによりアンチエイリアスをかけてみます。前回の記事を参照しつつ呼んで貰えればと思います。
 シェーダーコードは単体で機能する訳では無く、このシェーダーコードにデータを送り込むC#コードがセットになります。このC#コードについては後日解説予定です*1

注意

 サンプルコードは土屋がスクラッチで作成したシェーダーコードになります。ただし、文字描画処理自体は、TMPに実装されているロジックとほぼ同じ物です。
 シェーダープログラミング自体については解説しません。HLSL言語に馴染みの無い方は土屋の同人誌をぜひお読みください。
s-games.booth.pm

サンプルコード

 今回文字描画に使用するシェーダーコードを以下に示します。
 長いですが、大部分がシェーダープログラミング特有の定形的なコードなので、それについては飛ばします。
 ここでは文字描画ロジックに直接関係する「フラグメントシェーダー関数」について詳しく解説します。

Shader "TextRenderer/SDF" {

//プロパティ
Properties {
    //フォントアトラステクスチャ
    [NoScaleOffset]_MainTex("Font Atlas", 2D) = "white" {}
    //文字描画範囲の閾値
    [Space(20)]_faceThreshold("Face Threshold", Range(0,1)) = 0
    //アウトライン範囲の割合
    _outlineRatio("Outline Ratio", Range(0, 1)) = 0
    //アウトライン色と文字色のブレンド範囲の割合
    _blendRatio("Blend Ratio", Range(0, 1)) = 0
    //文字色
    [HDR]_faceColor("Face Color", Color) = (1,1,1,1)
    //アウトライン色
    [HDR]_outlineColor("Outline Color", Color) = (0,0,0,1)
    //アンチエイリアス範囲の割合
    _opacityRatio("Opacity Ratio", Range(0,1)) = 0
    //システムで使用
    _CullMode("Cull Mode", Float) = 0
}

SubShader {

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

    Cull [_CullMode]
    ZWrite Off
    Blend SrcAlpha OneMinusSrcAlpha

    Pass {
        CGPROGRAM
        #pragma target 3.0
        #pragma vertex vert
        #pragma fragment frag

        #include "UnityCG.cginc"
        #include "UnityUI.cginc"

        uniform sampler2D _MainTex; //フォントアトラステクスチャ
        uniform float _faceThreshold; //文字描画範囲の閾値
        uniform float _outlineRatio; //アウトライン範囲の割合
        uniform float _blendRatio; //アウトライン色と文字色のブレンド範囲の割合
        uniform fixed4 _faceColor; //文字色
        uniform fixed4 _outlineColor; //アウトライン色
        uniform float _opacityRatio; //アンチエイリアス範囲の割合

        struct Attributes {
            UNITY_VERTEX_INPUT_INSTANCE_ID
            float4    position    : POSITION;
            float3    normal        : NORMAL;
            float4    color        : COLOR;
            float2    atlasUV        : TEXCOORD0;
        };

        struct Varyings {
            UNITY_VERTEX_INPUT_INSTANCE_ID
            UNITY_VERTEX_OUTPUT_STEREO
            float4    position    : SV_POSITION;
            float4    color        : COLOR;
            float2    atlasUV        : TEXCOORD0;
        };

        float4 SRGBToLinear(float4 rgba) {
            return float4(
                        lerp(
                            rgba.rgb / 12.92f, 
                            pow((rgba.rgb + 0.055f) / 1.055f, 2.4f), 
                            step(0.04045f, rgba.rgb)
                        ), 
                        rgba.a
                    );
        }

        //頂点シェーダー(定形的なコード)
        Varyings vert(Attributes input)
        {
            Varyings output;

            UNITY_INITIALIZE_OUTPUT(Varyings, output);
            UNITY_SETUP_INSTANCE_ID(input);
            UNITY_TRANSFER_INSTANCE_ID(input,output);
            UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);

            float4 position = UnityObjectToClipPos(input.position);
            float4 color = input.color;

            #if (FORCE_LINEAR && !UNITY_COLORSPACE_GAMMA)
                color = SRGBToLinear(color);
            #endif

            output.position = position;            //スクリーン空間描画座標
            output.color    = color;            //頂点カラー値
            output.atlasUV    = input.atlasUV;    //アトラステクスチャUV

            return output;
        }

        //フラグメントシェーダー
        float4 frag(Varyings input) : SV_Target
        {
            //システムで使用
            UNITY_SETUP_INSTANCE_ID(input);

            //アトラステクスチャからSDF距離をサンプリング(rgbは未使用)
            float distance = tex2D(_MainTex, input.atlasUV).w;

            //文字描画範囲外をクリップ
            clip(distance - _faceThreshold);

            //アウトライン閾値を算出
            float outlineThreshold = lerp(_faceThreshold, 1, _outlineRatio);
            //アウトラインブレンド閾値を算出
            float blendThreshold = lerp(outlineThreshold, 0, _blendRatio);
            //SDF距離に応じて、カラー値のブレンド率を算出
            float colorBlendRatio = smoothstep(outlineThreshold, blendThreshold, distance);
            //アウトラインカラー値と文字カラー値をブレンド
            half4 color = lerp(_faceColor, _outlineColor, colorBlendRatio);

            //透明度閾値を算出
            float opacityThreshold = lerp(_faceThreshold, 1, _opacityRatio);
            //SDF距離に応じて、透明度のブレンド率を算出
            color.a *= smoothstep(_faceThreshold, opacityThreshold, distance);

            return color;
        }

        ENDCG
    }
}
}

アトラステクスチャ上の任意の文字を描画する仕組み

 コードを読んで行く前に、スクリーン上に文字が描画される仕組みを簡単に説明しておきます。
 画面に何かを描画する時はシーン(3D空間)に3角形のポリゴンを配置し、そのポリゴンにテクスチャを貼り付けます。これは3Dのゲームでも2Dのゲームでも同じです(2Dの場合はスクリーンにぴったりポリゴンを貼り付けるイメージ)。
 ポリゴンと、そのポリゴンに貼り付けるテクスチャの領域を紐付ける為にUV座標配列*2と言うメタデータが用意されています。各頂点に対応するUV座標を元にテクスチャをサンプリングすると、必要な描画が出来るわけです。
 普通の3Dオブジェクトであれば、ポリゴンとUV座標とは静的(固定)な対応になりますが、フォント描画では下図のようになっています。

 画面上には文字を描画する為に四角ポリゴン(3角ポリゴン2枚による矩形)が配置されています。
 TMP_FontAssetにはSDF化した文字が詰め込まれたアトラステクスチャと、各文字に対応するメタデータが含まれています。
 ある文字(ここではQ)を描画したい場合、まず「Q」についてのメタデータを取得します。メタデータには、アトラステクスチャ上で「Q」が格納されている位置、つまりUV座標(ここでは(x1,y1)/(x2,y1)/(x2,y1)/(x2,y2)の4点*3)が格納されています。この値を、四角ポリゴンの各頂点に対応するUV座標配列としてGPUに渡すと、画面に「Q」が表示されるのです。

フラグメントシェーダー関数

 フラグメントシェーダー関数は、あるポリゴンがスクリーン上に表示される時に、そのポリゴンが占めるすべてのピクセルごとに実行される関数です。主に、設定されたテクスチャから、そのピクセルに対応する位置(テクセルと言います)をサンプリングして、そのカラー値を返す事が目的となります。フラグメントシェーダーが返した値が、そのピクセルのカラー値になります。

算出する値

 先に、この関数の中で導出しようとしてる値について簡単に説明します。
この関数では、プロパティで設定された値を元に、outlineThreshold/blendThreshold/opacityThresholdの3つの値を導出します。
 これらの値は、文字描画時の色や透明度のブレンドを行う基準の距離値を表します。

 上図は、文字描画時における、それぞれの値が示す位置を表しています。黄色が文字色、赤がアウトライン色、白色はアルファブレンドだと考えてください(前回の記事のスクショも参照してください)。
 それぞれの値は以下の意味になります。

_faceThreshold 文字の太さ(文字の描画境界)
outlineThreshold アウトライン部の太さ
blendThreshold アウトライン色と文字色をブレンドする範囲
opacityThreshold 文字のフチをアルファブレンドをかける範囲

 _faceThresholdは予めユーザーがプロパティで与えている値です。他にもアンダーバー("_")が先頭にある変数は、同じく予めプロパティで設定されている値です。

テクスチャサンプリング

//フラグメントシェーダー
float4 frag(Varyings input) : SV_Target
{
    //システムで使用(定形コード)
    UNITY_SETUP_INSTANCE_ID(input);

    //アトラステクスチャからSDF距離をサンプリング(rgbは未使用)
    float distance = tex2D(_MainTex, input.atlasUV).w;

 tex2D関数は、テクスチャ(_MainTex)上の、指定された2次元UV座標(input.atlasUV)にあるテクセルの値(32bit値)を返します。この処理を「サンプリング」と言います。
 サンプリングした値はxyzwの4チャンネルに8bitずつ格納されます。距離値はwチャンネルに格納されているのでtex2Dの戻り値から".w"でwチャンネルを抽出し、distanceに格納します。
 余談ですが、サンプリングされる値は機械的にテクスチャから取得されるのではなく、UV座標に応じて付近のテクセルを線形補間した物になります。この仕組みによって、滑らかなテクスチャサンプリングが可能になるのです。

クリッピング

//文字描画範囲外をクリップ
clip(distance - _faceThreshold);

 clip関数は、引数の値が0未満の時にそのピクセルの描画を中止する関数です。フラグメントシェーダーが値を返すと、必ずそのピクセルにカラー値が書き込まれますが、clipにより中止された場合はその書き込み処理自体がスキップされます。
 ここでは、サンプリングした距離値よりも文字幅閾値が大きい場合に描画をスキップしています。これにより、文字幅閾値より小さい(つまり、文字幅閾値で設定したフチよりも外側)ピクセルについては描画しません。アルファ値を操作しているわけではない点に注意してください。

アウトライン境界の算出

//アウトライン内側フチ距離値を算出
float outlineThreshold = lerp(_faceThreshold, 1.0, _outlineRatio);

 文字色とアウトライン色の境界となる距離値を計算します。

 上図はこのlerp式の計算内容のイメージです。_faceThresholdは文字のフチの距離値です。_outlineRatio(%)は文字の中でアウトライン色が占める割合です。わかりやすさのために、割合を意味する変数には(%)と付記しています(以下同じ)。
 距離値は文字の内側に行くほど大きく(最大1)、外側に行くほど小さく(最小0)になります。lerp(x,y,t)はxが下限、yが上限、tが割合を意味し、t(%)に応じてxからyまでの値が返ります。ここでは_faceThresholdから1.0までの範囲で、_outlineRatio(%)になる値をoutlineThresholdとして取得しています。

アウトラインブレンド範囲の算出

//アウトラインブレンド外側フチ距離値を算出
float blendThreshold = lerp(outlineThreshold, 0.0, _blendRatio);

 次に、文字色とアウトライン色をブレンドする範囲を計算します。

 _blendRatio(%)は、色がブレンドされる範囲の割合で、大きいほど範囲が広くなります。lerp式は、先程算出したoutlineThresholdから0.0までの範囲で_blendRatio(%)になる値をblendThresholdとして取得しています。

//SDF距離に応じて、カラー値のブレンド率を算出
float colorBlendRatio = smoothstep(blendThreshold, outlineThreshold, distance);

 次に、このアウトライン色と文字色をブレンドする範囲における、ピクセルごとのブレンド率を計算します。返り値であるcolorBlendRatio(%)は、そのピクセルにおける文字色とアウトライン色のブレンド割合です。

 smoothstep(x,y,t)は、xが下限、yが上限、tが割合を意味し、t(%)がx未満であれば0.0、yより大きければ1.0、xからyの間であれば0.0~1.0のなめらかな変化値を返します*4
 ここでは、距離値がblendThresholdより小さければ0.0(つまり、100%アウトライン色とする)、outlineThresholdより大きければ1.0(つまり、100%文字色とする)、両者の間であれば滑らかに値が変化します。

//アウトラインカラー値と文字カラー値をブレンド
half4 color = lerp(_outlineColor, _faceColor, colorBlendRatio);

 前の式でカラー値のブレンド率(colorBlendRatio(%))が算出出来たので、この値を使ってピクセルのカラー値を決定します。

アルファブレンド(アンチエイリアス)境界の距離値

//透明度内側フチ距離値を算出
float opacityThreshold = lerp(_faceThreshold, 1, _opacityRatio);

 ここからは文字のフチのアンチエイリアス=透明度の割合を計算します。

 基本的な考え方はアウトラインの場合と同じです。文字のフチに相当する距離値である_faceThresholdから1.0までの範囲でアンチエイリアスを適用する割合(_opacityRatio(%))が示す値をopacityThresholdとして取得します。

//SDF距離に応じて、透明度のブレンド率を算出
color.a *= smoothstep(_faceThreshold, opacityThreshold, distance);
return color;

 こちらもアウトラインの場合と同じように、smoothstep式により、距離値に応じて0.0~1.0の値を算出し、それを透明度としてピクセルのアルファチャンネルに乗算します。

 アルファチャンネルに代入ではなく乗算してるのは拡張性*5の為で、このサンプルコードでは関係ありません。

補足

 上の例ではアウトラインがオンの場合と、アルファブレンドがオンの場合を別個に説明していますが、両方オンの場合でも計算は同じで、以下の様なイメージになります。

余談

 TMPが採用しているSDFフォントの描画ロジックについて解説しました。説明は長いですが、文字描画ロジック自体は非常にシンプルなのが面白いです。
 ちなみに、TMPでは計算のロジックにミスがあり、パラメータをどう設定してもアンチエイリアスを完全にオフにすることができません。互換性の問題もあるのでこのバグは解消されないんじゃないかなと思います。目視による確認が困難ですし、実用上の問題は無いと思います。
 また、このシェーダーコード(つまり、TMPのロジック)では、ブレンド率の遷移をsmoothstep()で計算していますが、いまいちパキっとした結果にならないなと個人的には思っています。別の補間式を用いる選択肢もあるかもしれません。

次回予告

 第3回ではシーン内に板ポリを配置し、このシェーダーコードにパラメータを送信するC#コードを解説する予定です。ただその前に別のエントリを挟むかも。

*1:シェーダーコードと比較にならないほど分量が多いのでどう記事を構成した物か悩み中。

*2:名称は流儀によって色々ある

*3:実際に格納されているのは文字毎の原点と幅/高さ情報

*4:いわゆるエルミート補間

*5:頂点カラーに設定したα値を反映する処理を想定しています