1 ポイント 投稿者 GN⁺ 2024-11-11 | 1件のコメント | WhatsAppで共有
  • レンダーパス内でデバッグメッセージを素早く出したい場面では、フォントアトラスを用意する負担が大きいため、フラグメントシェーダ定数だけでテキストを描く方式が有用
  • グリフは 8x16 ピクセルのビットマップで表現され、printable ASCII 96 文字を 1536 バイトuvec4 配列に格納し、シェーダ内で直接参照する
  • フォントデータは PSF1 ターミナルフォントから、4 バイトのヘッダと 512 バイトの非出力グリフ領域を飛ばした後、続く 96 個のグリフを抽出して作成できる
  • レンダリングは 4 文字単位の uint32_t と位置・スケールを含む per-instance 構造体を使い、全文字列を 単一の instanced draw call で描画する
  • 単純なデバッグ出力に焦点を当てた手法のため、4 文字パディング、\0 fill 文字の discardエンディアン補正といった制約をアプリケーションとシェーダの両方で処理する必要がある

フォントアトラスの代わりにシェーダ定数を使う

  • 一般的なテキストレンダリングでは、使用可能なグリフをフォントアトラスにレンダリングし、それをテクスチャとしてバインドしたうえで、各グリフに対応する三角形を描く方式が取られる
  • imguistb_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 特性を持たなければならないため、vec3uint を 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 & 10, 0, 1, 1 のシーケンスを作る
    • 9 >> gl_VertexIndex & 11, 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件のコメント

 
GN⁺ 2024-11-11
Hacker News の意見
  • 自分で試してみたいなら、計算を追っていけばよい。ShaderToy ならゼロから実装するのがとても簡単で、こういうものが好きなら土曜の朝の遊びとしても楽しい
    ゼロから作るのは楽しいが、最初のヒントが必要なら、さっき作った例がある: https://www.shadertoy.com/view/Mc3cW2
    ほかの人たちが作った賢いテキストハックもたくさんあり、300文字未満の Matrix の例 https://www.shadertoy.com/view/llXSzj や、緑色の CRT 表示効果 https://www.shadertoy.com/view/XtfSD8 などもある

    • 即時モードのテキストレンダリングを試すたびに、小さいサイズでテキストをきれいに見せることができなかった。最初の ShaderToy でも vec2(30, -30) の 30 を 300 に変えるとアーティファクトが見える
      きちんと処理するコツがあるのか気になる。自分の場合はフラグメントシェーダー内でテクスチャをマルチサンプリングするのが最も効いたが、それでも最新水準ほど良くはなかった
    • Unity のゲーム開発をしているが、この分野は本当に逆行しているように感じる。数年前、ある人が作った最高のフォントレンダリングツールを Unity が買収して標準機能に組み込み、その後開発はほぼ止まり、競合するフォントレンダリング市場も死んでしまった
      少し前にネイティブのコンソールフォントのように見えるアプリを作ろうとしたが、90% くらいまで合わせるだけで 2時間以上いじる必要があった
    • 話題からは外れるが興味深い例として、HTML/CSS/JS で作った Matrix 効果が 1024バイトに収まる: https://codegolf.stackexchange.com/a/17414
  • 奇抜でハック的で楽しい。実際、ほとんどすべての 3D レンダリング技法がそうだが、成果物が昔の電子掲示板を再現しようとしているのでなければ、特に美しいわけではない
    ビットをもっと追加すれば改善できるだろうが、見栄えが良くなるずっと前に、すべてのビットを設定するより簡単な方法を探すことになるはずだ。結局、ペイントソフトで白黒ピクセルとして作り、テクスチャとして保存するより効率的な解法はほとんどないので、出発点に戻ることになる
    現代の 3D レンダリングエンジンがテキストを描くより一般的な方法が気になるなら、SDF テキストや MSDF などの関連技法を調べるとよい。前処理段階で従来のテクスチャアトラスを使い、符号付き距離場アトラスを作る方式だ

    • 「出発点に戻る」という話に合わせて、まだ見ていなければ 1968年の論文 On the Design of Display Processors は読む価値がある: http://cva.stanford.edu/classes/cs99s/papers/myer-sutherland...
      その論文はハードウェアの話だが、ソフトウェアにも輪廻がある
    • SDF テキストや MSDF のような方式は、もう少なくとも一世代前の方式だ。最近はほとんどが https://sluglibrary.com のようなアプローチを使い、シェーダーでフォントのベジェ曲線を直接ラスタライズする
      以前この概念のごく基本的なバージョンを作ってみた: https://www.shadertoy.com/view/sdXBDs
    • たとえばテクスチャのアップロードがうまくいかない状況では、デバッグテキスト用としてかなり賢い。ただし原文がスプライトシートを16世紀の手動活版組版になぞらえているのはかわいいが、現実には印刷助手が金属活字を組むには1時間かかり、GPU にスプライトシートをアップロードするには 10ms もかからず、その後は無限に設定できる
      すばらしいトリックではないという意味ではなく、実際すばらしいトリックだ
  • テキストをメッシュとしてレンダリングする選択肢もある。TextMeshPro はさらに一歩進んで、符号付き距離場を使って任意の倍率に対応する
    https://docs.unity3d.com/Packages/com.unity.textmeshpro@4.0/...

    • さらに進めば、GPU 上でフォント曲線を直接評価する方式も可能で、倍率や遠近に関係なく高品質を出せる。効率的に実装するのは非常に難しいが、可能ではある
      例: https://sluglibrary.com
      メッシュと SDF は GPU 側がはるかに単純だが、大きく拡大しすぎると精度が落ちることがあり、メッシュを小さく縮小しすぎるとエイリアシングが発生することがある
  • とても素晴らしい。「従来の」テクスチャ方式との性能比較があると面白そうだ
    最近の GPU でこうした単純な処理をする場合、「性能はどうか?」という問いへの答えはたいてい「動く」なのだと思う

    • 「性能はどうか?」に対して自分が望む答えは、「VSCode が自分の VRAM を数百 MB から数 GB も食わなくなる」だ
  • 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...

    • 私がいちばん好きなピクセルフォントパックはこれです: https://int10h.org/oldschool-pc-fonts/
    • 最近になってようやく、「NES」フォントが1976年のアーケードゲームQuiz Showに由来することを知りました。このフォントは他のモノクロのKee/Atariゲームでも使われており、フォントデータはquizshowのMAME ROMセットに入っていますが、なぜかニブル単位に分割されています
      このゲームは興味深いことに、問題と解答のデータを8トラックテープに保存していました
  • すごいですね。自分で試したことのないテキストレンダリングアルゴリズムは、なかなか見かけません。自分のスタートアップでいろいろ実装しましたが、私は解像度非依存性とアンチエイリアシングが必要だったので、この方式は役に立たなかったでしょう
    すべてのベジェ曲線フォントファイルに一般化できるとは限りません。曲線をピクセルに変換する作業は、特にグリフが自己交差する場合に難しくなり得ます。全体として標準的なテキストレンダリングは解決済みに感じられ、非標準のユースケースは試すにはかなり過酷です
    この方式は概念的には、私が好きなWill Dobbieの方法に似ているように見えます。ただし、ずっと単純です。どちらも生のフォントデータを受け取り、シェーダーで直接使います。違いは、この方式がピクセルデータを配列に保存するのに対し、WillはSVGパスデータを「ベクターテクスチャ」として保存する点です
    興味があれば、Willの素晴らしいデモがあります: https://wdobbie.com/warandpeace/

  • 以前こういうことを試してみようかと考えましたが、GPUはテクスチャレンダリングに特に効率的で、ビット操作には相対的に遅いと理解していました。ここでメモリを少し節約できたとしても、実際にアトラスを使うより速いのか気になります
    通常のテクスチャにビットパッキングしておき、フラグメントシェーダーにデコードさせれば、両方の利点を得られるかもしれません

    • その理解はかなり古いものです。ここ15年ほどのGPUでは、テクスチャ参照はビット演算よりおおよそ100倍遅いです
  • 現代のコンピュータグラフィックスをほとんど知らないので純粋な質問ですが、小さなテクスチャをGPUにアップロードするコストはそんなに大きいのでしょうか。文字列全体を2Dテクスチャにレンダリングしてから、そのテクスチャを2つの三角形に表示すればよいのではないかと思いました

    • コストは大きくありません。この手法は性能というより、デバッグ用テキストをできるだけ簡単に画面へ出すためのものに近いです。シェーダーに少しデータを追加すれば、すぐにテキストが出ます
      逆にフォントアトラスを生成するコードを書くか、既存のアトラスを探して読み込む必要があり、そのためにはローディングコードも必要です。あるいはメッセージ全体をテクスチャに描画し、メッセージが変わるまでその結果をキャッシュする必要があります
      さらにリソース管理とバインディングまで必要ですが、この方式ではリソースが要りません。つまり、一般的なテキストの解法ではなく、画面にデバッグテキストを出すための手法です
      参考までに、ほとんどのブラウザやOSはテクスチャにテキストを描く方式で動作しています。フォントを動的にテクスチャアトラスへ描き、そのアトラスのグリフを使ってアプリウィンドウの一部のテクスチャをさらに作ります。ブラウザでテクスチャ境界を表示するとすべてのテクスチャを見ることができ、Rendering->Layer bordersは各テクスチャを青緑色で縁取り表示します
    • 今でもGPUメモリを無駄遣いしすぎないほうが、たいていは良いです。大きなテキストボックステクスチャはPCIバス経由で転送する必要があり、アップロードのタイミングやGPUリソースの退避状況によっては停止が発生することがあります
      相対的に遅いCPUに独立したテクスチャのテキストボックスを大量にレンダリングさせると、すぐに積み重なって予算を食いつぶす可能性があります
      グリフアトラスを使って描画するほうが、今でもリソース利用の面でははるかに優れています。現代のテキストレンダリングパイプラインは、拡大・縮小時のグリフの可読性を高めるためにSDFやエンコードされたベジェ曲線をよく使い、これもメモリをさらに節約できる良い方法です
    • N個の文字を覆う四角形を1つ描き、シェーダーでグリフを選び出す方式は、各文字ごとに個別の四角形を描くより速いはずです。少なくとも等幅フォントではそうです。ただし画面を埋められる文字数には限界があるので、実際の差は大きくない可能性が高いです
      アップロードの観点では、結局Xバイトのグリフがあり、何らかの形でGPUメモリに入る必要があります。テクスチャであれユニフォームデータであれシェーダー定数であれ、性能面で大きな差はありません。むしろ原文のようにシェーダー定数として入れると、すべての定数宣言をシェーダーコンパイラが処理しなければならないため、CPU側のコストが大きくなる可能性があります
      GPU側で重要なのは、グリフデータを読むときにどのメモリ階層に触れるかです。テクスチャフェッチはほとんどのGPUで専用のL1キャッシュを使い、おそらく通常のL1キャッシュより大きいでしょう。データの順序も重要です。テクスチャは通常、ピクセルブロックをシェーディングするときのキャッシュミスを避けるため、Morton順序の変種で保存されます。実用的なアトラスベースのテキストレンダラーなら、テクスチャを使うのがよいでしょう
      追記: 質問を読み違えていました。GPUで個別のグリフを描くことと、CPUでテキストブロック全体を描くことの比較であれば、これは速度と空間のトレードオフです。テキストにどれだけメモリを使うか、テキストが変わるか、文字ごとの効果が必要かなどによって答えは変わります
    • アップロード前に文字列全体をレンダリングすることはできますが、そうすると本質的にCPUレンダリングを使うことになるので、GPUが同じことをするより遅いでしょう
      付け加えると、「テクスチャなし」と呼んではいますが、この方式もテクスチャです。テクスチャが別の形式と別の場所に保存されているだけです。本当のテクスチャなしフォントレンダリングは、ベクター曲線をその場で評価します
    • アプリケーションによります。特に右から左へ書く文字、CJK、絵文字に遭遇し得るなら、これが最も簡単な方式です。ほとんどのテキストは毎フレーム変わらないのでテクスチャをキャッシュする価値があり、私たちには十分です
  • シェーダーにビットマップを保存しないと言っておきながら、まさにシェーダーにビットマップを保存する方法を説明している点がかなり紛らわしい
    要するに、ビットマップフォントをシェーダーに埋め込んだということ

    • いや、テクスチャにビットマップを保存しないと言っているだけです。シェーダーコードに直接埋め込むのとは別の話です
      実行時に読み込む必要がある別ファイルにデータを保存することと、ソースコードにデータを直接含めることの違いに例えられます