第1章では3Dモデルを「形・見た目・動き」の3層に分け、第2章ではSketchpadから生成AIまでの60年を追いました。 その歴史の大半で、ゲームや映画やWebの「形」を担ってきたのがポリゴンメッシュです。 この章ではメッシュを開いて、中にどんなデータがどんな形で入っているかを見ていきます。

TypeScriptで考えると分かりやすくなります。メッシュは「数値の配列が数本」にすぎません。 ただし配列の並べ方には、GPUの都合、ライティングの都合、テクスチャの都合、編集ツールの都合がそれぞれ反映されています。 立方体の頂点が8個ではなく24個になる理由が分かれば、この章の前半は押さえられます。

頂点は「位置」だけではない — 属性の集まり

メッシュの最小単位は頂点(vertex)、辺(edge)、面(face)です。 頂点は3D座標 (x, y, z) を持つ点、辺は2つの頂点を結ぶ線、面は辺で囲まれた多角形です。 ここで、頂点は座標以外にも複数の属性を持てます。この点が大事です。

属性glTFでの属性名型の例何に使うか
位置POSITIONvec3(float)頂点がどこにあるか
法線NORMALvec3(単位ベクトル)ライティング計算で面の向きを知る
接線TANGENTvec4ノーマルマップの向きを決める
UV座標TEXCOORD_0, TEXCOORD_1 …vec2テクスチャ画像のどこを貼るか
頂点カラーCOLOR_0vec3 / vec4頂点ごとの色
ボーン番号・重みJOINTS_0 / WEIGHTS_0vec4スキニング(第5章)。1セットで4影響、JOINTS_1などで追加

データベースにたとえると、頂点が「行」、属性が「カラム」です。 GPUに渡す頂点バッファは、1頂点分の属性をまとめて並べる構造体の配列(AoS)か、 属性ごとに別々の配列を持つ配列の構造体(SoA)のどちらかで作ります。 glTFでは属性ごとにaccessorを持つため、SoAに近い考え方になります。

頂点バッファとインデックスバッファ

インデックス付き三角形リスト

GPUの標準的な受け取り方はインデックス付き三角形リストです。 頂点属性は頂点バッファに一度だけ並べ、三角形は「どの頂点を使うか」を3つの番号で指定してインデックスバッファに並べます。 隣り合う三角形が同じ頂点を共有するとき、頂点データを重複して持たずに済みます。

WebGPUで三角形リスト(triangle-list)を描くと、頂点シェーダが返す位置3つごとに1枚の三角形ができます。 インデックスを使う場合は、インデックスバッファの数字が3つずつ区切られて、それぞれ1枚の三角形になります。

glTFはこの構造をそのままファイルにした

glTF 2.0はこの「頂点配列+インデックス配列」をファイル形式として定義しています。bufferはバイト列そのもの、bufferViewはその中の区間、accessorは「この区間をvec3のfloatとしてN個読め」という型情報です。 bufferViewの target で、頂点属性用(ARRAY_BUFFER)かインデックス用(ELEMENT_ARRAY_BUFFER)かを指定します。 仕様書は、バイナリ部分を「追加のパースなしでGPUバッファに直接ロードできることが多い」と説明しています。

graph LR
  BUF[buffer<br/>バイト列] --> BV1[bufferView<br/>頂点用区間]
  BUF --> BV2[bufferView<br/>インデックス用区間]
  BV1 --> AP[accessor<br/>POSITION vec3]
  BV1 --> AN[accessor<br/>NORMAL vec3]
  BV1 --> AU[accessor<br/>TEXCOORD_0 vec2]
  BV2 --> AI[accessor<br/>indices]
  AP --> PR[mesh.primitive]
  AN --> PR
  AU --> PR
  AI --> PR
  PR --> GPU[GPUで<br/>三角形を描画]

  style BUF fill:#8b5cf6,stroke:#6d28d9,color:#fff
  style PR fill:#3b82f6,stroke:#1d4ed8,color:#fff
  style GPU fill:#14b8a6,stroke:#0d9488,color:#fff
glTFのジオメトリ構造。バイト列を区間に切り、型情報を付けて、プリミティブの属性とインデックスとして参照する

立方体の頂点が24個になる理由

立方体の角は8つです。それならGPUに渡す頂点も8個で足りそうですが、実際には24個になるのが普通です。 理由は、1つの角に3枚の面が集まり、面ごとに法線とUVが違うからです。

右上手前の角 (1, 1, 1) を考えます。上面に属するときの法線は (0, 1, 0)、前面では (0, 0, 1)、右面では (1, 0, 0) です。 頂点バッファの1要素は「位置・法線・UVの組」なので、位置が同じでも法線が違えば別の頂点として登録するしかありません。 角8つ × 面3枚で24頂点、6面 × 三角形2枚 × 3インデックスで36インデックスになります。

// 立方体(一辺2、中心が原点)のうち2面分。残り4面も同じパターンで続く
// 1頂点 = [位置x, y, z, 法線x, y, z, u, v] の8要素(AoS)
const vertices = new Float32Array([
  // 前面 +Z(法線は全頂点 0,0,1)
  -1, -1,  1,   0, 0, 1,   0, 0,  // 0
   1, -1,  1,   0, 0, 1,   1, 0,  // 1
   1,  1,  1,   0, 0, 1,   1, 1,  // 2  ← 角(1,1,1)の1回目
  -1,  1,  1,   0, 0, 1,   0, 1,  // 3
  // 右面 +X(法線は全頂点 1,0,0)
   1, -1,  1,   1, 0, 0,   0, 0,  // 4
   1, -1, -1,   1, 0, 0,   1, 0,  // 5
   1,  1, -1,   1, 0, 0,   1, 1,  // 6
   1,  1,  1,   1, 0, 0,   0, 1,  // 7  ← 同じ角(1,1,1)の2回目
  // ...上面・左面・下面・背面で計24頂点
]);

// 各面は4頂点・2三角形。外から見て反時計回りに並べる
const indices: number[] = [];
for (let face = 0; face < 6; face++) {
  const base = face * 4;
  indices.push(base, base + 1, base + 2); // 1枚目の三角形
  indices.push(base, base + 2, base + 3); // 2枚目の三角形
}
const indexBuffer = new Uint16Array(indices); // 36要素

// サイズの目安
// 頂点: 24 × 8要素 × 4バイト = 768バイト
// インデックス: 36 × 2バイト = 72バイト
// インデックスを使わず36頂点を並べると 36 × 32バイト = 1152バイト

一般化すると、位置は同じでも、法線の不連続(ハードエッジ)やUVの継ぎ目(シーム)がある場所では頂点を分割する必要があります。 そのため「モデリングツールが表示する頂点数」と「GPUに渡る頂点数」は一致しません。 Blenderで8頂点と表示される立方体が、glTFに書き出すと24頂点になるのはこのためです。 逆に、球のようにすべての面がなめらかにつながり、シームも少ない形では、多くの頂点を共有できるので、インデックスの効果が大きくなります。

法線とシェーディング

面法線と頂点法線

面法線は面に垂直な単位ベクトルで、その面の「表側」を指します。頂点法線は頂点ごとに持たせる法線です。 ライティングは法線と光の向きの内積で決まるので、どちらの法線を使うかで見た目が大きく変わります。

  • フラットシェーディング: 各頂点に、属する面の面法線をそのまま持たせる。面ごとに明るさが一定になり、角ばって見える。立方体はこちら
  • スムーズシェーディング: 隣接する面の法線を平均して頂点法線にする。頂点の間で法線が補間され、少ないポリゴンでも曲面のように見える。球や顔はこちら

多くのツールでは、辺ごとに「ここはハード、ここはスムーズ」と指定できます。 ハードにした辺では法線が分かれるので、前節のとおり頂点が分割されます。 見た目の設定がデータ量に直結するのがメッシュの面白いところです。

ワインディング順で表裏が決まる

面の表裏は、法線ベクトルとは別に、三角形の頂点を並べる順番(ワインディング順)でも決まります。 glTF仕様は、表から見たときに頂点が反時計回り(CCW)に並ぶ面を表とします。 先ほどのコードで「外から見て反時計回りに並べる」と書いたのはこのためです。

GPUはこの表裏を使って裏面カリング(バックフェースカリング)を行います。 閉じた物体なら裏を向いた三角形は手前の面に隠れて見えないので、描画前に捨てれば処理が減ります。 glTFのマテリアルは doubleSided の既定値が false で、仕様上も「falseのとき裏面カリングが有効になり、表向きの三角形だけが描かれる」と定めています。

面が消える問題

このしくみが、3Dを始めたWebエンジニアがよく遭遇する「一部の面が消える」「中が透けて見える」症状の原因です。 典型的な原因は次の3つです。

  1. モデリング中に法線が反転した: 押し出しや結合の操作で一部の面の向きが裏返り、その面だけがカリングされる
  2. ミラー(負のスケール): 片側だけ作って鏡像コピーすると、頂点の並びが時計回りに反転する。glTF仕様は、ノードの変換行列の行列式が負ならワインディングを反転して扱うよう定めている
  3. そもそも片面だけのデータ: 葉っぱや布を1枚の板ポリゴンで作ると、裏から見たときに消える

UV展開 — 3Dの表面を2Dに切り開く

UV座標と展開

テクスチャ画像のどこをどの面に貼るかは、頂点ごとの2D座標 (u, v) で決まります。 これをUV座標と呼び、glTFでは TEXCOORD_0 などの属性に入ります。 3Dの表面を2D平面に切り開き、各頂点にUVを割り当てる作業がUV展開(UV unwrapping)です。 サイコロの展開図を描く作業を思い浮かべてください。

展開図の切れ目をシーム(seam)と呼びます。 閉じた立体を平らに広げるには、どこかを切らなければなりません。 シームの上の頂点は、3D空間では1点なのにUV空間では2か所に現れます。 そのため法線のハードエッジと同じく、シーム上の頂点もGPU上では分割されます。 シームの位置はテクスチャの継ぎ目として見えやすいので、首の後ろや脇の下など、目立たない場所に置くのが基本です。

テクスチャアトラス

複数のパーツのUVを1枚の大きな画像にまとめて配置する手法をテクスチャアトラスと呼びます。 パーツごとに別の画像と別のマテリアルを使うと、その数だけドローコール(CPUがGPUに描画を命じる1回の呼び出し)が増えます。 ドローコールは1回ごとに、どのバッファを読むか、どのテクスチャを使うかといった状態の準備が必要です。 小さな物体が数千個あると、描くより命令を出すほうに時間がかかります。 アトラスでマテリアルを1つにまとめれば、メッシュを結合して1回のドローコールで描けます。 Webで言えば、HTTPリクエストを減らすためのCSSスプライトに近い発想です。

トポロジー — つながり方の良し悪し

トポロジーは、頂点・辺・面の「つながり方」のことです。 座標値とは別の概念で、同じ形に見えるメッシュでもトポロジーは大きく違うことがあります。 ここでは、三角形とクアッド、エッジフロー、多様体とウォータータイトを順に見ます。

三角形とクアッド

GPUが描けるのは三角形だけです。3点はいつも1つの平面に乗り、三角形は必ず凸なので、ラスタライズの処理を単純にできます。 一方、Blenderなどのモデリングツールは四角形(クアッド)や5角形以上(Nゴン)も扱えます。 これらは書き出し時か描画前に三角形に分割されます(三角形化)。

観点三角形クアッド
GPUでの扱いそのまま描ける唯一の単位描画前に三角形2枚へ分割
平面性常に平面・常に凸4点が同一平面に乗るとは限らない
編集のしやすさ辺の流れが読みにくいエッジループを選択・追加しやすい
サブディビジョン分割すると歪みが出やすい相性が良く、なめらかに細分化できる
よく使う場面ランタイム配信、スキャン・生成AIの出力モデリング作業中、アニメーションする物

エッジフロー

クアッドの辺をつなげていくと、ぐるりと一周する線の列(エッジループ)ができます。 このループの流れをエッジフローと呼びます。 顔なら目や口の周りを同心円状に囲むように、腕なら関節の周りを輪切りにするように流すと、 アニメーションで変形しても形が崩れにくく、サブディビジョンでもきれいに細分化できます。

スキャンデータやAI生成メッシュは、三角形が乱雑に並んでいてエッジフローがありません。 これが「トポロジーが汚い」と言われる理由です。 第10章で扱う自己回帰型のメッシュ生成が「アーティストが作ったようなメッシュ」を目標にしているのも、この差を埋めるためです。

多様体とウォータータイト

多様体(manifold)メッシュとは、どの辺も高々2枚の面にしか共有されず、各頂点の周りが円盤状に面でつながっているメッシュです。 1本の辺に3枚以上の面がつながっていたり、2つの立体が1点だけで接していたりすると非多様体になります。

ウォータータイト(watertight)は、境界となる穴の開いた辺が1本もない、閉じたメッシュを指します。 水を入れても漏れない容器というイメージです。 ウォータータイトであれば「内側」と「外側」を定義できるので、次の用途では必須になります。

  • 3Dプリント: 内側と外側が決まらないと、どこを材料で埋めるかを決められない
  • 体積計算・ブーリアン演算: 内外の判定ができないと結果が定まらない
  • SDFへの変換: 符号付き距離の「符号」は内外で決まる

一方、ゲーム用アセットでは非多様体や開いた面を含むのが普通です。 葉っぱの板ポリゴンや、カメラから見えない底面を省いた建物などがその例です。 第10章で触れるTRELLIS.2が、内部表現(O-Voxel)で開いた面や非多様体も扱えることを特長に挙げているのは、こうした現実のアセットに対応するためです。

half-edge構造 — 編集に強いデータ構造

インデックス配列では「隣」が分からない

インデックス付き三角形リストは描画には最適ですが、隣接情報を持っていません。 「この辺の反対側にある面はどれか」「この頂点を囲む面を順に列挙したい」といった問い合わせには、配列全体を走査するしかありません。 細分割、簡略化、穴埋めのような編集処理ではこうした問い合わせが大量に発生するので、別のデータ構造が使われます。 その代表がhalf-edge(ハーフエッジ)構造です。

half-edge構造では、各辺を向きの異なる2本のハーフエッジに分けます。 各ハーフエッジは次の4つを参照し、頂点と面はそれぞれハーフエッジを1本だけ参照します。 つながり方の情報はハーフエッジ側にまとめて持たせる設計です。

  • twin: 同じ辺を逆向きにたどるハーフエッジ(反対側の面に属する)
  • next: 同じ面の中で次に続くハーフエッジ
  • vertex: このハーフエッジの始点の頂点
  • face: このハーフエッジが属する面
graph LR
  HE[ハーフエッジ] -->|twin| TW[逆向きの<br/>ハーフエッジ]
  HE -->|next| NX[面内の次の<br/>ハーフエッジ]
  HE -->|vertex| V[始点の頂点]
  HE -->|face| F[所属する面]
  TW -->|face| F2[隣の面]
  V -->|1本だけ参照| HE
  F -->|1本だけ参照| HE

  style HE fill:#8b5cf6,stroke:#6d28d9,color:#fff
  style TW fill:#3b82f6,stroke:#1d4ed8,color:#fff
  style F2 fill:#14b8a6,stroke:#0d9488,color:#fff
half-edge構造の参照関係。twinをたどれば隣の面に、nextをたどれば同じ面の次の辺に移れる

なぜ編集に強いのか

ある頂点の周りの面(1-ring)を列挙したいとき、half-edge構造なら「next → twin」の移動を繰り返すだけで一周できます。 1歩ごとの移動は参照をたどるだけなので定数時間です。 それ以前に使われていたwinged-edge構造では、辺ごとに「この頂点は辺の始点か終点か」を判定する必要があり、たどる処理が面倒でした。 辺を向きを持つハーフエッジに分けたことで、この場合分けが不要になりました。

ただし前提があります。half-edge構造は向き付け可能な多様体を想定しています。 1本の辺に3枚以上の面がつながる非多様体辺では、twinを1本に決められないため特別扱いが必要です。 前節の多様体の話は、ここでデータ構造の制約として関わってきます。

観点インデックス付き三角形リストhalf-edge構造
得意なこと描画(GPUへそのまま転送)隣接クエリと局所的な編集
隣接情報持たないtwin・nextで定数時間にたどれる
メモリ小さいハーフエッジごとに参照を持つぶん大きい
非多様体問題なく描ける特別扱いが必要
使われる場所glTFなどの配信形式、GPUバッファDCCツール内部、幾何処理ライブラリ

モデリングツールの内部と、配信用のファイル形式でデータ構造が違うのはこのためです。 編集中は隣接情報を持つ構造で作業し、書き出すときに描画向けの配列へ変換します。 Webアプリで言えば、編集画面では正規化したリレーショナルなデータを扱い、配信時には読み取り専用の非正規化JSONを出すのと似ています。

ローポリとハイポリ — ベイクで細部を移す

ノーマルマップへのベイク

ポリゴン数の多いモデルをハイポリ、少ないモデルをローポリと呼びます。 ゲームやWebのようなリアルタイム用途ではローポリを使い、ハイポリが持つ細かな凹凸はテクスチャに写し取って見た目を補います。 計算結果を事前にテクスチャへ焼き付けるこの処理をベイク(bake)と呼びます。 ビルド時にHTMLを生成しておくSSGと同じ発想です。

凹凸の再現に使うのがノーマルマップです。 法線の細かな揺らぎをRGBの値に符号化した画像で、ポリゴンを増やさずに画素単位で法線を変え、陰影だけで凹凸を表現します。 glTFは接線空間のノーマルマップを使い、メッシュに接線(TANGENT)が無い場合はMikkTSpaceという方式で計算するよう定めています。

リトポロジー

スカルプトツールで彫ったモデルや、フォトグラメトリで得たスキャンデータは、非常に密で乱雑なメッシュになりがちです。 その上に、整ったエッジフローを持つローポリのメッシュを張り直す作業をリトポロジーと呼びます。 目的は、アニメーションで変形しやすく、UV展開しやすく、軽いメッシュを得ることです。 ハイポリからローポリへ細部を移す流れは、次のようになります。

graph LR
  S[ハイポリ<br/>スカルプト・スキャン] --> R[リトポロジー<br/>ローポリを張り直す]
  R --> U[UV展開<br/>シームを決める]
  U --> B[ベイク<br/>ハイポリの凹凸を転写]
  B --> N[ノーマルマップ<br/>AOなど]
  N --> RT[ローポリ+テクスチャ<br/>で実行時に描画]

  style S fill:#8b5cf6,stroke:#6d28d9,color:#fff
  style B fill:#f97316,stroke:#ea580c,color:#fff
  style RT fill:#14b8a6,stroke:#0d9488,color:#fff
ハイポリの細部をローポリへ移す一般的な制作フロー。UVが先に決まっていないとベイク先の画像を作れない

UV展開がベイクより前にあることに注意してください。 ノーマルマップは「ローポリのUV空間」に描かれる画像なので、UVが決まっていないと書き込み先がありません。

LOD — 遠くの物は粗く描く

従来のLOD

LOD(Level of Detail)は、画面上で小さく映る物体ほどポリゴン数の少ない版に切り替えて、描画の負荷を下げる手法です。 最も詳細な版をLOD0とし、LOD1、LOD2と段階的に簡略化したメッシュを用意します。 Webで言えば、表示サイズに応じて画像を出し分ける srcset に近い発想です。

UnityのマニュアルはLODのコストとして次の点を挙げています。 LODごとにメッシュを持つのでメモリが増えること、簡略版を手作業で作る必要がある方式もあること、 そして切り替えの瞬間に形が飛ぶポッピングが目立つことです。 ポッピングは2つのLODを同時に描いて混ぜるクロスフェードで和らげられますが、そのぶん描画コストは増えます。

UE5 Naniteのクラスター単位LOD

Unreal Engine 5のNaniteは、この「物体ごとにLODを切り替える」考え方をクラスター単位に細かくしました。 インポート時にメッシュを128三角形ずつのクラスターに分け、 子クラスターを簡略化した親クラスターを積み重ねた階層を作ります。 併合と分割を繰り返すため、この階層は木ではなくDAG(有向非巡回グラフ)になります。

実行時には「画面上の誤差で見分けがつかない」ところで階層を切り、同じメッシュでも近い部分は細かく、遠い部分は粗く描きます。 隣り合うクラスターのLODが違うと境界にひび割れ(crack)が出るので、グループ単位で境界を固定して防いでいます。 NaniteをSIGGRAPH 2021で解説したKarisらは「画面のピクセル数には限りがあるのに、なぜピクセルより多い三角形を描くのか」と問題を立て、 ジオメトリの描画コストをシーンの複雑さではなく画面解像度に比例させることを目指しました。 レンダリング全体の中でのNaniteの位置づけは、第6章で改めて扱います。

手法切り替えの単位長所注意点
従来のLOD物体(メッシュ)ごと仕組みが単純で、多くのエンジンが対応メモリ増、ポッピング、簡略版の作成コスト
Nanite128三角形のクラスターごと同じメッシュ内で部分ごとに精度を変えられるUE5固有。クラスター境界のひび割れ対策が必要
リトポロジー制作時に1回変形・UV・軽量化に適した形を人が設計できる手作業が多く時間がかかる

まとめと次章へ

この章では、ポリゴンメッシュを次の要素に分解しました。

  • 頂点は位置・法線・UV・色などの属性の組で、面は頂点を指すインデックスの3つ組で表す
  • 法線の不連続やUVシームの上では頂点が分割されるので、立方体は8頂点ではなく24頂点になる
  • 表裏はワインディング順で決まり(glTFは反時計回り)、裏面カリングと法線反転の組み合わせで面が消える
  • 描画向けのインデックス配列と、編集向けのhalf-edge構造は役割が違う。half-edgeは多様体を前提とする
  • ハイポリの細部はリトポロジーとベイクでローポリに移し、遠景はLODで間引く

ここまでで扱ったのは3層のうちの「形」です。 次の第4章では「見た目」の層に進み、UVの上に貼られるテクスチャの種類、metallic-roughnessによるPBRマテリアル、 そしてこの章で登場したノーマルマップがライティング計算の中でどう使われるかを見ていきます。

主な参考資料

理解度チェック

問題 0 / 50%
Q1

角が8つの立方体を、フラットシェーディングとUV付きでGPUに渡すと、頂点バッファの頂点数が24個になるのが普通です。最も適切な理由はどれですか?

キーボード: 1〜4 で選択、Enter で回答