📧

TextMeshProでグラデーションをかける

2023/01/09に公開

はじめに

TextMeshProで横方向にグラデーションをかけると、1文字ごとにグラデーションがかかってしまいます。
それを、テキストごとにかかるようにしました。

環境

Unity 2022.2.0b16

スクリプト

TMPGradation.cs

TextMeshProにグラデーションをかけるものになります。

using System;
using System.Collections.Generic;
using System.Linq;
using Extensions;
using TMPro;
using UnityEngine;

namespace TMPGradation
{
    [ExecuteAlways]
    public class TMPGradation : MonoBehaviour
    {
        private const int GradientNum = 4;

        private TMP_Text textMeshProText;
        private bool isChange;

        private void OnEnable()
        {
            if (textMeshProText == null)
            {
                textMeshProText = GetComponent<TMP_Text>();
            }

            TMPro_EventManager.TEXT_CHANGED_EVENT.Add(TMPChangeEvent);
        }

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

        private void TMPChangeEvent(object obj)
        {
            if ((TMP_Text)obj != textMeshProText)
            {
                return;
            }

            isChange = true;
        }

        private void Update()
        {
            if (!isChange)
            {
                return;
            }

            isChange = false;
            UpdateGradient();
        }

        private void UpdateGradient()
        {
            if (!textMeshProText.enableVertexGradient)
            {
                return;
            }

            var colorMode = GetColorMode();

            if (colorMode is ColorMode.Single or ColorMode.VerticalGradient)
            {
                return;
            }

            // 通常の処理時間の前に、テキストの再生成を強制する関数
            textMeshProText.ForceMeshUpdate();

            var textInfo = textMeshProText.textInfo;
            var characterCount = textInfo.characterCount;

            var gradients = GetVertexGradients(textMeshProText.colorGradient, characterCount, colorMode);

            for (var i = 0; i < characterCount; i++)
            {
                var materialIndex = textInfo.characterInfo[i].materialReferenceIndex;
                var colors = textInfo.meshInfo[materialIndex].colors32;
                var vertexIndex = textInfo.characterInfo[i].vertexIndex;

                if (!textInfo.characterInfo[i].isVisible)
                {
                    continue;
                }

                colors[vertexIndex] = gradients[i].bottomLeft;
                colors[vertexIndex + 1] = gradients[i].topLeft;
                colors[vertexIndex + 2] = gradients[i].bottomRight;
                colors[vertexIndex + 3] = gradients[i].topRight;
            }

            // 変更した頂点の更新
            textMeshProText.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32);
        }

        private ColorMode GetColorMode()
        {
            return textMeshProText.colorGradient switch
            {
                { topLeft: var topLeft, topRight: var topRight, bottomLeft: var bottomLeft, bottomRight: var bottomRight }
                    when topLeft == topRight && topLeft == bottomLeft && topLeft == bottomRight => ColorMode.Single,
                { topLeft: var topLeft, topRight: var topRight, bottomLeft: var bottomLeft, bottomRight: var bottomRight }
                    when topLeft == bottomLeft && topRight == bottomRight => ColorMode.HorizontalGradient,
                { topLeft: var topLeft, topRight: var topRight, bottomLeft: var bottomLeft, bottomRight: var bottomRight }
                    when topLeft == topRight && bottomLeft == bottomRight => ColorMode.VerticalGradient,
                _ => ColorMode.FourCornersGradient
            };
        }

        private VertexGradient[] GetVertexGradients(VertexGradient vertexGradient, int characterCount, ColorMode colorMode)
        {
            var vertexColors = colorMode switch
            {
                ColorMode.HorizontalGradient => GetHorizontalColors(vertexGradient, characterCount),
                ColorMode.FourCornersGradient => GetFourCornersColors(vertexGradient, characterCount),
                _ => throw new ArgumentOutOfRangeException(nameof(colorMode), colorMode, null)
            };

            var gradients = vertexColors.Chunk(GradientNum).Select(x =>
            {
                var colors = x.ToArray();
                return new VertexGradient(colors[0], colors[1], colors[2], colors[3]);
            });

            return gradients.ToArray();
        }

        private IReadOnlyCollection<Color> GetHorizontalColors(VertexGradient vertexGradient, int characterCount)
        {
            var topLeft = vertexGradient.topLeft;
            var topRight = vertexGradient.topRight;
            var topLeftRatio = (topRight - topLeft) / characterCount;
            var colors = new List<Color>();

            for (var i = 0; i < characterCount; i++)
            {
                colors.Add(topLeft + topLeftRatio * i);
                colors.Add(topLeft + topLeftRatio * (i + 1));
                colors.Add(topLeft + topLeftRatio * i);
                colors.Add(topLeft + topLeftRatio * (i + 1));
            }

            return colors;
        }

        private IReadOnlyCollection<Color> GetFourCornersColors(VertexGradient vertexGradient, int characterCount)
        {
            var step = characterCount * GradientNum;

            var topLeft = vertexGradient.topLeft;
            var topRight = vertexGradient.topRight;
            var bottomLeft = vertexGradient.bottomLeft;
            var bottomRight = vertexGradient.bottomRight;

            var topLeftRatio = (topRight - topLeft) / step;
            var bottomLeftRatio = (bottomRight - bottomLeft) / step;

            var colors = new List<Color>();

            for (var i = 0; i < step; i += GradientNum)
            {
                colors.Add(topLeft + topLeftRatio * i);
                colors.Add(bottomLeft + bottomLeftRatio * (i + 1));
                colors.Add(bottomLeft + bottomLeftRatio * (i + 2));
                colors.Add(topLeft + topLeftRatio * (i + 3));
            }

            return colors;
        }
    }
}

IEnumerableExtension.cs

要素をn個ずつにまとめるものになります。

using System.Collections.Generic;
using System.Linq;

namespace Extensions
{
    // ReSharper disable once InconsistentNaming
    public static class IEnumerableExtension
    {
        public static IEnumerable<IEnumerable<T>>Chunk<T>(this IEnumerable<T> enumerable, int size)
        {
            while (enumerable.Any())
            {
                yield return enumerable.Take(size);
                enumerable = enumerable.Skip(size);
            }
        }
    }
}

TextMeshProでグラデーションをかける

通常のTextMeshProの機能でグラデーションをかけてみます。
TextMeshPro - TextColor Modeから、Horizontal Gradientを選択します。

すると、1文字ずつグラデーションがかかってしまいます。
(Verticalは文字全体にかかります)

正攻法としては、シェーダーをDistance Field等に変えて、Textureにアタッチします。
そして、Mappingを変更すると横方向のグラデーションをすることができます。

ただし、

  • マテリアルがグラデーションごとに増えてしまう
  • シェーダーを変えることがコスパ的に厳しい
  • グラデーションテクスチャをつど用意する必要がある

といったデメリットも抱えています。

かといって専用のシェーダーを用意するのも辛いので、今回はc#側で対応しようと思います。

不必要な場合はreturnする

inspector上のColor Gradientにチェックボックスが入っていないときや、
ColorModeがSingleVertical Gradientのときには不要なのでreturnします。

private void UpdateGradient()
{
    if (!textMeshProText.enableVertexGradient)
    {
        return;
    }

    var colorMode = GetColorMode();

    if (colorMode is ColorMode.Single or ColorMode.VerticalGradient)
    {
        return;
    }

    ...
}

private ColorMode GetColorMode()
{
    return textMeshProText.colorGradient switch
    {
        { topLeft: var topLeft, topRight: var topRight, bottomLeft: var bottomLeft, bottomRight: var bottomRight }
            when topLeft == topRight && topLeft == bottomLeft && topLeft == bottomRight => ColorMode.Single,
        { topLeft: var topLeft, topRight: var topRight, bottomLeft: var bottomLeft, bottomRight: var bottomRight }
            when topLeft == bottomLeft && topRight == bottomRight => ColorMode.HorizontalGradient,
        { topLeft: var topLeft, topRight: var topRight, bottomLeft: var bottomLeft, bottomRight: var bottomRight }
            when topLeft == topRight && bottomLeft == bottomRight => ColorMode.VerticalGradient,
        _ => ColorMode.FourCornersGradient
    };
}

グラデーション部分の作成

メッシュを強制的に更新する

ForceMeshUpdate()を呼んでメッシュを強制的に更新します。
そうしないと後述するtextInfo等でエラーが発生します。

private void UpdateGradient()
{
    ...

    // 通常の処理時間の前に、テキストの再生成を強制する関数
    textMeshProText.ForceMeshUpdate();

    ...
}

色をVertexGradientに変換する

VertexGradientはTextMeshPro側で用意されている型になります。

実装
[Serializable]
public struct VertexGradient
{
public Color topLeft;
public Color topRight;
public Color bottomLeft;
public Color bottomRight;

public VertexGradient (Color color)
{
    this.topLeft = color;
    this.topRight = color;
    this.bottomLeft = color;
    this.bottomRight = color;
}

/// <summary>
/// The vertex colors at the corners of the characters.
/// </summary>
/// <param name="color0">Top left color.</param>
/// <param name="color1">Top right color.</param>
/// <param name="color2">Bottom left color.</param>
/// <param name="color3">Bottom right color.</param>
public VertexGradient(Color color0, Color color1, Color color2, Color color3)
{
    this.topLeft = color0;
    this.topRight = color1;
    this.bottomLeft = color2;
    this.bottomRight = color3;
}

各ColorModeに応じてColorを取得し、それをVertexGradient型に変換しています。

private VertexGradient[] GetVertexGradients(VertexGradient vertexGradient, int characterCount, ColorMode colorMode)
{
    var vertexColors = colorMode switch
    {
        ColorMode.HorizontalGradient => GetHorizontalColors(vertexGradient, characterCount),
        ColorMode.FourCornersGradient => GetFourCornersColors(vertexGradient, characterCount),
        _ => throw new ArgumentOutOfRangeException(nameof(colorMode), colorMode, null)
    };

    var gradients = vertexColors.Chunk(GradientNum).Select(x =>
    {
        var colors = x.ToArray();
        return new VertexGradient(colors[0], colors[1], colors[2], colors[3]);
    });

    return gradients.ToArray();
}

HorizontalGradientのときのColor取得

テキストの文字数に合わせて割合を出しています。
そうすることより、その文字あたりの色を取得することができます。
HorizontalGradientのときには、VertexGradientのtopLefttopRightにinspector上で入れた値が入っています。

VertexGradient型は左上、右上、左下、右下の順番に定義されているので
左側はtopLeftに近い色、右側はtopRightに近い色を計算しています。

private IReadOnlyCollection<Color> GetHorizontalColors(VertexGradient vertexGradient, int characterCount)
{
    var topLeft = vertexGradient.topLeft;
    var topRight = vertexGradient.topRight;
    var topLeftRatio = (topRight - topLeft) / characterCount;
    var colors = new List<Color>();

    for (var i = 0; i < characterCount; i++)
    {
        colors.Add(topLeft + topLeftRatio * i);
        colors.Add(topLeft + topLeftRatio * (i + 1));
        colors.Add(topLeft + topLeftRatio * i);
        colors.Add(topLeft + topLeftRatio * (i + 1));
    }

    return colors;
}

FourCornersGradientのときのColor取得

HorizontalGradientのときと同じような考え方で色を計算しています。
今回は上側だけではなく下側もあるので、GradientNum(4)を乗算しています。

private IReadOnlyCollection<Color> GetFourCornersColors(VertexGradient vertexGradient, int characterCount)
{
    var step = characterCount * GradientNum;

    var topLeft = vertexGradient.topLeft;
    var topRight = vertexGradient.topRight;
    var bottomLeft = vertexGradient.bottomLeft;
    var bottomRight = vertexGradient.bottomRight;

    var topLeftRatio = (topRight - topLeft) / step;
    var bottomLeftRatio = (bottomRight - bottomLeft) / step;

    var colors = new List<Color>();

    for (var i = 0; i < step; i += GradientNum)
    {
        colors.Add(topLeft + topLeftRatio * i);
        colors.Add(bottomLeft + bottomLeftRatio * (i + 1));
        colors.Add(bottomLeft + bottomLeftRatio * (i + 2));
        colors.Add(topLeft + topLeftRatio * (i + 3));
    }

    return colors;
}

TextMeshProに反映

取得したVertexGradientを、TextMeshProに反映します。
TextMeshProのcolor32は左下、左上、右下、右上の順に入っているので、そのとおりに代入します。

UpdateVertexDataで色を変更したことをTextMeshPro側に伝えます。

for (var i = 0; i < characterCount; i++)
{
    var materialIndex = textInfo.characterInfo[i].materialReferenceIndex;
    var colors = textInfo.meshInfo[materialIndex].colors32;
    var vertexIndex = textInfo.characterInfo[i].vertexIndex;

    if (!textInfo.characterInfo[i].isVisible)
    {
        continue;
    }

    colors[vertexIndex] = gradients[i].bottomLeft;
    colors[vertexIndex + 1] = gradients[i].topLeft;
    colors[vertexIndex + 2] = gradients[i].bottomRight;
    colors[vertexIndex + 3] = gradients[i].topRight;
}

// 変更した頂点の更新
textMeshProText.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32);

テキストが変更されたときのみ、グラデーションをかける

Update()等で毎フレーム行う必要がないので、変更されたときのみかけるようにしています。
TMPro_EventManager.TEXT_CHANGED_EVENTでTextMeshProの変更時イベントを追加することができるので追加します。
isChangeがTrueになったときのみ、グラデーションをかけるようにしています。

private void OnEnable()
{
    if (textMeshProText == null)
    {
        textMeshProText = GetComponent<TMP_Text>();
    }

    TMPro_EventManager.TEXT_CHANGED_EVENT.Add(TMPChangeEvent);
}

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

private void TMPChangeEvent(object obj)
{
    if ((TMP_Text)obj != textMeshProText)
    {
        return;
    }

    isChange = true;
}

結果

https://youtu.be/V-ccsSRu4es

https://youtu.be/28hhEy1H5Gs

備考

改行には対応していないので、別途対応が必要です。

GitHubで編集を提案

Discussion