第4部 プロシージャル & ジェネラティブ — 第24章

2D SDF

第4部が始まります。ここから先は三角形も頂点バッファも出てきません。第2部で作った全画面シェーダー(第6章)の上に戻り、計算だけで絵を作る領域に入ります。その最初の道具が、第8章で入口だけ見たSDF(符号付き距離関数)です。

第8章ではlength(p) - rを「円周まであとどれくらいか」と呼び、minで形を足しました。この章ではそこに条件を与えます。どんな値でも「距離っぽい」で 済ませるのではなく、あらゆる点で、最も近い表面までのユークリッド距離を返すものだけを 距離場と呼ぶことにする。そう決めた瞬間に、合成・変形・繰り返しのどれが安全でどれが嘘をつくのかが はっきりし、同時に第9章で「近似だ」と断った sin 曲線の線の太さも直せるようになります。第28章のレイマーチングは、この「本物さ」の上にしか建ちません。

この章のデモ 4 本。ボタンで切り替えられます。「和・差・積・smooth min」は同じ円と矩形に 4 通りの合成を掛けた 2×2 の比較(ポインタ左右で円が動きます)、「本物の距離か」は 4 つの距離らしきものを等高線と輪郭線の太さで見分ける比較(ポインタ左右で波の周波数、上下で 楕円の潰し具合)、「タイリング」はmodによる繰り返しの成功例と失敗例(ポインタ左右でセルの大きさ)、「万華鏡 × SDF」は第10章の 折り畳みの上に組んだ一枚絵(ポインタ左右で分割数、上下で溶ける幅)です。

この章で学ぶこと:

1. 「距離っぽい値」と本物の距離場

第9章で書いたabs(p.y - wave)のような値も、第8章のlength(p) - rも、どちらも「0 で輪郭、内側で負、外側で正」でした。塗るだけならこれで十分です。しかし距離としての正確さには差があります。ここではっきりさせておきます。

関数 d(p)符号付き距離関数(SDF)であるとは、どの点pについても、その値の絶対値が形の表面までの最短のユークリッド距離に等しく、内側で符号が負になることをいいます。この条件は、次の一言に言い換えられます。

1 進めば、値もちょうど 1 変わる— つまり勾配の大きさがどこでも 1(|∇d| = 1)ということです。∇dは「x に 1 進むと値がいくつ変わるか」「y に 1 進むといくつ変わるか」を並べたベクトルで、勾配と呼びます。|∇d|はその長さ、つまり「最も急な向きに 1 進んだとき、値がいくつ変わるか」です。

最短距離を測る向きへまっすぐ進めば、表面までの残りはその歩幅ぶんだけ減る — だから傾きは必ず 1 です。逆に、表面から自分の点まで歩く道のりのどこかで傾きが 1 に足りなければ、そのぶん値は積み上がりません。|∇d|がどこでも 1 以下に収まる場は、必ず距離より小さい値を返します(過小評価)。ただしこれは道のり全体についての話で、「この点で傾きが 1 未満だから、この点の値が 過小評価」とは言えません(2 節で見るmaxの合成は、内側では値が厳密なのに折れ目で傾きが落ちます)。この条件は目で確かめられますdが一定の線 — 等高線 — を等間隔の値で引くと、|∇d| = 1ならその線は画面上でも等間隔に並ぶからです。傾きが小さい方向では、値が なかなか変わらないぶん等高線の間隔が広がります。

本物の距離場(円)20 px ごと20 px ごと|∇d| = 1 — 縦も横も同じ間隔非一様スケールで壊れた場20 px ごと31.4 px ごと横方向は |∇d| ≒ 0.64 — 間隔が広がる
同じ値の刻み(図では 0.14 ごと)で等高線を引いたところ。左の円はどの向きにも 20 px ごとに並びますが、右の「円を横に引き伸ばしただけの式」は横方向だけ間隔が広がります。等高線の間隔の乱れは、そのまま|∇d|が 1 からずれていることの表示です。

デモ 2 本目「本物の距離か」の上半分が、この図をそのままシェーダーにしたものです。左が円、右が 「円を軸ごとに違う倍率で伸ばした楕円」で、ポインタを上下に動かすと潰し具合が連続的に変わります。潰すほど横の等高線が間延びし、同時に白い輪郭線が太くなるのも見てください。輪郭線はabs(d) - 0.012という「値としての太さ」で描いているので、値の変化が緩い場所ほど幅を広く取ってしまうのです。

数値でも確かめておきます。半径 1 の円の座標をvec2(0.44, 0.28)で割って作った「楕円の距離」を JS に移植して測ると、外側での|∇d|は最小 0.6364、そして返す値は真の距離の 0.6364 倍まで小さくなりました(長軸の 先の点で、値 0.1949 に対し真の距離 0.3063)。この 0.6364 は0.28 / 0.44ちょうど — つまり短い方の倍率 / 長い方の倍率です。

2. 和・差・積 — min と max で形を組み立てる

第8章ではmin(d1, d2)が形の和(union)になることを見ました。残りの 2 つも 1 行です。デモ 1 本目は、まったく同じ円と 矩形に対して 4 通りの合成を掛け、2×2 に並べたものです(左上が和、右上が差、左下が積、右下が次節の smooth min)。

src/lessons/24-2d-sdf/01-operators.frag(抜粋)
  float dUnion = min(d1, d2); // 和: 近い方の形までの距離
  float dDifference = max(d1, -d2); // 差: d2 の内側(-d2 が負)を打ち消して穴を開ける
  float dIntersection = max(d1, d2); // 積: 両方の内側でだけ負
  float dSmooth = smin(d1, d2, SMOOTH_K); // なめらかな和

ここで正直に書いておくべきことがあります。この 3 つのうち、結果が本物の距離場であり続けるとは限りません。デモと同じ配置で JS に移植して測った値を並べます。測定条件は次のとおりです — 半径 0.20 の円と半サイズ (0.18, 0.14) の矩形、ポインタは中央(円の中心 x が-0.12。この値はポインタで動くので、位置を決めないと数字が決まりません)、走査範囲は 区画 1 枚ぶんそのもの(x が -0.75〜+0.75、y が -0.5〜+0.5)を 1501×1001 に刻んだ格子、真の距離は 合成後の形の境界を各形 20000 点でサンプリングして求めています。

合成外側: 真の距離とのずれの最大内側: 真の距離とのずれの最大
min(d1, d2)0.0000(厳密)0.0752(|d| は真の距離の 0.464 倍)
max(d1, d2)0.0162(0.957 倍)0.0000(厳密)
max(d1, -d2)0.1070(0.518 倍)0.0000(厳密)

読み方はこうです。min外側では厳密で、内側では深さを小さめに見積もります。max系はその逆で、内側では厳密、外側で小さめになります。共通しているのは、どれも真の距離を上回らない— 過大評価はせず、必ず過小評価の側に外れることです(走査した約 150 万点のどこにも、真の距離を上回った点はありませんでした)。なお積の外側のずれが最大になったのは 区画の下端(0.132, -0.499)で、形から離れるほど大きくなる種類のずれです。この列の値は走査範囲を 広げれば増えます。

なぜ外れるのか。maxが返しているのは「2 つの元の形の表面までの距離のうち遠い方」です。ところが 合成後の形の表面は、元の表面の一部だけでできています。相手の内側に入って しまった部分は、もう表面ではありません。それでも max はその「表面ではなくなった点」までの距離を答えてしまうことがある — これがずれの正体です。

積 = レンズ形Pmax が返す 0.61= 円 1 の表面まででもこの点はレンズの外真の距離 0.86= レンズの先端までこの円弧までしか進めないと答える
積の過小評価。半径 0.5 の円 2 つを中心間 0.96 で重ねたレンズ形について、真上の点 P から測るとmaxは 0.6092 を返しますが、実際にレンズへ届くまでは 0.8600 あります(比 0.708)。円と矩形の配置より 交差が細いぶん、ずれも大きくなります。

3. smooth min — なめらかに溶かす

min(a, b)のグラフには、a == bのところに折れ目があります。この折れ目がそのまま、2 つの形をくっつけたときの「谷」として絵に出ます。折れ目を丸めた最小値 —smooth min(なめらかな最小値)を使うと、形どうしが水滴のように溶け合います。

定番は Inigo Quilez 氏のquadratic polynomial smooth minimumです。この章では、同氏の記事に「オリジナル版」として 載っている次の形を使います。

src/lessons/24-2d-sdf/sdf.glsl(抜粋)
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);
}

h(b - a)kで測って 0〜1 に収めた混合率です。b - a+k以上なら h は 1 に張り付いてmix(b, a, 1) = a-k以下なら h は 0 でb— つまり2 つの値が k 以上離れていれば、結果は min とぴったり同じです。 重なる範囲だけを、後ろの- k * h * (1.0 - h)が押し下げます。JS に移植して確かめた性質を並べます。

b - a-k+k0min(a, b)smin(a, b, k)k / 4= a == b での落ち込み
aを固定してbを動かしたときのminsmin。差が±kの外では 2 本は完全に重なり、内側だけが放物線でつながります(この区間は二次関数なので、図の曲線は 2 次ベジエ 1 本で厳密に描けます)。最も落ち込むのはa == bのところで、深さはちょうどk / 4です。

デモ 1 の右下がこのsminです。ポインタで円を矩形へ近づけると、触れる前から輪郭が引き合って首がつながります。k溶け合いの強さを決める値です。2 つの面の隙間がk / 2まで縮んだところで首がつながり、min からの膨らみは最も深いところでk / 4になります。前者は上の性質からすぐ出ます — 隙間のちょうど中点では 2 つの距離が等しく隙間 / 2なので、そこでの値は隙間 / 2 - k / 4。これが 0 になるのが隙間 = k / 2のときです。半径 0.20 の円 2 つ・k = 0.12で確かめると、隙間 0.090 で +0.01500、0.068 で +0.00400、0.060 で 0.00000、0.046 で -0.00700 でした。等高線を見ると、つながった首の周りだけ間隔が広がっている —|∇d| < 1になっている領域が、まさに溶かした部分だとわかります。

ただし「k から先で引き合いが始まる」という言い方はできません。このsmin|a - b| < kでありさえすれば値を下げるので、2 つの形がどれだけ遠く離れていても、両方までの距離が拮抗している場所では場が min からずれます(smin(5.0, 5.0, 0.12)4.97で、min との差はやはりk / 4)。kは距離のしきい値ではなく、2 つの値の差に対するしきい値です。

最大値の側も同じ理屈で丸められます。sdf.glslにはsmaxも入れてあります(デモでは使っていないので、実験リストで差し替えて使ってください)。差max(d1, -d2)maxsmaxに変えると、くり抜いた穴の縁が丸くなります。

src/lessons/24-2d-sdf/sdf.glsl(抜粋)
float smax(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);
}

4. 距離を壊す変形、壊さない変形

第10章でドメイン変形 (domain transformation)という考え方に名前を付けました — 「形を描く式は変えず、その手前で座標(定義域)のほうを加工する」やり方です。座標をいじるからには、当然距離が保たれるかどうかが問題になります。分類ははっきりしています。

座標をいじるときの 4 パターン(まとめ。実ファイルからの抜粋ではありません)
// 壊さない: 平行移動・回転・鏡映 — 長さを変えない変換なので、距離もそのまま
float dMoved = sdBox(rot * (p - offset), b);

// 直せる: 一様スケール — 座標を s で割ったぶん、結果に s を掛け戻す
float dScaled = sdBox(p / s, b) * s;

// 直せない: 非一様スケール — 軸ごとに倍率が違うと、どんな定数を掛けても距離にならない
float dSquashed = sdBox(p / vec2(sx, sy), b) * min(sx, sy);

// 壊さない: オフセット — d = 0 の線が r だけ外側の等高線へ移るだけ
float dRounded = sdBox(p, b) - r;
src/lessons/24-2d-sdf/02-true-distance.frag(抜粋)
  vec2 scale = vec2(ELLIPSE_X, mix(ELLIPSE_X, 0.12, mouse01.y));
  float dEllipse = sdCircle(qTop / scale, 1.0) * min(scale.x, scale.y);

5. 縦の距離を、本当の距離に近づける

第9章では sin 波を 1 本の線として描くのに、自分の高さと曲線の高さの差abs(p.y - wave)を使いました。そのとき置いた宿題を、ここで回収します。

まず何が起きているかをはっきりさせます。d = p.y - A * sin(F * x)の勾配は(-A * F * cos(F * x), 1)なので、その大きさはsqrt(1 + (A * F * cos)^2)— 平らなところで 1、最も急な斜面でsqrt(1 + (A * F)^2)です。勾配が 1 より大きいということは、1 進んだだけで値が 1 以上増えてしまう — つまり距離を大きめに見積もっているということです。同じしきい値で塗ると、斜面では 帯が早く打ち切られて線が細くなります

直し方は、その勾配の大きさで割るだけです。

src/lessons/24-2d-sdf/02-true-distance.frag(抜粋)
  float slope = AMPLITUDE * frequency * cos(phase);
  float dNormalized = dVertical / length(vec2(slope, 1.0));

d|∇d|で割った値は、d = 0 の面までの距離の 1 次近似です(値がこの傾きのまま変化し続けると 仮定して、0 になるまでの歩数を求めている、と読めます)。曲線のごく近くではほぼ正確で、離れるほど 誤差が増えます。線を引くのに必要なのは近くだけなので、これで十分実用になります。

デモ 2 の下半分が、この比較です。上の行が縦距離のまま、下の行が勾配で割った版で、白い線はどちらも 同じabs(d) - 0.012で描いています。JS に移植して、曲線に垂直な方向で線の半幅を測った実測値がこちらです(振幅 0.13、しきい値 0.012。1 周期を 20001 点に分け、各点で曲線の法線方向に二分法で|d| = 0.012になる距離を求めました)。

周波数 F|∇d| の最大 = sqrt(1 + (A·F)²)縦距離のまま(半幅)勾配で割った版(半幅)
6(ポインタ左端)1.26820.00946〜0.01200(1.268 倍)0.01195〜0.01205(1.008 倍)
9.5(ポインタ中央)1.58910.00755〜0.01200(1.589 倍)0.01180〜0.01220(1.033 倍)
13(ポインタ右端)1.96370.00611〜0.01200(1.963 倍)0.01156〜0.01246(1.078 倍)

F = 13 では、縦距離のままだと線の太さが場所によって約 2 倍違いますが、勾配で割ると8% 以内に収まります。理想の 0.01200 に対する縦距離の最小値の比は、理論値1 / sqrt(1 + (A * F)^2)と小数第 3 位まで一致しました(F = 6 で実測 0.78852 / 理論 0.78850、F = 13 で実測 0.50932 / 理論 0.50924)。

sin のように微分が手で書ける式なら、いま見たとおり解析的な勾配が最も正確です。 そうでないとき — 複雑に合成したdや、外から与えられた場 — は、勾配を数値的に取ります。第5・8章で使ったdFdx/dFdyの出番です。

勾配を画面の微分から取る版(この章のデモには入っていません)
// 解析的な勾配が書けないときは、dFdx / dFdy で数値的に取る。
// この 2 つは「隣のピクセルとの差」なので、割った結果の単位は
// 標準座標ではなく「ピクセル」になる(fwidth と同じ土俵。第5・8章)
float pixels = d / length(vec2(dFdx(d), dFdy(d)));

// 標準座標に戻したいなら、1 ピクセルぶんの幅を掛ける
float side = min(u_resolution.x, u_resolution.y);
float corrected = pixels * (2.0 / side);

単位に注意してください。dFdx/dFdyが返すのは「1 ピクセル進むと値がいくつ変わるか」なので、それで割った結果はピクセル単位の距離です。輪郭を数ピクセルぼかしたいだけならむしろ好都合で、 標準座標に戻したいときだけ 1 ピクセル分の幅2.0 / sideを掛けます。

6. mod で無限に敷き詰める

第7章で「本格的なタイリングは第24章」、第9章でmodによる繰り返しを扱いました。SDF と組み合わせると、形を 1 つ書くだけで、それが無限に並びます。やることはドメイン変形 1 行です。

src/lessons/24-2d-sdf/03-tiling.frag(抜粋)
  vec2 qMod = mod(q, c) - 0.5 * c;
  float dTiled = sdCircle(qMod, radius);

mod(q, c)は 0〜c を返すので(GLSL のmodの定義は仕様どおりx - y * floor(x / y)。負の入力でも 0〜c に収まります)、- 0.5 * cすると畳んだ座標は -c/2〜+c/2 になります。あとはこのqModに対して普通に形を描くだけで、全セルに同じ形が現れます。描画コストはセルの数によらず一定— インスタンスを増やすのではなく、座標の読み替えで済ませているからです。

折り畳みにはroundを使う書き方もあり、こちらのほうがよく見かけます。範囲は同じですが、原点の位置が違います

2 通りの折り畳み(対比。実ファイルからの抜粋ではありません)
// mod 版: 畳んだ座標は -c/2〜+c/2。原点 p = 0 はセルの「角」に来る
vec2 qMod = mod(p, c) - 0.5 * c;

// round 版: 範囲は同じだが、原点 p = 0 はセルの「中心」に来る。
// セルの番地 id がそのまま手に入るのも利点
vec2 id = round(p / c);
vec2 qRound = p - c * id;
mod(p, c) - 0.5 * cp = 0セルの角形はここp - c * round(p / c)p = 0セルの中心 = 形の位置
2 つの折り畳みの違い。縦線がセルの境目、点が形の置かれる位置です。mod版ではp = 0がセルの境目に来るので、形は原点からc / 2ずれた格子に並びます。round版はp = 0がセルの中心なので、原点に形が 1 つ座ります。

round版のもう 1 つの利点は、セルの番地 id が副産物として手に入ることです。第10章の市松模様でfloorからタイルの番地を取り出したのと同じもので、これがあると「セルごとに形を変える」ができます。デモ 3 の右下は、番地の偶奇で円と矩形を切り替え、番地から作ったsinの値で大きさを変えています。

繰り返しを有限にしたいときは、番地をclampで止めます。範囲の外では端のセルの番地が使われ続けるので、そこから先は「端の形までの距離」が そのまま伸びていきます。実測でも、この書き方は範囲の内外を通して誤差 0.000000 で厳密でした(セルの中央に置いた半径 0.34c の円・±1 セル・c は 0.20 / 0.35 / 0.50 で確認。半径がセルの半分以下なので、隣のコピーと重ならない配置です — 次の落とし穴で見るとおり、 この「重ならない」が効いています)。

src/lessons/24-2d-sdf/03-tiling.frag(抜粋)
  vec2 limitedId = clamp(round(q / c), -LIMIT, LIMIT);
  float dLimited = sdCircle(q - c * limitedId, radius);

7. 対称性と組み合わせる — 万華鏡の上に形を置く

第10章の万華鏡で「第24章では、この万華鏡の対称性を SDF と組み合わせて扱い直します」と書きました。ここで回収します。やることは 6 節のタイリングとまったく同じ構造です —座標を畳んでから、形を 1 つ描く。違いは、 畳む対象が直交座標ではなく角度 θ だということだけです。

src/lessons/24-2d-sdf/04-scene.frag(抜粋)
  float sectors = floor(mix(4.0, 12.0, mouse01.x) + 0.5);
  float sector = 2.0 * PI / sectors;
  float a = mod(theta + u_time * SPIN, sector);
  a = abs(a - sector * 0.5);
  vec2 q = r * vec2(cos(a), sin(a));

ここでmodが行っているのは回転absが行っているのは鏡映です。どちらも 4 節の「距離を壊さない変形」なので、畳んだ先の座標qの上で測った距離は、そのまま元の平面での距離として通用します。あとはこのqの上で、この章の合成を使って形を組むだけです。

src/lessons/24-2d-sdf/04-scene.frag(抜粋)
  float k = mix(0.005, 0.20, mouse01.y); // 溶ける幅。ほぼ 0 なら min と同じ
  float d = sdCircle(q - vec2(0.62, 0.13), 0.14);
  d = smin(d, sdBox(q - vec2(0.40, 0.03), vec2(0.30, 0.035)), k); // 中心へ伸びる腕
  d = smin(d, sdCircle(q - vec2(0.20, 0.02), 0.10), k); // 内側の玉
  d = smin(d, sdCircle(q - vec2(0.88, 0.06), 0.07), k); // 外側の玉
  d = max(d, -sdCircle(q - vec2(0.62, 0.13), 0.06)); // 差で穴を開ける

ポインタを上下に動かすとkが 0.005 から 0.20 まで変わります。kがほぼ 0 のときでも、4 つの部品のうち円・腕・内側の玉の 3 つはすでに重なっています(食い込みはどちらの組も 0.0350)。離れているのは外側の玉だけで、腕や円との面の 隙間は 0.0593 です。kを上げていくと、まず重なっている 3 つの角ばった谷が丸い首に変わり、続いて外側の玉が吸い寄せられて本体につながります。合流するkを数値で追うと0.1061— ポインタの下から約 52% の位置でした(扇形 1 枚ぶんの座標qの上で、場が負になる領域の連結を 0.00025 刻みの格子で判定)。形の数も式の構造も一切変えずに、数字 1 つで「別々のもの」から「溶け合ったもの」までを連続的に行き来できる — これが距離場を持ち回っていることの配当です。

最後に、max(d, -sdCircle(...))で穴を開け、塗るときにはfill(abs(d + RIM_OFFSET) - RIM_WIDTH)で「形の内側 0.03 のところを走る等高線」を 1 本だけ白く抜いています。第8章のabs(d) - td - rを組み合わせただけで、輪郭のさらに内側に縁取りが入ります。距離場は形が変わっても自動で追従するので、分割数を変えてもkを変えても、縁取りは常に輪郭から 0.03 の位置に貼り付いたままです。

コード全文

4 本のシェーダーは同じ距離関数を使うので、共有部分をsdf.glslに切り出し、main.tsの文字列置換で差し込んでいます。仕組みは第23章のpbr.glslとまったく同じです。

src/lessons/24-2d-sdf/main.ts(抜粋)
function resolveIncludes(source: string): string {
  return source.replace('#include "sdf.glsl"', () => sdfChunkSource.trim());
}
src/lessons/24-2d-sdf/sdf.glsl
// 第24章の 4 本のデモが共有する距離関数チャンク。
// 各 .frag の `#include "sdf.glsl"` を main.ts が文字列置換で差し替える
// (仕組みは第23章の pbr.glsl と同じ)。

// ---- 第8章の形。1 文字も変えていない -----------------------------------

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

float sdBox(vec2 p, vec2 b) {
  vec2 q = abs(p) - b;
  return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
}

// 塗りの常套句: 距離 → 0〜1 のマスク(輪郭の両側を約 1 ピクセルずつぼかす)
float fill(float d) {
  float w = fwidth(d);
  return 1.0 - smoothstep(-w, w, d);
}

// ---- この章の主役 -------------------------------------------------------

// なめらかな最小値(Inigo Quilez の quadratic polynomial smooth minimum・オリジナル版)。
// k は「溶ける幅」で、|a - b| が k 以上離れると h が 0 か 1 に張り付き、
// 結果は min(a, b) にぴったり一致する。最も深く食い込むのは a == b のときで、
// min より k / 4 だけ小さい値を返す。2 つの面がつながるのは、面どうしの隙間が
// k / 2 まで縮んだときになる(いずれも本文 3 節で数値確認済み)。
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);
}

// なめらかな最大値。smin の比較の向きと符号を裏返しただけで、
// a == b のとき max より k / 4 だけ大きい値を返す。
// この章のデモは使っていない(実験リストで差し替えて使う)
float smax(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);
}

// ---- 可視化 -------------------------------------------------------------

// 距離場そのものを絵にする(第8章デモ2の色分けと等高線を関数にまとめ直したもの)。
// spacing は等高線の間隔。等高線が等間隔に並ぶかどうかが、
// 「この d は本物の距離場か」を目で判定する手がかりになる(本文 1 節)
vec3 fieldColor(float d, float spacing) {
  vec3 color = mix(vec3(0.95, 0.55, 0.25), vec3(0.20, 0.36, 0.70), step(0.0, d));
  color *= 1.0 - 0.55 * clamp(abs(d), 0.0, 1.0); // 遠いほど暗く

  // 最寄りの等高線までの距離。線の太さのほうは fwidth で画面上約 1.5 ピクセルに
  // 揃えてあるので、目に入る違いは「縞の間隔」だけになる
  float g = abs(fract(d / spacing - 0.5) - 0.5) * spacing;
  float w = max(1.5 * fwidth(d), 1e-5); // 平らな場所で edge0 == edge1 にならないよう下限を置く
  color *= 1.0 - 0.45 * (1.0 - smoothstep(0.0, w, g));
  return color;
}

// 比較デモの仕切り線。区画の境界までの距離 e を渡すと、線の上で 0 に近づく係数を返す
float panelDivider(float e) {
  return smoothstep(0.0, 0.007, e);
}

fieldColorの中の等高線は、第8章デモ2のfract(d * 6.0)の縞を「線の太さを画面上で一定に」した版です。fractの値をそのまま使うのではなく、最寄りの等高線までの距離に直してからfwidthで幅を決めているので、間隔が詰まっても線が潰れません。

src/lessons/24-2d-sdf/sdf.glsl(抜粋)
  float g = abs(fract(d / spacing - 0.5) - 0.5) * spacing;
  float w = max(1.5 * fwidth(d), 1e-5); // 平らな場所で edge0 == edge1 にならないよう下限を置く
  color *= 1.0 - 0.45 * (1.0 - smoothstep(0.0, w, g));
src/lessons/24-2d-sdf/01-operators.frag
#version 300 es

// 第24章 デモ1: 4 つの合成を並べて比べる。
// 画面を 2×2 に分け、まったく同じ「円 + 矩形」に別々の合成を掛ける。
//   左上 = 和   min(d1, d2)         右上 = 差 max(d1, -d2)
//   左下 = 積   max(d1, d2)         右下 = なめらかな和 smin(d1, d2, SMOOTH_K)
// 色は塗りではなく距離場そのもの(内外の色分け + 等高線)。
// 形の見た目だけでなく、外側の等高線がどう走るかを見比べるのがこのデモの本題。
// ポインタを左右に動かすと、円が矩形を通り抜ける。

precision highp float;

uniform vec2 u_resolution;
uniform vec2 u_mouse;

out vec4 fragColor;

#include "sdf.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// 円の半径
const float RADIUS = 0.20;
// 矩形の半サイズ(中心から辺まで)
const vec2 HALF_SIZE = vec2(0.18, 0.14);
// smin の「溶ける幅」。0 に近づけると min に戻る
const float SMOOTH_K = 0.12;
// 等高線の間隔
const float SPACING = 0.06;
// ----------------------------------------------------------------------

void main() {
  // 標準座標(第7章)
  vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);

  // 2×2 の区画。区画の中心を原点に置き直した局所座標 q の上で形を描く
  vec2 q = p - vec2(p.x < 0.0 ? -0.75 : 0.75, p.y < 0.0 ? -0.5 : 0.5);
  int panel = (p.y > 0.0 ? 0 : 2) + (p.x > 0.0 ? 1 : 0);

  // 円だけポインタで左右に動かす(0〜1 を -0.34〜+0.10 に写す)
  float mouse01 = u_mouse.x / u_resolution.x;
  float d1 = sdCircle(q - vec2(mix(-0.34, 0.10, mouse01), 0.0), RADIUS);
  float d2 = sdBox(q - vec2(0.13, 0.0), HALF_SIZE);

  // 4 通りの合成をすべて計算しておいてから区画で選ぶ。
  // fwidth を使う fill / fieldColor は、分岐の外で 1 回だけ呼ぶ(第8章)
  float dUnion = min(d1, d2); // 和: 近い方の形までの距離
  float dDifference = max(d1, -d2); // 差: d2 の内側(-d2 が負)を打ち消して穴を開ける
  float dIntersection = max(d1, d2); // 積: 両方の内側でだけ負
  float dSmooth = smin(d1, d2, SMOOTH_K); // なめらかな和

  float d = panel == 0
      ? dUnion
      : (panel == 1 ? dDifference : (panel == 2 ? dIntersection : dSmooth));

  vec3 color = fieldColor(d, SPACING);
  color = mix(color, vec3(1.0), fill(abs(d) - 0.004)); // d = 0 の輪郭を白で重ねる
  color *= panelDivider(min(abs(p.x), abs(p.y))); // 区画の仕切り

  fragColor = vec4(color, 1.0);
}
src/lessons/24-2d-sdf/02-true-distance.frag
#version 300 es

// 第24章 デモ2: 「その値は本物の距離か」を等高線と線の太さで見分ける。
// 4 つの区画に、まったく同じ描き方(等間隔の等高線 + 太さ一定の白い輪郭線)で
// 4 つの「距離らしきもの」を並べる。
//   左上   = 円 length(p) - r          … 本物。等高線は等間隔、輪郭線の太さも一定
//   右上   = 円を x と y で違う倍率に伸ばした楕円 … |∇d| < 1 になり、横方向の
//                                        等高線が間延びし、輪郭線も太くなる
//   下段上 = sin 曲線までの「縦方向の距離」(第9章) … 斜面で等高線が詰まり、線が細る
//   下段下 = それを勾配の大きさで割った版         … 線の太さがほぼ均一になる
// ポインタ: 左右で sin の周波数、上下で楕円の潰し具合が変わる。

precision highp float;

uniform float u_time;
uniform vec2 u_resolution;
uniform vec2 u_mouse;

out vec4 fragColor;

#include "sdf.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// 左上の円の半径
const float CIRCLE_RADIUS = 0.30;
// 右上の楕円の x 半径(y 半径はポインタの上下で 0.44 → 0.12 に変わる)
const float ELLIPSE_X = 0.44;
// sin 波の振幅
const float AMPLITUDE = 0.13;
// 白い輪郭線の半分の太さ。4 区画とも同じ値を使う
const float LINE = 0.012;
// 等高線の間隔(上段 / 下段)
const float SPACING_TOP = 0.07;
const float SPACING_BOTTOM = 0.045;
// ----------------------------------------------------------------------

void main() {
  vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
  vec2 mouse01 = u_mouse / u_resolution;

  // ---- 上段(p.y > 0): 左右 2 区画。区画の中心を原点にした局所座標
  vec2 qTop = p - vec2(p.x < 0.0 ? -0.75 : 0.75, 0.5);

  // 左上: 本物の距離場。どこでも |∇d| = 1
  float dCircle = sdCircle(qTop, CIRCLE_RADIUS);

  // 右上: 座標を軸ごとに違う倍率で割ってから円の距離を測ったもの。
  // 一様スケールなら sd(p / s) * s で正しい距離に直せるが、s が軸ごとに違うと
  // どんな定数を掛けても距離にはならない(本文 4 節)
  vec2 scale = vec2(ELLIPSE_X, mix(ELLIPSE_X, 0.12, mouse01.y));
  float dEllipse = sdCircle(qTop / scale, 1.0) * min(scale.x, scale.y);

  // ---- 下段(p.y < 0): 上下 2 区画。x はそのまま、y だけ行の中心へ寄せる
  vec2 qRow = vec2(p.x, p.y - (p.y > -0.5 ? -0.25 : -0.75));

  float frequency = mix(6.0, 13.0, mouse01.x);
  float phase = frequency * qRow.x - u_time * 0.7;
  float wave = AMPLITUDE * sin(phase);

  // 第9章の「縦方向の距離」。真上・真下に測った差でしかない
  float dVertical = qRow.y - wave;

  // 勾配は解析的に出せる: ∇d = (-A * F * cos(phase), 1)。
  // その長さで割ると、曲線のすぐ近くでは本当の距離に一致する(本文 5 節)
  float slope = AMPLITUDE * frequency * cos(phase);
  float dNormalized = dVertical / length(vec2(slope, 1.0));

  // 4 区画ぶんを計算してから選ぶ(fwidth を使う関数は分岐の外で呼ぶ)
  bool top = p.y > 0.0;
  float d = top
      ? (p.x < 0.0 ? dCircle : dEllipse)
      : (p.y > -0.5 ? dVertical : dNormalized);
  float spacing = top ? SPACING_TOP : SPACING_BOTTOM;

  vec3 color = fieldColor(d, spacing);
  // 太さを 2 * LINE に固定した輪郭線。d が本物の距離なら、この線はどこでも同じ太さに見える
  color = mix(color, vec3(1.0), fill(abs(d) - LINE));

  // 仕切り: y = 0 と y = -0.5 の横線、それに上段だけの縦線 x = 0
  float edge = min(abs(p.y), abs(p.y + 0.5));
  color *= panelDivider(min(edge, top ? abs(p.x) : 1.0));

  fragColor = vec4(color, 1.0);
}
src/lessons/24-2d-sdf/03-tiling.frag
#version 300 es

// 第24章 デモ3: 座標を折り畳んで形を敷き詰める。
//   左上 = 無限タイリング。形をセルの中央に置く    → 距離は厳密なまま
//   右上 = 同じ形をセルの中でずらしただけ          → 隣のセルのぶんが計算に入らず距離が壊れる
//   左下 = 有限繰り返し clamp(round(p / c), -n, n) → 中央の 3×3 セルだけに形が出る
//   右下 = セルの番地 id で形と大きさを変える
// ポインタを左右に動かすと、セルの一辺 c が 0.20〜0.50 で変わる。

precision highp float;

uniform float u_time;
uniform vec2 u_resolution;
uniform vec2 u_mouse;

out vec4 fragColor;

#include "sdf.glsl"

// ---- 書き換えて試すための定数 ----------------------------------------
// 形の半径。セルの一辺 c に対する比で決めるので、c を変えても関係は保たれる。
// 0.5 以下ならセルからはみ出さない
const float RADIUS_RATIO = 0.34;
// 右上の区画で形をずらす量(セルの一辺に対する比)。0.0 にすると左上と同じになる
const vec2 OFFSET_RATIO = vec2(0.30, 0.16);
// 有限繰り返しの範囲。中央のセルから ±1 セル = 3×3 セル
const float LIMIT = 1.0;
// ----------------------------------------------------------------------

void main() {
  vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);

  // 2×2 の区画。区画の中心を原点に置き直した局所座標 q
  vec2 q = p - vec2(p.x < 0.0 ? -0.75 : 0.75, p.y < 0.0 ? -0.5 : 0.5);
  int panel = (p.y > 0.0 ? 0 : 2) + (p.x > 0.0 ? 1 : 0);

  float mouse01 = u_mouse.x / u_resolution.x;
  float c = mix(0.20, 0.50, mouse01); // セルの一辺
  float radius = RADIUS_RATIO * c;

  // (1) 無限タイリング。mod で「セルの中での位置」に畳む。
  //     GLSL の mod は 0〜c を返すので、-0.5 * c すると畳んだ座標は -c/2〜+c/2 になる
  vec2 qMod = mod(q, c) - 0.5 * c;
  float dTiled = sdCircle(qMod, radius);

  // (2) 同じ形をセルの中でずらしただけ。形が「自分のセルの領域」からはみ出すので、
  //     隣のセルのコピーのほうが近い点が現れる。折り畳みはそれを見ないので、
  //     距離が過大評価され、形はセルの境界でちぎれる
  float dBroken = sdCircle(qMod - OFFSET_RATIO * c, radius);

  // (3) 有限繰り返し。セルの番地を clamp で止めると、範囲の外では端のセルの形までの
  //     距離が続く。round 版の畳み込みなので原点はセルの中心
  vec2 limitedId = clamp(round(q / c), -LIMIT, LIMIT);
  float dLimited = sdCircle(q - c * limitedId, radius);

  // (4) セルの番地で形を変える。id は第10章の市松模様の「タイルの番地」と同じもの。
  //     隣より大きい形が置かれたセルの周りでは距離が壊れる点は (2) と同じ。
  //     なお番地から「ランダムな値」を作るのはハッシュの仕事で、それは第25章
  vec2 id = round(q / c);
  vec2 qId = q - c * id;
  float variation = 0.5 + 0.5 * sin(id.x * 1.7 + id.y * 2.3 + u_time * 0.5);
  float scaled = radius * (0.55 + 0.45 * variation);
  float dVaried = mod(id.x + id.y, 2.0) < 0.5
      ? sdCircle(qId, scaled)
      : sdBox(qId, vec2(scaled * 0.85));

  float d = panel == 0 ? dTiled : (panel == 1 ? dBroken : (panel == 2 ? dLimited : dVaried));

  vec3 color = fieldColor(d, c * 0.22);
  color = mix(color, vec3(1.0), fill(abs(d) - 0.004));
  color *= panelDivider(min(abs(p.x), abs(p.y)));

  fragColor = vec4(color, 1.0);
}
src/lessons/24-2d-sdf/04-scene.frag
#version 300 es

// 第24章 デモ4: 第10章の万華鏡の上に、この章の合成で組んだ形を置いた一枚絵。
// 手順は 3 段だけ。
//   ① θ を mod + abs で扇形 1 枚に畳む(ドメイン変形。第10章)
//   ② 畳んだ座標 q の上で、円と矩形を smin でつなぎ、max(d, -d2) で穴を開ける
//   ③ 距離 → マスク → mix で塗る(第8章の常套句)
// ポインタ: 左右で分割数(4〜12 枚)、上下で smin の溶ける幅 k が変わる。
// 上下いっぱいまで動かすと、角ばった谷 → 丸い首、離れていた外側の玉 → 本体と合流、
// と連続的に変わる(k ≒ 0.106 で合流する。本文 7 節)。

precision highp float;

uniform float u_time;
uniform vec2 u_resolution;
uniform vec2 u_mouse;

out vec4 fragColor;

#include "sdf.glsl"

const float PI = 3.141592653589793;

// ---- 書き換えて試すための定数 ----------------------------------------
// 内側に入れる細い縁取りの位置(d = -0.03 の等高線)と、その半分の太さ
const float RIM_OFFSET = 0.03;
const float RIM_WIDTH = 0.008;
// 全体の回転の速さ
const float SPIN = 0.12;
// ----------------------------------------------------------------------

void main() {
  vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
  vec2 mouse01 = u_mouse / u_resolution;

  float r = length(p);
  float theta = atan(p.y, p.x);

  // ① 万華鏡の折り畳み(第10章)。回転と鏡映だけでできているので、
  //    折り畳んだ先の座標 q の上では長さがそのまま保たれる = 距離が壊れない。
  //    ただし「扇形からはみ出した形」は隣の扇形のぶんが見えないので、
  //    そこはタイリングと同じ理由で近似になる(本文 6・7 節)
  float sectors = floor(mix(4.0, 12.0, mouse01.x) + 0.5);
  float sector = 2.0 * PI / sectors;
  float a = mod(theta + u_time * SPIN, sector);
  a = abs(a - sector * 0.5);
  vec2 q = r * vec2(cos(a), sin(a));

  // ② 扇形 1 枚ぶんの形。ここを書き換えると全扇形に反映される
  float k = mix(0.005, 0.20, mouse01.y); // 溶ける幅。ほぼ 0 なら min と同じ
  float d = sdCircle(q - vec2(0.62, 0.13), 0.14);
  d = smin(d, sdBox(q - vec2(0.40, 0.03), vec2(0.30, 0.035)), k); // 中心へ伸びる腕
  d = smin(d, sdCircle(q - vec2(0.20, 0.02), 0.10), k); // 内側の玉
  d = smin(d, sdCircle(q - vec2(0.88, 0.06), 0.07), k); // 外側の玉
  d = max(d, -sdCircle(q - vec2(0.62, 0.13), 0.06)); // 差で穴を開ける

  // ③ 塗る。距離は形の外側でも値を持っているので、にじみにもそのまま使える(第8章)
  vec3 color = vec3(0.06, 0.07, 0.09);
  color += vec3(0.10, 0.18, 0.34) * exp(-5.0 * max(d, 0.0));
  vec3 body = mix(vec3(0.98, 0.74, 0.34), vec3(0.88, 0.28, 0.44), clamp(r, 0.0, 1.0));
  color = mix(color, body, fill(d));
  // 形の内側 RIM_OFFSET のところに走る等高線を 1 本だけ白く抜く
  color = mix(color, vec3(1.0), fill(abs(d + RIM_OFFSET) - RIM_WIDTH));
  color *= 1.0 - 0.35 * smoothstep(0.7, 1.5, r); // 周辺減光

  fragColor = vec4(color, 1.0);
}
src/lessons/24-2d-sdf/main.ts
// 第24章: 2D SDF — 距離場で形を合成する
//
// 第2部と同じ構成: 1 つの canvas に対して、切替ボタンでフラグメントシェーダーを
// 差し替える。CPU 側で増えたのは、4 本のシェーダーが共有する距離関数
// (sdf.glsl)を差し込む極小のインクルードだけ。仕組みは第23章と同じ。

import { startFullscreenShader } from '../../lib/fullscreen-shader';
import operatorsSource from './01-operators.frag?raw';
import trueDistanceSource from './02-true-distance.frag?raw';
import tilingSource from './03-tiling.frag?raw';
import sceneSource from './04-scene.frag?raw';
import sdfChunkSource from './sdf.glsl?raw';

// 置換文字列を関数で渡すのは、String.replace が `$&` などを特別扱いするため(第23章)
function resolveIncludes(source: string): string {
  return source.replace('#include "sdf.glsl"', () => sdfChunkSource.trim());
}

const demos = [
  { id: 'operators', label: '和・差・積・smooth min', source: resolveIncludes(operatorsSource) },
  { id: 'true', label: '本物の距離か', source: resolveIncludes(trueDistanceSource) },
  { id: 'tiling', label: 'タイリング', source: resolveIncludes(tilingSource) },
  { id: 'scene', label: '万華鏡 × SDF', source: resolveIncludes(sceneSource) },
] as const;

const canvas = document.querySelector<HTMLCanvasElement>('#demo');
const controls = document.querySelector<HTMLParagraphElement>('#demo-buttons');
if (!canvas || !controls) {
  throw new Error('デモに必要な要素が見つかりません');
}

let handle = startFullscreenShader(canvas, demos[0].source);

for (const [index, demo] of demos.entries()) {
  const button = document.createElement('button');
  button.type = 'button';
  button.textContent = demo.label;
  button.setAttribute('aria-pressed', index === 0 ? 'true' : 'false');
  button.addEventListener('click', () => {
    // いまのループを止めてから、新しいシェーダーで開始し直す(第6章の stop() の出番)。
    // 古いプログラムの解放は省略している(後始末は第35章)
    handle.stop();
    handle = startFullscreenShader(canvas, demo.source);
    for (const b of controls.querySelectorAll('button')) {
      b.setAttribute('aria-pressed', 'false');
    }
    button.setAttribute('aria-pressed', 'true');
  });
  controls.append(button);
}

three.js との対応

この章の操作は、ポリゴンの世界では「ジオメトリを作り直す」処理にあたります。距離場ではどれも 1 行から数行の式です。

three.jsこの章
メッシュの論理演算(CSG)。three.js 本体には無く、外部ライブラリで頂点を作り直して トポロジを繋ぎ直すmin/max(d1, d2)/max(d1, -d2)の 1 行。頂点が存在しないので、繋ぎ直すものが無い(ただし結果は近似距離場になる)
Shapeholes に穴の輪郭を渡して三角形分割するmax(d, -dHole) の 1 行
three/addons/geometries/RoundedBoxGeometry.js(角丸の箱。分割数ぶん頂点が増える)sdBox(p, b) - r— 引き算 1 回。rを変えても頂点数は変わらない
three/addons/objects/MarchingCubes.js(メタボール。密度場をグリッドで サンプリングしてポリゴン化する)smin(d1, d2, k)— ポリゴン化せずに、溶け合った輪郭をピクセルごとに直接評価する
InstancedMesh で同じ形を大量に並べるmodによる座標の折り畳み。インスタンス数という概念が無く、無限に並べても描画コストは変わらない(代わりに、形がセルからはみ出せない)
Object3D.scale に軸ごとに違う値を入れて潰す座標を軸ごとに違う倍率で割ると距離が壊れる。一様スケールだけが* sで直せる

手元で動かして、壊してみる

この章のコードはsrc/lessons/24-2d-sdf/にあります。共有部分はsdf.glslにあるので、そこを直すと 4 本すべてに効きます。

まとめ

次章第25章「乱数とノイズ — hash / value noise / gradient noise」では、この章に 決定的に足りていないもの —ばらつきを手に入れます。この章のタイリングは、どのセルも 同じか、sinで規則的に変化するだけでした。セルごとに「でたらめな」値が欲しくなりますが、GPU には乱数がありません。そもそも毎フレーム同じ絵を出すには、状態を持たず座標から決まる値でなければ 困ります。そこでハッシュ関数を自作し、それを補間してvalue noisegradient noise (Perlin)という「なめらかにでたらめな場」を 組み立てます。