第2部 シェーダーで学ぶ数学 — 第8章

形を描く

第7章では、グラデーション — 座標を色に変える計算 — を扱いました。この章ではいよいよ「形」を描きます。ただし、頂点を並べて三角形を組み立てるのではありません。この章で身につけるのは、形とは、各ピクセルから形までの距離のことだという発想の転換です。フラグメントシェーダーは 1 ピクセルの色しか決められませんが、そのピクセルが 「円まであとどれくらいか」を計算できれば、円は描けます。しかも後で見るとおり、「あとどれくらいか」というは、内か外かという判定よりはるかに多くの表現の材料になります。

three.js で言えば、CircleGeometryShapeGeometryが CPU で形を頂点の列に分解してから描いていたのに対し、こちらは頂点を一切増やさず(全画面三角形の 3 頂点のまま)、ピクセルごとの計算だけで形を出します。

この章のデモ 4 本。ボタンでフラグメントシェーダーを切り替えられます(main.ts が第6章の stop() でループを止めてから、次のシェーダーで開始し直しています)。「距離場を見る」は時間で 半径が脈動し、「min で合成」はポインタで太陽が動きます。

この章で学ぶこと:

1. 「内側か外側か」ではなく「どれだけ離れているか」

座標系は第7章で作った標準座標をそのまま使います。復習として式だけ再掲します — 中心が原点、短い辺の方向が -1〜+1 で、アスペクト比補正済み。円が円のまま描ける座標系です。以降のシェーダーはすべてこの 1 行から始まります。

この章の全シェーダー共通の 1 行目(第7章)
// 第7章で導出した標準座標。中心が原点、短い辺の方向が -1〜+1、
// アスペクト比補正済み。この章のシェーダーはすべてこの p の上で考える
vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);

さて、円を描くことを考えます。素朴に考えると「このピクセルは円の内側か?」という Yes / No の判定問題です。中心からの距離が半径より小さければ内側 — それで確かに円は塗れます。しかしこの章では、判定の手前にある量をそのまま持ち回ります。

円の符号付き距離
// 中心 center・半径 radius の円の「符号付き距離」
// 内側: 負 / 円周上: 0 / 外側: 正
float d = length(p - center) - radius;

length(p - center)は中心からの距離(第5章の幾何関数)、そこから半径を引いたdは「円周まであとどれくらいか」です。この d には 3 つの状態が符号で刻まれています。

このように「図形の輪郭までの距離を、内側なら負にして返す関数」を符号付き距離関数(SDF: signed distance function)と呼びます。Yes / No に潰してしまうと「内側」という事実しか残りませんが、距離のままなら「輪郭のすぐ近くにいる」「深く内側にいる」が区別できます。この 1 つの数値から、くっきりした塗り・ぼかし・輪郭線・角丸・グロー・形同士の合成までが全部出てくる — それをこの章で順に見ていきます。

2. 符号を塗りに変える — step・smoothstep・fwidth

d が手に入ったら、それを「塗る量」= 0〜1 のマスクに変換します。デモの「円と塗り方 3 種」では、画面を縦に 3 分割して同じ円を 3 通りの変換で塗っています。まず 3 つの式を並べます。

d をマスクに変える 3 つの方法
// (a) step: 符号だけを見る。d ≦ 0(内側)なら 1
float maskStep = step(0.0, -d);

// (b) smoothstep 固定幅: d が 0.02 → 0.0 に近づくにつれ 0 → 1
float maskFixed = 1.0 - smoothstep(0.0, 0.02, d);

// (c) smoothstep + fwidth: 輪郭の両側を約 1 ピクセルずつ(合計約 2 ピクセル)ぼかす
float w = fwidth(d);
float maskAA = 1.0 - smoothstep(-w, w, d);

(a) step(第7章)は符号を 0 / 1 に潰す最短の方法です。境界は完全にくっきりしますが、ピクセルは「塗る / 塗らない」の 2 択しかないため、円周が斜めに横切るピクセルでは階段状のギザギザ — いわゆるジャギーが出ます。デモの左の円の輪郭に目を近づけると見えます。

(b) smoothstep 固定幅は、境界の付近(ここでは d が 0〜0.02 の帯)だけを滑らかに 1 → 0 へ遷移させます。ジャギーは消えますが、ぼかし幅が標準座標で 0.02という固定値なのが弱点です。標準座標は画面の短辺を基準にした座標なので、この幅は「画面の短辺の 1% ぶん」を常に占めます。ブラウザで拡大したり大きなディスプレイで見たりすると、そのぶん物理的に太いぼけになります。輪郭は「なめらか」ではなく「にじんでいる」ように見えてきます。

そこで(c) fwidthです。fwidth(d)は「d の値が、隣のピクセルとの間でどれだけ変わるか」を返します(第5章の微分関数カタログ)。つまりw = fwidth(d)「1 ピクセル進むと d がいくつ増えるか」そのものです。ぼかし幅を ±w にすれば、遷移帯は解像度や拡大率がどうであれ常に約 2 ピクセルぶん— ジャギーを消すのにちょうど必要なだけの、最小のぼかしになります。これが解像度に依存しないアンチエイリアスで、この先の章では形を塗るときの既定の方法としてずっと使います。

もうひとつ、(c) の数値の気持ちよさにも触れておきます。符号付き距離関数は「1 進むと 1 増える」関数なので、fwidth(d)はほぼ「標準座標での 1 ピクセルの幅」に一致します。d がちゃんと距離になっていれば、ぼかしはどの形でも・どの場所でも同じ 1 ピクセル幅に揃う、ということです。

デモの 1 本目の全文です。3 つのマスクを縦帯で切り替えているだけです。

src/lessons/08-drawing-shapes/01-circle.frag
#version 300 es

// 第8章 デモ1: 同じ円を 3 通りの塗り方で比較する。
// 画面を縦に 3 分割し、左から
//   (a) step             — くっきり。ただしピクセル単位のジャギーが出る
//   (b) smoothstep 固定幅 — なめらか。ただしぼかし幅が「標準座標の 0.02」で固定
//   (c) smoothstep + fwidth — 輪郭の両側を約 1 ピクセルずつぼかすよう自動調整(この章の主役)
// ブラウザの拡大率を変えて (b) と (c) の輪郭を見比べるのがこのデモの本題。

precision highp float;

uniform vec2 u_resolution;

out vec4 fragColor;

// 円の符号付き距離: 原点中心・半径 radius の円周までの距離。
// 内側で負、円周上で 0、外側で正
float sdCircle(vec2 p, float radius) {
  return length(p) - radius;
}

void main() {
  // 標準座標(第7章): 中心が原点、短い辺の方向が -1〜+1、アスペクト比補正済み
  float side = min(u_resolution.x, u_resolution.y);
  vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / side;

  // いま何番目の縦帯にいるか(左から 0, 1, 2)
  int band = int(gl_FragCoord.x * 3.0 / u_resolution.x);

  // 各帯の中央のピクセル座標を、p と同じ式で標準座標へ変換して円の中心にする
  vec2 centerPx = vec2((float(band) + 0.5) / 3.0, 0.5) * u_resolution;
  vec2 center = (centerPx * 2.0 - u_resolution) / side;

  float d = sdCircle(p - center, 0.42);

  // (a) 符号だけを見る: d ≦ 0(内側)なら 1、正(外側)なら 0
  float maskStep = step(0.0, -d);

  // (b) 固定幅のぼかし: d が 0.02 → 0.0 に近づくにつれ 0 → 1
  float maskFixed = 1.0 - smoothstep(0.0, 0.02, d);

  // (c) ぼかし幅を fwidth で決める。
  //     w = fwidth(d) は「隣のピクセルとの間で d がどれだけ変わるか」(第5章)。
  //     輪郭の両側 ±w、つまり片側約 1 ピクセルずつ・合計約 2 ピクセルをぼかす
  float w = fwidth(d);
  float maskAA = 1.0 - smoothstep(-w, w, d);

  float mask = band == 0 ? maskStep : (band == 1 ? maskFixed : maskAA);

  // 帯の境目がわかるよう、背景の明るさを帯ごとに少しずつ変えておく
  vec3 background = vec3(0.06, 0.07, 0.09) + float(band) * 0.03;
  vec3 color = mix(background, vec3(0.92, 0.62, 0.25), mask);
  fragColor = vec4(color, 1.0);
}

3. 距離場を見る

d は円の内側だけでなく、画面のすべてのピクセルで値を持っています。画面全体に広がる「各点から形までの距離」の分布を距離場(distance field)と呼びます。塗りに使う前に、一度この場そのものを絵にしてみましょう。距離場は地形図によく似ています。円周が「海抜 0 の海岸線」、外側は標高がプラス、内側はマイナス。等しい d を結んだ線は等高線です。

-0.250(円周)+0.25+0.5+0.75Pd = 円周までの最短距離
円の距離場。等しい d を結んだ等高線は、円の場合すべて同心円になります。数字は標準座標での d の値(半径 0.5 の円の例)。どの点 P でも、d はそこから円周までの最短距離で、内側では符号が負になります。

デモの「距離場を見る」は、この図をそのままシェーダーにしたものです。使っている道具はどれも第7章までのものです。

src/lessons/08-drawing-shapes/02-field.frag
#version 300 es

// 第8章 デモ2: 円の距離場そのものを絵にする。
//   1. 内側(d < 0)と外側(d > 0)を色分け
//   2. 円周から遠いほど暗くする(距離の「深さ」)
//   3. fract(d * 6.0) の縞 = 1/6 間隔の等高線
//   4. |d| が小さいところに白い線 = d = 0 の等高線(円周そのもの)
// 半径は u_time でゆっくり脈動する(sin の使いこなしは第9章)。
// 円が伸び縮みしても、縞は常に「円周からの距離」に貼り付いたまま動く。

precision highp float;

uniform float u_time;
uniform vec2 u_resolution;

out vec4 fragColor;

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

  float radius = 0.5 + 0.15 * sin(u_time * 0.8);
  float d = length(p) - radius;

  // 1. 内外の色分け。step(0.0, d) は外側で 1 になる
  vec3 inside = vec3(0.95, 0.55, 0.25);
  vec3 outside = vec3(0.25, 0.45, 0.85);
  vec3 color = mix(inside, outside, step(0.0, d));

  // 2. 円周から離れるほど暗く。d の絶対値がそのまま「遠さ」になっている
  color *= 1.0 - 0.5 * clamp(abs(d), 0.0, 1.0);

  // 3. fract で d を 1/6 ごとに繰り返して縞にする = 等高線。
  //    縞の境界はあえて step のままにしてある(ジャギーの直し方は実験リストで)
  color *= 0.85 + 0.15 * step(0.5, fract(d * 6.0));

  // 4. d = 0 の等高線(円周)を白い線で重ねる。線の縁は fwidth でアンチエイリアス
  float w = fwidth(d);
  float outline = 1.0 - smoothstep(0.008 - w, 0.008 + w, abs(d));
  color = mix(color, vec3(1.0), outline);

  fragColor = vec4(color, 1.0);
}

半径が脈動しても、縞は常に円周に貼り付いたまま動くことに注目してください。縞は「円からの距離が同じ場所」を結んだ線なので、円がどう変わろうと自動で追従します。距離は判定の材料ではなく、絵の材料そのもの— この感覚がこの章の核心です。

4. いろいろな形の距離 — 矩形・線分・リング

円の SDF が length 1 つで書けたのは、円が「中心から等距離の点の集まり」という 距離そのものの定義を持つ形だからです。他の形はもう少し考える必要がありますが、定番の形の SDF は数行で書けます。この章では矩形と線分、そしてリングを導出します。

矩形 — sdBox

矩形の符号付き距離
// 矩形: b は中心から辺までの半サイズ(幅・高さの半分)
float sdBox(vec2 p, vec2 b) {
  vec2 q = abs(p) - b; // ① 第 1 象限へ折りたたみ、角を基準にした相対位置へ
  return length(max(q, 0.0)) // ② 外側: 正の成分だけでできた直角三角形の斜辺
       + min(max(q.x, q.y), 0.0); // ③ 内側: 近い方の辺までの距離(負)。外側では 0
}

3 行ですが、1 行ずつに意味が詰まっているので順に追います。

abs(p) — 矩形は上下にも左右にも対称なので、p の符号を全部正にしても「どの辺・どの角に近いか」の答えは変わりません。これで考える範囲を右上の第 1 象限だけに折りたたみます。続けて半サイズ b を引いたq = abs(p) - bは、「右上のを原点とした相対位置」です。q.xが正なら右の辺より外、負なら内側 — y も同様です。

② 外側にいるとき。q の正の成分だけが「辺からのはみ出し」です。角の外(q が両方正)なら、最近点は角そのもので、距離はlength(q)。辺の横(片方だけ正)なら、最近点は辺の上で、距離ははみ出した成分そのもの。max(q, 0.0)で負の成分を 0 に潰してからlengthを取ると、この 2 つの場合が 1 つの式にまとまります(片方が 0 のベクトルの length は、もう片方の絶対値です)。

③ 内側にいるとき(q が両方負)は ② が 0 になってしまうので、負の距離を別に足します。内側から見た最寄りの辺は「q の大きい方(0 に近い方)」の軸にあり、その距離がmax(q.x, q.y)(負の値)です。外側では正になってしまうのでmin(..., 0.0)で外側では 0 に。②と③は常にどちらかが 0 なので、足せば完成です。

中心Aq.xq.y角の外: length(q)B辺の横: q.x(q.y は負)C内側: 近い辺まで(負)破線 = 外側の等高線。角の周りでは「角までの距離が等しい円弧」になるため、自然に丸くなる
sdBox の場合分け。A(角の外)では length(q)、B(辺の横)では はみ出した成分だけ、C(内側)では最寄りの辺までの負の距離が採用されます。

図の破線に注目してください。矩形の外側の等高線は、辺の近くでは辺に平行な直線ですが、角の周りでは円弧になります。角の外側では最近点が常に「角」なので、等高線は角を中心にした円になるからです。つまり sdBox の距離場の等高線は、最初から角の丸い矩形です。ということは —

角丸と太さは「引くだけ」で手に入る
// 距離から 0.07 を引くだけで、輪郭(d = 0 の線)が 0.07 だけ外へ膨らむ = 角丸矩形
float dRound = sdBox(p, vec2(0.19, 0.12)) - 0.07;

// 線分も同じ。「太さのない線までの距離」から半分の太さを引くと、太さのある線になる
float dSeg = sdSegment(p, a, b) - 0.035;

d から定数 r を引くと、「d = 0 の線」が元の輪郭から r だけ外側の等高線に移ります。矩形なら角丸矩形、円なら半径が r 大きい円です。CSS のborder-radiusや three.js で角丸の板を作るときの頂点の工夫に相当するものが、SDF では引き算 1 回で、しかも数学的に正確な円弧として手に入ります。「形 = 距離」の発想が最初に効いてくる場面です。

線分 — sdSegment

線分 ab までの距離は「p から、線分上で p に最も近い点までの距離」です。その最近点を求めるのに、初登場の関数dot(内積)を 1 か所だけ使います。

線分の距離
// 線分 ab: p から線分上の最近点までの距離
float sdSegment(vec2 p, vec2 a, vec2 b) {
  vec2 pa = p - a;
  vec2 ba = b - a;
  // pa を ba に「影を落として」、最近点が a から何割の位置かを得る。
  // clamp で 0〜1 に収めるので、最近点は必ず線分の上に留まる
  float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
  return length(pa - ba * h); // 最近点 a + ba * h までの距離
}

dot(pa, ba)は、直感的には「pa が ba の方向にどれだけ伸びているか」を測る量です。真上から光を当てて、pa の影を ba の上に落とすイメージで捉えてください。dot(ba, ba)は ba 自身の長さの 2 乗なので、割ったhは「影の先端が、a から b までの何割の位置にあるか」になります。h をそのまま使えば「無限に伸びた直線」への最近点ですが、clamp(h, 0.0, 1.0)で 0〜1 に制限することで、最近点が線分の外へ出そうなときは端点 a(h = 0)または b(h = 1)に留まります。あとは最近点a + ba * hまでの距離を測るだけです。

sdSegmentが返すのは「太さのない線」までの距離なので、そのまま塗ると何も見えません(d = 0 になるピクセルはほぼ存在しません)。角丸と同じ理屈で- 0.035のように半分の太さを引けば、両端の丸い太さのある線になります。three.js のLine+LineBasicMaterialではlinewidthが多くの環境で 1px 固定という有名な制約がありますが、SDF の線は太さも端の形も自由です。

リング — 等高線を 1 本取り出す

リング(円環)の距離
// abs で「円周からの符号なし距離」にしてから、リングの半分の太さを引く
float dRing = abs(length(p - center) - radius) - thickness;

absを取ると、内側の負の距離が正に折り返され、「円周からの符号なし距離」になります。つまり塗りつぶした円が、輪郭線だけの円に変わる— 距離場の等高線を 1 本取り出す操作です。そこから太さの半分を引けば、幅のあるリングになります。デモ 2 の白い円周線も同じ考え方です(あちらは abs(d)をしきい値と直接比べる書き方で、式の形だけ少し違います)。このabs(d) - tはどんな SDF にも使えるので、矩形の枠線も同じ 1 行で作れます。

デモの 3 本目は、この節の 4 つの形をそのまま並べたものです。

src/lessons/08-drawing-shapes/03-shapes.frag
#version 300 es

// 第8章 デモ3: 矩形・角丸矩形・線分・リング。
// どれも「p から形までの符号付き距離」を返す関数(2D の SDF)として書く。
// 左から: sdBox / sdBox - 0.07(角丸) / sdSegment - 0.035(太さ付き) / リング

precision highp float;

uniform vec2 u_resolution;

out vec4 fragColor;

// 矩形: b は中心から辺までの半サイズ(幅・高さの半分)。導出は本文 4 節
float sdBox(vec2 p, vec2 b) {
  vec2 q = abs(p) - b; // 対称性で第 1 象限に折りたたみ、角を基準にした相対位置へ
  return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
}

// 線分 ab: p から線分上の最近点までの距離
float sdSegment(vec2 p, vec2 a, vec2 b) {
  vec2 pa = p - a;
  vec2 ba = b - a;
  // pa を ba に「影を落として」、最近点が a から何割の位置かを得る。
  // clamp で 0〜1 に収めるので、最近点は必ず線分の上(dot の本格解説は第11章)
  float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
  return length(pa - ba * h); // 最近点 a + ba * h までの距離
}

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

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

  // 4 つの形。位置は標準座標で直書き(この canvas は 3:2 なので x は -1.5〜+1.5)
  float dBox = sdBox(p - vec2(-1.05, 0.0), vec2(0.26, 0.19));
  float dRound = sdBox(p - vec2(-0.35, 0.0), vec2(0.19, 0.12)) - 0.07; // 引くだけで角丸
  float dSeg = sdSegment(p, vec2(0.12, -0.28), vec2(0.62, 0.3)) - 0.035; // 太さも引くだけ
  float dRing = abs(length(p - vec2(1.05, 0.0)) - 0.27) - 0.05; // abs で輪郭だけ残す

  // 背景の上に、それぞれ色を変えて重ねる(距離 → マスク → mix)
  vec3 color = vec3(0.06, 0.07, 0.09);
  color = mix(color, vec3(0.35, 0.65, 0.95), fill(dBox));
  color = mix(color, vec3(0.95, 0.6, 0.3), fill(dRound));
  color = mix(color, vec3(0.55, 0.85, 0.45), fill(dSeg));
  color = mix(color, vec3(0.9, 0.45, 0.75), fill(dRing));

  fragColor = vec4(color, 1.0);
}

5. min で形を足す — 合成の入口

形が距離になったことの、もうひとつの大きな配当が合成です。2 つの形の距離場 d1・d2 があるとき、各ピクセルでmin(d1, d2)を取ると何が起きるでしょうか。

和(union)
// 2 つの距離場の min = 2 つの形の和(union)。
// 「近い方の形までの距離」を採用すれば、どちらかの内側にいる限り d は負のまま
float d = min(d1, d2);

min は「近い方の形までの距離」です。どちらか一方の内側にいれば min は負なので、塗られる領域は2 つの形を重ねた和になります。ジオメトリの世界で 2 つのメッシュを溶接しようとすると頂点の縫い合わせという難題になりますが、距離場なら min 1 つで、いくつでも形を足していけます。デモの 4 本目は、円 3 つ・線分 2 本・地面を min だけで 1 つの距離場にまとめた雪だるまです。

src/lessons/08-drawing-shapes/04-scene.frag
#version 300 es

// 第8章 デモ4: min による合成だけで作る一枚絵。
// 雪だるま(円 3 つ + 腕の線分 2 本)と地面(水平線)を min でひとつの距離場にまとめる。
// 太陽は u_mouse に追従する。空 → 太陽 → 雪の順に塗るので、
// ポインタを下へ動かすと太陽は地面の「後ろ」に沈む(塗り重ねの順序がそのまま前後関係)。

precision highp float;

uniform vec2 u_resolution;
uniform vec2 u_mouse;

out vec4 fragColor;

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

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);
}

float fill(float d) {
  float w = fwidth(d);
  return 1.0 - smoothstep(-w, w, d);
}

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

  // u_mouse は gl_FragCoord と同じ座標系(第6章)なので、p と同じ式で標準座標へ。
  // ポインタ未操作の間、ハーネスは画面中央を渡してくるので m = (0, 0) になる
  vec2 m = (u_mouse * 2.0 - u_resolution) / side;

  // 空: 高さ p.y で 2 色を混ぜるグラデーション(第7章)
  vec3 color = mix(vec3(0.16, 0.22, 0.4), vec3(0.45, 0.55, 0.75), p.y * 0.5 + 0.5);

  // 太陽: ポインタに追従する円。先に塗るので、あとから塗る雪の後ろに隠れる
  float dSun = sdCircle(p - m, 0.13);
  color = mix(color, vec3(0.98, 0.8, 0.35), fill(dSun));

  // 雪だるま + 地面: min で「全部まとめてひとつの形」にしていく
  float d = sdCircle(p - vec2(-0.55, -0.42), 0.34); // 胴(下)
  d = min(d, sdCircle(p - vec2(-0.55, 0.06), 0.24)); // 胴(中)
  d = min(d, sdCircle(p - vec2(-0.55, 0.43), 0.16)); // 頭
  d = min(d, sdSegment(p, vec2(-0.76, 0.12), vec2(-1.08, 0.36)) - 0.025); // 左腕
  d = min(d, sdSegment(p, vec2(-0.34, 0.12), vec2(-0.02, 0.36)) - 0.025); // 右腕
  d = min(d, p.y + 0.62); // 地面。「p.y + 0.62」自体が水平線 y = -0.62 までの符号付き距離

  color = mix(color, vec3(0.93, 0.95, 0.98), fill(d));

  fragColor = vec4(color, 1.0);
}

読みどころが 3 つあります。

u_mouse を標準座標に変換して形の位置にする
// u_mouse は gl_FragCoord と同じ座標系(第6章)なので、p と同じ式で標準座標にできる
vec2 m = (u_mouse * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
float dSun = length(p - m) - 0.13; // ポインタに追従する円

合成には和のほかにも、max(d1, -d2)で「d2 をくり抜く」差、max(d1, d2)で「重なりだけ残す」積、そして 2 つの形をなめらかに溶け合わせる smooth min があります。これらと、modで 1 つの形を無限に敷き詰めるタイリングは、第24章でまとめて楽しみます。この章では min だけ持ち帰ってください。

6. 常套句 — 距離 → マスク → mix

この章でやったことを 1 つの型に整理します。デモ 3・4 のfill()関数がこの型そのものでした。

形を 1 つ塗るときの常套句
// 1. 距離: ピクセルから形までの符号付き距離を計算する
float d = sdBox(p - position, halfSize);

// 2. マスク: 輪郭の両側を約 1 ピクセルずつぼかして 0〜1 へ
float w = fwidth(d);
float mask = 1.0 - smoothstep(-w, w, d);

// 3. 色: マスクの割合で色を重ねる
color = mix(color, shapeColor, mask);
  1. 距離 — ピクセルから形までの符号付き距離 d を計算する
  2. マスク— d を 0〜1 に変換する。既定は fwidth による両側約 1 ピクセルずつのぼかし。輪郭線が欲しければ先にabs(d) - t、角丸や太さが欲しければd - rを挟む
  3. mix(color, shapeColor, mask) で背景の上に重ねる

第9章以降、このパイプラインは断りなく使います。「形を描いている」コードを見たら、頭の中でこの 3 段に分解して読んでください。逆に、表現を工夫したくなったら、3 段のどこに手を入れるかを考えます — 距離を歪ませるのか(第26章のドメインワーピング)、マスクへの変換を変えるのか、色の重ね方を変えるのか。

コード全文

デモを動かしているmain.tsの全文です。フラグメントシェーダー 4 本は各節に掲載済みなので再掲しません。ボタンで切り替えるたびに、第6章ハーネスのstop()でループを止めてから、新しいシェーダーで開始し直しています。

src/lessons/08-drawing-shapes/main.ts
// 第8章: 形を描く
// 第2部のデモは「1 つの canvas + 切替ボタン」方式。
// フラグメントシェーダー 4 本を、ボタンで差し替えながら同じハーネスで動かす。

import { startFullscreenShader } from '../../lib/fullscreen-shader';
import circleSource from './01-circle.frag?raw';
import fieldSource from './02-field.frag?raw';
import shapesSource from './03-shapes.frag?raw';
import sceneSource from './04-scene.frag?raw';

const demos = [
  { id: 'circle', label: '円と塗り方 3 種', source: circleSource },
  { id: 'field', label: '距離場を見る', source: fieldSource },
  { id: 'shapes', label: '矩形・線分・リング', source: shapesSource },
  { id: 'scene', label: 'min で合成', source: 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 との対応

この章の内容は、three.js では「ジオメトリを作る」領域に当たります。ただし作り方が根本から違います — three.js(ラスタライズ一般)の形は頂点の集まり(ポリゴン)で、この章の形はピクセルごとの距離計算です。

three.jsこの章のやり方
Shape+ShapeGeometry(輪郭を CPU で頂点列に分解し、三角形分割してから GPU へ)頂点は全画面三角形の 3 つだけ。形はジオメトリとしては存在せず、フラグメントシェーダーの距離計算で現れる
CircleGeometry(r, segments) — segments を増やすほど円に近づく多角形length(p) - r— 数学的に正確な円。いくら拡大しても多角形の折れ目は現れない
Line+LineBasicMaterial(linewidthは多くの環境で 1px 固定)sdSegment(p, a, b) - t — 太さ t は自由、端は自動で丸くなる
scene.add(mesh1, mesh2, ...) でオブジェクトを並べるmin(d1, d2) で距離場ごと合成する(第24章でさらに差・積・smooth min)
WebGLRenderer({ antialias: true })(MSAA。ポリゴンの縁に効く)fwidth+smoothstep。MSAA はシェーダーが塗った模様の縁には効かないため、シェーダー内の形は自前でぼかす

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

この章のコードはsrc/lessons/08-drawing-shapes/にあります。デモのボタンで対象のシェーダーに切り替えてから編集してください。

まとめ

次章は三角関数です。この章のデモでも半径の脈動にこっそりsinを使いましたが、振幅・周波数・位相という 3 つのつまみを手に入れると、波・振動・円運動 — 「動き」の語彙が一気に広がります。距離と組み合わせれば、輪郭が波打つ円もすぐそこです。