はじめに
ちょうど今年からUnityを触る機会が増えたので、折角なので何か書こうと思い
Unityアドベントカレンダー初参加になります。
知識的にもまだ貧しい所も多いので何かありましたら気軽にコメントよろしくお願いいたします
さて、普段何気なく身近にあるものの、ゲーム制作においては地味に対応が面倒くさい「ルビ表示」の話です。
UnityのText表示は、Text(Legacy)
やTextMeshPro
など様々なものがあります。
本記事で紹介する方法は、例としてuGUIのText(Legacy)
を扱いますが、
考え方そのものはこれに限った話ではないので、他へも応用可能です。
ルビ表示どうやるのがいいんだろう?
Unityでルビ表示の仕組みを作るのってどうやるのがいいんだろう?と思い
ひとまずネットで調べてみると以下の方法が見つかりました。
-
TextMeshPro
のリッチタグを使って行う-
<space>
<size>
などで位置やサイズを調整して表示 -
Text(Legacy)
では無理そう - 参考
-
- ルビ毎に
GameObject
を生成して適切な位置に表示
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を設定して、追加します。
(※空白文字だと頂点がないので注意)
今回はテストコードなので、単にベースの文字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);
}
わりとそれっぽくなっています。
ということで、方針的にはできそうな感じなので、あとはもう少しちゃんとルビの表示位置の計算だとか仕組みの整理をすれば上手くできそうな予感!!
TextMeshPro
の場合
IMeshModifier
は使用できなかったと思うので、
TMP_TextInfo.meshInfo
を更新してUpdateGeometry
に渡すみたいな事になります。
(もしかすると他の方法あるかも?)
参考:【Unity】TextMesh Proをアニメーションさせる~プログラマ編~
また、フォントの扱いも変わってくるので上記のやり方ではうまくできないと思います。
仕組みの整理をする
参考程度に、こんな感じのを作ってみたというのを貼っておきます。
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
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
}
まとめ
- Textのメッシュに頂点を手動で追加することでルビ表示の仕組みを作ることができた。
-
Text(Legacy)
-
IMeshModifier
で頂点追加 - ルビに使用する文字は
Font.RequestCharactersInTexture
でテクスチャに書き込む - ルビに使用する文字の情報を
Font.GetCharacterInfo
で取得 - いい感じに頂点の座標を計算して、uvを設定
-
-
TextMeshPro
- 同じ方向性で対応は可能だが実際のコードは今回紹介したものとは、かなり変わる(今回は省略)
- 頂点操作周り
- フォントテクスチャ周り
- 同じ方向性で対応は可能だが実際のコードは今回紹介したものとは、かなり変わる(今回は省略)
-
- その他のルビ表示方法
-
TextMeshPro
のリッチタグで対応 - ルビ毎に
GameObject
を生成 - 他にも良いやり方があれば教えてください
-