UV 座標と色
ここから第2部「シェーダーで学ぶ数学」です。第6章で作ったハーネスの上で、以降しばらくはフラグメントシェーダーだけを書いていきます。main.tsは canvas とシェーダーを渡す数行だけになり、主役は.fragファイルに移ります。
その第 1 歩となるこの章のテーマは、座標と色です。 どんなに複雑なシェーダー作品も、突き詰めれば「いま塗っているピクセルの位置から、 そのピクセルの色を計算する 1 つの関数」です。だから最初に、位置の表し方(この章で決める 2 つの標準座標)と、色の作り方・混ぜ方(mixを中心とした補間関数)を固めます。第8〜10章は、この章で決めた座標の書き方をそのまま前提にします。
three.js で言えば、ShaderMaterialのfragmentShaderに書いていた中身そのものの世界です。CPU 側の段取りはもう組み上がっているので、GLSL に集中できます。
mix/smoothstep/step/fractの見た目カタログ、「グラデーション」はそれらだけで作った仕上げの一枚です(ポインタを動かすと配色が変わります)。この章で学ぶこと:
- UV 座標(0〜1・左下原点)と、「座標を色にして目で確かめる」デバッグ手法
- uv のまま円を描くと楕円になる理由 — 第3章から続いた「歪み」の正体
- 中心原点・アスペクト比補正済みの標準座標
pの導出 mix/clamp/step/smoothstep/fractで色を作り、混ぜるmixの入力を差し替えて、グラデーションの方向・形・動きを設計する
1. UV 座標 — 画面の位置を 0〜1 にする
フラグメントシェーダーには、いま塗っているピクセルの位置が組み込み変数gl_FragCoord(第5章)として届いています。単位はピクセルで、原点は左下です。ただしピクセル単位のままだと、canvas の解像度が変わるたびに数値の意味が変わってしまいます。 そこで、画面全体のサイズu_resolutionで割って、位置を「画面の何割の場所か」という 0〜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)にそのまま出力します。
#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);
}たった 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 でやったものです。
// 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) のままだと毎回引き算することになるからです。段階を踏んで導出します。
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: 中心原点化— 0〜1 の値を 2 倍して 1 引くと -1〜+1 になります。画面中央が (0, 0)、右上の隅が (+1, +1)。これはちょうど NDC(クリップ空間を w で割った後の座標。第3章で区別しました。w = 1 のうちは一致します)の xy と同じ範囲で、つまり第3章で頂点座標に使っていた、あの歪んだままの座標系です。
- 手順 2: 短辺基準に測り直す— いったん
u_resolutionを掛けてピクセルに戻し、縦横どちらも短いほうの辺の長さmin(u_resolution.x, u_resolution.y)で割ります。x と y を同じ数で割ったので、「長さ 1.0」が縦横どちらでも同じピクセル数になり、歪みが消えます。
割る基準に短辺を選んだので、短辺方向がちょうど -1〜+1になります。横長の canvas なら y が -1〜+1 で、x はそれより広い範囲に届きます(この canvas は 3:2 なので x は -1.5〜+1.5)。言い換えると「-1〜+1 の正方形が、canvas がどんな形でも必ず画面内に収まる」基準です。逆にmaxで割ると長辺が -1〜+1 になり、正方形の上下(または左右)が画面からはみ出します。
2 つの手順を 1 行にまとめたものを、この章の標準その 2 とします。第8章以降のシェーダーは、断りなくこの 2 行から始まります。
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〜+11 行にまとめた式は、導出の 2 手順を代数的に整理しただけです(uv を展開して分配するとgl_FragCoord.xy * 2.0 - u_resolutionが出てきます)。デモ「円と歪み」の右半分が、このpで同じ大きさの円を描いたものです。全文を載せます。
#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 つです。
- 円の中心にも同じ補正式を通している— 右の円の中心は「uv で (0.75, 0.5) にあたる点」ですが、uv の値をそのまま
pと比べることはできません。座標系が違う値同士を混ぜるのが、この種のバグの最大の発生源です。 中心の座標を同じ式でpの座標系へ変換してから距離を測っています。 length(p - 中心)の等距離線が真円になる— 補正済みのpでは「距離 0.36」が縦でも横でも同じピクセル数なので、等距離の点の集合が画面上でも円になります。 canvas の幅をブラウザごと変えても、右の円は円のままです。
「距離を測って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(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)はaとbをt : (1 − t)ではなく(1 − t) : tの重みで混ぜる線形補間(lerp)です。覚え方は「tはどれだけbに寄るか」。注意点が 1 つあります。mixは t を 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 回繰り返すのこぎり波。縞模様になる |
言葉でも整理しておきます。
step(edge, x)—xがedge未満なら 0、以上なら 1。if 文の代わりに「境目」を値として作る関数です。smoothstep(e0, e1, x)—e0以下で 0、e1以上で 1、間を S 字(3t² − 2t³)でつなぎます。stepのエッジをぼかした版で、範囲外は自動で 0・1 に張り付くためclampが要りません。境目のなめらかさが必要な場面すべてで主役になります。fract(x)—xの整数部を捨てて小数部だけ返します。座標に掛け算してからfractに通すと 0〜1 が何度も繰り返され、1 回作った模様を無限に敷き詰める基本手段になります(本格的なタイリングは第24章)。
デモの全文はこちらです。帯の分割にも fract と整数化を使っています。
#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はただの数値です。どこから来た値かは問われないので、材料を差し替えれば グラデーションの方向も形も動きも変わります。
// 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 色のグラデーション: 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_resolution で割る
vec2 mouse = u_mouse / u_resolution; // 0〜1。uv とちょうど同じ座標系になる
vec3 color = mix(colorA, colorB, mouse.x); // ポインタの横位置で 2 色を混ぜる以上を全部使った仕上げが、デモの「グラデーション」です。材料はこの章で出てきたものだけ —p、mixの段重ね、smoothstep、length、そして揺らし役のsin(第4章から使っているもの。本格的な使いこなしは第9章)です。
#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部の以降の章も、この形を使い回します。
// 第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.MathUtilsの lerp(a, b, t) / clamp(x, min, max) /smoothstep(x, min, max) | mix/ clamp /smoothstep(edge0, edge1, x)。GLSL のsmoothstepは x が最後の引数で、順序が違う点に注意 |
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/にあります。切替式になったので、いま画面に出しているシェーダーのファイルを編集すると即座に反映されます。
01-uv-debug.fragのuvの計算の直後にuv.y = 1.0 - uv.y;を足す。緑の向きが上下反転して、DOM と同じ「上が 0」の座標系になります。左下原点の何よりの確認です02-aspect.fragのpの式のminをmaxに変える。右の円は真円のままひとまわり大きくなります(-1〜+1 が長辺にフィットするようになり、p の「長さ 1」が担うピクセル数が 1.5 倍になるためです。x も y も同じ数で割る限り歪みは出ません)。なおcenterの式にもminがあるので、片方だけ変えると円の位置までずれます — それも含めて観察してください02-aspect.fragの左の円の中心vec2(0.25, 0.5)や半径 0.18(smoothstepの 2 つの値も一緒に)を変えて、楕円の潰れ方が中心の位置によらないことを確かめる03-functions.fragの最上段のt = uv.x;をt = uv.x * 1.5 - 0.25;に変える。mixが範囲外のtを外挿し、帯の両端に色が振り切れて平らになった領域ができます。clampを通すとどう変わるかも見てください03-functions.fragのfract(uv.x * 4.0)の 4.0 を 8.0 や 32.0 にして、縞の本数が掛けた数で決まることを確かめる04-gradient.fragのcolorA〜colorCを好きな 3 色に差し替える。sinの係数(0.13 / 0.09)を 10 倍にして速度の意味を、mouse.yの 0.08 を 0.5 にしてポインタの効き具合を確かめる
まとめ
- 標準座標その 1:
uv = gl_FragCoord.xy / u_resolution(0〜1・左下原点)。座標を色にして目で確かめるのが シェーダーデバッグの基本 - uv は 0〜1 の正方形を画面に引き伸ばした座標系なので、そのまま図形を描くと歪む — 第3・4章から続いた歪みの正体
- 標準座標その 2:
p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y)(中心原点・短辺方向が -1〜+1)。x と y を同じ長さで測り直すから歪まない。第8章以降はこの記法を前提にする - 色は 0〜1 の
vec3。mixで混ぜ、tの作り方(step/smoothstep/fract、位置・時間・ポインタ)がグラデーションの設計そのもの mixのtは clamp されない。範囲が保証できなければ自分で収める
次章は「形を描く」です。この章のpの上で、lengthによる円、矩形や線分の距離、そしてsmoothstepによる輪郭のアンチエイリアス — 距離をもとに形を塗る技法(のちの第24章で SDF として本格化する考え方)を身につけます。