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

座標変換 — モデル・ビュー・射影 (MVP)

第3章で最初の三角形を描いたとき、頂点シェーダーには「z = 0.0(奥行きなし)、w = 1.0(同次座標。詳細は第12章)」というコメントを添えました。ようやくその第12章です。この章では、頂点の座標がローカル座標からスクリーンのピクセルまで運ばれていく変換パイプラインの全体像を 1 枚の図にし、その中心にあるモデル・ビュー・射影 (Model / View / Projection)の 3 枚の 4×4 行列を gl-matrix で組んで、初めて奥行きのある絵を出します。

three.js なら new THREE.PerspectiveCamera(45, aspect, 0.1, 100) を作ってscene.add(mesh)すれば立体が映ります。それで済むのは、three.js が毎フレーム、この章で作る行列 3 枚(matrixWorld/ matrixWorldInverse /projectionMatrix)を組み立ててシェーダーへ送っているからです。この章では、その自動化の中身を全部自分の手で書きます。使う道具は第11章で揃えたもの — dot / cross / normalize、平行移動のために次元を 1 つ足した同次座標の w、そして gl-matrix の行列が列優先の Float32Arrayだという事実です。

同じ四角形をモデル行列と色だけ差し替えて 5 枚並べ、自作 lookAt のカメラが周回しています。ボタンは 射影行列の差し替えだけで、シーン側は何も変えていません。下の読み出し行は、自作 myLookAt と gl-matrix の mat4.lookAt の検算です。カメラが向こう側へ回り込むと前後関係が破綻しますが、これは仕様です(7 節で種明かし)。

この章で学ぶこと:

1. 変換パイプラインの全体像

3D 描画とは、突き詰めれば「頂点の座標を、いくつかの座標系を乗り換えながらスクリーンまで運ぶこと」です。座標系(空間)は 6 つ、乗り換え(変換)は 5 回。この章の主役は次の 1 枚の図で、第3部の残りの章はすべてこの図の上で仕事をします。

ローカル座標 (local)ワールド座標 (world)ビュー座標 (view)クリップ空間 (clip)NDC(正規化デバイス座標)スクリーン座標モノ自身の座標。四角形の頂点は ±0.5シーン共通の座標。全部のモノとカメラが同居カメラが原点。視線は -z 方向gl_Position に書く vec4。まだ w で割っていない各軸 -1〜+1 の箱。第3章の -1〜+1 の正方形の 3D 版描画バッファのピクセル。gl_FragCoord の座標系× u_model(モデル行列)× u_view(ビュー行列)× u_projection(射影行列)÷ w(透視除算)ビューポート変換モノをワールドに置く(2 節)カメラの逆変換(3 節)遠近の付け方を決める(4 節)GPU が自動で行う(5 節)GPU が自動で行う。gl.viewport(第2章)強調色の 3 段が自分の頂点シェーダーの仕事(gl_Position に書くのはクリップ空間まで)。灰色の 2 段は GPU の固定機能で、シェーダーには書かない。
この章の主役。頂点はローカル座標で生まれ、行列 3 枚で クリップ空間まで運ばれ(ここまでが頂点シェーダーの仕事)、残りの透視除算とビューポート変換は GPU が自動で行います。

図に入る前に、座標系の約束を 1 つ決めておきます。本サイトのワールド座標は右手系・Y-up(+x が右、+y が上、+z が手前)で統一します。ビュー座標でカメラが自分の -z 方向を見るのも、同じ約束の一部です。

上 3 本の矢印が、この章で作る行列 3 枚です。頂点シェーダーは 3 枚を順に掛けるだけで、レッスンコードのscene.vertの中身は実質この 1 行です。

src/lessons/12-coordinate-transforms/scene.vert(抜粋)
// 変換は右から順に効く: モデル → ビュー → 射影。
// vec4(..., 1.0) の 1.0 が同次座標の w(第11章)。
// gl_Position に書くのはクリップ空間の座標で、NDC ではない。
// このあと GPU が自動で xyz を w で割り(透視除算)、NDC にする
gl_Position = u_projection * u_view * u_model * vec4(a_position, 1.0);

読み方に 1 つだけ約束があります。行列 × ベクトルの掛け算はベクトルに近い側(右)から順に効くので、u_projection * u_view * u_model * vは「まずモデル、次にビュー、最後に射影」です。 英語圏では 3 枚まとめてMVP 行列と呼びます(掛ける式では P・V・M の順に並ぶのに、呼び名は適用順の M・V・P です)。

src/lessons/12-coordinate-transforms/scene.vert(抜粋)
// 変換行列 3 枚。CPU 側から uniformMatrix4fv で渡される(第4章の伏線回収)
uniform mat4 u_model; // ローカル → ワールド(モノの置き方)
uniform mat4 u_view; // ワールド → ビュー(カメラの逆変換)
uniform mat4 u_projection; // ビュー → クリップ(遠近の付け方)

2. モデル行列 — モノをワールドに置く

ローカル座標は「モノ自身の作業台」です。レッスンコードの四角形は、原点中心・一辺 1(頂点は ±0.5)で 1 個作ってあるだけで、5 枚の板はすべてこの同じ頂点データの使い回しです。 どこに・どの向きで・どの大きさで置くかは、板ごとのモデル行列が決めます。three.js で 1 つの geometry から複数の mesh を作り、mesh.positionmesh.scaleだけ変えるのと同じ構図です。

src/lessons/12-coordinate-transforms/main.ts(抜粋)
const model = new Float32Array(16); // gl-matrix の行列の実体は 16 要素の Float32Array
mat4.identity(model); // 単位行列(なにもしない変換)から始める
mat4.translate(model, model, panel.position); // ① ワールドの置き場所へ動かし
mat4.rotateY(model, model, panel.angleY); // ② その場で自分の y 軸回りに回し
mat4.scale(model, model, [panel.scale, panel.scale, 1]); // ③ 大きさを変える

mat4.translate(out, m, v)が「m に平行移動行列を右から掛けて out に書く」ことと、その結果頂点に近い(右側の)変換から効くことは、第11章 7 節でデモ付きで見たとおりです。上のコードは数式にすると M = T × R × S なので、頂点への適用順は scale → rotateY → translate になります。

第11章の覚え方をコードの見た目で言い換えると、「後から書いた変換ほど、モノに近い側(ローカル)で効く」。この章の板で言えば、rotateYtranslateより後なので、板は「移動した先で、自分の y 軸を軸にその場で回る」=自転です。rotateYを先(コードの上)に書けば回転が translate より外側で効き、「ワールドの y 軸の周りを回る」=公転の配置になります。実験リストで実際に入れ替えてみてください。

3. ビュー行列 — カメラは存在しないので、世界を逆に動かす

WebGL にカメラという機能はありません。GPU は常に「クリップ空間に来た頂点」を描くだけです。では カメラを動かして見えるあの映像は何かというと、カメラの動きの逆をシーン全体に掛けたものです。第10章の 2D 回転の節で「座標を +θ 回すと、絵は -θ 回って見える。カメラを右へ振ると景色が左へ流れるのと同じ」という観察をしました。あれがビュー行列の正体です。

手順はこうです。まずカメラを普通のモノとして扱い、ワールドに置く行列(カメラのモデル行列。three.js の camera.matrixWorld)を考えます。それを C とすると、ビュー行列 V はその逆行列 C⁻¹です(three.js のcamera.matrixWorldInverse)。カメラを右に 3 動かす代わりに世界全体を左に 3 動かし、カメラを +θ 回す代わりに世界を -θ 回す — 結果として「カメラが原点にいて -z 方向を見ている」ことにした座標系がビュー座標です。

問題は C の作り方です。「eye の位置から target を見て、up をだいたい上にする」という指定からカメラの向きを求めるのが、有名なlookAtです。gl-matrix にはmat4.lookAtがありますが、この章では中身を理解するためにmyLookAtを自作します(自作はこの章だけで、第13章以降は gl-matrix 版のmat4.lookAtをそのまま使います)。核心は、カメラの基底(右・上・後ろの 3 本の軸)を cross で組み立てることです。

src/lessons/12-coordinate-transforms/main.ts(myLookAt 前半)
// ① カメラの基底(右・上・後ろ)を cross で組み立てる(第11章の cross の出番)。
//    カメラは自分の -z 方向を見る約束なので、z 軸は視線の「逆」= eye - target
const zAxis = vec3.subtract(vec3.create(), eye, target);
vec3.normalize(zAxis, zAxis);
// up は「だいたい上」でよい。z 軸と直交する本当の右向きは cross が作ってくれる
const xAxis = vec3.cross(vec3.create(), up, zAxis);
vec3.normalize(xAxis, xAxis);
// 本当の上 = z × x。直交する単位ベクトル同士の cross なので、もう正規化は要らない
const yAxis = vec3.cross(vec3.create(), zAxis, xAxis);

第10章で覚えた「列 = 基底の行き先」の読み方を使うと、カメラを置く行列 C は、この 3 本と eye を列に並べたものです。そしてビュー行列はその逆行列(第11章 7 節)ですが、逆行列を数値的に解く必要はありません。C の回転部分は互いに直交する単位ベクトルでできている —正規直交 (orthonormal)— ので、逆 = 転置で済むからです。

なぜそうなるかは、第11章の dot だけで確かめられます。R を 3 本の基底を列に並べた行列とすると、Rᵀ × R の (i, j) 成分は「i 番目の基底と j 番目の基底の dot」です。互いに直交するので i ≠ j なら 0、単位ベクトルなので i = j なら 1 — つまり単位行列です。Rᵀ が R の逆行列だ、ということに他なりません。だから実装は、基底を「列」ではなく「行」に置き直すだけ。平行移動部分は -eye を転置後の回転で回したもので、これも各基底との dot になります。

カメラを置く行列 C(camera.matrixWorld)xAxisyAxiszAxiseyex.xx.yx.zy.xy.yy.zz.xz.yz.ze.xe.ye.z0001列 = 基底の行き先(第10章の読み方)逆行列ビュー行列 V = C⁻¹(camera.matrixWorldInverse)x.xx.yx.z-x·ey.xy.yy.z-y·ez.xz.yz.z-z·e0001回転部分は転置(xAxis の列が行になる)、4 列目は -dot(基底, eye)
強調色の xAxis を目で追ってください。C では 1 列目だったものが、V では 1 行目に寝ています(転置)。4 列目の -x·e は -dot(xAxis, eye) の略記です。

コードでは、この V の 16 要素を列優先(1 列目 → 2 列目 → …。第11章)の順で直接詰めます。

src/lessons/12-coordinate-transforms/main.ts(myLookAt 後半)
// 1 列目
out[0] = xAxis[0];
out[1] = yAxis[0];
out[2] = zAxis[0];
out[3] = 0;
// 2 列目
out[4] = xAxis[1];
out[5] = yAxis[1];
out[6] = zAxis[1];
out[7] = 0;
// 3 列目
out[8] = xAxis[2];
out[9] = yAxis[2];
out[10] = zAxis[2];
out[11] = 0;
// 4 列目(平行移動)
out[12] = -vec3.dot(xAxis, eye);
out[13] = -vec3.dot(yAxis, eye);
out[14] = -vec3.dot(zAxis, eye);
out[15] = 1;

本当に mat4.lookAt と同じものが作れているのかは、口で言うより比べるのが早い — デモでは毎フレーム両方を計算し、16 要素の差の最大値を canvas の下の読み出し行(readout)に出しています。値が 1e-6 を下回ったままなら、差は浮動小数点の丸めだけ、つまり同じ行列です。

src/lessons/12-coordinate-transforms/main.ts(抜粋)
// ビュー行列: カメラを原点の周りでゆっくり周回させる。
// 動いているのはカメラだけで、板のモデル行列は一度も変わっていない
const angle = time * ORBIT_SPEED;
vec3.set(eye, Math.sin(angle) * ORBIT_RADIUS, CAMERA_HEIGHT, Math.cos(angle) * ORBIT_RADIUS);
myLookAt(view, eye, TARGET, UP);

4. 射影行列 — perspective と ortho

ビュー座標はまだ「カメラから見て右に 1、前に 5」といった世界の単位のままです。これをクリップ空間へ持ち込み、どの範囲が画面に入るか・遠近をどう付けるかを決めるのが射影行列です。デモのボタンで切り替えている 2 種類を見ます。

perspective — 視錐台 (frustum)eyefovynearfar台形の内側だけが描かれる。近いものほど画面上で大きいortho — 直方体nearfar視線は平行。距離が変わっても画面上の大きさは同じ
横から見た描画範囲の形。perspective はカメラから開く台形(視錐台)、ortho は直方体です。この内側がクリップ空間の -w〜+w(NDC の -1〜+1)に写されます。

mat4.perspective(out, fovy, aspect, near, far) の 4 つの引数の意味です。

src/lessons/12-coordinate-transforms/main.ts(抜粋)
// 射影行列: aspect は毎フレーム描画バッファの実サイズから求める。
// リサイズで歪まないのはこの 1 行のおかげ(第7章の補正の役目が射影行列へ移った)
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
if (mode === 'perspective') {
  mat4.perspective(projection, FOVY, aspect, NEAR, FAR);
} else {
  const halfW = ORTHO_HALF_HEIGHT * aspect;
  mat4.ortho(projection, -halfW, halfW, -ORTHO_HALF_HEIGHT, ORTHO_HALF_HEIGHT, NEAR, FAR);
}

aspect を毎フレーム、実際の描画バッファのサイズから計算していることに注目してください。第7章では「短辺で割る」補正をフラグメントシェーダーに書きましたが、3D パイプラインではこの補正が射影行列の中へ引っ越します。ウィンドウをリサイズしても板が歪まないのは、この 1 行のおかげです。

mat4.ortho(out, left, right, bottom, top, near, far)は、指定した直方体をそのまま NDC の箱に写します。遠近が付かないので、UI・ミニマップ・設計図面のような「距離で大きさが変わってほしくない」描画に使います。デモを ortho に切り替えると、5 枚の板の見かけの大きさの差が「距離のせい」だった分だけ消えて、scale の差だけが残ります。

5. 透視除算と w — クリップ空間と NDC は別物

第3章で gl_Position を紹介したとき、「-1〜+1 という範囲は座標を w で割った後のNDC(正規化デバイス座標)の話で、割る前の 4 次元座標がクリップ空間。この区別は w が 1 でなくなる第12章で効いてくる」と先送りしていました。ここがその回収です。頂点シェーダーがgl_Positionに書くのはクリップ空間の座標であり、GPU は頂点シェーダーの後に

ndc = clip.xyz / clip.w

という割り算を自動で行います。これが透視除算 (perspective division)です。第3章までは w = 1 で割っていたので、クリップ空間と NDC がたまたま一致していた — だからこそ「クリップ空間は -1〜+1」という説明でも絵が正しく出ていたわけです。

では w に 1 以外が入るのはいつか。それが perspective 行列の仕掛けです。gl-matrix のmat4.perspectiveが作る行列は、3 列目の最下段(配列でいうとm[11]。第11章 8 節の添字の読み方です)に -1 が置いてあり、掛け算するとclip.w = -z_view(= カメラからの前方距離)になります(ビュー座標では視線が -z 方向なので、カメラの前にあるものは z_view が負。符号を反転して正の距離にしています)。つまり、

「遠いものは小さく見える」という遠近法の全てが、この割り算 1 回に入っています。たとえば同じ大きさの板でも、カメラからの距離が 2 倍になれば w も 2 倍になり、NDC での大きさはちょうど半分です。一方mat4.orthoの行列は w = 1 のままにするので、割っても何も起きず、遠近が付きません。perspective と ortho の見た目の違いは、結局「w に何を入れるか」の違いなのです。

6. uniformMatrix4fv — 行列を GPU へ送る

行列 3 枚を組めたら、あとは GPU へ送るだけです。第4章の uniform 関数ファミリーの一覧表で、uniformMatrix2fv / 3fv / 4fvの行だけ「変換行列(第12章)」と先送りしていました。ここがその回収です。mat4 uniform への転送はgl.uniformMatrix4fv(location, transpose, data)で、ロケーションの取得と「useProgram 中のプログラムに効く」というルールは第4章のスカラー版と全く同じです。

src/lessons/12-coordinate-transforms/main.ts(抜粋)
// ビューと射影はシーン共通なのでフレームに 1 回。
// 第 2 引数 transpose は常に false(gl-matrix は最初から列優先だから。本文 6 節)
gl.uniformMatrix4fv(viewLocation, false, view);
gl.uniformMatrix4fv(projectionLocation, false, projection);

data には 16 要素の Float32Array を列優先で渡します。gl-matrix の行列は最初から列優先の Float32Array なので(第11章)、変換もコピーも一切せず、そのまま渡すだけです。第11章で「なぜ列優先などという紛らわしい約束を覚えるのか」と思ったなら、ここがその回収です — CPU 側のライブラリと GPU の約束が同じ向きに揃っているから、境界で何も起きないのです。

7. 前後関係はまだ描画順まかせ — 第13章への伏線

最後に、このデモに仕込んである時限爆弾の種明かしです。5 枚の板の配列panelsは、初期カメラから見て奥になるものから順に手で並べてあり、描画ループはその順にdrawArraysを呼んでいるだけです。

src/lessons/12-coordinate-transforms/main.ts(抜粋)
// モデル行列と色だけ差し替えながら、同じ四角形を 5 回描く。
// drawList の並び(奥 → 手前)が、そのまま画面の前後関係になっている
for (const item of drawList) {
  gl.uniformMatrix4fv(modelLocation, false, item.model);
  gl.uniform3f(colorLocation, item.color[0], item.color[1], item.color[2]);
  gl.drawArrays(gl.TRIANGLES, 0, 6);
}

今の WebGL の状態では、後から描いたフラグメントが前のフラグメントを無条件に上書きします。奥の板 → 手前の板の順に描けば正しい前後関係に「見える」— しかしそれは塗り絵の順序で作った錯覚で、GPU は奥行きを一切考慮していません。証拠に、カメラが周回して反対側へ回り込むと、ワールドでの奥・手前が入れ替わるのに描画順は固定のままなので、遠くの板が手前の板を覆い隠す破綻が起きます。デモを 10 秒ほど眺めていれば必ず目撃できます。

モノが増えるたびに CPU で並べ替えるのは高くつきますし、板どうしが交差したらそもそも順序では解決できません。正しい解決策は、ピクセルごとに奥行きを記録して比較する深度テストです。第2章のコンテキスト属性の表で「3D の前後関係の判定に必須(第13章)」とだけ書いた depth(深度バッファ)が、ついに主役になります — それが次の第13章です。

コード全文

シェーダー 2 本と CPU 側の全文です。シェーダーがほぼ空っぽなことに注目してください — この章の仕事のほとんどは、CPU 側で行列を組むことでした。

src/lessons/12-coordinate-transforms/scene.vert
#version 300 es

// 頂点属性(配線番号の流儀は第3章と同じ)。
// 3D になったので、位置は vec2 から vec3 に増えている
layout(location = 0) in vec3 a_position; // ローカル座標

// 変換行列 3 枚。CPU 側から uniformMatrix4fv で渡される(第4章の伏線回収)
uniform mat4 u_model; // ローカル → ワールド(モノの置き方)
uniform mat4 u_view; // ワールド → ビュー(カメラの逆変換)
uniform mat4 u_projection; // ビュー → クリップ(遠近の付け方)

void main() {
  // 変換は右から順に効く: モデル → ビュー → 射影。
  // vec4(..., 1.0) の 1.0 が同次座標の w(第11章)。
  // gl_Position に書くのはクリップ空間の座標で、NDC ではない。
  // このあと GPU が自動で xyz を w で割り(透視除算)、NDC にする
  gl_Position = u_projection * u_view * u_model * vec4(a_position, 1.0);
}
src/lessons/12-coordinate-transforms/scene.frag
#version 300 es

precision highp float;

// この章の主役は頂点シェーダー側の座標変換。
// フラグメント側は、板ごとに CPU から渡された色をそのまま出すだけ
uniform vec3 u_color;

out vec4 fragColor;

void main() {
  fragColor = vec4(u_color, 1.0);
}
src/lessons/12-coordinate-transforms/main.ts
// 第12章: 座標変換 — モデル・ビュー・射影 (MVP)
// 同じ四角形をモデル行列で 5 枚配置し、自作 lookAt のビュー行列で周回カメラを作り、
// perspective / ortho の射影行列をボタンで切り替える。
// 前後関係は「配列の並び順」だけに頼っている(第13章の深度テストで解決する)。

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

// ---------------------------------------------------------------------------
// シーンの定義
// ---------------------------------------------------------------------------

interface Panel {
  position: [number, number, number]; // ワールドでの置き場所
  angleY: number; // y 軸回りの向き(ラジアン)
  scale: number; // 大きさ(1 で一辺 1)
  color: [number, number, number];
}

// 奥(z が小さい)から手前(z が大きい)の順に「手で」並べてある。
// 描画はこの配列の順なので、前後関係はこの並びに完全に依存している。
// カメラが向こう側へ回り込むと並びが逆転して破綻する(本文 7 節・第13章への伏線)
const panels: Panel[] = [
  { position: [0.0, 0.0, -1.8], angleY: 0.35, scale: 2.4, color: [0.85, 0.3, 0.35] },
  { position: [0.9, 0.15, -0.9], angleY: -0.25, scale: 1.8, color: [0.95, 0.65, 0.25] },
  { position: [-0.7, -0.1, 0.0], angleY: 0.2, scale: 1.5, color: [0.3, 0.75, 0.5] },
  { position: [0.4, 0.05, 0.9], angleY: -0.3, scale: 1.2, color: [0.35, 0.55, 0.95] },
  { position: [-0.3, 0.2, 1.8], angleY: 0.25, scale: 0.9, color: [0.75, 0.5, 0.9] },
];

// カメラと射影の設定
const ORBIT_RADIUS = 5.0; // カメラの周回半径
const ORBIT_SPEED = 0.25; // 周回の角速度(ラジアン/秒)
const CAMERA_HEIGHT = 1.6; // カメラの高さ(少し見下ろす)
const FOVY = (45 * Math.PI) / 180; // 縦の視野角。gl-matrix はラジアンで渡す(three.js は度)
const NEAR = 0.1; // これより手前は描かない(0 にすると奥行きが潰れる。本文 4 節)
const FAR = 100.0; // これより奥は描かない
// ortho の縦半分の高さ。perspective で距離 ORBIT_RADIUS に見える高さ
// tan(FOVY / 2) × ORBIT_RADIUS ≒ 2.07 に合わせ、切替時の見た目の大きさを揃えている
const ORTHO_HALF_HEIGHT = 2.1;

const TARGET: ReadonlyVec3 = vec3.fromValues(0, 0, 0); // カメラが見つめる点(原点)
const UP: ReadonlyVec3 = vec3.fromValues(0, 1, 0); // 「だいたい上」の向き

// ---------------------------------------------------------------------------
// ビュー行列の自作: myLookAt
// カメラの置き方(eye / target / up)から「ワールド → ビュー」の行列を作る。
// mat4.lookAt と同じもの。仕組みを見るために一度だけ自作する(第13章以降は gl-matrix 版)
// ---------------------------------------------------------------------------

function myLookAt(out: mat4, eye: ReadonlyVec3, target: ReadonlyVec3, up: ReadonlyVec3): mat4 {
  // ① カメラの基底(右・上・後ろ)を cross で組み立てる(第11章の cross の出番)。
  //    カメラは自分の -z 方向を見る約束なので、z 軸は視線の「逆」= eye - target
  const zAxis = vec3.subtract(vec3.create(), eye, target);
  vec3.normalize(zAxis, zAxis);
  // up は「だいたい上」でよい。z 軸と直交する本当の右向きは cross が作ってくれる
  const xAxis = vec3.cross(vec3.create(), up, zAxis);
  vec3.normalize(xAxis, xAxis);
  // 本当の上 = z × x。直交する単位ベクトル同士の cross なので、もう正規化は要らない
  const yAxis = vec3.cross(vec3.create(), zAxis, xAxis);

  // ② 列に (xAxis, yAxis, zAxis, eye) を並べれば「カメラを置く行列」(カメラのモデル行列)。
  //    ビュー行列はその逆行列で、逆行列は次の 2 つの組で直接書ける。
  //    - 回転部分: 正規直交なので 逆 = 転置(基底を「列」でなく「行」に置く)
  //    - 平行移動部分: -eye を転置後の回転で回したもの = 各基底と eye の dot の符号反転
  // ③ 詰める順序は列優先(第11章)。1 列目 → 2 列目 → … の順で 16 個
  // 1 列目
  out[0] = xAxis[0];
  out[1] = yAxis[0];
  out[2] = zAxis[0];
  out[3] = 0;
  // 2 列目
  out[4] = xAxis[1];
  out[5] = yAxis[1];
  out[6] = zAxis[1];
  out[7] = 0;
  // 3 列目
  out[8] = xAxis[2];
  out[9] = yAxis[2];
  out[10] = zAxis[2];
  out[11] = 0;
  // 4 列目(平行移動)
  out[12] = -vec3.dot(xAxis, eye);
  out[13] = -vec3.dot(yAxis, eye);
  out[14] = -vec3.dot(zAxis, eye);
  out[15] = 1;
  return out;
}

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

type ProjectionMode = 'perspective' | 'ortho';

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  controls: HTMLParagraphElement,
  readout: HTMLParagraphElement,
): void {
  // --- 初期化: プログラムと四角形の VAO(第3章と同じ流れ) ------------------

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

  // ローカル座標の四角形(一辺 1・原点中心・z = 0 の平面)。三角形 2 枚ぶんの 6 頂点。
  // 全部の板がこの 1 つのジオメトリを使い回し、置き場所はモデル行列が決める
  // biome-ignore format: 1 頂点 = 1 行の並びを保つ
  const positions = new Float32Array([
    -0.5, -0.5, 0.0,
    0.5, -0.5, 0.0,
    0.5, 0.5, 0.0,
    -0.5, -0.5, 0.0,
    0.5, 0.5, 0.0,
    -0.5, 0.5, 0.0,
  ]);

  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao);

  const positionBuffer = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
  gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STATIC_DRAW);
  gl.enableVertexAttribArray(0);
  gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 0, 0); // vec2 だった第3章と違い、サイズは 3

  gl.bindVertexArray(null);

  const modelLocation = gl.getUniformLocation(program, 'u_model');
  const viewLocation = gl.getUniformLocation(program, 'u_view');
  const projectionLocation = gl.getUniformLocation(program, 'u_projection');
  const colorLocation = gl.getUniformLocation(program, 'u_color');

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

  // --- モデル行列: 板ごとに 1 回だけ組む(このシーンでは板は動かない) --------
  // gl-matrix の変換関数は「いまの行列に右から掛ける」。
  // コードの順は translate → rotateY → scale だが、頂点に効く順はその逆
  // (scale → rotateY → translate)。「後から書いた変換ほどモノに近い側で効く」(本文 2 節)

  const drawList = panels.map((panel) => {
    const model = new Float32Array(16); // gl-matrix の行列の実体は 16 要素の Float32Array
    mat4.identity(model); // 単位行列(なにもしない変換)から始める
    mat4.translate(model, model, panel.position); // ① ワールドの置き場所へ動かし
    mat4.rotateY(model, model, panel.angleY); // ② その場で自分の y 軸回りに回し
    mat4.scale(model, model, [panel.scale, panel.scale, 1]); // ③ 大きさを変える
    return { color: panel.color, model };
  });

  // --- 射影モードの切替ボタン(第8章と同じ作り) ----------------------------

  let mode: ProjectionMode = 'perspective';
  const modes: { id: ProjectionMode; label: string }[] = [
    { id: 'perspective', label: 'perspective(透視投影)' },
    { id: 'ortho', label: 'ortho(平行投影)' },
  ];

  for (const entry of modes) {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = entry.label;
    button.setAttribute('aria-pressed', entry.id === mode ? 'true' : 'false');
    button.addEventListener('click', () => {
      mode = entry.id;
      for (const b of controls.querySelectorAll('button')) {
        b.setAttribute('aria-pressed', 'false');
      }
      button.setAttribute('aria-pressed', 'true');
    });
    controls.append(button);
  }

  // --- リサイズ(第4章と同じ) ----------------------------------------------

  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);
    }
  }

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

  const eye = vec3.create();
  const view = mat4.create();
  const viewCheck = mat4.create(); // 検算用: mat4.lookAt の結果
  const projection = mat4.create();
  let maxDiff = 0; // myLookAt と mat4.lookAt の成分差(これまでの最大)

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

    // ビュー行列: カメラを原点の周りでゆっくり周回させる。
    // 動いているのはカメラだけで、板のモデル行列は一度も変わっていない
    const angle = time * ORBIT_SPEED;
    vec3.set(eye, Math.sin(angle) * ORBIT_RADIUS, CAMERA_HEIGHT, Math.cos(angle) * ORBIT_RADIUS);
    myLookAt(view, eye, TARGET, UP);

    // 検算: 自作の結果を gl-matrix の mat4.lookAt と成分ごとに比べる。
    // 差が浮動小数点の丸め(1e-6 未満)に収まっていれば、同じ行列を作れている
    mat4.lookAt(viewCheck, eye, TARGET, UP);
    for (let i = 0; i < 16; i++) {
      maxDiff = Math.max(maxDiff, Math.abs(view[i] - viewCheck[i]));
    }
    readout.textContent = `myLookAt と mat4.lookAt の成分差(これまでの最大): ${maxDiff.toExponential(2)}`;

    // 射影行列: aspect は毎フレーム描画バッファの実サイズから求める。
    // リサイズで歪まないのはこの 1 行のおかげ(第7章の補正の役目が射影行列へ移った)
    const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
    if (mode === 'perspective') {
      mat4.perspective(projection, FOVY, aspect, NEAR, FAR);
    } else {
      const halfW = ORTHO_HALF_HEIGHT * aspect;
      mat4.ortho(projection, -halfW, halfW, -ORTHO_HALF_HEIGHT, ORTHO_HALF_HEIGHT, NEAR, FAR);
    }

    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.useProgram(program);

    // ビューと射影はシーン共通なのでフレームに 1 回。
    // 第 2 引数 transpose は常に false(gl-matrix は最初から列優先だから。本文 6 節)
    gl.uniformMatrix4fv(viewLocation, false, view);
    gl.uniformMatrix4fv(projectionLocation, false, projection);

    gl.bindVertexArray(vao);

    // モデル行列と色だけ差し替えながら、同じ四角形を 5 回描く。
    // drawList の並び(奥 → 手前)が、そのまま画面の前後関係になっている
    for (const item of drawList) {
      gl.uniformMatrix4fv(modelLocation, false, item.model);
      gl.uniform3f(colorLocation, item.color[0], item.color[1], item.color[2]);
      gl.drawArrays(gl.TRIANGLES, 0, 6);
    }

    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);

three.js との対応

この章でやったことは、three.js が毎フレーム自動でやっている行列更新の自作です。renderer.render(scene, camera)の内側では、シーングラフを辿って各 Object3D の行列を更新し、この章の frame() と同じものを組み立てて uniform に流し込んでいます。

three.jsこの章
mesh.position/ .rotation / .scale から組まれるObject3D.matrixWorldu_modelmat4.translaterotateYscaleの合成(親子階層は第19章の glTF のノード階層で)
camera.matrixWorldInverseu_view。カメラを置く行列の逆行列 = myLookAt の出力
camera.projectionMatrixu_projectionmat4.perspective/ mat4.ortho
new THREE.PerspectiveCamera(fov, aspect, near, far)mat4.perspective(out, fovy, aspect, near, far)。ただし three.js の fov は、gl-matrix はラジアン
new THREE.OrthographicCamera(left, right, top, bottom, near, far)mat4.ortho(out, left, right, bottom, top, near, far)(引数順が微妙に違う点に注意)
camera.lookAt(x, y, z)この章で自作した myLookAt(out, eye, target, up)
renderer.render(scene, camera)が毎フレーム行う行列更新と uniform 転送frame() の中の myLookAt / mat4.perspective / uniformMatrix4fv 一式

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

この章のコードはsrc/lessons/12-coordinate-transforms/にあります。行列は「壊すと何が起きるか」で理解が定着します。

まとめ

次章は「最初の 3D」です。板ではなく立方体を、頂点を使い回すインデックス描画(drawElements)で組み、深度テストで前後関係を GPU に任せ、カリングで裏向きの面を捨てます。第3章から予告し続けてきた VAO と IBO の非対称な関係にも、そこで決着を付けます。