6. 図形と距離関数
前章ではプレイグラウンドを作りました。
フルスクリーンのクアッドが1枚と、全てのピクセルで1回ずつ実行され、自分の位置・時間・マウスを知っているフラグメントシェーダーです。
此れで何でも描けますが、「何でも」は計画ではありません。
此の章では、フラグメントシェーダーで図形を表現する最も便利な方法を学びます。
其れが距離関数です。
距離関数は、全てのピクセルで一つの質問に答えます。
「図形の境界までどれ位離れていて、内側と外側のどちらにいるのか?」
此の一つの数があれば、図形を塗り、境界を完璧に滑らかにし、輪郭・光彩・影を描き、粘土の様に図形同士をくっつける事が出来ます。
章の最後では、円・箱・線だけで小さなキャラクターを描き、其の目がマウスを追い掛ける様にします。
此の章で変更するのは shader/playground.glsl だけです。
Cのコードは変わらないので、テスターを起動したまま、編集する度にRキーを押して下さい。
6.1 距離で描く
全ての例は、第5章で作った座標から始まります:
vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
原点はウィンドウの中央、yは下端が−1で上端が1、xは800×600のウィンドウでは約−1.33から1.33です。
xとyで1単位の長さが同じなので、円は丸いままです。
では、原点を中心とする半径の円を考えてみましょう。
位置にあるピクセルは、中心からだけ離れています(ベクトルの長さ、線型代数の第3章)。
其処から半径を引くと、境界までの距離になります:
符号は、どちら側にいるかを教えてくれます:
- :外側。は図形に届くまでに歩く距離です。
- :丁度境界の上。
- :内側。は境界までの距離です。
此の様な関数を符号付き距離関数(Signed Distance Function、SDF)と呼びます。
で、数字で確かめてみましょう:
- :なので、。外側で、0.5離れています。
- :なので、。境界の上です。
- :なので、。内側で、境界まで0.3です。
GLSLでは1行です:
float sdCircle(vec2 p, float r) {
return length(p) - r;
}
length(v) はを返します(vec3 ならも)。
sqrt(dot(v, v)) と同じです。
名前の sd は「signed distance(符号付き距離)」の略です。
此の種の関数を書く人の殆どが使う命名の習慣なので、あたしも其れに従います。
何故わざわざ距離を使うのでしょうか?「内側かどうか」だけ聞けば良いのでは?
其れは、距離の方がずっと多くの情報を持っているからです。
「内側かどうか」はピクセル毎に1ビットしかありません。
距離なら境界にどれ位近いかが分かり、其れこそが滑らかな境界・輪郭・光彩・柔らかい影に必要な物です。
更に、図形同士を普通の min と max で組み合わせる事も出来ます。
此の章では其の全てを使います。
6.2 最初の円:step
距離を絵にするには、「内側なら1、外側なら0」が必要です。
其れをするのが step です:
step(0.0, d) は円の外側()で1、内側で0になるので、1.0 - step(0.0, d) を使い、背景色と前景色の間を mix で混ぜます(mix(a, b, t) = 、第3章)。
GLSLの殆どの関数と同じく、step もベクトルに対して成分毎に動作します。
#version 450 core
in vec2 vUv;
out vec4 out_color;
uniform float uTime;
uniform vec2 uResolution;
uniform vec2 uMouse;
void main() {
vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
float d = length(p) - 0.5; // 円の符号付き距離
float inside = 1.0 - step(0.0, d); // d < 0 なら1、そうでなければ0
vec3 bg = vec3(0.10, 0.10, 0.12);
vec3 fg = vec3(1.00, 0.75, 0.20);
vec3 color = mix(bg, fg, inside);
out_color = vec4(color, 1.0);
}
保存してRキーを押すと、円が出ます:
遠くから見れば問題無さそうですが、境界をよく見て下さい(下の画像の左半分は、此の円の境界を6倍に拡大した物です)。
全てのピクセルが完全に黄色か完全に暗いかのどちらかなので、境界が階段になっています。
此れをエイリアシングと言い、図形が動くとチラチラと揺れて見えます。
6.3 柔らかい境界:smoothstep
直すには、境界上のピクセルを部分的に塗れば良いです。
半分だけ円の中に入っているピクセルは、2色の丁度中間の色にするべきです。
第5章のマウスの円で既に smoothstep を使いましたが、ここで実際に何を計算しているのか見てみましょう。
6.3.1 公式
smoothstep(e0, e1, x) は、x が e0 から e1 まで動く間に0から1まで変化します。
先ず x を0〜1の値(真っ直ぐな傾斜)に変換し、次に其の傾斜をS字カーブに曲げます:
clamp(x, lo, hi) は値を範囲内に切り詰めます。
min(max(x, lo), hi) と同じです。
min と max は二つの値の小さい方と大きい方を返します(ベクトルなら成分毎)。
何故真っ直ぐな傾斜ではなくなのでしょうか?
多項式は傾斜と同じくで0、で1ですが、其の傾き()も両端で0になります。
従って、カーブは0から穏やかに離れ、1に穏やかに到着し、目に見える折れ目がありません。
境界では殆ど違いが分かりませんが、アニメーションやノイズ(第8章)では大きな違いになります。
計算例:smoothstep(0.25, 0.75, 0.6)。
GLSLの仕様では、e0 >= e1の時のsmoothstepの結果は未定義です。逆向きのカーブを得る為にsmoothstep(0.01, 0.0, d)と書いているコードをよく見掛けます。殆どのドライバーでは偶々動きますが、保証はされていません。代わりに1.0 - smoothstep(0.0, 0.01, d)と書いて下さい。
6.3.2 1ピクセル分のぼかし
柔らかい境界の幅はどれ位にするべきでしょうか?
大体1ピクセルです。
此の座標ではウィンドウの高さ(uResolution.y ピクセル)が2単位なので、1ピクセルは
単位です(800×600のウィンドウの場合)。
境界の1ピクセル内側から1ピクセル外側までを混ぜます:
void main() {
vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
float d = length(p) - 0.5;
float px = 2.0 / uResolution.y; // 1ピクセルの大きさ(pの単位)
float inside = 1.0 - smoothstep(-px, px, d); // 境界の前後1ピクセルで滑らかに
vec3 bg = vec3(0.10, 0.10, 0.12);
vec3 fg = vec3(1.00, 0.75, 0.20);
vec3 color = mix(bg, fg, inside);
out_color = vec4(color, 1.0);
}
上の拡大画像の右半分が其の結果です。
階段が消え、境界のピクセルは中間の色になり、普通の大きさで見ると円は完全に丸く見えます。
6.4 何時でも合うアンチエイリアス:fwidth
2.0 / uResolution.y が正しいのは、d が此の p の単位で測られている間だけです。
次章では座標を拡大縮小したり歪めたりするので、其の時には1ピクセルが d の0.0033単位とは限らなくなります。
GPUに直接「此のピクセルから隣のピクセルまでに d がどれ位変わるか?」と聞けた方が良いですね。
GPUは其れに答えられます。
フラグメントシェーダーは決して1つだけで実行されないからです。
フラグメントは必ず2×2ピクセルの小さなブロック単位で処理され、ブロック内のシェーダーは足並みを揃えて実行されます。
其の為、シェーダーは隣のピクセルでの式の値を覗く事が出来ます:
dFdx(v):此のピクセルから右隣のピクセルまでにvがどれ位変わるか(大体v(x + 1, y) - v(x, y))。dFdy(v):同じく、上方向。fwidth(v):abs(dFdx(v)) + abs(dFdy(v))、1ピクセルでのvの変化の合計。
abs(x) は絶対値です(ベクトルなら成分毎)。
此の円では fwidth(d) は約0.0033〜0.0047になります(境界を横切る方向に1ピクセル分距離が変わり、斜めだと少し多くなります)。
ウィンドウの大きさや、其の前に座標に何をしたかに関係無くです。
此れで、シリーズの残り全体で使うヘルパー関数が出来ます:
// 符号付き距離から、アンチエイリアス付きの塗り(0〜1)を作る
float fill(float d) {
float w = fwidth(d);
return 1.0 - smoothstep(-w, w, d);
}
dFdx、dFdy及びfwidthはフラグメントシェーダーにしかありません。2×2のブロックになるのはフラグメントだけだからです。此のシリーズで使うデスクトップのGLSLの全バージョン(macOSの4.1も含む)で使えます。ブロック内のピクセルによって条件が違うifの中では結果が未定義になるので、其の様な分岐の外で呼んで下さい(fillの様に)。
6.5 距離場を見る
図形が使うのはの場所だけですが、関数は全てのピクセルで数を返します。
其の数全体を距離場と呼びます。
SDFが上手く行かない時、一番のデバッグ方法は図形だけでなく距離場全体を見る事です。
距離を色に変換する関数を紹介します(SDFについて誰よりも多く書いているInigo Quilezが広めたアイデアです):
// 距離場を色で表示する(外側はオレンジ、内側は青、等高線付き)
vec3 showField(float d) {
vec3 color = d > 0.0 ? vec3(0.90, 0.60, 0.30) : vec3(0.40, 0.70, 0.90);
color *= 1.0 - exp(-6.0 * abs(d)); // 境界に近い程暗く
color *= 0.8 + 0.2 * cos(62.83 * d); // 等高線(縞1本 = 距離0.1)
float w = fwidth(d);
color = mix(color, vec3(1.0), 1.0 - smoothstep(0.0, 2.0 * w, abs(d))); // d = 0 の線
return color;
}
void main() {
vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
float d = sdCircle(p, 0.5);
out_color = vec4(showField(d), 1.0);
}
(6.1の sdCircle を showField の上に置いて下さい。GLSLではCと同じく、関数は使う前に宣言されていなければなりません。)
1行ずつ見てみましょう:
d > 0.0 ? a : bは外側でオレンジ、内側で青を選びます。?:演算子はCと同じです。exp(x)はです。境界ではなので色が黒になり、では、では約0.95です。ピクセルが境界にどれ位近いかが分かります。cos(62.83 * d)は距離毎に繰り返すので、縞1本が0.1単位です。0.8 + 0.2 * cos(...)を掛けると、色の明るさが60%と100%の間で波打ちます。- 最後の行は、が2ピクセル未満の所に白い線を描きます。此れが実際の図形です。
リングは等間隔の完全な円です。
正確な距離場は此の様に見えます。
境界から0.1離れる毎に距離が丁度0.1増えるので、等高線は何処でも等間隔になります。
6.6 箱
円は簡単でした。境界の全ての点が中心から同じ距離にあるからです。
長方形(殆どのシェーダーコードと同じく「箱」と呼びます)はもう少し考える必要がありますが、結果は短く、一度導出してみる価値があります。
6.6.1 箱のSDFを導く
箱の中心は原点です。
半分の大きさをとします。
横はからまで、縦はからまでです。
ステップ1:全てを4分の1に折り畳む。
箱は対称なので、点はと全く同じだけ箱から離れています。
abs(p) を取ると全ての点が右上の4分の1に鏡映され、以降は其の4分の1だけを扱えば良くなります。
ステップ2:各辺をどれだけ越えているか測る。
は右の辺からどれだけ右にいるか(左なら負)、は上の辺について同じ物です。
箱の辺を延長した2本の破線が、4分の1を3つの領域に分けます:
- 角の領域(かつ、の様に):箱の一番近い点は角で、距離はの長さです。
- 辺の領域(だが、の様に):一番近い点は真っ直ぐ左の右辺上にあり、距離は単にです。(箱の上なら。)
- 内側(両方負、の様に):内側にいて、一番近い辺は近い方の辺です。両方の数は負で、近い方の辺は数が0に近い方、つまり大きい方です:。
ステップ3:3つの場合を一つの公式にする。
max(d, 0.0) は負の成分を0にします。
角の領域では何も変わらないので、長さはです。
辺の領域ではになり、長さはです。
内側ではになり、長さは0です。
従って、length(max(d, 0.0)) は外側の2つの場合を処理し、内側では0になります。
内側の為に min(max(d.x, d.y), 0.0) を足します。
此れは内側では、外側(少なくとも一つの成分が正)では何処でも0です。
// 箱(中心が原点、半分の大きさがb)の符号付き距離
float sdBox(vec2 p, vec2 b) {
vec2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
max(d, 0.0) に注目して下さい。
片方の引数がベクトルで、もう片方が float の場合、GLSLは全ての成分を同じfloatと比較します。
6.6.2 数字で確かめる
の箱を使います。
角の領域、:
辺の領域、:
内側、:
此の点は上の辺から0.2下、右の辺から0.4左にあるので、一番近いのは上の辺で、0.2離れています。正解です。
又、は abs でになるので、此方も0.5になります。
sdCircle の呼び出しを sdBox(p, vec2(0.6, 0.3)) に替えて、距離場を見てみましょう:
外側では等高線の角が丸くなります。角の領域では距離が一つの点まで測られるからです。
内側では鋭い長方形のままです。
此の画像は良い確認方法になります。
等高線が等間隔でなければ、其の関数は本当の距離ではありません。
6.7 線分
2Dの描画では線が至る所にあります。
筆の跡、矢印、時計の針(次章)、グラフ。
からまでの線分への距離は、線型代数の第4章の射影をそのまま使います。
- 全てをから測ります:と。
- を線分の方向に射影します。内積を長さの2乗で割ると、一番近い点が線分のどの辺りにあるかが割合で分かります。で0、で1です。
- 線分には端があるので、をにクランプします。の先にある点(の様に)に一番近いのは其の物です。
- 距離はから其の一番近い点までの長さです。
、、での計算例:
又、ではクランプ前になのでとなり、。端点までの距離です。
dot(a, b) は内積です(線型代数の第4章)。
// 線分abまでの距離
float sdSegment(vec2 p, vec2 a, vec2 b) {
vec2 pa = p - a;
vec2 ba = b - a;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
return length(pa - ba * h);
}
此れは符号の無い距離です。
線分には内側が無いので、決して負になりません。
太さを付けるには半径を引きます。
sdSegment(p, a, b) - 0.1 はカプセル、つまり端の丸い太い線です。
片方の端をマウスに追従させてみましょう。
uMouse はピクセル単位なので、gl_FragCoord と全く同じ様に変換します:
void main() {
vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
vec2 m = (2.0 * uMouse - uResolution) / uResolution.y; // マウスも同じ座標に
float d = sdSegment(p, vec2(-0.5, -0.2), m) - 0.1; // 太さ0.1の線(カプセル)
out_color = vec4(showField(d), 1.0);
}
マウスを動かしてみて下さい。
カプセルが伸び縮みして追い掛け、距離場は等間隔のままです。
6.8 図形の変形:角丸と輪郭
SDFは境界までの距離を教えてくれるので、距離を変えれば境界を動かせます。
二つの小さな技で、沢山の新しい図形が作れます。
角丸:どんな距離からでも数を引くと、図形は全方向にだけ大きくなり、全ての角が半径で丸くなります(箱の外側の等高線をもう一度見て下さい。どの線も、箱を或る量だけ大きくして角を丸めた物です)。
元の大きさの角丸の箱を作るには、先に箱を小さくしてから大きくします:
// 角の丸い箱:小さい箱を作ってから、距離からrを引く
float sdRoundBox(vec2 p, vec2 b, float r) {
return sdBox(p, b - r) - r;
}
輪郭:abs(d) は符号の無い境界までの距離なので、境界の両側で小さくなります。
abs(d) - w は境界の周りの幅の帯でだけ負になります。
此れが輪郭で、円に使えばリングになります。
どんなSDFにも使え、別の abs の結果にも使えます。
abs(abs(d) - 0.1) - 0.02 は細いリングを2本作ります。
此れまでの図形を全部並べてみましょう。
其々 p から位置を引く事で別の場所に動かしています(引くと何故図形が前に進むのかは次章で説明します)。
min で組み合わせると全てが同じ画面に入ります。理由は次の節で見ます。
void main() {
vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
vec3 color = vec3(0.10, 0.10, 0.12);
vec3 ink = vec3(1.00, 0.75, 0.20);
// 上の段
float d1 = sdCircle(p - vec2(-0.85, 0.45), 0.3);
float d2 = sdBox(p - vec2(0.0, 0.45), vec2(0.3, 0.2));
float d3 = sdRoundBox(p - vec2(0.85, 0.45), vec2(0.3, 0.2), 0.1);
// 下の段
float d4 = sdSegment(p, vec2(-1.1, -0.65), vec2(-0.6, -0.25)) - 0.06; // カプセル
float d5 = abs(sdCircle(p - vec2(0.0, -0.45), 0.25)) - 0.04; // リング
float d6 = abs(sdRoundBox(p - vec2(0.85, -0.45), vec2(0.3, 0.2), 0.1)) - 0.02; // 枠線
float d = min(min(min(d1, d2), min(d3, d4)), min(d5, d6));
color = mix(color, ink, fill(d));
out_color = vec4(color, 1.0);
}
6.9 図形の組み合わせ
二つのSDFとは集合の様に組み合わせられます:
| 演算 | 公式 | 意味 |
|---|---|---|
| 和集合 | min(a, b) |
どちらかの図形の内側 |
| 積集合 | max(a, b) |
両方の図形の内側 |
| 差集合 | max(a, -b) |
aの内側だがbの内側ではない |
何故此れで上手く行くのでしょうか?
点が和集合の内側にあるのは、少なくとも一つの図形の内側にある時、つまりとの少なくとも一方が負の時で、其れは丁度小さい方が負の時です。
積集合の内側にあるのは両方が負の時だけで、其れは大きい方が負の時です。
差集合ではの符号を反転します。
は「bの外側全て」のSDFで、と其れの積集合を取ります。
// 3組の円と箱:左=和集合、中央=積集合、右=差集合
vec2 c0 = vec2(-0.85, 0.0), c1 = vec2(0.0, 0.0), c2 = vec2(0.85, 0.0);
vec2 ca = vec2(-0.1, 0.06), cb = vec2(0.1, -0.06);
float a0 = sdCircle(p - c0 - ca, 0.26), b0 = sdBox(p - c0 - cb, vec2(0.2));
float a1 = sdCircle(p - c1 - ca, 0.26), b1 = sdBox(p - c1 - cb, vec2(0.2));
float a2 = sdCircle(p - c2 - ca, 0.26), b2 = sdBox(p - c2 - cb, vec2(0.2));
float d = min(min(
min(a0, b0), // 和集合
max(a1, b1)), // 積集合
max(a2, -b2)); // 差集合(aからbを引く)
(灰色の線は元の二つの図形で、輪郭用のヘルパー 1.0 - smoothstep(0.0, 1.5 * fwidth(d), abs(d)) で描いています。)
一つ正直な注意点があります。
和集合は図形の外側で正確な距離ですが、積集合と差集合は常に正確とは限りません。
小さ過ぎる距離を返す事があります(大き過ぎる事は決してありません)。
2Dで描く分には境界が正しい場所にあるので全く問題ありません。
第14章(レイマーチング)では「距離が大き過ぎない事」を頼りにしますが、此れらの演算は其の約束を守っています。
6.10 滑らかな和集合
min は図形同士を、接する所に鋭い折れ目を残してくっつけます。
有機的な形(スライム、粘土、メタボール、キャラクターの頭に繋がる耳)には柔らかい繋ぎ目が必要です。
其の為の技がスムーズミニマムです。
二つの値が離れている時は min と同じ様に振る舞い、近い時は角を丸めます。
6.10.1 公式
よく使われる多項式版:
// 滑らかな和集合(kは繋がる部分の大きさ)
float smin(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
仕組み:
- がより以上小さければ、なのでです。
mix(b, a, 1)はで、。結果は丁度で、minと同じです。 - の方が以上小さければ、で、結果は丁度です。
- 其の間ではが0から1まで滑らかに変わり、
mixが二つの距離を混ぜ、追加の項が結果を少しだけ引き下げます。此れが最大になるのは()の時で、だけ引きます。
距離を引き下げると其処で境界が外側に動くので、図形の間の隙間が埋まります。
は両端で0になる放物線なので、混ぜた部分は折れ目無く min に繋がります。
、、での計算例:
min(0.1, 0.2) なら0.1ですが、滑らかな版は0.067で、少しだけ「内側」になります。
、なら差0.4がより大きいのでとなり、結果は丁度0.1です。
6.10.2 試してみる
少し隙間を空けた半径0.15の二つの円を、min、の smin、の smin で繋いでみます:
// 2つの円の組を3つ:左=min、中央=smin(k = 0.1)、右=smin(k = 0.3)
vec2 c0 = vec2(-0.85, 0.0), c1 = vec2(0.0, 0.0), c2 = vec2(0.85, 0.0);
vec2 o = vec2(0.17, 0.0);
float d0 = min(sdCircle(p - c0 + o, 0.15), sdCircle(p - c0 - o, 0.15));
float d1 = smin(sdCircle(p - c1 + o, 0.15), sdCircle(p - c1 - o, 0.15), 0.1);
float d2 = smin(sdCircle(p - c2 + o, 0.15), sdCircle(p - c2 - o, 0.15), 0.3);
float d = min(min(d0, d1), d2);
円の間の隙間は0.04なので、min では離れたままです。
ではお互いに手を伸ばして首が出来、では溶け合って一つの形になります。
動いている所を見るのが一番です。
二つの円が uTime で近付いたり離れたりします。灰色の線は円其の物です:
float x = 0.38 + 0.22 * cos(uTime * 2.0944); // 3秒で1周期
float a = sdCircle(p - vec2(-x, 0.0), 0.3);
float b = sdCircle(p - vec2(x, 0.0), 0.3);
float d = smin(a, b, 0.3);
なので、コサインは3秒で1周期します。
6.11 全部まとめる:小さなキャラクター
此れで、ちゃんとした物を描くのに十分な部品が揃いました。
此れが此の章の最終的な playground.glsl です。
どの部分も此の章の図形で、画家の様に奥から手前へ色を1層ずつ塗り重ねます。
#version 450 core
in vec2 vUv;
out vec4 out_color;
uniform float uTime; // 起動してからの秒数
uniform vec2 uResolution; // 画面の大きさ(ピクセル)
uniform vec2 uMouse; // マウスの位置(ピクセル、左下が原点)
// ---- 第6章:符号付き距離関数(負=内側、0=境界、正=外側) ----
// 円(中心が原点、半径r)
float sdCircle(vec2 p, float r) {
return length(p) - r;
}
// 箱(中心が原点、半分の大きさがb)
float sdBox(vec2 p, vec2 b) {
vec2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
// 線分abまでの距離
float sdSegment(vec2 p, vec2 a, vec2 b) {
vec2 pa = p - a;
vec2 ba = b - a;
float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
return length(pa - ba * h);
}
// 角の丸い箱:小さい箱を作ってから、距離からrを引く
float sdRoundBox(vec2 p, vec2 b, float r) {
return sdBox(p, b - r) - r;
}
// 符号付き距離から、アンチエイリアス付きの塗り(0〜1)を作る
float fill(float d) {
float w = fwidth(d);
return 1.0 - smoothstep(-w, w, d);
}
// 滑らかな和集合(kは繋がる部分の大きさ)
float smin(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
// 頭:円に2つの耳を滑らかにくっつける
float sdHead(vec2 p) {
float head = sdCircle(p, 0.6);
float earL = sdCircle(p - vec2(-0.45, 0.5), 0.17);
float earR = sdCircle(p - vec2(0.45, 0.5), 0.17);
return smin(head, min(earL, earR), 0.12);
}
// 図形を色で塗り重ねる
vec3 paint(vec3 base, vec3 ink, float d) {
return mix(base, ink, fill(d));
}
void main() {
// 中心が原点、縦が-1〜1になる座標(第5章)
vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y;
vec2 m = (2.0 * uMouse - uResolution) / uResolution.y; // マウスも同じ座標に
// 背景:縦のグラデーション
vec3 color = mix(vec3(0.16, 0.12, 0.22), vec3(0.08, 0.06, 0.10), p.y * 0.5 + 0.5);
// 影:同じ距離関数をずらして、ぼかして使う
float shadow = sdHead(p - vec2(0.05, -0.06));
color *= 1.0 - 0.5 * (1.0 - smoothstep(-0.05, 0.12, shadow));
// 頭の塗りと輪郭
float head = sdHead(p);
color = paint(color, vec3(1.00, 0.80, 0.30), head);
color = paint(color, vec3(0.35, 0.18, 0.10), abs(head) - 0.012);
// ほっぺ:境界を広くぼかした円
float cheek = min(sdCircle(p - vec2(-0.36, -0.12), 0.09),
sdCircle(p - vec2(0.36, -0.12), 0.09));
color = mix(color, vec3(1.0, 0.50, 0.50), 0.6 * (1.0 - smoothstep(-0.04, 0.04, cheek)));
// 目:縦のカプセル
vec2 eyeL = vec2(-0.2, 0.12);
vec2 eyeR = vec2(0.2, 0.12);
float eyes = min(sdSegment(p, eyeL - vec2(0.0, 0.06), eyeL + vec2(0.0, 0.06)),
sdSegment(p, eyeR - vec2(0.0, 0.06), eyeR + vec2(0.0, 0.06))) - 0.075;
color = paint(color, vec3(1.0), eyes);
color = paint(color, vec3(0.35, 0.18, 0.10), abs(eyes) - 0.01);
// 瞳:マウスの方を見る(目の中から出ない様に長さを制限)
vec2 look = m - (eyeL + eyeR) * 0.5;
look = look / max(length(look), 1.0) * 0.05;
float pupils = min(sdCircle(p - eyeL - look, 0.04), sdCircle(p - eyeR - look, 0.04));
color = paint(color, vec3(0.12, 0.08, 0.06), pupils);
// 口:リングの下半分(リングと半平面の積集合)
float ring = abs(sdCircle(p - vec2(0.0, 0.02), 0.3)) - 0.022;
float below = p.y + 0.12; // y < -0.12 の部分が負
color = paint(color, vec3(0.35, 0.18, 0.10), max(ring, below));
out_color = vec4(color, 1.0);
}
各部分から学べる事:
- 頭:円1つと耳の円2つを
sminで繋いでいるので、耳が貼り付けたのではなく頭から生えている様に見えます。図形を専用の関数sdHeadにすると、2回使えます。 - 影:同じ
sdHeadを少し右下にずらし、とても広いsmoothstep(1ピクセルではなく−0.05から0.12まで)を使っています。広い境界はぼかしで、図形の暗いコピーをぼかした物は柔らかい影です。ビットマップならぼかしのパスが必要ですが、SDFなら1行です。 - 輪郭:
abs(head) - 0.012は6.8の輪郭の技で、塗りの後に塗ります。 - ほっぺ:此れも広い
smoothstepで、黄色が透けて見える様に60%だけ混ぜます。 - 目:縦に立てたカプセル(6.7)です。
- 瞳:両目の間からマウスへの方向です。
look / max(length(look), 1.0)は短いベクトルはそのままにし、長いベクトルを長さ1に縮めるので、瞳は最大0.05しか動かず、目から出ません。(何故normalize(look)ではないのでしょうか?normalizeは長さで割りますが、マウスが丁度両目の間にあると長さが0になります。maxで0除算を防いでいます。) - 口:リングと半平面
p.y + 0.12の積集合(max)です。半平面は一番簡単なSDFで、水平線までの符号付き距離です(線より下が負)。積集合で、リングの其の線より下の部分だけが残ります。
マウスを動かすと、目が追い掛けます。
シェーダーが何を「見ている」か確認するには、main の中身を out_color = vec4(showField(sdHead(p)), 1.0); に置き換えて下さい(6.5の showField もファイルに追加します):
耳の近くの等高線が滑らかな事に注目して下さい。
smin のお陰で折れ目がありません。
6.12 此の章の新しいGLSL関数
| 関数 | 計算する物 |
|---|---|
length(v) |
|
dot(a, b) |
|
abs(x) |
なら、そうでなければ(成分毎) |
min(a, b)、max(a, b) |
小さい方、大きい方の値(成分毎。bはfloatでも良い) |
clamp(x, lo, hi) |
min(max(x, lo), hi) |
step(e, x) |
なら0、そうでなければ1 |
smoothstep(e0, e1, x) |
として |
exp(x) |
|
dFdx(v)、dFdy(v) |
x又はy方向の隣のピクセルまでのvの変化(フラグメントシェーダーのみ) |
fwidth(v) |
abs(dFdx(v)) + abs(dFdy(v)) |
6.13 実際のゲームやエンジンでのSDF
距離関数はデモシーンの小技だけではなく、実際のソフトウェアの至る所で使われています。
- 文字:2007年、ValveはChris Greenによる短い論文を発表しました。色の代わりに距離場を保存した小さなテクスチャから、文字やデカールを描画する方法です。シェーダーは此の章の
fillと全く同じ事をします。0.5の辺りでsmoothstepです。文字はどんな大きさでもくっきりしたままで、輪郭・光彩・ドロップシャドウが殆どタダで手に入ります。UnityのTextMesh Proを始め、多くのUIシステムが此れを使っています。マルチチャンネルSDF(MSDF)という改良版は鋭い角も保てます。 - UI:角丸のボタン、進捗リング、体力バー、アイコン、選択時の輪郭は、ここで書いた
sdRoundBox、abs(d) - w、fillで描かれる事がよくあります。どんな解像度にも対応し、テクスチャが要らないからです。 - デカールとエフェクト:衝撃波のリング、地面のターゲットマーカー、ストラテジーゲームやMOBAの効果範囲の円は、大抵フラグメントシェーダーに円かリングのSDFを持つクアッドです。
- 3D:Unreal Engineはメッシュ毎に3Dの距離場を保存し、柔らかい影とアンビエントオクルージョンに使っています。Media MoleculeのPS4用ソフト「Dreams」は、スカルプト機能全体を滑らかな和集合で組み合わせたSDFの上に作っています。そしてレイマーチング(第14章)は、此の章の関数の3D版から3Dシーン全体を描きます。
6.14 まとめ
- 符号付き距離関数は図形の境界までの距離を返します。内側で負、境界で0、外側で正です。
- 円:
length(p) - r。箱:absで折り畳んでからlength(max(d, 0.0)) + min(max(d.x, d.y), 0.0)。線分:dotで射影し、線分にclamp。 stepは硬いギザギザの境界、約1ピクセル幅のsmoothstepは滑らかな境界になります。fwidthは座標に何をしても其の幅を測ってくれます。d - rは図形を丸めて大きくし、abs(d) - wは輪郭にします。- 和集合
min、積集合max、差集合max(a, -b)。sminは図形を滑らかに繋ぎ、最大で引きます。 - デバッグの時は距離場全体を可視化(
showField)して下さい。等高線が等間隔なら距離は正しいです。 - ずらしたSDFに広い
smoothstepを使うと、柔らかい影や光彩がタダで手に入ります。
6.15 課題
- ★ 半分の大きさがの箱に対する、点、、の符号付き距離を計算して下さい。(辺の領域からの正の値、負の値、角の領域からの値が一つずつ出る筈です。)
- ★
smoothstep(0.0, 2.0, 0.5)とsmin(0.3, 0.3, 0.4)を手で計算して下さい。プレイグラウンドで色として表示して答えを確かめて下さい。 - ★★ 的を描いて下さい。赤と白が交互になった5本の同心リングを、
sdCircle1つと距離へのfractだけで作ります。ウィンドウの大きさを変えても、リングは完全に滑らかなままの筈です。 - ★★ 3つの半平面の積集合で正三角形を描いて下さい(半平面は単位法線
nに対してdot(p, n) - cです)。次に- rで角を丸めようとしてみて下さい。三角形はrだけ大きくなりますが、6.8の角丸の箱と違って角は鋭いままの筈です。何故そうなるのか、6.9の最後のmaxについての注意を使って説明して下さい。 - ★★ 6.11のキャラクターに瞬きをさせて下さい。3秒毎に0.15秒間、目のカプセルを線に潰します(ヒント:カプセルの縦の半分の長さを
fract(uTime / 3.0)に依存させます)。目を閉じている間は瞳が消える筈です。 - ★★ マウスカーソルを柔らかい光にして下さい。強さが
exp(-8.0 * max(head, 0.0))で、明るさがマウスと頭の近さに依存する光彩を頭の周りに描きます。頭の外では光彩が背景へと滑らかに消えていく筈です。 - ★★★ 二つの円の積集合
max(a, b)は、図形の外側で正確な距離場ではありません。重なった二つの円でshowField(max(a, b))を描き、等高線が等間隔でなくなる場所を探して下さい。其処で何故maxが小さ過ぎる距離を返すのか説明して下さい。
ファイル: a/shader/playground.glsl | b/shader/playground.glsl
| 前 | 新 | ||
|---|---|---|---|
| @@ -7,16 +7,97 @@ uniform float uTime; // 起動してからの秒数 | |||
| 7 | uniform vec2 uResolution; // 画面の大きさ(ピクセル) | 7 | uniform vec2 uResolution; // 画面の大きさ(ピクセル) |
| 8 | uniform vec2 uMouse; // マウスの位置(ピクセル、左下が原点) | 8 | uniform vec2 uMouse; // マウスの位置(ピクセル、左下が原点) |
| 9 | 9 | ||
| 10 | // ---- 第6章:符号付き距離関数(負=内側、0=境界、正=外側) ---- | ||
| 11 | |||
| 12 | // 円(中心が原点、半径r) | ||
| 13 | float sdCircle(vec2 p, float r) { | ||
| 14 | return length(p) - r; | ||
| 15 | } | ||
| 16 | |||
| 17 | // 箱(中心が原点、半分の大きさがb) | ||
| 18 | float sdBox(vec2 p, vec2 b) { | ||
| 19 | vec2 d = abs(p) - b; | ||
| 20 | return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0); | ||
| 21 | } | ||
| 22 | |||
| 23 | // 線分abまでの距離 | ||
| 24 | float sdSegment(vec2 p, vec2 a, vec2 b) { | ||
| 25 | vec2 pa = p - a; | ||
| 26 | vec2 ba = b - a; | ||
| 27 | float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); | ||
| 28 | return length(pa - ba * h); | ||
| 29 | } | ||
| 30 | |||
| 31 | // 角の丸い箱:小さい箱を作ってから、距離からrを引く | ||
| 32 | float sdRoundBox(vec2 p, vec2 b, float r) { | ||
| 33 | return sdBox(p, b - r) - r; | ||
| 34 | } | ||
| 35 | |||
| 36 | // 符号付き距離から、アンチエイリアス付きの塗り(0〜1)を作る | ||
| 37 | float fill(float d) { | ||
| 38 | float w = fwidth(d); | ||
| 39 | return 1.0 - smoothstep(-w, w, d); | ||
| 40 | } | ||
| 41 | |||
| 42 | // 滑らかな和集合(kは繋がる部分の大きさ) | ||
| 43 | float smin(float a, float b, float k) { | ||
| 44 | float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); | ||
| 45 | return mix(b, a, h) - k * h * (1.0 - h); | ||
| 46 | } | ||
| 47 | |||
| 48 | // 頭:円に2つの耳を滑らかにくっつける | ||
| 49 | float sdHead(vec2 p) { | ||
| 50 | float head = sdCircle(p, 0.6); | ||
| 51 | float earL = sdCircle(p - vec2(-0.45, 0.5), 0.17); | ||
| 52 | float earR = sdCircle(p - vec2(0.45, 0.5), 0.17); | ||
| 53 | return smin(head, min(earL, earR), 0.12); | ||
| 54 | } | ||
| 55 | |||
| 56 | // 図形を色で塗り重ねる | ||
| 57 | vec3 paint(vec3 base, vec3 ink, float d) { | ||
| 58 | return mix(base, ink, fill(d)); | ||
| 59 | } | ||
| 60 | |||
| 10 | void main() { | 61 | void main() { |
| 11 | // ピクセル座標を0〜1に正規化 | 62 | // 中心が原点、縦が-1〜1になる座標(第5章) |
| 12 | vec2 uv = gl_FragCoord.xy / uResolution; | 63 | vec2 p = (2.0 * gl_FragCoord.xy - uResolution) / uResolution.y; |
| 64 | vec2 m = (2.0 * uMouse - uResolution) / uResolution.y; // マウスも同じ座標に | ||
| 65 | |||
| 66 | // 背景:縦のグラデーション | ||
| 67 | vec3 color = mix(vec3(0.16, 0.12, 0.22), vec3(0.08, 0.06, 0.10), p.y * 0.5 + 0.5); | ||
| 68 | |||
| 69 | // 影:同じ距離関数をずらして、ぼかして使う | ||
| 70 | float shadow = sdHead(p - vec2(0.05, -0.06)); | ||
| 71 | color *= 1.0 - 0.5 * (1.0 - smoothstep(-0.05, 0.12, shadow)); | ||
| 72 | |||
| 73 | // 頭の塗りと輪郭 | ||
| 74 | float head = sdHead(p); | ||
| 75 | color = paint(color, vec3(1.00, 0.80, 0.30), head); | ||
| 76 | color = paint(color, vec3(0.35, 0.18, 0.10), abs(head) - 0.012); | ||
| 77 | |||
| 78 | // ほっぺ:境界を広くぼかした円 | ||
| 79 | float cheek = min(sdCircle(p - vec2(-0.36, -0.12), 0.09), | ||
| 80 | sdCircle(p - vec2(0.36, -0.12), 0.09)); | ||
| 81 | color = mix(color, vec3(1.0, 0.50, 0.50), 0.6 * (1.0 - smoothstep(-0.04, 0.04, cheek))); | ||
| 82 | |||
| 83 | // 目:縦のカプセル | ||
| 84 | vec2 eyeL = vec2(-0.2, 0.12); | ||
| 85 | vec2 eyeR = vec2(0.2, 0.12); | ||
| 86 | float eyes = min(sdSegment(p, eyeL - vec2(0.0, 0.06), eyeL + vec2(0.0, 0.06)), | ||
| 87 | sdSegment(p, eyeR - vec2(0.0, 0.06), eyeR + vec2(0.0, 0.06))) - 0.075; | ||
| 88 | color = paint(color, vec3(1.0), eyes); | ||
| 89 | color = paint(color, vec3(0.35, 0.18, 0.10), abs(eyes) - 0.01); | ||
| 13 | 90 | ||
| 14 | // 時間で動くグラデーション | 91 | // 瞳:マウスの方を見る(目の中から出ない様に長さを制限) |
| 15 | vec3 color = 0.5 + 0.5 * cos(uTime + uv.xyx + vec3(0.0, 2.0, 4.0)); | 92 | vec2 look = m - (eyeL + eyeR) * 0.5; |
| 93 | look = look / max(length(look), 1.0) * 0.05; | ||
| 94 | float pupils = min(sdCircle(p - eyeL - look, 0.04), sdCircle(p - eyeR - look, 0.04)); | ||
| 95 | color = paint(color, vec3(0.12, 0.08, 0.06), pupils); | ||
| 16 | 96 | ||
| 17 | // マウスの周りに白い円 | 97 | // 口:リングの下半分(リングと半平面の積集合) |
| 18 | float d = length(gl_FragCoord.xy - uMouse); | 98 | float ring = abs(sdCircle(p - vec2(0.0, 0.02), 0.3)) - 0.022; |
| 19 | color = mix(color, vec3(1.0), 1.0 - smoothstep(18.0, 20.0, d)); | 99 | float below = p.y + 0.12; // y < -0.12 の部分が負 |
| 100 | color = paint(color, vec3(0.35, 0.18, 0.10), max(ring, below)); | ||
| 20 | 101 | ||
| 21 | out_color = vec4(color, 1.0); | 102 | out_color = vec4(color, 1.0); |
| 22 | } | 103 | } |
コメント(0)
まだコメントはありません。
ログインするとコメント出来ます。