文字を描く

fadis氏:画像がいけたので、次は文字です。

スケーラブルフォントの形式として昔からよく使われているTrueTypeは、2次のベジェ曲線で文字の輪郭を表しています。GPUで素早くベクター画像を描く手法は昔からさまざま考えられていますが、文字は極端に拡大されたり極端に縮小されたりすることはないので、動的な三角形分割をしなくてもそこそこの品質で描くことができます。そこで今回はフォントを事前に三角形のメッシュに変換して、それを描く方向で実装を試みます。

ttf2meshはTrueTypeを三角形分割するライブラリです。これを使ってフォントを頂点配列に変換しましょう。ttf2meshはテッセレーション自体はよくできているのですが、format 4のcmapしか読まないという問題があります。

TrueTypeには曲線のパラメーターだけでなく、それをフォントとして使うために必要なさまざまな情報が含まれています。TrueTypeのファイルの先頭には、何の情報がファイルのどのあたりに置かれているかを表すオフセットテーブルが書かれています。

TrueTypeに含まれる情報の塊は「テーブル」と呼ばれ、個々のテーブルには内容を表す4文字の名前がついています。曲線のパラメーターはグリフテーブルに含まれています。

グリフテーブルのどこからどこまでが1文字分の情報なのかを表すのが、ロケーションテーブルです。cmapテーブルは、ロケーションテーブルに並んでいる個々の文字が、文字コードでどの文字に対応するかを表すテーブルです。

cmapの具体的な記録方法は、9種類定義されています。今日の多くのフォントは、Unicodeのコードポイント32bit整数で扱うFormat 12のテーブルを持っていますが、同時にUnicodeが16bitの空間しかなかった時代のアプリケーションでも読めるように、Format 4のテーブルも持っています。

日本語で使われる文字は、ひらがな、カタカナ、基本的な漢字等は16bitの基本多言語面に収まっていますが、後から追加された漢字は16bitでは表現できない追加漢字面に置かれています。ttf2mechはFormat 4のcmapしか読まないので、日本語フォントを食べさせると追加漢字面の漢字のコードポイントが取れません。

というわけで、ttf2meshを改造していきます。まず16bit整数でコードポイントを持っているところを、片っ端から32bit整数にしていきます。次にFormat 12のcampをバースする関数を実装します。これで日本語フォントのすべてのグリフのコードポイントが取れるようになりました。

いろんな文字を1回のDrawコールで描きたいので、頂点配列を切り替えなくてよいように、すべてのグリフを1つの頂点配列の中に並べます。その場合、あるグリフを描きたい時に、頂点インデックスのどこからどこまでを舐めればよいかを、どこか別の場所に記録しておく必要があります。この情報を記録する手段として、今回は3DモデルデータのフォーマットであるglTFを使います。

ttf2meshでTrueTypeをglTFにします。まず、ttf2meshでフォントを読み込み、グリフの情報が読めていることを確認して、グリフを三角形メッシュに分割します。こうして得られた頂点がすでに頂点配列にある頂点と異なっていたら、頂点配列へ新しい頂点を追加し、頂点インデックスを追加します。

最後に、頂点インデックスの今追加した範囲が1つのグリフであることを表す配列を作ります。glTFのヘッダはただのJSONなので、ただのJSONエンコーダで組み立てていきます。各グリフの頂点インデックスに対応するバッファビューを作り、アクセサで型を与えて、グリフの数だけノードを作ります。最後にファイルに吐いたら完成です。

できあがったグリフのデータは普通のglTFなので、Blenderで読んで正しい内容になっているかが確認できます。

このグリフデータを読むアプリケーション側の実装です。バイナリをGPUに送って、個々のノードの頂点インデックスのオフセットをglyph_idから見つけるためのハッシュテーブルを作っておきます。

とりあえず読んだものを順番に描いてみた結果がこれです。

文字もたくさん描かないといけないので、長方形のようにたくさんの描画要求を一括で投げる必要があります。しかし長方形の場合と違って、個々の文字はトポロジが異なるので、インスタンシングで量産することはできません。

このような場合でも1コマンドで大量の描画を行えるようにするのが、IndirectDrawです。 IndirectDrawでは、個々のDrawコマンドの引数のリストをあらかじめGPUから見えるメモリに置いておき、GPUにそのリストを舐めるように要求します。キャンバスに対して文字の追加が要求されたら、まずGPUに送った頂点配列の中にそのグリフがあるかを調べ、長方形の場合と同じように、色・大きさ・配置の情報をGPUに渡す構造体に積みます。さらに頂点インデックスのどこからどこまでを読むべきかを、インダイレクトバッファに積みます。

描画する際は、グリフデータの頂点配列とグリフデータの頂点インデックスをbindし、インダイレクトバッファを引数としてdrawIndexedIndirectコマンドを投げます。このような手順を経て、「Hello,world!」を表示することができます。

言語によって正しい文字の並べ方は違う

今このデモでは、1文字書くたびに次の文字を描く位置を文字幅分だけ右に移動させています。しかし、現実の文字はそんなに簡単ではありません。英語のアルファベットは、文字幅分ずつ右に進んでいけば正しく並べることができます。日本語もこの点では同じで、文字幅分ずつ右に進んでいけばOKです。

これはタイ語です。一部の文字は前の文字を修飾する役割を果たしていて、並べると前の文字の上に置かれます。これはヒンディー語です。注目してほしいのは3文字目で、4文字目の文字と合体することでグリフが別のものに変わっています。

これはヘブライ語です。この言語は右から左に向かって書きます。1文字書いたら、文字幅分だけ左に進まなければなりません。

これはアラビア語です。アラビア語も右から左に向かって書く言語で、しかも文字の並びによってその形が大きく変化します。

文字を描く上での問題を解決する「HarfBuzz」「ubidi」「ubrk」

さまざまな言語の正しい文字の並べ方を理解してコードを書くのは大変な作業ですが、その大変な作業を肩代わりしてくれるライブラリがあります。HarfBuzzです。HarfBuzzは与えられた文字列を描くために、どのグリフをどの位置に置くべきかを教えてくれます。

HarfBuzzは文字を描く上で解決しなければならない問題の一部を片付けてくれますが、すべてを片付けてくれるわけではありません。例えば、ある文字列が右から左に進むと言われた時に、その向きで文字を置く位置を求めてはくれますが、その文字列の言語が左から右に書く言語なのか、右から左に書く言語なのかは判断してくれません。

ある文字列がどちら向きに進むべきなのかを判断する方法は、Unicodeの規格で定められています。ここにはめんどくさいルールがたくさん書かれているのですが、そうしたUnicodeのめんどくさい決まりを実装したライブラリICUを使うと簡単に対応できます。

ubidiはUnicodeの双方向アルゴリズムの実装です。ubidi_getBaseDirectionに文字列を渡すと、その文字列がどちら向きに始まる文字列かを判断してくれます。

文字列の中には、進行方向の異なる文字列が混ざることがあります。(スライドを示して)上の例では、左から右に進む英語の中に、右から左に進むヘブライ語が混ざっています。下の例では、右から左に進むヘブライ語の中に、左から右に進む英語が混ざっています。

アラビア数字は世界共通で左から右に向かって書かれるため、右から左に進む言語では日常的に進行方向の異なる文字列が混ざることになります。

ubidiを使うと、文字列を進行方向と同じ範囲で区切ることができます。

文字を表示する領域の幅が限られている時、長いテキストは折り返して表示する必要があります。Unicodeの文字列には2つの行に切り分けてよい場所と、そうではない場所があります。

例えば英語の場合、単語の途中で折り返すべきではありません。日本語の場合、捨て仮名を先頭に持ってくるべきではありません。複数のコードポイントが合体して1つの文字を構成している言語では、その一部が次の行に送られるような折り返しをすべきではありません。

どこなら折り返してもよいかはUnicodeの規格で定められていて、ICUにその実装が含まれています。

ubrkを使うと、文字列を折り返してはいけない塊単位で区切ることができます。ubidiで進行方向毎に区切った後の文字列にubrkをかけることで、文字列に折り返して大丈夫かどうかの情報を付与します。

文字列を進行方向で分割し、改行できるかどうかでさらに分割し、分割された個々の塊をHarfBuzzにかけて配置の情報に変換します。最後にHarfBuzzが求めた描画位置を連結して、それが描画範囲からはみ出すようなら、折り返し可能な位置で改行させます。

HarfBuzzでは長さは1,024分の1em(エム)を単位とする数字で表現されます。エムというのはフォントのディセンダーラインからアセンダーラインまでの幅を1とする長さです。

文字列をHarfBuzzにかけると、オフセットとアドバンスが得られます。オフセットはこの文字をカーソルからどれだけ離れた位置に描くべきか、という値です。アドバンスはこの文字を描いた後カーソルをどれだけ動かすべきか、という値です。

HarfBuzzはエムで長さを表すため文字の大きさに依存しませんが、実際に文字を描くには1エムがいくつなのかを決める必要があります。文字の大きさはポイントという単位がよく用いられます。1ポイントはだいたい0.35ミリメートルです。

画面にミリメートルで表される大きさのものを描くには、ディスプレイの物理的な大きさの情報が必要ですが、ディスプレイは自身の大きさの情報をGPUに伝えています。

この情報はVulkanから取ることができます。画面がプロジェクタ等の場合、取れないこともありますが、そういう場合はとりあえず96dpiにしておきましょう。

Wikipediaのナスの概要をICUとHarfBuzzとglTFにしたフォントを使って描く

これはWikipediaのナスについての記事を、ICUとHarfBuzzとglTFにしたフォントを使って描いたものです。同じ方法で日本語の文章も描くことができます。

これはタイ語の場合です。使っているタイ語フォントに数字が含まれていないため、一部表示ができていませんが、グリフの配置自体はChromeの表示と一致しているので問題なさそうです。ちなみにタイ語は本来段落の最初に字下げを入れますが、字下げを実装していないので左端から段落が始まっています。

ヘブライ語の場合です。ナスの学名の部分はラテン語なので左から右に文字が並んでいて、それ以外の部分は右から左に文字が並んでいます。

6文字目の括弧は普通のASCIIコードの開き括弧なので、本来のグリフは右を向いている括弧ですが、右から左に書くテキストの中に現れているため、HarfBuzzは双方向アルゴリズム3.4章のルールに従って、グリフを左向きの括弧に置き換えています。

アラビア語の場合です。Chromeの表示と比較してみるとだいぶ違うような気がするのですが、アラビア語が読めなさすぎて何が起きているのかよくわかりません。

中国語の文字は合体したり変形したりしないので、表示自体は手堅く行えます。ただ、グリフが多すぎて使っているglTFパーサが読み込みを諦めるので、読み込みを諦める閾値を上げる必要がありました。

というわけで、ダイアログに文字が入りました。

ユーザーから入力を受け取り画面に表示する方法

ここまで表示を見てきましたが、GUIは表示してユーザーに見せるだけでなく、ユーザーからの入力を受ける必要があります。

Xサーバーがいる場合、Xサーバーがアプリケーションに入力イベントを通知してきます。しかしXサーバーは一番下のレイヤーではありません。入力デバイスは、Linuxカーネルのヒューマンインターフェイスデバイスドライバが握っていて、イベントデバイス、libinputを経てXサーバーに情報が渡ってきます。したがってアプリケーションがlibinputからの入力イベントを受け取ることで、Xサーバーがいなくても入力イベントを取れます。

やってみましょう。udevを開いてlibinputのコンテキストを作ります。libinputは入力デバイスをシートと呼ばれるグループに紐づけて使うので、まずシートを作ります。あとはループで届いたイベントを処理します。

libinputは入力イベントが起きた時に読めるようになるファイルディスクリプタを提供しているため、ループの最後でこのファイルディスクリプタが読めるようになるのを待ちます。

マウスが動いたイベントをlibinputから拾って、カーソルが動いたら新しい位置にカーソルを描き直すと、こんな感じでマウスカーソルを動かせるようになります。

libinputでキーボード入力も拾えます。キーボードのイベントでは、キーの番号が渡ってきます。この番号は何を表しているのでしょうか。libinputが渡してくるキーの番号は、Linuxカーネルのinput-event-codesで定義されています。

例えば30ならKEY_Aなので、キーボードのAが押されたという意味のような気がしますが、そうとは限りません。input-event-codesの30番は、もしANSIキーボードだったならAがある位置のキーが押されたという意味です。世の中にはいろいろな形・いろいろな配列のキーボードがありますが、KEY_Aは紫で示した位置のキーが押されたという意味です。

この位置にあるキーは、実際にはAではないことがあります。例えばフランス語、AZERTY配列のキーボードの場合、KAY_AはQのキーが押されたという意味になります。「どの位置のキーが押されたか」を「何の文字のキーが押されたか」に変換するには、キーボードに刻印された文字の並びに関する知識が必要です。

X Window Systemはこの変換表を持っていて、X Window Systemから切り離されたxkbcommonを使うとXがなくても変換表を使うことができます。

xkbcommonは、キーボードレイアウトを指定すると、何の文字が入力されたかを教えてくれます。xkbcommonを使うには、キーボードレイアウトの情報が必要です。

いまどきのLinux環境では、localectlでキーボードレイアウトの設定をするのが一般的なのでこの値を使いたいのですが、アプリケーションはどうやってこの値を取ってくればよいのでしょうか?

この手のシステムDに設定した値はD-Busで拾うことができます。D-BusはLinux環境で昔から使われている、プロセス間通信のプロトコルです。org.freedesktop.locale1に接続して、localectlで設定した値をもらってきます。

次にxkbのコンテキストを作って、先ほど拾ったキーボードレイアウトの名前で対応するキーマップを探します。最後にキーボードの状態マシンを作ります。

この状態マシンにどの位置からのキーが押されたかを渡します。xkbcommonはXのキーボードイベントの番号を要求していますが、Linuxのキーボードイベントの番号に8を足すとXのキーボードイベントの番号になるようになっているので、libinputからもらった値に8を足して、xkbcommonに渡します。

xkbから入力された文字をもらいます。何の文字が入力されたかが取れるようになりました。あとはそれを文字の描画と連動させれば、画面に入力した内容を表示できます。

キーボードからテキストを入力できて「めでたしめでたし」と言いたいところですが、まだ問題が残っています。日本語が打てない。

日本語、中国語、ハングル等の文字はキーボードから直接入力はできず、変換エンジンを介して入力する文字列を決定する必要があります。QtやGTK+といったGUIツールキットは、IMMODULEを介してキーボードイベントを入力メソッドに渡し、変換エンジンが求めた候補をアプリケーションに返します。

QtもGTK+もいませんが、IMMODULEがやるように入力メソッドと会話すれば、日本語の変換ができます。

入力メソッドの実装には複数の種類があり、会話する方法が異なります。どの入力メソッドを使っても、主要な変換エンジンを使うことができます。

今回はfcitxを介して変換エンジンを使います。fcitxはD-Busを介して必要な情報をやり取りします。

D-Busのセッションバスのorg.fcitx5.Fcitx5のinputmethodに接続します。CreateInputContextを呼ぶと新しいテキストが作られ、そのコンテキストにアクセスするためのパスが返ってきます。返ってきたパスに接続します。

xkbcommonに通す前のキーの位置と通した後のキーの文字をfcitx5に送ると、fcitxからいろいろなシグナルが返ってきます。全角半角キー等で変換エンジンの切り替えが行われると、現在の変換エンジンの名前が飛んできます。変換が確定すると、確定した内容が飛んできます。

変換エンジンが切られた状態で入力された文字も、シグナルで飛んできます。変換候補ウィンドウに表示すべき内容が変わった時も、シグナルが飛んできます。変換中の文字列として表示すべき内容が変わった時も、シグナルが飛んできます。

fcitx5からのシグナル表示を切り替えて確定した文字列を並べていくと、こうなります。試していませんが、中国語変換エンジンChewingを使えば、中国語も打てると思います。

未解決問題を紹介

今回実装したのはここまでですが、いろいろ未解決の問題が残っています。半透明の長方形を正しく描くには、後ろにくる要素が先に描画されることを保証する必要がありますが、現状そうした実装を用意していないので、半透明を正しく描くことができません。

HarfBuzzは縦書きのテキストを正しく配置することができますが、縦書きできないテキストが混ざってきたり、右から左に書くテキストが混ざってくると話がややこしくなるので、縦書きはとりあえず考えないことにしています。

文字にアンチエイリアスをかけていないので、拡大してみるとけっこうガタガタしています。これは4点のMSAAをかければきれいになる気がします。

ナウいフォントはTrueTypeではなくOpenTypeになっています。これはTrueTypeに3次ベジェ曲線のサポート等を追加したもので、ttf2meshでは読めません。NotoSansCJKがOpenTypeで読めないのがつらいので、なんとかしたいところです。

D-Busをしゃべるのにsd-busを使っていますが、ライセンス的にも振る舞い的にも厄介なので、将来的にはD-Busを自前でしゃべるようにしようと思います。

今回のデモをRaspberryPiのGPUで実行するとクラッシュする原因がよくわかっていません。RaspberryPiのGPUでは使えるディスクリプタの最大数が非常に少ないので、そのへんに引っかかってるんじゃないかと見ています。

まとめに入りましょう。GUIをするのにXは要りません。DRMのプライマリノードを握って画面のコントロールを手に入れましょう。libinputで入力を受け付けましょう。ご清聴ありがとうございました。