リアルタイムレンダリングについて
はじめに
はじめまして。そうでない方はいつもお世話になっております。ゆういちろう(20)です。
絶賛就活中です。今日もESを書きました。忙しいです。よって今年の記事は非常に省エネです。悪しからず...
この記事はCCS Advent Calender 2024の20日目の記事です。
昨年と同じく12/20に、昨年と同じく表裏同時公開です。私らしく、とってもautisticでいいですね!
この記事では昨年度の続きとして、リアルタイムレンダリングについてつらつらと書いていきます。具体的な処理の話は全然していませんので、さらっと読めるかと思います。逆に言うと各ワードに関する説明がありませんので、気になったらその都度ググって頂くことを推奨いたします。
例のごとく著者の偏見に基づく情報です。正式な場所での引用は控えられることを推奨いたします。
人間の最も高貴な行いは誠実に戦い たゆまず努力することであり、情報こそが真の悦びをもたらすのだ。
基礎
ここでいうレンダリングとは、3次元空間を表す高次な情報を、その空間を写した2次元画像に変換する処理のことです。中でもリアルタイムレンダリングとは、その処理を遅くとも1/30[sec]で完了させるようなものを指します。ご存じの通り、ゲームに不可欠な技術です。
ここでは(いったん物理ベースとかは置いておいて) リアルタイムレンダリング、具体的にはGPUを操作してリアルタイムに3Dモデルを描画するうえで最低限必要な処理の流れについて述べます。
なお、このGPUを操作するためのAPIのことをグラフィックスAPIといい、有名どころではDirectX, OpenGL, Vulkan, Metal等が挙げられます。一般的に3Dゲームを作ろうとするとお世話になるUnityやUnreal Engineなどは、これらのグラフィックスAPIの上に構築されています。
前提(注意点)
3次元形状の表現方法には色々あるのですが、ここでは三角形の集まりとして表されるメッシュ構造に絞ります。また、描画パスやコマンド、その他GPUの操作のためのややこしい概念については説明しません。加えて、数学的な背景も説明しません。
グラフィックスパイプライン
GPUプログラミングは適当な関数にメッシュを渡せば画像がポンと出てくる、というような単純なものではありません。これは、リアルタイムレンダリングを実現するために各処理を固有のハードウェア化して高速化しているからです。そのため、予めCPU側でグラフィックスパイプラインと呼ばれる形式ですべての処理を定義し、GPU側に渡してあげる必要があります。以下に、グラフィックスパイプラインの概略図を示します。
(こちらより引用)
長すぎるだろ!
各段階についてさらっと説明します。
前準備: バッファオブジェクト
GPU上でメッシュを表示するにあたり、メッシュの全頂点を格納したGPU上のメモリ領域と、どの頂点がどう三角形を構成するかという添え字(インデックス)を格納したGPU上のメモリ領域が必要です。このうち前者を頂点バッファ、後者をインデックスバッファと呼びます。表示したいメッシュの頂点情報をCPUからGPUに転送し、予めこれらをGPU上で作成しておきます。
頂点属性
頂点バッファ/インデックスバッファから頂点のデータを読み取って三角形を構成し、グラフィックスパイプラインの入力データとする際、頂点ごとに持てる情報の形式(座標、法線方向、色、UV座標、etc.)が自由であるため、予め1つの頂点がどのような情報(属性)を持つのか定義しておきます。
バーテックスシェーダ (頂点シェーダ)
このシェーダという単語はそこそこ有名なため、初心者を大いに混乱させる原因となっていると思うのですが、要はGPU上で動作するプログラムのことです。グラフィックスパイプラインには、処理をある程度自由に書き換えられる(=プログラマブルな)段階が存在します。頂点シェーダでは主に、頂点を受け取ってその座標空間を変換(WVP変換)し、後述するフラグメントシェーダにおけるシェーディング(用語が混乱するが、シェーダとは別の、本来の陰影付けの意味)に使用する情報(各画素に対応する座標や法線など)を出力する処理を自由に記述します。
座標変換(WVP変換)
この頂点シェーダで行われる座標変換について少し詳細に書きます。
-
ワールド(W)行列
まず、皆様がよく知っている3Dモデルファイルを読み込んで得られるメッシュの頂点は、それぞれのモデルにおいて好き勝手な空間上の座標を指しています。(これをローカル空間などと呼びます。)よって、複数の3Dモデルを単に読み込むと、各モデルの座標系が混在してしまいます。これを避けるため、いったん全てのモデルを統一した空間に変換し、その空間上で3Dモデルの拡大縮小・回転・平行移動といった制御を行列として表現します。この統一空間のことをワールド空間と呼び、各メッシュの頂点ベクトルをワールド空間に変換する行列のことをワールド行列と呼びます。
例えばキーを入力したら指定のメッシュを動かす、というような処理は、メッシュごとのワールド行列を書き換えることで行われています。
注意: 当然頂点の座標だけでなく、頂点ごとの法線もワールド空間に変換する必要があるのですが、法線にワールド行列を乗算する場合は注意してください。法線は位置ではなく方向を表すベクトルなので、座標と同じように変換を適用すると壊れます。具体的にはワールド行列 に対しW としたものを適用すれば良いです。(W^{-1})^T
余談: 文献によっては、ワールド空間Wではなくモデル空間Mとすることがあります。
(こちらの方が多数派かも?) -
ビュー(V)行列
次にこのワールド空間を、カメラから見えている空間に変換する必要があります。これはすなわち、カメラが今いる座標を無理やり原点に持っていくような変換です。カメラが右に回転することを表すために、カメラ以外の全て(=ワールド空間全体)が左に回転するような変換を行うイメージです。
このカメラを中心として変換された空間をビュー空間、ワールド空間の各座標をビュー空間に変換するための行列をビュー行列と言います。 -
プロジェクション(P)行列
最後に、カメラから見えている空間をラスタライズされる領域の中に切り出し、3次元っぽく見せるための透視投影(パースペクティブ、いわゆるパース)を行う必要があります。具体的には、ユーザが近く/遠くの限界を決めて、視錐台と呼ばれるその間の空間を切り出して、遠くほど拡大する変換を適用します。下図のようなイメージです。
(こちらより引用)
この投影を適用した空間をプロジェクション空間と呼び、ビュー空間の各座標をプロジェクション空間に変換する行列のことをプロジェクション行列と呼びます。
こうした変換をまとめてWVP変換と呼びます。 なぜこう呼ぶかというと、頂点シェーダでこの変換を行う際、
out.pos = mul(mul(mul(float4(in.pos, 1.0), W), V), P);
みたいなコードを書くからです。嘘です。
(実は私はベクトルを行ではなく列として扱う方のAPIを使っているので、このようなコードはあまり書きません)
ユニフォームバッファ(定数バッファ)
この頂点シェーダや後述するフラグメントシェーダでは、予めCPU側から任意のベクトルや行列といったデータをGPU側に転送しておいて自由に参照することができます。こうしたデータが入っているGPU上のメモリ領域のことをユニフォームバッファ(DirectX系では定数バッファ)と呼びます。先ほどのWVP変換も、これを利用して頂点シェーダで行います。他にも、ここに光源の位置・方向、カメラの位置・方向、メッシュに貼るテクスチャ、メッシュの材質パラメータ...などの情報を突っ込んでおいてシェーダでよしなにすることで、様々な見え方の表現を実現しているというわけです。
(本当の実装ではテクスチャは画像なのでユニフォームバッファとしては扱いません。GPUには画像の読み書き専用の高速なメモリ機構があったりなかったりするからです。ベクトルや低次元の行列はユニフォームバッファとして、画像は画像として参照することができます。)
ラスタライザ
ラスタライズとはメッシュを構成する各三角形に対してそれが映る画素を求める操作のことであり(前年記事参照)、ラスタライザとは、このラスタライズを高速に行うGPU上の専用ハードウェアです。処理のイメージとしては3次元空間上のメッシュをべちゃっとスクリーンに射影する感じです。ラスタライザはGPU上の特化型ハードウェアの恩恵を受けているため、頂点のデータとそこからどう三角形を作るかといったルールを入力してあげれば、各画素に対応する三角形をかなり高速に特定してくれます。
フラグメントシェーダ(ピクセルシェーダ)
フラグメントとはピクセルのことです。(じゃあピクセルシェーダって名前でいいだろDirectX系ではピクセルシェーダと呼びます)
ここではレンダリングする画像の各ピクセルに対して、ラスタライザから受け取った、自身に映っている三角形の情報と、シーン中の光源やカメラの位置といった直接渡される情報を利用し、シェーディングを行う処理をシェーダとして記述します。例えばメッシュが赤色なら赤色に、ちょっとリアルにしたければ現在の三角形の法線と光源方向のコサインに応じて適当に明るさを変える、とかです。ここで物理法則に従ったすごいシェーダを書くほど物体の見え方はリアルでいい感じになりますし、マンガのベタ塗りっぽくなるような離散的な陰影処理を書けばみんな大好きトゥーンシェーディングができます。
(具体的なリアルタイム向けシェーディングのやり方についてはすごくいろいろあるので説明不可能です、とりあえず最初は"Phongの反射モデル"から始めるのが良いと思います。)
このフラグメントシェーダの書き方によってゲームのリアリティや表現技法の大部分が決まるため、「新しいシェーダを導入!」といった一見謎の売り文句が流行するというわけです。
(シェーダとはただのプログラムなので"新しいシェーダを導入"したからと言って別にグラフィックがいい感じになるとは限らないのですが...)
フレームバッファ
これが最後の画像出力です。GPUがこのメモリ領域に描画結果を書き出すと、その領域はディスプレイに転送されて表示されます。
まとめ
もっとも単純なグラフィックスパイプラインは以上のように構成されます。この一連の処理をグラフィックスAPIを介してCPU上で作成したのち、GPUに送り付けてその通りに動作してもらう、ということになります。描画するときのイメージは大体
auto command = gpu.createCommand();// GPUへの指令コマンド
command.setFramebuffer(framebuffer);// 描画先画像を指定
command.setGraphicsPipeline(graphicspipeline);// グラフィックスパイプライン設定反映
for (const auto& [mesh, material] : scene)
{
command.setUniformBuffer(mesh.transform);
command.setTexture(material.texture);
//... (その他いろいろ使う情報を指定)
// グラフィックスパイプラインを実行(描画)する命令
command.draw(mesh.vertexBuffer, mesh.indexBuffer);
}
//...
gpu.execute(command);// GPUにコマンドを実行させる
window.present();// フレームバッファを画面に表示
こんなイメージです。(かなりテキトーです。ご勘弁ください)
ここでは1つのグラフィックスパイプラインでシーン中のすべてのメッシュを描画します。このうち各シェーダはメッシュごとに変えられない(そのためには別のグラフィックスパイプラインが必要)のですが、シェーダに渡すユニフォームバッファやテクスチャは変えられます。そのため、メッシュごとに異なる処理をする場合はユニフォームバッファで分岐したりグラフィックスパイプラインを変えたりとやり方がたくさんあるわけです。
ちなみにUnityとかゲームの高速化でたまに聞く「ドローコールを減らせ!」というのは、このcommand.draw(..)という関数の呼び出しを減らせ、という意味です。
発展
リアルタイムレンダリングにおける、ちょっとした発展的な技術について述べます。
これらに共通する前提は、「グラフィックスパイプラインは十分高速であるため、現代のゲームなら1フレーム以内に何度も実行することができる」というところから始まっています。つまり、3次元空間を2次元画像に射影する操作を何度も行って表現の幅を広げているということです。
シャドウマップ
先ほどフラグメントシェーダの説明で、物体の位置・法線、光源の位置と方向が分かればシェーディング(陰影付け)は可能と説明しましたが、厳密には"陰" (shade)だけで、今のままでは物体の外にできる"影" (shadow)が表現できません。これは、1回のグラフィックスパイプラインの実行では表現不可能です。(前年記事参照)
そこで、このグラフィックスパイプラインを2回実行することでその画素が影かどうかを判定する手法がシャドウマップです。
具体的には、
- 光源の位置に擬似的なカメラを置き、光源の方向を向いて全メッシュを画像(シャドウマップ)にレンダリングする。
- そのあと通常のレンダリングを行うが、ここで1. のレンダリング結果画像(シャドウマップ)を利用する。今シェーディングしている画素が影になっていれば、シャドウマップにはほかのメッシュに遮られて映っていないはずであるため、これを利用して現在の画素が影かどうか判断することができる。
のような2ステップのレンダリングで実現します。
なお、この処理は単純に1枚のシャドウマップで行うと、解像度の影響を受けて影がジャギジャギになってしまったりするため、様々な対処法が考案されています。
遅延シェーディング
最初に述べたようなレンダリングのやり方には弱点があります。それは、シーン中に光源がたくさんあると劇的に重くなってしまうことです。(当たり前?)
コードの例を見ていただくと分かる通り、このパイプラインはメッシュごとに実行されます。一方シーンに光源がたくさんあると、フラグメントシェーダで光源の数だけシェーディングの計算を行う必要があります。これはつまり、処理を行う回数が、メッシュが
そこで、とある頭のいいひと(Michael Deeringさん) がこう考えました。
- 最初のグラフィックスパイプラインで、カメラから見えている各メッシュの座標、法線、マテリアル情報などシェーディングに必要な情報だけを数枚の画像としてまとめて書き出してしまう(例えば座標のXYZ, 法線のXYZ, マテリアルの色RGB + 粗さαとかならRGBA画像3枚ぶんに収まる)。
- 次のグラフィックスパイプラインで、カメラの視界をちょうど遮るような板を置き、その板に先ほどの画像たちを貼り付けて各画素でシェーディングをすれば、実質的に全部のメッシュをシェーディングした場合と同じ見え方になる!
1.で様々な情報を書き込んだ画像をジオメトリバッファ、略してG-Bufferと呼びます。また1.の処理をジオメトリパス、2. の処理をライティングパスと言ったりします。かなり天才的なアイデアだと思います。これにより、1.で
一方、当然デメリットもあります。
- GPUメモリを食う
画像をたくさん使って情報を書きだすため、GPU上でのメモリの読み書き/転送が大量発生します。そのため、GPUメモリ(VRAM)容量が小さい場合は使えません。Switchがこれを使えなくてCAPCOMが困った話がこちらに掲載されていたりします。 - (半)透明物体に弱い
シェーディングを行う時点で世界がカメラに対して最前面のメッシュのみになっているため、そのままでは(半)透明な物体の後ろに見えるはずのメッシュが消えてしまいます。これは予めメッシュをカメラからの距離でソートするなどの処理で改善可能ですが、3次元空間でのソートはちょっと重くてつらいです。
(順序関係なく透過を扱うOIT, Order Independent Transparencyと呼ばれるややこしい手法もあり、これで何とかしているゲームもたくさんあります。)
まとめ
本記事ではリアルタイムレンダリングをやるために最低限必要な流れを、説明しやすそうなところだけ超適当に説明しました。実際にやる場合はもっと面倒な作業(GPUの初期化、GPU上でのメモリ領域に対する取り扱い方の設定、ウィンドウからのフレームバッファの作成、ユニフォームバッファやテクスチャをシェーダから使うための登録用識別子の作成、グラフィックスパイプラインの段階ごとの同期、etc.)が多く、かなり虚無です。自分や他の人が使っているような、そのあたりの処理を肩代わりしてくれるライブラリを使うのが正解だと思います。(そのようなライブラリを作ったりもしていますので、気が向いたら使っていただけるとうれしいです。)
もし自分でやる!という場合は微力ながらお手伝いいたしますので、是非ともご連絡をお願いいたします。
2日で書いたクソ記事ですが、何かの役に立てば幸いです。就活が終わったら、もっと詳細に解説する機会をください。ありがとうございました。
Discussion