- レンダーパス内でデバッグメッセージを素早く出したい場面では、フォントアトラスを用意する負担が大きいため、フラグメントシェーダ定数だけでテキストを描く方式が有用
- グリフは 8x16 ピクセルのビットマップで表現され、printable ASCII 96 文字を 1536 バイトの
uvec4 配列に格納し、シェーダ内で直接参照する
- フォントデータは PSF1 ターミナルフォントから、4 バイトのヘッダと 512 バイトの非出力グリフ領域を飛ばした後、続く 96 個のグリフを抽出して作成できる
- レンダリングは 4 文字単位の
uint32_t と位置・スケールを含む per-instance 構造体を使い、全文字列を 単一の instanced draw call で描画する
- 単純なデバッグ出力に焦点を当てた手法のため、4 文字パディング、
\0 fill 文字の discard、エンディアン補正といった制約をアプリケーションとシェーダの両方で処理する必要がある
フォントアトラスの代わりにシェーダ定数を使う
- 一般的なテキストレンダリングでは、使用可能なグリフをフォントアトラスにレンダリングし、それをテクスチャとしてバインドしたうえで、各グリフに対応する三角形を描く方式が取られる
imgui や stb_truetype もこの方式を使っているが、素早いデバッグメッセージ出力には準備工程が煩雑になることがある
- 代替案として、フォントアトラスに相当するデータを フラグメントシェーダ内の整数定数として保存する
- 整数はビットマップのように扱えるため、フラグメントの
xy 位置を特定のビット位置に対応付け、そのビットが立っていれば前景色、立っていなければ背景色を出力する
8x16 ビットマップグリフと ASCII テーブル
- 1 バイトでは 1 行分のピクセルしか表せないため、より読みやすいグリフを作る目的で、グリフごとに 16 バイトを使う
- 1 つのグリフは 8x16 ピクセルのキャンバスとなり、GLSL の
uvec4 1 個に必要な 16 バイトをちょうど収められる
- printable ASCII の 96 グリフを保存すると、全体のデータ量は 1536 バイトになる
font_data[96] 配列は、ASCII 値から 0x20 を引いた値をインデックスとして使う
0x20 SPACE から始まる printable ASCII グリフを対象にする
- サンプルコードでは容量節約のため一部の項目だけを示している
- 完全なビットマップテーブルは Island source code に含まれている
PSF1 フォントからビットマップを抽出する
- 必要なビットマップのエンコーディングは、PSF1 フォーマットのターミナルフォントとほぼ一致している
- PSF1 ターミナルフォントからデータを抽出する手順は単純
- ImHex のようなヘックスエディタでフォントファイルを開く
- 4 バイトのヘッダを飛ばす
- 512 バイトの非出力グリフセクションを飛ばす
- 続く 96 個のグリフ、つまり 1536 バイトを “Copy as → C Array” で書き出す
- 抽出した char 配列は
uint 配列に編集した後、uvec4 単位にまとめられる
- raw char をそのまま
uint に連結すると エンディアンが逆転するため、サンプリング時にそれを補正する
- 使用されたピクセルフォントの元データは、Scott Fial の無料ピクセルフォント Tamsyn から取得している
単一の instanced draw call の構成
- テキストレンダリングは 1 回の instanced draw call で処理する
- draw call は 2 つの attribute stream を使う
- per-draw ストリームは、通常の quad を描くために必要な情報だけを持つ
- per-instance ストリームは、画面上の位置オフセットと出力するテキストを持つ
- 位置オフセットには x, y の float を使い、余った float 領域にはフォントスケール値を入れられる
- Vulkan では vertex output binding の全コンポーネントが同じ interpolation 特性を持たなければならないため、
vec3 と uint を 1 つのバインディング内で綺麗に混在させるのは難しい
- テキストは 4 文字単位で
uint32_t にパックする
- 基本的な vertex attribute データ型の最小単位は通常 32 ビットなので、4 文字を一度に収められる
- メッセージ長は 4 で割り切れる必要がある
- 足りない部分は
\0 文字で埋める
- per-instance データは
word_data 構造体で表現される
pos_and_scale[3]: xy 位置と scale
word: 出力する 4 文字
- アプリケーションはメッセージを 4 文字チャンクに分割し、各チャンクを
uint32_t に変換した後、位置オフセットとともに word_data 配列へ蓄積する
- レンダリング時には、この配列を debug text drawing pipeline の per-instance binding としてバインドし、quad の数だけ instance を描画する
Vertex Shader で位置と文字を渡す
- vertex shader は
gl_Position、レンダリングする word、テクスチャ座標に相当する値を出力する
gl_Position は per-instance の pos_and_scale データを使って、NDC 座標系で三角形の頂点を画面上に配置する
- レンダリングする word は入力 attribute
uint をそのままフラグメントシェーダへ渡す
- 補間されないよう
flat qualifier を使う
- テクスチャ座標は
gl_VertexIndex から合成する
12 >> gl_VertexIndex & 1 は 0, 0, 1, 1 のシーケンスを作る
9 >> gl_VertexIndex & 1 は 1, 0, 0, 1 のシーケンスを作る
- この組み合わせで
(0,1), (0,0), (1,0), (1,1) の uv 座標を分岐なしで生成する
- vertex shader は前景色と背景色も per-instance データとして受け取り、フラグメントシェーダへ渡す
Fragment Shader におけるグリフサンプリング
- フラグメントシェーダがテキストをレンダリングするには、3 つの情報が必要
- 補間されたフラグメント uv 座標
- 出力する文字データ
in_word
- グリフビットマップ配列
font_data
- uv 座標は正規化された float 範囲
vec2(0.f,0.f) から vec2(1.f,1.f) までで、グリフのピクセル座標は uvec2(0,0) から uvec2(7,15) までとなる
- 4 文字の word 全体は、横 32 ピクセル・縦 16 ピクセルの領域として扱う
uv.xy * vec2(8 * WORD_LEN, 16) を floor して word のピクセル座標へ量子化する
- 座標範囲は
uvec2(0..31, 0..15) に制限する
word_pixel_coord.x / 8 で 4 文字のうちどの文字領域かを求める
word_pixel_coord.x % 8 でグリフ内の x 座標を求める
- 文字コードは
font_data のインデックスへ変換される
- 最初のグリフは
0x20 SPACE なので、printable_character - 0x20 を offset として使う
- その offset で
uvec4 のグリフビットマップを取得する
- y 座標は
glyph_pixel_coord.y / 4 により uvec4 内の特定の uint を選ぶ
- この
uint は 4 行分のピクセルデータを保持している
- ImHex で抽出した char をそのまま連結して
uint にしたため、行の順序が逆になっている
(8*(3-(glyph_pixel_coord.y)%4)) のように後ろからインデックスして補正する
- x 座標は
7-glyph_pixel_coord.x でビットを選ぶ
- バイトの最上位ビットが最も高いインデックス側に保存されているため、左から右へ対応させるには逆方向のインデックスが必要になる
- 最終的な
current_pixel 値を使い、mix(background_colour, foreground_colour, current_pixel) を適用して色を決める
短い文字列と fill 文字の処理
- 文字列長が 4 で割り切れない場合、アプリケーションが不足分を
\0 文字で埋める
- フラグメントシェーダは、出力対象の文字が
\0 かどうかを検査する
\0 の fill 文字に遭遇したときは背景も描かず、discard を実行する
- この処理により、4 文字パッキングの制約を保ちながら短い文字列も出力できる
利用形態とコードの場所
- Island プロジェクトでは
le::DebugPrint を呼び出して、画面にデバッグテキストを表示できる
- フラグメントシェーダ全体のコードは github で確認できる
- コード例では文字列データを渡し、
"That's all, %s" 形式のメッセージを画面に表示している
1件のコメント
Hacker News の意見
自分で試してみたいなら、計算を追っていけばよい。ShaderToy ならゼロから実装するのがとても簡単で、こういうものが好きなら土曜の朝の遊びとしても楽しい
ゼロから作るのは楽しいが、最初のヒントが必要なら、さっき作った例がある: https://www.shadertoy.com/view/Mc3cW2
ほかの人たちが作った賢いテキストハックもたくさんあり、300文字未満の Matrix の例 https://www.shadertoy.com/view/llXSzj や、緑色の CRT 表示効果 https://www.shadertoy.com/view/XtfSD8 などもある
vec2(30, -30)の 30 を 300 に変えるとアーティファクトが見えるきちんと処理するコツがあるのか気になる。自分の場合はフラグメントシェーダー内でテクスチャをマルチサンプリングするのが最も効いたが、それでも最新水準ほど良くはなかった
少し前にネイティブのコンソールフォントのように見えるアプリを作ろうとしたが、90% くらいまで合わせるだけで 2時間以上いじる必要があった
奇抜でハック的で楽しい。実際、ほとんどすべての 3D レンダリング技法がそうだが、成果物が昔の電子掲示板を再現しようとしているのでなければ、特に美しいわけではない
ビットをもっと追加すれば改善できるだろうが、見栄えが良くなるずっと前に、すべてのビットを設定するより簡単な方法を探すことになるはずだ。結局、ペイントソフトで白黒ピクセルとして作り、テクスチャとして保存するより効率的な解法はほとんどないので、出発点に戻ることになる
現代の 3D レンダリングエンジンがテキストを描くより一般的な方法が気になるなら、SDF テキストや MSDF などの関連技法を調べるとよい。前処理段階で従来のテクスチャアトラスを使い、符号付き距離場アトラスを作る方式だ
その論文はハードウェアの話だが、ソフトウェアにも輪廻がある
以前この概念のごく基本的なバージョンを作ってみた: https://www.shadertoy.com/view/sdXBDs
すばらしいトリックではないという意味ではなく、実際すばらしいトリックだ
テキストをメッシュとしてレンダリングする選択肢もある。TextMeshPro はさらに一歩進んで、符号付き距離場を使って任意の倍率に対応する
https://docs.unity3d.com/Packages/com.unity.textmeshpro@4.0/...
例: https://sluglibrary.com
メッシュと SDF は GPU 側がはるかに単純だが、大きく拡大しすぎると精度が落ちることがあり、メッシュを小さく縮小しすぎるとエイリアシングが発生することがある
とても素晴らしい。「従来の」テクスチャ方式との性能比較があると面白そうだ
最近の GPU でこうした単純な処理をする場合、「性能はどうか?」という問いへの答えはたいてい「動く」なのだと思う
Sebastian Lague が、いくつかのフォントレンダリング技法を扱う良い動画を作っている
https://youtu.be/SO83KQuuZvg
似たような手法を試したことがありますが、フォントデータ全体をフラグメントシェーダーのソースコード内に入れていました。そうすると、
snprintfでCPUにマップされたGPUバッファへ直接出力できます。危険なやり方だとは分かっています個々の文字を頂点シェーダーで描く代わりに、全画面三角形を1つだけ描き、UV座標の代わりに
gl_FragCoordを使いました。最も効率的な方法ではありませんが、デバッグ機能であり、実用上は十分高速でしたファイル名とは違って、NESではなくIBM PC ROMのフォントを使っています。「NES font」や他の8x8ピクセルフォントはWebで見つけられます
https://github.com/rikusalminen/triangles/blob/nesfont/shade...
このゲームは興味深いことに、問題と解答のデータを8トラックテープに保存していました
すごいですね。自分で試したことのないテキストレンダリングアルゴリズムは、なかなか見かけません。自分のスタートアップでいろいろ実装しましたが、私は解像度非依存性とアンチエイリアシングが必要だったので、この方式は役に立たなかったでしょう
すべてのベジェ曲線フォントファイルに一般化できるとは限りません。曲線をピクセルに変換する作業は、特にグリフが自己交差する場合に難しくなり得ます。全体として標準的なテキストレンダリングは解決済みに感じられ、非標準のユースケースは試すにはかなり過酷です
この方式は概念的には、私が好きなWill Dobbieの方法に似ているように見えます。ただし、ずっと単純です。どちらも生のフォントデータを受け取り、シェーダーで直接使います。違いは、この方式がピクセルデータを配列に保存するのに対し、WillはSVGパスデータを「ベクターテクスチャ」として保存する点です
興味があれば、Willの素晴らしいデモがあります: https://wdobbie.com/warandpeace/
以前こういうことを試してみようかと考えましたが、GPUはテクスチャレンダリングに特に効率的で、ビット操作には相対的に遅いと理解していました。ここでメモリを少し節約できたとしても、実際にアトラスを使うより速いのか気になります
通常のテクスチャにビットパッキングしておき、フラグメントシェーダーにデコードさせれば、両方の利点を得られるかもしれません
現代のコンピュータグラフィックスをほとんど知らないので純粋な質問ですが、小さなテクスチャをGPUにアップロードするコストはそんなに大きいのでしょうか。文字列全体を2Dテクスチャにレンダリングしてから、そのテクスチャを2つの三角形に表示すればよいのではないかと思いました
逆にフォントアトラスを生成するコードを書くか、既存のアトラスを探して読み込む必要があり、そのためにはローディングコードも必要です。あるいはメッセージ全体をテクスチャに描画し、メッセージが変わるまでその結果をキャッシュする必要があります
さらにリソース管理とバインディングまで必要ですが、この方式ではリソースが要りません。つまり、一般的なテキストの解法ではなく、画面にデバッグテキストを出すための手法です
参考までに、ほとんどのブラウザやOSはテクスチャにテキストを描く方式で動作しています。フォントを動的にテクスチャアトラスへ描き、そのアトラスのグリフを使ってアプリウィンドウの一部のテクスチャをさらに作ります。ブラウザでテクスチャ境界を表示するとすべてのテクスチャを見ることができ、Rendering->Layer bordersは各テクスチャを青緑色で縁取り表示します
相対的に遅いCPUに独立したテクスチャのテキストボックスを大量にレンダリングさせると、すぐに積み重なって予算を食いつぶす可能性があります
グリフアトラスを使って描画するほうが、今でもリソース利用の面でははるかに優れています。現代のテキストレンダリングパイプラインは、拡大・縮小時のグリフの可読性を高めるためにSDFやエンコードされたベジェ曲線をよく使い、これもメモリをさらに節約できる良い方法です
アップロードの観点では、結局Xバイトのグリフがあり、何らかの形でGPUメモリに入る必要があります。テクスチャであれユニフォームデータであれシェーダー定数であれ、性能面で大きな差はありません。むしろ原文のようにシェーダー定数として入れると、すべての定数宣言をシェーダーコンパイラが処理しなければならないため、CPU側のコストが大きくなる可能性があります
GPU側で重要なのは、グリフデータを読むときにどのメモリ階層に触れるかです。テクスチャフェッチはほとんどのGPUで専用のL1キャッシュを使い、おそらく通常のL1キャッシュより大きいでしょう。データの順序も重要です。テクスチャは通常、ピクセルブロックをシェーディングするときのキャッシュミスを避けるため、Morton順序の変種で保存されます。実用的なアトラスベースのテキストレンダラーなら、テクスチャを使うのがよいでしょう
追記: 質問を読み違えていました。GPUで個別のグリフを描くことと、CPUでテキストブロック全体を描くことの比較であれば、これは速度と空間のトレードオフです。テキストにどれだけメモリを使うか、テキストが変わるか、文字ごとの効果が必要かなどによって答えは変わります
付け加えると、「テクスチャなし」と呼んではいますが、この方式もテクスチャです。テクスチャが別の形式と別の場所に保存されているだけです。本当のテクスチャなしフォントレンダリングは、ベクター曲線をその場で評価します
シェーダーにビットマップを保存しないと言っておきながら、まさにシェーダーにビットマップを保存する方法を説明している点がかなり紛らわしい
要するに、ビットマップフォントをシェーダーに埋め込んだということ
実行時に読み込む必要がある別ファイルにデータを保存することと、ソースコードにデータを直接含めることの違いに例えられます