三角関数
第4章の三角形は0.65 + 0.1 * sin(u_time * 2.0)で脈動していました。あのとき「* 2.0が速さ、0.1が振れ幅。使いこなしは第9章で」と先送りした約束を、この章で回収します。sin と cos は、シェーダー表現における動きと繰り返しの母です。揺れる・流れる・回る・脈打つ・縞になる — この先の章で作る動きのほとんどは、sin の 4 つのパラメータの組み合わせに分解できます。 数学の教科書のように公式を並べるのではなく、すべてのパラメータを絵で確かめていきます。
three.js でも、メッシュを揺らすときは結局Math.sin(clock.getElapsedTime())を書いてきたはずです。この章でやることはそれと同じ式です。違うのは走る場所で、CPU では 1 オブジェクトに 1 回だった計算が、フラグメントシェーダーでは全ピクセルで並列に走ります。
この章で学ぶこと:
- sin の解剖 — 振幅 A・周波数 F・位相 P・オフセット C がそれぞれ絵のどこに効くか
- ラジアンと π — sin の引数の単位
- -1〜+1 を 0〜1 に直す常套句
0.5 + 0.5 * sin(...) - 「曲線を描く」を「曲線からの距離」に置き換える(第8章の応用)
- 波の重ね合わせ — 足し算だけで複雑さが生まれる(そして、その限界)
vec2(cos(t), sin(t))による円運動modによる空間・時間の繰り返しと、位相ずらしによる伝播
1. sin を解剖する — 振幅・周波数・位相・オフセット
sin(サイン)は「なめらかに -1 と +1 の間を往復し続ける関数」です。まずはこの往復を自在に加工するための、たった 1 つの型を頭に入れます。
// この章の主役。x に入れる値と 4 つの定数で、あらゆる「揺れ」を設計する
// y = A * sin(F * x + P) + C
//
// A (振幅) : 振れ幅。中心から上下にどれだけ揺れるか
// F (周波数) : 詰まり具合。周期(1 回の繰り返しの長さ)は 2π / F
// P (位相) : 波の「進み具合」。曲線ごと左右にずらす
// C (オフセット): 中心の位置。曲線ごと上下にずらす4 つのパラメータの役割を、図と対応させて言葉にしておきます。
- 振幅 (amplitude) A— sin の結果は -1〜+1 なので、A を掛けると -A〜+A になります。「揺れの大きさ」のツマミです。
- 周波数 (frequency) F— x に掛ける係数。x が進むスピードを F 倍にするので、波が F 倍詰まります。1 回の繰り返しの長さ(周期)は 2π / F です。
- 位相 (phase) P— x に足す値。波の「進み具合」を前後にずらし、絵としては 曲線ごと左右に平行移動します。向きには注意してください。P を増やすと波は左へ、減らすと右へ動きます(山は「F・x + P = π/2」を満たす x にあるので、P が増えたぶん x は小さくなるためです)。デモが
- SPEED * u_timeとマイナスで足しているのは、波を右へ流すためです。ここに時間を入れると波が流れるのが、この章で一番使う技です。 - オフセット C— 最後に足す値。揺れの中心を -1〜+1 の中心 0 から C へ持ち上げます。
第4章の脈動0.65 + 0.1 * sin(u_time * 2.0)をこの型に当てはめると、C = 0.65、A = 0.1、F = 2.0(x は時間)、P = 0 — つまり「0.55〜0.75 の間を 周期 2π / 2.0 = π ≒ 3.1 秒で往復するツマミ」だったと読めます。
2. 値域の常套句 — -1〜+1 を 0〜1 へ
型を使う前に、いちばん頻出のイディオムを片付けます。sin の結果は -1〜+1 ですが、シェーダーで欲しい値はたいてい 0〜1 です — 色の成分も、mixの割合も 0〜1 で考えるからです(mixの第 3 引数は clamp されず、0〜1 の外では外挿になります。第6章のfirst.fragのコメントでも触れました)。そこで、A = 0.5・C = 0.5 を当てはめた次の形が定型文になります。
// sin は -1〜+1 を往復する。色や mix の割合には 0〜1 が欲しいので、
// 「半分に縮めて、0.5 持ち上げる」— A = 0.5, C = 0.5 の形にする
float t = 0.5 + 0.5 * sin(u_time); // 0〜1 を往復
vec3 color = mix(colorA, colorB, t);「半分に縮めて(× 0.5)、中心を 0.5 に持ち上げる(+ 0.5)」— それだけです。逆に「0.2〜0.8 を往復させたい」なら中心 C = 0.5、振れ幅 A = 0.3 で0.5 + 0.3 * sin(...)。欲しい範囲の中心が C、半分の幅が Aという読み方ができれば、もう sin の値域で迷うことはありません。第4章の0.65 + 0.1 * sin(...)も、第6章のfirst.fragにあった0.15 * sin(...)も、全部この仲間です。
3. 波を画面に描く — 「曲線」を「距離」に置き換える
ここから絵にしていきます。デモ 1 本目「sin 波の解剖」は、曲線 y = A・sin(F・x + P) + C を線として描いたものです。ただし、フラグメントシェーダーには「線を引く」命令はありません。 あるのは「いま塗っている 1 ピクセルの色を決める」ことだけです。そこで第8章で確立した 考え方をそのまま使います —形は、形からの距離が小さいところを塗れば現れる。
「曲線の上にあるピクセル」とは、自分の x での曲線の高さsin(p.x)と、自分の高さp.yがほぼ一致しているピクセルのことです。つまり差の絶対値abs(p.y - sin(p.x))が、そのまま「曲線からの(縦方向の)距離」になります。あとは第8章の常套句どおり、距離をfwidthで滑らかに 0/1 のマスクへ変え、mixで色を塗るだけです。
// 「曲線 y = A・sin(F・x + P) + C の上」をピクセルの言葉に直す:
// 各ピクセルが、自分の高さ p.y と曲線の高さ wave の差を測る(第8章の距離→マスク)。
// fwidth は abs を取る前の符号付きの値に掛ける(折れ目で幅がつぶれるのを避ける)
float wave = AMPLITUDE * sin(FREQUENCY * p.x - SPEED * u_time) + OFFSET;
float sd = p.y - wave;
float aa = fwidth(sd);
float dist = abs(sd);
float line = 1.0 - smoothstep(0.025 - aa, 0.025 + aa, dist);座標は第7章で決めた標準座標(中心が原点、短い辺が -1〜+1)なので、振幅 0.5 の波は画面の 上下方向の半分弱を使って揺れます。そして注目してほしいのが位相です。- SPEED * u_timeと、位相の位置に時間を入れています。1 フレームごとに波全体がわずかに 左右へ平行移動し続ける — これが「波が流れる」の正体です。静止した曲線の絵と流れる波の間に、 追加の仕組みは何もありません。位相に時間を足しただけです。
全文を載せます。冒頭の定数 4 つが、そのまま第 1 節の A・F・P・C に対応しています。
#version 300 es
// 第9章 デモ1: sin 波の解剖
// 曲線 y = A * sin(F * x + P) + C を 1 本の線として描く。
// 下の定数 4 つを書き換えて、それぞれが波のどこに効くかを確かめるためのシェーダー。
precision highp float;
uniform float u_time;
uniform vec2 u_resolution;
out vec4 fragColor;
// ---- 書き換えて試すための定数 ----------------------------------------
// A: 振幅。中心線からの振れ幅(波の高さ)
const float AMPLITUDE = 0.5;
// F: 周波数。x 方向にどれだけ波が詰まるか(周期は 2π / F)
const float FREQUENCY = 3.0;
// 位相 P を時間で進める速さ。0.0 にすると波は止まる
const float SPEED = 2.0;
// C: オフセット。波全体を上下にずらす
const float OFFSET = 0.0;
// ----------------------------------------------------------------------
void main() {
// 第7章の標準座標: 中心が原点、短い辺が -1〜+1
vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
// 曲線の高さ。位相 P の位置に -SPEED * u_time を入れているので波は右へ流れる
float wave = AMPLITUDE * sin(FREQUENCY * p.x - SPEED * u_time) + OFFSET;
// 「このピクセルは曲線の上か」= 自分の高さ p.y と曲線の高さの差(第8章の距離→マスク)。
// fwidth は符号付きの sd に対して取る。abs(sd) に取ると、曲線の真上(折れ目)で
// 左右の差が打ち消し合って幅がつぶれ、縁の AA が効かなくなることがある
float sd = p.y - wave;
float aa = fwidth(sd);
float dist = abs(sd);
float line = 1.0 - smoothstep(0.025 - aa, 0.025 + aa, dist);
// 曲線より下もうっすら塗って、波の「面」も見えるようにする
float below = 1.0 - smoothstep(-aa, aa, sd);
vec3 color = vec3(0.06, 0.07, 0.09); // 背景(canvas の CSS 背景色と同じ)
color = mix(color, vec3(0.16, 0.24, 0.38), below * 0.6);
color = mix(color, vec3(0.55, 0.80, 1.00), line);
fragColor = vec4(color, 1.0);
}4. 波を重ねる — 足し算だけで複雑になる
sin 1 本は、どこまでいっても単調な波です。しかし周波数の違う sin を足すと、様子が変わります。デモ 2 本目「波の重ね合わせ」では、3 本の sin 波(細い色分けした線)と、それを単純に足しただけの合成波(太い明るい線)を同時に描いています。
float wave1 = 0.40 * sin(2.0 * p.x + 1.1 * u_time);
float wave2 = 0.20 * sin(5.0 * p.x - 1.7 * u_time);
float wave3 = 0.10 * sin(11.0 * p.x + 2.9 * u_time);
// 重ね合わせは、ただの足し算
float sum = wave1 + wave2 + wave3;合成波は、もはや一目では sin と分からないうねりになります。3 本がそれぞれ違う速さ・違う向きに 流れているので、合成波の形そのものが時々刻々と変わり続けるのもポイントです。ここでの設計には 定石があります — 周波数を上げるほど、振幅を下げる(このデモでは 0.40 / 0.20 / 0.10)。低い周波数が大きな起伏を作り、高い周波数が細部の揺れを添える、という役割分担です。
#version 300 es
// 第9章 デモ2: 波の重ね合わせ
// 振幅・周波数・速さの違う 3 本の sin 波(細い線)と、
// それを全部足しただけの合成波(太い明るい線)を同時に描く。
// 波を足したり係数を変えたりして、合成波の表情の変化を確かめる。
precision highp float;
uniform float u_time;
uniform vec2 u_resolution;
out vec4 fragColor;
// 「曲線 y = target からの縦方向の距離」を線のマスクにする(第8章)
float lineMask(float y, float target, float halfWidth) {
float dist = abs(y - target);
float aa = fwidth(dist);
return 1.0 - smoothstep(halfWidth - aa, halfWidth + aa, dist);
}
void main() {
vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
// 周波数を上げるほど振幅を小さくしている(細かい波ほど控えめに足す)
float wave1 = 0.40 * sin(2.0 * p.x + 1.1 * u_time);
float wave2 = 0.20 * sin(5.0 * p.x - 1.7 * u_time);
float wave3 = 0.10 * sin(11.0 * p.x + 2.9 * u_time);
// 重ね合わせは、ただの足し算
float sum = wave1 + wave2 + wave3;
vec3 color = vec3(0.06, 0.07, 0.09);
// 部品の 3 本は細く、色分けして描く
color = mix(color, vec3(0.30, 0.45, 0.85), lineMask(p.y, wave1, 0.012));
color = mix(color, vec3(0.85, 0.55, 0.30), lineMask(p.y, wave2, 0.012));
color = mix(color, vec3(0.35, 0.70, 0.45), lineMask(p.y, wave3, 0.012));
// 合成波は太く、明るく
color = mix(color, vec3(0.92, 0.95, 1.00), lineMask(p.y, sum, 0.028));
fragColor = vec4(color, 1.0);
}5. cos と円運動
ここまで sin だけでやってきました。相棒の cos(コサイン)は、実は新しい関数ではありません。cos(x) = sin(x + π/2)— つまりcos は、位相を 1/4 周期(π/2)だけ進めた sinです。第 1 節の言葉で言えば「P だけ違う同じ波」で、山がくる位置が 1/4 周期早いだけです。x = 0 での値だけ覚えておくと便利です: sin(0) = 0(中心から出発)、cos(0) = 1(てっぺんから出発)。
では、なぜわざわざ 2 つあるのか。sin と cos をペアで使うと、円運動が手に入るからです。もともと sin と cos の定義は円にあります — 半径 1 の円(単位円)の上を角度 t だけ進んだ点の、x 座標が cos(t)、y 座標が sin(t)。つまりvec2(cos(t), sin(t))は常に単位円の上の点で、t を時間で増やせばその点は円周上を回り続けます。
デモ 3 本目「円運動」は、この 1 行を 3 回使っただけのものです。点を打つのは第8章の円の 描き方(中心からの距離が半径以下なら塗る)で、中心の位置だけをvec2(cos(t), sin(t))で毎フレーム動かします。
// 角度 t = 角速度 × 時間 + 初期位相。位置は 半径 × vec2(cos(t), sin(t))
float t1 = 1.6 * speed * u_time;
vec2 pos1 = 0.30 * vec2(cos(t1), sin(t1));
color = mix(color, vec3(0.55, 0.80, 1.00), circleMask(p, pos1, 0.035));半径を変えたければ全体を r 倍、速さを変えたければ t に掛ける係数(角速度)を変える、スタート位置を変えたければ初期位相を足す — すべて第 1 節のパラメータ操作そのままです。デモでは内側の点ほど速く回して惑星の公転らしく し、ポインタの横位置で全体の速さの倍率を変えています(u_mouseは未操作時に画面中央が入る仕様(第6章)なので、触るまではちょうど等倍です)。
#version 300 es
// 第9章 デモ3: cos と sin で円運動
// vec2(cos(t), sin(t)) は「単位円の上の、角度 t の位置」。
// 半径と速さの違う 3 つの点を回し、ポインタの横位置で全体の速さを変える。
precision highp float;
uniform float u_time;
uniform vec2 u_resolution;
uniform vec2 u_mouse;
out vec4 fragColor;
// GLSL に π の定数は用意されていないので、自分で書く
const float PI = 3.141592653589793;
// 中心 center・半径 radius の塗りつぶしの円(第8章)
float circleMask(vec2 p, vec2 center, float radius) {
float dist = length(p - center) - radius;
float aa = fwidth(dist);
return 1.0 - smoothstep(-aa, aa, dist);
}
// 原点中心・半径 radius の軌道(細いリング)
float orbitMask(vec2 p, float radius) {
float dist = abs(length(p) - radius);
float aa = fwidth(dist);
return 1.0 - smoothstep(0.004 - aa, 0.004 + aa, dist);
}
void main() {
vec2 p = (gl_FragCoord.xy * 2.0 - u_resolution) / min(u_resolution.x, u_resolution.y);
// ポインタの横位置(0〜1)→ 速さの倍率(0〜2)。
// 未操作時の u_mouse は画面中央(第6章)なので、触るまではちょうど 1 倍
float speed = 2.0 * u_mouse.x / u_resolution.x;
vec3 color = vec3(0.06, 0.07, 0.09);
// 軌道を先に薄く描いておく
color = mix(color, vec3(0.25, 0.30, 0.38), orbitMask(p, 0.30));
color = mix(color, vec3(0.25, 0.30, 0.38), orbitMask(p, 0.55));
color = mix(color, vec3(0.25, 0.30, 0.38), orbitMask(p, 0.85));
// 中心の点(太陽のつもり)
color = mix(color, vec3(0.95, 0.80, 0.45), circleMask(p, vec2(0.0), 0.06));
// 惑星: 角度 t = 角速度 × 時間 + 初期位相。位置は 半径 × vec2(cos(t), sin(t))。
// 内側の惑星ほど角速度を大きく(速く)している
float t1 = 1.6 * speed * u_time;
vec2 pos1 = 0.30 * vec2(cos(t1), sin(t1));
color = mix(color, vec3(0.55, 0.80, 1.00), circleMask(p, pos1, 0.035));
float t2 = 0.9 * speed * u_time + 0.75 * PI;
vec2 pos2 = 0.55 * vec2(cos(t2), sin(t2));
color = mix(color, vec3(0.85, 0.55, 0.30), circleMask(p, pos2, 0.045));
float t3 = 0.5 * speed * u_time + 1.4 * PI;
vec2 pos3 = 0.85 * vec2(cos(t3), sin(t3));
color = mix(color, vec3(0.60, 0.85, 0.55), circleMask(p, pos3, 0.040));
fragColor = vec4(color, 1.0);
}6. mod と繰り返し
sin は「値の繰り返し」を作る関数でした。もう 1 つの繰り返しの道具がmodです。mod(x, 周期)は x を周期で割った余り、絵の言葉で言えば数直線を周期の長さで切って、全部同じ場所に 折り重ねる関数です。結果は必ず 0〜周期の範囲に収まります。第7章から使ってきたfract(x)は、実はmod(x, 1.0)とまったく同じもの — 「周期 1.0 専用の mod」だったわけです。
// mod(q, 1.0) で座標を周期 1.0 の繰り返しにし、0.5 を引いて各タイルの中心を原点にする。
// fract(x) は mod(x, 1.0) と同じものなので、fract(q) - 0.5 と書いてもよい
vec2 q = p * 1.4;
vec2 cell = mod(q, 1.0) - 0.5;座標に mod をかけると、空間そのものが繰り返します。上の 2 行で、画面は周期 1.0 のタイルに区切られ、どのタイルの中でもcellは「タイル中心を原点とする -0.5〜+0.5 の座標」になります。この後に書いた図形は、1 回書くだけで全タイルに現れます — 各ピクセルが自分のタイル内座標で同じ計算をするだけなので、 タイルが何枚あってもコストは変わりません。時間に使えばmod(u_time, 4.0)で「4 秒ごとに 0 に戻る時計」になり、アニメーションをループさせられます。
デモ 4 本目「繰り返し模様」は、この章の道具の総動員です。mod で区切った各タイルに、タイル中心からの距離 r を位相に使った sin の波紋を置き、値域は例の常套句で 0〜1 に直して 2 色のグラデーションに割り当てています。
float r = length(cell);
float phase = 2.0 * (p.x + p.y);
float ripple = 0.5 + 0.5 * sin(14.0 * r - 1.2 * u_time + phase); // -1〜+1 → 0〜1鍵は 2 行目のphaseです。位相に「タイルに区切る前の座標」を混ぜているので、場所によって波の進み具合が ずれます。全タイルの波紋が一斉に明滅するのではなく、左下のタイルから右上のタイルへ 順番に波が渡っていくように見える — 位相の場所ずらしは、この「伝播」を作る定番の手です。 係数を 0.0 にすると全タイルが同時に脈打つだけになるので、見比べてみてください。
#version 300 es
// 第9章 デモ4: sin と mod で繰り返し模様
// mod で空間を周期 1.0 のタイルに区切り、各タイルに sin の波紋を置く。
// 波紋の位相に「タイルに区切る前の座標」を混ぜているので、
// 模様はタイルをまたいでつながり、波が画面全体を伝わっていくように見える。
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);
// mod(q, 1.0) で座標を周期 1.0 の繰り返しにし、0.5 を引いて各タイルの中心を原点にする。
// fract(x) は mod(x, 1.0) と同じものなので、fract(q) - 0.5 と書いてもよい
vec2 q = p * 1.4;
vec2 cell = mod(q, 1.0) - 0.5;
// タイルの中心からの距離で波紋を作る。
// 位相に 2.0 * (p.x + p.y) を混ぜている = 場所によって波の進み具合がずれる。
// このずれが「左下から右上へ波が伝わる」動きになる(係数を 0.0 にすると全タイル同時)
float r = length(cell);
float phase = 2.0 * (p.x + p.y);
float ripple = 0.5 + 0.5 * sin(14.0 * r - 1.2 * u_time + phase); // -1〜+1 → 0〜1
// もう 1 枚、大きくゆるい斜めの sin を重ねて、画面全体の明るさをゆっくり揺らす
float swell = 0.5 + 0.5 * sin(2.0 * (p.x - p.y) + 0.4 * u_time);
// 波紋を 2 色のグラデーションに割り当てる(2 乗して暗い側を締める)
vec3 colorA = vec3(0.08, 0.10, 0.22);
vec3 colorB = vec3(0.60, 0.80, 0.95);
vec3 color = mix(colorA, colorB, ripple * ripple);
color *= 0.75 + 0.25 * swell;
// タイルの継ぎ目が気になったら、この行を有効にすると境界が赤く見える:
// color = mix(color, vec3(1.0, 0.3, 0.3), step(0.48, max(abs(cell.x), abs(cell.y))));
fragColor = vec4(color, 1.0);
}コード全文
フラグメントシェーダー 4 本は各節に掲載したとおりです。CPU 側のmain.tsは、ボタンを作ってハーネスを差し替えているだけです。切り替え時に第6章のstop()が初めて本領を発揮している点にだけ注目してください — 前のループを止めずにstartFullscreenShaderを呼ぶと、1 つの canvas に 2 つのループが同時に走ってしまいます。
// 第9章: 三角関数
// 1 つの canvas を、4 本のフラグメントシェーダーで切り替えるデモ。
// CPU 側の仕事はボタンの生成と、ハーネス(第6章)の起動・停止だけ。
import { startFullscreenShader } from '../../lib/fullscreen-shader';
import waveSource from './01-wave.frag?raw';
import superposeSource from './02-superpose.frag?raw';
import circularSource from './03-circular.frag?raw';
import patternSource from './04-pattern.frag?raw';
const demos = [
{ id: 'wave', label: 'sin 波の解剖', source: waveSource },
{ id: 'superpose', label: '波の重ね合わせ', source: superposeSource },
{ id: 'circular', label: '円運動', source: circularSource },
{ id: 'pattern', label: '繰り返し模様', source: patternSource },
] 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 に「sin を隠してくれる API」はほとんどありません。CPU 側で書いてきた式が、そのまま GPU 側に引っ越すだけです。
| three.js での書き方 | この章 |
|---|---|
毎フレームmesh.position.y = Math.sin(clock.getElapsedTime() * 2) * 0.5のように CPU で計算して代入(オブジェクト 1 個につき 1 回) | sin(u_time * F) * Aをフラグメントシェーダーに書く。同じ式が全ピクセルで並列に評価される |
THREE.MathUtils.degToRad() / radToDeg() | 組み込み関数radians()/degrees()(第5章)。ただしシェーダーでは最初からラジアンで考えるのが普通 |
AnimationMixerとキーフレームで動きを再生する | 式で動きを作る(プロシージャルアニメーション)。振幅・周波数・位相の設計が キーフレーム打ちの代わりになる |
THREE.MathUtils.euclideanModulo(x, m) | mod(x, m)。JavaScript の%ではなくこちらが GLSL の mod と同じ挙動(負の x でも 0〜m) |
EllipseCurveで円軌道を作りgetPointAt()で位置を取る | 半径 * vec2(cos(t), sin(t)) の 1 行 |
手元で動かして、壊してみる
この章のコードはsrc/lessons/09-trigonometry/にあります。sin のパラメータは、変える前に「どうなるはずか」を予想してから変えるのが いちばん力になります。
01-wave.fragのAMPLITUDE/FREQUENCY/SPEEDを1 つずつ10 倍にする(必ず予想してから)。AMPLITUDE = 5.0 では波のほとんどが画面外に出て急斜面だけが見え、FREQUENCY = 30.0 では斜面で線が痩せます — 第 3 節の「縦方向だけの距離」の限界が実際に見えるはずです01-wave.fragのu_timeを6.28 * u_mouse.x / u_resolution.xに差し替える(uniform 宣言にu_mouseを足すのを忘れずに)。時間が「ポインタで手動スクラブする軸」に変わります。位相が本当に 「波を平行移動させているだけ」だと手の感覚で分かります02-superpose.fragに 4 本目float wave4 = 0.05 * sin(23.0 * p.x - 3.7 * u_time);を足し、sumにも加える。細かい周波数を足すほど「複雑だが規則的」の印象がどう変わるか観察する03-circular.fragのspeedの式をu_mouse.y / u_resolution.yベースに変えたり、各惑星の初期位相(0.75 * PIなど)を全部 0.0 にして一直線からスタートさせたりしてみる03-circular.fragでポインタを素早く動かすと、惑星が滑らかに加減速せず一瞬でワープします。 理由を考えてみてください(角度を「速度 × 経過時間」で毎フレーム計算し直しているので、速度を 変えると過去の分までまとめて掛け直される — 滑らかに変えるには角度を積み上げる必要があり、 それには前フレームの状態の保存が要ります。第29章の伏線です)04-pattern.fragのmod(q, 1.0)の周期を 0.5 や 2.0 に変える。タイルの数がどう変わるか、- 0.5も合わせて直すべきか(タイル中心の座標はどこになるか)を考える04-pattern.fragのphaseの係数 2.0 を 0.0 にして「伝播」を消す。次に 10.0 にして、位相のずらしすぎで波面が バラバラに見えることを確かめる04-pattern.fragのu_timeをmod(u_time, 5.0)に変える。5 秒ごとに模様が一瞬「飛ぶ」のが見えるはずです。位相の連続性が切れるためで、 きれいにループさせるには周期の設計が要る、という教訓です
まとめ
- sin の型は
y = A * sin(F * x + P) + C。振幅 A = 振れ幅、周波数 F = 詰まり具合(周期は 2π / F)、位相 P = 左右ずらし、オフセット C = 上下ずらし - 引数の単位はラジアン。円 1 周 = sin 1 往復 = 2π。π の定数は自分で書く
0.5 + 0.5 * sin(...)で -1〜+1 を 0〜1 へ。一般形は「中心 C ± 振れ幅 A」- 波の線は
abs(p.y - sin(...))の距離→マスク(第8章)。位相に時間を入れると波が流れる - 重ね合わせはただの足し算。「低周波は大きく・高周波は小さく」が定石だが、どこまで足しても 周期的 — 自然なランダムさはノイズ(第25章)の仕事
vec2(cos(t), sin(t))は単位円上の点。r 倍で半径、t の係数で速さ、足し込みで初期位相mod(x, 周期)は空間も時間も繰り返す(fractは周期 1.0 の mod)。位相を場所でずらすと伝播が生まれる
次章は第2部の締めくくり、極座標と 2D 回転です。この章の円運動を裏返して「位置 → 角度」をatan(y, x)で取り出すと、画面を放射や渦で考えられるようになります。そして第4章から持ち越している sin/cos 直書きの回転の式が、mat2という行列 1 つにまとまります — 第3部「行列 = 変換」への最初の一歩です。