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

モデル読み込み — OBJ と glTF 2.0

第14章で、平面・球・トーラスを数式から生成しました。しかし世の中の 3D モデルの大半は、数式で書ける形をしていません。キャラクター、建物、家具 — こうした形は モデリングソフトで人が作り、ファイルとして流通します。第14章では、どんな形を 作っても GPU に渡すものは「頂点の属性の束 + 三角形のインデックス」のままだ、と書きました。 その「どんな形」を外から持ってくるのが、この章です。

three.js ならnew GLTFLoader().load(url, ...)の 1 行で済む話です。この章はその 1 行を開けて、ファイルの中身が GPU の頂点バッファになるまでを自分で書きます。扱う形式は 2 つ — 手で読める最小のテキスト形式OBJ (Wavefront OBJ)と、現在の Web 3D の標準であるglTF 2.0 (GL Transmission Format)です。

3 つのモデルを切り替えます。ドラッグで回り込み、ホイールで寄り引きできます(第15章の 軌道カメラの簡略版)。OBJ(法線あり)vnをそのまま使うのでなめらか、OBJ(法線なし)は同じ形なのに面ごとの 平らな陰影になります — ファイルに法線がないので、読み込み側が 2 辺の外積で面法線を 計算しているからです。glTFはノード階層つきで、支柱が回るとその子の腕と頭もついて回ります。読み出し行には 読み込んだファイルの内訳が出て、glTF のときは階層を辿って合成した Head ノードのワールド位置も並びます。

この章で学ぶこと:

1. なぜ自分でパーサを書くのか

最初に正直なことを書いておきます。実務で OBJ パーサや glTF ローダーを自作する理由は、ほとんどありません。three.js のGLTFLoaderは仕様のほぼ全域(スキニング、アニメーション、モーフターゲット、各種拡張)に対応していて、 テストも実績も桁違いです。この章のコードは、その足元にも及びません。

それでも書くのは、形式を理解するためです。理解しておくと、こういうときに 効きます。

どれも「ローダーの中で何が起きているか」を知らないと勘で潰すしかない問題です。この章を 読み終えると、.gltfをテキストエディタで開いて中身が読めるようになります。それがこの章の到達点です。

2. OBJ の書式 — 4 つの命令でできている

OBJ (Wavefront OBJ)は、もともと Wavefront Technologies 社の 3D ソフト用に作られた形式です。標準化団体が管理する公式の仕様書は存在しません— 出回っているのは各社・各実装が書いたドキュメントです。それでも長く生き残っているのは、 書式が単純でテキストだからです。以下は「広く実装されている書き方」であって、規格に 照らして正しいと言えるものではない点は断っておきます。

OBJ は 1 行 1 命令です。行頭の単語が命令の種類を表します。

意味
v x y z頂点位置 (vertex)。4 つ目に w が付くこともあるが、まず使われない
vt u vテクスチャ座標 (vertex texture)
vn x y z法線 (vertex normal)。単位ベクトルとは限らない
f …面 (face)。3 つ以上の「隅」を並べる。隅の書き方が次の主題
o / g / sオブジェクト名 / グループ名 / スムージンググループ。この章のパーサは読み飛ばす
mtllib / usemtl材質ファイル (.mtl) の指定と、以降の面に使う材質名。こちらも読み飛ばす(9 節)
#コメント
OBJ の書式(説明のための最小例)
# 三角形 1 枚だけの OBJ(書式の説明用)
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 0.0 1.0 0.0
vt 0.0 0.0
vt 1.0 0.0
vt 0.0 1.0
vn 0.0 0.0 1.0
f 1/1/1 2/2/1 3/3/1

ここに OBJ 最大の癖が 2 つ出ています。1 つ目、インデックスが 1 始まりです。f 1/1/11は「最初のv」を指します。プログラムから見れば 0 始まりに直す作業が必ず要ります。

2 つ目、隅はv/vt/vnの 3 つのインデックスの組です。vtvnは省略でき、1(位置だけ)/ 1/1(位置と UV)/1//1(位置と法線・UV を飛ばすので/が 2 つ続く)/ 1/1/1(全部)の 4 通りがあります。

デモで読み込んでいるきのこのモデルは、こんなファイルです。

public/models/mushroom.obj(抜粋)
# 第19章 サンプルモデル: きのこ(頂点法線・テクスチャ座標つき)
# 傘のドームと柄の側面には滑らかな頂点法線 vn が入っている
mtllib mushroom.mtl
o Mushroom

v 0.000000 1.600000 0.000000
v 0.382683 1.554328 0.000000
v 0.331414 1.554328 0.191342
public/models/mushroom.obj(抜粋)
g cap
usemtl cap
f 2/1/1 1/2/2 3/3/3
f 3/3/3 1/4/2 4/5/4
f 4/5/4 1/6/2 5/7/5

このファイルにはvが 73 行、vtが 115 行、vnが 50 行あります。3 つの数がバラバラなことに注目してください。位置は 傘の継ぎ目で共有できるので少なく、UV は継ぎ目で切れるので多く、法線は同じ向きの面が 多いので少ない。OBJ は 3 つの配列を独立に持てるので、それぞれが必要な数だけあります。

src/lessons/19-model-loading/obj-parser.ts(抜粋)
/**
 * OBJ のインデックスは 1 始まり。負の値は「末尾から数えて何番目か」を表す。
 * 「未指定」だけを -1 で返し、不正な値はその場で throw する —
 * 両方を -1 で表すと、あとでエラーの原因を取り違える。
 */
function resolveIndex(
  text: string | undefined,
  count: number,
  line: number,
  token: string,
): number {
  if (text === undefined || text === '') return -1; // "12//3" の真ん中のような未指定
  const value = Number.parseInt(text, 10);
  if (!Number.isInteger(value) || value === 0) {
    throw new Error(`${line} 行目: 面のインデックスが読めません(${token})`);
  }
  const index = value > 0 ? value - 1 : count + value;
  if (index < 0) {
    throw new Error(`${line} 行目: 負のインデックスが先頭より前を指しています(${token})`);
  }
  return index;
}

/** "12/7/3" / "12//3" / "12/7" / "12" のいずれの形も受け付ける */
function parseCorner(token: string, counts: [number, number, number], line: number): Corner {
  const parts = token.split('/');
  return {
    v: resolveIndex(parts[0], counts[0], line, token),
    vt: resolveIndex(parts[1], counts[1], line, token),
    vn: resolveIndex(parts[2], counts[2], line, token),
  };
}

そしてf3 隅とは限りません。四角形は 4 隅、n 角形は n 隅で書けます。GPU が描けるのは三角形だけ(第13章)なので、パーサ側で分割します。いちばん 簡単なのがファン (fan) 分割で、隅 0 を軸に (0, 1, 2), (0, 2, 3), (0, 3, 4) … と切っていく方法です。凸多角形ならこれで正しく埋まります(凹んだ多角形は はみ出しますが、実用上ほとんどの OBJ は凸の四角形です)。デモのきのこは 73 面のうち 72 面が三角形と四角形、最後の 1 面だけが 12 角形で、分割後は 142 枚の三角形になります。

src/lessons/19-model-loading/obj-parser.ts(抜粋)
let triangleCount = 0;
for (const face of faces) {
  // 四角形以上の面は「扇(ファン)分割」で三角形にする。
  // 隅 0 を軸に (0, 1, 2), (0, 2, 3), (0, 3, 4) … と切っていく
  const first = vertexIndex(face[0]);
  for (let k = 1; k < face.length - 1; k++) {
    outIndices.push(first, vertexIndex(face[k]), vertexIndex(face[k + 1]));
    triangleCount++;
  }
}

3. インデックスが 3 本ある形式を、1 本しかない WebGL へ

ここがこの章の山場です。もう一度、OBJ の面の 1 隅f 2/1/1 …を見てください。この隅は「位置は 2 番、UV は 1 番、法線は 1 番」と言っています。

OBJ は「位置の配列」「UV の配列」「法線の配列」を別々に持ち、隅ごとに 3 本のインデックスで拾ってきます。ところがgl.drawElementsに渡せるインデックスは1 本だけです。インデックス5と言ったら、位置も法線も UV も、いっせいに 5 番目が読まれます。属性ごとに違う番号を 指定する方法は、WebGL には存在しません。

したがって変換が必要です。(v, vt, vn) の組み合わせ 1 つにつき、GPU 用の頂点を 1 つ作る。同じ組み合わせが再び現れたら、作った頂点を使い回す。この「組み合わせの 重複を潰す」ために Map を使います。

OBJ: インデックスが 3 本f 2/1/1 …v位置73 個vtUV115 個vn法線50 個3 つの配列の長さはバラバラでよい組をキーにMap<"2/1/1", 0>同じ組は使い回すWebGL: インデックスは 1 本位置法線UV0位置法線UV121271271 頂点 = 属性の組。128 頂点に増えるindices: 0, 1, 2, 2, 3, 4, …(426 個)drawElements が読むのはこの 1 本だけきのこのモデル: 面 73 枚 → 三角形 142 枚位置は 73 個しかないのに、GPU に渡す頂点は128 個になる理由は第13章の立方体とまったく同じ — 頂点とは「位置」ではなく「属性の組」だから
OBJ の 3 本のインデックスを、WebGL の 1 本へ畳み込む。(v, vt, vn) の組を Map のキーにして、同じ組には同じ頂点番号を割り当てます。位置を共有していても UV や法線が違えば別の頂点になるので、頂点数は元の位置の数より増えます。
src/lessons/19-model-loading/obj-parser.ts(抜粋・簡略化)
function vertexIndex(corner: Corner): number {
  // 3 本のインデックスの組をそのままキーにする。同じ組が再び現れたら使い回す
  const key = `${corner.v}/${corner.vt}/${corner.vn}`;
  const cached = cache.get(key);
  if (cached !== undefined) return cached;
  // (ここで positions / normals / uvs から 1 頂点ぶんを取り出して積む)
  cache.set(key, index);
  return index;
}

第13章で、立方体の角は 8 つなのに頂点は 24 個必要だと学びました。角を 共有する 3 つの面で法線(第13章では色)が違うから、同じ位置の頂点を 3 つ持つしかない — という話でした。いまやっているのは、まったく同じことの一般形です。OBJ は「位置は共有、属性は別」という人間に都合のよい持ち方を許していて、GPU は「1 頂点 = 属性の組」というハードウェアに都合のよい持ち方しか受け付けない。 パーサの仕事の半分は、この 2 つの世界の間の翻訳です。

数字で見ると納得しやすいはずです。デモの読み出し行が示すとおり、きのこのモデルはvが 73 個なのに、展開後の頂点は 128 個になります。増えた 55 個は、傘の UV の継ぎ目(第14章で「無駄な頂点には理由がある」と書いた、あれです)と、傘と柄の境目で 法線が切り替わる場所です。

4. 法線を持たないモデル — 2 辺の外積で面法線を作る

vnを持たない OBJ は珍しくありません。手書きしたファイル、法線を書き出さない設定でエクスポート したファイル、そもそも法線という概念がなかった時代のデータ。しかし第17章で見たとおり、 法線がなければ陰影は付きません。ないなら作るしかありません。

デモの 2 つ目のモデルは、1 つ目とまったく同じ形をvn抜きで書き出したファイルです。

public/models/mushroom-flat.obj(抜粋)
# 第19章 サンプルモデル: きのこ(法線なし)
# vn 行がないので、読み込み側で 2 辺の外積から面法線を計算する
# 最後の 12 角形の面だけ、負のインデックス(末尾から数える)で書いてある
o Mushroom

ここで第11章の伏線を回収します。三角形の頂点を a, b, c とすると、2 辺b - ac - aはその三角形が乗っている平面を張ります。この 2 本に垂直なベクトルは —外積 (cross product)そのものです。第11章で「2 本のベクトルから、それらに垂直な 3 本目を作れる」と書いた能力が、 ここで働きます。

src/lessons/19-model-loading/face-normals.ts(抜粋)
  // 2 辺のベクトル(頂点 a を起点にする)
  const e1x = positions[b * 3] - ax;
  const e1y = positions[b * 3 + 1] - ay;
  const e1z = positions[b * 3 + 2] - az;
  const e2x = positions[c * 3] - ax;
  const e2y = positions[c * 3 + 1] - ay;
  const e2z = positions[c * 3 + 2] - az;
  // 外積 e1 × e2(第11章の定義そのまま)
  out[outOffset] += e1y * e2z - e1z * e2y;
  out[outOffset + 1] += e1z * e2x - e1x * e2z;
  out[outOffset + 2] += e1x * e2y - e1y * e2x;

外積の向きも第11章と第13章のとおりです。a → b → c が反時計回り (CCW) に並んでいれば、cross(b - a, c - a)表側を向きます。つまり、面の巻き順が正しく揃っているファイルなら、 計算した法線は自動的に外を向きます。逆に言えば、計算した法線が内側を向いてしまうモデルは、巻き順が壊れているということです。

もう 1 つ、外積の長さにも意味があります。|cross(e1, e2)|は 2 辺が張る平行四辺形の面積、つまり三角形の面積の 2 倍です。正規化せずに足し込んでいけば、 「大きい面ほど強い」重み付き平均が勝手にできあがります。この性質は、このあとスムージングを 扱うときに効きます。

面法線ができたら、それを面の 3 隅すべてに配ります。隣り合う面とは共有しません。共有しないからこそ、境目で法線がぱきっと切り替わり、面がそれぞれ平らに見える — これがフラットシェーディング (flat shading)です。デモの「OBJ(法線なし)」が カクカクして見えるのはこのためです。

src/lessons/19-model-loading/obj-parser.ts(抜粋)
} else if (generatedNormals) {
  // フラット: 面ごとに 1 本の法線を作り、その面の全隅に配る。
  // 面をまたいで共有しないので、隣り合う面の境目がくっきり出る
  for (const face of faces) {
    if (!needsNormals(face)) continue;
    const normal = [0, 0, 0];
    // 多角形は扇状に三角形へ割り、各三角形の外積を足し合わせる
    // (平面ならどの三角形も同じ向き。多少ゆがんでいても面積で重み付けされる)
    for (let k = 1; k < face.length - 1; k++) {
      accumulateTriangleNormal(normal, 0, positions, face[0].v, face[k].v, face[k + 1].v);
    }
    normalizeAll(normal);
    const index = normals.length / 3;
    normals.push(normal[0], normal[1], normal[2]);
    for (const corner of face) corner.vn = index;
  }
}

なめらかに見せたいなら、逆のことをします。ある位置を共有するすべての面の法線を 平均するのです。これがスムージング (smoothing)で、パーサには{ smoothNormals: true }のオプションとして入れてあります。実際に試すと、法線なし版の頂点数が 288 個から 115 個へ減ります — 面ごとに別々だった法線が位置ごとに 1 本へ統合され、(v, vt, vn) の組み合わせが減るからです。ここでも「頂点数 = 属性の組み合わせの数」という 3 節の話がそのまま効いています。

5. glTF 2.0 — なぜ「JSON + バイナリ」の 2 階建てなのか

ここから後半、glTF 2.0 (GL Transmission Format)です。Khronos Group が策定した公開仕様で、three.js でも標準的に使われています。仕様書の 「動機と設計目標」の節は glTF を「コンパクトで、実行時の処理と描画が効率的な、オープンで相互運用可能な 3D アセットの 『転送 (transmission)』形式」と説明し、続けて「これらのバイナリリソースは、追加の解析や処理なしに GPU バッファへ直接ロードできることが 多い」と書いています。

OBJ との最大の違いがここです。OBJ はテキストなので、読み込むたびに文字列を数値へ変換する 作業が発生します。頂点が 10 万個あれば、parseFloatが数十万回走ります。glTF はそれをしません。頂点データはGPU にそのまま渡せるバイト列として最初から入っていて、読み込み側の仕事は「どこから何バイト読むか」を JSON から知ることだけです。仕様は同じ節で「glTF はオーサリング形式ではない」「人間が読むためのものではない」とも断っています。編集用のデータはオーサリング形式に 置いておき、glTF は配布と実行時ロードのための書き出し先として使う — そういう役割分担の形式です。

そのため glTF は 2 階建てになっています。

glTF の JSON の骨格(説明のための簡略表記)
{
  "asset": { "version": "2.0" },
  "scene": 0,
  "scenes":     [ ... ],   // 表示するノードの集合
  "nodes":      [ ... ],   // 変換と親子関係。mesh を参照する
  "meshes":     [ ... ],   // primitives = 1 ドローコールぶんの塊
  "materials":  [ ... ],   // pbrMetallicRoughness など
  "accessors":  [ ... ],   // bufferView を「型つき配列」として読む指示
  "bufferViews":[ ... ],   // buffer の中の連続した一区間
  "buffers":    [ ... ]    // バイト列そのもの(.bin / data: URI / GLB の BIN)
}

どの配列もトップレベルに平らに並び、お互いを添字で参照します"mesh": 1と書いてあればmeshes[1]"POSITION": 12と書いてあればaccessors[12]。ネストではなく添字なので、同じメッシュを複数のノードから 使い回したり、同じ bufferView を複数の accessor から覗いたりできます。

描画の単位はmeshes[].primitives[]です。1 つの primitive が1 ドローコールぶんの塊で、頂点属性 (attributes)、インデックス (indices)、材質 (material) をそれぞれ accessor / 材質の添字で指します。1 つのメッシュを複数の primitive に分けるのは、部分ごとに違う材質を割り当てたいときです。デモのロボットの頭が まさにそれで、本体(オレンジ)とバイザー(水色)の 2 つの primitive でできています。

public/models/robot.gltf(抜粋・meshes[3])
{
  "name": "head",
  "primitives": [
    {
      "attributes": {
        "POSITION": 12,
        "NORMAL": 13,
        "TEXCOORD_0": 14
      },
      "indices": 15,
      "material": 2
    },
    {
      "attributes": {
        "POSITION": 16,
        "NORMAL": 17,
        "TEXCOORD_0": 18
      },
      "indices": 19,
      "material": 3
    }
  ]
}

modeを書けばプリミティブの種類(点・線・三角形ストリップなど)も選べますが、既定値は4= 三角形です。この章のローダーは 4 以外を明確にエラーにしています。

6. buffer → bufferView → accessor

バイナリ側の 3 段構造が、glTF でいちばん最初に躓くところです。しかし役割を一言で 言い切れば、そう複雑ではありません。

componentType意味JavaScript の TypedArray
5120signed byte(8 bit)Int8Array
5121unsigned byte(8 bit)Uint8Array
5122signed short(16 bit)Int16Array
5123unsigned short(16 bit)Uint16Array
5125unsigned int(32 bit)Uint32Array
5126float(IEEE-754 単精度)Float32Array

この番号に見覚えがあるかもしれません。gl.FLOATgl.UNSIGNED_SHORTの実体そのものです(gl.FLOAT === 5126)。glTF が OpenGL の列挙値をそのまま採用しているので、読み取った番号をvertexAttribPointerにほぼ素通しできます。なお符号つき 32 bit 整数は仕様上サポートされていませんtypeのほうは"SCALAR"(1 成分)・"VEC2""VEC3""VEC4""MAT2""MAT3""MAT4"の 7 種類で、1 要素のバイト数は componentType のバイト数 × type の成分数 です。

buffers[0] — robot.bin(4200 バイト)bufferView 4byteOffset 840+ byteLength 768bufferView 4 の中身 — byteStride 32 = 1 頂点ぶんの間隔(インターリーブ)位置 12 B法線 12 BUV 8 B位置 12 B頂点 0(32 バイト)頂点 1(32 バイト)byteStride = 32accessors — 同じ bufferView を 3 つの accessor が別々の byteOffset で覗くPOSITION → accessors[4]byteOffset: 0componentType: 5126type: "VEC3" / count: 24NORMAL → accessors[5]byteOffset: 12componentType: 5126type: "VEC3" / count: 24TEXCOORD_0 → accessors[6]byteOffset: 24componentType: 5126type: "VEC2" / count: 24第13章で書いた stride / offset が、そのままファイルの中に書いてある
buffer → bufferView → accessor の 3 段。bufferView が「バイト列のどこからどこまで」を 決め、accessor が「そこを何型で何個読むか」を決めます。byteStrideがあるものはインターリーブされていて、複数の accessor が同じ区間を別々のbyteOffsetから覗きます。
public/models/robot.gltf(抜粋・bufferViews[4])
{
  "buffer": 0,
  "byteOffset": 840,
  "byteLength": 768,
  "byteStride": 32,
  "target": 34962
},
public/models/robot.gltf(抜粋・accessors[4]〜[6])
{
  "bufferView": 4,
  "byteOffset": 0,
  "componentType": 5126,
  "count": 24,
  "type": "VEC3",
  "min": [-0.18, 0.0, -0.18],
  "max": [0.18, 1.1, 0.18]
},
{
  "bufferView": 4,
  "byteOffset": 12,
  "componentType": 5126,
  "count": 24,
  "type": "VEC3"
},
{
  "bufferView": 4,
  "byteOffset": 24,
  "componentType": 5126,
  "count": 24,
  "type": "VEC2"
},

第13章の伏線を、ここで回収します。あのとき「実際の 3D データはほぼこの形で 流通しています(第19章で読む glTF もそうです)」と書きました。その証拠が上の"byteStride": 32です。位置 12 バイト + 法線 12 バイト + UV 8 バイト = 32 バイトが 1 頂点ぶんで、 3 つの accessor が同じ区間をbyteOffset0 / 12 / 24 から覗いている。これは第13〜14章で自分の手で組み立てたレイアウトと、バイト単位で同じものです。第14章のinterleave()が作る「1 頂点 = 8 float = 32 バイト」と一致しているのは偶然ではなく、これが 素直な並べ方だからです。

byteStrideないbufferView もあります。その場合は要素が隙間なく詰まっている (tightly packed)という意味です。インデックスのように頂点属性ではないデータには、そもそもbyteStrideを書いてはいけない決まりです。デモのロボットは、意図的に 2 種類を混ぜてあります — 台座 (base) だけは属性ごとに別々の bufferView(隙間なし)、残りは インターリーブです。読み出し行の「うちインターリーブ 4」がその内訳です。

src/lessons/19-model-loading/gltf-loader.ts(抜粋)
const elementBytes = componentBytes * components;
// byteStride があるものはインターリーブ。ないものは隙間なく詰まっている
const stride = bufferView.byteStride ?? elementBytes;
const start = (bufferView.byteOffset ?? 0) + (accessor.byteOffset ?? 0);

// 仕様が要求する収まり具合をそのまま確かめる。壊れたファイルをここで止められる
const need = (accessor.byteOffset ?? 0) + stride * (accessor.count - 1) + elementBytes;
if (need > bufferView.byteLength) {
  throw new Error(
    `accessors[${index}] が bufferViews[${accessor.bufferView}] に収まりません` +
      `(${need} > ${bufferView.byteLength} バイト)`,
  );
}

const out = viewOf(accessor.componentType, new ArrayBuffer(total * componentBytes), 0, total);
if (stride === elementBytes) {
  // 連続しているので 1 回のコピーで済む
  out.set(viewOf(accessor.componentType, buffer, start, total));
} else {
  // 1 要素ずつ stride だけ飛ばしながら拾う(第13章の stride / offset そのもの)
  for (let i = 0; i < accessor.count; i++) {
    out.set(
      viewOf(accessor.componentType, buffer, start + i * stride, components),
      i * components,
    );
  }
}

7. ノード階層とワールド行列

ここで第12章の伏線を回収します。あのとき three.js 対応表に 「u_modelmat4.translaterotateYscaleの合成(親子階層は第19章の glTF のノード階層で)」と書きました。その第19章です。

glTF のnodes木構造です。各ノードは自分の変換を持ち、childrenで子ノードの添字を並べます。仕様は「ノード階層は閉路のない、互いに交わらない木の集合でなければならない」と定めています — 各ノードの親は 0 個か 1 個です。そしてscenesがルートノードの集合を、scene(単数)がどのシーンを最初に表示するかを指します。

public/models/robot.gltf(抜粋・nodes)
{
  "name": "Turntable",
  "matrix": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, -0.7, 0, 1],
  "children": [1, 2]
},
{
  "name": "Base",
  "mesh": 0
},
{
  "name": "Column",
  "translation": [0.0, 0.3, 0.0],
  "mesh": 1,
  "children": [3]
},
{
  "name": "ArmPivot",
  "translation": [0.0, 1.1, 0.0],
  "rotation": [0.0, 0.0, -0.21644, 0.976296],
  "mesh": 2,
  "children": [4]
},
{
  "name": "Head",
  "translation": [1.15, 0.0, 0.0],
  "scale": [0.9, 0.9, 0.9],
  "mesh": 3
}

デモのロボットは Turntable(土台の位置合わせ)→ Column(支柱)→ ArmPivot(腕の付け根) → Head(頭)の 4 段になっています。変換の書き方が2 通りある点に注目 してください。

TRS から行列を作るときの順序も仕様が決めています —T * R * S の順に掛ける。頂点から見ると、まず scale が効き、次に rotation、最後に translation です。第12章で「行列の掛け算は右から順に 頂点へ作用する」と学んだのと同じ読み方です。gl-matrix のmat4.fromRotationTranslationScaleがちょうどこの順序を作ってくれるので、1 行で済みます。

src/lessons/19-model-loading/gltf-loader.ts(抜粋)
function buildNode(node: NodeJson): GltfNode {
  const localMatrix = mat4.create();
  if (node.matrix) {
    // matrix は列優先の 16 要素。gl-matrix も列優先なので、そのまま入れられる(第11章)
    mat4.copy(localMatrix, node.matrix);
  } else {
    // TRS。仕様は「T * R * S の順に掛ける」と定めている
    // (頂点にはまず scale、次に rotation、最後に translation が効く)
    mat4.fromRotationTranslationScale(
      localMatrix,
      node.rotation ?? [0, 0, 0, 1], // クォータニオン XYZW(W がスカラー部)
      node.translation ?? [0, 0, 0],
      node.scale ?? [1, 1, 1],
    );
  }

そしてワールド行列です。仕様の言葉では「あるノードのグローバル変換行列は、親のグローバル変換行列と自身のローカル変換行列の積である。親を持たないノードでは、グローバル変換行列はローカル変換行列に等しい」。 そのまま書き下すと、こうなります。

src/lessons/19-model-loading/gltf-loader.ts(抜粋)
export function computeWorldMatrices(
  nodes: readonly GltfNode[],
  roots: readonly number[],
  out: mat4[],
): void {
  const visit = (index: number, parent: ReadonlyMat4): void => {
    mat4.multiply(out[index], parent, nodes[index].localMatrix);
    for (const child of nodes[index].children) visit(child, out[index]);
  };
  for (const root of roots) visit(root, IDENTITY);
}
nodes — 親子関係の木world — 親から順に掛け合わせた行列TurntableBaseColumnArmPivotHeadmesh なしmesh 0 basemesh 1 columnmesh 2 armmesh 3 headW0 = L0W1 = W0 × L1W2 = W0 × L2W3 = W2 × L3W4 = W3 × L4ルートは親がないので L0 のまま親は Turntable親は Turntable親は Column親は ArmPivotL2(Column のローカル行列)を回すとW2が変わり、その下のW3W4も自動的に変わる腕と頭に触っていないのに一緒に回るのは、これだけの理由
ロボットのノード階層と、そこから組み立てるワールド行列。木をルートから辿りながら 「親のワールド行列 × 自分のローカル行列」を掛けていくだけです。子は親の結果を受け取るので、 親の変換を書き換えれば子孫はまとめて追随します。

中身は小さな再帰 1 つだけです。これがシーングラフ (scene graph)の全部で、three.js のObject3D.updateMatrixWorld()が毎フレームやっているのも、突き詰めればこの計算です。デモでは Column と ArmPivot の 2 つのノードだけを回しています。子の Head には何もしていないのに、支柱が回れば頭も 一緒に回る — 親のワールド行列が変われば、子のワールド行列も自動的に変わるからです。

src/lessons/19-model-loading/main.ts(抜粋)
animate: (time) => {
  // ローカル行列を「元の変換 × 追加の回転」で作り直す。
  // 元の行列を取っておかないと、毎フレーム回転が積み上がってしまう
  mat4.rotateY(model.nodes[column].localMatrix, columnBase, time * 0.6);
  mat4.rotateZ(model.nodes[armPivot].localMatrix, armBase, Math.sin(time * 0.9) * 0.5);
},

もう 1 つ、第15章の伏線も回収できます。あの章の three.js 対応表でObject3D.getWorldPosition()に「今回は自前の変数 1 つで足りるが、親子階層を持つモデルでは『ワールド行列から位置を 取り出す』操作になる(ノード階層は第19章)」と書きました。ワールド行列の 4 列目(平行移動成分)が、まさにそのワールド位置です。

src/lessons/19-model-loading/main.ts(抜粋)
// 親を辿って合成したワールド行列から位置だけを取り出す。
// three.js の Object3D.getWorldPosition() に当たる操作(第15章)
mat4.getTranslation(worldPosition, scene.world[scene.probe]);

デモの読み出し行に出ている Head のワールド位置がこれです。腕が振れると x と y が変わり、支柱が回ると z も変わる。第15章の追跡カメラでtargetに入れるべき値は、階層のあるモデルではこうやって取り出します。ついでに、あの章で 「対象の進行方向に合わせて後ろに回り込ませるには、オフセットを対象の向きで回す必要が あります — そこは第19章のノード階層以降の話です」と書いた件も片付きます。ワールド行列の左上 3×3 が、その対象の「向き」そのものです。ワールド固定のオフセットを掛ける代わりに、この 3×3 を掛けてから足せば、 対象の背後に回り込むカメラになります。

8. .gltf + .bin と .glb、そして data: URI

glTF の中身は同じでも、入れ物 (container) は 3 通りあります。

.glbの構造は仕様どおりに読めば単純です。12 バイトのヘッダ(magic= ASCII の"glTF"= 0x46546C67version= 2、length= ファイル全体のバイト数)のあとに、チャンクが並びます。チャンクはchunkLength/ chunkType/ 中身の 3 つ組で、JSON チャンクが必ず 1 番目、BIN チャンクが 2 番目と 順序まで決められています。全体はリトルエンディアンです。

src/lessons/19-model-loading/gltf-loader.ts(抜粋)
let offset = 12; // 12 バイトのヘッダ(magic / version / length)の直後から
while (offset + 8 <= length) {
  const chunkLength = header.getUint32(offset, true);
  const chunkType = header.getUint32(offset + 4, true);
  const chunkStart = offset + 8;
  if (chunkType === CHUNK_JSON) {
    const text = new TextDecoder().decode(new Uint8Array(data, chunkStart, chunkLength));
    json = JSON.parse(text) as GltfJson;
  } else if (chunkType === CHUNK_BIN) {
    // slice でコピーして、先頭が 0 バイト目に来る ArrayBuffer にしておく
    bin = data.slice(chunkStart, chunkStart + chunkLength);
  }
  // 知らない種類のチャンクは読み飛ばす(拡張のために仕様がそう定めている)
  offset = chunkStart + chunkLength;
}

この章のローダーは、拡張子ではなく先頭 4 バイトが"glTF"かどうかで判定しています。public/models/にはrobot.glbも置いてあるので、main.tsの読み込み先を差し替えれば同じ絵が出ます(「壊してみる」で試します)。

9. マテリアルと、この章で扱わないもの

glTF のマテリアルはmetallic-roughnessという 1 つのモデルに統一されています。pbrMetallicRoughnessの下に、基本色 (baseColorFactor/ baseColorTexture)、金属度 (metallicFactor)、粗さ (roughnessFactor) が並ぶ形です。

public/models/robot.gltf(抜粋・materials[2])
{
  "name": "Accent",
  "pbrMetallicRoughness": {
    "baseColorFactor": [0.93, 0.45, 0.22, 1.0],
    "metallicFactor": 0.0,
    "roughnessFactor": 0.4
  }
},

この章のローダーが読むのはbaseColorFactorの 4 要素(RGBA)のうち RGB だけで、それを第17章の Blinn-Phong を簡略化したシェーダー(鏡面色 0.25 固定・shininess 48 固定)のu_baseColorに流し込んでいます。デモのロボットの 4 色は全部この値です。metallic / roughness という 2 つのパラメータが実際にどう光の反射を決めるのかは、 Cook-Torrance BRDF を扱う第22章 (PBR) の主題なので、この章は構造の説明にとどめます。テクスチャの参照(baseColorTexturetexturesimages +samplers)も、第16章のテクスチャと組み合わせれば読めるようになりますが、 この章のローダーは対応していません。

ほかにも、glTF が持てるのに扱わないものがあります。正直に並べておきます。

10. 座標系・単位・巻き — glTF が決めている約束

OBJ には「上はどっちか」の決まりすらありません。Y-up で書かれることが多いものの、 それは慣習であって規定ではなく、書き出すツールの設定次第で上下も前後も入れ替わります (多くの DCC ツールの OBJ エクスポータに「Forward Axis」「Up Axis」の設定があるのは そのためです)。読み込んでみたらモデルが横倒しになっていた、という事故が起きるのは これが理由です。glTF はそこを仕様で固定しています

位置と巻き順については、第3部でここまで使ってきた約束 — Y が上の右手系、 外から見て CCW が表(第13章) — と一致しています。だからデモのローダーは座標変換を一切して いません(逆向きなのは上に挙げた UV だけで、この章はテクスチャを読まないので出番が ありません)。gl.cullFaceも既定のBACKのままで正しく描けます。もし three.js から glTF を読んで「裏返って見える」なら、 どこかに負のスケールが混じっていないか(= 行列式が負になっていないか)を疑うのが 定石です。

コード全文

OBJ パーサ、共通の外積ヘルパー、glTF ローダー(抜粋)、そしてデモ本体とシェーダーです。 読み込むモデル (public/models/)は、この章のために生成した小さなファイル群です。

src/lessons/19-model-loading/obj-parser.ts
// 第19章: OBJ パーサ
//
// OBJ は 1 行 1 命令のテキスト形式。この章で解釈するのは v / vt / vn / f の 4 つだけで、
// 残り(o / g / s / usemtl / mtllib など)は読み飛ばす。
//
// この章の山場は「インデックスの本数の違い」を吸収するところ:
//   OBJ  … 位置・UV・法線に *別々の* インデックスを持てる
//   WebGL … drawElements のインデックスは *1 本* しかない
// そこで (v, vt, vn) の組み合わせごとに 1 頂点を作り直す。第13章の
// 「立方体の角は 8 つなのに頂点は 24 個要る」とまったく同じ理屈で、頂点数は増える。

import type { Geometry } from '../../lib/geometry';
import { accumulateTriangleNormal, normalizeAll } from './face-normals';

/** f の 1 隅。値は 0 始まりに直したインデックスで、-1 は「指定なし」 */
interface Corner {
  v: number;
  vt: number;
  vn: number;
}

export interface ObjStats {
  /** ファイルにあった v / vt / vn / f の数 */
  positionCount: number;
  uvCount: number;
  normalCount: number;
  faceCount: number;
  /** 三角形分割したあとの三角形の枚数 */
  triangleCount: number;
  /** (v, vt, vn) の組み合わせを展開したあとの、GPU に渡す頂点の数 */
  vertexCount: number;
  /** vn がなく、読み込み側で法線を計算したか */
  generatedNormals: boolean;
}

export interface ObjModel {
  geometry: Geometry;
  stats: ObjStats;
}

export interface ParseObjOptions {
  /**
   * vn を持たないファイルの法線の作り方。
   * false(既定)= 面法線をそのまま使う → フラットシェーディング
   * true = 頂点を共有する面の法線を平均する → スムーズシェーディング
   */
  smoothNormals?: boolean;
}

/**
 * 数値を読む。壊れた行を素通しさせない —
 * NaN のまま GPU へ送っても例外は出ず、三角形が黙って消えるだけで原因が追えなくなる。
 */
function parseNumber(text: string | undefined, line: number, command: string): number {
  const value = Number(text);
  if (text === undefined || text === '' || !Number.isFinite(value)) {
    throw new Error(`${line} 行目: ${command} の数値が読めません(${text ?? '値がありません'})`);
  }
  return value;
}

/**
 * OBJ のインデックスは 1 始まり。負の値は「末尾から数えて何番目か」を表す。
 * 「未指定」だけを -1 で返し、不正な値はその場で throw する —
 * 両方を -1 で表すと、あとでエラーの原因を取り違える。
 */
function resolveIndex(
  text: string | undefined,
  count: number,
  line: number,
  token: string,
): number {
  if (text === undefined || text === '') return -1; // "12//3" の真ん中のような未指定
  const value = Number.parseInt(text, 10);
  if (!Number.isInteger(value) || value === 0) {
    throw new Error(`${line} 行目: 面のインデックスが読めません(${token})`);
  }
  const index = value > 0 ? value - 1 : count + value;
  if (index < 0) {
    throw new Error(`${line} 行目: 負のインデックスが先頭より前を指しています(${token})`);
  }
  return index;
}

/** "12/7/3" / "12//3" / "12/7" / "12" のいずれの形も受け付ける */
function parseCorner(token: string, counts: [number, number, number], line: number): Corner {
  const parts = token.split('/');
  return {
    v: resolveIndex(parts[0], counts[0], line, token),
    vt: resolveIndex(parts[1], counts[1], line, token),
    vn: resolveIndex(parts[2], counts[2], line, token),
  };
}

/** 解決したインデックスが、実際に読み込めた要素数に収まっているか */
function checkRange(command: string, index: number, count: number): void {
  if (index >= count) {
    throw new Error(
      `${command} のインデックスが範囲外です(${index + 1} 番目 / ${command}${count} 個)`,
    );
  }
}

export function parseObj(source: string, options: ParseObjOptions = {}): ObjModel {
  // --- 1 パス目: 行を読んで、ファイルに書かれたままの形で溜める ---------------
  const positions: number[] = [];
  const uvs: number[] = [];
  const normals: number[] = [];
  const faces: Corner[][] = [];

  for (const [lineIndex, rawLine] of source.split('\n').entries()) {
    const line = rawLine.trim();
    if (line === '' || line.startsWith('#')) continue;
    const lineNumber = lineIndex + 1; // エラーメッセージは人が数える 1 始まりで出す
    const tokens = line.split(/\s+/);
    switch (tokens[0]) {
      case 'v':
        // v は w(第 4 成分)を持つことがあるが、使うのは x y z だけ
        positions.push(
          parseNumber(tokens[1], lineNumber, 'v'),
          parseNumber(tokens[2], lineNumber, 'v'),
          parseNumber(tokens[3], lineNumber, 'v'),
        );
        break;
      case 'vt':
        uvs.push(
          parseNumber(tokens[1], lineNumber, 'vt'),
          parseNumber(tokens[2], lineNumber, 'vt'),
        );
        break;
      case 'vn':
        normals.push(
          parseNumber(tokens[1], lineNumber, 'vn'),
          parseNumber(tokens[2], lineNumber, 'vn'),
          parseNumber(tokens[3], lineNumber, 'vn'),
        );
        break;
      case 'f': {
        // 負のインデックスは「その行までに宣言された数」を基準に解決するので、
        // 今の要素数をその場で渡す
        const counts: [number, number, number] = [
          positions.length / 3,
          uvs.length / 2,
          normals.length / 3,
        ];
        const corners: Corner[] = [];
        for (let i = 1; i < tokens.length; i++) {
          corners.push(parseCorner(tokens[i], counts, lineNumber));
        }
        // 3 隅未満の面は三角形にならないので捨てる
        if (corners.length >= 3) faces.push(corners);
        break;
      }
      default:
        // o / g / s / usemtl / mtllib などは、この章のパーサでは使わない
        break;
    }
  }

  // --- 2 パス目: vn がない面のために法線を作る(第11章の外積) -----------------
  // ファイルに書かれていた vn の数(この後 normals には計算した法線を追記する)
  const fileNormalCount = normals.length / 3;
  const needsNormals = (face: Corner[]): boolean => face.some((corner) => corner.vn < 0);
  const generatedNormals = faces.some(needsNormals);

  if (generatedNormals && options.smoothNormals) {
    // スムーズ: 面法線を「位置ごと」に足し込んでから正規化する。
    // 同じ位置を共有する面の平均になるので、角がなめらかにつながる
    const accumulated = new Float32Array(positions.length);
    for (const face of faces) {
      if (!needsNormals(face)) continue;
      for (let k = 1; k < face.length - 1; k++) {
        const [a, b, c] = [face[0].v, face[k].v, face[k + 1].v];
        accumulateTriangleNormal(accumulated, a * 3, positions, a, b, c);
        accumulateTriangleNormal(accumulated, b * 3, positions, a, b, c);
        accumulateTriangleNormal(accumulated, c * 3, positions, a, b, c);
      }
    }
    normalizeAll(accumulated);
    // 位置と同じ番号で引ける位置に追記する
    const base = normals.length / 3;
    for (const value of accumulated) normals.push(value);
    for (const face of faces) {
      if (!needsNormals(face)) continue;
      for (const corner of face) corner.vn = base + corner.v;
    }
  } else if (generatedNormals) {
    // フラット: 面ごとに 1 本の法線を作り、その面の全隅に配る。
    // 面をまたいで共有しないので、隣り合う面の境目がくっきり出る
    for (const face of faces) {
      if (!needsNormals(face)) continue;
      const normal = [0, 0, 0];
      // 多角形は扇状に三角形へ割り、各三角形の外積を足し合わせる
      // (平面ならどの三角形も同じ向き。多少ゆがんでいても面積で重み付けされる)
      for (let k = 1; k < face.length - 1; k++) {
        accumulateTriangleNormal(normal, 0, positions, face[0].v, face[k].v, face[k + 1].v);
      }
      normalizeAll(normal);
      const index = normals.length / 3;
      normals.push(normal[0], normal[1], normal[2]);
      for (const corner of face) corner.vn = index;
    }
  }

  // --- 3 パス目: (v, vt, vn) の組み合わせごとに 1 頂点へ展開する ---------------
  const cache = new Map<string, number>();
  const outPositions: number[] = [];
  const outNormals: number[] = [];
  const outUvs: number[] = [];
  const outIndices: number[] = [];

  function vertexIndex(corner: Corner): number {
    // 3 本のインデックスの組をそのままキーにする。同じ組が再び現れたら使い回す
    const key = `${corner.v}/${corner.vt}/${corner.vn}`;
    const cached = cache.get(key);
    if (cached !== undefined) return cached;

    // 範囲外を素通しすると undefined → NaN が GPU へ流れ、三角形が黙って消える
    if (corner.v < 0) throw new Error('面の隅に v のインデックスがありません');
    checkRange('v', corner.v, positions.length / 3);
    if (corner.vt >= 0) checkRange('vt', corner.vt, uvs.length / 2);
    if (corner.vn >= 0) checkRange('vn', corner.vn, normals.length / 3);

    const index = outPositions.length / 3;
    if (index > 65534) {
      // 第14章と同じ理由。65535 は WebGL2 では「プリミティブ再開」の予約値
      throw new Error('頂点数が Uint16 インデックスの上限(65535)を超えました');
    }
    outPositions.push(
      positions[corner.v * 3],
      positions[corner.v * 3 + 1],
      positions[corner.v * 3 + 2],
    );
    if (corner.vn >= 0) {
      outNormals.push(
        normals[corner.vn * 3],
        normals[corner.vn * 3 + 1],
        normals[corner.vn * 3 + 2],
      );
    } else {
      outNormals.push(0, 1, 0);
    }
    if (corner.vt >= 0) {
      outUvs.push(uvs[corner.vt * 2], uvs[corner.vt * 2 + 1]);
    } else {
      outUvs.push(0, 0);
    }
    cache.set(key, index);
    return index;
  }

  let triangleCount = 0;
  for (const face of faces) {
    // 四角形以上の面は「扇(ファン)分割」で三角形にする。
    // 隅 0 を軸に (0, 1, 2), (0, 2, 3), (0, 3, 4) … と切っていく
    const first = vertexIndex(face[0]);
    for (let k = 1; k < face.length - 1; k++) {
      outIndices.push(first, vertexIndex(face[k]), vertexIndex(face[k + 1]));
      triangleCount++;
    }
  }

  return {
    geometry: {
      positions: Float32Array.from(outPositions),
      normals: Float32Array.from(outNormals),
      uvs: Float32Array.from(outUvs),
      indices: Uint16Array.from(outIndices),
    },
    stats: {
      positionCount: positions.length / 3,
      uvCount: uvs.length / 2,
      normalCount: fileNormalCount,
      faceCount: faces.length,
      triangleCount,
      vertexCount: outPositions.length / 3,
      generatedNormals,
    },
  };
}
src/lessons/19-model-loading/face-normals.ts
// 第19章: 法線を持たないモデルのために、面から法線を作る
//
// 三角形の 2 辺の外積(第11章)がそのまま面の法線になる。
// この 1 つの計算を、OBJ パーサと glTF ローダーの両方から使う。
//
// なぜ「共通モジュール(src/lib)」ではなくレッスン内に置くのか:
// 外部モデルを読むのはこの章だけで、共通化する理由がまだ読者に見えていないため。
// ローダーとシーングラフの共通化は第34章のミニエンジンでまとめて検討する。

/**
 * 三角形 (a, b, c) の面法線を out に足し込む(正規化はしない)。
 *
 * cross(b - a, c - a) は「2 辺が張る平行四辺形の面積」の長さを持つので、
 * 足し込むだけで自動的に「大きい面ほど強い」重み付き平均になる。
 * 頂点が反時計回り(CCW)に並んでいれば、結果は表側を向く(第13章)。
 */
export function accumulateTriangleNormal(
  out: Float32Array | number[],
  outOffset: number,
  positions: ArrayLike<number>,
  a: number,
  b: number,
  c: number,
): void {
  const ax = positions[a * 3];
  const ay = positions[a * 3 + 1];
  const az = positions[a * 3 + 2];
  // 2 辺のベクトル(頂点 a を起点にする)
  const e1x = positions[b * 3] - ax;
  const e1y = positions[b * 3 + 1] - ay;
  const e1z = positions[b * 3 + 2] - az;
  const e2x = positions[c * 3] - ax;
  const e2y = positions[c * 3 + 1] - ay;
  const e2z = positions[c * 3 + 2] - az;
  // 外積 e1 × e2(第11章の定義そのまま)
  out[outOffset] += e1y * e2z - e1z * e2y;
  out[outOffset + 1] += e1z * e2x - e1x * e2z;
  out[outOffset + 2] += e1x * e2y - e1y * e2x;
}

/**
 * 3 要素ずつ正規化する。長さ 0 のものは (0, 1, 0) にしておく
 * (面積ゼロの三角形しか接していない頂点でも NaN を撒かないため)
 */
export function normalizeAll(values: Float32Array | number[]): void {
  for (let i = 0; i < values.length; i += 3) {
    const x = values[i];
    const y = values[i + 1];
    const z = values[i + 2];
    const length = Math.hypot(x, y, z);
    if (length > 0) {
      values[i] = x / length;
      values[i + 1] = y / length;
      values[i + 2] = z / length;
    } else {
      values[i] = 0;
      values[i + 1] = 1;
      values[i + 2] = 0;
    }
  }
}

gltf-loader.tsだけは全文が 500 行を超えるので、本文で引いていない主要な部分だけを抜粋します。 accessor の読み出し(6 節)・GLB のチャンク(8 節)・buildNodecomputeWorldMatrices(7 節)は、すでに本文で全文を載せています。残りを含めた 全体はsrc/lessons/19-model-loading/gltf-loader.tsを開いてください。

src/lessons/19-model-loading/gltf-loader.ts(抜粋・ローダーが返す形)
// --- ローダーが返す形 ---------------------------------------------------------

export interface GltfPrimitive {
  /** 第14章と同じ Geometry(位置・法線・UV + インデックス)に落とす */
  geometry: Geometry;
  /** pbrMetallicRoughness.baseColorFactor(RGBA)。PBR の中身は第22章 */
  baseColor: ReadonlyVec4;
  materialName: string;
  /** この primitive の頂点データがインターリーブされていたか(bufferView.byteStride の有無) */
  interleaved: boolean;
}

export interface GltfNode {
  name: string;
  /** 親からの相対変換。matrix か TRS のどちらかから作る */
  localMatrix: mat4;
  children: number[];
  /** meshes の添字。メッシュを持たないノードは -1 */
  mesh: number;
}

export interface GltfStats {
  generator: string;
  nodeCount: number;
  maxDepth: number;
  meshCount: number;
  primitiveCount: number;
  interleavedCount: number;
  vertexCount: number;
  triangleCount: number;
  /** GLB コンテナだったか */
  binary: boolean;
}

export interface GltfModel {
  nodes: GltfNode[];
  /** scene が指すルートノードの添字 */
  roots: number[];
  meshes: { name: string; primitives: GltfPrimitive[] }[];
  stats: GltfStats;
}
src/lessons/19-model-loading/gltf-loader.ts(抜粋・ジオメトリの組み立て)
// --- ジオメトリの組み立て -----------------------------------------------------

/**
 * NORMAL を持たない primitive の法線を作る。
 * 仕様は「クライアントは面法線 (flat normals) を計算しなければならない」と定めている。
 * 面法線は面ごとに向きが違うので、三角形ごとに頂点を作り直す(共有をやめる)。
 */
function buildFlatNormals(
  positions: Float32Array,
  uvs: Float32Array,
  indices: Uint16Array,
): Geometry {
  const triangles = indices.length / 3;
  if (triangles * 3 > 65535) {
    throw new Error('面法線の計算で頂点数が Uint16 インデックスの上限を超えました');
  }
  const outPositions = new Float32Array(triangles * 9);
  const outNormals = new Float32Array(triangles * 9);
  const outUvs = new Float32Array(triangles * 6);
  const outIndices = new Uint16Array(triangles * 3);
  const normal = new Float32Array(3);

  for (let t = 0; t < triangles; t++) {
    const corners = [indices[t * 3], indices[t * 3 + 1], indices[t * 3 + 2]];
    normal.fill(0);
    accumulateTriangleNormal(normal, 0, positions, corners[0], corners[1], corners[2]);
    normalizeAll(normal);
    for (let k = 0; k < 3; k++) {
      const source = corners[k];
      const target = t * 3 + k;
      outPositions.set(positions.subarray(source * 3, source * 3 + 3), target * 3);
      outNormals.set(normal, target * 3);
      outUvs.set(uvs.subarray(source * 2, source * 2 + 2), target * 2);
      outIndices[target] = target;
    }
  }
  return { positions: outPositions, normals: outNormals, uvs: outUvs, indices: outIndices };
}

function buildPrimitive(
  gltf: GltfJson,
  buffers: ArrayBuffer[],
  primitive: PrimitiveJson,
): GltfPrimitive {
  // mode の既定値は 4 = TRIANGLES。それ以外はこの章では描かない
  const mode = primitive.mode ?? 4;
  if (mode !== 4) throw new Error(`mode ${mode} はこの章のローダーでは扱いません(4 のみ対応)`);

  const positionIndex = primitive.attributes.POSITION;
  if (positionIndex === undefined) throw new Error('POSITION を持たない primitive です');
  const positionData = readAccessor(gltf, buffers, positionIndex);
  const positions = toFloat32(positionData);
  const vertexCount = positions.length / 3;

  const uvIndex = primitive.attributes.TEXCOORD_0;
  const uvs =
    uvIndex === undefined
      ? new Float32Array(vertexCount * 2)
      : toFloat32(readAccessor(gltf, buffers, uvIndex));

  const indices =
    primitive.indices === undefined
      ? // インデックスなしの primitive は 0, 1, 2, … を並べたのと同じ
        Uint16Array.from({ length: vertexCount }, (_, i) => i)
      : toUint16Indices(readAccessor(gltf, buffers, primitive.indices));

  const normalIndex = primitive.attributes.NORMAL;
  const geometry: Geometry =
    normalIndex === undefined
      ? buildFlatNormals(positions, uvs, indices)
      : {
          positions,
          normals: toFloat32(readAccessor(gltf, buffers, normalIndex)),
          uvs,
          indices,
        };

  const material =
    primitive.material === undefined ? undefined : gltf.materials?.[primitive.material];
  const factor = material?.pbrMetallicRoughness?.baseColorFactor;
  return {
    geometry,
    // baseColorFactor の既定値は [1, 1, 1, 1](材質を書かなければ白)
    baseColor: vec4.fromValues(
      factor?.[0] ?? 1,
      factor?.[1] ?? 1,
      factor?.[2] ?? 1,
      factor?.[3] ?? 1,
    ),
    materialName: material?.name ?? '(既定のマテリアル)',
    interleaved: positionData.interleaved,
  };
}
src/lessons/19-model-loading/gltf-loader.ts(抜粋・入口)
// --- 入口 ---------------------------------------------------------------------

export async function loadGltf(url: string): Promise<GltfModel> {
  const absolute = new URL(url, location.href);
  const response = await fetch(absolute);
  if (!response.ok) throw new Error(`${url} を取得できませんでした (HTTP ${response.status})`);
  const raw = await response.arrayBuffer();

  // 先頭 4 バイトが "glTF" なら GLB コンテナ、そうでなければ JSON テキスト
  const binary = raw.byteLength >= 12 && new DataView(raw).getUint32(0, true) === GLB_MAGIC;
  const { json: gltf, bin } = binary
    ? parseGlb(raw)
    : { json: JSON.parse(new TextDecoder().decode(raw)) as GltfJson, bin: null };

  if (!gltf.asset?.version?.startsWith('2.')) {
    throw new Error(`glTF 2.x ではありません: version ${gltf.asset?.version}`);
  }
  // extensionsRequired に並ぶ拡張は「対応していないなら読み込むな」という意味(9 節)
  if (gltf.extensionsRequired?.length) {
    throw new Error(`必須の拡張に対応していません: ${gltf.extensionsRequired.join(', ')}`);
  }

  const buffers = await resolveBuffers(gltf, absolute, bin);
  const meshes = (gltf.meshes ?? []).map((mesh) => ({
    name: mesh.name ?? '(無名メッシュ)',
    primitives: mesh.primitives.map((primitive) => buildPrimitive(gltf, buffers, primitive)),
  }));
  const nodes = (gltf.nodes ?? []).map(buildNode);

  // scene(単数)が既定のシーンを指す。なければ scenes[0] を使う
  const scene = gltf.scenes?.[gltf.scene ?? 0];
  const roots = scene?.nodes ?? [];

  let maxDepth = 0;
  const measure = (index: number, depth: number): void => {
    maxDepth = Math.max(maxDepth, depth);
    for (const child of nodes[index].children) measure(child, depth + 1);
  };
  for (const root of roots) measure(root, 1);

  const primitives = meshes.flatMap((mesh) => mesh.primitives);
  return {
    nodes,
    roots,
    meshes,
    stats: {
      generator: gltf.asset.generator ?? '(不明)',
      nodeCount: nodes.length,
      maxDepth,
      meshCount: meshes.length,
      primitiveCount: primitives.length,
      interleavedCount: primitives.filter((primitive) => primitive.interleaved).length,
      vertexCount: primitives.reduce((sum, p) => sum + p.geometry.positions.length / 3, 0),
      triangleCount: primitives.reduce((sum, p) => sum + p.geometry.indices.length / 3, 0),
      binary,
    },
  };
}
src/lessons/19-model-loading/main.ts
// 第19章: モデル読み込み — OBJ と glTF 2.0
//
// 3 つのモデルをボタンで切り替える。
//   1. OBJ(法線あり)  … ファイルの vn をそのまま使う → なめらかな陰影
//   2. OBJ(法線なし)  … vn がないので 2 辺の外積で面法線を計算する(第11章)→ カクカク
//   3. glTF           … ノード階層つき。親が回ると子もついて回る(第12章)
//
// 読み込みそのものは obj-parser.ts / gltf-loader.ts が担当し、このファイルは
// 「読み込んだ Geometry を GPU に載せて、ノード階層を辿って描く」ところに集中する。
// 陰影は第17章の方向光 1 つの Blinn-Phong を、この章向けに簡略化して使っている
// (鏡面色 0.25 固定・shininess 48 固定)。

import { mat3, mat4, type ReadonlyVec3, type ReadonlyVec4, vec3, vec4 } from 'gl-matrix';
import { type Geometry, interleave } from '../../lib/geometry';
import { compileShader, linkProgram } from '../../lib/shader';
import { computeWorldMatrices, type GltfNode, loadGltf } from './gltf-loader';
import fragmentSource from './model.frag?raw';
import vertexSource from './model.vert?raw';
import { parseObj } from './obj-parser';

// public/models/ に置いたファイルは、サイトのルート直下から配信される
const MODEL_BASE = '/models/';

// ---------------------------------------------------------------------------
// メッシュ: Geometry を VAO に配線する(第13〜15章と同じ手順)
// ---------------------------------------------------------------------------

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

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

  const FLOAT_BYTES = Float32Array.BYTES_PER_ELEMENT;
  const stride = 8 * FLOAT_BYTES; // 1 頂点 = 位置 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
  gl.vertexAttribPointer(1, 3, gl.FLOAT, false, stride, 3 * FLOAT_BYTES);
  gl.enableVertexAttribArray(2); // a_uv
  gl.vertexAttribPointer(2, 2, gl.FLOAT, false, stride, 6 * FLOAT_BYTES);

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

// ---------------------------------------------------------------------------
// シーン: OBJ も glTF も「ノードの木 + 描画パーツ」の同じ形に揃える
// ---------------------------------------------------------------------------

interface Part {
  mesh: Mesh;
  /** baseColorFactor と同じ RGBA。この章が使うのは RGB だけ */
  baseColor: ReadonlyVec4;
  /** このパーツが属するノードの添字 */
  node: number;
}

interface Scene {
  info: string;
  /** OBJ は「ルート 1 個だけの木」として扱うので、描画コードは glTF と共通になる */
  nodes: GltfNode[];
  roots: number[];
  /** ノードごとのワールド行列(毎フレーム計算し直す) */
  world: mat4[];
  parts: Part[];
  /** ノードのローカル行列を毎フレーム書き換える(glTF のみ) */
  animate: ((time: number) => void) | null;
  /** 読み出し行にワールド位置を出すノード(-1 なら出さない) */
  probe: number;
}

const OBJ_COLOR: ReadonlyVec4 = vec4.fromValues(0.86, 0.55, 0.42, 1.0);

function buildObjScene(gl: WebGL2RenderingContext, geometry: Geometry, info: string): Scene {
  // ノード階層のない OBJ でも、木の形に合わせておけば描画コードを共有できる。
  // ルート 1 個だけ置いて、モデルを原点まわりに収めるための平行移動を入れる
  const root: GltfNode = {
    name: 'model',
    localMatrix: mat4.fromTranslation(mat4.create(), [0, -0.8, 0]),
    children: [],
    mesh: -1,
  };
  return {
    info,
    nodes: [root],
    roots: [0],
    world: [mat4.create()],
    parts: [{ mesh: createMesh(gl, geometry), baseColor: OBJ_COLOR, node: 0 }],
    animate: null,
    probe: -1,
  };
}

async function loadGltfScene(gl: WebGL2RenderingContext, url: string): Promise<Scene> {
  const model = await loadGltf(url);

  // ノードとメッシュの対応をほどいて、「描くもの」の平らなリストにする。
  // 1 つのメッシュが複数の primitive を持つ場合、primitive ごとに 1 ドローコール
  const meshes = model.meshes.map((mesh) =>
    mesh.primitives.map((primitive) => ({
      mesh: createMesh(gl, primitive.geometry),
      baseColor: primitive.baseColor,
    })),
  );
  const parts: Part[] = [];
  model.nodes.forEach((node, index) => {
    if (node.mesh < 0) return;
    for (const entry of meshes[node.mesh]) parts.push({ ...entry, node: index });
  });

  // 回すノードを名前で探す。回すのは Column と ArmPivot の 2 つだけで、
  // その子(ArmPivot・Head)はワールド行列の合成で自動的についてくる
  const findNode = (name: string): number => model.nodes.findIndex((node) => node.name === name);
  const column = findNode('Column');
  const armPivot = findNode('ArmPivot');
  const columnBase = mat4.clone(model.nodes[column].localMatrix);
  const armBase = mat4.clone(model.nodes[armPivot].localMatrix);

  const stats = model.stats;
  return {
    info:
      `glTF ${stats.binary ? '(GLB)' : '(.gltf + .bin)'}: ノード ${stats.nodeCount} 個・` +
      `最大 ${stats.maxDepth} 段 / メッシュ ${stats.meshCount}・primitive ${stats.primitiveCount}` +
      `(うちインターリーブ ${stats.interleavedCount})/ 頂点 ${stats.vertexCount}・` +
      `三角形 ${stats.triangleCount}`,
    nodes: model.nodes,
    roots: model.roots,
    world: model.nodes.map(() => mat4.create()),
    parts,
    animate: (time) => {
      // ローカル行列を「元の変換 × 追加の回転」で作り直す。
      // 元の行列を取っておかないと、毎フレーム回転が積み上がってしまう
      mat4.rotateY(model.nodes[column].localMatrix, columnBase, time * 0.6);
      mat4.rotateZ(model.nodes[armPivot].localMatrix, armBase, Math.sin(time * 0.9) * 0.5);
    },
    probe: findNode('Head'),
  };
}

// ---------------------------------------------------------------------------
// カメラ・ライトの定数(第3部の標準: 45°・0.1・100)
// ---------------------------------------------------------------------------

const FOVY = (45 * Math.PI) / 180;
const NEAR = 0.1;
const FAR = 100;

// 方向光。u_lightDirection は「面から光源へ向かう」単位ベクトル(第17章)
const LIGHT_DIRECTION: ReadonlyVec3 = vec3.normalize(vec3.create(), [0.55, 0.8, 0.45]);
const LIGHT_COLOR: ReadonlyVec3 = vec3.fromValues(1.0, 0.96, 0.9);
const AMBIENT_COLOR: ReadonlyVec3 = vec3.fromValues(0.18, 0.2, 0.25);

const MODELS = [
  { label: 'OBJ(法線あり)', file: 'mushroom.obj', kind: 'obj' },
  { label: 'OBJ(法線なし)', file: 'mushroom-flat.obj', kind: 'obj' },
  { label: 'glTF', file: 'robot.gltf', kind: 'gltf' },
] as const;

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

function setup(
  gl: WebGL2RenderingContext,
  canvas: HTMLCanvasElement,
  controls: HTMLParagraphElement,
  readout: HTMLParagraphElement,
): void {
  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 normalMatrixLocation = gl.getUniformLocation(program, 'u_normalMatrix');
  const lightDirectionLocation = gl.getUniformLocation(program, 'u_lightDirection');
  const lightColorLocation = gl.getUniformLocation(program, 'u_lightColor');
  const ambientColorLocation = gl.getUniformLocation(program, 'u_ambientColor');
  const cameraPositionLocation = gl.getUniformLocation(program, 'u_cameraPosition');
  const baseColorLocation = gl.getUniformLocation(program, 'u_baseColor');

  gl.enable(gl.DEPTH_TEST);
  gl.enable(gl.CULL_FACE);
  gl.clearColor(0.06, 0.07, 0.09, 1.0);

  // --- 状態 -----------------------------------------------------------------

  const scenes: (Scene | null)[] = MODELS.map(() => null);
  let current = -1;
  const LOADING = 'モデルを読み込んでいます…';

  // 軌道カメラ(第15章の簡略版。減衰なし・軌道モードのみ)
  const orbit = { theta: 0.75, phi: 0.3, radius: 4.6 };
  const PHI_LIMIT = Math.PI / 2 - 0.05;
  const MIN_RADIUS = 2.2;
  const MAX_RADIUS = 12;

  const eye = vec3.create();
  const target = vec3.fromValues(0, 0, 0);
  const UP: ReadonlyVec3 = vec3.fromValues(0, 1, 0);
  const worldPosition = vec3.create();
  const view = mat4.create();
  const projection = mat4.create();
  const normalMatrix = mat3.create();

  // --- 切替ボタン(読み込みが終わるまで押せない) ----------------------------

  // 読み込みが終わった順に選ぶと、どれが最初に出るか毎回変わってしまう。
  // 手で選ばれるまでは「読み込めたうち、いちばん若い番号」を表示する
  let userSelected = false;

  function select(index: number): void {
    current = index;
    for (const [i, button] of buttons.entries()) {
      button.setAttribute('aria-pressed', i === index ? 'true' : 'false');
    }
  }

  const buttons = MODELS.map((entry, index) => {
    const button = document.createElement('button');
    button.type = 'button';
    button.textContent = entry.label;
    button.disabled = true;
    button.setAttribute('aria-pressed', 'false');
    button.addEventListener('click', () => {
      userSelected = true;
      select(index);
    });
    controls.append(button);
    return button;
  });

  // --- 読み込み(非同期。3 つ並行で走らせる) --------------------------------

  async function load(index: number): Promise<void> {
    const entry = MODELS[index];
    const url = MODEL_BASE + entry.file;
    if (entry.kind === 'gltf') {
      scenes[index] = await loadGltfScene(gl, url);
      return;
    }
    const response = await fetch(url);
    if (!response.ok)
      throw new Error(`${entry.file} を取得できませんでした (HTTP ${response.status})`);
    // smoothNormals: true にすると、法線のないモデルでも隣接面を平均してなめらかになる
    const { geometry, stats } = parseObj(await response.text());
    scenes[index] = buildObjScene(
      gl,
      geometry,
      `${entry.file}: v ${stats.positionCount}・vt ${stats.uvCount}・vn ${stats.normalCount}・` +
        `f ${stats.faceCount} → 三角形 ${stats.triangleCount}・頂点 ${stats.vertexCount}` +
        `${stats.generatedNormals ? '(法線は面から計算)' : '(ファイルの vn を使用)'}`,
    );
  }

  const failures: string[] = [];
  for (const [index, entry] of MODELS.entries()) {
    load(index)
      .then(() => {
        buttons[index].disabled = false;
        if (!userSelected && (current < 0 || index < current)) select(index);
      })
      .catch((error: unknown) => {
        // 読み込みの失敗は握りつぶさず、読み出し行に出す(describe が必ず連結する)
        const message = error instanceof Error ? error.message : String(error);
        failures.push(`${entry.label} の読み込みに失敗: ${message}`);
      });
  }

  // --- 軌道カメラの入力(第15章の簡略版) ------------------------------------

  let activePointerId: number | null = null;
  let lastX = 0;
  let lastY = 0;

  canvas.addEventListener('pointerdown', (event) => {
    if (event.button !== 0) return;
    activePointerId = event.pointerId;
    lastX = event.clientX;
    lastY = event.clientY;
    canvas.setPointerCapture(event.pointerId);
  });

  canvas.addEventListener('pointermove', (event) => {
    if (event.pointerId !== activePointerId) return;
    const speed = (2 * Math.PI) / canvas.clientHeight;
    orbit.theta -= (event.clientX - lastX) * speed;
    orbit.phi += (event.clientY - lastY) * speed;
    orbit.phi = Math.min(PHI_LIMIT, Math.max(-PHI_LIMIT, orbit.phi));
    lastX = event.clientX;
    lastY = event.clientY;
  });

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

  canvas.addEventListener(
    'wheel',
    (event) => {
      event.preventDefault();
      const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 100 : 1;
      orbit.radius *= Math.exp(event.deltaY * scale * 0.001);
      orbit.radius = Math.min(MAX_RADIUS, Math.max(MIN_RADIUS, orbit.radius));
    },
    { passive: false },
  );

  // --- リサイズ ---------------------------------------------------------------

  function resizeIfNeeded(): void {
    const dpr = Math.min(window.devicePixelRatio, 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 describe(scene: Scene | null): string {
    const lines: string[] = [];
    if (scene) {
      lines.push(scene.info);
      if (scene.probe >= 0) {
        // 親を辿って合成したワールド行列から位置だけを取り出す。
        // three.js の Object3D.getWorldPosition() に当たる操作(第15章)
        mat4.getTranslation(worldPosition, scene.world[scene.probe]);
        const [x, y, z] = worldPosition;
        const name = scene.nodes[scene.probe].name;
        lines.push(`${name} のワールド位置: (${x.toFixed(2)}, ${y.toFixed(2)}, ${z.toFixed(2)})`);
      }
    } else if (failures.length === 0) {
      lines.push(LOADING);
    }
    // 失敗は最後に必ず足す。他のモデルが表示できていても、読めなかった理由は消さない
    lines.push(...failures);
    return lines.join(' / ');
  }

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

    // 球面座標 → カメラ位置(第15章)
    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);
    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);
    gl.uniform3fv(lightDirectionLocation, LIGHT_DIRECTION);
    gl.uniform3fv(lightColorLocation, LIGHT_COLOR);
    gl.uniform3fv(ambientColorLocation, AMBIENT_COLOR);
    gl.uniform3fv(cameraPositionLocation, eye);

    const scene = current < 0 ? null : scenes[current];
    if (scene) {
      scene.animate?.(time);
      // ワールド行列 = 親のワールド行列 × 自分のローカル行列(第12章)を木の上で繰り返す
      computeWorldMatrices(scene.nodes, scene.roots, scene.world);

      for (const part of scene.parts) {
        const model = scene.world[part.node];
        // 法線行列 = モデル行列の左上 3×3 の逆転置(第17章)。glTF の Head ノードには
        // スケールが入っているので、mat3(u_model) の手抜きでは足りない
        mat3.normalFromMat4(normalMatrix, model);
        gl.uniformMatrix4fv(modelLocation, false, model);
        gl.uniformMatrix3fv(normalMatrixLocation, false, normalMatrix);
        gl.uniform3f(baseColorLocation, part.baseColor[0], part.baseColor[1], part.baseColor[2]);
        gl.bindVertexArray(part.mesh.vao);
        gl.drawElements(gl.TRIANGLES, part.mesh.indexCount, gl.UNSIGNED_SHORT, 0);
      }
    }

    readout.textContent = describe(scene);
    requestAnimationFrame(frame);
  }

  // このページのデモはページと寿命を共にするので、rAF ループの停止も、canvas に付けた
  // 5 つのリスナーの解除もしていない。リソース解放の一般論は第35章
  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/19-model-loading/model.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;
// 法線行列 = モデル行列の左上 3×3 の逆転置(第17章)
uniform mat3 u_normalMatrix;

out vec3 v_normal;
out vec3 v_worldPosition;
out vec2 v_uv;

void main() {
  vec4 worldPosition = u_model * vec4(a_position, 1.0);

  // ライティングはワールド空間で計算する(第17章)ので、法線と位置を
  // ワールド空間のままフラグメントシェーダーへ渡す
  v_worldPosition = worldPosition.xyz;
  v_normal = u_normalMatrix * a_normal;
  v_uv = a_uv;

  gl_Position = u_projection * u_view * worldPosition;
}
src/lessons/19-model-loading/model.frag
#version 300 es

precision highp float;

in vec3 v_normal;
in vec3 v_worldPosition;
in vec2 v_uv;

// 方向光 1 つの Blinn-Phong(第17章)。この章の主役はモデルの読み込みなので、
// 第17章の式をこの章向けに簡略化して使う(鏡面色 0.25 固定・shininess 48 固定)
uniform vec3 u_lightDirection; // 面から光源へ向かう単位ベクトル(光の進む向きではない)
uniform vec3 u_lightColor;
uniform vec3 u_ambientColor;
uniform vec3 u_cameraPosition; // ワールド空間のカメラ位置
uniform vec3 u_baseColor;

out vec4 fragColor;

const float SHININESS = 48.0;

void main() {
  // 補間で長さが 1 からずれるので正規化し直す(第17章 3 節)
  vec3 N = normalize(v_normal);
  vec3 L = u_lightDirection;
  vec3 V = normalize(u_cameraPosition - v_worldPosition);
  vec3 H = normalize(L + V);

  float diffuse = max(dot(N, L), 0.0);
  float specular = pow(max(dot(N, H), 0.0), SHININESS) * step(0.0001, diffuse);

  vec3 color = u_baseColor * (u_ambientColor + u_lightColor * diffuse)
             + u_lightColor * specular * 0.25;

  fragColor = vec4(color, 1.0);
}

three.js との対応

この章で書いたものは、three.js のローダーとシーングラフにほぼ 1 対 1 で対応します。 対応表というより、「あの API の中では何が起きているか」の一覧です。

three.jsこの章
OBJLoader(examples/jsm)parseObj()。(v, vt, vn) の組み合わせを展開してGeometryにする。three.js 版はMTLLoaderと組み合わせて材質も読める
GLTFLoader(examples/jsm)loadGltf().gltf+.bin/.glb/data:URI に対応。アニメーション・スキニング・テクスチャ・拡張は非対応
THREE.Group/Object3D.add()が作る親子関係とupdateMatrixWorld()glTF のnodes[].children+computeWorldMatrices()。「親のワールド行列 × 自分のローカル行列」を 木の上で繰り返す(第12章)
Object3D.position/.quaternion/.scale/.matrixglTF ノードのtranslation/rotation/scale/matrix。TRS の合成順 T * R * S も同じ
Object3D.getWorldPosition(target)mat4.getTranslation(out, world[node])。ワールド行列の平行移動成分を 取り出すだけ(第15章の予告の回収)
BufferGeometry.setAttribute('position', new BufferAttribute(...))glTF のaccessorsitemSizetype、配列の型がcomponentTypeに対応する
InterleavedBuffer+InterleavedBufferAttribute(buffer, itemSize, offset)bufferView.byteStride+accessor.byteOffset。第13〜14章で自作した stride / offset が、ファイルの中に書かれている。単位は違い、three.js のstride/offsettyped-array の要素数、glTF はバイト。同じ 1 頂点(位置 3 + 法線 3 + UV 2 の float)でも three.js は 8、glTF は 32
Mesh 1 つ = geometry + materialglTF のprimitive1 つ = attributes + indices +material。1 メッシュに複数 primitive があれば、three.js でも複数の Mesh になる
MeshStandardMaterialcolor/metalness/roughnesspbrMetallicRoughnessbaseColorFactor/metallicFactor/roughnessFactor。この章は基本色だけを使う(PBR の計算は第22章)
BufferGeometry.computeVertexNormals()parseObj(source, { smoothNormals: true })。面法線を位置ごとに 平均する。外積の長さ(= 面積)で重み付けされる点は three.js 版も同じ。ただし three.js は既存の頂点インデックス単位で平均するので、OBJLoaderが (v, vt, vn) で分割した頂点は継ぎ目をまたいでつながらない。この章は OBJ のv(位置)単位で平均するのでつながる
AnimationMixer / SkinnedMesh非対応。glTF はどちらも持てるが、このサイトでは扱わない
DRACOLoader / KTX2Loader非対応(9 節で存在にのみ触れる)

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

コードはsrc/lessons/19-model-loading/、モデルはpublic/models/にあります。モデルはテキスト(OBJ / .gltf)なので、エディタで直接書き換えて壊せるのがこの章の面白いところです。

まとめ

これで、画面に出せるものの幅が一気に広がりました。ジオメトリは生成もでき、読み込みもできる。 テクスチャ(第16章)も、ライティング(第17〜18章)も乗っている。ここからは、描く先そのものを 変えます。次章第20章「フレームバッファ — オフスクリーン描画とレンダーターゲット」では、canvas ではなく自分で用意したテクスチャに描く方法を扱います。「描いた結果を テクスチャとして読み直す」ことができるようになると、影(第21章)・ポストプロセス (第33章)・フィードバック(第29章)という、後半の大きなテーマがまとめて射程に入ります。 第13章で予告した「FBO では深度アタッチメントを自分で用意する」の回収もそこです。