第3章で形(メッシュ)、第4章で見た目(マテリアル)、第5章で動き(リギングとアニメーション)を扱いました。 ここまでで3Dモデルの「データ」はそろいましたが、データのままでは画面に何も映りません。 この章では、頂点と三角形とマテリアルの集まりをピクセルの色に変える処理、つまりレンダリングの仕組みを見ていきます。

描き方は大きく2系統ある

3Dモデルを画像にする方法は、ループの向きで2つに分かれます。 1つはラスタライズで、三角形を1枚ずつ画面に投影し、その三角形が覆うピクセルを塗ります。 物体を起点にして画面へ向かう方式です。WebGL・WebGPU・ゲームエンジン・BlenderのEEVEEがこちらに属します。

もう1つはレイトレーシング/パストレーシングです。ピクセルごとにカメラから光線(レイ)を飛ばし、何に当たり、光がどう跳ね返ってきたかを追います。 こちらは画面を起点にして物体へ向かいます。BlenderのCyclesや映画制作のレンダラがこの方式です。

系統ループの向き代表例速度帯
ラスタライズ物体 → 画面(三角形ごとにピクセルを塗る)WebGL / WebGPU、ゲーム、Blender EEVEEリアルタイム(60fpsなら16.7ms/フレーム)
レイ/パストレーシング画面 → 物体(ピクセルごとに光を追う)Blender Cycles、映画のレンダラオフライン(秒〜時間/フレーム)

速度と物理的な正確さはトレードオフの関係にあります。近年はハードウェアの進化によって、2つを組み合わせるハイブリッドがリアルタイムでも使われ始めました。 まずはWebエンジニアが最初に触れるラスタライズから順に見ます。

ラスタライズのパイプライン

GPUのラスタライズは、決まった順番で処理が流れるパイプラインです。 開発者がプログラムを書けるのは主に頂点シェーダとフラグメントシェーダの2か所で、残りはGPUが固定機能として処理します。

graph TB
  V["頂点データ"] --> VS["頂点シェーダ<br/>MVP変換"]
  VS --> CS["クリップ座標<br/>(x, y, z, w)"]
  CS --> CL["クリッピング"]
  CL --> DIV["透視除算<br/>÷w でNDCへ"]
  DIV --> VP["ビューポート変換"]
  VP --> R["ラスタライズ<br/>ピクセル判定"]
  R --> FS["フラグメント<br/>シェーダ"]
  FS --> D["深度テスト<br/>Zバッファ"]
  D --> B["ブレンド"]
  B --> FB["フレームバッファ"]

  style VS fill:#8b5cf6,stroke:#6d28d9,color:#fff
  style FS fill:#8b5cf6,stroke:#6d28d9,color:#fff
  style D fill:#3b82f6,stroke:#1d4ed8,color:#fff
ラスタライズのパイプライン。紫は開発者が書くシェーダ、それ以外はGPUの固定機能

頂点シェーダとMVP変換

頂点シェーダの主な仕事は、頂点の座標を変換することです。 モデルの頂点はまず、そのモデル自身を原点とするローカル座標で記録されています。これを3つの行列で順に変換します。

  • Model行列: 物をワールドのどこに、どの向きと大きさで置くか(ローカル座標 → ワールド座標)
  • View行列: カメラをどこに構えるか。実際にはカメラを動かす代わりに世界全体を逆向きに動かします(ワールド座標 → ビュー座標)
  • Projection行列: 遠くの物を小さく見せる遠近を付けます(ビュー座標 → クリップ座標)

模型を部屋に置き、カメラを構え、写真に焼く、という3段階だと考えると覚えやすいでしょう。 3つの行列は掛け合わせて1本の式にまとめます。

#version 300 es
// 頂点シェーダ(WebGL2のGLSL)
layout(location = 0) in vec3 aPos;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;

void main() {
  // 行列は右から左に読む: 最初にModel、次にView、最後にProjection
  gl_Position = projection * view * model * vec4(aPos, 1.0);
  // このあとGPUが自動で x, y, z を w で割る(透視除算)
}

行列の積は右から左に作用するので、式の上では一番右の model が最初に掛かります。 書く順と適用される順が逆になる点は、最初につまずきやすいところです。

クリップ・透視除算・ビューポート変換

頂点シェーダが出力するのはクリップ座標という4次元の値(x, y, z, w)です。 透視投影の射影行列は、w に奥行きに応じた値が入るように作られています。 GPUは頂点シェーダの後で x, y, z をそれぞれ w で割ります。これを透視除算と呼び、結果が3次元のNDC(正規化デバイス座標)です。 奥にある頂点ほど w が大きく、割ったあとの x, y が中央に寄ります。遠くの物が小さく見える遠近の正体は、この「w に奥行きを入れておき、最後に w で割る」処理です。 なお正射影では w は1のままなので、割っても何も変わりません。

クリップ空間の範囲から外れた部分は捨てられます(クリッピング)。 最後にNDCをキャンバスの解像度に合わせて引き伸ばすビューポート変換を行い、ピクセル単位のスクリーン座標が得られます。

WebGLとWebGPUのNDC深度範囲の違い

NDCの範囲はAPIによって異なります。Webエンジニアが特に気を付けたいのが深度(z)の範囲です。

APINDCの x, yNDCの z(深度)
OpenGL / WebGL-1 〜 +1-1 〜 +1
WebGPU-1 〜 +1(yは上向き)0 〜 +1

ラスタライズとフラグメントシェーダ

頂点は3つずつ組になって三角形になります(WebGPUの triangle-list なら、頂点シェーダが返した位置3つごとに1枚)。ラスタライズは、各ピクセルの中心がその三角形の内側にあるかを判定し、内側にあるピクセルを塗る対象として選ぶ処理です。 選ばれたピクセルごとにフラグメントシェーダが1回ずつ呼ばれます。 頂点シェーダが位置を計算し、フラグメントシェーダが色を計算する、という分担です。 第4章のPBRの計算(ベースカラー・メタリック・ラフネスから光の反射を求める処理)は、主にこのフラグメントシェーダで実行されます。

WebGPUでは、これらのコマンドはすぐには実行されません。 エンコーダに「レンダーパス開始→パイプライン設定→描画→終了」を記録し、device.queue.submit を呼んだ時点で初めてGPUに送られます。

深度テストとZバッファ

三角形を描く順番は自由です。それでも手前の物が奥の物を正しく隠せるのは、深度バッファ(Zバッファ)があるからです。 カラーバッファと同じ解像度のバッファに、ピクセルごとに「これまでに描いた中で一番手前の深さ」を記録しておきます。 新しいフラグメントが来たら深度を比べ、手前なら色を描いて深度を更新し、奥なら捨てます。 奥から順に塗り重ねる「画家のアルゴリズム」を使わなくても、順不同で描けるようになったことがZバッファの意義です。

多くのGPUはフラグメントシェーダの前に深度テストを行い(Early depth test)、隠れる面の色計算を省きます。 シェーダの中で深度を書き換えると、この最適化は使えなくなります。

ブレンドと、半透明が難しい理由

フラグメントの色は最後に、すでに描かれている色とブレンドされます。 WebGPUのブレンド式は「result = operation(src × srcFactor, dst × dstFactor)」という形です。src は今回のフラグメントの出力、dst はすでに描かれている値を指します。 デフォルトは src をそのまま上書きする設定で、半透明ではプリマルチプライド・アルファを前提にした one / one-minus-src-alpha の組み合わせがよく使われます。

問題は、深度テストが透明度を考慮しないことです。 半透明のガラスを先に描くとガラスの深度が記録され、その後ろにある物体のフラグメントは深度テストで捨てられてしまいます。 そのため半透明は次の手順で描くのが定石です。

  1. 不透明な物体を先にすべて描く
  2. 半透明の物体をカメラからの距離でソートする
  3. 遠いものから順に描いてブレンドする

Zバッファで不要にしたはずの「奥から順に描く」作業が、半透明では再び必要になります。 しかもソートは物体単位なので、物体同士が交差していたり、1つのメッシュ内で面が重なっていたりすると、正しい順にはなりません。 アルファが0の透けたピクセルでも、描画すれば深度は書き込まれます。そのため木の葉の透けた部分が後ろの葉を隠す、といった問題も起きます。アルファが一定値未満のピクセルをシェーダで discard して描画自体をやめるのが定番の対処です。 「奥から順にソートしてα合成する」というこの発想は、この章の最後と第9章で再び登場します。

ドローコールのコスト

ラスタライズの速度を左右するのは、三角形の数だけではありません。 CPUがGPUに「このメッシュを描け」と命じる1回の命令をドローコールと呼びます。 1回ごとに、どのバッファを読むか、頂点属性の形式、テクスチャ、uniformなどの状態をCPU側で準備し、バス越しにGPUへ送る必要があります。 小さな物体が数千個あると、描画そのものより命令を出す処理に時間がかかるようになります。

WebエンジニアにはN+1問題にたとえると分かりやすいでしょう。 ユーザー一覧を取ってから1人ずつ投稿をSELECTすると、1件ずつは軽くてもクエリの往復回数で遅くなります。 ドローコールも同じで、1回あたりの準備コストが積み重なってボトルネックになります。

インスタンシングとバッチング

N+1問題の解決策がバルク取得であるように、ドローコールにもまとめて処理する手段があります。

インスタンシングは、同じメッシュを1回アップロードし、1回の命令で多数のコピーを描く方法です。 インスタンス数を渡して描画を呼ぶと、シェーダ内で gl_InstanceID(0始まりの通し番号)が使えるので、それを使ってインスタンスごとの位置や向きを引きます。 草むら、森の木、群衆などに向いています。

// 頂点シェーダ: インスタンスごとにModel行列を切り替える
in vec3 aPos;
uniform mat4 view;
uniform mat4 projection;
uniform mat4 models[100];

void main() {
  mat4 model = models[gl_InstanceID]; // 0〜99
  gl_Position = projection * view * model * vec4(aPos, 1.0);
}

// CPU側(WebGL2): 100本の木を1回のドローコールで描く
// gl.drawElementsInstanced(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0, 100);

バッチングは、マテリアルが同じ別々のメッシュを1つのバッファに結合し、まとめて1回で描く方法です。 インスタンシングは「同じ形を何個も」、バッチングは「違う形をひとまとめに」と覚えると区別しやすくなります。

LOD(Level of Detail)

もう1つの定番がLODです。画面上で小さくしか映らない遠くの物体を、ポリゴン数を減らした簡略版モデルに差し替えて描きます。 一番詳細なものをLOD0と呼び、数字が大きいほど粗くなります。 代償として、LODごとにメッシュを持つのでメモリが増え、方式によっては簡略版を手作業で作る必要があります。 切り替えの瞬間に形が飛ぶ「ポッピング」も目立ちます。2つのLODを重ねて描くクロスフェードで和らげられますが、その分描画コストは増えます。 この手作業のLODとドローコールの制約を根本から見直したのが、後で紹介するUE5のNaniteです。

レイトレーシングとパストレーシング

ラスタライズは「この三角形はどのピクセルを覆うか」には答えられますが、「このピクセルに、部屋の反対側の赤い壁から反射した光がどれだけ届くか」には直接答えられません。 影や映り込みや間接光を正しく求めるには、光の経路を追う必要があります。

レンダリング方程式(Kajiya 1986)

1986年、James T. KajiyaはSIGGRAPHで論文「The Rendering Equation」を発表し、既存のレンダリング手法を1つの積分方程式で一般化しました。現代的な書き方では次の形です。

Lo(x, ωo) = Le(x, ωo) + ∫Ω fr(x, ωi, ωo) Li(x, ωi) (n·ωi) dωi

点 x から方向 ωo へ出ていく光 Lo は、その点自身が放つ光 Le と、半球 Ω 上のあらゆる方向から入ってきた光 Li にBRDF(第4章で扱った反射の性質 fr)と角度の項を掛けて足し合わせたもの、という意味です。 核心は、入ってくる光 Li が別の点から出ていく光 Lo だという点にあります。 式が自分自身を参照する再帰になっていて、壁から壁へ跳ね返る間接光や相互反射が自然に含まれます。

ラスタライズのPBRは、この方程式のうち光源から直接届く項だけを計算し、残りを環境マップなどで近似しています。 パストレーシングは、この積分を乱数を使って正面から解く方法です。

モンテカルロ積分とノイズ

半球全体の積分は解析的に解けないので、ランダムに選んだ方向へ光線を飛ばして平均を取ります。これがモンテカルロ積分です。 1つのピクセルからカメラ光線を飛ばし、物体に当たったらBRDFに従って次の方向をランダムに選び、また飛ばす、を繰り返して光源までの経路(パス)を作ります。

graph TB
  P["ピクセル"] --> RAY["カメラから<br/>光線を発射"]
  RAY --> BVH["BVHで<br/>交差判定"]
  BVH --> HIT["当たった点で<br/>BRDFを評価"]
  HIT --> NEXT["次の方向を<br/>乱数で選ぶ"]
  NEXT -->|反射を繰り返す| BVH
  HIT --> ACC["N本の経路を<br/>平均"]
  ACC --> DN["デノイズ"]
  DN --> OUT["ピクセルの色"]

  style BVH fill:#3b82f6,stroke:#1d4ed8,color:#fff
  style DN fill:#10b981,stroke:#059669,color:#fff
パストレーシングの流れ。1ピクセルにつきN本の経路を作り、その平均を色とする

問題は収束の遅さです。モンテカルロ推定の誤差はサンプル数 N の平方根に反比例し、O(N-1/2) で減ります。誤差を半分にするにはサンプルを4倍にしなければなりません。世論調査で誤差を半分にするには、回答者を4倍に増やす必要があるのと同じ理屈です。 1ピクセルあたり数本の光線ではざらざらしたノイズが残り、数千本でようやく落ち着きます。オフラインレンダリングが遅い根本的な理由はここにあります。

デノイズ

そこで、少ないサンプルで得たノイズの多い画像をきれいにするデノイザが重要になりました。 たとえばIntelのOSSライブラリOpen Image Denoiseは深層学習ベースで、1ピクセル1サンプル(1spp)からほぼ収束した画像までを学習しており、必要なサンプル数を数桁減らせるとしています。 NVIDIAもTuringのホワイトペーパーで、RTコアとデノイズの組み合わせによって必要なレイ数を大きく減らせると説明しています。

BVH — 交差判定を速くする木構造

光線1本ごとに全三角形と交差判定をすると、計算量は三角形数 n に比例します。BVH(Bounding Volume Hierarchy)は、三角形を階層的にグループ分けし、各ノードに配下全体を包む箱(バウンディングボックス)を持たせた木です。 光線が箱に当たらなければ、その部分木を丸ごとスキップできます。 総当たりを二分探索のような絞り込みに変える点は、データベースのB-Treeインデックスと同じ発想です。 葉に1つずつ三角形を入れた二分BVHのノード数は 2n−1 で、構築には表面積ヒューリスティック(SAH)が標準的に使われます。

ハードウェアレイトレーシング(Turing, 2018)

2018年8月、NVIDIAはSIGGRAPHで「世界初のレイトレーシングGPU」と銘打ったQuadro RTXを発表し、同月にGeForce RTX 20シリーズも続きました。 アーキテクチャ名はTuringです。TuringはSMごとにRTコアを載せています。RTコアはバウンディングボックス判定とレイ-三角形交差判定の2つのユニットでBVHの走査を自律的に行い、当たったかどうかだけをSMに返します。 ハードウェアがない場合、BVH走査には1レイあたり数千命令スロットが必要だったとされます。 ホワイトペーパーによれば、RTX 2080 Ti / Quadro RTX 6000の性能は10 Giga Rays/secで、前世代Pascalのソフトウェア実装(約1.1 Giga Rays/sec)の約10倍です。

ただし、ゲームの「リアルタイムレイトレーシング」は画面全体をパストレースしているわけではありません。 反射・影・間接光など一部の効果にだけ少数のレイを使い、デノイズで補うハイブリッドが一般的です。

実際のエンジンではどう使い分けているか

Blender: CyclesとEEVEE

Blenderには用途の異なるレンダーエンジンが同居しています。 公式マニュアルは、Cyclesを「物理ベースのパストレーサ」、EEVEEを「物理ベースのリアルタイムレンダラ」と説明しています。 3つ目のWorkbenchは、レイアウトやモデリング時のプレビュー用です。 CyclesとEEVEEはカメラ・ライト・マテリアルを共有するので、同じシーンをエンジンだけ切り替えて描けます(一部の機能は片方でしか使えません)。

項目CyclesEEVEE
方式パストレーシングラスタライズが基本(4.2以降は画面空間のレイトレースを併用)
物理的な正確さ高い(光を実際に追う)Cyclesより低い(光の振る舞いを推定する)
速度シーンとサンプル数次第で秒〜分/フレーム以上リアルタイム。ビューポート表示にも使われる
主な用途最終品質の静止画・映像プレビュー、ルック開発、NPR(非写実表現)
影レイで計算4.2以降はVirtual Shadow Maps

EEVEEは2.80で導入されたエンジンです。光線を追わずにラスタライズで光の振る舞いを推定するので、物理的な正確さではCyclesに及びません。 ただしBlender 4.2のEEVEE(通称EEVEE Next)は、すべてのBSDFでスクリーンスペース・レイトレーシングを使うようになりました。 「EEVEEはラスタライズ」は基本的には正しいものの、現行版は画面に描かれた情報を使ったレイトレースを部分的に併用している、と理解するのが正確です。

UE5 Nanite — ドローコールのない仮想化ジオメトリ

Unreal Engine 5のNaniteは、テクスチャを仮想化(virtual texturing)したのと同じようにジオメトリを仮想化する仕組みです。 2021年のSIGGRAPHでBrian Karisらが行った発表では、ポリゴン数・ドローコール・メモリの予算をなくすこと、映画品質の元データをそのまま使えること、手作業の最適化を不要にすることが目標に挙げられています。 出発点にある問いは「画面のピクセル数には限りがあるのに、なぜピクセルより多くの三角形を描くのか」です。 ジオメトリの描画コストを、シーンの複雑さではなく画面解像度に比例させることを狙っています。

  • 128三角形のクラスタ: インポート時にメッシュを1クラスタ128三角形のグループに分割し、階層を作ります
  • クラスタ単位の自動LOD: 子クラスタを簡略化した親クラスタを積み上げた階層(併合と分割を繰り返すので木ではなくDAG)を作り、実行時には画面上で見分けがつかない粒度で階層を切り出します。同じメッシュでも部分ごとにLODが変わり、境界のひび割れはグループ単位で境界を固定して防ぎます
  • ストリーミング: 階層全体をメモリに置かず、見えている詳細だけを必要に応じて読み込みます
  • ドローコールを使わない: 公式ドキュメントは、Naniteが独自のレンダリングパスで動き、従来のドローコールを完全に迂回すると説明しています
  • ソフトウェアラスタライザ: ピクセル級の小さな三角形はハードウェアのラスタライザが苦手なので、コンピュートシェーダで自前のラスタライズを行います。小三角形中心のケースで、ハードウェア実装より平均3倍速いと報告されています

前半で見た「ドローコールのコスト」と「手作業のLOD」という2つの制約を、まとめて設計から取り除いた点がNaniteの新しさです。 ただしモーフターゲット非対応、マテリアルの種類の制限など、対応しない機能もあります(バージョンによって変わります)。

UE5 Lumen — ベイク不要の動的GI

Lumenは、間接光(グローバルイルミネーション、GI)と反射を完全に動的に計算するシステムです。 従来は間接光を事前計算してテクスチャに焼き込む「ライトマップ」が主流で、ライトを動かすと焼き直しが必要でした。 Lumenを有効にするとライトマップは使われなくなり、光源が動いても壁が壊れても間接光が追従します。

光の追跡には、メッシュの距離場を使うソフトウェアレイトレーシングと、対応GPUでのハードウェアレイトレーシングを使い分け、画面の深度と色を使うスクリーントレースも併用します。 Epicのパフォーマンスガイドによれば、目標はコンソールの1080pで、GIと反射に60fps目標なら4ms、30fps目標なら8msです。 16.7msのフレーム予算のうち4分の1ほどを光の計算に充てる設計だと分かります。

オフラインとリアルタイムの速度感

ここまでの数字を1つの表に並べます。次章以降で扱うNeRFと3D Gaussian Splatting(3DGS)も参考に入れています。

手法1フレームあたりの目安条件・補足
ゲーム(60fps / 30fps)16.7ms / 33.3msリアルタイムの基準
UE5 Lumen(GI+反射のみ)4ms / 8msコンソール、1080p、60fps / 30fps目標
3D Gaussian Splatting約7.5ms(134fps)Mip-NeRF360データセット平均、RTX A6000
Blender Cycles秒〜分以上シーン・サンプル数・GPUで大きく変わる
Mip-NeRF360十数秒(0.06fps)3DGS論文の比較表
NeRF(原論文)約30秒NVIDIA V100
映画(Pixar)数十時間報道によれば『モンスターズ・ユニバーシティ』(2013年)で平均29時間とされる

ゲームの16.7msと映画の数十時間を比べると、ざっと数百万倍の開きがあります。 RTコアとデノイズ、Naniteのような仕組みは、この差を少しずつ縮めるための工夫だと見ることができます。

第9章への伏線: NeRFと3DGSは同じ式を使っている

第9章で扱うNeRFと3DGSも、レンダリングの観点から見ると面白い関係にあります。 NeRFはカメラから光線を飛ばし、光線上の点ごとに密度 σ と色 c をニューラルネットに問い合わせ、手前から順に重ねます。ピクセルの色は次の式で求めます。

C = Σi Ti αi ci (αi = 1 − exp(−σi δi)、Ti = Πj<i (1 − αj))

αi はその区間の不透明度、Ti はそこまで光が遮られずに届く割合です。 NeRFの論文自身が、この式は伝統的なαコンポジティング(α合成)に帰着すると書いています。 3DGSの論文も、点ベースのα合成とNeRF型のボリュームレンダリングは本質的に同じ画像形成モデルを共有していて、違うのはレンダリングのアルゴリズムだ、と明言しています。

違いは、重ねる点をどう見つけるかです。NeRFは光線の上を歩きながら点を探すレイトレーシング型の計算で、1枚に約30秒かかりました。 3DGSはガウシアンを先に画面へ投影し、深度でソートしてから手前から順にα合成するラスタライズ型の計算で、100fpsを超えます。 この章で見た「半透明は奥行きでソートしてから重ねる」という処理が、そのまま最新の3D表現の描画方法になっているわけです。

まとめと次章へ

  • ラスタライズは「MVP変換→透視除算でNDC→ビューポート→ピクセル判定→フラグメントシェーダ→深度テスト→ブレンド」の順に進む。NDCの深度範囲はWebGLが-1〜+1、WebGPUが0〜+1
  • Zバッファのおかげで不透明物は順不同で描けるが、半透明はソートが必要で、交差する物体では破綻しうる
  • ドローコールはN+1問題と同じ構造のコストを持ち、インスタンシング・バッチング・LODで抑える
  • パストレーシングはレンダリング方程式をモンテカルロで解く。誤差はサンプル数の平方根に反比例するので、デノイズ・BVH・RTコアが不可欠
  • Naniteは128三角形クラスタとソフトウェアラスタライズでドローコールとLODの制約を外し、Lumenはベイク不要の動的GIを数msで実現する

ここまでで、3Dモデルを形・見た目・動きの3層で捉え、それを画面に描く方法まで一通り見てきました。 次の第7章では、これらのデータをファイルとしてどう保存し、ツール間で受け渡すのかを扱います。OBJ・FBX・glTF・USDの設計思想を、「オーサリング用かランタイム用か」という軸で整理します。

主な参考資料

理解度チェック

問題 0 / 50%
Q1

ラスタライズのパイプラインで処理が進む順に並べてください。

矢印ボタンで正しい順序に並べ替えてください

1フラグメントシェーダで色を計算
2wで割ってNDCにする(透視除算)
3頂点シェーダでMVP変換
4深度テスト(Zバッファ)
5ピクセル中心が三角形内かを判定(ラスタライズ)