コポうぇぶろぐ

コポコポによる備忘録&Tipsブログ

【Unity】TextMesh Proをアニメーションさせる~プログラマ編~

はじめに

TextMesh Pro は Unity公式の無料のテキストUIアセットで、最近ではデフォルトでインポートされてたりします。
テキストのビジュアルだけでなく、リッチテキストや整列等、機能面においても優れたアセットですが、
テキストをアニメーションさせる仕組みは実装されていないので、何かしらコンポーネントを作る必要があります。

本記事では、TextMesh Proでのシンプルなアニメーションを実現する方法と、
ジオメトリの動かし方(C#)に関する実装をまとめていきます。
https://raw.githubusercontent.com/coposuke/TextMeshProAnimator/image/TMPA1.gif


デザイナーの方向けに専用に別の記事を用意してみました。
とにかくテキストを動かしたい!という方はこちらをご覧ください。
coposuke.hateblo.jp

TextMesh Proについて(本の紹介)

TextMesh Proの使い方や機能は、執筆した本にて詳しく解説しております。ぜひ読んでみてください!(突然の広告)

Unity デザイナーズ・バイブル(2020/6 発売)  

Unity ゲームプログラミング・バイブル(2018/4 発売)

作ったもの

本のサンプル用に作成したアニメーションシステムはこちらです。ご自由にお使いください。
github.com

シンプルなアニメーション

表示文字数を増やしていくタイプのシンプルなアニメーションを作成してみましょう。
これは maxVisibleCharacters を変更することで実現できます。

maxVisibleCharacters は 最大表示文字数 で、テキストがどんなに長くても maxVisibleCharacters の数だけしか表示されません。
例えば、0なら何も表示されず、10なら10文字だけ表示されます。

この maxVisibleCharacters を 0から徐々に増やしていくことで実現するのですが、
このパラメータはインスペクターに表示されないので、変更するためにはプログラムで値を変える必要があります。

コンポーネントを作る

maxVisibleCharacters を設定するコンポーネントを作成します。

using UnityEngine;
using TMPro;

[ExecuteInEditMode]
[RequireComponent(typeof(TextMeshPro))]
public class TextMeshProMaxVisibleCharaController : MonoBehaviour
{
	public int maxVisibleCharacters;
	private TextMeshPro text;

	private void Update()
	{
		if (this.text == null)
			this.text = GetComponent<TextMeshPro>();

		this.text.maxVisibleCharacters = this.maxVisibleCharacters
	}
}

インスペクターでこの maxVisibleCharacters を設定してあげれば、表示される文字数が変わるようになったかと思います。
あまりにもシンプルすぎますが、Animator 等で徐々に表示されるアニメーションが作れるようになりました。
f:id:coposuke:20200605160948g:plain:w600

    【TIPS】属性について
    クラス名の上にある宣言は 属性 といって、クラスに情報を付加しています。
    ExecuteInEditMode はUnityに対して、エディタ―上でもExecuteしますという情報付加、
    RequireComponent もUnityに対して、必ず指定コンポーネントが一緒にアタッチされている状態にしますという情報付加です。

ただし、これだとADV系のゲームでは少々使いづらいと思うので、
この maxVisibleCharacters を使って自由に作り変えてみてください。

GitHub に Animatorなしでもアニメーションするものを作ったので、是非使ってみてください。
github.com

複雑なアニメーション

一つ一つのテキストを動かすアニメーションを作成してみましょう。
TextMesh Pro では Transformの座標を中心に四角い平面のメッシュが並んでいます。
このメッシュの4頂点の情報を書き換えることで、1文字1文字に動きを付けることが出来ます。

https://raw.githubusercontent.com/coposuke/TextMeshProAnimator/image/TMPA2.gif

ジオメトリ情報を取得する

TMP_Text クラス(TextMeshPro や TextMeshProUGUI クラスの継承元)には、
単語や行などのテキストに関する TMP_TextInfo を保持しており、その中に TMP_MeshInfo というメッシュの情報を持っています。
TMP_MeshInfo は頂点情報を持っており、この情報を元にメッシュを生成することができます。

f:id:coposuke:20200605115419p:plain
TMP_MeshInfoの例('い'を取り出す)

ジオメトリを変更する(頂点カラー)

では実際に Update でテキストの色を変えてみましょう。

using UnityEngine;
using TMPro;

public class TextMeshProAnimator : MonoBehaviour
{
    [SerializeField] private Gradient gradientColor;
    private TMP_Text textComponent;

    private void Update()
    {
        if (this.textComponent == null)
            this.textComponent = GetComponent<TMP_Text>();

        UpdateAnimation();
    }

    private void UpdateAnimation()
    {
        // ① メッシュを再生成する(リセット)
        this.textComponent.ForceMeshUpdate(true);
        this.textInfo = textComponent.textInfo;

        // ②頂点データを編集した配列の作成
        var count = Mathf.Min(this.textInfo.characterCount, this.textInfo.characterInfo.Length);
        for (int i = 0; i < count; i++)
        {
            var charInfo = this.textInfo.characterInfo[i];
            if (!charInfo.isVisible)
                continue;

            int materialIndex = charInfo.materialReferenceIndex;
            int vertexIndex = charInfo.vertexIndex;

            // Gradient
            Color32[] colors = textInfo.meshInfo[materialIndex].colors32;

            float timeOffset = -0.5f * i;
            float time1 = Mathf.PingPong(timeOffset + Time.realtimeSinceStartup, 1.0f);
            float time2 = Mathf.PingPong(timeOffset + Time.realtimeSinceStartup - 0.1f, 1.0f);
            colors[vertexIndex + 0] = gradientColor.Evaluate(time1); // 左下
            colors[vertexIndex + 1] = gradientColor.Evaluate(time1); // 左上
            colors[vertexIndex + 2] = gradientColor.Evaluate(time2); // 右上
            colors[vertexIndex + 3] = gradientColor.Evaluate(time2); // 右下
        }

        // ③ メッシュを更新
        for (int i = 0; i < this.textInfo.materialCount; i++)
        {
            if (this.textInfo.meshInfo[i].mesh == null) { continue; }

            this.textInfo.meshInfo[i].mesh.colors32 = this.textInfo.meshInfo[i].colors32;
            textComponent.UpdateGeometry(this.textInfo.meshInfo[i].mesh, i);
        }
    }
}

このコンポーネントをGameObjectにアタッチし、インスペクターで画像のように設定します。
f:id:coposuke:20200605032940p:plain

実行すると、このような動きになります。
f:id:coposuke:20200604172227g:plain

重要なポイントは下記3点です。

  1. TMP_Text.ForceMeshUpdate() でテキストからメッシュ再生成(元の状態にリセット)
  2. TMP_MeshInfo.vertices などの情報を更新
  3. TMP_Text.UpdateGeometry() でメッシュを更新

meshInfo.color32 の配列の中身を直接書き換えて、UpdateGeometry() で渡してメッシュを再生成しています。
meshInfo
.color32 を使うことは強引なやりかたで、ForceMeshUpdate() でメッシュを再生成しないとマズかったりします(詳しくは後述)。

    【TIPS】Gradient および gradientColorについて
    [SerializeField] は Unity のエディタ上でインスペクターでパラメータを調整できるようにする属性です。(正確な意味合いはちょっと違いますが)
    Gradient は グラデーションカラーを設定できるクラスで、Evaluate(0.0f ~ 1.0)から滑らかな色を取得できます。

ジオメトリを変更する(頂点座標)

今度は、Update でテキストの座標を動かしてみましょう。
先程のコンポーネントを下記のように書き換えます。

private void UpdateAnimation()
{
    // ① メッシュを再生成する(リセット)
    ...

    // ② 頂点データを編集した配列の作成
    var count = Mathf.Min(textInfo.characterCount, textInfo.characterInfo.Length);
    for (int i = 0; i < count; i++)
    {
        ...

        // Wave
        Vector3[] verts = textInfo.meshInfo[materialIndex].vertices;

        float sinWaveOffset = 0.5f * i;
        float sinWave = Mathf.Sin(sinWaveOffset + Time.realtimeSinceStartup * Mathf.PI);
        verts[vertexIndex + 0].y += sinWave;
        verts[vertexIndex + 1].y += sinWave;
        verts[vertexIndex + 2].y += sinWave;
        verts[vertexIndex + 3].y += sinWave;
    }

    // ③ メッシュを更新
    for (int i = 0; i < textInfo.materialCount; i++)
    {
        if (this.textInfo.meshInfo[i].mesh == null) { continue; }

        textInfo.meshInfo[i].mesh.vertices = textInfo.meshInfo[i].vertices;  // 変更
        textComponent.UpdateGeometry(textInfo.meshInfo[i].mesh, i);
    }
}

実行すると、このような動きになります。
f:id:coposuke:20200604172247g:plain

このままだと汎用性がないので、AnimationCurve や Tween などを駆使して、
インスペクターで設定できるようにすると、演出制作を反復しながら作れて良いです。

GitHub に 汎用性高めなアニメーションするコンポーネントを作ったので、是非使ってみてください。
(大変お世話になっている Typeface Animator のパラメータを参考にさせて頂きました)
github.com

注意点

1.メッシュの生成が遅れる

OnValidate や Awake 等で、Meshの生成が遅れているケースがたまにあります。
for文で回す場合に配列の限界値チェックやNullチェックは行っておいた方が良さそうです。

2.Garbage Collection

重要なポイント①の TMP_Text.ForceMeshUpdate() は、入力されたテキストを元に、メッシュを再生成します(恐らく)。
その為 Mesh_Info[].vertices や colors32 を直接弄っても、元に戻してくれるため、専用に配列を作る必要はありませんでした。

ですが、TMP_Text.ForceMeshUpdate() はメソッドコール時にGC(ゴミ)が発生します。
毎フレーム呼び出すとカクツキの原因になるので、頂点情報は予めキャッシュするなどして運用したほうが◎です。

ただし、テキストが変更された場合にキャッシュしたものを更新しないといけないので、
TMPro_EventManager.TEXT_CHANGED_EVENT に登録することで検知する必要があります。

using UnityEngine;
using TMPro;

public class TextMeshProAnimator : MonoBehaviour
{
    private TMP_Text textComponent;
    private TMP_TextInfo textInfo;
    private bool hasTextChanged = true;
    private Vector3[][] baseVertices = default;

    private void Update()
    {
        if (this.textComponent == null)
            this.textComponent = GetComponent<TMP_Text>();

        UpdateAnimation();
    }

    private void OnEnable()
    {
        TMPro_EventManager.TEXT_CHANGED_EVENT.Add(OnTextChanged);
    }

    private void OnDisable()
    {
        TMPro_EventManager.TEXT_CHANGED_EVENT.Remove(OnTextChanged);
    }

    private void OnTextChanged(Object obj)
    {
        if (obj == this.textComponent)
            this.hasTextChanged = true;
    }

    private void UpdateAnimation()
    {
        // GC対策
        if (this.hasTextChanged)
        {
            // ① メッシュを再生成する(リセット)
            this.textComponent.ForceMeshUpdate(true);
            this.textInfo = textComponent.textInfo;

            // ForceMeshUpdate にて OnTextChanged が呼び出されるのでこのタイミングでフラグを下す
            this.hasTextChanged = false;

            // ①’ 頂点データの保存
            if (this.baseVertices == null)
                this.baseVertices = new Vector3[this.textInfo.materialCount][];

            for (int i = 0; i < textInfo.materialCount; i++)
            {
                TMP_MeshInfo meshInfo = textInfo.meshInfo[i];

                if (this.baseVertices[i] == null ||
                    this.baseVertices[i].Length != meshInfo.vertices.Length)
                    System.Array.Resize(ref this.baseVertices[i], meshInfo.vertices.Length);

                System.Array.Copy(meshInfo.vertices, this.baseVertices[i], meshInfo.vertices.Length);
            }
        }

        // ② 頂点データを編集した配列の作成
        var count = Mathf.Min(this.textInfo.characterCount, this.textInfo.characterInfo.Length);
        for (int i = 0; i < count; i++)
        {
            var charInfo = this.textInfo.characterInfo[i];
            if (!charInfo.isVisible)
                continue;

            int materialIndex = this.textInfo.characterInfo[i].materialReferenceIndex;
            int vertexIndex = this.textInfo.characterInfo[i].vertexIndex;

            Vector3[] baseVerts = this.baseVertices[materialIndex];
            Vector3[] animVerts = this.textInfo.meshInfo[materialIndex].vertices;

            // Wave
            float sinWaveOffset = 0.5f * i;
            float sinWave = Mathf.Sin(sinWaveOffset + Time.realtimeSinceStartup * Mathf.PI);
            animVerts[vertexIndex + 0].y = baseVerts[vertexIndex + 0].y + sinWave;
            animVerts[vertexIndex + 1].y = baseVerts[vertexIndex + 1].y + sinWave;
            animVerts[vertexIndex + 2].y = baseVerts[vertexIndex + 2].y + sinWave;
            animVerts[vertexIndex + 3].y = baseVerts[vertexIndex + 3].y + sinWave;
        }

        // ③ メッシュを更新
        ...
    }
}

検知直後は Text_MeshInfo の情報が古いことがあるので、安全を取って Update で処理しています。

3.リッチテキスト(<s> / <u> / <mark>)

TextMesh Pro のリッチテキストの中には、取消線や下線やマークといったテキストの上から表示する機能があります。
恐らくこれらのメッシュも TMP_TextInfo のどこかにあるのかと思いますが、追い切れていません(雑で申し訳ない)。

これらのメッシュに対してアニメーションは付けていないので、線やマークが浮いた状態になってしまいます。
その苦肉の策として、先程解説があった maxVisibleCharacters を同時利用することで何とか防ぐことが出来ます。

ただし、maxVisibleCharacters を変更しても ForceMeshUpdate を呼び出さないと反映されません。
それによりGCが毎フレーム発生してしまうので、
アニメーションするテキストは、上記リッチテキストを避けるという運用で何とかやり過ごしています。

まとめ

テキストアニメーションさせる方法は2種類あり、
メッシュ情報に手を加えてあげれば色々動かして遊べます!という記事でした。

頂点バッファをいじる手法だったので C# で実装し動かしましたが、
シェーダー(+とあるリッチテキスト)を駆使しても頂点を動かせることができます。
シェーダーの手法に関しては、また別の記事にてまとめたいと思います。