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単位の長さが同じなので、円は丸いままです。

では、原点を中心とする半径rの円を考えてみましょう。
位置𝐩にあるピクセルは、中心から‖𝐩‖だけ離れています(ベクトルの長さ、線型代数の第3章)。
其処から半径を引くと、境界までの距離になります:

d=‖𝐩‖−r

半径rの円。外側の点pではdが0より大きく、内側の点qではdが0より小さく、境界ではd = 0

符号は、どちら側にいるかを教えてくれます:

  • d>0:外側。dは図形に届くまでに歩く距離です。
  • d=0:丁度境界の上。
  • d<0:内側。−dは境界までの距離です。

此の様な関数を符号付き距離関数(Signed Distance Function、SDF)と呼びます。
r=0.5で、数字で確かめてみましょう:

  • 𝐩=(0.6,0.8):‖𝐩‖=0.36+0.64=1なので、d=1−0.5=0.5。外側で、0.5離れています。
  • 𝐩=(0.3,0.4):‖𝐩‖=0.09+0.16=0.5なので、d=0。境界の上です。
  • 𝐩=(0,0.2):‖𝐩‖=0.2なので、d=−0.3。内側で、境界まで0.3です。

GLSLでは1行です:

float sdCircle(vec2 p, float r) {
  return length(p) - r;
}

length(v) はvx2+vy2を返します(vec3 なら+vz2も)。
sqrt(dot(v, v)) と同じです。
名前の sd は「signed distance(符号付き距離)」の略です。
此の種の関数を書く人の殆どが使う命名の習慣なので、あたしも其れに従います。

何故わざわざ距離を使うのでしょうか?「内側かどうか」だけ聞けば良いのでは?
其れは、距離の方がずっと多くの情報を持っているからです。
「内側かどうか」はピクセル毎に1ビットしかありません。
距離なら境界にどれ位近いかが分かり、其れこそが滑らかな境界・輪郭・光彩・柔らかい影に必要な物です。
更に、図形同士を普通の min と max で組み合わせる事も出来ます。
此の章では其の全てを使います。

6.2 最初の円:step

距離を絵にするには、「内側なら1、外側なら0」が必要です。
其れをするのが step です:

step(e,x)={0x<e1x≥e

step(0.0, d) は円の外側(d≥0)で1、内側で0になるので、1.0 - step(0.0, d) を使い、背景色と前景色の間を mix で混ぜます(mix(a, b, t) = a(1−t)+bt、第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キーを押すと、円が出ます:

stepで描いた暗い背景の上の黄色い円

遠くから見れば問題無さそうですが、境界をよく見て下さい(下の画像の左半分は、此の円の境界を6倍に拡大した物です)。
全てのピクセルが完全に黄色か完全に暗いかのどちらかなので、境界が階段になっています。
此れをエイリアシングと言い、図形が動くとチラチラと揺れて見えます。

左:stepのギザギザの境界を拡大。右:smoothstepの滑らかな境界を拡大

6.3 柔らかい境界:smoothstep

直すには、境界上のピクセルを部分的に塗れば良いです。
半分だけ円の中に入っているピクセルは、2色の丁度中間の色にするべきです。
第5章のマウスの円で既に smoothstep を使いましたが、ここで実際に何を計算しているのか見てみましょう。

6.3.1 公式

smoothstep(e0, e1, x) は、x が e0 から e1 まで動く間に0から1まで変化します。
先ず x を0〜1の値t(真っ直ぐな傾斜)に変換し、次に其の傾斜をS字カーブに曲げます:

t=clamp(x−e0e1−e0,0,1)smoothstep(e0,e1,x)=t2(3−2t)

clamp(x, lo, hi) は値を範囲内に切り詰めます。
min(max(x, lo), hi) と同じです。
min と max は二つの値の小さい方と大きい方を返します(ベクトルなら成分毎)。

stepは0.5で0から1に跳ぶ。clampの傾斜はedge0からedge1までの直線。smoothstepは同じ二つの境界の間のS字カーブ

何故真っ直ぐな傾斜tではなくt2(3−2t)なのでしょうか?
多項式3t2−2t3は傾斜と同じくt=0で0、t=1で1ですが、其の傾き(6t−6t2)も両端で0になります。
従って、カーブは0から穏やかに離れ、1に穏やかに到着し、目に見える折れ目がありません。
境界では殆ど違いが分かりませんが、アニメーションやノイズ(第8章)では大きな違いになります。

計算例:smoothstep(0.25, 0.75, 0.6)。

t=0.6−0.250.75−0.25=0.350.5=0.7t2(3−2t)=0.49×(3−1.4)=0.49×1.6=0.784

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ピクセルは

px=2uResolution.y=2600≈0.0033

単位です(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 の変化の合計。

fwidth(d)=|∂d∂x|+|∂d∂y|

abs(x) は絶対値|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 距離場を見る

図形が使うのはd=0の場所だけですが、関数は全てのピクセルで数を返します。
其の数全体を距離場と呼びます。
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) はexです。境界では1−e0=0なので色が黒になり、|d|=0.1では1−e−0.6≈0.45、|d|=0.5では約0.95です。ピクセルが境界にどれ位近いかが分かります。
  • cos(62.83 * d) は距離2π/62.83≈0.1毎に繰り返すので、縞1本が0.1単位です。0.8 + 0.2 * cos(...) を掛けると、色の明るさが60%と100%の間で波打ちます。
  • 最後の行は、|d|が2ピクセル未満の所に白い線を描きます。此れが実際の図形です。

円の距離場:内側に青いリング、外側にオレンジのリング、d = 0に白い円

リングは等間隔の完全な円です。
正確な距離場は此の様に見えます。
境界から0.1離れる毎に距離が丁度0.1増えるので、等高線は何処でも等間隔になります。

6.6 箱

円は簡単でした。境界の全ての点が中心から同じ距離にあるからです。
長方形(殆どのシェーダーコードと同じく「箱」と呼びます)はもう少し考える必要がありますが、結果は短く、一度導出してみる価値があります。

6.6.1 箱のSDFを導く

箱の中心は原点です。
半分の大きさを𝐛=(bx,by)とします。
横は−bxからbxまで、縦は−byからbyまでです。

ステップ1:全てを4分の1に折り畳む。
箱は対称なので、点(−0.8,−0.7)は(0.8,0.7)と全く同じだけ箱から離れています。
abs(p) を取ると全ての点が右上の4分の1に鏡映され、以降は其の4分の1だけを扱えば良くなります。

ステップ2:各辺をどれだけ越えているか測る。

𝐝=abs(𝐩)−𝐛

dxは右の辺からどれだけ右にいるか(左なら負)、dyは上の辺について同じ物です。
箱の辺を延長した2本の破線が、4分の1を3つの領域に分けます:

半分の大きさがbの箱。dの両成分が正の角の領域、片方だけ正の辺の領域、両方負の内側。別の4分の1にある点pはabsで鏡映される

  • 角の領域(dx>0かつdy>0、𝐩1の様に):箱の一番近い点は角で、距離は(dx,dy)の長さです。
  • 辺の領域(dx>0だがdy<0、𝐩2の様に):一番近い点は真っ直ぐ左の右辺上にあり、距離は単にdxです。(箱の上ならdy。)
  • 内側(両方負、𝐩3の様に):内側にいて、一番近い辺は近い方の辺です。両方の数は負で、近い方の辺は数が0に近い方、つまり大きい方です:max(dx,dy)。

ステップ3:3つの場合を一つの公式にする。
max(d, 0.0) は負の成分を0にします。
角の領域では何も変わらないので、長さはdx2+dy2です。
辺の領域では(dx,0)になり、長さはdxです。
内側では(0,0)になり、長さは0です。
従って、length(max(d, 0.0)) は外側の2つの場合を処理し、内側では0になります。
内側の為に min(max(d.x, d.y), 0.0) を足します。
此れは内側ではmax(dx,dy)、外側(少なくとも一つの成分が正)では何処でも0です。

f(𝐩)=‖max(𝐝,𝟎)‖+min(max(dx,dy),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.5,0.3)の箱を使います。

角の領域、𝐩=(0.8,0.7):

𝐝=(0.8−0.5,0.7−0.3)=(0.3,0.4)‖max(𝐝,𝟎)‖=0.09+0.16=0.5min(max(0.3,0.4),0)=0f=0.5

辺の領域、𝐩=(0.8,0.1):

𝐝=(0.3,−0.2)‖max(𝐝,𝟎)‖=‖(0.3,0)‖=0.3min(max(0.3,−0.2),0)=min(0.3,0)=0f=0.3

内側、𝐩=(0.1,0.1):

𝐝=(−0.4,−0.2)‖max(𝐝,𝟎)‖=0min(max(−0.4,−0.2),0)=−0.2f=−0.2

此の点は上の辺から0.2下、右の辺から0.4左にあるので、一番近いのは上の辺で、0.2離れています。正解です。
又、𝐩=(−0.8,−0.7)は abs で(0.8,0.7)になるので、此方も0.5になります。

sdCircle の呼び出しを sdBox(p, vec2(0.6, 0.3)) に替えて、距離場を見てみましょう:

箱の距離場:内側は長方形のリング、外側は角の丸いリング

外側では等高線の角が丸くなります。角の領域では距離が一つの点まで測られるからです。
内側では鋭い長方形のままです。
此の画像は良い確認方法になります。
等高線が等間隔でなければ、其の関数は本当の距離ではありません。

6.7 線分

2Dの描画では線が至る所にあります。
筆の跡、矢印、時計の針(次章)、グラフ。
𝐚から𝐛までの線分への距離は、線型代数の第4章の射影をそのまま使います。

aからbまでの線分。点pはa + h(b − a)に射影され、dは垂直な距離。bの先にある点p′ではhが1にクランプされる

  1. 全てを𝐚から測ります:𝐩−𝐚と𝐛−𝐚。
  2. 𝐩−𝐚を線分の方向に射影します。内積を長さの2乗で割ると、一番近い点が線分のどの辺りにあるかが割合hで分かります。𝐚で0、𝐛で1です。
  3. 線分には端があるので、hを[0,1]にクランプします。𝐛の先にある点(𝐩′の様に)に一番近いのは𝐛其の物です。
  4. 距離は𝐩から其の一番近い点までの長さです。

h=clamp((𝐩−𝐚)⋅(𝐛−𝐚)(𝐛−𝐚)⋅(𝐛−𝐚),0,1),f=‖(𝐩−𝐚)−h(𝐛−𝐚)‖

𝐚=(−0.5,0)、𝐛=(0.5,0)、𝐩=(0.2,0.3)での計算例:

𝐩−𝐚=(0.2,0.3)−(−0.5,0)=(0.7,0.3)𝐛−𝐚=(1,0)h=0.7×1+0.3×01×1+0×0=0.7f=‖(0.7,0.3)−0.7×(1,0)‖=‖(0,0.3)‖=0.3

又、𝐩=(0.9,0.3)ではクランプ前にh=1.4なのでh=1となり、f=‖(1.4,0.3)−(1,0)‖=‖(0.4,0.3)‖=0.5。端点𝐛までの距離です。

dot(a, b) は内積axbx+aybyです(線型代数の第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を引くと、図形は全方向にrだけ大きくなり、全ての角が半径rで丸くなります(箱の外側の等高線をもう一度見て下さい。どの線も、箱を或る量だけ大きくして角を丸めた物です)。
元の大きさの角丸の箱を作るには、先に箱を小さくしてから大きくします:

// 角の丸い箱:小さい箱を作ってから、距離からrを引く
float sdRoundBox(vec2 p, vec2 b, float r) {
  return sdBox(p, b - r) - r;
}

輪郭:abs(d) は符号の無い境界までの距離なので、境界の両側で小さくなります。
abs(d) - w は境界の周りの幅2wの帯でだけ負になります。
此れが輪郭で、円に使えばリングになります。
どんな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つの図形:円、箱、角丸の箱、カプセル、リング、角丸の箱の枠線

6.9 図形の組み合わせ

二つのSDFaとbは集合の様に組み合わせられます:

演算 公式 意味
和集合 min(a, b) どちらかの図形の内側
積集合 max(a, b) 両方の図形の内側
差集合 max(a, -b) aの内側だがbの内側ではない

何故此れで上手く行くのでしょうか?
点が和集合の内側にあるのは、少なくとも一つの図形の内側にある時、つまりaとbの少なくとも一方が負の時で、其れは丁度小さい方が負の時です。
積集合の内側にあるのは両方が負の時だけで、其れは大きい方が負の時です。
差集合ではbの符号を反転します。
−bは「bの外側全て」のSDFで、aと其れの積集合を取ります。

  // 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 公式

よく使われる多項式版:

h=clamp(12+b−a2k,0,1)smin(a,b,k)=mix(b,a,h)−kh(1−h)

// 滑らかな和集合(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);
}

仕組み:

  • aがbよりk以上小さければ、b−a2k≥12なのでh=1です。mix(b, a, 1) はaで、k⋅1⋅0=0。結果は丁度aで、min と同じです。
  • bの方がk以上小さければ、h=0で、結果は丁度bです。
  • 其の間ではhが0から1まで滑らかに変わり、mix が二つの距離を混ぜ、追加の項kh(1−h)が結果を少しだけ引き下げます。此れが最大になるのはa=b(h=12)の時で、k4だけ引きます。

距離を引き下げると其処で境界が外側に動くので、図形の間の隙間が埋まります。
h(1−h)は両端で0になる放物線なので、混ぜた部分は折れ目無く min に繋がります。

二つの円の断面。赤いminは二つの距離が交わる所で鋭い山になり、黄色いsminは其れを丸め、山の頂点でk/4低くなる

k=0.3、a=0.1、b=0.2での計算例:

h=clamp(0.5+0.2−0.10.6,0,1)=0.5+0.16‾=0.6‾mix(0.2,0.1,0.6‾)=0.2+(0.1−0.2)×0.6‾=0.13‾kh(1−h)=0.3×0.6‾×0.3‾=0.06‾smin=0.13‾−0.06‾=0.06‾

min(0.1, 0.2) なら0.1ですが、滑らかな版は0.067で、少しだけ「内側」になります。
a=0.1、b=0.5なら差0.4がkより大きいのでh=1となり、結果は丁度0.1です。

6.10.2 試してみる

少し隙間を空けた半径0.15の二つの円を、min、k=0.1の smin、k=0.3の 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 では離れたままです。
k=0.1ではお互いに手を伸ばして首が出来、k=0.3では溶け合って一つの形になります。
動いている所を見るのが一番です。
二つの円が 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);

sminで繋がった、近付いた二つの円

2.0944≈2π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で、水平線y=−0.12までの符号付き距離です(線より下が負)。積集合で、リングの其の線より下の部分だけが残ります。

マウスを動かすと、目が追い掛けます。

シェーダーが何を「見ている」か確認するには、main の中身を out_color = vec4(showField(sdHead(p)), 1.0); に置き換えて下さい(6.5の showField もファイルに追加します):

頭の距離場:耳の周りで等高線が滑らかに曲がる

耳の近くの等高線が滑らかな事に注目して下さい。
smin のお陰で折れ目がありません。

6.12 此の章の新しいGLSL関数

関数 計算する物
length(v) vx2+vy2(+vz2+vw2)
dot(a, b) axbx+ayby(+…)
abs(x) x≥0ならx、そうでなければ−x(成分毎)
min(a, b)、max(a, b) 小さい方、大きい方の値(成分毎。bはfloatでも良い)
clamp(x, lo, hi) min(max(x, lo), hi)
step(e, x) x<eなら0、そうでなければ1
smoothstep(e0, e1, x) t=clamp((x−e0)/(e1−e0),0,1)としてt2(3−2t)
exp(x) ex
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 は図形を滑らかに繋ぎ、最大でk4引きます。
  • デバッグの時は距離場全体を可視化(showField)して下さい。等高線が等間隔なら距離は正しいです。
  • ずらしたSDFに広い smoothstep を使うと、柔らかい影や光彩がタダで手に入ります。

6.15 課題

  1. ★ 半分の大きさが𝐛=(0.5,0.2)の箱に対する、点(0.9,0.0)、(0.0,0.0)、(0.8,0.6)の符号付き距離を計算して下さい。(辺の領域からの正の値、負の値、角の領域からの値が一つずつ出る筈です。)
  2. ★ smoothstep(0.0, 2.0, 0.5) と smin(0.3, 0.3, 0.4) を手で計算して下さい。プレイグラウンドで色として表示して答えを確かめて下さい。
  3. ★★ 的を描いて下さい。赤と白が交互になった5本の同心リングを、sdCircle 1つと距離への fract だけで作ります。ウィンドウの大きさを変えても、リングは完全に滑らかなままの筈です。
  4. ★★ 3つの半平面の積集合で正三角形を描いて下さい(半平面は単位法線 n に対して dot(p, n) - c です)。次に - r で角を丸めようとしてみて下さい。三角形はrだけ大きくなりますが、6.8の角丸の箱と違って角は鋭いままの筈です。何故そうなるのか、6.9の最後の max についての注意を使って説明して下さい。
  5. ★★ 6.11のキャラクターに瞬きをさせて下さい。3秒毎に0.15秒間、目のカプセルを線に潰します(ヒント:カプセルの縦の半分の長さを fract(uTime / 3.0) に依存させます)。目を閉じている間は瞳が消える筈です。
  6. ★★ マウスカーソルを柔らかい光にして下さい。強さが exp(-8.0 * max(head, 0.0)) で、明るさがマウスと頭の近さに依存する光彩を頭の周りに描きます。頭の外では光彩が背景へと滑らかに消えていく筈です。
  7. ★★★ 二つの円の積集合 max(a, b) は、図形の外側で正確な距離場ではありません。重なった二つの円で showField(max(a, b)) を描き、等高線が等間隔でなくなる場所を探して下さい。其処で何故 max が小さ過ぎる距離を返すのか説明して下さい。

DIFFをダウンロード

ファイル: 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)

まだコメントはありません。