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

三角関数

第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 回だった計算が、フラグメントシェーダーでは全ピクセルで並列に走ります。

この章のデモ 4 本。ボタンで切り替えます(canvas は 1 つで、フラグメントシェーダーだけを 差し替えています)。「sin 波の解剖」は 1 本の sin 波を線として描いたもの、「波の重ね合わせ」は 3 本の sin とその合成波、「円運動」は半径・速さの違う 3 つの点(ポインタの横位置で速さが変わります)、 「繰り返し模様」は sin と mod で作った波紋のタイルです。

この章で学ぶこと:

1. sin を解剖する — 振幅・周波数・位相・オフセット

sin(サイン)は「なめらかに -1 と +1 の間を往復し続ける関数」です。まずはこの往復を自在に加工するための、たった 1 つの型を頭に入れます。

sin の型(この章で何度も出てくる形)
// この章の主役。x に入れる値と 4 つの定数で、あらゆる「揺れ」を設計する
//   y = A * sin(F * x + P) + C
//
//   A (振幅)      : 振れ幅。中心から上下にどれだけ揺れるか
//   F (周波数)    : 詰まり具合。周期(1 回の繰り返しの長さ)は 2π / F
//   P (位相)      : 波の「進み具合」。曲線ごと左右にずらす
//   C (オフセット): 中心の位置。曲線ごと上下にずらす
xyy = 0中心線 y = C周期 2π / F(F を上げると詰まる)振幅 AC位相 P で左右へ破線 = P だけ変えた同じ波
y = A・sin(F・x + P) + C の 4 つのパラメータ。A と F が波の形(高さと詰まり具合)を決め、P と C は形を変えずに曲線ごと平行移動します。

4 つのパラメータの役割を、図と対応させて言葉にしておきます。

第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 を当てはめた次の形が定型文になります。

-1〜+1 → 0〜1 の変換(頻出)
// 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で色を塗るだけです。

src/lessons/09-trigonometry/01-wave.frag(抜粋)
// 「曲線 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 に対応しています。

src/lessons/09-trigonometry/01-wave.frag
#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 波(細い色分けした線)と、それを単純に足しただけの合成波(太い明るい線)を同時に描いています。

src/lessons/09-trigonometry/02-superpose.frag(抜粋)
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)。低い周波数が大きな起伏を作り、高い周波数が細部の揺れを添える、という役割分担です。

src/lessons/09-trigonometry/02-superpose.frag
#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 を時間で増やせばその点は円周上を回り続けます。

xy半径 1 の円t が増える向き(反時計回り)t(cos(t), sin(t))cos(t)sin(t)角度 t の点の位置:x = cos(t)y = sin(t)t = 0 は右(x 軸の正の向き)。2π 進むと 1 周して元の位置に戻る。半径 r の円にしたいときは全体を r 倍:r * vec2(cos(t), sin(t))
単位円と角度 t。「波」として見てきた sin は、この円運動の y 成分だけを取り出して時間軸に並べたものです。cos は同じ運動の x 成分です。

デモ 3 本目「円運動」は、この 1 行を 3 回使っただけのものです。点を打つのは第8章の円の 描き方(中心からの距離が半径以下なら塗る)で、中心の位置だけをvec2(cos(t), sin(t))で毎フレーム動かします。

src/lessons/09-trigonometry/03-circular.frag(抜粋)
// 角度 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章)なので、触るまではちょうど等倍です)。

src/lessons/09-trigonometry/03-circular.frag
#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」だったわけです。

src/lessons/09-trigonometry/04-pattern.frag(抜粋)
// 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 色のグラデーションに割り当てています。

src/lessons/09-trigonometry/04-pattern.frag(抜粋)
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 にすると全タイルが同時に脈打つだけになるので、見比べてみてください。

src/lessons/09-trigonometry/04-pattern.frag
#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 つのループが同時に走ってしまいます。

src/lessons/09-trigonometry/main.ts
// 第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 のパラメータは、変える前に「どうなるはずか」を予想してから変えるのが いちばん力になります。

まとめ

次章は第2部の締めくくり、極座標と 2D 回転です。この章の円運動を裏返して「位置 → 角度」をatan(y, x)で取り出すと、画面を放射や渦で考えられるようになります。そして第4章から持ち越している sin/cos 直書きの回転の式が、mat2という行列 1 つにまとまります — 第3部「行列 = 変換」への最初の一歩です。