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

UV 座標と色

ここから第2部「シェーダーで学ぶ数学」です。第6章で作ったハーネスの上で、以降しばらくはフラグメントシェーダーだけを書いていきます。main.tsは canvas とシェーダーを渡す数行だけになり、主役は.fragファイルに移ります。

その第 1 歩となるこの章のテーマは、座標です。 どんなに複雑なシェーダー作品も、突き詰めれば「いま塗っているピクセルの位置から、 そのピクセルのを計算する 1 つの関数」です。だから最初に、位置の表し方(この章で決める 2 つの標準座標)と、色の作り方・混ぜ方(mixを中心とした補間関数)を固めます。第8〜10章は、この章で決めた座標の書き方をそのまま前提にします。

three.js で言えば、ShaderMaterialfragmentShaderに書いていた中身そのものの世界です。CPU 側の段取りはもう組み上がっているので、GLSL に集中できます。

この章のデモ。ボタンで 4 つのフラグメントシェーダーを切り替えられます(canvas は 1 つで、押すたび にシェーダーだけ差し替えています)。「UV の可視化」は座標をそのまま色にしたデバッグ表示、「円と歪み」 は補正前後の円の比較、「補間関数」はmix/smoothstep/step/fractの見た目カタログ、「グラデーション」はそれらだけで作った仕上げの一枚です(ポインタを動かすと配色が変わります)。

この章で学ぶこと:

1. UV 座標 — 画面の位置を 0〜1 にする

フラグメントシェーダーには、いま塗っているピクセルの位置が組み込み変数gl_FragCoord(第5章)として届いています。単位はピクセルで、原点は左下です。ただしピクセル単位のままだと、canvas の解像度が変わるたびに数値の意味が変わってしまいます。 そこで、画面全体のサイズu_resolutionで割って、位置を「画面の何割の場所か」という 0〜1 の値に正規化します。

UV 座標の定義(この章の標準その 1)
// gl_FragCoord.xy: いま塗っているピクセルの位置(左下原点・ピクセル単位)
// u_resolution:    描画バッファ全体のサイズ(ピクセル)
vec2 uv = gl_FragCoord.xy / u_resolution; // → どちらの成分も 0〜1 に収まる

この 0〜1 の座標を UV 座標と呼びます(第6章のfirst.fragで「詳しくは第7章」とだけ書いて使った 1 行が、まさにこれでした)。x でも y でもない文字が必要だったから u と v が当てられた、という程度の由来で、テクスチャ画像の位置指定(第16章)でも同じ名前が使われます。 0〜1 に正規化しておく利点は、解像度から独立することです。「画面の中央」は canvas が何ピクセルでも常に (0.5, 0.5) で、リサイズしても devicePixelRatio が変わっても、コードは 1 文字も変わりません。

そして UV 座標を初めて使うときに必ずやってほしいのが、デモの「UV の可視化」です。uv.xを赤(R)、uv.yを緑(G)にそのまま出力します。

src/lessons/07-uv-and-color/01-uv-debug.frag
#version 300 es

// 第7章デモ 1: UV 座標の可視化 — シェーダーデバッグの定番。
// uv.x を赤(R)、uv.y を緑(G)にそのまま出力する。
//   左下 (0, 0) → 黒 / 右下 (1, 0) → 赤 / 左上 (0, 1) → 緑 / 右上 (1, 1) → 黄
// 「座標が思ったとおりに取れているか?」を疑ったら、まずこの形に戻って目で確かめる。

precision highp float;

uniform vec2 u_resolution; // 描画バッファのサイズ(ピクセル)

out vec4 fragColor;

void main() {
  // gl_FragCoord.xy は、いま塗っているピクセルの位置(左下原点・ピクセル単位)。
  // 画面全体のサイズ u_resolution で割ると、canvas の大きさによらず
  // どちらの成分も 0〜1 に収まる。これが UV 座標
  vec2 uv = gl_FragCoord.xy / u_resolution;

  // x を R、y を G に入れる。B は使わない。
  // 色がそのまま「このピクセルの座標の値」の読み出し結果になる
  fragColor = vec4(uv.x, uv.y, 0.0, 1.0);
}
画面全体中央 = (0.5, 0.5)(0, 0) → 黒(1, 0) → 赤(0, 1) → 緑(1, 1) → 黄+u (uv.x)+v (uv.y)原点は左下
UV 空間の四隅の値と、デモ「UV の可視化」でそれぞれの隅に出る色。y は上向きに増えることに注意してください。

たった 2 行ですが、これはこの先ずっと使う最重要のデバッグ手法です。 シェーダーには console.log がありません(画面のピクセルが唯一の出力です)。 だから「座標の変換を間違えていないか」「この式はどんな値の分布になるのか」を調べる手段は、値を色にして目で見ることです。この後の節でも、何か新しい値を作るたびに頭の中でこの可視化をやり直すつもりで読んでください。

デモで確認してほしいのは原点の位置です。赤(uv.x)は右へ行くほど強くなり、緑(uv.y)はへ行くほど強くなります。つまり原点 (0, 0) は左下です。DOM(clientYや CSS)は左上原点・y 下向きなので、上下が逆です。第2章のviewport/scissorから始まって、第6章のハーネスがマウス座標を左下原点へ変換していたのも、すべてこの WebGL の流儀に合わせるためでした。なお Shadertoy のfragCoord / iResolution.xyもまったく同じ左下原点の UV です。

2. アスペクト比の壁 — その円、楕円になります

UV 座標が手に入ったので、さっそく何か描きたくなります。定番は円です。「中心からの距離が半径以下なら塗る」 — 距離はdistance(またはlength。第5章)で出せます。デモ「円と歪み」の左半分は、それを素直に uv でやったものです。

02-aspect.frag(抜粋) — uv のまま円を描く
// uv 空間では「中心 (0.25, 0.5)・半径 0.18 の円」のつもりだが、
// uv の 0〜1 の正方形ごと canvas に引き伸ばされるため、画面上では楕円になる
float dLeft = distance(uv, vec2(0.25, 0.5));
float circleLeft = 1.0 - smoothstep(0.175, 0.185, dLeft);

smoothstepの役割は 4 節で説明するので、ここでは「距離 0.18 のところに境目を作って内側を塗る」と読んでください。 式の上ではどこにも間違いはありません。uv 空間で、中心 (0.25, 0.5) から距離 0.18 以内の点の集合 — 定義どおりの円です。ところがデモを見ると、横に伸びた楕円が表示されます。

原因は uv 自体にあります。uv は 0〜1 × 0〜1 の正方形ですが、この canvas は横長(CSS で 3:2)です。正方形の座標系を横長の画面にそのまま貼り付けているので、uv 空間で見て正しい円も、 画面に出るときは横方向だけ 1.5 倍に引き伸ばされます。数値で言えば、uv の「横に 0.18」は横幅の 18% = 0.18 × 幅ピクセルで、「縦に 0.18」は高さの 18% = 0.18 × 高さピクセル。同じ 0.18 が、向きによって違うピクセル数を意味してしまうのです。

実はこの歪み、初登場ではありません。第3章では左右対称のはずの三角形が横に伸びて表示され、「補正は後の章で」 としていました。第4章の回転する三角形が、回転の途中で伸び縮みして見えたのも同じ理由です。クリップ空間の -1〜+1 の正方形も、uv の 0〜1 の正方形も、canvas のアスペクト比を知らないまま画面いっぱいに引き伸ばされる— これが 4 章越しに引きずってきた「歪み」の正体です。この章で決着をつけます。

3. 標準座標 p — 中心原点とアスペクト比補正

歪みの原因は「x と y で、単位あたりのピクセル数が違うこと」でした。ならば直し方も決まっています。x も y も、同じ 1 つの長さを基準に測り直せばよいのです。ついでに、原点も左下から 画面中央へ移します。円や渦のような図形は中心から描くことが多く、中心が (0.5, 0.5) のままだと毎回引き算することになるからです。段階を踏んで導出します。

uv から標準座標 p への導出(2 手順)
vec2 uv = gl_FragCoord.xy / u_resolution;

// 手順 1: ×2 − 1 で、0〜1 を -1〜+1 に写す。これで画面中央が原点になる。
// 実はこの範囲は NDC(クリップ空間を w で割った後の座標。第3章)の xy と同じで、
// 歪みもまだそのまま
vec2 ndc = uv * 2.0 - 1.0;

// 手順 2: いったんピクセルに戻し(× u_resolution)、
// 縦横どちらも「短辺の長さ min(w, h)」という同じ 1 つの数で割り直す。
// x も y も同じ数で割るので、「長さ 1.0」が縦横で同じピクセル数になる
vec2 p = ndc * u_resolution / min(u_resolution.x, u_resolution.y);

手順を確認します。

割る基準に短辺を選んだので、短辺方向がちょうど -1〜+1になります。横長の canvas なら y が -1〜+1 で、x はそれより広い範囲に届きます(この canvas は 3:2 なので x は -1.5〜+1.5)。言い換えると「-1〜+1 の正方形が、canvas がどんな形でも必ず画面内に収まる」基準です。逆にmaxで割ると長辺が -1〜+1 になり、正方形の上下(または左右)が画面からはみ出します。

uv(補正なし)円のつもり(0, 0)(1, 1)0〜1 の正方形が横に引き伸ばされるp(補正済み)真円y = +1y = -1x = +1.5x = -1.5短辺(縦)が -1〜+1。長さの基準が縦横で同じ
補正前後の座標範囲(canvas が 3:2 の場合)。左の uv では正方形の座標系が画面に引き伸ばされて円が潰れます。右の標準座標 p では短辺が -1〜+1 になり、x は ±1.5 まで届く代わりに、円は真円のままです。

2 つの手順を 1 行にまとめたものを、この章の標準その 2 とします。第8章以降のシェーダーは、断りなくこの 2 行から始まります

この章で確立する 2 つの標準座標(第8〜10章はこの記法を前提にする)
vec2 uv = gl_FragCoord.xy / u_resolution; // 0〜1。左下原点
vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
// ↑ 中心原点・アスペクト比補正済み。短辺方向が -1〜+1

1 行にまとめた式は、導出の 2 手順を代数的に整理しただけです(uv を展開して分配するとgl_FragCoord.xy * 2.0 - u_resolutionが出てきます)。デモ「円と歪み」の右半分が、このpで同じ大きさの円を描いたものです。全文を載せます。

src/lessons/07-uv-and-color/02-aspect.frag
#version 300 es

// 第7章デモ 2: アスペクト比の壁。
// 「半径が一定の円」を、左半分は uv 基準(補正なし)、右半分は p 基準(補正あり)で塗る。
// 左は canvas の横縦比のぶんだけ横に伸びて楕円になり、右は常に真円になる。

precision highp float;

uniform vec2 u_resolution; // 描画バッファのサイズ(ピクセル)

out vec4 fragColor;

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution;

  // この章で決める標準座標 p: 中心原点・アスペクト比補正済み。
  // 短辺方向がちょうど -1〜+1 になる(導出は本文 3 節)
  vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);

  // --- 左半分: uv のまま円を描く(補正なし) ---
  // uv 空間では「中心 (0.25, 0.5)・半径 0.18 の円」のつもりだが、
  // uv の 0〜1 の正方形ごと canvas に引き伸ばされるため、画面上では楕円になる
  float dLeft = distance(uv, vec2(0.25, 0.5));
  float circleLeft = 1.0 - smoothstep(0.175, 0.185, dLeft);

  // --- 右半分: p で円を描く(補正あり) ---
  // 円の中心は「uv の (0.75, 0.5) にあたる点」。中心の座標にも同じ補正式を通す
  vec2 center =
      (vec2(0.75, 0.5) * u_resolution * 2.0 - u_resolution) /
      min(u_resolution.x, u_resolution.y);
  float dRight = distance(p, center);
  // p の単位は「短辺の半分 = 1.0」なので、半径 0.36 は左の 0.18(縦基準)と同じ見た目の大きさ
  float circleRight = 1.0 - smoothstep(0.35, 0.37, dRight);

  // step(0.5, uv.x) は画面の左半分で 0、右半分で 1。これで左右を塗り分ける
  float side = step(0.5, uv.x);
  float circle = mix(circleLeft, circleRight, side);

  // 左(補正なし)は警告っぽい暖色、右(補正済み)は落ち着いた寒色
  vec3 background = vec3(0.06, 0.07, 0.09);
  vec3 colorLeft = vec3(0.90, 0.45, 0.25); // 楕円になってしまう uv 基準
  vec3 colorRight = vec3(0.30, 0.75, 0.65); // 真円になる p 基準
  vec3 color = mix(background, mix(colorLeft, colorRight, side), circle);

  // 中央に境界線を 1 本(smoothstep で細く塗る。境目の塗り方の本格解説は第8章)
  float divider = 1.0 - smoothstep(0.001, 0.003, abs(uv.x - 0.5));
  color = mix(color, vec3(0.35), divider);

  fragColor = vec4(color, 1.0);
}

読みどころは 2 つです。

「距離を測ってsmoothstepで塗る」— 実はこれが、次の第8章の主題である形を描く技法の入口です。この章では グラデーションの材料としてだけ使い、円・矩形・線分を自在に描く本格活用は次章に譲ります。

4. 色を作る・混ぜる — mix と仲間たち

座標が整ったので、色の番です。といっても、色のための特別な型はありません。第3章からずっとやってきた とおり、色はvec3(RGB)の各成分を 0〜1 で持つだけです。vec3(1.0, 0.0, 0.0)が赤、vec3(1.0)が白、vec3(0.0)が黒。ただの数値ベクトルなので、足し算も定数倍もスウィズル(第5章)も自由です。color.bgrと書けば赤と青が入れ替わり、color * 0.5で暗くなります。 「色 = 3 本の数値のグラデーション」という感覚が、この節の土台です。

色作りの中心にあるのが、2 つの値を混ぜる mix です。

mix — 線形補間(lerp)
// mix(a, b, t) = a * (1.0 - t) + b * t  … 成分ごとの単純な重み付き平均
vec3 color = mix(colorA, colorB, t); // t = 0.0 で colorA、1.0 で colorB、0.5 で中間色

mix(a, b, t)abt : (1 − t)ではなく(1 − t) : tの重みで混ぜる線形補間(lerp)です。覚え方は「tはどれだけbに寄るか」。注意点が 1 つあります。mixt を 0〜1 に制限しません。範囲外のtを渡すと素直に外挿して、0〜1 を超えた色成分を作ります(画面に書き込まれる直前に成分ごとに 0〜1 へ 切り詰められるので、結果は「振り切れた色」になります)。tの範囲が保証できない式を書いたときは、clamp(t, 0.0, 1.0)で 0〜1 に収めてから渡すのが安全です。

残りの 3 つの関数は、いずれも「tの作り方」を豊かにする道具です。定義は第5章のカタログに 出ているので、ここではグラデーションとしての見た目で覚えます。デモの「補間関数」に 切り替えてください。同じ「暗い色 → 明るい色」の帯が 4 本並んでいて、違うのはtの作り方だけです。

帯(上から)t の作り方見た目
最上段(オレンジ)t = uv.x一定の速さで変わる、直線的なグラデーション
2 段目(緑)t = smoothstep(0.25, 0.75, uv.x)0.25 までは 0、0.75 からは 1、間は S 字カーブでなめらかな境目。両端が平らになる
3 段目(青)t = step(0.5, uv.x)0.5 を境に 0 か 1 だけ。くっきりした境目で、グラデーションが消える
最下段(ピンク)t = fract(uv.x * 4.0)0 → 1 の上り坂を 4 回繰り返すのこぎり波。縞模様になる

言葉でも整理しておきます。

デモの全文はこちらです。帯の分割にも fract と整数化を使っています。

src/lessons/07-uv-and-color/03-functions.frag
#version 300 es

// 第7章デモ 3: 補間関数のカタログ。
// 画面を横帯 4 本に分け、同じ「暗い色 → 明るい色」のグラデーションを
// t の作り方だけ変えて並べる。上から順に:
//   最上段(オレンジ): t = uv.x             — mix にそのまま。直線的な変化
//   2 段目(緑):       t = smoothstep(...)  — 0.25〜0.75 の間だけ、なめらかに 0 → 1
//   3 段目(青):       t = step(0.5, uv.x)  — 0.5 を境に 0 か 1。中間がない
//   最下段(ピンク):   t = fract(uv.x*4.0)  — のこぎり波。0〜1 を 4 回繰り返す縞
// band の番号は下から 0, 1, 2, 3(uv.y は下が 0 なので)。上の並びとは逆向きに数える

precision highp float;

uniform vec2 u_resolution; // 描画バッファのサイズ(ピクセル)

out vec4 fragColor;

void main() {
  vec2 uv = gl_FragCoord.xy / u_resolution;

  // 何本目の帯か(下から 0, 1, 2, 3)と、帯の中での縦位置(0〜1)
  int band = int(uv.y * 4.0);
  float bandY = fract(uv.y * 4.0);

  float t; // 「混ぜる割合」。帯ごとに作り方だけを変える
  vec3 bright; // 帯ごとの明るい側の色(どの帯か見分けるため)
  if (band >= 3) {
    t = uv.x; // そのまま渡す = 一定の速さで変わる直線的なグラデーション
    bright = vec3(0.95, 0.55, 0.25); // オレンジ
  } else if (band == 2) {
    t = smoothstep(0.25, 0.75, uv.x); // 0.25 以下は 0、0.75 以上は 1、間はなめらかな S 字
    bright = vec3(0.35, 0.85, 0.55); // 緑
  } else if (band == 1) {
    t = step(0.5, uv.x); // 0.5 未満は 0、0.5 以上は 1。くっきりした境目
    bright = vec3(0.35, 0.55, 0.95); // 青
  } else {
    t = fract(uv.x * 4.0); // 整数部を捨てる → 0 → 1 の上り坂を 4 回繰り返す
    bright = vec3(0.90, 0.40, 0.70); // ピンク
  }

  vec3 dark = vec3(0.07, 0.08, 0.11);
  vec3 color = mix(dark, bright, t);

  // 帯と帯の境目を暗くして区切りを見せる(bandY が 0 と 1 に近いところだけ暗く)
  float separator = smoothstep(0.0, 0.06, bandY) * (1.0 - smoothstep(0.94, 1.0, bandY));
  color *= mix(0.3, 1.0, separator);

  fragColor = vec4(color, 1.0);
}

5. グラデーション設計 — mix の入力を差し替える

ここまでの帯はすべてuv.xを材料にした「左 → 右」でした。しかしmixにとってtはただの数値です。どこから来た値かは問われないので、材料を差し替えれば グラデーションの方向も形も動きも変わります。

t の材料カタログ(位置系は 0〜1 に収まる。length(p) だけは画面の隅で 1 を超えるので clamp を通している)
// mix(colorA, colorB, t) の t に何を入れるかで、グラデーションの姿が決まる
t = uv.x;                          // 左 → 右
t = uv.y;                          // 下 → 上
t = (uv.x + uv.y) * 0.5;           // 斜め
t = clamp(length(p), 0.0, 1.0);    // 中心から放射状(真円になるのは p のおかげ)。
                                   // length(p) は画面の隅で 1 を超えるので clamp する
t = 0.5 + 0.5 * sin(u_time);       // 位置と無関係に、時間で全体が往復
t = fract(uv.x * 4.0);             // 繰り返す縞

位置(uv/p)を入れれば空間方向のグラデーション、時間(u_time)を入れれば アニメーション、そして両方を足せば「動くグラデーション」になります。3 色以上に増やしたいときは、mixを段重ねにします。

3 色を重ねる
// 3 色のグラデーション: t の前半で A → B、後半で B → C と 2 段重ねる。
// smoothstep の範囲を重ねたりずらしたりすると、各色の「幅」を設計できる
vec3 color = mix(colorA, colorB, smoothstep(0.0, 0.5, t));
color = mix(color, colorC, smoothstep(0.5, 1.0, t));

1 段目の結果をそのまま 2 段目のa側に渡すのがコツです。smoothstepの範囲(ここでは 0〜0.5 と 0.5〜1)を広げたり狭めたり重ねたりすると、各色が占める幅と境目のなめらかさを 別々に設計できます。実際、デモの04-gradient.fragでは範囲を 0〜0.65 と 0.60〜1.0 に重ねて、真ん中の色の遷移を長めに取っています。

もうひとつの入力がポインタです。u_mouseは第6章で決めたとおり「描画バッファのピクセル・左下原点」なので、混ぜる割合として使うにはu_resolutionで割って 0〜1 にします。

u_mouse を混ぜ具合に使う
// u_mouse はピクセル値(左下原点)なので、割合として使うなら u_resolution で割る
vec2 mouse = u_mouse / u_resolution; // 0〜1。uv とちょうど同じ座標系になる
vec3 color = mix(colorA, colorB, mouse.x); // ポインタの横位置で 2 色を混ぜる

以上を全部使った仕上げが、デモの「グラデーション」です。材料はこの章で出てきたものだけ —pmixの段重ね、smoothsteplength、そして揺らし役のsin(第4章から使っているもの。本格的な使いこなしは第9章)です。

src/lessons/07-uv-and-color/04-gradient.frag
#version 300 es

// 第7章デモ 4: 仕上げ — この章の道具だけで作る、ゆっくり動く多色グラデーション。
// 使っているのは mix / smoothstep / length / clamp と、揺らし用の sin だけ
// (sin は第4章から登場済み。本格的な使いこなしは第9章)。
// ノイズも図形(SDF)もまだ使っていない — それらは後の章の題材。

precision highp float;

uniform float u_time; // 経過時間(秒)
uniform vec2 u_resolution; // 描画バッファのサイズ(ピクセル)
uniform vec2 u_mouse; // ポインタ位置(ピクセル・左下原点。未操作時は画面中央)

out vec4 fragColor;

void main() {
  // 標準座標 p: 中心原点・アスペクト比補正済み・短辺方向が -1〜+1(本文 3 節)
  vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);

  // u_mouse はピクセル値なので、u_resolution で割って 0〜1 の割合にしてから使う
  vec2 mouse = u_mouse / u_resolution;

  // 混ぜる割合 t: 斜めの位置を土台に、時間でゆっくり揺らし、
  // ポインタの横位置で全体を持ち上げ下げする。mix に渡す前に 0〜1 へ clamp
  float t = 0.5
      + 0.35 * p.x * sin(u_time * 0.13)
      + 0.35 * p.y * sin(u_time * 0.09 + 1.7)
      + 0.30 * (mouse.x - 0.5);
  t = clamp(t, 0.0, 1.0);

  // 3 色を 2 段の mix で重ねる。smoothstep の範囲が「どの t でどの色へ移るか」の設計図
  vec3 colorA = vec3(0.08, 0.12, 0.30); // 藍
  vec3 colorB = vec3(0.72, 0.28, 0.40); // 茜
  vec3 colorC = vec3(0.98, 0.84, 0.55); // 金
  vec3 color = mix(colorA, colorB, smoothstep(0.0, 0.65, t));
  color = mix(color, colorC, smoothstep(0.60, 1.0, t));

  // 縁を少し暗くする(ビネット)。length(p) の等距離線が真円になるのは、補正済みの p だから
  color *= 1.0 - 0.35 * smoothstep(0.5, 1.4, length(p));

  // ポインタの縦位置で、全体をほんの少しだけ白へ寄せる(2 つ目の「混ぜ具合」の入力)
  color = mix(color, vec3(1.0), 0.08 * mouse.y);

  fragColor = vec4(color, 1.0);
}

構造は単純で、「tを 1 つ作る → 3 色を 2 段のmixで塗る → length(p)で縁を落とす」の 3 段です。tの中に斜めの位置・時間・ポインタの 3 つの材料が同居していて、それぞれの係数(0.35 や 0.30)が「効き具合」になっています。ノイズも図形もまだ使っていないのに、それなりに見られる 1 枚になる — グラデーション設計だけでも表現の道具として成立することを確かめてください。

コード全文

4 つのフラグメントシェーダーは各節に掲載したとおりです(ファイルはsrc/lessons/07-uv-and-color/にあります)。ここでは CPU 側のmain.tsだけ載せます。この章から導入した切替式デモの仕組みで、ボタンが押されたら第6章のstop()で現在のループを止め、新しいシェーダーでstartFullscreenShaderをやり直しています(古いプログラムなど GPU リソースの解放は省略しています — 後始末は第35章)。 第2部の以降の章も、この形を使い回します。

src/lessons/07-uv-and-color/main.ts
// 第7章: UV 座標と色
// デモは第6章のハーネスの上で動く。この章から、書くのはフラグメントシェーダーだけ。
import { startFullscreenShader } from '../../lib/fullscreen-shader';
import uvDebugSource from './01-uv-debug.frag?raw';
import aspectSource from './02-aspect.frag?raw';
import functionsSource from './03-functions.frag?raw';
import gradientSource from './04-gradient.frag?raw';

// 切替式デモ: ボタンで表示するフラグメントシェーダーを切り替える
const demos = [
  { id: 'uv-debug', label: 'UV の可視化', source: uvDebugSource },
  { id: 'aspect', label: '円と歪み', source: aspectSource },
  { id: 'functions', label: '補間関数', source: functionsSource },
  { id: 'gradient', label: 'グラデーション', source: gradientSource },
] 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 との対応

この章で書いた GLSL を、three.js で使ってきた道具に対応付けます。

three.jsこの章の GLSL
THREE.Color(r, g, b を各 0〜1 で持つ)vec3 をそのまま色として使う
color.lerp(other, t)/ Color.lerpColors(a, b, t)mix(a, b, t)
THREE.MathUtilslerp(a, b, t) / clamp(x, min, max) /smoothstep(x, min, max)mix/ clamp /smoothstep(edge0, edge1, x)。GLSL のsmoothstepx最後の引数で、順序が違う点に注意
ShaderMaterialのフラグメントシェーダーに varying で届く uv(ジオメトリが持つ UV 属性由来。頂点シェーダーから受け渡す)gl_FragCoord.xy / u_resolutionで自作。全画面描画では「画面上の位置」がそのまま UV になるため、頂点からの受け渡しが要らない
PerspectiveCamera.aspectを設定すると射影行列が歪みを補正してくれるmin(u_resolution.x, u_resolution.y)で割る自前の補正。カメラと射影行列そのものは第11〜12章で自作する

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

この章のコードはsrc/lessons/07-uv-and-color/にあります。切替式になったので、いま画面に出しているシェーダーのファイルを編集すると即座に反映されます。

まとめ

次章は「形を描く」です。この章のpの上で、lengthによる円、矩形や線分の距離、そしてsmoothstepによる輪郭のアンチエイリアス — 距離をもとに形を塗る技法(のちの第24章で SDF として本格化する考え方)を身につけます。