WebGPU APIのRust実装であるwgpuをベースにした2D GPUレンダラーのプロトタイプ実装を試みる中、ぶち当たった大きな壁の一つがテキストレンダリングでした。

GPUでどうテキストを描画するか

文字は曲線が成す複雑な図形です。テキストを描画することは簡単ではありません。

テキストのレンダリングをGPUに任せたい場合、大きく分けて2つのアプローチが考えられます。

  • ベクター描画方式:文字(グリフ)の輪郭データをGPUに送り、GPUで図形として描画する
  • ビットマップ方式:文字を並べたビットマップ画像をGPUにテクスチャとして送り、描画したい文字が描かれた部分だけ切り出して使う

ベクター描画方式

グリフのアウトライン(ベジエ曲線など)を直接GPU上で描画するシェーダーを実装する方法です。

拡大しても滑らかな形状が得られますが、動作は滑らかにならないリスクがあります。GPUとはいえ、リアルタイムに曲線を描くのは重い処理だからです。そしておそらく、実装者にとっても心が重い複雑な実装になります。

ビットマップ方式

文字の形状の描画が重いのなら、一度描画した文字を使い回せるようにできないでしょうか?

そこで、予め必要な文字を画像に描画しておき、GPUではそれをテクスチャとして描画したい場所に貼り付けるだけ、という方式が考えられます。

この方法はパフォーマンス面で有利になりますが、可変な文字サイズを求められる場面ではデメリットが目立ちます。ビットマップ画像は拡大するとジャギーが目立つため、どんなサイズでも綺麗な表示を維持したいのなら、フォントサイズごとに別画像を用意するしかないのです。

ビットマップ方式の改良:SDF化

文字を使い回せる画像を用意し、GPUではそれをテクスチャとして使って描画するだけ、というビットマップ方式のアプローチはそのままに、可変なフォントサイズに対応するため、もうひと手間加えた手法が考え出されました。それがSDFテキストレンダリングです。

SDFSigned Distance Field:符号付き距離関数)とは、文字の輪郭からの距離を画素ごとに保持したグレースケール画像です。

NOTE

SDFは矩形の描画などにも使えるもっと広い概念ですが、ここではテキストレンダリングに使う場合に絞った表現にしています。

SDFの各画素が持つ値は、文字の輪郭(エッジ)に近いほど0.5に近く、離れるほど0または1に向かいます。

シェーダー(GPU上で動作するプログラム)では、この距離情報をテクスチャから取り出し、輪郭に近いほど濃く、遠いほど薄く、色を乗せていきます。

イメージとしては、輪郭を線として描くのではなく、濃淡で表現するようなアプローチです。
このような実装により、拡大してもボケたりギザギザが目立ったりしない、滑らかな境界(輪郭)を実現できます。

SDFテキストレンダリングの実装

実際に、OSウィンドウにSDFテキストレンダリングを行う簡単な実装を試してみました。

大まかには、次のような手順の実装です。

1. フォントファイルから各文字(グリフ)を取り出す

まずはCPU側での準備です。
.ttf.otfなどのフォントファイルから、使いたい文字を読み込み、それぞれの形状(アウトライン)を取得します。

私の実装コードにはttf_parserというRustライブラリだけを使って足掻いていた痕跡がありますが、
一般的にはFreeTypeなどのライブラリを使うのが妥当でしょう。

2. 取り出した文字をテクスチャに格納(アトラスの作成)

各文字を1つずつ画像(ビットマップ)に変換して、それらを1枚の大きな画像(テクスチャアトラス)にまとめます。
描画時に毎回別々の文字画像を読み込むのは非効率なので、1つの画像にまとめておくことで高速に描画できるようにします。

複数の文字を1枚の大きな画像(アトラス)に配置する際には、なるべく多くの文字を1枚のアトラス画像に格納できるよう、効率的な配置を決める必要があります。
シンプルなアルゴリズムとしては、次の記事で示されている実装が興味深かったです。

とはいえ、アルゴリズムについては深追いしなくても、Rustではetagereというライブラリを使用して実装できます。
etagereでは、アルゴリズムを実行した結果、どのように領域を割り当てているかをSVG形式で出力する機能もあり、視覚的な理解とデバッグに役立ちます。

3. アトラス内の各グリフをSDF化する

ここまではビットマップ方式の実装と同様ですが、SDFテキストレンダリングではもう一手間加えます。

SDF(符号付き距離関数)とは、「グリフの輪郭までの距離をピクセルごとに記録した画像」のことでした。
「符号付き」という名称になっているのは、輪郭の内側か外側かを符号で区別するからです。

  • 輪郭の内側:正の距離
  • 輪郭の外側:負の距離

具体的には、各グリフの周辺ピクセルに対して、輪郭からの最短距離を計算し、明るさ(グレースケール値)として画像に記録します。

この距離計算とSDF画像の作成はCPU側で実装しましたが、コンピュートシェーダで距離計算をGPUに任せるような改良も可能かもしれません。

4. アトラステクスチャをシェーダーに送って描画する

テクスチャアトラス(SDF化された文字画像)と文字ごとの位置・UV情報を使って、GPUで描画します。

具体的には、WebGPU APIでテクスチャアトラスをGPUに送り、フラグメントシェーダーでピクセルのSDF値を参照して「輪郭に近いかどうか」を判定し、滑らかなアルファブレンドを行います。

@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4f {
  let g = text.glyphs[in.instance];
 
  // textureSample.a : 矩形を描画
  // textureSample.r : 文字を描画
  let distance = textureSample(atlas, atlas_sampler, in.uv).r;
 
  var width = mix(0.4, 0.1, clamp(g.font_size, 0.0, 40.0) / 40.0);
  width /= 2.0; // TODO: apply dpr
  let alpha = g.color.a * smoothstep(0.5 - width, 0.5 + width, distance);
 
  return vec4f(g.color.rgb, alpha);
}

SDFとシェーダーが成す柔軟性

SDFテキストレンダリングのメリットは、なんといってもその柔軟性です。

1つのSDFテクスチャで、どんなフォントサイズにも対応できます。
また、シェーダーで少し調整するだけで、アウトライン(縁取り)、シャドウ(影)、グロー(発光)などの視覚効果も簡単に実現できます。

すべてを解決できるわけではない

しかし、テキストを扱う以上、やはり大きな壁となるのが日本語の存在です。

日本語フォントに対応しようとすると、ひらがな、カタカナ、漢字などもすべて格納する巨大なアトラス画像が必要になるでしょう。
表示するテキストが予め決まっているゲーム等では、アトラスに格納する文字を限定できますが、テキストエディタやブラウザなどの場合、どう対応しているのか興味深いものです…

問題は日本語だけではありません。途方もない世界なのは目に見えているので、しばらくは深追いしないことにします。