科学&テクノロジー

GPU で計算された距離を使用したテキストのレンダリングと効果

11月 12, 2025 / nipponese

1762920772
2025-11-12 00:56:00

テキストレンダリングは、 呪われた。テキストに取り組んだことがある人なら誰でも同じことを言うでしょう。レイアウト、双方向、整形、Unicode、またはレンダリング自体に関する問題であっても、完全に解決された問題というわけではありません。私の個人的なケースでは、クリエイティブ コンテンツの合成エンジンのコンテキストでテキストをレンダリングすることに取り組んできました。クレイジーなテキスト効果が必要であり、それらが適度に高速である必要がありました。これは、可能な限り GPU で動作することを意味します。距離フィールドはアンチエイリアシングのロックを解除し、基本的に無料で多くの優れた効果を作成できるため、距離フィールドは明らかな要件でした。

この記事では、特にモバイル デバイスを対象とする場合、CPU で計算するよりもはるかに高速な GPU で符号付き距離フィールドを計算する方法を説明します。アルゴリズムを適切に高速化し、制限について嘆いた後、これがどのような効果をもたらすかを見ていきます。

Mochiy Pop One フォントの「あ」グリフのプログレッシブ ビルド

グリフの輪郭の抽出

非ビットマップ フォントには、一連の閉じた線と (2 次または 3 次の) ベジェ曲線で構成されるアウトラインによって定義されたグリフが含まれています。それらの抽出はそれほど複雑ではありません。 フリータイプ または ttf-パーサー 通常、それを行う方法を公開します。

この記事の目的のために、シェーダー内のベジェ曲線のリストをハードコーディングしますが、もちろん、より本格的なセットアップでは、それらはストレージ バッファーなどを介してアップロードされます。

使用する この小さなプログラム、グリフは一連のアウトラインとして固定サイズの配列にダンプできます。

struct Bezier {
    vec2 p0; // start point
    vec2 p1; // control point 1
    vec2 p2; // control point 2
    vec2 p3; // end point
};

#define N 42
#define NC 2
const int glyph_A_count[2] = int[](33, 9);
const Bezier glyph_A[42] = Bezier[](
    Bezier(vec2(  0.365370,  -0.570817), vec2(  0.374708,  -0.631518), vec2(  0.332685,  -0.687549), vec2(  0.339689,  -0.748249)),
    Bezier(vec2(  0.339689,  -0.748249), vec2(  0.339689,  -0.748249), vec2(  0.344358,  -0.764591), vec2(  0.351362,  -0.771595)),
    Bezier(vec2(  0.351362,  -0.771595), vec2(  0.384047,  -0.822957), vec2(  0.402724,  -0.855642), vec2(  0.442412,  -0.885992)),
    // ...
);

glyph_A_count グリフを構成する各サブシェイプに存在するベジェ曲線の数が含まれます。 glyph_A には、ベジェ 3 次曲線のリストが含まれています。

グリフも直線と二次曲線で構成されていますが、それらをすべて 3 次曲線に拡張します。「より多くのことができる人は、より少なくできる」ということです。

これらの公式を使用して、直線と二次関数をそれぞれ三次関数に拡張します。

begin{aligned} B_1 &= begin{bmatrix} P_0 \ mathrm{mix}(P_0, P_1, 1/3) \ mathrm{mix}(P_0, P_1, 2/3) \ P_1 end{bmatrix} \ B_2 &= begin{bmatrix} P_0 \ mathrm{mix}(P_0, P_1, 2/3) \ mathrm{mix}(P_1, P_2, 1/3) \ P_2 end{bmatrix} end{aligned}

どこ P_n はベジェ制御点です。

わかりやすくするため、また最も複雑なケースを十分にテストすることを確認したいため、この記事ではこのアプローチに固執します。しかしそれは、さらなる最適化の余地がたくさんあることも意味します。線形および二次方程式を解く方がはるかに簡単であるため、これは読者の演習として残されています。

警告

シェーダでの計算を節約するために、これらのカーブの多項式形式を直接アップロードしたくなるかもしれません。やめてください。多項式の端が 1 つ評価されるため、正確なステッチング プロパティが失われます。 B_n(1) 次の多項式の開始と一致する必要はありません B_{n+1}(0)。これにより、人為的な「精密穴」が生じ、不明瞭な方法でレンダリングが中断されます。

形状までの符号付き距離

前の記事、3次ベジェ曲線までの距離を取得する方法を見てきました。各グリフは複数のアウトラインで構成されているため、それらすべてを単純に実行して最短距離を選択することができます。

float get_distance(vec2 p, Bezier buf[N], int counts[NC]) {
    int base = 0;
    float dist = 1e38;

    for (int j = 0; j 

どこ bezier_sq() で定義されているベジェ曲線までの距離の 2 乗です。 前の記事

Virgil フォントから「A」グリフまでの距離

これは問題なく機能しますが、ご想像のとおり、ピクセルごとに非常に多くの距離を解決するのは安価ではありません。最初の簡単な最適化は、現在の最適な距離よりも遠い境界ボックスを持つ曲線を無視することです。これは、どの曲線もより短い距離を与えることができないためです。

ボックス距離の最適化

各ボックスは次のようにベジェ曲線を囲みます。

最も素朴/保守的な境界ボックス

より厳しい境界を使用することもできますが、より多くの計算が必要になるため、これは良いトレードオフであると感じました。

これを内部ループに実装するのは非常に簡単です。

for (int i = 0; i  dist)
        continue;

    float d = bezier_sq(p, p0, p1, p2, p3);
    dist = min(dist, d);
}

境界ボックスの式までの距離は次から得られます。 Inigo Quilez による説明ビデオ (内部距離のない基本的なもの)、ベジェ制御点座標に適応されます。

これにより、特定のケースでは多くの計算が節約されますが、この「C」グリフのヒート マップが示すように、最悪のケースでも依然としてかなりひどい状況になります。

評価される距離のヒート マップ

実際、場合によっては、他の曲線のほとんどを無視できるほど小さい、良好なベジェ曲線に到達するまでに長い時間がかかることがあります。形状の始まりから遠ざかるほどそれを観察します。

したがって、次のステップは、適切な初期候補を見つけることです。これを行うための安価な方法の 1 つは、まず各曲線の中心点までの距離を計算し、最小のものを選択することです。

// Find a good initial guess
int best = 0;
float boxd = 1e38;
for (int i = 0; i 

この最適化はすぐにヒート マップに反映され、中心点のみが臨界点になるように見えます (このグリフは円を形成するため、異常なケースです)。

大まかな初期推定を含むヒート マップ

巻数

最後のステップは、形状の内側にいるのか外側にいるのかを判断することです。ここには学校が2つありますが、 偶数-奇数 そして ゼロ以外の ルール。フォント レンダリングの場合は後者が想定されるため、後者を選択します。

ベジェ曲線の分解、その特定のアルゴリズムの理論については説明したので、詳細については再度説明しません。基本的な考え方は、現在の位置から一方向に光線を照射し、指定された曲線を何回横切ったかを取得することです。ここでは水平光線を任意に選択します。 y = P_y ここで、P は現在の座標です。

各曲線のトポロジーは、それを検討する価値があるかどうかのヒントを与えてくれます。たとえば、すべてのコントロール ポイントが現在位置の上または下にある場合、それは無視できます。すべての標識をマスクに保存し、光線が曲線の境界ボックスの完全に下または完全に上に来るとすぐに救済することができます。

int signs = int(p0.y 

各記号は、光線に対する制御点の位置を示します。開始点の相対位置を全体の方向の基準として使用できます (交差点がある場合は、それが下から来るか上から来ることがわかります)。

int inc = (signs & 1) == 0 ? 1 : -1;

また、ベジェ曲線を通常の多項式に変換する必要があります。
at^3+bt^2+ct+d:

vec2 a = -p0 + 3.*(p1 - p2) + p3,
     b = 3. * (p0 - 2.*p1 + p2),
     c = 3. * (p1 - p0),
     d = p0 - p;

次に、y 根を見つけて、x 軸上のすべての点を確認します。すべての交差点 (最大 3 つ) で、符号を切り替えます。

    float r[5];
    int count = root_find3(r, a.y, b.y, c.y, d.y);
    vec3 t = vec3(r[0], r[1], r[2]);
    vec3 v = ((a.x*t + b.x)*t + c.x)*t + d.x;
    if (count > 0 && v.x >= 0.) w += inc;
    if (count > 1 && v.y >= 0.) w -= inc;
    if (count > 2 && v.z >= 0.) w += inc;

前回の記事で既に 5 次のルート ファインダーを作成しているため、3 次の小さなバージョンを構築するだけです。

int root_find3(out float r[5], float a, float b, float c, float d) {
    float r2[5];
    int n = root_find2(r2, 3.*a, b+b, c);
    return cy_find5(r, r2, n, 0., 0., a, b, c, d);
}

注記

私たちのルートファインダーはルートを外部に返しません [0,1] したがってフィルタリングは必要ありません。

要約すると:

int bezier_winding(vec2 p, vec2 p0, vec2 p1, vec2 p2, vec2 p3) {
    int w = 0;
    int signs = int(p0.y  0 && v.x >= 0.) w += inc;
    if (count > 1 && v.y >= 0.) w -= inc;
    if (count > 2 && v.z >= 0.) w += inc;
    return w;
}

すべてのサブシェイプについて、巻き数を累積し、最後にそれを使用して内側か外側かを決定できます。

float get_distance(vec2 p, Bezier buf[N], int counts[NC]) {
    int w = 0;
    int base = 0;
    float dist = 1e38;

    for (int j = 0; j 

そして、これが次のとおりです。

Virgil フォントから「A」グリフまでの符号付き距離

警告

この巻き数ロジックは脆弱すぎる可能性があります。水平接線/ルートの重複など、潜在的な縮退ケースはカバーされていません。しかし、何らかの理由で、私がこれらの問題と何年も戦っていた間、広範なテストでは奇妙な例外的なケースに問題が発生しないようでした。これはおそらく、ルートファインダーが以前使用していたものよりも回復力があったためです。

制限事項

邪悪な曲線

これは満足のいくものに見えるかもしれませんが、これは問題の始まりにすぎません。たとえば、可変長フォントは通常、次のような混沌としたパターンに従います。

Quicksand フォントのグリフ「e」

自己オーバーラップ部分に加えて、右側の逆折り三角形に注目してください。

これは距離フィールドを完全に破壊します。

重複により SDF が壊れたグリフ

単純な文字表示 (SDF で利用できる幅広い効果を利用していないものを意味します) であっても、不具合が発生し始めます。

SDF の破損によるグリフの不具合

重なり部分の周囲に小さな「亀裂」が現れるはずです。これは、ゼロクロッシングを避けるために距離を小さな定数だけ短くすることで軽減できますが、グリフ全体に影響を与えます (より太くなります)。

そして、それは可変長引数の問題だけが原因ではなく、設計者は単純化のためにオーバーラップに依存することがあります。

Quicksand フォントのグリフ「t」

そして時々…そうする正当な理由があるとしましょう:

ベンガル語の絵文字

これは簡単に対処できるものではありません。

たとえば、次の 2 つの重なった形状を考えてみましょう。

重なっている 2 つの図形内の距離

実際の距離 (白い円) は、どちらの形状への最小距離でも、エッジへの最小距離でさえないことがわかります。これは 2 つの曲線間の交点にありますが、その点はありません。ここでは線分を扱っていますが、3次曲線の場合、問題は爆発的に複雑になります。

この時点で、前処理されたアウトラインのみのカーブを GPU レンダラーに供給するなど、別の戦略が必要になります。多くの人は、この問題に対処するために曲線の平坦化に頼っています。残念ながら、これはまた別の研究分野であり、今回は取り上げません。

イニゴが話した 符号付き距離の組み合わせ いくつかのアイデアが必要な場合は、最初のアイデア (諦める) を除けば、ここでは特に当てはまるものはないようです。

アトラスと重なり合う距離

ブラーやグローなどの一部の効果は文字の境界を越えて拡張されるため、距離フィールドはグリフ自体よりも大きくする必要があります。これは、効果が広がりすぎると重複が生じることを意味します。単語に効果を与える場合、距離フィールドはすべての単語グリフ (場合によっては文) の統合バージョンでなければなりません。グリフ距離のアトラスの古典的なアプローチは確実に機能しません。

次の図では、グリフごとにジオメトリが使用されており、各ジオメトリはより大きな距離フィールドを考慮して拡大されており、エフェクトを適用するときに重複する可能性があります。

距離が遠いため、キャラクターのジオメトリが重なり合う

丸い角

すべての距離マップと同様に、同じ制限があります。最も一般的な問題は、角が丸くなる問題です。これは通常、次の方法を使用して対処されます。
マルチチャネル符号付き距離場ジェネレーター、しかし、GPU 上のポーテージにとってどれだけアクセスしやすいかを判断するのは困難です。


msdfgen によるコーナー改善のデモンストレーション

注記

この問題は中間テクスチャでのみ発生します。ここのように正確な距離をシェーダー内で直接計算する場合、これは問題になりません。

効果

これらすべての制限にもかかわらず、私たちはすでに多くのことができるので、前向きな気持ちでこの記事を閉じましょう。これはここでは行われませんが、距離フィールドを中間テクスチャに保存するとすぐに、これらのエフェクトはすべて無料になります。

まず、アンチエイリアス/ブラーがあります。

AA/ブラーエフェクト

私が書いた 専用の記事 それを達成する方法についてさらに詳しい情報が必要な場合は、SDF の AA (およびブラー) のテーマを参照してください。

「丸め」などの単純な演算子を使用して、形状を大幅に変更することもできます。

d -= rounding;
丸め効果

これは、以前にオーバーラップの不具合をカバーするために提案したテクニックと同じですが、エフェクトとしてブランド名を変更しただけです。

同じ精神で、アウトライン ストロークを作成することもできます (元のグリフ デザインを維持するために外側のエッジに)。

アウトライン効果

これは すごい 背景が何であってもテキストを表示できるので便利です。この機能を正しく実行するのは難しく、コストがかかるため、非常に多くのエディタがこの機能を備えていません。ただし、距離フィールドが与えられている場合、私たちがしなければならないことはこれだけです (これにはすべての境界線のアンチエイリアシングも含まれます)。

float aa = fwidth(d); // pixel width estimates
float w = aa * .5; // half diffuse width
vec2 b = vec2(0,1)*outline - d; // inner and outer boundaries; vec2(-1,0) for inner, vec2(-.5,.5) for centered
float inner_mask = smoothstep(-w, w, b.x); // cut-off between the outline and the outside (whole shape w/ outline)
float outer_mask = smoothstep(-w, w, b.y); // cut-off between the fill color and the outline (whole shape w/o outline)
float outline_mask = outer_mask - inner_mask;
vec3 o = (inner_color*inner_mask + outline_color*outline_mask) * outer_mask;

自分のキャラクターを掘り下げることもできます d = abs(d)-ring:

リングエフェクト

また、グローを適用してネオン効果を作成することもできます。

ネオン/グロー効果を組み合わせたリング
float glow_power = glow * exp(-max(d, 0.) * 10.);
o += glow_color * glow_power;

この距離を利用して、ドロップ シャドウ、あらゆる種類の歪み、その他多くのクリエイティブな方法を実行することもできます。高速な視覚効果が必要な場合には、これが基本であることがわかります。

結論

この記事は、2D レンダリングに関するシリーズの最終回です。私はこれらの問題について長年(ほとんど一人で)苦労してきた後、この経験と知識を共有したいと考えていました。業界標準と競合できる、優れた無料のオープンソースのテキスト効果レンダリング エンジンを提供できればよかったと思います。 (不)幸運なことに、私にとって冒険はここで終わりですが、これがこの主題に興味を持つクリエイターや将来のいじくり回し者にとって有益であることを願っています。

更新情報やより頻繁なコンテンツについては、私をフォローしてください
マストドン。購読もお気軽にどうぞ
RSS 新しい書き込みの通知を受け取るため。通常、他の手段を通じて私に連絡することも可能です (下のフッターを確認してください)。最後に、一部の記事に関する議論は、HackerNews、Lobste.rs、Reddit で見つかることがあります。

#GPU #で計算された距離を使用したテキストのレンダリングと効果