モデル読み込み — 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)です。
vnをそのまま使うのでなめらか、OBJ(法線なし)は同じ形なのに面ごとの 平らな陰影になります — ファイルに法線がないので、読み込み側が 2 辺の外積で面法線を 計算しているからです。glTFはノード階層つきで、支柱が回るとその子の腕と頭もついて回ります。読み出し行には 読み込んだファイルの内訳が出て、glTF のときは階層を辿って合成した Head ノードのワールド位置も並びます。この章で学ぶこと:
- なぜ自分でパーサを書くのか(そして実務ではなぜ書かなくてよいのか)
- OBJ の書式 —
v/vt/vn/f、1 始まりのインデックス、負のインデックス、四角形以上の面の三角形分割 - この章の山場: OBJ は位置・UV・法線に別々のインデックスを持てるが、
drawElementsのインデックスは1 本しかない。だから (v, vt, vn) の組み合わせごとに 1 頂点へ展開する(第13章の「角は 8 つなのに頂点は 24 個」と同じ理屈) - 法線を持たないモデルの面法線を、三角形の 2 辺の外積で計算する(第11章の回収)
- glTF 2.0 の構造 —
buffers→bufferViews→accessorsの 3 段、meshes[].primitives、nodesとscenes - accessor の読み方 —
componentTypeと TypedArray の対応、byteStrideがあるものはインターリーブされている(第13章の回収) - ノード階層からワールド行列を組み立てる(第12章の親子階層の回収)
.gltf+.bin、.glb、data:URI の 3 つの入れ物の違い
1. なぜ自分でパーサを書くのか
最初に正直なことを書いておきます。実務で OBJ パーサや glTF ローダーを自作する理由は、ほとんどありません。three.js のGLTFLoaderは仕様のほぼ全域(スキニング、アニメーション、モーフターゲット、各種拡張)に対応していて、 テストも実績も桁違いです。この章のコードは、その足元にも及びません。
それでも書くのは、形式を理解するためです。理解しておくと、こういうときに 効きます。
- モデルが真っ黒に描画される → 法線がない? それともマテリアルが既定のまま?(9 節・4 節)
- 表示はされるが面がところどころ抜ける → 巻き順とカリング? それとも本当に穴が開いている?(第13章)
- ファイルが妙に大きい → インデックスなしで書き出されている? UV の継ぎ目で頂点が増えている?(3 節)
- モデルの一部だけ動かしたい → ノード階層のどこにぶら下がっているかを知る必要がある(7 節)
どれも「ローダーの中で何が起きているか」を知らないと勘で潰すしかない問題です。この章を 読み終えると、.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 節) |
# | コメント |
# 三角形 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/1の1は「最初のv」を指します。プログラムから見れば 0 始まりに直す作業が必ず要ります。
2 つ目、隅はv/vt/vnの 3 つのインデックスの組です。vtやvnは省略でき、1(位置だけ)/ 1/1(位置と UV)/1//1(位置と法線・UV を飛ばすので/が 2 つ続く)/ 1/1/1(全部)の 4 通りがあります。
デモで読み込んでいるきのこのモデルは、こんなファイルです。
# 第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.191342g 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 つの配列を独立に持てるので、それぞれが必要な数だけあります。
/**
* 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),
};
}そしてfは3 隅とは限りません。四角形は 4 隅、n 角形は n 隅で書けます。GPU が描けるのは三角形だけ(第13章)なので、パーサ側で分割します。いちばん 簡単なのがファン (fan) 分割で、隅 0 を軸に (0, 1, 2), (0, 2, 3), (0, 3, 4) … と切っていく方法です。凸多角形ならこれで正しく埋まります(凹んだ多角形は はみ出しますが、実用上ほとんどの OBJ は凸の四角形です)。デモのきのこは 73 面のうち 72 面が三角形と四角形、最後の 1 面だけが 12 角形で、分割後は 142 枚の三角形になります。
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 を使います。
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抜きで書き出したファイルです。
# 第19章 サンプルモデル: きのこ(法線なし)
# vn 行がないので、読み込み側で 2 辺の外積から面法線を計算する
# 最後の 12 角形の面だけ、負のインデックス(末尾から数える)で書いてある
o Mushroomここで第11章の伏線を回収します。三角形の頂点を a, b, c とすると、2 辺b - aとc - aはその三角形が乗っている平面を張ります。この 2 本に垂直なベクトルは —外積 (cross product)そのものです。第11章で「2 本のベクトルから、それらに垂直な 3 本目を作れる」と書いた能力が、 ここで働きます。
// 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(法線なし)」が カクカクして見えるのはこのためです。
} 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 階建てになっています。
{
"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 でできています。
{
"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 でいちばん最初に躓くところです。しかし役割を一言で 言い切れば、そう複雑ではありません。
- buffer— バイト列そのもの。
uriで外部ファイル(.bin)やdata:URI を指すか、GLB の BIN チャンクを指す - bufferView— buffer の中の連続した一区間。
byteOffset/byteLength、そして頂点属性ならbyteStrideを持つ - accessor— bufferView を型つきの配列として読む指示。
componentType(要素の型)・type(要素の形)・count(要素数)・byteOffset(bufferView 内での開始位置)
componentType | 意味 | JavaScript の TypedArray |
|---|---|---|
5120 | signed byte(8 bit) | Int8Array |
5121 | unsigned byte(8 bit) | Uint8Array |
5122 | signed short(16 bit) | Int16Array |
5123 | unsigned short(16 bit) | Uint16Array |
5125 | unsigned int(32 bit) | Uint32Array |
5126 | float(IEEE-754 単精度) | Float32Array |
この番号に見覚えがあるかもしれません。gl.FLOATやgl.UNSIGNED_SHORTの実体そのものです(gl.FLOAT === 5126)。glTF が OpenGL の列挙値をそのまま採用しているので、読み取った番号をvertexAttribPointerにほぼ素通しできます。なお符号つき 32 bit 整数は仕様上サポートされていません。typeのほうは"SCALAR"(1 成分)・"VEC2"・"VEC3"・"VEC4"・"MAT2"・"MAT3"・"MAT4"の 7 種類で、1 要素のバイト数は componentType のバイト数 × type の成分数 です。
byteStrideがあるものはインターリーブされていて、複数の accessor が同じ区間を別々のbyteOffsetから覗きます。{
"buffer": 0,
"byteOffset": 840,
"byteLength": 768,
"byteStride": 32,
"target": 34962
},{
"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」がその内訳です。
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_model。mat4.translate→ rotateY →scaleの合成(親子階層は第19章の glTF のノード階層で)」と書きました。その第19章です。
glTF のnodesは木構造です。各ノードは自分の変換を持ち、childrenで子ノードの添字を並べます。仕様は「ノード階層は閉路のない、互いに交わらない木の集合でなければならない」と定めています — 各ノードの親は 0 個か 1 個です。そしてscenesがルートノードの集合を、scene(単数)がどのシーンを最初に表示するかを指します。
{
"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 通りある点に注目 してください。
matrix— 列優先 (column-major) の 16 要素を直接書く。gl-matrix も列優先なので(第11章)、読み取った配列をそのままmat4に入れられます。ただし仕様は「TRS に分解できるものでなければならない」= せん断を 含んではいけない、と定めていますtranslation/rotation/scale(TRS)— 3 つを別々に書く。rotationは単位クォータニオン (quaternion) の XYZWで、W がスカラー部です (gl-matrix のquatと同じ並び)。省略時の既定値は[0,0,0]/[0,0,0,1]/[1,1,1]
TRS から行列を作るときの順序も仕様が決めています —T * R * S の順に掛ける。頂点から見ると、まず scale が効き、次に rotation、最後に translation です。第12章で「行列の掛け算は右から順に 頂点へ作用する」と学んだのと同じ読み方です。gl-matrix のmat4.fromRotationTranslationScaleがちょうどこの順序を作ってくれるので、1 行で済みます。
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],
);
}そしてワールド行列です。仕様の言葉では「あるノードのグローバル変換行列は、親のグローバル変換行列と自身のローカル変換行列の積である。親を持たないノードでは、グローバル変換行列はローカル変換行列に等しい」。 そのまま書き下すと、こうなります。
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);
}中身は小さな再帰 1 つだけです。これがシーングラフ (scene graph)の全部で、three.js のObject3D.updateMatrixWorld()が毎フレームやっているのも、突き詰めればこの計算です。デモでは Column と ArmPivot の 2 つのノードだけを回しています。子の Head には何もしていないのに、支柱が回れば頭も 一緒に回る — 親のワールド行列が変われば、子のワールド行列も自動的に変わるからです。
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 列目(平行移動成分)が、まさにそのワールド位置です。
// 親を辿って合成したワールド行列から位置だけを取り出す。
// 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 通りあります。
.gltf+ 外部ファイル— JSON がテキストファイルで、buffers[].uriが.binを、画像が.pngを指す。ファイルは複数になるが、テキストエディタで開いて中身が読める。デモで 読み込んでいるのはこれ(robot.gltf+robot.bin)data:URI 埋め込み—"uri": "data:application/octet-stream;base64,AAAA…"のように、バイナリを Base64 にして JSON の中へ書く。ファイルは 1 つで済むが、Base64 はデータを約 33% 太らせるうえ、デコードの手間も増える.glb(Binary glTF)— JSON とバイナリを 1 つのバイナリファイルに詰めた入れ物。Base64 にしないので太らず、リクエストも 1 回。配布するならたいていこれ
.glbの構造は仕様どおりに読めば単純です。12 バイトのヘッダ(magic= ASCII の"glTF"= 0x46546C67、version= 2、length= ファイル全体のバイト数)のあとに、チャンクが並びます。チャンクはchunkLength/ chunkType/ 中身の 3 つ組で、JSON チャンクが必ず 1 番目、BIN チャンクが 2 番目と 順序まで決められています。全体はリトルエンディアンです。
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) が並ぶ形です。
{
"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) の主題なので、この章は構造の説明にとどめます。テクスチャの参照(baseColorTexture→ textures → images +samplers)も、第16章のテクスチャと組み合わせれば読めるようになりますが、 この章のローダーは対応していません。
ほかにも、glTF が持てるのに扱わないものがあります。正直に並べておきます。
- アニメーション— glTF は
animationsでキーフレームアニメーションを持てます。時間と値の列を accessor で持ち、ノードの TRS を補間する仕組みです。このサイトでは扱いません - スキニング / モーフターゲット—
skins(骨と重み)とtargets(頂点の差分)。こちらもこのサイトでは扱いません - sparse accessor— 「ほとんど同じ配列の、違う要素だけを書く」圧縮の仕組み。主にモーフターゲット用です。 この章のローダーは、見つけたら黙って無視せずエラーにします
- 拡張 (extensions)— 頂点を圧縮する
KHR_draco_mesh_compression、テクスチャを GPU 圧縮形式で持つKHR_texture_basisu(KTX2)、ライトを定義するKHR_lights_punctualなど。存在だけ知っておいてください。これらが付いたファイルは、対応していない ローダーでは(拡張がextensionsRequiredに入っていれば)読めません。この章のローダーもextensionsRequiredを見て、中身が 1 つでもあればエラーにします — 読めないものを「読めたふり」で 描き始めるほうが厄介だからです
10. 座標系・単位・巻き — glTF が決めている約束
OBJ には「上はどっちか」の決まりすらありません。Y-up で書かれることが多いものの、 それは慣習であって規定ではなく、書き出すツールの設定次第で上下も前後も入れ替わります (多くの DCC ツールの OBJ エクスポータに「Forward Axis」「Up Axis」の設定があるのは そのためです)。読み込んでみたらモデルが横倒しになっていた、という事故が起きるのは これが理由です。glTF はそこを仕様で固定しています。
- 右手系 (right-handed)。+Y が上、+Z が前、-X が右で、 アセットの正面は +Z を向く
- すべての長さの単位はメートル。ただしこれが効くのは「ノードの グローバル変換を適用したあと」の頂点位置についてで、メッシュのローカル座標そのものは 無次元です(スケールはノードが持ちうるため)
- 角度はすべてラジアン、正の回転は反時計回り
- UV の原点は左上。仕様は "The origin of the texture coordinates (0, 0) corresponds to the upper left corner of a texture image."(テクスチャ座標の原点 (0, 0) は画像の左上隅に対応する)と定めています。第14章の
createPlane/createSphereが生成する UV はv = 0 が下端なので、ここだけは第3部の約束と逆向きです。glTF のテクスチャを貼る段になったら、1.0 - vで読み替えるか、転送時にUNPACK_FLIP_Y_WEBGLを切る(第16章 2 節)ことになります。この章のローダーはテクスチャを読まないので、 いまは影響しません - RGB の原色は ITU-R BT.709 の色度座標に従う(色を扱う第27章で 再登場する話です)
- 三角形の巻き順は、ノードのグローバル変換の行列式で決まる。行列式が 正なら反時計回り (CCW) が表、負なら時計回りが表。負のスケールで鏡像を作れるようにする ための規定です
位置と巻き順については、第3部でここまで使ってきた約束 — Y が上の右手系、 外から見て CCW が表(第13章) — と一致しています。だからデモのローダーは座標変換を一切して いません(逆向きなのは上に挙げた UV だけで、この章はテクスチャを読まないので出番が ありません)。gl.cullFaceも既定のBACKのままで正しく描けます。もし three.js から glTF を読んで「裏返って見える」なら、 どこかに負のスケールが混じっていないか(= 行列式が負になっていないか)を疑うのが 定石です。
コード全文
OBJ パーサ、共通の外積ヘルパー、glTF ローダー(抜粋)、そしてデモ本体とシェーダーです。 読み込むモデル (public/models/)は、この章のために生成した小さなファイル群です。
// 第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,
},
};
}// 第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 節)・buildNodeとcomputeWorldMatrices(7 節)は、すでに本文で全文を載せています。残りを含めた 全体は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;
}// --- ジオメトリの組み立て -----------------------------------------------------
/**
* 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,
};
}// --- 入口 ---------------------------------------------------------------------
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,
},
};
}// 第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);#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;
}#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/.matrix | glTF ノードのtranslation/rotation/scale/matrix。TRS の合成順 T * R * S も同じ |
Object3D.getWorldPosition(target) | mat4.getTranslation(out, world[node])。ワールド行列の平行移動成分を 取り出すだけ(第15章の予告の回収) |
BufferGeometry.setAttribute('position', new BufferAttribute(...)) | glTF のaccessors。itemSizeがtype、配列の型がcomponentTypeに対応する |
InterleavedBuffer+InterleavedBufferAttribute(buffer, itemSize, offset) | bufferView.byteStride+accessor.byteOffset。第13〜14章で自作した stride / offset が、ファイルの中に書かれている。単位は違い、three.js のstride/offsetはtyped-array の要素数、glTF はバイト。同じ 1 頂点(位置 3 + 法線 3 + UV 2 の float)でも three.js は 8、glTF は 32 |
Mesh 1 つ = geometry + material | glTF のprimitive1 つ = attributes + indices +material。1 メッシュに複数 primitive があれば、three.js でも複数の Mesh になる |
MeshStandardMaterialのcolor/metalness/roughness | pbrMetallicRoughnessのbaseColorFactor/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)なので、エディタで直接書き換えて壊せるのがこの章の面白いところです。
main.tsの{ label: 'glTF', file: 'robot.gltf', kind: 'gltf' }をfile: 'robot.glb'に変える — まったく同じ絵が出ます。読み出し行の表示が「(.gltf + .bin)」から 「(GLB)」に変わり、ローダーが先頭 4 バイトで入れ物を判別していることが確認できますmain.tsのparseObj(await response.text())をparseObj(await response.text(), { smoothNormals: true })に変える — 「OBJ(法線なし)」がなめらかになり、読み出し行の頂点数が 288 から 115 へ減ります。傘と柄の境目まで丸くつながってしまうのが、スムージンググループを 読まないことの代償です(4 節)public/models/robot.gltfの Head ノードの"scale": [0.9, 0.9, 0.9]を"scale": [0.9, 2.5, 0.9]にする — 頭だけが縦に伸びます。法線行列を使っているので陰影は正しいままです。model.vertのu_normalMatrix * a_normalをmat3(u_model) * a_normalに書き換えると、不均等スケールで法線が狂う様子が見えます(第17章)robot.gltfの ArmPivot の"children": [4]を消す — 頭が原点に取り残されます。ノードも mesh も残っているのに、ルートから辿れなくなったせいでワールド行列が一度も更新されず、 単位行列のまま描かれるからです(読み出し行のワールド位置も (0.00, 0.00, 0.00) に固定されます)。「親に繋がっているかどうか」がすべて、という確認ですrobot.gltfの bufferView 4 の"byteStride": 32を"byteStride": 16にする — インターリーブの歩幅が狂い、支柱だけが原形をとどめない形に 崩れます(bufferView 4 は支柱専用だからです。台座・腕・頭は別の bufferView なので無傷で、 「どの bufferView がどの部品か」がそのまま目で分かります)。stride が「次の頂点までの距離」だという第13章の話が、ここで痛いほど分かりますrobot.gltfのaccessors[7](支柱のインデックス)の"count": 36を"count": 12にする — 支柱が三角形 4 枚ぶんしか描かれず、箱の一部だけが残ります。countが「何要素読むか」であることの確認です。逆に大きくすると 「accessors[7] が bufferView に収まりません」というエラーがローダーから出て、 読み出し行に表示されますpublic/models/mushroom.objのf行のどれかで、隅の並びを逆にする(たとえばf 2/1/1 1/2/2 3/3/3をf 3/3/3 1/2/2 2/1/1に)— その面だけ巻きが裏返り、カリングされて穴が開きます。 モデルデータの穴の多くは、これが原因です(第13章)mushroom-flat.objの最後の行(負のインデックスの面)の-1/-1を-2/-2にする — 底面の 12 角形の最後の隅が 1 つ手前とダブり、そこだけ面積ゼロの三角形になって、 底に切れ込みが入ります(下から覗くと欠けが見えます)。負のインデックスが「末尾から数える」 ことの確認ですmain.tsのMODEL_BASEを'/models-typo/'にする — 3 つとも読み込みに失敗し、読み出し行に HTTP 404 のエラーが並びます。 非同期の読み込みでは、失敗をどこかに出さないと「真っ黒な画面」だけが残ります
まとめ
- 自作パーサを書く目的は形式の理解。実務では
OBJLoader/GLTFLoaderを使ってよい。それでも中身を知っていると、真っ黒・穴あき・巨大ファイルといった 事故の切り分けができる - OBJ は
v/vt/vn/fのテキスト形式。インデックスは 1 始まりで、負の値は末尾から数える。四角形以上の面は ファン分割で三角形にする。標準化団体が管理する公式の仕様書は存在しない - この章の山場: OBJ は位置・UV・法線に別々のインデックスを持てるが、
drawElementsのインデックスは 1 本だけ。(v, vt, vn) の組み合わせごとに 1 頂点へ展開する。だから 位置 73 個のモデルが 128 頂点になる — 第13章の「角は 8 つ、頂点は 24 個」の一般形 - 法線を持たないモデルは、三角形の 2 辺の外積で面法線を作る(第11章)。面ごとに 持たせればフラット、位置ごとに平均すればスムーズ。glTF は仕様で「フラットにせよ」と 定めている
- glTF 2.0 は転送と実行時ロードのための形式。
buffers→bufferViews→accessorsの 3 段で「どこから何型で何個読むか」を指定する。componentTypeはgl.FLOATなどの列挙値そのもの bufferView.byteStrideがあるものはインターリーブ。第13〜14章で自作した「1 頂点 = 32 バイト」のレイアウトが、 そのままファイルに書かれている- ノード階層のワールド行列 = 親のワールド行列 × 自分のローカル行列。変換は
matrix(列優先 16 要素)か TRS(クォータニオンは XYZW、合成はT * R * S)。ワールド行列の平行移動成分がgetWorldPosition()相当 - 入れ物は
.gltf+ 外部ファイル /data:URI /.glbの 3 通り。GLB は 12 バイトのヘッダ + JSON チャンク + BIN チャンク - 座標系は右手系・+Y が上・+Z が前、単位はメートル、角度はラジアン。位置と巻き順は 第3部の約束と一致しているので変換は不要。ただしUV の原点だけは左上(第14章の生成する UV は v = 0 が下端)で逆向き
これで、画面に出せるものの幅が一気に広がりました。ジオメトリは生成もでき、読み込みもできる。 テクスチャ(第16章)も、ライティング(第17〜18章)も乗っている。ここからは、描く先そのものを 変えます。次章第20章「フレームバッファ — オフスクリーン描画とレンダーターゲット」では、canvas ではなく自分で用意したテクスチャに描く方法を扱います。「描いた結果を テクスチャとして読み直す」ことができるようになると、影(第21章)・ポストプロセス (第33章)・フィードバック(第29章)という、後半の大きなテーマがまとめて射程に入ります。 第13章で予告した「FBO では深度アタッチメントを自分で用意する」の回収もそこです。