8.4K Views
September 26, 19
スライド概要
2019/9/25-6に開催されたUnite Tokyo 2019の講演スライドです。
今福 文章(KLab株式会社)
こんな人におすすめ
・テキストレンダリングの仕組みや拡張について知りたいプログラマ
・フォントやレイアウトエンジンについて興味があるプログラマ
・アラビア語展開を考えているデベロッパー
受講者が得られる知見
・アラビア語に関する知識
・テキストレンダリングに関する知識、技術
Unityのイベント資料はこちらから:
https://www.slideshare.net/UnityTechnologiesJapan/clipboards
リアルタイム3Dコンテンツを制作・運用するための世界的にリードするプラットフォームである「Unity」の日本国内における販売、サポート、コミュニティ活動、研究開発、教育支援を行っています。ゲーム開発者からアーティスト、建築家、自動車デザイナー、映画製作者など、さまざまなクリエイターがUnityを使い想像力を発揮しています。
ゼロから始める アラビア語レンダリング KLab株式会社 今福 文章 (Fumiaki Imafuku)
今福 文章 (Fumiaki Imafuku) KLab株式会社 技術統括部 UIライブラリ、スクリプトエンジン、 開発支援ツールなどを開発。 時々ゲーム開発。
アラビア語は……
読めません! 書けません! 話せません!
経緯 – キャプテン翼 アラビア語対応
文字が表示できるなんて 当たり前じゃないか
文字が表示できるなんて 当たり前じゃないか
レイアウトエンジンが 対応していない文字は 正しく表示できない
正 誤
お品書き — アラビア語について — アラビア語を表示する際の課題 — アラビア語を表示する方法
アラビア語について
2.3 27 3
2.3 27 3 使用人口(億人) 公用語としている国の数 使用されている 国や地域の多さ 引用:Wikipediaのアラビア語の頁より
アラビア語 — 西アジア、北アフリカの国や地域で 多く話されている — 国連の公用語にもなっている
アラビア文字 — 基本的に右から左への横書き — アラビア語で使うのは、28文字 — 筆記体のように文字を繋げる — 文字を繋げる際に形状が変化する — アラビア語以外でも使われる
アラビア語を表示する際の課題
課題 1
右から左
基本的に 右から左
例外あります
右から左と 左から右が混在
アラビア文字は、双方向テキスト — アラビア文字、英数字などが一つのテキスト内に混在した場合、 それぞれを適切な方向にレンダリングしなければならない
アラビア文字は、双方向テキスト — アラビア文字、英数字などが一つのテキスト内に混在した場合、 それぞれを適切な方向にレンダリングしなければならない 右から左 左から右 アラビア文字 アラビア文字以外の文字 記号 アラビア文字の数字 数字
アラビア文字は、双方向テキスト — アラビア文字、英数字などが一つのテキスト内に混在した場合、 それぞれを適切な方向にレンダリングしなければならない 右から左 左から右 アラビア文字 アラビア文字以外の文字 記号 アラビア文字の数字 数字
アラビア文字の数字 0 1 2 3 4 5 6 7 8 9 ٠ ١ ٢ ٣ ٤ ٥ ٦ ٧ ٨ ٩ 2019/9/26 ٢٠١٩/٩/٢٦
()
双方向テキストでの括弧 — 左括弧 ‘(’ 、右括弧 ‘)’ の文字コードは、向きに関わらず同じ — 右から左にレイアウトする場合、表示を切り替える
課題 2
文字の変形
変形ルール — 単語内での記述位置 — 特定文字の組み合わせ
単語内での記述位置 — 単独 — 先頭 — 中間 — 末尾 末尾 中間 先頭 単独 ﻞ ﻠ ﻟ ﻝ
単語内での記述位置(特殊) — 単独 — 先頭 — 中間 — 末尾 末尾 中間 先頭 単独 ﺎ - - ﺍ
単語内での記述位置(特殊) — 先頭に来た場合、単独を使用 — 単語の中間に来た場合、末尾を使用 — 次に続く文字は、繋げられなくなるため、先頭を使用 末尾 中間 先頭 単独 ﺎ - - ﺍ
単語内での記述位置(超特殊) — ハムザ — 声門破裂音 — 文字として記述する場合、単独のみ 末尾 中間 先頭 単独 - - - ﺀ
特定文字の組み合わせ — ‘’ﻝと‘’ﺍが連続した場合、 ‘’ﻻという文字になる
課題 3
シャクル
発音記号
アラビア文字は 子音のみで表記
Tsubasa
Tsubasa TBS
読み方が わからない
シャクルの扱い — 基本的には、アラビア文字に慣れていない初心者向け – 子供向けの書籍 – 教科書 – 教典 — 原則、省略不可なものもある – ハムザ など
シャクルの付け方 — 文字の上下に付く — 複数個付くパターンもある — 文字ごとにつける位置が決まっている
アラビア語を表示する方法
GPOS/GSUB
取得できない
GPOS - Glyph Positioning — 文字の位置調整に使用されるデータ — シャクルの位置を決める際に必要
GSUB - Glyph Substitution — 文字の置換に使用されるデータ — 文字の変形に必要
普段使ってる文字は、 よしなに処理されて表示される
どうするのか?
Presentation Forms
Presentation Forms — 変形済みのアラビア文字が定義されている — シャクル付きの文字も定義されている – 全てのシャクルには対応していない
先に結論から
文字列をよしなに処理して、 鏡に写したようにレイアウトする
アラビア語を表示するまでの手順
手順 — テキストデータの処理 — 頂点データの処理
テキストデータの処理
テキストデータの処理手順 — 文字を種類ごとに分類する — 分類した文字の向きを判断する — 文字を並び替える — 文字コードを表示用に変換する
文字を種類ごとに分類
右から左 左から右 アラビア文字 アラビア文字以外の文字 記号 アラビア文字の数字 数字
右から左 左から右 アラビア文字 アラビア文字以外の文字 アラビア文字の数字 数字 どちらの向きもあり得る 括弧 記号 空白
文字の分け方 — アラビア文字 — アラビア文字の数字 — アラビア文字以外の文字 — 数字 — 空白 — 制御文字 — 左括弧 — 右括弧 — その他
アラビア文字の判定方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 bool IsArabicWithoutNumber(char character) { int code = (int) character; // アラビア文字のブロック内にいる if (code >= 0x0600 && code <= 0x06FF) { // アラビア文字の数字は別扱い return code < 0x0660 || code > 0x0669; } // プレゼンテーションフォームのブロック内にいる if (code >= 0xFB50 && code <= 0xFEFF) { return true; } } return false;
文字や数字の判定 — char.IsLetter — char.IsNumber — char.IsWhiteSpace — char.IsControl
括弧の判定 — 括弧として対応するものを定義して、判定する — 一方の括弧と対になる括弧の組合せも定義しておく — 不等号も扱う必要がある
文字の分類方法 — 文字列内の文字を1文字ずつ判定 — 同種の文字が続く範囲をトークン化
トークンの定義 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class Token { // 文字列内での開始インデックス public readonly int startIndex; // 長さ public readonly int length; // 種類 public readonly TokenType type; } // 右から左にレイアウトするものかどうか public bool isRightToLeft;
例1 ( ا ل ع ر ) a b c
例1 ( 左括弧 ا ل ع アラビア文字 ر ) 右括弧 a 空白 b c アラビア文字以外の文字
分類した文字の向きを判断する
右から左 左から右 アラビア文字 アラビア文字以外の文字 アラビア文字の数字 数字 どちらの向きもあり得る 括弧 記号 空白
例1 ( 左括弧 ا ل ع アラビア文字 ر ) 右括弧 a 空白 b c アラビア文字以外の文字
例1 ( ا ل ع ر ) a b c 左括弧 アラビア文字 右括弧 空白 アラビア文字以外の文字 ← ← ← ← →
右から左 左から右 アラビア文字 アラビア文字以外の文字 アラビア文字の数字 数字 どちらの向きもあり得る アラビア語以外の文字が来ない限りは、 括弧 基本的に右から左 記号 空白
例2 ا ل ع ر ( a b c ) アラビア文字 左括弧 アラビア文字以外 空白 アラビア 文字以外 右括弧 ? ? ? ? ? ?
例2 ا ل ع ر ( a b c ) アラビア文字 左括弧 アラビア文字以外 空白 アラビア 文字以外 右括弧 ? ? ? ? ? ?
単純に判断できる部分を埋める ا ل ع ر ( a b c ) アラビア文字 左括弧 アラビア文字以外 空白 アラビア 文字以外 右括弧 ← ? → ? → ?
英字の間の空白は? ا ل ع ر ( a b c ) アラビア文字 左括弧 アラビア文字以外 空白 アラビア 文字以外 右括弧 ← ? → ? → ? “ab c”? “c ab”?
英字の間の空白は? ا ل ع ر ( a b c ) アラビア文字 左括弧 アラビア文字以外 空白 アラビア 文字以外 右括弧 ← ? → → → ? “ab c”? “c ab”?
どちらの向きもあり得る文字の向き — 前後の文字や間に含まれる文字の向きで決まる — アラビア文字以外の文字の後で、アラビア文字以外の文字、 数字などの挟まれた範囲は、左から右 — 括弧で囲まれた範囲に、アラビア文字、アラビア文字の数字 が入っていない場合は、左から右
括弧の向きは? ا ل ع ر ( a b c ) アラビア文字 左括弧 アラビア文字以外 空白 アラビア 文字以外 右括弧 ← ? → → → ?
括弧の間にアラビア語はないので、左から右 ا ل ع ر ( a b c ) アラビア文字 左括弧 アラビア文字以外 空白 アラビア 文字以外 右括弧 ← → → → → →
レンダリングするとこうなる ا ل ع ر ( a b c ) アラビア文字 左括弧 アラビア文字以外 空白 アラビア 文字以外 右括弧 ← → → → → →
文字を並び替える 文字コードを表示用に変換する
という話の前に……
右から左にレイアウトする文字を どうやってレンダリングするか?
通常のテキストは、左から右
右から左のレンダリング結果を得る方法 — データを逆にする — レンダリングを逆にする
データを逆にする
入力データを逆順で格納し直す 0 1 2 3 4 あ い う え お 0 1 2 3 4 お え う い あ
あれ、もしかして、解決?
そうは問屋が卸さない
幅が足りずに改行が行われた場合 入力 あいうえお 逆順化 おえういあ 期待 えういあ お 「お」が改行されてほしい
幅が足りずに改行が行われた場合 入力 あいうえお 逆順化 おえういあ 期待 現実 えういあ お おえうい あ
レンダリングを逆にする
ポリゴンの左右を逆にする時、 どうしますか?
Y軸を180度回転させる
文字も逆で読めない
文字毎に再度180度回転
右から左は正しくなった
左から右の文字は どうするのか?
この状態で左から右を正しく処理するには? — データを逆にする — レンダリングを180度反転させた位置にずらす
この状態で左から右を正しく処理するには? — データを逆にする — レンダリングを180度反転させた位置にずらす
文字を並び替える 文字コードを表示用に変換する
文字を並び替えた場合の利点
レンダリング処理が簡単
改行が行われない文字列に対しては こちらの方が効率がいい
文字コードを表示用に変換する
変換が必要な文字 — アラビア文字 — 右から左の括弧
アラビア文字の変形 — 単語内での記述位置 — 特定文字の組み合わせ
アラビア文字の変形 — 単語内での記述位置 — 特定文字の組み合わせ ( 左括弧 ا ل ع アラビア文字 ر ) 右括弧 a 空白 b c アラビア文字以外の文字
アラビア文字の変形 — 単語内での記述位置 — 特定文字の組み合わせ ( 左括弧 ا ل ع アラビア文字 単語 ر ) 右括弧 a 空白 b c アラビア文字以外の文字
変形の手順 — 入力データをプレゼンテーションフォームに変換 — 文字の並びに合わせて、適切な形状に置換
入力データをプレゼンテーションフォームに変換 — 単独系のプレゼンテーションフォームと紐付けるだけ — ‘’ﻝと‘’ﺍの連続は、‘’ﻻに変換
シャクルを外す
入力データをプレゼンテーションフォームに変換 — 単独系のプレゼンテーションフォームと紐付けるだけ — ‘’ﻝと‘’ﺍの連続は、‘’ﻻに変換 — シャクルの除外 – GPOS がないので正しい位置に出せない – 一部の省略できないものは、プレゼンテーションフォーム側でシャ クル付きの文字が定義されているので、そちらを使う
各文字の変形情報を定義 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class ArabicFormSet { /// <summary>単独</summary> public readonly int isolated; /// <summary>末尾</summary> public readonly int final; /// <summary>先頭</summary> public readonly int initial; /// <summary>中間</summary> public readonly int medial; /// <summary>先頭、中間を持つかどうか</summary> public readonly bool hasInitialAndMedial; } /// <summary>尾字を持つかどうか(基本的にHamza用の例外)</summary> public readonly bool hasFinal;
各形状は規則的に定義されている 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class ArabicFormSet { // フィールド } ArabicFormSet(ArabicLetter.PresentationForms isolated, bool hasInitialAndMedial) { this.isolated = (int) isolated; // Unicode上で末尾は単独から1つ、先頭は2つ、中間は3つズレた位置に格納されている // 先頭、中間を持たない文字の場合は、中抜けになるわけではなく、次の文字が詰めて配置されている final = this.isolated + 1; initial = hasInitialAndMedial ? this.isolated + 2 : 0xFFFF; medial = hasInitialAndMedial ? this.isolated + 3 : 0xFFFF; this.hasInitialAndMedial = hasInitialAndMedial; hasFinal = true; }
文字の並びに合わせて、適切な形状に置換 — 末尾かどうか — 先頭かどうか — 中間かどうか
単語の先頭、末尾以外の位置でも、 先頭、末尾の形状になる文字もある
直前が末尾なので、先頭 先頭の形状がないので、単独 中間の形状がないので、末尾
末尾かどうか 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 bool IsFinal(ArabicFormSet prev, ArabicFormSet current, ArabicFormSet next) { // 変形できない if (!current.hasFinal) { return false; } // 頭字、中字からの続きではない if (prev == null || !prev.hasInitialAndMedial) { return false; } // 自身が中間の形状を持っていて、次の文字に末尾の形状がある if (current.hasInitialAndMedial && next != null && next.hasFinal) { return false; } } return true;
先頭かどうか 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 bool IsInitial(ArabicFormSet prev, ArabicFormSet current, ArabicFormSet next) { // そもそも変形できない if (!current.hasInitialAndMedial) { return false; } // 先頭、中間から続く if (prev != null && prev.hasInitialAndMedial) { return false; } // 中間、末尾に続かない if (next == null || (!next.hasInitialAndMedial && !next.hasFinal)) { return false; } } return true;
中間かどうか 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 bool IsMedial(ArabicFormSet prev, ArabicFormSet current, ArabicFormSet next) { // そもそも変形できない if (!current.hasInitialAndMedial) { return false; } // 先頭、中間から続かない if (prev == null || !prev.hasInitialAndMedial) { return false; } // 末尾に続かない if (next == null || !next.hasFinal) { return false; } } return true;
右から左の括弧の変換 — 括弧の開きと閉じは、アラビア語でも文字コードが同じ ( 左括弧 ا ل ع アラビア文字 ر ) 右括弧 a 空白 b c アラビア文字以外の文字
( ا ﻟ ﻌ ﺮ ) ) ر ع ل ا (
) ا ﻟ ﻌ ﺮ ( ) ر ع ل ا (
これでテキスト部分の処理は完了です
c b a ) ر ع ل ا (
c b a ( ﺮ ﻌ ﻟ ا )
いざ、表示
……あれ?
理想 現実
レンダリングは 左から右のまま
c b a ( ﺮ ﻌ ﻟ ا )
頂点データの処理
理想 現実
頂点データの処理 — 右から左 : 左右の位置を文字単位で反転 — 左から右 : 左右の位置をグループごと移動して反転
頂点データの処理手順 — 頂点データの生成 — 文字ごとの頂点データの設定
Text コンポーネントを拡張したクラスを用意 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class ArabicText : UnityEngine.UI.Text { protected override void OnPopulateMesh(VertexHelper vertexHelper) { // ここに頂点データの処理を記述する } }
頂点データの生成
TextGenerator で頂点データを生成
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class ArabicText : UnityEngine.UI.Text
{
protected override void OnPopulateMesh(VertexHelper vertexHelper)
{
Vector2 extents = rectTransform.rect.size;
var settings = GetGenerationSettings(extents);
// workerText には、アラビア語用に処理されたテキストが入っている
cachedTextGenerator.PopulateWithErrors(workerText, settings, gameObject);
}
}
// 頂点、行の情報を取得
IList<UIVertex> verts = cachedTextGenerator.verts;
IList<UILineInfo> lines = cachedTextGenerator.lines;
TextGenerator — テキストの頂点情報や文字、行の情報を生成してキャッシュ することができる — 生成した際にフォントテクスチャに必要なフォントが書き込 まれる — GPOSのデータに対応したデータが作られる(全てではない) — スペース区切りでの改行位置調整
文字ごとの頂点データの設定
使用するデータ — 頂点情報 — 行情報 — 文字を分類した際のトークン – 処理する文字の向きで処理方法が変わるため
トークン毎に処理
右から左の文字の処理方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
UIVertex[] buffer = new UIVertex[4];
for (int i = 0; i < batchVertexCount; ++i)
{
int bufferIndex = i & 3;
// 頂点の順番は、左下から反時計回り
int indexOffset = (i & 1) == 0 ? 1 : -1;
buffer[bufferIndex] = verts[batchStartVertexIndex + i];
// 文字の見た目も反転してしまうので、見た目は本来の通りになるように、左右を反転させる
buffer[bufferIndex].position.x = verts[batchStartVertexIndex + i + indexOffset].position.x;
buffer[bufferIndex].position.x += (center - buffer[bufferIndex].position.x) * 2;
// unitsPerPixel, roundingOffset の処理は、Text コンポーネントと同じなので割愛
}
if (bufferIndex == 3)
{
vertexHelper.AddUIVertexQuad(buffer);
}
左から右の文字の処理方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
UIVertex[] buffer = new UIVertex[4];
float blockMin = verts[batchStartVertexIndex].position.x;
float blockMax = verts[batchStartVertexIndex + (batchCharacterCount - 1) * 4 + 1].position.x;
float blockCenter = blockMin + (blockMax - blockMin) / 2;
float xOffset = (center - blockCenter) * 2;
for (int i = 0; i < batchVertexCount; ++i)
{
int bufferIndex = i & 3;
buffer[bufferIndex] = verts[batchStartVertexIndex + i];
buffer[bufferIndex].position.x += xOffset;
// unitsPerPixel, roundingOffset の処理は、Text コンポーネントと同じなので割愛
}
if (bufferIndex == 3)
{
vertexHelper.AddUIVertexQuad(buffer);
}
これで頂点部分の処理は完了です
リトライ
お、それっぽい……
が
ココ
隙間が空いてる
なぜ?
処理が足りていない
フォントのレイアウトデータ
アセンダーライン キャップライン ミーンライン ベースライン ディセンダーライン a セット セット幅
サイドベアリング アセンダーライン キャップライン ミーンライン ベースライン ディセンダーライン a セット セット幅
サイドベアリングは 左右均一とは限らない
左右反転するとズレる
CharacterInfo — 文字のレンダリング情報が取得できる
サイドベアリングの取得方法 — 左側 – CharacterInfo.bearing — 右側 – 取得できません
取得できる値から逆算
CharacterInfo.bearing a CharacterInfo.glyphWidth ? CharacterInfo.advance ?
全ての文字、入力パターンに対して、 正しいわけではなさそう
空白など一部を除き、おおよそ それらしき値が取れる
サイドベアリングの取得方法 — 左側 – CharacterInfo.bearing — 右側 – advance - glyphWidth - bearing (仮定)
文字の処理に当てはめる
CharacterInfo の取得 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 CharacterInfo info; font.GetCharacterInfo(character, out info, fontSize, fontStyle);
右から左の文字の処理方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// すでに処理済みのテキストの長さ
int alreadyPutLength;
for (int i = 0; i < batchVertexCount; ++i)
{
// インデックスの計算
int bearingOffset = 0;
if (bufferIndex == 0)
{
int characterIndex = i / 4;
char character = workerText[alreadyPutLength + characterIndex];
CharacterInfo info;
font.GetCharacterInfo(character, out info, fontSize, fontStyle);
int leftBearing = info.bearing;
int rightBearing = info.advance - info.glyphWidth - info.bearing;
bearingOffset = leftBearing - rightBearing;
}
// 頂点の処理
buffer[bufferIndex].position.x += bearingOffset;
}
// unitsPerPixel, roundingOffset の処理、VertexHelper に頂点を追加
左から右の文字の処理方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// すでに処理済みのテキストの長さ
int alreadyPutLength;
float blockMin = verts[batchStartVertexIndex].position.x – info.bearing;
float blockMax = verts[batchStartVertexIndex + (batchCharacterCount - 1) * 4 + 1].position.x;
// 最初の文字の左側のサイドベアリング分を補正
char firstCharacter = workerText[alreadyPutLength];
CharacterInfo info;
font.GetCharacterInfo(firstCharacter, out info, fontSize, fontStyle);
blockMin -= info.bearing;
// 最後の文字の右側のサイドベアリング分を補正
char lastCharacter = workerText[alrealdyPutLength + batchCharacterCount - 1];
font.GetCharacterInfo(lastCharacter, out info, fontSize, fontStyle);
blockMax += (info.advance – info.glyphWidth – info.bearing);
float blockCenter = blockMin + (blockMax - blockMin) / 2;
float xOffset = (center - blockCenter) * 2;
for (int i = 0; i < batchVertexCount; ++i)
{
// 頂点の処理
}
もう一度、リトライ
隙間消滅
検証
文字を入れ替えて レンダリングはそのまま 表示したものと比較
ピッタリ
注意点
反転する際の基準点を RectTranform の 中心から求めるとアライメントが逆転する
ともあれ……
最低限正しく表示できる
感想
超絶しんどいです!!
ご清聴ありがとうございました!