11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

UnityAdvent Calendar 2024

Day 8

TextMeshProで複数フォントを1つのアトラスに無理やり書き込む方法

Last updated at Posted at 2024-12-07

はじめに

TextMeshProを使って、文字毎にフォントを変えたい場合にFallbackを使うことで実現が可能ですが、
この場合SubMeshに分かれる問題がありました。

まずFallbackを使った通常の方法をおさらいしつつ
その後、無理やり同じアトラスに複数のフォントで予め書き込んでおく事でFallbackを使うことなく実現する方法を紹介します。

Fallbackによる複数フォントの扱い(通常)

TextMeshProで文字毎にフォントを変えたい場合は、通常Fallbackの仕組みを使うと実現が可能です。

フォントアセット作成

Fallbackに登録

これで、文字毎にフォントを変えることが可能になります。

問題点

通常はこのやり方で問題ないのですが、SubMeshに分かれてしまうという制限があり
2PassShaderなどが絡むと都合が悪いときがあります。

しかし、理論的には同じアトラスに複数のフォントで予め書き込んでおくことができれば、Fallbackは使わず、SubMeshも作られずに文字毎にフォントを変えることも可能だと思い
これを無理やり実現する方法を考えたので、今回はそれを紹介します。

無理やり複数フォントで書き込む

TextMeshProには正規で複数フォントでアトラスを作成する仕組みはないので
リフレクションなどを使って無理やり実現します。


特定の文字をアトラスに書き込む処理自体は、TryAddCharacters関数が用意されています。
それを呼ぶ前に、フォントを動的に差し替えてあげることで、無理やり他のフォントで書き込みます。
なお、書き込む前はDynamicにしておかないと書き込めないので注意です。

// フォントを差し替え
SetSourceFontFile(fontAsset, otherFont);
// Dynamicにしないと書き込みできない
fontAsset.atlasPopulationMode = AtlasPopulationMode.Dynamic;

// アトラステクスチャに該当フォントで書き込む
uint[] unicodes = ToUnicodes(text);
fontAsset.TryAddCharacters(unicodes, out uint[] missing);
// フォント無理やり変更
private static void SetSourceFontFile(TMP_FontAsset fontAsset, Font newFont)
{
    // リフレクションでフォントを差し替える
    FieldInfo sourceFontFile = typeof(TMP_FontAsset).GetField("m_SourceFontFile_EditorRef", BindingFlags.NonPublic | BindingFlags.Instance);

    sourceFontFile.SetValue(fontAsset, newFont);
}

ツール化する

実際にアセットを作成する作業をするうえでツール化は大事です。
ScriptableObjectEditorWindowを活用してツール実装します。

細かい解説は省きますが、コードを貼っておきます。

ScriptableObjectで設定を保存しておく

FontAtlasPreset.cs
using System;
using TMPro;
using TMPro.EditorUtilities;
using UnityEditor;
using UnityEngine;

/// <summary>
/// FontAtlasEditorのプリセット
/// </summary>
[CreateAssetMenu(fileName = "Data", menuName = "TextMeshPro/FontAtlasPreset")]
public class FontAtlasPreset : ScriptableObject
{
    [Serializable]
    public class WriteInfo
    {
        /// <summary> フォント </summary>
        public Font Font;

        /// <summary> 書き込む文字 </summary>
        public string Text;
    }

    /// <summary> 対象アセット </summary>
    public TMP_FontAsset To;

    /// <summary> 書き込み情報 </summary>
    public WriteInfo[] FromList = new WriteInfo[1];
}

[CustomEditor(typeof(FontAtlasPreset))]
public class FontAtlasEditorPresetEditor : Editor
{
    SerializedProperty _to;
    SerializedProperty _fromList;
    private void OnEnable()
    {
        _to = serializedObject.FindProperty(nameof(FontAtlasPreset.To));
        _fromList = serializedObject.FindProperty(nameof(FontAtlasPreset.FromList));
    }
    public override void OnInspectorGUI()
    {
        serializedObject.Update();

        // 対象のFontAsset選択
        _to.objectReferenceValue = EditorGUILayout.ObjectField("Target Font", _to.objectReferenceValue, typeof(TMP_FontAsset), false);
        var presetTo = _to.objectReferenceValue as TMP_FontAsset;
        if (presetTo != null && presetTo.atlasPopulationMode == AtlasPopulationMode.Dynamic)
        {
            EditorGUILayout.HelpBox("Dynamic Fontは編集できません。", MessageType.Error);
        }

        // 書き込みフォント
        EditorGUILayout.LabelField("From Font");
        using (new EditorGUI.IndentLevelScope())
        {
            for (int i = 0; i < _fromList.arraySize; i++)
            {
                SerializedProperty element = _fromList.GetArrayElementAtIndex(i);
                using (new EditorGUILayout.HorizontalScope())
                {
                    var font = element.FindPropertyRelative(nameof(FontAtlasPreset.WriteInfo.Font));
                    font.objectReferenceValue = (Font)EditorGUILayout.ObjectField("Font", font.objectReferenceValue, typeof(Font), false);
                    if (GUILayout.Button("Remove", GUILayout.Width(70)))
                    {
                        _fromList.DeleteArrayElementAtIndex(i);
                    }
                }
                if (i >= _fromList.arraySize)
                {
                    break;
                }
                var text = element.FindPropertyRelative(nameof(FontAtlasPreset.WriteInfo.Text));
                text.stringValue = EditorGUILayout.TextArea(text.stringValue, TMP_UIStyleManager.textAreaBoxWindow, GUILayout.Height(4 * 15), GUILayout.ExpandWidth(true));
            }
        }
        if (GUILayout.Button("Add"))
        {
            _fromList.InsertArrayElementAtIndex(_fromList.arraySize);
        }

        serializedObject.ApplyModifiedProperties();
    }
}

EditorWindowの実装

FontAtlasEditorWindow.cs
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using TMPro;
using TMPro.EditorUtilities;
using UnityEditor;
using UnityEngine;

using WriteInfo = FontAtlasPreset.WriteInfo;

/// <summary>
/// FontAsset AtlasTextureの編集
/// </summary>
public class FontAtlasEditorWindow : EditorWindow
{
    /// <summary> プリセット </summary>
    private FontAtlasPreset _preset;
    /// <summary> 対象アセット </summary>
    private TMP_FontAsset _to;
    /// <summary> 書き込み情報 </summary>
    private WriteInfo[] _fromList = new WriteInfo[1];

    /// <summary> テクスチャをクリアするか</summary>
    private bool _clearTexture = false;

    [MenuItem("Window/TextMeshPro/FontAtlasEditor")]
    public static void ShowWindow()
    {
        GetWindow<FontAtlasEditorWindow>("FontAtlasEditor");
    }

    void OnGUI()
    {
        GUILayout.Label("Font Asset Editor", EditorStyles.boldLabel);

        // プリセット対応
        var prevPreset = _preset;
        _preset = (FontAtlasPreset)EditorGUILayout.ObjectField("Preset", _preset, typeof(FontAtlasPreset), false);
        if (prevPreset != _preset)
        {
            if (_preset != null)
            {
                _to = _preset.To;
                _fromList = _preset.FromList;
            }
            else if (prevPreset != null)
            {
                _fromList = new WriteInfo[prevPreset.FromList.Length];
                for (int i = 0; i < prevPreset.FromList.Length; ++i)
                {
                    _fromList[i] = new WriteInfo()
                    {
                        Font = prevPreset.FromList[i].Font,
                        Text = prevPreset.FromList[i].Text,
                    };
                }
            }
        }
        bool enableOld = GUI.enabled;
        GUI.enabled = _preset == null;
        _to = (TMP_FontAsset)EditorGUILayout.ObjectField("Target Font", _to, typeof(TMP_FontAsset), false);
        if (_to != null && _to.atlasPopulationMode == AtlasPopulationMode.Dynamic)
        {
            EditorGUILayout.HelpBox("Dynamic Fontは編集できません。", MessageType.Error);
        }
        EditorGUILayout.LabelField("From Font");
        EditorGUI.indentLevel++;
        for (int i = 0; i < _fromList.Length; i++)
        {
            _fromList[i] = _fromList[i] ?? new WriteInfo();

            EditorGUILayout.BeginHorizontal();
            _fromList[i].Font = (Font)EditorGUILayout.ObjectField("Font", _fromList[i].Font, typeof(Font), false);
            if (GUILayout.Button("Remove", GUILayout.Width(70)))
            {
                RemoveAt(ref _fromList, i);
            }
            EditorGUILayout.EndHorizontal();
            if (i >= _fromList.Length)
            {
                break;
            }
            _fromList[i].Text = EditorGUILayout.TextArea(_fromList[i].Text, TMP_UIStyleManager.textAreaBoxWindow, GUILayout.Height(4 * 15), GUILayout.ExpandWidth(true));
        }
        EditorGUI.indentLevel--;
        if (GUILayout.Button("Add"))
        {
            Array.Resize(ref _fromList, _fromList.Length + 1);
        }
        GUI.enabled = enableOld;
        GUILayout.Space(10);
        EditorGUILayout.HelpBox("既に書き込まれている文字は失敗します。", MessageType.Warning);
        _clearTexture = EditorGUILayout.Toggle("Clear Atlas Texture", _clearTexture);
        bool enableOld2 = GUI.enabled;
        GUI.enabled = EnableWrite();
        if (GUILayout.Button("Write"))
        {
            if (Write(_to, _fromList))
            {
                UnityEngine.Debug.Log("Font Edit Completed");
            }
            else
            {
                UnityEngine.Debug.LogError("Font Edit Failed");
            }
        }
        GUI.enabled = enableOld2;
    }
    private static void RemoveAt<T>(ref T[] array, int index)
    {
        for (int i = index; i < array.Length - 1; i++)
        {
            array[i] = array[i + 1];
        }
        System.Array.Resize(ref array, array.Length - 1);
    }
    bool EnableWrite()
    {
        if (_to == null)
        {
            return false;
        }
        if (_to.atlasPopulationMode == AtlasPopulationMode.Dynamic)
        {
            return false;
        }
        foreach (WriteInfo info in _fromList)
        {
            if (info == null)
            {
                return false;
            }
            if (info.Font == null)
            {
                return false;
            }
            if (string.IsNullOrEmpty(info.Text))
            {
                return false;
            }
        }
        return true;
    }

    // 書き込み
    private bool Write(TMP_FontAsset to, IReadOnlyList<WriteInfo> fromList)
    {
        var originFont = to.sourceFontFile;
        bool result = true;
        try
        {
            if (_clearTexture)
            {
                // テクスチャクリア
                to.atlasPopulationMode = AtlasPopulationMode.Dynamic;
                to.ClearFontAssetData();
            }
            foreach (var from in fromList)
            {
                // フォントを差し替え
                SetSourceFontFile(to, from.Font);
                // Dynamicにしないと書き込みできない
                to.atlasPopulationMode = AtlasPopulationMode.Dynamic;

                // アトラステクスチャに該当フォントで書き込む
                uint[] unicodes = ToUnicodes(from.Text);
                result = to.TryAddCharacters(unicodes, out uint[] missing);

                if (missing != null && missing.Length > 0)
                {
                    UnityEngine.Debug.LogWarning($"Missing or Already Exists: {ToCharacters(missing)}");
                }
            }
        }
        finally
        {
            to.atlasPopulationMode = AtlasPopulationMode.Static;
            SetSourceFontFile(to, originFont);
        }

        // フォントアセットを更新
        AssetDatabase.SaveAssets();
        return result;
    }

    private static uint[] ToUnicodes(string characters)
    {
        List<uint> char_List = new List<uint>(characters.Length);

        for (int i = 0; i < characters.Length; i++)
        {
            uint unicode = characters[i];

            // Handle surrogate pairs
            if (i < characters.Length - 1 && char.IsHighSurrogate((char)unicode) && char.IsLowSurrogate(characters[i + 1]))
            {
                unicode = (uint)char.ConvertToUtf32(characters[i], characters[i + 1]);
                i += 1;
            }

            // Check to make sure we don't include duplicates
            if (char_List.FindIndex(item => item == unicode) == -1)
                char_List.Add(unicode);
        }

        return char_List.ToArray();
    }
    private static string ToCharacters(uint[] unicodes)
    {
        var builder = new StringBuilder();
        foreach (var unicode in unicodes)
        {
            builder.Append(char.ConvertFromUtf32((int)unicode));
        }
        return builder.ToString();
    }

    // フォント無理やり変更
    private static void SetSourceFontFile(TMP_FontAsset fontAsset, Font newFont)
    {
        // リフレクションでフォントを差し替える
        FieldInfo sourceFontFile = typeof(TMP_FontAsset).GetField("m_SourceFontFile_EditorRef", BindingFlags.NonPublic | BindingFlags.Instance);

        sourceFontFile.SetValue(fontAsset, newFont);
    }
}

結果

複数のフォントを同じアトラスに書き込めた

SubMeshは作られない!
image.png

まとめ

  • 文字毎にフォントを変えたい場合 Fallbackを登録することで実現できる
    • しかし、SubMeshに分かれるという問題がある
      • 2Pass Shaderなどで都合が悪いことがある
  • アトラスにあらかじめ複数のフォントで書き込んで置ければ、Fallbackなしで実現できるはず
    • リフレクションを使って書き込む直前にフォントを変えつつ TryAddCharactersを実行
  • 実作業で運用しやすいように、ScriptableObjectEditorWindowを活用してツール化
11
6
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
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?