11
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

UnityAdvent Calendar 2023

Day 3

UnityTextで頂点追加でルビ表示してみた

Last updated at Posted at 2023-12-02

はじめに

ちょうど今年からUnityを触る機会が増えたので、折角なので何か書こうと思い
Unityアドベントカレンダー初参加になります。
知識的にもまだ貧しい所も多いので何かありましたら気軽にコメントよろしくお願いいたします:pray:


さて、普段何気なく身近にあるものの、ゲーム制作においては地味に対応が面倒くさい「ルビ表示」の話です。

UnityのText表示は、Text(Legacy)TextMeshProなど様々なものがあります。
本記事で紹介する方法は、例としてuGUIのText(Legacy)を扱いますが、
考え方そのものはこれに限った話ではないので、他へも応用可能です。

ルビ表示どうやるのがいいんだろう?

Unityでルビ表示の仕組みを作るのってどうやるのがいいんだろう?と思い
ひとまずネットで調べてみると以下の方法が見つかりました。

TextMeshProのみの対応で良ければ前者の方法で良いのかもと思いつつ、最終的なtextとしてはルビをメタ情報として入れていないので妙な気持ち悪さを感じる所はありました。

このやり方だと、素のTextMeshProのコンポーネントをそのまま使う場合はGetParsedText()にルビ文字も含んでしまうので、タグ抜きの文字列を取得したい際に注意がいる。
ラッパークラスに継承してoverrideすれば、その辺りの解決はできる気がする。

対して、後者の方法はルビ毎にGameObjectの生成をする事自体がパフォーマンス面で少し無駄が多い気がして気になっていました。

ということで、他に何かやり方はないだろうかと考えていました。

頂点追加すればよいのでは?

普段文字を表示したい際Textコンポーネントのtextプロパティに文字列を設定して表示しています。

    [SerializeField] Text text;

    void SetText(string str)
    {
        // textプロパティに設定
        text.text = str;
    }

しかし、必ずしもtextに表示したい文字列を設定する必要はあるのでしょうか?

メッシュに頂点を追加して、適切な座標とフォントテクスチャのUVを設定さえできれば
好きな座標に好きな文字を描画できるのではないでしょうか?

試してみる

例えばこんな感じのコンポーネントを書いてみました。

public class TestMeshModifier : MonoBehaviour, IMeshModifier
{
    [SerializeField] Text text;

    [SerializeField] string rubyText = "るびのてすと";
    void Awake()
    {
        // フォントテクスチャ更新
        text.font.RequestCharactersInTexture(rubyText, text.fontSize / 2);
    }
    public void ModifyMesh(Mesh mesh)
    {
        throw new NotImplementedException();
    }

    public void ModifyMesh(VertexHelper verts)
    {
        var stream = ListPool<UIVertex>.Get();
        verts.GetUIVertexStream(stream);

        Modify(ref stream);

        verts.Clear();
        verts.AddUIVertexTriangleStream(stream);

        ListPool<UIVertex>.Release(stream);
    }
    void Modify(ref List<UIVertex> stream)
    {
        int length = stream.Count / 6;
        for (int charIndex = 0; charIndex < length; ++charIndex)
        {
            if (charIndex >= rubyText.Length)
            {
                break;
            }
            // フォントテクスチャから指定の文字のUVを取得したい
            if (!text.font.GetCharacterInfo(rubyText[charIndex], out var characterInfo, text.fontSize / 2))
            {
                continue;
            }
            // ↓本当は空白は頂点が生成されずインデックスがずれるのでこれはダメだが、テストコードなのでヨシ
            if (!text.font.GetCharacterInfo(text.text[charIndex], out var characterInfoBase, text.fontSize))
            {
                continue;
            }
            var center = (stream[charIndex * 6 + 0].position + stream[charIndex * 6 + 2].position) / 2;
            Vector3 pivot = center;
            pivot.y = stream[charIndex * 6 + 2].position.y - characterInfoBase.minY + text.fontSize;

            for (int i = 0; i < 6; ++i)
            {
                UIVertex vert = stream[charIndex * 6 + i];
                Vector3 position = vert.position;
                switch (i)
                {
                    case 0:
                    case 5:
                        // 左上
                        position = pivot + new Vector3(-characterInfo.advance / 2.0f, characterInfo.maxY, 0);
                        vert.uv0 = characterInfo.uvTopLeft; // uv設定
                        break;
                    case 1:
                        // 右上
                        position = pivot + new Vector3(characterInfo.advance / 2.0f, characterInfo.maxY, 0);
                        vert.uv0 = characterInfo.uvTopRight;
                        break;
                    case 4:
                        // 左下
                        position = pivot + new Vector3(-characterInfo.advance / 2.0f, characterInfo.minY, 0);
                        vert.uv0 = characterInfo.uvBottomLeft;
                        break;
                    case 2:
                    case 3:
                        // 右下
                        position = pivot + new Vector3(characterInfo.advance / 2.0f, characterInfo.minY, 0);
                        vert.uv0 = characterInfo.uvBottomRight;
                        break;
                }
                vert.position = position;

                // 頂点を追加
                stream.Add(vert);
            }
        }
    }
}

Text(Legacy)なのでIMeshModifierで頂点情報にアクセスして更新しています。

public void ModifyMesh(VertexHelper verts)
{
    var stream = ListPool<UIVertex>.Get();
    verts.GetUIVertexStream(stream);

    Modify(ref stream);

    verts.Clear();
    verts.AddUIVertexTriangleStream(stream);

    ListPool<UIVertex>.Release(stream);
}

ルビで表示する文字はFont.RequestCharactersInTextureでフォントテクスチャに事前に書き込む必要がります。

// フォントテクスチャ更新
text.font.RequestCharactersInTexture(rubyText, text.fontSize / 2);

書き込んだ文字の情報はFont.GetCharacterInfoで取得できます。
CharacterInfoからuvが取得できたり、大きさがとれたりします。

text.font.GetCharacterInfo(rubyText[charIndex], out CharacterInfo characterInfo, text.fontSize / 2);

1文字につき6頂点あり、各頂点適切な座標やuvを設定して、追加します。
(※空白文字だと頂点がないので注意)
image.png

今回はテストコードなので、単にベースの文字1文字ずつの上に別のルビテキストを1文字ずつ表示していますが、実際にはもっといろいろ計算がいると思います。

var center = (stream[charIndex * 6 + 0].position + stream[charIndex * 6 + 2].position) / 2;
Vector3 pivot = center;
pivot.y = stream[charIndex * 6 + 2].position.y - characterInfoBase.minY + text.fontSize;

for (int i = 0; i < 6; ++i)
{
    UIVertex vert = stream[charIndex * 6 + i];
    Vector3 position = vert.position;
    switch (i)
    {
        case 0:
        case 5:
            // 左上
            position = pivot + new Vector3(-characterInfo.advance / 2.0f, characterInfo.maxY, 0);
            vert.uv0 = characterInfo.uvTopLeft; // uv設定
            break;
        case 1:
            // 右上
            position = pivot + new Vector3(characterInfo.advance / 2.0f, characterInfo.maxY, 0);
            vert.uv0 = characterInfo.uvTopRight;
            break;
        case 4:
            // 左下
            position = pivot + new Vector3(-characterInfo.advance / 2.0f, characterInfo.minY, 0);
            vert.uv0 = characterInfo.uvBottomLeft;
            break;
        case 2:
        case 3:
            // 右下
            position = pivot + new Vector3(characterInfo.advance / 2.0f, characterInfo.minY, 0);
            vert.uv0 = characterInfo.uvBottomRight;
            break;
    }
    vert.position = position;

    // 頂点を追加
    stream.Add(vert);
}

image.png

わりとそれっぽくなっています。

ということで、方針的にはできそうな感じなので、あとはもう少しちゃんとルビの表示位置の計算だとか仕組みの整理をすれば上手くできそうな予感!!

TextMeshProの場合
IMeshModifierは使用できなかったと思うので、
TMP_TextInfo.meshInfoを更新してUpdateGeometryに渡すみたいな事になります。
(もしかすると他の方法あるかも?)

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

また、フォントの扱いも変わってくるので上記のやり方ではうまくできないと思います。

参考:Unityにおける文字の描画と比較検証

仕組みの整理をする

参考程度に、こんな感じのを作ってみたというのを貼っておきます。

RubyString.cs
RubyString.cs
using System.Collections.Generic;
using System.Text;

/// <summary>
/// ルビ文字列
/// 
/// <ruby=るび>るび</ruby>のような文字列を扱う
/// </summary>
public class RubyString
{
    /// <summary>
    /// パース
    /// </summary>
    /// <param name="str"></param>
    /// <returns></returns>
    public static RubyString Parse(string str)
    {
        if (str is null)
        {
            return null;
        }
        int startIndex = 0;
        int endIndex = 0;

        List<Pair> pairs = new List<Pair>(str.Length);
        while ((startIndex = str.IndexOf("<ruby=", endIndex)) != -1)
        {
            if (endIndex != startIndex)
            {
                pairs.Add(new Pair(str.Substring(endIndex, startIndex - endIndex), null));
            }
            endIndex = str.IndexOf("</ruby>", startIndex);
            if (endIndex == -1)
            {
                break;
            }

            startIndex += 6; // "<ruby="
            string rubyText = str.Substring(startIndex, endIndex - startIndex);
            string[] parts = rubyText.Split('>');
            string baseText = parts.Length > 1 ? parts[1] : null;
            string ruby = parts.Length > 0 ? parts[0] : null;
            if (ruby != null && ruby.Length >= 2 && ruby[0] == '"' && ruby[ruby.Length - 1] == '"')
            {
                // ダブルクォーテーションでで囲われているならそれを取り除く
                ruby = ruby[1..^1];
            }

            pairs.Add(new Pair(baseText, ruby));
            endIndex += 7; // "</ruby>"
            if (endIndex >= str.Length)
            {
                break;
            }
        }
        if (startIndex == -1)
        {
            if (endIndex < str.Length)
            {
                pairs.Add(new Pair(str.Substring(endIndex), null));
            }
        }
        else if (endIndex == -1)
        {
            pairs.Add(new Pair(str.Substring(startIndex), null));
        }
        return new RubyString(str, pairs);
    }
    private RubyString(string str, List<Pair> data)
    {
        this.data = data;
        var builder = new StringBuilder();
        for (int i = 0; i < data.Count; ++i)
        {
            builder.Append(data[i].Str);
        }
        this.rawString = str;
        this.baseString = builder.ToString();
    }

    /// <summary>
    /// 生の文字列
    /// </summary>
    public string RawString => rawString;

    /// <summary>
    /// ルビを含めない文字列
    /// </summary>
    public string BaseString => baseString;

    /// <summary>
    /// ルビ文字列のセットデータを取得
    /// </summary>
    public IReadOnlyList<Pair> Data => data;

    public override string ToString()
    {
        return rawString;
    }

    public struct Pair
    {
        public Pair(string str, string ruby)
        {
            Str = str;
            Ruby = ruby;
        }
        public string Str;
        public string Ruby;
    }

    private string rawString;
    private string baseString;
    private List<Pair> data;
}
RubyText.cs
RubyText.cs
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using UnityEngine;
using UnityEngine.Pool;
using UnityEngine.UI;
#if UNITY_EDITOR
using UnityEditor;
#endif

public class RubyText : Text, IMeshModifier
{
    [TextArea(3, 10)][SerializeField] protected string m_RubyText = string.Empty;
    [SerializeField] protected float m_RubyFontScale = 0.5f;

    RubyString strCached;

    public override string text
    {
        set
        {
            m_RubyText = value;
            if (m_RubyText is null)
            {
                m_RubyText = "";
            }
            strCached = RubyString.Parse(value);
            base.text = strCached?.BaseString ?? null;
            RequetRubyTexture();
        }
        get => base.text;
    }
    public RubyString rubyText
    {
        set
        {
            m_RubyText = value?.RawString;
            if (m_RubyText is null)
            {
                m_RubyText = "";
            }
            strCached = value;
            base.text = strCached?.BaseString ?? null;
            RequetRubyTexture();
        }
        get => strCached;
    }
    void RequetRubyTexture()
    {
        int baseFontSize = fontSize;
        int rubyFontSize = Mathf.RoundToInt(baseFontSize * m_RubyFontScale);
        for (int di = 0; di < strCached.Data.Count; ++di)
        {
            RubyString.Pair sr = strCached.Data[di];
            if (sr.Ruby is not null)
            {
                font.RequestCharactersInTexture(sr.Ruby, rubyFontSize);
            }
            font.RequestCharactersInTexture(sr.Str, baseFontSize);
        }
    }
    public void ModifyMesh(Mesh mesh)
    {
        throw new NotImplementedException();    
    }

    public void ModifyMesh(VertexHelper verts)
    {
        if (strCached == null)
        {
            return;
        }
        var stream = ListPool<UIVertex>.Get();
        verts.GetUIVertexStream(stream);

        Modify(ref stream);

        verts.Clear();
        verts.AddUIVertexTriangleStream(stream);

        ListPool<UIVertex>.Release(stream);
    }

    void Modify(ref List<UIVertex> stream)
    {
        string targetString = PureString(strCached.BaseString);

        int textLength = targetString.Length;
        int[] baseIndexToMeshIndex = new int[textLength];
        {
            int charMeshIndex = 0;
            for (int charIndex = 0; charIndex < textLength; ++charIndex)
            {
                char c = targetString[charIndex];
                if (c != ' ' && char.IsWhiteSpace(c))
                {
                    continue;
                }
                baseIndexToMeshIndex[charIndex] = charMeshIndex;
                ++charMeshIndex;
            }
        }
        int[] startIndexMap = new int[strCached.Data.Count];
        string[] pureStrMap = new string[strCached.Data.Count];
        {
            int charIndex = 0;
            for (int i = 0; i < strCached.Data.Count; ++i)
            {
                startIndexMap[i] = charIndex;
                string str = PureString(strCached.Data[i].Str);
                pureStrMap[i] = str;
                charIndex += str.Length;
            }
        }

        // ルビの頂点追加
        int baseFontSize = fontSize;
        int rubyFontSize = Mathf.RoundToInt(baseFontSize * m_RubyFontScale);
        for (int di = 0; di < strCached.Data.Count; ++di)
        {
            RubyString.Pair sr = strCached.Data[di];
            if (sr.Ruby is null)
            {
                continue;
            }
            string pureStr = pureStrMap[di];
            float baseRegionWidth = 0;
            float[] baseOffsMap = new float[pureStr.Length];
            float[] baseOffsMapLeft = new float[pureStr.Length];
            for (int i = 0; i < pureStr.Length; ++i)
            {
                if (font.GetCharacterInfo(pureStr[i], out CharacterInfo characterInfoBase, baseFontSize))
                {
                    float advance = characterInfoBase.advance;
                    baseOffsMapLeft[i] = baseRegionWidth;
                    baseRegionWidth += advance;
                    baseOffsMap[i] = baseRegionWidth - advance / 2.0f;
                }
            }
            float rubyRegionWidth = rubyFontSize * sr.Ruby.Length;
            float regionWidthDiff = -(rubyRegionWidth - baseRegionWidth) / 2.0f;
            for (int si = 0; si < sr.Ruby.Length; ++si)
            {
                if (char.IsWhiteSpace(sr.Ruby[si]))
                {
                    continue;
                }
                int baseIndex = 0;
                {
                    float threshold = rubyFontSize * si + rubyFontSize / 2.0f + regionWidthDiff;
                    for (int i = pureStr.Length - 1; i >= 0; --i)
                    {
                        if (i == 0)
                        {
                            baseIndex = startIndexMap[di] + 0;
                            break;
                        }
                        char c = pureStr[i];
                        if (c != ' ' && char.IsWhiteSpace(c))
                        {
                            continue;
                        }
                        if (baseOffsMapLeft[i] < threshold)
                        {
                            baseIndex = startIndexMap[di] + i;
                            break;
                        }
                    }
                }
                if (baseIndex < textLength)
                {
                    char bc = targetString[baseIndex];
                    if (bc != ' ' && char.IsWhiteSpace(bc))
                    {
                        Debug.LogError("空白のみの文字上にルビは表示できません");
                        continue;
                    }
                    int charMeshIndex = baseIndexToMeshIndex[baseIndex];
                    if (font.GetCharacterInfo(sr.Ruby[si], out CharacterInfo characterInfo, rubyFontSize))
                    {
                        float offsetXPos = (rubyFontSize * si + rubyFontSize / 2.0f) - (baseOffsMap[baseIndex - startIndexMap[di]]) + regionWidthDiff;
                        Vector3 pivot = (stream[charMeshIndex * 6 + 0].position + stream[charMeshIndex * 6 + 2].position) / 2.0f;
                        if (font.GetCharacterInfo(bc, out CharacterInfo characterInfoBase, baseFontSize))
                        {
                            pivot.y = stream[charMeshIndex * 6 + 2].position.y - characterInfoBase.minY;
                        }
                        pivot += new Vector3(offsetXPos, baseFontSize, 0);
                        for (int i = 0; i < 6; i++)
                        {
                            UIVertex vert = stream[charMeshIndex * 6 + i];
                            Vector3 position = vert.position;
                            switch (i)
                            {
                                case 0:
                                case 5:
                                    vert.uv0 = characterInfo.uvTopLeft;
                                    position = pivot + new Vector3(-characterInfo.advance / 2.0f, characterInfo.maxY, 0);
                                    break;
                                case 1:
                                    vert.uv0 = characterInfo.uvTopRight;
                                    position = pivot + new Vector3(characterInfo.advance / 2.0f, characterInfo.maxY, 0);
                                    break;
                                case 4:
                                    vert.uv0 = characterInfo.uvBottomLeft;
                                    position = pivot + new Vector3(-characterInfo.advance / 2.0f, characterInfo.minY, 0);
                                    break;
                                case 2:
                                case 3:
                                    vert.uv0 = characterInfo.uvBottomRight;
                                    position = pivot + new Vector3(characterInfo.advance / 2.0f, characterInfo.minY, 0);
                                    break;
                            }
                            vert.position = position;
                            stream.Add(vert);
                        }
                    }
                }
            }

        }
    }

#if UNITY_EDITOR
    protected override void OnValidate()
    {
        this.text = m_RubyText;

        base.OnValidate();
    }

#endif
    string PureString(string text)
    {
        if (supportRichText)
        {
            return Regex.Replace(text, "<color=.*?>|</color>|<b>|</b>|<i>|</i>|<size=.*?>|</size>|<material=.*?>|</material>|<quad.*?>", String.Empty);
        }
        return text;
    }
#if UNITY_EDITOR
    [CustomEditor(typeof(RubyText))]
    public class CustomTextEditor : UnityEditor.UI.TextEditor
    {
        SerializedProperty m_RubyTextData;
        SerializedProperty m_FontData;
        SerializedProperty m_RubyFontScaleData;

        protected override void OnEnable()
        {
            base.OnEnable();
            m_RubyTextData = serializedObject.FindProperty("m_RubyText");
            m_FontData = serializedObject.FindProperty("m_FontData");
            m_RubyFontScaleData = serializedObject.FindProperty("m_RubyFontScale");
        }

        public override void OnInspectorGUI()
        {
            var component = (RubyText)target;
            serializedObject.Update();

            EditorGUILayout.PropertyField(m_RubyTextData);
            EditorGUILayout.PropertyField(m_RubyFontScaleData);
            EditorGUILayout.PropertyField(m_FontData);

            AppearanceControlsGUI();
            RaycastControlsGUI();
            MaskableControlsGUI();
            serializedObject.ApplyModifiedProperties();
        }
    }
#endif
}

image.png

image.png

まとめ

  • Textのメッシュに頂点を手動で追加することでルビ表示の仕組みを作ることができた。
    • Text(Legacy)
      • IMeshModifierで頂点追加
      • ルビに使用する文字はFont.RequestCharactersInTextureでテクスチャに書き込む
      • ルビに使用する文字の情報をFont.GetCharacterInfoで取得
      • いい感じに頂点の座標を計算して、uvを設定
    • TextMeshPro
      • 同じ方向性で対応は可能だが実際のコードは今回紹介したものとは、かなり変わる(今回は省略)
        • 頂点操作周り
        • フォントテクスチャ周り
  • その他のルビ表示方法
    • TextMeshProのリッチタグで対応
    • ルビ毎にGameObjectを生成
    • 他にも良いやり方があれば教えてください:bow_tone1:
11
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?