Edited at
Unity #2Day 19

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

これはUnity #2 Advent Calendar 2018の19日目の記事です。

※画像が読み込まれるまでお待ちください WORMHOLE


はじめに

上の映像は、Tokyo Demo Fest 2018 PC Demoの優勝作品『WORMHOLE』のオープニング部分です。

タイトルの文字をパラパラと出現させたり消失させたりする演出は、TextMeshProとカスタムシェーダーを組み合わせて実装しました。

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

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


  • Unity 2018.2.17f1

  • TextMeshPro 1.2.4


TextMeshPro

TextMeshProは、SDF(Signed Distance Field, 文字の輪郭までの距離を画素値にした画像)をつかって高品質にフォントをレンダリングできるアセットです。

Package ManagerやAssetStoreから無料で入手可能です。


TextMeshProの描画の仕組み

本題に入る前に、TextMeshProの描画の仕組みについて触れておきます。

TextMeshProのシェーダー挙動は一般的なシェーダーのものとはやや異なります。

描画の仕組みを把握しておくと、後ほど解説するカスタムシェーダーの実装の理解が進むはずです。

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


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

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


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

Font Asset Creator

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

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

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


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

TextMeshProのワイヤーフレム表示

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

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

フラグメントシェーダーでSDFのテクスチャをフェッチして、フォントの内外の判定を行い、

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

// 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 の中身は気にしなくても問題ないので、読み飛ばしてください。


斜体

斜体

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


太字

太字

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


演出別の実装解説

カスタムシェーダーはTMP_SDF-Mobile.shaderをベースに実装しました。

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


演出1: モーフィング

シェーダー全体: TMP_SDF-MobileMorphing.shader


シェーダーの差分

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

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の強みを活かした良い応用例だと思います。

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グラデーションで色を塗りました。

c.rg *= input.texcoord0.xy;


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

シェーダー全体: TMP_SDF-MobileCyber.shader

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)) の「時間による減衰」を組み合わせました。

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;


色付けの処理

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

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』でも利用したエフェクトです。

シェーダー全体: TMP_SDF-MobileScan.shader

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@shutosgさんのこの作品からインスピレーションを得ました。


おわりに

TextMeshProを含めUnityの描画系Componentの多くはシェーダーを適用可能です。

カスタムシェーダーを利用して、Unity標準ではできない独創的な演出を加えてみましょう!

シェーダー最高!

最後に『WORMHOLE』について解説した記事を紹介します。

TextMeshPro以外にもUnity3Dの新機能を活用しています。ぜひご覧ください!