[Unity] カスタムシェーダーでTextMeshProに独創的な演出を加える

これは[Unity #2 Advent Calendar 2018](https://qiita.com/advent-calendar/2018/unity2)の19日目の記事です。

<img width="100%" alt="※画像が読み込まれるまでお待ちください WORMHOLE" src="https://media.giphy.com/media/fHouJLi9ipkpXxVPki/giphy.gif">

<!--img width="100%" alt="※画像が読み込まれるまでお待ちください Graphics by gam0022" src="https://media.giphy.com/media/XpjH1bTO4cGUYb7zfr/giphy.gif"-->

# はじめに

上の映像は、Tokyo Demo Fest 2018 PC Demoの優勝作品[『WORMHOLE』](https://youtu.be/k5MotEfghjQ)のオープニング部分です。
タイトルの文字をパラパラと出現させたり消失させたりする演出は、TextMeshProとカスタムシェーダーを組み合わせて実装しました。

この記事では3つの演出例を通して、TextMeshProの文字描画に独創的な演出を加えるためのカスタムシェーダーの実装方法を紹介します。

なお、実装環境は次の通りです。

- Unity 2018.2.17f1
- TextMeshPro 1.2.4

# TextMeshPro

TextMeshProは、SDF（Signed Distance Field, 文字の輪郭までの距離を画素値にした画像）をつかって高品質にフォントをレンダリングできるアセットです。
Package Managerや[AssetStore](https://assetstore.unity.com/packages/essentials/beta-projects/textmesh-pro-84126)から**無料**で入手可能です。

## TextMeshProの描画の仕組み

本題に入る前に、TextMeshProの描画の仕組みについて触れておきます。
TextMeshProのシェーダー挙動は一般的なシェーダーのものとはやや異なります。
描画の仕組みを把握しておくと、後ほど解説するカスタムシェーダーの実装の理解が進むはずです。

TextMeshProの描画は2つのステップで行われます。

1. SDFフォントのアトラステクスチャの事前生成
2. シェーダーによるテキストの描画

## SDFフォントのアトラステクスチャの事前生成

<img width="75%" alt="Font Asset Creator" src="https://qiita-image-store.s3.amazonaws.com/0/17718/ad338575-de1d-42db-6048-0396d7e7fba5.png">


`Window > TextMeshPro > Font Asset Creator` からSDFフォントのアトラステクスチャを生成できます。

TextMeshProではフォントの中心が`1.0`、フォントの外側が`0.0`になるようなSDFのテクスチャアトラスを生成します。

SDFというと、フォントの内側が負、外側が正というのを思い浮かべてしまいますが、テクスチャは0～1の範囲であることを考えると納得できる仕様です。

## シェーダーによるテキストの描画

<img width="50%" alt="TextMeshProのワイヤーフレム表示" src="https://qiita-image-store.s3.amazonaws.com/0/17718/9086e0e4-8068-c0d3-a906-45721fbc23ec.png">

フレームデバッガーやワイヤフレーム表示で挙動を確認すると、TextMeshProがフォントをレンダリングする仕組みを把握できます。

まず、1文字ずつ四角形のメッシュが割り当てられており、SDFテクスチャのUV座標は頂点データとしてメッシュに埋め込まれています。

フラグメントシェーダーでSDFのテクスチャをフェッチして、フォントの内外の判定を行い、
内部ならシェーディングを行い、外側ならピクセルを破棄して描画をキャンセルすることで、フォントをレンダリングします。

```glsl
// SDFをフェッチ
half d = tex2D(_MainTex, input.texcoord0.xy).a * input.param.x;

// SDFを元に内外判定とカラー計算
half4 c = input.faceColor * saturate(d - input.param.w);

// c.a が0以下ならピクセルを破棄
clip(c.a - 0.001);
```

TextMeshProは高機能であるため、シェーダーの実装量は多いですが、フォントの描画だけに着目すれば大して複雑なことはしていません。

シェーダーバリアントも多用していますが、`#if` の中身は気にしなくても問題ないので、読み飛ばしてください。

### 斜体

<img width="50%" alt="斜体" src="https://qiita-image-store.s3.amazonaws.com/0/17718/c61080fa-0839-da20-7295-1d822019be5d.png">

斜体はメッシュを傾けて実装されているので、シェーダー側では特に意識する必要はありません。シェーダー側で何も実装せずとも勝手に斜体になります。

### 太字

<img width="50%" alt="太字" src="https://qiita-image-store.s3.amazonaws.com/0/17718/8a83b83f-8fa6-e4ec-e209-14f1cf0889a9.png">

太字はSDFからのテキストの内外判定をする閾値を制御することで実装されています。

# 演出別の実装解説

カスタムシェーダーは[TMP_SDF-Mobile.shader](https://github.com/gam0022/unity-demoscene/blob/f7145ee3f8fe5b0088d7f043cd8cf6c63fe063eb/Assets/TextMesh%20Pro/Resources/Shaders/TMP_SDF-Mobile.shader)をベースに実装しました。

TMP_SDF-Mobile.shaderに限らず、Mobile向けのシェーダーの実装は単純で短いため改変しやすいです。

## 演出1: モーフィング

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">TextMeshPro は SDF をつかってフォントレンダリングしていることを思い出し、もしかしてモーフィーングできるのでは？と思って挑戦してみたらできた！ <a href="https://twitter.com/hashtag/unity3d?src=hash&amp;ref_src=twsrc%5Etfw">#unity3d</a> <a href="https://twitter.com/hashtag/Unity?src=hash&amp;ref_src=twsrc%5Etfw">#Unity</a> <a href="https://twitter.com/hashtag/creativecoding?src=hash&amp;ref_src=twsrc%5Etfw">#creativecoding</a> <a href="https://t.co/nCx7xBwGGv">pic.twitter.com/nCx7xBwGGv</a></p>&mdash; がむ (@gam0022) <a href="https://twitter.com/gam0022/status/1053969654165585920?ref_src=twsrc%5Etfw">2018年10月21日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

シェーダー全体: [TMP_SDF-MobileMorphing.shader](https://github.com/gam0022/unity-demoscene/blob/fbb8b0a88080be88a49b649099c3ad3d9a6bb40c/Assets/Demoscene/Projects/2018-10-21-TextMeshProShader/TMP_SDF-MobileMorphing.shader)

### シェーダーの差分

※説明を簡単にするために、ここではコードの差分のうち一部を抜粋します。

```diff
diff -wu "Assets/TextMesh Pro/Resources/Shaders/TMP_SDF-Mobile.shader" "Assets/Demoscene/Projects/2018-10-21-TextMeshProShader/TMP_SDF-MobileMorphing.shader"
--- Assets/TextMesh Pro/Resources/Shaders/TMP_SDF-Mobile.shader	2018-08-30 19:39:38.000000000 +0900
+++ Assets/Demoscene/Projects/2018-10-21-TextMeshProShader/TMP_SDF-MobileMorphing.shader	2018-10-21 20:17:18.000000000 +0900
@@ -186,7 +188,9 @@
 		// PIXEL SHADER
 		fixed4 PixShader(pixel_t input) : SV_Target
 		{
-			half d = tex2D(_MainTex, input.texcoord0.xy).a * input.param.x;
+			half d1 = tex2D(_MainTex, input.texcoord0.xy).a * input.param.x;
+			half d2 = tex2D(_MainTex, input.texcoord0.xy - float2(-0.03, 1.0 / 4.7)).a * input.param.x;
+			half d = lerp(d1, d2, 0.5 * (1.0 + sin(0.5 * PI * _Time.y)));
 			half4 c = input.faceColor * saturate(d - input.param.w);

 		#ifdef OUTLINE_ON
@@ -219,6 +223,10 @@
 			clip(c.a - 0.001);
 		#endif

+		    c.rg *= input.texcoord0.xy;
 			return c;
 		}
 		ENDCG
```

元の文字のSDF `d1` と
適当にずらした位置にある文字のSDF `d2`を
`lerp`で線形補間すればモーフィングできます。

SDFをつかうとモーフィーングを非常にお手軽に実装できます。
SDFを採用したTextMeshProの強みを活かした良い応用例だと思います。

```glsl
half d1 = tex2D(_MainTex, input.texcoord0.xy).a * input.param.x;
half d2 = tex2D(_MainTex, input.texcoord0.xy - float2(-0.03, 1.0 / 4.7)).a * input.param.x;
half d = lerp(d1, d2, 0.5 * (1.0 + sin(0.5 * PI * _Time.y)));
```

さらに、次のコードでSDFテクスチャのUVに応じてUVグラデーションで色を塗りました。

```glsl
c.rg *= input.texcoord0.xy;
```

## 演出2: ブラウン管風のエフェクト

<blockquote class="twitter-tweet" data-conversation="none" data-lang="ja"><p lang="ja" dir="ltr">TextMeshPro に適当なシェーダーを適用する実験。<br>TextMeshPro 用のシェーダーの書き方が分かってきた気がする。<a href="https://twitter.com/hashtag/unity3d?src=hash&amp;ref_src=twsrc%5Etfw">#unity3d</a> <a href="https://twitter.com/hashtag/Unity?src=hash&amp;ref_src=twsrc%5Etfw">#Unity</a> <a href="https://twitter.com/hashtag/creativecoding?src=hash&amp;ref_src=twsrc%5Etfw">#creativecoding</a> <a href="https://t.co/BPUoH83nrA">pic.twitter.com/BPUoH83nrA</a></p>&mdash; がむ (@gam0022) <a href="https://twitter.com/gam0022/status/1053989356384354304?ref_src=twsrc%5Etfw">2018年10月21日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

シェーダー全体: [TMP_SDF-MobileCyber.shader](https://github.com/gam0022/unity-demoscene/blob/fbb8b0a88080be88a49b649099c3ad3d9a6bb40c/Assets/Demoscene/Projects/2018-10-21-TextMeshProShader/TMP_SDF-MobileMorphing.shader)

```diff
diff -wu "Assets/TextMesh Pro/Resources/Shaders/TMP_SDF-Mobile.shader" "Assets/Demoscene/Projects/2018-10-21-TextMeshProShader/TMP_SDF-MobileCyber.shader"
--- Assets/TextMesh Pro/Resources/Shaders/TMP_SDF-Mobile.shader	2018-08-30 19:39:38.000000000 +0900
+++ Assets/Demoscene/Projects/2018-10-21-TextMeshProShader/TMP_SDF-MobileCyber.shader	2018-10-21 21:34:11.000000000 +0900
@@ -105,7 +107,7 @@
 			fixed4	outlineColor	: COLOR1;
 			float4	texcoord0		: TEXCOORD0;			// Texture UV, Mask UV
 			half4	param			: TEXCOORD1;			// Scale(x), BiasIn(y), BiasOut(z), Bias(w)
-			half4	mask			: TEXCOORD2;			// Position in clip space(xy), Softness(zw)
+			half4	spos			: TEXCOORD2;			// Position in clip space(xy), Softness(zw)
 		#if (UNDERLAY_ON | UNDERLAY_INNER)
 			float4	texcoord1		: TEXCOORD3;			// Texture UV, alpha, reserved
 			half2	underlayParam	: TEXCOORD4;			// Scale(x), Bias(y)
@@ -172,7 +174,7 @@
 				outlineColor,
 				float4(input.texcoord0.x, input.texcoord0.y, maskUV.x, maskUV.y),
 				half4(scale, bias - outline, bias + outline, bias),
-				half4(vert.xy * 2 - clampedRect.xy - clampedRect.zw, 0.25 / (0.25 * half2(_MaskSoftnessX, _MaskSoftnessY) + pixelSize.xy)),
+				ComputeScreenPos(vPosition),
 			#if (UNDERLAY_ON | UNDERLAY_INNER)
 				float4(input.texcoord0 + layerOffset, input.color.a, 0),
 				half2(layerScale, layerBias),
@@ -182,11 +184,15 @@
 			return output;
 		}

+        inline float Mod(float a, float b)
+        {
+            return frac(abs(a / b)) * abs(b);
+        }

 		// PIXEL SHADER
 		fixed4 PixShader(pixel_t input) : SV_Target
 		{
-			half d = tex2D(_MainTex, input.texcoord0.xy).a * input.param.x;
+			half d = tex2D(_MainTex, input.texcoord0.xy + exp(-Mod(_Time.y, 4.0)) * float2(0.02 * sin(_Time.y * 100.0), 0.002 * sin(input.spos.y * 100.0))).a * input.param.x;
 			half4 c = input.faceColor * saturate(d - input.param.w);

 		#ifdef OUTLINE_ON
@@ -219,6 +225,10 @@
 			clip(c.a - 0.001);
 		#endif

+		    c *= 0.5 * (1.0 + sin(input.spos.y * 500.0)) * abs(sin(100.0 * _Time.y + input.spos.y * 30.0)) * abs(sin(4.00 * _Time.y + 1.0 * input.texcoord0.x));
 			return c;
 		}
 		ENDCG
```

スクリーンスペースに模様を計算したり揺らしたりする例です。

まずは、スクリーンスペースに模様を計算する前には頂点シェーダーでスクリーンスペースの座標を取得する必要があります。
この頂点シェーダーで頂点座標からスクリーンスペースの座標に変換する処理が `ComputeScreenPos(vPosition)` です。

スクリーンスペースの座標を`spos`という名前で頂点シェーダーからフラグメントシェーダーに渡し、フラグメントシェーダで`spos`に応じて動きや色を処理すれば動画のようになります。

#### 揺らす処理

「sin波による単純な動き」と `exp(-Mod(_Time.y, 4.0))` の「時間による減衰」を組み合わせました。

```glsl
half d = tex2D(_MainTex, input.texcoord0.xy + 
    exp(-Mod(_Time.y, 4.0)) * float2(0.02 * sin(_Time.y * 100.0), 0.002 * sin(input.spos.y * 100.0))).a * input.param.x;
```

#### 色付けの処理

「スクリーン座標に応じた細かい波」と「時間に応じた波」を掛け算で組み合わせて縞模様をつくりました。

```glsl
c *= 0.5 * (1.0 + sin(input.spos.y * 500.0)) * 
   abs(sin(100.0 * _Time.y + input.spos.y * 30.0)) * 
   abs(sin(4.00 * _Time.y + 1.0 * input.texcoord0.x));
```

## 演出3: 出現と消失アニメーション

最後にご紹介するのが『WORMHOLE』でも利用したエフェクトです。

<blockquote class="twitter-tweet" data-conversation="none" data-lang="ja"><p lang="ja" dir="ltr">TextMeshPro シェーダー遊び その3<a href="https://twitter.com/hashtag/unity3d?src=hash&amp;ref_src=twsrc%5Etfw">#unity3d</a> <a href="https://twitter.com/hashtag/Unity?src=hash&amp;ref_src=twsrc%5Etfw">#Unity</a> <a href="https://twitter.com/hashtag/creativecoding?src=hash&amp;ref_src=twsrc%5Etfw">#creativecoding</a> <a href="https://t.co/bUJvfyDhBr">pic.twitter.com/bUJvfyDhBr</a></p>&mdash; がむ (@gam0022) <a href="https://twitter.com/gam0022/status/1056398353569697792?ref_src=twsrc%5Etfw">2018年10月28日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

シェーダー全体: [TMP_SDF-MobileScan.shader](https://github.com/gam0022/unity-demoscene/blob/5f1674ad57b76101aab4edc3897859128cd6b86f/Assets/Demoscene/Projects/2018-10-28-TDF2018/Shaders/TMP_SDF-MobileScan.shader)

```diff
diff -wu "Assets/TextMesh Pro/Resources/Shaders/TMP_SDF-Mobile.shader" "Assets/Demoscene/Projects/2018-10-21-TextMeshProShader/TMP_SDF-MobileScan.shader"
--- Assets/TextMesh Pro/Resources/Shaders/TMP_SDF-Mobile.shader	2018-08-30 19:39:38.000000000 +0900
+++ Assets/Demoscene/Projects/2018-10-21-TextMeshProShader/TMP_SDF-MobileScan.shader	2018-10-28 13:05:42.000000000 +0900
@@ -172,7 +174,7 @@

 		// PIXEL SHADER
 		fixed4 PixShader(pixel_t input) : SV_Target
 		{
-			half d = tex2D(_MainTex, input.texcoord0.xy).a * input.param.x;
+		    half2 uv = input.texcoord0.xy;
+		    uv.y = clamp(uv.y, 0.0, 0.5 + 0.5 * sin(_Time.y));
+			half d = tex2D(_MainTex, uv).a * input.param.x;
 			half4 c = input.faceColor * saturate(d - input.param.w);

 		#ifdef OUTLINE_ON
```

今回紹介する中では、一番実装が短くて実質的な差分は**わずか3行**です。

SDFテクスチャをフェッチするUVを`clamp`することで、フォントの一部を引き伸ばしたような効果を加えました。

テクスチャ上では文字同士がばらばらに配置されていて、文字ごとにUVが異なっています。
そこで、経過時間に応じてUVを一様にclampすると、文字ごとに表示されるまでの時間を変えることができます。

処理としてはUVを**一様に**変化させているだけなのですが、文字ごとのUVが異なるために結果として**ランダムに**出現するかのように見せられるということです。

この効果は@setchiさんの[Cyber jewel](https://twitter.com/setchi/status/1015441713429557248)、@shutosgさんの[この作品](https://twitter.com/shuto_fms/status/983003003249557505)からインスピレーションを得ました。

# おわりに

TextMeshProを含めUnityの描画系Componentの多くはシェーダーを適用可能です。
カスタムシェーダーを利用して、Unity標準ではできない独創的な演出を加えてみましょう！
シェーダー最高！

最後に『WORMHOLE』について解説した記事を紹介します。
TextMeshPro以外にもUnity3Dの新機能を活用しています。ぜひご覧ください！

<blockquote class="twitter-tweet" data-conversation="none" data-lang="ja"><p lang="ja" dir="ltr"><a href="https://twitter.com/hashtag/TokyoDemoFest?src=hash&amp;ref_src=twsrc%5Etfw">#TokyoDemoFest</a> 2018 の Demo Compo で1位になった<br>『WORMHOLE by <a href="https://twitter.com/gam0022?ref_src=twsrc%5Etfw">@gam0022</a> &amp; <a href="https://twitter.com/sadakkey?ref_src=twsrc%5Etfw">@sadakkey</a> 』<br>の映像制作技術について解説しました ✍️<a href="https://twitter.com/hashtag/Unity3D?src=hash&amp;ref_src=twsrc%5Etfw">#Unity3D</a> の新機能も活用し、効率的に開発と制作を行いました 😌<a href="https://t.co/RTIvDeKB8z">https://t.co/RTIvDeKB8z</a></p>&mdash; がむ (@gam0022) <a href="https://twitter.com/gam0022/status/1072647132627300354?ref_src=twsrc%5Etfw">2018年12月12日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# Licence

この記事のソースコードは MIT License です。