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

カメラ — 軌道カメラ・追跡カメラ

第12章から第14章までのデモは、カメラが固定か、プログラムが勝手に動かすものでした。作った立体を 「裏はどうなっているんだろう」と思ってもドラッグでは回せない — three.js ならOrbitControlsを 1 行つないで終わりのところです。この章では、そのOrbitControlsの中身を自作します。ドラッグで注視点の周りを回り、ホイールで近づき、指を離してもすっと滑って止まる。あの操作感の正体が、注視点 1 点と 3 つの数値、そして lerp だけでできていることを確かめます。

新しい行列は 1 つも登場しません。ビュー行列を作るmat4.lookAtは第12章で中身まで自作した道具なので、ここでは完成品として使います。カメラ操作とはつまるところ、lookAt に渡す eye(カメラの位置)と target(注視点)を毎フレームどう決めるかという問題です。この章はその「決め方」の設計の話です。

軌道カメラ: ドラッグで回転、ホイールで距離を調整できます。追跡カメラ: トーラスの周りを公転する小さな球を、カメラが少し遅れて追いかけます。

この章で学ぶこと:

1. 舞台の準備 — 第13・14章の道具でシーンを組む

カメラを動かすからには、眺めがいのある舞台が要ります。今回は第14章のgeometry.tsから 3 つの形を借りて、原点でゆっくり回るトーラス、その周りを公転する小さな球、そして床になる平面を置きます。頂点データは第14章どおり位置・法線・UV を 1 本の VBO にインターリーブし、深度テストと裏面カリング(第13章)を有効にします。

シェーダーは「法線をそのまま色にする」normalize(v_normal) * 0.5 + 0.5が主役です。ライティングは第17章の主題なのでまだ使いませんが、法線色でも面の向きごとに色が変わるので、立体感とカメラの動きは十分確かめられます。床だけは uniform のフラグで UV の市松模様に切り替えて、距離感の手がかりにします。

1 つだけ第14章の復習を。createPlaneの板は XY 平面上にあり、表(CCW に見える側)は +z を向いているので、床にするには X 軸まわりに -90° 回します。+90° だと表が下を向き、裏面カリングで床が消えます(第14章 §3)。

src/lessons/15-camera/main.ts(抜粋)
// 床: XY 平面の板を X 軸で -90° 回して床にする(第14章)。
// CCW の表が +y(上)を向くので、カリングを有効にしたまま上から見える
mat4.fromTranslation(model, FLOOR_POSITION);
mat4.rotateX(model, model, -Math.PI / 2);
drawMesh(floorMesh, model, 1);

2. カメラの状態設計 — 注視点と球面座標

軌道カメラ (orbit camera) は「注視点の周りの球面上をカメラが動く」カメラです。まず、状態として何を持つかを決めます。eye の xyz をそのまま持つと、「水平に回す」「近づく」という操作のたびに 3 成分をまとめて更新する羽目になり、すぐ破綻します。そこで発想を変えて、操作の軸そのものを状態にします

注視点(点 1 つ)と、この 3 つの数値で、カメラの置き方は過不足なく決まります。(θ, φ, r) の組を球面座標 (spherical coordinates)と呼びます。第10章で「距離と角度」の極座標を学びましたが、あれの 3D 版です。水平面の中は極座標(角度 θ・半径 r·cosφ)で位置が決まり、そこに「見上げる角度 φ」が 1 つ加わっただけ、と読めます。

ただし、第10章とは軸の割り当てが入れ替わっています。第10章では θ = atan(y, x) と、+x を起点に測りましたが、ここでは +z を起点にし、zが第10章の x 役、xy 役になります(だから逆算もatan2(dx, dz)です)。カメラが初期状態で手前(+z)にいるほうが直感的で、three.js のSphericalも同じ約束(Math.atan2(x, z))だからです。

第14章で球の頂点を置いたときにも、同じ球面座標を使いました。ただし割り当てが少し違います — 第14章は南極から測る極角(0〜π)と +x 起点の方位角、この章は水平面から測る仰角φ(-π/2〜+π/2)と +z 起点の方位角 θ です。極角 = π/2 + 仰角、θ = 方位角 + π/2 で読み替えられます(第14章の極角は南極起点なので符号が + になります。three.js のSpherical.phiは +y 起点で向きが違うため、そちらの読み替えは後述の対応表を見てください)。仰角を採るのは、ドラッグの上下がそのまま「見上げ・見下ろし」に対応して操作の設計が素直になるからです。

+y+x+zr·sinφr·cosφeye(カメラの位置)r(距離)target(注視点)θ 方位角φ 仰角eye.x = target.x + r · cosφ · sinθeye.y = target.y + r · sinφeye.z = target.z + r · cosφ · cosθ
球面座標からカメラ位置への変換。仰角 φ を上げると水平成分が r·cosφ に縮み、高さが r·sinφ になる — 水平面の中の見方は第10章の極座標と同じで、起点の軸だけ +x から +z に入れ替わっています。θ = 0 のとき、カメラは +z 側(手前)にいます。

コードでは、この状態を次のように持ちます。同じ値が thetathetaTargetのように 2 本立てになっているのは 5 節の減衰のためで、いまは「Target 付きが本当の状態、無印はそれを追いかける表示用」とだけ思っておいてください。

src/lessons/15-camera/main.ts(抜粋)
const orbit = {
  theta: 0.6, // 方位角(ラジアン)。y 軸まわりの向き
  phi: 0.35, // 仰角(ラジアン)。水平面から上がプラス
  radius: 6.5, // 注視点からの距離
  thetaTarget: 0.6,
  phiTarget: 0.35,
  radiusTarget: 6.5,
};
const PHI_LIMIT = Math.PI / 2 - 0.05; // 仰角の限界。真上・真下の ε 手前で止める
const MIN_RADIUS = 2.5;
const MAX_RADIUS = 14;
const DAMPING = 0.12; // 1 フレームで目標値へ近づく割合(1.0 で減衰なし)
const TRACK_DAMPING = 0.06; // 追跡カメラ用。小さいほど遅れてついていく

変換式はそのまま TypeScript になります。

src/lessons/15-camera/main.ts(抜粋)
// 球面座標 (θ, φ, r) → eye の位置。第10章の「極座標 → 直交座標」の 3D 版
eye[0] = target[0] + orbit.radius * Math.cos(orbit.phi) * Math.sin(orbit.theta);
eye[1] = target[1] + orbit.radius * Math.sin(orbit.phi);
eye[2] = target[2] + orbit.radius * Math.cos(orbit.phi) * Math.cos(orbit.theta);

eye と target が決まってしまえば、残りは第12章で組んだ流れそのままです。ここから先、カメラの話はこの 2 行より上流だけで進みます。

src/lessons/15-camera/main.ts(抜粋)
// eye と target が決まれば、あとは第12章で学んだ 2 つの行列を作るだけ
mat4.lookAt(view, eye, target, UP);
const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
mat4.perspective(projection, FOVY, aspect, NEAR, FAR);

3. ドラッグで回す — Pointer Events と setPointerCapture

入力は第4章・第11章に続いて Pointer Events です。ただし今回は「動きに追従」ではなく「つかんで回す」なので、押した (pointerdown)・動いた (pointermove)・離した (pointerup) の 3 つを組み合わせ、押している間だけドラッグ量を角度に変換します。

src/lessons/15-camera/main.ts(抜粋)
canvas.addEventListener('pointerdown', (event) => {
  if (mode !== 'orbit') return;
  // 主ボタンだけを拾う。右クリックのドラッグでカメラが回らないように
  // (タッチ・ペンの接地も button === 0 で届く)
  if (event.button !== 0) return;
  dragging = true;
  activePointerId = event.pointerId;
  lastX = event.clientX;
  lastY = event.clientY;
  // キャプチャしておくと、ポインタが canvas の外に出ても
  // pointermove / pointerup がこの canvas に届き続ける
  canvas.setPointerCapture(event.pointerId);
});

canvas.addEventListener('pointermove', (event) => {
  if (!dragging || event.pointerId !== activePointerId) return;
  const dx = event.clientX - lastX;
  const dy = event.clientY - lastY;
  lastX = event.clientX;
  lastY = event.clientY;

  // canvas の高さぶんのドラッグ = 一周(2π)。OrbitControls と同じ換算
  const speed = (2 * Math.PI) / canvas.clientHeight;
  orbit.thetaTarget -= dx * speed;
  orbit.phiTarget += dy * speed;

  // 仰角のクランプ: 真上・真下(±π/2)では視線と up が平行になって
  // lookAt が破綻するので、ε だけ手前で止める
  orbit.phiTarget = Math.min(PHI_LIMIT, Math.max(-PHI_LIMIT, orbit.phiTarget));
});

ポイントは 4 つあります。

4. ホイールでズーム — 距離は乗算で変える

ズームは wheel イベントで距離 r を変えるだけ — なのですが、素朴にradius += deltaY * 定数加算にすると操作感が破綻します。r = 10 でちょうどよい 1 ノッチの移動量は、r = 3 では大きすぎて一気に突き抜け、r = 14 では小さすぎてじれったい。人間の距離感覚は比率で働くので、1 ノッチ = 数%という乗算にすると、どの距離でも同じ操作感になります。

src/lessons/15-camera/main.ts(抜粋)
// deltaY の単位は deltaMode で変わる(0 = px / 1 = 行 / 2 = ページ)。
// 行・ページ単位で来る環境でも同じ効き方になるよう px 相当に揃える(three.js も同じことをしている)
const LINE_HEIGHT = 16;
const PAGE_HEIGHT = 100;

canvas.addEventListener(
  'wheel',
  (event) => {
    // ページのスクロールを乗っ取るので preventDefault する。モードによって
    // スクロールしたりしなかったりしないよう、早期 return より前で止める
    event.preventDefault();
    if (mode !== 'orbit') return;

    const scale = event.deltaMode === 1 ? LINE_HEIGHT : event.deltaMode === 2 ? PAGE_HEIGHT : 1;

    // 加算ではなく乗算: 1 ノッチごとに数 % ずつ。近くでは細かく、
    // 遠くでは大きく動くので、どの距離でも操作感が揃う
    orbit.radiusTarget *= Math.exp(event.deltaY * scale * 0.001);
    orbit.radiusTarget = Math.min(MAX_RADIUS, Math.max(MIN_RADIUS, orbit.radiusTarget));
  },
  // 要素に直接付けたリスナーは仕様上どのみち passive: false だが、
  // window などへ付け替えた瞬間に壊れる暗黙の前提なので明示しておく(本文参照)
  { passive: false },
);

Math.exp(deltaY * 0.001)は deltaY = 100 で約 1.105 倍、-100 で約 0.905 倍。上下対称な倍率になるので、1 ノッチ寄って 1 ノッチ引くと元の距離にきちんと戻ります。r には最小・最大のクランプも掛けます。近すぎると第12章の near 面にめり込んで中が見え、遠すぎると被写体を見失うためです(OrbitControls のminDistance/ maxDistance に相当)。

もう 1 つ、deltaYの単位は deltaMode(0 = ピクセル / 1 = 行 / 2 = ページ)で変わります。行単位で返す環境では 1 ノッチが数程度の値になり、Math.exp(0.003)≒ 1.003 倍にしかならず、ズームがほとんど効きません。そこで行なら ×16、ページなら ×100 して px 相当に揃えてから使います — three.js のOrbitControlsも同じ係数で吸収しています。ライブラリが黙って面倒を見てくれる「実機差」は、自作するなら自分で吸収する、の一例です。

5. 減衰 — 目標値と現在値を分ける

ここまでの実装でも動きますが、操作してみると「手を離した瞬間ピタッと止まる」「1 ノッチごとにカクッと動く」硬さがあります。OrbitControls でenableDamping = trueにしたときのあの滑らかさの正体は、驚くほど単純です。入力が書き換えるのは目標値だけ。描画に使う現在値は、毎フレーム目標値へ一定割合だけ近づける(lerp。第7章の mix と同じ計算を CPU 側でやっているだけです)。それだけです。

src/lessons/15-camera/main.ts(抜粋)
// 減衰: 現在値を目標値へ、毎フレーム一定割合だけ近づける(lerp)。
// 入力は目標値だけを書き換え、描画は現在値だけを使う
orbit.theta += (orbit.thetaTarget - orbit.theta) * DAMPING;
orbit.phi += (orbit.phiTarget - orbit.phi) * DAMPING;
orbit.radius += (orbit.radiusTarget - orbit.radius) * DAMPING;

「残り距離の 12% ずつ詰める」ので、動き始めは速く、目標に近づくほどゆっくりになる — 自然な減速カーブが 1 行の lerp から出てきます。ホイールの段付き感も消え、ドラッグを離したあとも少し滑ってから止まります。目標値と現在値を分けるこの構えは、カメラに限らず「値の変化を滑らかに見せたい」場面すべてで使える定番です(次節の追跡カメラでも同じ手を使います)。

6. 追跡カメラ — target を差し替えるだけ

カメラの状態を「eye と target をどう決めるか」に整理したご利益が、ここで一気に出ます。動くものを追いかける追跡カメラ (follow camera)は、target を動く対象の位置にするだけで出来上がりです。デモの「追跡カメラ」ボタンを押すと、トーラスの周りを公転している小さな球にカメラが切り替わります。

src/lessons/15-camera/main.ts(抜粋)
// 追跡カメラ: 注視点は対象そのもの、eye は対象からのオフセット(ワールド固定)。
// どちらも lerp で少し遅らせると、急発進しない滑らかな追跡になる
vec3.lerp(target, target, spherePosition, TRACK_DAMPING);
vec3.add(trackEye, spherePosition, TRACK_OFFSET);
vec3.lerp(eye, eye, trackEye, TRACK_DAMPING);

eye は対象位置 + ワールドに固定したオフセット(少し上、+z 側)で決めています。対象の進行方向に合わせて後ろに回り込ませるには、オフセットを対象の向きで回す必要があります — そこは第19章のノード階層以降の話です。ここでも target と eye を対象の真の位置へ直接代入せず、lerp で遅らせています。剛結すると対象の細かい上下動までカメラが拾って画面が揺れますが、少し遅れて追うだけで「カメラマンが手で追っている」ような柔らかさになります。三人称視点のゲームのカメラも、基本はこの形の発展です。

もう 1 つ、モードを追跡 → 軌道へ戻すときの小技を仕込んであります。軌道カメラの (θ, φ, r) は切り替え前のまま残っているので、そのまま戻すとカメラが一瞬でワープします。そこで切り替えの瞬間に、いまの eye と target から球面座標を逆算して (θ, φ, r) を上書きします。2 節の変換式の逆向き、つまり第10章でやった「直交座標 → 極座標」の 3D 版です。ここでも起点は +z なので、引数の順はatan2(dx, dz)— 第10章の atan(y, x) とは x と z の役が入れ替わっている点に注意してください。

src/lessons/15-camera/main.ts(抜粋)
// 追跡 → 軌道に戻る瞬間、現在のカメラ位置から球面座標を逆算しておくと
// カメラが飛ばない。直交座標 → 球面座標は、第10章の atan の 3D 版
// (起点が +z なので atan2(dx, dz)。第10章とは x と z の役が入れ替わる)
function syncOrbitToCamera(): void {
  const dx = eye[0] - target[0];
  const dy = eye[1] - target[1];
  const dz = eye[2] - target[2];
  const radius = Math.hypot(dx, dy, dz);
  if (radius === 0) return; // dy / 0 が NaN になるのを防ぐ(この経路では起きないが保険)
  orbit.theta = Math.atan2(dx, dz);
  orbit.phi = Math.asin(dy / radius);
  orbit.radius = radius;
  orbit.thetaTarget = orbit.theta;
  orbit.phiTarget = Math.min(PHI_LIMIT, Math.max(-PHI_LIMIT, orbit.phi));
  orbit.radiusTarget = Math.min(MAX_RADIUS, Math.max(MIN_RADIUS, radius));
}

コード全文

手元のファイルはsrc/lessons/15-camera/にあります。シェーダーは第14章のgeometry.vert/ geometry.fragとほぼ同じで、床の市松表示だけが増えています — この章の主役はあくまで uniform に渡すビュー行列の「作られ方」でした。切り替えのフラグをboolではなく float の uniform にしてあるのは、mix()の第 3 引数にそのまま渡せて分岐を書かずに済むからです(第13章ではuniform bool+ uniform1i を使いました)。

src/lessons/15-camera/main.ts
// 第15章: カメラ — 軌道カメラ・追跡カメラ
// three.js の OrbitControls に相当する「軌道カメラ」を自作する。
// カメラの状態は「注視点 target + 球面座標(方位角 θ・仰角 φ・距離 r)」だけで持ち、
// 毎フレーム eye の位置を計算して mat4.lookAt に渡す(lookAt の導出は第12章)。
// もう 1 つの「追跡カメラ」は、target を動く対象に差し替えるだけで作れる。

import { mat4, vec3 } from 'gl-matrix';
import {
  createPlane,
  createSphere,
  createTorus,
  type Geometry,
  interleave,
} from '../../lib/geometry';
import { compileShader, linkProgram } from '../../lib/shader';
import fragmentSource from './scene.frag?raw';
import vertexSource from './scene.vert?raw';

// ---------------------------------------------------------------------------
// メッシュ: ジオメトリを VAO に配線する(第13・14章の手順そのまま)
// ---------------------------------------------------------------------------

interface Mesh {
  vao: WebGLVertexArrayObject;
  indexCount: number;
}

function createMesh(gl: WebGL2RenderingContext, geometry: Geometry): Mesh {
  const vao = gl.createVertexArray();
  gl.bindVertexArray(vao);

  // 位置・法線・UV を 1 本の VBO にインターリーブ(第14章)。
  // stride / offset はバイト単位。数値を直書きせず BYTES_PER_ELEMENT から組み立てる(第13章)
  const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT;
  const stride = 8 * FLOAT_BYTES; // 1 頂点 = 8 float(位置 3 + 法線 3 + UV 2)= 32 バイト
  const vbo = gl.createBuffer();
  gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
  gl.bufferData(gl.ARRAY_BUFFER, interleave(geometry), gl.STATIC_DRAW);
  gl.enableVertexAttribArray(0); // a_position
  gl.vertexAttribPointer(0, 3, gl.FLOAT, false, stride, 0);
  gl.enableVertexAttribArray(1); // a_normal: 位置 3 つ分(12 バイト)の後ろ
  gl.vertexAttribPointer(1, 3, gl.FLOAT, false, stride, 3 * FLOAT_BYTES);
  gl.enableVertexAttribArray(2); // a_uv: 位置 + 法線 6 つ分(24 バイト)の後ろ
  gl.vertexAttribPointer(2, 2, gl.FLOAT, false, stride, 6 * FLOAT_BYTES);

  // インデックスバッファのバインドは VAO に記録される(第13章)
  const ibo = gl.createBuffer();
  gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
  gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geometry.indices, gl.STATIC_DRAW);

  gl.bindVertexArray(null);
  return { vao, indexCount: geometry.indices.length };
}

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

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  controls: HTMLParagraphElement,
): void {
  // --- 描画の準備(ここまでは第13・14章の復習) -----------------------------

  const program = linkProgram(
    gl,
    compileShader(gl, gl.VERTEX_SHADER, vertexSource),
    compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource),
  );
  const modelLocation = gl.getUniformLocation(program, 'u_model');
  const viewLocation = gl.getUniformLocation(program, 'u_view');
  const projectionLocation = gl.getUniformLocation(program, 'u_projection');
  const checkerLocation = gl.getUniformLocation(program, 'u_checker');

  const torusMesh = createMesh(gl, createTorus(0.7, 0.28, 64, 32));
  const floorMesh = createMesh(gl, createPlane(9, 9));
  const sphereMesh = createMesh(gl, createSphere(0.16, 24, 16));

  gl.enable(gl.DEPTH_TEST); // 深度テスト(第13章)
  gl.enable(gl.CULL_FACE); // 裏面カリング。捨てる面は既定の BACK のまま
  gl.clearColor(0.06, 0.07, 0.09, 1.0);

  // --- 軌道カメラの状態 -----------------------------------------------------
  // 注視点 target(点 1 つ)と、方位角 θ・仰角 φ・距離 r の 3 つの数値でカメラは決まる。
  // 各値を「目標値(入力が書き換える)」と「現在値(描画が使う)」の 2 本立てで
  // 持つのは減衰のため。毎フレーム、現在値を目標値へ lerp で近づける

  const orbit = {
    theta: 0.6, // 方位角(ラジアン)。y 軸まわりの向き
    phi: 0.35, // 仰角(ラジアン)。水平面から上がプラス
    radius: 6.5, // 注視点からの距離
    thetaTarget: 0.6,
    phiTarget: 0.35,
    radiusTarget: 6.5,
  };
  const PHI_LIMIT = Math.PI / 2 - 0.05; // 仰角の限界。真上・真下の ε 手前で止める
  const MIN_RADIUS = 2.5;
  const MAX_RADIUS = 14;
  const DAMPING = 0.12; // 1 フレームで目標値へ近づく割合(1.0 で減衰なし)
  const TRACK_DAMPING = 0.06; // 追跡カメラ用。小さいほど遅れてついていく

  const ORBIT_TARGET = vec3.fromValues(0, 0, 0); // 軌道カメラの注視点(トーラスの中心)
  // 追跡対象から eye までのオフセット(ワールド固定。対象の向きには追従しない)
  const TRACK_OFFSET = vec3.fromValues(0, 1.2, 2.8);
  const UP = vec3.fromValues(0, 1, 0);
  // カメラ定数(第3部の標準)。fovy 45°・near 0.1・far 100
  const FOVY = (45 * Math.PI) / 180;
  const NEAR = 0.1;
  const FAR = 100;
  // 床の位置。球の公転(上下 ±0.5)より十分下に置いて、めり込ませない
  const FLOOR_POSITION = vec3.fromValues(0, -1.5, 0);

  // 「いま実際に」カメラがある点と見ている点。モードをまたいでも連続に保つ
  const eye = vec3.create();
  const target = vec3.fromValues(0, 0, 0);

  // 毎フレーム使い回す入れ物(フレームごとに new しない)
  const spherePosition = vec3.create();
  const trackEye = vec3.create();
  const model = mat4.create();
  const view = mat4.create();
  const projection = mat4.create();

  let mode: 'orbit' | 'track' = 'orbit';

  // --- ドラッグで回す: pointerdown / pointermove / pointerup ----------------

  let dragging = false;
  // ドラッグ中のポインタの id。2 本目の指や別のデバイスに lastX / lastY を奪われないよう照合する
  let activePointerId: number | null = null;
  let lastX = 0;
  let lastY = 0;

  canvas.addEventListener('pointerdown', (event) => {
    if (mode !== 'orbit') return;
    // 主ボタンだけを拾う。右クリックのドラッグでカメラが回らないように
    // (タッチ・ペンの接地も button === 0 で届く)
    if (event.button !== 0) return;
    dragging = true;
    activePointerId = event.pointerId;
    lastX = event.clientX;
    lastY = event.clientY;
    // キャプチャしておくと、ポインタが canvas の外に出ても
    // pointermove / pointerup がこの canvas に届き続ける
    canvas.setPointerCapture(event.pointerId);
  });

  canvas.addEventListener('pointermove', (event) => {
    if (!dragging || event.pointerId !== activePointerId) return;
    const dx = event.clientX - lastX;
    const dy = event.clientY - lastY;
    lastX = event.clientX;
    lastY = event.clientY;

    // canvas の高さぶんのドラッグ = 一周(2π)。OrbitControls と同じ換算
    const speed = (2 * Math.PI) / canvas.clientHeight;
    orbit.thetaTarget -= dx * speed;
    orbit.phiTarget += dy * speed;

    // 仰角のクランプ: 真上・真下(±π/2)では視線と up が平行になって
    // lookAt が破綻するので、ε だけ手前で止める
    orbit.phiTarget = Math.min(PHI_LIMIT, Math.max(-PHI_LIMIT, orbit.phiTarget));
  });

  function endDrag(event: PointerEvent): void {
    if (event.pointerId !== activePointerId) return;
    dragging = false;
    activePointerId = null;
    if (canvas.hasPointerCapture(event.pointerId)) {
      canvas.releasePointerCapture(event.pointerId);
    }
  }
  canvas.addEventListener('pointerup', endDrag);
  canvas.addEventListener('pointercancel', endDrag);

  // --- ホイールでズーム: 距離 r を「乗算」で変える --------------------------

  // deltaY の単位は deltaMode で変わる(0 = px / 1 = 行 / 2 = ページ)。
  // 行・ページ単位で来る環境でも同じ効き方になるよう px 相当に揃える(three.js も同じことをしている)
  const LINE_HEIGHT = 16;
  const PAGE_HEIGHT = 100;

  canvas.addEventListener(
    'wheel',
    (event) => {
      // ページのスクロールを乗っ取るので preventDefault する。モードによって
      // スクロールしたりしなかったりしないよう、早期 return より前で止める
      event.preventDefault();
      if (mode !== 'orbit') return;

      const scale = event.deltaMode === 1 ? LINE_HEIGHT : event.deltaMode === 2 ? PAGE_HEIGHT : 1;

      // 加算ではなく乗算: 1 ノッチごとに数 % ずつ。近くでは細かく、
      // 遠くでは大きく動くので、どの距離でも操作感が揃う
      orbit.radiusTarget *= Math.exp(event.deltaY * scale * 0.001);
      orbit.radiusTarget = Math.min(MAX_RADIUS, Math.max(MIN_RADIUS, orbit.radiusTarget));
    },
    // 要素に直接付けたリスナーは仕様上どのみち passive: false だが、
    // window などへ付け替えた瞬間に壊れる暗黙の前提なので明示しておく(本文参照)
    { passive: false },
  );

  // --- モード切替 -----------------------------------------------------------

  // 追跡 → 軌道に戻る瞬間、現在のカメラ位置から球面座標を逆算しておくと
  // カメラが飛ばない。直交座標 → 球面座標は、第10章の atan の 3D 版
  // (起点が +z なので atan2(dx, dz)。第10章とは x と z の役が入れ替わる)
  function syncOrbitToCamera(): void {
    const dx = eye[0] - target[0];
    const dy = eye[1] - target[1];
    const dz = eye[2] - target[2];
    const radius = Math.hypot(dx, dy, dz);
    if (radius === 0) return; // dy / 0 が NaN になるのを防ぐ(この経路では起きないが保険)
    orbit.theta = Math.atan2(dx, dz);
    orbit.phi = Math.asin(dy / radius);
    orbit.radius = radius;
    orbit.thetaTarget = orbit.theta;
    orbit.phiTarget = Math.min(PHI_LIMIT, Math.max(-PHI_LIMIT, orbit.phi));
    orbit.radiusTarget = Math.min(MAX_RADIUS, Math.max(MIN_RADIUS, radius));
  }

  const modes = [
    { id: 'orbit', label: '軌道カメラ' },
    { id: 'track', label: '追跡カメラ' },
  ] as const;

  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', () => {
      if (entry.id === mode) return;
      if (entry.id === 'orbit') {
        syncOrbitToCamera();
      }
      // ドラッグ中に切り替えると dragging が true のまま残るので、ここで畳む
      dragging = false;
      activePointerId = null;
      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);
    // 表示サイズが 0 でも 0 除算・NaN 行列にならないよう 1px を下限にする(第2章)
    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);
    }
  }

  // --- 描画ループ -----------------------------------------------------------

  function drawMesh(mesh: Mesh, modelMatrix: mat4, checker: number): void {
    gl.uniformMatrix4fv(modelLocation, false, modelMatrix); // transpose は常に false
    gl.uniform1f(checkerLocation, checker);
    gl.bindVertexArray(mesh.vao);
    gl.drawElements(gl.TRIANGLES, mesh.indexCount, gl.UNSIGNED_SHORT, 0);
  }

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

    // 追跡対象: トーラスの周囲を公転する小さな球。
    // 公転: 半径 1.9 を 0.7 rad/s、上下に ±0.5 を 1.1 rad/s(第9章)。
    // 周期をずらすと軌道が閉じず、追跡カメラの動きが単調にならない
    vec3.set(
      spherePosition,
      Math.cos(time * 0.7) * 1.9,
      Math.sin(time * 1.1) * 0.5,
      Math.sin(time * 0.7) * 1.9,
    );

    if (mode === 'orbit') {
      // 減衰: 現在値を目標値へ、毎フレーム一定割合だけ近づける(lerp)。
      // 入力は目標値だけを書き換え、描画は現在値だけを使う
      orbit.theta += (orbit.thetaTarget - orbit.theta) * DAMPING;
      orbit.phi += (orbit.phiTarget - orbit.phi) * DAMPING;
      orbit.radius += (orbit.radiusTarget - orbit.radius) * DAMPING;

      // 注視点をトーラスの中心へ戻す(追跡カメラから切り替えた直後のため)
      vec3.lerp(target, target, ORBIT_TARGET, DAMPING);

      // 球面座標 (θ, φ, r) → eye の位置。第10章の「極座標 → 直交座標」の 3D 版
      eye[0] = target[0] + orbit.radius * Math.cos(orbit.phi) * Math.sin(orbit.theta);
      eye[1] = target[1] + orbit.radius * Math.sin(orbit.phi);
      eye[2] = target[2] + orbit.radius * Math.cos(orbit.phi) * Math.cos(orbit.theta);
    } else {
      // 追跡カメラ: 注視点は対象そのもの、eye は対象からのオフセット(ワールド固定)。
      // どちらも lerp で少し遅らせると、急発進しない滑らかな追跡になる
      vec3.lerp(target, target, spherePosition, TRACK_DAMPING);
      vec3.add(trackEye, spherePosition, TRACK_OFFSET);
      vec3.lerp(eye, eye, trackEye, TRACK_DAMPING);
    }

    // eye と target が決まれば、あとは第12章で学んだ 2 つの行列を作るだけ
    mat4.lookAt(view, eye, target, UP);
    const aspect = gl.drawingBufferWidth / gl.drawingBufferHeight;
    mat4.perspective(projection, FOVY, aspect, NEAR, FAR);

    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
    gl.useProgram(program);
    gl.uniformMatrix4fv(viewLocation, false, view);
    gl.uniformMatrix4fv(projectionLocation, false, projection);

    // トーラス: 原点でゆっくり回転
    mat4.identity(model);
    mat4.rotateX(model, model, time * 0.4);
    mat4.rotateY(model, model, time * 0.6);
    drawMesh(torusMesh, model, 0);

    // 床: XY 平面の板を X 軸で -90° 回して床にする(第14章)。
    // CCW の表が +y(上)を向くので、カリングを有効にしたまま上から見える
    mat4.fromTranslation(model, FLOOR_POSITION);
    mat4.rotateX(model, model, -Math.PI / 2);
    drawMesh(floorMesh, model, 1);

    // 追跡対象の球
    mat4.fromTranslation(model, spherePosition);
    drawMesh(sphereMesh, model, 0);

    requestAnimationFrame(frame);
  }

  // このページのデモはページと寿命を共にするので、rAF ループの停止も、canvas に付けた
  // 5 つのリスナー(pointerdown / pointermove / pointerup / pointercancel / wheel)の
  // 解除もしていない。止める必要が出るケースは第6章の stop()、リソース解放の一般論は第35章
  requestAnimationFrame(frame);
}

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

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

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

setup(gl, canvas, controls);
src/lessons/15-camera/scene.vert
#version 300 es

// 第3部の共通の属性配置(第14章と同じ): 0 = 位置, 1 = 法線, 2 = UV
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec3 a_normal;
layout(location = 2) in vec2 a_uv;

uniform mat4 u_model;
uniform mat4 u_view;
uniform mat4 u_projection;

out vec3 v_normal;
out vec2 v_uv;

void main() {
  // 法線もモデル行列で回す。今回のモデル行列は回転と平行移動だけなので
  // 左上 3×3 を取り出す mat3(u_model) で十分(拡大縮小が混ざるときの
  // 正しい扱い = 法線行列は第17章)
  v_normal = mat3(u_model) * a_normal;
  v_uv = a_uv;

  gl_Position = u_projection * u_view * u_model * vec4(a_position, 1.0);
}
src/lessons/15-camera/scene.frag
#version 300 es

precision highp float;

in vec3 v_normal;
in vec2 v_uv;

// 0.0 = 法線を色にする(トーラス・球) / 1.0 = UV の市松模様(床)。
// bool ではなく float なのは、下の mix() の第 3 引数にそのまま渡して分岐を避けるため
uniform float u_checker;

out vec4 fragColor;

void main() {
  // 法線色: 成分が -1〜+1 の単位ベクトルを 0〜1 に写して RGB にする。
  // ライティング(第17章)を学ぶ前に立体感を確かめる定番のデバッグ表示。
  // 補間された法線は長さが 1 からずれるので normalize し直す(第14章)
  vec3 normalColor = normalize(v_normal) * 0.5 + 0.5;

  // UV 市松: floor で格子番号を作り、縦横の和の偶奇で塗り分ける(第9章の応用)。
  // 第14章のコメントで示した step(0.5, fract(...)) の XOR とは別解(格子の細かさだけ違う)
  float cell = mod(floor(v_uv.x * 12.0) + floor(v_uv.y * 12.0), 2.0);
  vec3 checkerColor = mix(vec3(0.13, 0.15, 0.19), vec3(0.22, 0.25, 0.31), cell);

  fragColor = vec4(mix(normalColor, checkerColor, u_checker), 1.0);
}

three.js との対応

この章で書いたのは、three.js ではOrbitControlsがまるごと隠している部分です。主な設定項目と自作コードの変数は 1 対 1 に対応します。

three.jsこの章
new OrbitControls(camera, canvas) + 毎フレーム update()この章の main.ts 全体(状態 + イベント + フレーム毎の減衰と eye の再計算)
controls.targettarget(注視点。lookAt に渡す点)
enableDamping / dampingFactor目標値・現在値の 2 本立てと DAMPING(毎フレームの lerp 割合)
minDistance / maxDistanceMIN_RADIUS / MAX_RADIUS
minPolarAngle / maxPolarAngle±PHI_LIMITのクランプ。ただし three.js は「+y 軸から測る極角(0〜π)」、この章は「水平面から測る仰角(-π/2〜+π/2)」なので、極角 = π/2 − 仰角 の読み替えが要る
camera.lookAt(target)mat4.lookAt(view, eye, target, UP)(行列の中身の導出は第12章)
Object3D.getWorldPosition()(追跡対象の位置取り)spherePosition。今回は自前の変数 1 つで足りるが、親子階層を持つモデルでは「ワールド行列から位置を取り出す」操作になる(ノード階層は第19章)

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

カメラは「操作感」が主題なので、定数を変えて手で確かめるのが一番身になります。src/lessons/15-camera/main.tsをいじってみてください。

まとめ

シーンを自由な角度から眺められるようになったので、次は物体の表面です。第16章「テクスチャ」では、画像を GPU に送って面に貼るまでの一式 — フィルタ・ラップ・ミップマップ・複数テクスチャユニット — を扱います。法線色の代わりに、ようやく「絵」が貼れるようになります。