第3部 3D ラスタライズパイプライン — 第11章

ベクトルと行列 — gl-matrix で学ぶ線形代数

第3部の始まりです。ここからの目標は、三角形を立体としてスクリーンに映すこと。 そのために避けて通れないのが、ベクトルと行列 — いわゆる線形代数です。といっても身構える必要は ありません。第10章の最後にmat2で見た「行列 = 変換先の基底を並べたもの」という読み方を、そのまま 4×4 へ育てるだけです。

three.js を使っていたころ、position.set(...)vector.add(...)Matrix4を何気なく呼んでいたはずです。この章では、その中身にあたる演算をgl-matrixという小さなライブラリで自分の手で組みます。dot と cross のような「名前は知っているが 実感がない」演算は、マウスで動かせるデモで目に焼き付けてしまいましょう。

この章のデモ 2 本。「ベクトルの演算」はマウスで動くベクトル a と回転するベクトル b の dot・cross・射影(黄色の「影」)をその場で計算して表示します。「行列 = 変換」は同じ家に同じ 3 つの変換を順序だけ変えて掛けたもので、本体が青い家が T・R・S、本体が緑の家が S・R・T。原点に描かれる暗い家は変換前(単位行列)の姿です。数値は下の読み出し行に毎フレーム出ます。

この章で学ぶこと:

1. なぜ CPU 側にもベクトルと行列が要るのか

第5章で見たとおり、GLSL にはvec3mat4dot/ cross /normalizeが言語仕様として組み込まれています。では CPU 側には要らないのかというと、逆です。シーンの段取りはぜんぶ CPU の仕事だからです。カメラをどこに置くか、オブジェクトをどこへ動かすか、どの向きに回すか — こうした計算はフレームに一度だけ行えばよく、その結果(行列)を GPU に渡して、何万という頂点への適用だけを GPU にやらせる。これが第3部で作っていく分業です。ところが JavaScript にはベクトル型も行列型もありません。そこで登場するのがgl-matrix— WebGL 界隈で長年使われてきた、定番の線形代数ライブラリです。このプロジェクトには インストール済みなので、import するだけで使えます。

gl-matrix の out 引数スタイル
import { vec3 } from 'gl-matrix';

const a = vec3.fromValues(1, 2, 0);
const b = vec3.fromValues(4, -1, 0);

// three.js 風なら a.clone().add(b)。gl-matrix は「結果の入れ物」を先に渡す
const sum = vec3.create(); // (0, 0, 0) の Float32Array を確保
vec3.add(sum, a, b); // sum = a + b。戻り値も sum なので入れ子にもできる

最初に面食らうのがこのout 引数 (out parameter)スタイルです。three.js のa.add(b)のようなメソッドチェーンと違い、gl-matrix の関数はほぼすべて「結果を書き込む先」を第1引数に取ります。理由は割り切っていて、毎フレームのアロケーションを避けるためです。関数が呼ばれるたびに新しいオブジェクトを返す設計だと、毎秒 60 フレーム × 数十回の演算で大量の使い捨てオブジェクトが生まれ、やがてガベージコレクション (GC) が走ってフレームが跳びます。out 引数なら、入れ物を最初に確保して使い回せるので、定常状態でのアロケーションをゼロにできます。 この章のmain.tsも、デモのsetup()の先頭でスクラッチ変数(使い回し用の入れ物)をまとめて確保しています。

src/lessons/11-vectors-and-matrices/main.ts(抜粋)
const vecA = vec2.create(); // マウスに追従するベクトル a
const vecB = vec2.create(); // ゆっくり回転するベクトル b
const bHat = vec2.create(); // b の単位ベクトル(normalize の結果)
const proj = vec2.create(); // a を b の直線に射影した点
const crossAB = vec3.create(); // vec2.cross の結果(z 成分だけが残る)

なお、第5章の関数カタログではdot/ cross /normalizeの「主な出番」をこの第11章に割り当て、意味の解説を持ち越していました。ここからの 3 節でその約束を回収します。

2. ベクトル — 「位置」と「向き + 長さ」の二つの読み方

ベクトルとは、さしあたり「数の組」です。vec2 なら (x, y)、vec3 なら (x, y, z)。ただし同じ (x, y) でも、読み方が二つあります。

足し算とスケールは、この「矢印」の読み方で考えるとしっくりきます。vec2.add(out, a, b)は「a だけ進んでから b だけ進む」— 平行四辺形の対角線です。vec2.scale(out, a, 2)は向きを保ったまま長さを 2 倍にします。そしてvec2.length(a)が矢印の長さ(第8章で円を描いたlengthと同じ、三平方の定理です)、vec2.normalize(out, a)長さを 1 に揃えて「向き」だけを取り出す操作です。長さ 1 のベクトルを単位ベクトル (unit vector)と呼び、以降の dot の話で主役になります。

aba + badd: a の先から b を継ぎ足す2vvnormalize: 長さ 1 の円まで縮める(向きは不変)
左: 加算は「継ぎ足し」で、平行四辺形の対角線になります。右: normalize は向きを保ったまま長さを 1 に、scale は向きを保ったまま長さだけを変えます。

3. 内積 dot — 影を落とし、角度を測る

第8章の線分 SDF では、dotを「点を線分の上に影として落とす」道具として、直感だけ先取りしました。ここで 正体を明かします。定義は拍子抜けするほど単純で、成分どうしを掛けて足すだけです。

dot(a, b) = a.x * b.x + a.y * b.y(vec3 なら z の項が増える)— 結果は ベクトルではなく 1 個の数(スカラー)です。そしてこの単純な式が、幾何の言葉では次と一致します。

dot(a, b) = |a| × |b| × cosθ(θ は a と b のなす角)

この 1 本の式から、3 つの読み方が出てきます。デモの 1 本目で全部確かめられます。

b に垂直な方向の「光」b̂ の向きa影 = dot(a, b̂)θθ が 90° を超えると影は反対側に伸びる(dot は負になる)
dot の幾何的な意味。b に垂直な方向から光を当てると、a は b の直線上に長さ |a|cosθ の影を落とします。これがdot(a, b̂)です。

デモの計算部分はこれだけです。normalizeで b の向きだけを取り出し、dot で影の長さを測り、scaleで「向き × 長さ」に戻して影の先端を得る — 3 つの演算の連携プレーです。

src/lessons/11-vectors-and-matrices/main.ts(抜粋)
// この章の主役たち。すべて out 引数スタイル(結果の入れ物を最初の引数で渡す)
const dotAB = vec2.dot(vecA, vecB); // スカラーを返すものは out が要らない
vec2.cross(crossAB, vecA, vecB); // 2D の cross は z 成分だけが残る(out は vec3)
vec2.normalize(bHat, vecB); // b の単位ベクトル
const along = vec2.dot(vecA, bHat); // a の「b 方向成分」= 射影の符号付き長さ
vec2.scale(proj, bHat, along); // 射影の点 = b の向き × その長さ

4. 外積 cross — 垂直なベクトルを作り、面積を測る

dot がスカラーを返すのに対し、cross(外積)はベクトルを返しますvec3.cross(out, a, b)の結果は、a にも b にも垂直なベクトルです。向きは右手系 (right-handed)の約束で決まります: 右手の指を a から b へ曲げたとき、親指の向く方がa × b。順序を入れ替えたb × aは真逆を向きます(dot は順序を入れ替えても同じ値なのと対照的です)。

もう一つの顔が面積です。a × bの長さは|a||b|sinθで、これはa と b が張る平行四辺形の面積に一致します。a と b が平行なら面積 0 — つまり cross がゼロベクトルになったら「二つは同じ(または真逆の)向き」です。

面積 = |a × b|aba × ba にも b にも垂直a から b へ回す向きに右手の指を曲げると、親指が a × b(右手系)
cross は「a と b の張る面に垂直な軸」を作ります。長さは平行四辺形の面積 |a||b|sinθ で、a と b が平行に近づくほど 0 に潰れます。

「2D なのにデモで cross が出せるのはなぜ?」— いい質問です。2D のベクトルを z = 0 の 3D ベクトルとみなして cross を取ると、結果は必ず z 軸方向を向きます(x, y 成分は常に 0)。gl-matrix のvec2.cross(out, a, b)はそのまま vec3 を返す仕様で、意味を持つのはz 成分 = a.x * b.y - a.y * b.x だけです。この符号が「b は a のどちら側にいるか」を教えてくれます —cross(a, b).zが正なら b は a の左(反時計回り)側、負なら右(時計回り)側です。デモで a を b の反対側へ動かすと、この符号がひっくり返るのを確認してください。

5. 行列 = 変換 — 「列 = 基底の行き先」を 4×4 へ

ここからが後半戦、行列です。第10章でmat2についてまとめた読み方を、そのまま持ち上げます。

この読み方は行列のサイズによらず通用します。mat3 なら「3 本の基底 (x, y, z 軸) の行き先を 3 列に並べたもの」、mat4 なら 4 本です。2×2 で表せた回転・拡大・せん断はそのまま大きい行列の左上に収まり、3D の回転や拡大は mat3 で書けます。ところが、たった一つだけ、この枠組みに入らない変換があります。 それが平行移動です。

6. 平行移動だけが仲間はずれ — 同次座標と w

「全部の点に (tx, ty) を足すだけ」という一番単純な変換が、なぜ行列にできないのか。 行列 × ベクトルの規則を思い出すと、ゼロベクトル(原点)にどんな行列を掛けても結果はゼロベクトルです(すべての列を 0 で重み付けするので)。つまり行列で書ける変換 —線形変換 (linear transformation)— は原点を絶対に動かせません。回転も拡大もせん断も原点は不動です。 しかし平行移動は原点ごと動かす変換なので、この土俵の外にいます。

そこで発想を変えます。次元を 1 つ上げるのです。2D の点 (x, y) を、3 つ目の成分に 1 を付けて (x, y, 1) と書くことにします。この書き方を同次座標 (homogeneous coordinates)と呼びます。すると 2D の平行移動が、3×3 の行列で書けてしまいます。

2D の平行移動を mat3 で書く
2D の平行移動を「1 つ上の次元」の線形変換として書く:

| 1  0  tx |   | x |   | x + tx |
| 0  1  ty | × | y | = | y + ty |
| 0  0  1  |   | 1 |   | 1      |

点を (x, y, 1) に持ち上げると、3 列目(基底 (0, 0, 1) の行き先)が
そのまま足し込まれる。これが平行移動の正体

種明かしをすると、3 列目は「基底 (0, 0, 1) の行き先」であり、点はみんな 3 つ目の成分に 1 を持っているので、3 列目が全員に 1 倍で足し込まれる— それが平行移動になる、という仕掛けです。3D 空間で見れば、これは「高さ 1 の平面をずらすせん断」で、れっきとした線形変換です (3D の原点は動いていないことに注目してください)。平行移動を、1 つ上の次元の線形変換として密輸する— これが同次座標のトリックです。

ww = 0w = 1せん断で傾いた軸点 (x, y, 1)+tw = 1 の住人は横滑りする = 平行移動方向 (x, y, 0)w = 0 の住人は動かない(3D の原点も不動)
同次座標の断面図(x と w だけを描いたもの)。平行移動の正体は「w 方向に 1 段上がった平面のせん断」で、w = 1 に住む点だけが横滑りし、w = 0 に住む方向ベクトルは 影響を受けません。

3D でも理屈は同じです。3D の点 (x, y, z) に 4 つ目の成分w = 1を付けて (x, y, z, 1) とし、4×4 行列の 4 列目に平行移動を仕込む。これが「3D なのに、なぜか行列は 4×4」の答えです。そして w には、おまけどころではない副産物があります。w = 0のベクトルは、どんな 4×4 行列を掛けても平行移動の列が効きません(0 倍で足されるので)。つまり同次座標は、この章の冒頭で分けた二つの読み方 — 「位置」と「向き」— をデータの形で区別する仕組みにもなっているのです。

点と方向は w で区別される
// 同じ 4×4 行列を掛けても、w で受ける変換が変わる
vec4 point = vec4(x, y, z, 1.0); // 点:   回転・拡大に加えて平行移動も受ける
vec4 direction = vec4(x, y, z, 0.0); // 方向: 回転・拡大だけ受け、平行移動は無視

ここまでに出てきた変換に、名前を付けておきます。回転・拡大・せん断のような線形変換に平行移動を 足したものを、まとめてアフィン変換 (affine transformation)と呼びます。「直線は 直線のまま、平行な直線は平行のまま」保たれるのが特徴で、この章で組む平行移動・回転・拡大の 組み合わせはすべてこの仲間です。4×4 行列でいえば左上 3×3 が線形変換の部分、4 列目の先頭 3 つが平行移動で、最下行が (0, 0, 0, 1)のものがアフィン変換にあたります(この対応は 8 節の図で確かめます)。裏を返すと、その最下行に手を入れる第12章の射影行列はアフィン変換ではありません— だから遠くのものが小さくなり、平行なはずの線が消失点で交わるのです。 線形代数の教科書や他のグラフィックスの資料はこの名前で説明していることが多いので、 読み替えの手がかりとして覚えておくと役に立ちます。

7. gl-matrix で行列を組む — 合成の順序

4×4 が要る理由がわかったところで、gl-matrix で実際に組みます。デモの 2 本目「行列 = 変換」では、同じ家に「平行移動 T・回転 R・拡大 S」の 3 つを、積む順序だけ変えて掛けています。

src/lessons/11-vectors-and-matrices/main.ts(抜粋)
// T・R・S: 頂点には S → R → T の順に効く。
// 「その場で回って伸び縮みし、置き場所は動かない」いつもの合成順
mat4.identity(mTRS);
mat4.translate(mTRS, mTRS, T_OFFSET);
mat4.rotateZ(mTRS, mTRS, angle);
mat4.scale(mTRS, mTRS, scaleVec);

// S・R・T: 同じ 3 つの変換を逆順に積むと、先に置いた家を原点ごと回す
// ことになり、家は原点の周りを公転して距離まで伸び縮みする
mat4.identity(mSRT);
mat4.scale(mSRT, mSRT, scaleVec);
mat4.rotateZ(mSRT, mSRT, angle);
mat4.translate(mSRT, mSRT, T_OFFSET);

mat4.translate(out, m, v)は「m に平行移動を右から掛けた行列」を out に書きます。右から掛けた行列ほど 頂点に先に効くので、T → R → S の順に呼んだ mTRS は、頂点から見ると拡大 → 回転 → 平行移動の順に働きます。家は原点で伸び縮みしながら回り、最後に定位置へ運ばれる — デモの本体が青い家がこれで、置き場所はピタリと動きません。

本体が緑の家は同じ 3 つを逆順に積んだ mSRT です。頂点から見ると先に平行移動してから原点まわりの回転と拡大を受けるため、家は原点の周りを公転し、原点からの 距離まで伸び縮みします。第10章の市松模様で見た「fract の前に回すか、後に回すか」と同じ話が、 行列の積み順という形で再登場しているわけです。行列の掛け算は順序を入れ替えると結果が変わる(非可換)— 3D でバグらせたとき最初に疑う場所なので、デモの 2 軒の動きの違いを目に焼き付けてください。

変換を積めるということは、元に戻す変換も考えられるということです。回転 +θ に対する回転 -θ のように、掛けると単位行列(なにもしない変換)に戻る相手を逆行列 (inverse matrix)と呼び、M⁻¹ と書きます。一般の逆行列を求めるのは面倒な計算ですが、回転だけでできた行列には 近道があります — 第12章のビュー行列で使います。

8. 列優先レイアウト — 16 個の数字のどこに何が入るか

第10章で「mat2(a, b, c, d)は列から埋まる。この流儀はメモリにも続く」と予告しました。ここで回収します。gl-matrix のmat4は 16 要素の Float32Array で、1 列目 → 2 列目 → 3 列目 → 4 列目の順に、 各列を上から下へ並べます。これが列優先 (column-major)レイアウトです。 「列 = 基底の行き先」と合わせると、16 個の数字が一気に読めるようになります。

0123456789101112131415左上 3×3: 回転・拡大(基底 x, y, z の行き先)← 4 列目の先頭 3 つが平行移動 (tx, ty, tz)添字は m[番号] の番号。列の中を上から下へ進み、次の列へ移る(列優先)最下行 m[3], m[7], m[11], m[15] はふつう 0, 0, 0, 1(w の行。第12章の射影で動き出す)メモリ上(Float32Array):0 1 2 34 5 6 78 9 10 1112 13 14 151 列目2 列目3 列目4 列目 = 平行移動
mat4 の 16 要素。紙の行列を列ごとに切って横一列に並べたものがメモリの姿で、 平行移動 (tx, ty, tz) はm[12], m[13], m[14]に入ります。

デモの 2 本目の読み出し行には、まさにこのm[12], m[13]を毎フレーム表示してあります。T・R・S の家では平行移動を最初に積んだので (0.75, 0.25) のまま不動、S・R・T の家では後から積んだ平行移動が拡大と回転に巻き込まれて、値がぐるぐる 変わります。「行列のお尻の 3 つを見れば、そのオブジェクトがどこにいるかわかる」— デバッグで本当によく使う知識です。

9. CPU で変換して、毎フレーム送る — そして第12章へ

最後に、この章のデモが行列をどう使っているかです。実は意図的に、いちばん 素朴な方法を取っています。CPU で全頂点に行列を掛け、変換済みの座標を毎フレーム GPU へ送り直すのです。

src/lessons/11-vectors-and-matrices/main.ts(抜粋)
for (let i = 0; i < HOUSE_POSITIONS.length / 2; i += 1) {
  vec2.set(houseVertex, HOUSE_POSITIONS[i * 2], HOUSE_POSITIONS[i * 2 + 1]);
  // 行列 × 頂点を CPU で計算する(z = 0、w = 1 の点として扱われる)。
  // 第12章では、この掛け算が頂点シェーダーへ移る
  vec2.transformMat4(houseVertex, houseVertex, matrix);
  const color = i < HOUSE_BODY_VERTEX_COUNT ? bodyColor : roofColor;
  pushVertex(triBatch, houseVertex[0], houseVertex[1], color);
}

vec2.transformMat4(out, v, m)は、vec2 を「z = 0、w = 1 の点」とみなして mat4 を掛けてくれる便利関数です(6 節の同次座標がさっそく仕事をしています)。こうして CPU で作った頂点列を、第3章と同じbufferDataで毎フレームアップロードします。変わったのは最後の引数だけ — 「一度書いたら変えない」という意味のSTATIC_DRAWに代えて、「何度も書き換える」というヒントのDYNAMIC_DRAWを渡します(これは性能のヒントであって、動作は変わりません)。

src/lessons/11-vectors-and-matrices/main.ts(抜粋)
// CPU で計算した頂点を GPU へ再アップロードする。
// DYNAMIC_DRAW は「何度も書き換える」というドライバへのヒント。
// subarray はコピーではなく「先頭から使ったぶんまで」のビューを作るだけ
gl.bindBuffer(gl.ARRAY_BUFFER, batch.positionBuffer);
gl.bufferData(gl.ARRAY_BUFFER, batch.positions.subarray(0, batch.count * 2), gl.DYNAMIC_DRAW);
gl.bindBuffer(gl.ARRAY_BUFFER, batch.colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, batch.colors.subarray(0, batch.count * 3), gl.DYNAMIC_DRAW);

この方式は、頂点が数十個しかないこのデモでは何の問題もありません。しかし第1章の言葉を 思い出してください — CPU と GPU の間の転送は遅いのでした。頂点が数万、数十万に なったとき、毎フレーム全頂点を CPU で計算して送り直すのは完全に急所になります。しかも 冷静に考えると、毎フレーム変わっているのは行列の 16 個の数字だけで、 家のローカル座標は 1 ミリも変わっていません。

答えはもう見えています。頂点は最初に一度だけ送って動かさず、行列 16 個を uniform として渡し、掛け算は頂点シェーダーにやらせる— 第4章から予告し続けてきたuniformMatrix4fvの出番ですが、それは次の第12章の主役なので、ここでは踏み込みません。第12章では行列がモデル・ ビュー・射影の 3 段のリレーになり、第3章からの宿題「行列でワールド座標からクリップ空間へ」も そこで完結します。

コード全文

この章のシェーダーは「受け取った座標と色をそのまま出す」だけの最小構成です。主役は CPU 側のmain.ts— gl-matrix の計算と、動的バッチ(毎フレーム頂点を作り直して送る仕組み)にあります。

src/lessons/11-vectors-and-matrices/main.ts
// 第11章: ベクトルと行列
// gl-matrix のベクトル演算(dot / cross / normalize)と行列(mat4)を CPU 側で使う。
// 変換済みの頂点を毎フレーム bufferData(DYNAMIC_DRAW)でアップロードして描く。
// シェーダーは受け取った座標をそのまま出すだけ(行列を uniform で GPU に渡すのは第12章)。

import {
  mat4,
  type ReadonlyMat4,
  type ReadonlyVec2,
  type ReadonlyVec3,
  vec2,
  vec3,
} from 'gl-matrix';
import { compileShader, linkProgram } from '../../lib/shader';
import fragmentSource from './basic.frag?raw';
import vertexSource from './basic.vert?raw';

// ---------------------------------------------------------------------------
// 毎フレーム頂点を作り直して送る「動的バッチ」
// 第3章の VBO は一度きりの STATIC_DRAW だったが、今回は中身が毎フレーム変わる
// ---------------------------------------------------------------------------

// 1 フレームに積める最大頂点数。配列は最初に一度だけ確保して使い回す
const MAX_VERTICES = 128;

interface Batch {
  vao: WebGLVertexArrayObject;
  positionBuffer: WebGLBuffer;
  colorBuffer: WebGLBuffer;
  positions: Float32Array; // xy を MAX_VERTICES 頂点ぶん
  colors: Float32Array; // rgb を MAX_VERTICES 頂点ぶん
  count: number; // いま積まれている頂点数
}

function createBatch(gl: WebGL2RenderingContext): Batch {
  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao);

  // 配線は第3章と同じ。ただしデータはまだ入れない(毎フレーム入れ直すため)
  const positionBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
  gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);

  const colorBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
  gl.enableVertexAttribArray(1);
  gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 0, 0);

  gl.bindVertexArray(null);

  return {
    vao,
    positionBuffer,
    colorBuffer,
    positions: new Float32Array(MAX_VERTICES * 2),
    colors: new Float32Array(MAX_VERTICES * 3),
    count: 0,
  };
}

function drawBatch(gl: WebGL2RenderingContext, batch: Batch, mode: GLenum): void {
  if (batch.count === 0) {
    return;
  }
  // CPU で計算した頂点を GPU へ再アップロードする。
  // DYNAMIC_DRAW は「何度も書き換える」というドライバへのヒント。
  // subarray はコピーではなく「先頭から使ったぶんまで」のビューを作るだけ
  gl.bindBuffer(gl.ARRAY_BUFFER, batch.positionBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, batch.positions.subarray(0, batch.count * 2), gl.DYNAMIC_DRAW);
  gl.bindBuffer(gl.ARRAY_BUFFER, batch.colorBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, batch.colors.subarray(0, batch.count * 3), gl.DYNAMIC_DRAW);

  gl.bindVertexArray(batch.vao);
  gl.drawArrays(mode, 0, batch.count);
}

// ---------------------------------------------------------------------------
// 色と図形の定数
// ---------------------------------------------------------------------------

type Color = readonly [number, number, number];

const COLOR_A: Color = [1.0, 0.45, 0.55]; // ベクトル a(マウス)
const COLOR_B: Color = [0.35, 0.65, 1.0]; // ベクトル b(ゆっくり回転)
const COLOR_PROJ: Color = [1.0, 0.85, 0.4]; // a を b の直線に射影した「影」
const COLOR_AXIS: Color = [0.28, 0.32, 0.38]; // 座標軸
const COLOR_GUIDE: Color = [0.2, 0.23, 0.28]; // 補助線(a の先端から影へ下ろす垂線)

const COLOR_HOUSE_BODY: Color = [0.35, 0.65, 1.0];
const COLOR_HOUSE_ROOF: Color = [1.0, 0.45, 0.55];
const COLOR_HOUSE_BODY_ALT: Color = [0.45, 0.85, 0.6];
const COLOR_HOUSE_ROOF_ALT: Color = [1.0, 0.85, 0.4];
const COLOR_HOUSE_FAINT: Color = [0.22, 0.25, 0.3];

// 家(本体 2 枚 + 屋根 1 枚 = 三角形 3 枚)のローカル座標。原点が家の中心
// biome-ignore format: 1 頂点 = 1 行の並びを保つ
const HOUSE_POSITIONS = new Float32Array([
  -0.22, -0.22, // 本体 左下
  0.22, -0.22, // 本体 右下
  0.22, 0.18, // 本体 右上
  -0.22, -0.22, // 本体 左下
  0.22, 0.18, // 本体 右上
  -0.22, 0.18, // 本体 左上
  -0.3, 0.18, // 屋根 左
  0.3, 0.18, // 屋根 右
  0.0, 0.5, // 屋根 頂点
]);
const HOUSE_BODY_VERTEX_COUNT = 6; // 先頭 6 頂点が本体、残りが屋根

// 矢印の見た目
const ARROW_HEAD_LENGTH = 0.1;
const ARROW_HEAD_WIDTH = 0.07;

// ---------------------------------------------------------------------------
// デモ本体
// ---------------------------------------------------------------------------

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  controls: HTMLParagraphElement,
  readout: HTMLParagraphElement,
): void {
  // --- gl-matrix のスクラッチ変数 -------------------------------------------
  // out 引数用に最初に一度だけ確保し、毎フレーム使い回す(本文 1 節)

  const vecA = vec2.create(); // マウスに追従するベクトル a
  const vecB = vec2.create(); // ゆっくり回転するベクトル b
  const bHat = vec2.create(); // b の単位ベクトル(normalize の結果)
  const proj = vec2.create(); // a を b の直線に射影した点
  const crossAB = vec3.create(); // vec2.cross の結果(z 成分だけが残る)
  const ORIGIN: ReadonlyVec2 = vec2.fromValues(0, 0);

  const arrowDir = vec2.create(); // 矢印の向き(単位ベクトル)
  const arrowPerp = vec2.create(); // 矢印の向きに垂直なベクトル
  const arrowBase = vec2.create(); // 矢じりの付け根
  const arrowLeft = vec2.create(); // 矢じりの左角
  const arrowRight = vec2.create(); // 矢じりの右角

  const mTRS = mat4.create(); // 平行移動 → 回転 → 拡大の順に積んだ行列
  const mSRT = mat4.create(); // 拡大 → 回転 → 平行移動の順に積んだ行列
  const IDENTITY: ReadonlyMat4 = mat4.create(); // 単位行列(変換前の家を描くのに使う)
  const scaleVec = vec3.create();
  const houseVertex = vec2.create();
  const T_OFFSET: ReadonlyVec3 = vec3.fromValues(0.75, 0.25, 0); // 平行移動量(固定)

  const program = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, vertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource),
  );

  const triBatch = createBatch(gl); // 塗りつぶし(矢じり・家)
  const lineBatch = createBatch(gl); // 線(軸・矢印の柄・補助線)

  gl.clearColor(0.06, 0.07, 0.09, 1.0);

  // --- 座標系 --------------------------------------------------------------
  // 計算は「短辺が -1〜+1、横は ±aspect」の標準座標(第7章)で行い、
  // クリップ空間へは頂点を積むときに x / aspect で変換する

  let aspect = 1.5;

  function resizeIfNeeded(): void {
    const dpr = Math.min(window.devicePixelRatio, 2);
    // display: none などで表示サイズが 0 のときも、バッファは最低 1px 確保しておく
    // (0 だと aspect が 0 / 0 = NaN になり、行列も頂点も丸ごと NaN に染まる)
    const width = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const height = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== width || canvas.height !== height) {
      canvas.width = width;
      canvas.height = height;
      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
    }
    aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
  }

  // --- マウス(第4章と同じ変換 + 標準座標へ) -------------------------------

  const mouseWorld: vec2 = vec2.fromValues(0.55, 0.4);

  canvas.addEventListener('pointermove', (event) => {
    const rect = canvas.getBoundingClientRect();
    const x = (event.clientX - rect.left - canvas.clientLeft) / canvas.clientWidth;
    const y = (event.clientY - rect.top - canvas.clientTop) / canvas.clientHeight;
    mouseWorld[0] = (x * 2 - 1) * aspect; // クリップ空間 → 標準座標(x は ±aspect)
    mouseWorld[1] = 1 - y * 2;
  });

  // --- 頂点を積むヘルパー ---------------------------------------------------

  function pushVertex(batch: Batch, x: number, y: number, color: Color): void {
    if (batch.count >= MAX_VERTICES) {
      return; // あふれたら黙って捨てる(このデモでは起きない)
    }
    batch.positions[batch.count * 2] = x / aspect; // 標準座標 → クリップ空間
    batch.positions[batch.count * 2 + 1] = y;
    batch.colors[batch.count * 3] = color[0];
    batch.colors[batch.count * 3 + 1] = color[1];
    batch.colors[batch.count * 3 + 2] = color[2];
    batch.count += 1;
  }

  function pushLine(x0: number, y0: number, x1: number, y1: number, color: Color): void {
    pushVertex(lineBatch, x0, y0, color);
    pushVertex(lineBatch, x1, y1, color);
  }

  // 矢印 = 柄(gl.LINES)+ 矢じり(塗りつぶし三角形)
  function pushArrow(from: ReadonlyVec2, to: ReadonlyVec2, color: Color): void {
    vec2.subtract(arrowDir, to, from);
    const len = vec2.length(arrowDir);
    vec2.normalize(arrowDir, arrowDir); // 長さ 0 のときは 0 のまま(gl-matrix の仕様)
    // 2D で 90° 回すには (x, y) → (-y, x)。第10章の回転行列の 2 列目と同じ形
    vec2.set(arrowPerp, -arrowDir[1], arrowDir[0]);

    const headLength = Math.min(ARROW_HEAD_LENGTH, len);
    vec2.scaleAndAdd(arrowBase, to, arrowDir, -headLength); // 矢じりの付け根 = 先端 - 向き × 長さ
    pushLine(from[0], from[1], arrowBase[0], arrowBase[1], color);

    vec2.scaleAndAdd(arrowLeft, arrowBase, arrowPerp, ARROW_HEAD_WIDTH * 0.5);
    vec2.scaleAndAdd(arrowRight, arrowBase, arrowPerp, -ARROW_HEAD_WIDTH * 0.5);
    pushVertex(triBatch, to[0], to[1], color);
    pushVertex(triBatch, arrowLeft[0], arrowLeft[1], color);
    pushVertex(triBatch, arrowRight[0], arrowRight[1], color);
  }

  function pushAxes(): void {
    pushLine(-aspect, 0, aspect, 0, COLOR_AXIS);
    pushLine(0, -1, 0, 1, COLOR_AXIS);
  }

  const formatNumber = (n: number): string => n.toFixed(2).padStart(5);

  // --- モード 1: ベクトルの演算 --------------------------------------------

  function buildVectorScene(time: number): void {
    // b はゆっくり回転する長さ 0.7 のベクトル、a はマウスに追従する
    vec2.set(vecB, Math.cos(time * 0.25) * 0.7, Math.sin(time * 0.25) * 0.7);
    vec2.copy(vecA, mouseWorld);

    // この章の主役たち。すべて out 引数スタイル(結果の入れ物を最初の引数で渡す)
    const dotAB = vec2.dot(vecA, vecB); // スカラーを返すものは out が要らない
    vec2.cross(crossAB, vecA, vecB); // 2D の cross は z 成分だけが残る(out は vec3)
    vec2.normalize(bHat, vecB); // b の単位ベクトル
    const along = vec2.dot(vecA, bHat); // a の「b 方向成分」= 射影の符号付き長さ
    vec2.scale(proj, bHat, along); // 射影の点 = b の向き × その長さ

    // dot = |a||b|cosθ を θ について解いてなす角を出す
    const lenA = vec2.length(vecA);
    const lenB = vec2.length(vecB);
    const cosTheta = lenA > 1e-6 ? dotAB / (lenA * lenB) : 1;
    const angleDeg = (Math.acos(Math.min(Math.max(cosTheta, -1), 1)) * 180) / Math.PI;

    pushAxes();
    pushLine(vecA[0], vecA[1], proj[0], proj[1], COLOR_GUIDE); // a の先端から影へ下ろす垂線
    pushArrow(ORIGIN, proj, COLOR_PROJ); // a が b の直線に落とす「影」
    pushArrow(ORIGIN, vecB, COLOR_B);
    pushArrow(ORIGIN, vecA, COLOR_A);

    readout.textContent =
      `a = (${formatNumber(vecA[0])}, ${formatNumber(vecA[1])})  ` +
      `b = (${formatNumber(vecB[0])}, ${formatNumber(vecB[1])})  |  ` +
      `dot(a, b) = ${formatNumber(dotAB)}  なす角 = ${angleDeg.toFixed(1).padStart(5)}°  ` +
      `cross(a, b).z = ${formatNumber(crossAB[2])}  b への射影 = ${formatNumber(along)}`;
  }

  // --- モード 2: 行列 = 変換 ------------------------------------------------

  function pushHouse(matrix: ReadonlyMat4, bodyColor: Color, roofColor: Color): void {
    for (let i = 0; i < HOUSE_POSITIONS.length / 2; i += 1) {
      vec2.set(houseVertex, HOUSE_POSITIONS[i * 2], HOUSE_POSITIONS[i * 2 + 1]);
      // 行列 × 頂点を CPU で計算する(z = 0、w = 1 の点として扱われる)。
      // 第12章では、この掛け算が頂点シェーダーへ移る
      vec2.transformMat4(houseVertex, houseVertex, matrix);
      const color = i < HOUSE_BODY_VERTEX_COUNT ? bodyColor : roofColor;
      pushVertex(triBatch, houseVertex[0], houseVertex[1], color);
    }
  }

  function buildMatrixScene(time: number): void {
    const angle = time * 0.6; // 回転はゆっくり回し続ける
    const scale = 0.8 + 0.3 * Math.sin(time * 0.9); // 拡大率は 0.5〜1.1 を往復
    vec3.set(scaleVec, scale, scale, 1);

    // T・R・S: 頂点には S → R → T の順に効く。
    // 「その場で回って伸び縮みし、置き場所は動かない」いつもの合成順
    mat4.identity(mTRS);
    mat4.translate(mTRS, mTRS, T_OFFSET);
    mat4.rotateZ(mTRS, mTRS, angle);
    mat4.scale(mTRS, mTRS, scaleVec);

    // S・R・T: 同じ 3 つの変換を逆順に積むと、先に置いた家を原点ごと回す
    // ことになり、家は原点の周りを公転して距離まで伸び縮みする
    mat4.identity(mSRT);
    mat4.scale(mSRT, mSRT, scaleVec);
    mat4.rotateZ(mSRT, mSRT, angle);
    mat4.translate(mSRT, mSRT, T_OFFSET);

    pushAxes();
    pushHouse(IDENTITY, COLOR_HOUSE_FAINT, COLOR_HOUSE_FAINT); // 変換前(原点の薄い家)
    pushHouse(mTRS, COLOR_HOUSE_BODY, COLOR_HOUSE_ROOF);
    pushHouse(mSRT, COLOR_HOUSE_BODY_ALT, COLOR_HOUSE_ROOF_ALT);

    // 列優先レイアウトの実地確認: 平行移動成分は m[12], m[13](, m[14])に入る
    readout.textContent =
      `回転 = ${formatNumber(angle % (Math.PI * 2))} rad  拡大 = ${formatNumber(scale)}  |  ` +
      `T・R・S の m[12], m[13] = ${formatNumber(mTRS[12])}, ${formatNumber(mTRS[13])}(不動)  ` +
      `S・R・T の m[12], m[13] = ${formatNumber(mSRT[12])}, ${formatNumber(mSRT[13])}(回されている)`;
  }

  // --- モード切替(第8章と同じボタン方式) ----------------------------------

  const modes = [
    { label: 'ベクトルの演算', build: buildVectorScene },
    { label: '行列 = 変換', build: buildMatrixScene },
  ] as const;

  let currentBuild: (time: number) => void = buildVectorScene;

  for (const [index, mode] of modes.entries()) {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = mode.label;
    button.setAttribute('aria-pressed', index === 0 ? 'true' : 'false');
    button.addEventListener('click', () => {
      currentBuild = mode.build;
      for (const b of controls.querySelectorAll('button')) {
        b.setAttribute('aria-pressed', 'false');
      }
      button.setAttribute('aria-pressed', 'true');
    });
    controls.append(button);
  }

  // --- 描画ループ ------------------------------------------------------------
  // このページのデモはページと寿命を共にするので、rAF ループの停止も pointermove の
  // 解除もしていない(止める必要が出るケースは第6章の stop()、GPU リソースを含む
  // 後始末の一般論は第35章)

  function frame(timestamp: DOMHighResTimeStamp): void {
    resizeIfNeeded();
    const time = timestamp / 1000;

    // バッチを空にして、今フレームの頂点を CPU で作り直す
    triBatch.count = 0;
    lineBatch.count = 0;
    currentBuild(time);

    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.useProgram(program);
    drawBatch(gl, triBatch, gl.TRIANGLES);
    drawBatch(gl, lineBatch, gl.LINES);

    requestAnimationFrame(frame);
  }

  requestAnimationFrame(frame);
}

// ---------------------------------------------------------------------------
// 要素とコンテキストの取得
// ---------------------------------------------------------------------------

const canvas = document.querySelector<HTMLCanvasElement>('#demo');
const controls = document.querySelector<HTMLParagraphElement>('#demo-buttons');
const readout = document.querySelector<HTMLParagraphElement>('#readout');
if (!canvas || !controls || !readout) {
  throw new Error('デモに必要な要素が見つかりません');
}

const gl = canvas.getContext('webgl2');
if (!gl) {
  throw new Error('このブラウザは WebGL2 に対応していません');
}

setup(gl, canvas, controls, readout);
src/lessons/11-vectors-and-matrices/basic.vert
#version 300 es

// 頂点属性の配線は第3章と同じ。layout(location = N) で番号を固定する
layout(location = 0) in vec2 a_position;
layout(location = 1) in vec3 a_color;

out vec3 v_color;

void main() {
  v_color = a_color;

  // この章のシェーダーは、受け取った座標をそのまま出すだけ。
  // 行列は CPU 側(gl-matrix)ですでに適用済みの座標が届く。
  // 行列を uniform で GPU に渡し、ここで掛けるようにするのは第12章
  gl_Position = vec4(a_position, 0.0, 1.0);
}
src/lessons/11-vectors-and-matrices/basic.frag
#version 300 es

precision highp float;

// 頂点色を補間して受け取り、そのまま出す(第3章と同じ最小構成)。
// この章の主役は CPU 側の gl-matrix なので、シェーダーは意図的に何もしない
in vec3 v_color;

out vec4 fragColor;

void main() {
  fragColor = vec4(v_color, 1.0);
}

three.js との対応

three.js のベクトル・行列 API と gl-matrix は、演算の中身は同じで、呼び出しの流儀だけが違います。

three.jsこの章
THREE.Vector2 / THREE.Vector3(クラス)vec2 / vec3(実体はただの Float32Array)
a.add(b)— a 自身を書き換えて this を返す(メソッドチェーン)。非破壊にしたければa.clone().add(b)vec3.add(out, a, b)— 結果の入れ物 out を呼ぶ側が用意する(out 引数。毎フレームのアロケーション回避)
a.dot(b)/ a.cross(b) / a.normalize() /a.length()vec3.dot(a, b)/ vec3.cross(out, a, b) /vec3.normalize(out, a)/ vec3.length(a)
THREE.Matrix4Matrix4.elements は列優先の配列mat4= 16 要素の Float32Array。同じ列優先で、平行移動はm[12], m[13], m[14]
Object3D.position/ .rotation / .scaleに入れるだけで、内部のupdateMatrix()が毎フレーム 1 枚の行列(matrix)に合成してくれるmat4.identitytranslaterotateZscaleを自分の手で積む(この「自動更新」相当を自作して uniform に渡すのが第12章)
Vector3.applyMatrix4(m)vec3.transformMat4(out, v, m)(w = 1 の点として掛け、最後に w で割るところまで three.js と同じ)。ただしこの章の家の頂点に使っているのは 2D 用のvec2.transformMat4(out, v, m)で、こちらは z = 0・w = 1 とみなし、w で割らない簡易版

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

この章のコードはsrc/lessons/11-vectors-and-matrices/にあります。dot と cross と行列の順序は、数値をいじった回数だけ手に馴染みます。

まとめ

次章は「座標変換 — モデル・ビュー・射影 (MVP)」です。この章で組めるようになった行列をuniformMatrix4fvで頂点シェーダーへ渡し、モデル → ビュー → 射影の 3 段リレーで、ローカル座標の頂点を クリップ空間まで運びます。第10章の「座標を +θ 回すと絵は -θ 回る」がビュー行列として、 第3章からの宿題の w が透視除算として、それぞれ主役に返り咲きます。