GameDev #6 3Dグラフィクス [Devlog #022]

Table of Contents

ゲーム開発者の教科書:Game Programming in C++ を読んで理解したことについてを要約します(内容の転載を避け、詳しく説明しすぎないように配慮します)

ゲームプログラミング in C++


3Dグラフィクス

ここでは、2Dゲームワールドから3Dゲームワールドに移行するプロセスを学ぶ

Actorの3次元座標変換

このプロジェクトでは、$+x$が前方、$+y$が右向き、$+z$が上向きとなる左手座標系(left-handed coordinate system)を用いる

3次元の変換行列

$(a,b,c)$をオフセットとする4×4の平行移動行列は、

$$ T(a,b,c) =\begin{bmatrix} 1 & 0 & 0 & 0 \\\ 0 & 1 & 0 & 0 \\\ 0 & 0 & 1 & 0 \\\ a & b & c & 1 \end{bmatrix} $$

スケール行列は、

$$ S(s_x,s_y,s_z) =\begin{bmatrix} s_x & 0 & 0 & 0 \\\ 0 & s_y & 0 & 0 \\\ 0 & 0 & s_z & 0 \\\ 0 & 0 & 0 & 1 \end{bmatrix} $$

オイラー角

3次元での回転表現のアプローチであるオイラー角(Euler angle)は、上下軸周りの回転"ヨー"(yow)、左右軸周りの回転"ピッチ"(pitch)、前後軸周りの回転"ロール"(roll)という3つの角度で表現する

3つのオイラー角の別々の回転行列を組み合わせることで、回転行列を作ることができるが、掛け合わせの順序がオブジェクトの最終的な回転に影響する

任意の方向を向くことが要求された際に、ヨー・ピッチ・ロールの個々の角度を単純な計算で求めることができない

また、2つの角の間を補間することが要求された際に、不自然な角度に見える特異点に遭遇し、正しく補間することができない

クォータニオン

多くのゲームがクォータニオン(quaternion:四元数)を使っている

目的に関してのみ言うと、クォータニオンは、x、y、zに限らず、任意の軸周りの回転を表現する方法であると考えられる

参考解説動画1(3Blue1Brown)
参考解説記事1(株式会社セガ 開発技術部)

基礎的な定義

単位クォータニオン(unit quaternion)は、大きさが1のクォータニオンであり、ベクトルとスカラーの成分を持つ

$$ q = [\vec{q_v},q_s] $$

ベクトル成分とスカラー成分は、正規化された回転軸$\hat{a}$と、回転角$\theta$から計算される

$$ \vec{q_v} = \hat{a}sin\frac{\theta}{2},\quad q_s = cos\frac{\theta}{2} $$

ここで、位置$S$で$+x$の方向を向いている状態から、座標$P$を向くように回転させるとする

新しい座標$P$に向かうベクトルを正規化したものを$NewFacing$とすると、

$$ NewFacing = \frac{P-S}{\lVert P-S\rVert} $$

もとの向き($+x$の方向)から新しい向きに回す回転軸をクロス積で計算し、ベクトルを正規化すると、

$$ \hat{a} = \frac{\langle 1,0,0\rangle × NewFacing}{\lVert \langle1,0,0\rangle × NewFacing\rVert} $$

回転角度をドット積とアークコサインを使って計算すると、

$$ \theta = arccos(\langle 1,0,0\rangle \cdot NewFacing) $$

最後に、軸$\hat{a}$と角度$\theta$から、点$P$を向くまでの回転を表すクォータニオンを作る



$NewFacing$が元の向きと平行なら、回転を行わない処理にする必要がある(ゼロ除算を回避する)

2つのベクトルが逆向きであれば、$\pi$ラジアンの回転が必要になる

回転を組み合わせる

グラスマン積(Grassmann product)は、$q$に続いて$p$で回転する $$ (\vec{pq})_v = p_s \vec{q_v} + q_s \vec{p_v} + \vec{p_v} × \vec{q_v} $$ $$ (pq)_v = p_s q_s - \vec{p_v} \cdot \vec{q_v} $$

可換ではなく、回転は右$q$から左$p$の順序で適用される

単位クォータニオンの場合、クォータニオンの逆元(inverse)は、ベクトル成分の符号を反転させて求まる $$ q^{-1} = [-\vec{p_v},q_s] $$

単位元(恒等クォータニオン)は以下のように定義される $$ \vec{i_v} = \langle 0,0,0\rangle , \quad i_s = 1 $$

クォータニオンでベクトルを回転する

3次元ベクトル$\vec{v}$を、単位クォータニオンで回転させるとする

$v$をクォータニオン$r$で表現すると、 $$ r = [\vec{v},0] $$

2つのグラスマン積で$r^{\prime}$を計算すると、 $$ r^{\prime} = q\cdot r\cdot q^{-1} $$ ▶$q\cdot r$(クォータニオンの積)は、$q$によって定義される空間回転を$r$に適用する
▶この時点での結果は、元のベクトル空間ではなく、回転した空間における$r$の位置を表す
▶$q\cdot r$の結果に$q^{-1}$を掛けることで、回転した空間から元の空間への"逆回転"が適用される(回転を取り消すのではなく、回転後のベクトルを、元の座標系での向きに調整する)
▶$r^{\prime}$は、元のベクトル$v$が空間内でどのように回転したかを表す(純粋クォータニオン)

回転されたベクトルは、$r^{\prime}$のベクトル成分になる $$ \vec{v^{\prime}} = \vec{r^{\prime}_v} $$

参考解説動画2(3Blue1Brown)

球面線形補間

クォータニオンによって、球面線形補間(spherical linear interpolation:Slerp)と呼ばれる、より正確な回転の補間がサポートされる

Slerpは、$a$から$b$へ向かうパラメータとなる範囲$[0,1]$の小数値を受け取る $$ Slerp(a,b,0.25) $$

クォータニオンから回転行列への変換

$$ \vec{q_v} = \langle q_x, q_y, q_z\rangle ,\quad q_s = q_w $$ $$ Rotate(q) =\begin{bmatrix} 1-2{q_y}^2-2{q_z}^2 & 2q_xq_y + 2q_wq_z & 2q_xq_z - 2q_wq_y & 0 \\\ 2q_xq_y - 2q_wq_z & 1-2{q_x}^2-2{q_z}^2 & 2q_yq_z + 2q_wq_x & 0 \\\ 2q_xq_z + 2q_wq_y & 2q_yq_z - 2q_wq_x & 1-2{q_x}^2-2{q_y}^2 & 0 \\\ 0 & 0 & 0 & 1 \end{bmatrix} $$

クォータニオンのコーディング

Math.hヘッダーファイルにQuaternionクラスを設置

先に$p$、その後に$q$の回転をする時は以下のように書く

Quaternion result = Quaternion::Concatenate(q, p);

Actorの新しい座標変換を使う

Actor.h

Actorクラスは、位置のVector3、回転のQuaternion、スケールのfloatを持たせる

Matrix4 mWorldTransform;
Vector3 mPosition;
Quaternion mRotation;
float mScale;
Actor.cpp
Actor::ComputeWorldTransform()

スケーリング、回転、平行移動の順番

mWorldTransform = Matrix4::CreateScale(mScale);
mWorldTransform *= Matrix4::CreateFromQuaternion(mRotation);
mWorldTransform *= Matrix4::CreateTranslation(mPosition);
Actor.h

GetForward()では、もとの前方ベクトル($+x$)をクォータニオンで回転させる

Vector3 GetForward() const { 
	return Vector3::Transform(Vector3::UnitX, mRotation); 
}
MoveComponent::Update()
  1. 所有アクターの回転クォータニオンを取得
  2. 回転させるための新しいクォータニオンを作る
  3. もとの回転と新しい回転を結合して最終的な回転クォータニオンを作る
void MoveComponent::Update(float deltaTime) {
	if (!Math::NearZero(mAngularSpeed)) {
		Quaternion rot = mOwner->GetRotation();
		float angle = mAngularSpeed * deltaTime;
		// Create quaternion for incremental rotation
		// (Rotate about up axis)
		Quaternion inc(Vector3::UnitZ, angle);
		// Concatenate old and new quaternion
		rot = Quaternion::Concatenate(rot, inc);
		mOwner->SetRotation(rot);
	}
	
	if (!Math::NearZero(mForwardSpeed)) {
		Vector3 pos = mOwner->GetPosition();
		pos += mOwner->GetForward() * mForwardSpeed * deltaTime;		
		mOwner->SetPosition(pos);
	}
}

3Dモデルのロード

BlenderやMayaのような3Dモデリングツールで作成したモデルを、頂点バッファとインデックスバッファにロードするコードが必要になる

モデルフォーマットの選択

UnityやUnreal Engineなどのゲームエンジンでは、FBXのような変換フォーマット(exchange formats)をエディタでインポートするが、ランタイムでは使用しない(エンジンの内部フォーマットに変換する)

エクスポータープラグインは、モデリングツールのフォーマットから、ランタイム目的で設計された独自フォーマットに変換する

ほとんどのゲームでは、独自フォーマットにバイナリファイルフォーマットを使うが、当プロジェクトではJSON(JavaScript Object Notation)テキストフォーマットを使う

Cube.gpmesh

gpmeshファイルフォーマットでの立方体の表現

モデルの頂点フォーマットを指定するvertexformatのPosNormTxは、位置を表す3個のfloatとテクスチャ座標を表す2個のfloatの間に、頂点法線のための3個のfloatを追加している

シェーダープログラムを指定するshaderではBasicMesh、モデルに割り当てたテクスチャのリストを指定するtexturesでは[Assets/Cube.png]を指定する

verticesindicesでは、モデルの頂点とインデックスのバッファを指定する

{
	"version":1,
	"vertexformat":"PosNormTex",
	"shader":"BasicMesh",
	"textures":[
		"Assets/Cube.png"
	],
	"specularPower":100.0,
	"vertices":[
		[-0.5,-0.5,-0.5,0,0,-1,0,0],
		[0.5,-0.5,-0.5,0,0,-1,1,0],
		[-0.5,0.5,-0.5,0,0,-1,0,-1],
		[0.5,0.5,-0.5,0,0,-1,1,-1],
		[-0.5,0.5,0.5,0,1,0,0,-1],
		[0.5,0.5,0.5,0,1,0,1,-1],
		[-0.5,-0.5,0.5,0,0,1,0,0],
		[0.5,-0.5,0.5,0,0,1,1,0],
		[-0.5,0.5,-0.5,0,0,-1,0,-1],
		[0.5,-0.5,-0.5,0,0,-1,1,0],
		[-0.5,0.5,-0.5,0,1,0,0,-1],
		[0.5,0.5,-0.5,0,1,0,1,-1],
		[-0.5,0.5,0.5,0,1,0,0,-1],
		[-0.5,0.5,0.5,0,0,1,0,-1],
		[0.5,0.5,0.5,0,0,1,1,-1],
		[-0.5,-0.5,0.5,0,0,1,0,0],
		[-0.5,-0.5,0.5,0,-1,0,0,0],
		[0.5,-0.5,0.5,0,-1,0,1,0],
		[-0.5,-0.5,-0.5,0,-1,0,0,0],
		[0.5,-0.5,-0.5,0,-1,0,1,0],
		[0.5,-0.5,-0.5,1,0,0,1,0],
		[0.5,-0.5,0.5,1,0,0,1,0],
		[0.5,0.5,-0.5,1,0,0,1,-1],
		[0.5,0.5,0.5,1,0,0,1,-1],
		[-0.5,-0.5,0.5,-1,0,0,0,0],
		[-0.5,-0.5,-0.5,-1,0,0,0,0],
		[-0.5,0.5,0.5,-1,0,0,0,-1],
		[-0.5,0.5,-0.5,-1,0,0,0,-1]
	],
	"indices":[
		[2,1,0],
		[3,9,8],
		[4,11,10],
		[5,11,12],
		[6,14,13],
		[7,14,15],
		[18,17,16],
		[19,17,18],
		[22,21,20],
		[23,21,22],
		[26,25,24],
		[27,25,26]
	]
}

エクスポーター:https://github.com/gameprogcpp/code/tree/master/Exporter

頂点属性の更新

gpmeshファイルでの"位置"、“法線”、“テクスチャ座標"の3つの頂点属性を使う

VertexArray.cpp

各頂点のサイズをfloat 8個分に増やし、法線の属性を追加する

// Specify the vertex attributes
// (For now, assume one vertex format)
// Position is 3 floats
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float), 0);
// Normal is 3 floats
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * sizeof(float),
	reinterpret_cast<void*>(sizeof(float) * 3));
// Texture coordinates is 2 floats
glEnableVertexAttribArray(2);
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * sizeof(float),
	reinterpret_cast<void*>(sizeof(float) * 6));
Sprite.vert
// Attribute 0 is position, 1 is normal, 2 is tex coords.
layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inNormal;
layout(location = 2) in vec2 inTexCoord;

gpmeshファイルのロード

RapidJSONは、JSONファイルの高速な読み込みをサポートする

メッシュのローディングはMeshクラスにカプセル化する

Mesh.h

Textureクラスと同じく、Meshクラスにもコンストラクタ、デストラクタ、Loas()Unload()がある

Load()Gameへのポインタを受け取り、Gameでロードされたテクスチャの連想配列にアクセスする

Meshのメンバデータには、
▶テクスチャの配列、
▶頂点バッファとインデックスバッファのVertexArrayポインタ、
▶オブジェクト空間での境界球(bounding sphere)の半径(オブジェクト空間の原点と最も遠い点までの距離 -> コリジョンで利用する)
がある

Mesh.cpp
Mesh::Load()

すべての頂点とインデックスを格納する2つの一時的な配列を構築する

RapidJSONライブラリを通じて全部の値を読み終えたら、VertexArrayオブジェクトを構築する

Renderer.h

ロードしたメッシュの連想配列mMeshesを追加

Renderer.cpp
Renderer::GetMesh()

メッシュがすでに連想配列に入っているか、それともディスクからロードする必要があるかを判定する

3Dメッシュの描画

レンダリング関連のコードをGameからRendererに移行する

Gameクラスは、Game::Initialize()Rendererインスタンスの生成と初期化を行う

Renderer::Initialize()は、画面の幅と高さを受け取り、メンバ変数として保存する

Game::GenerateOutput()が、Renderer::Draw()を呼び出す

テクスチャの連想配列、メシュの連想配列、SpriteComponentの連想配列もGameからRendererに移行する

クリップ空間への変換(再び)

ビュー射影行列を、ビュー行列と射影行列に分解して考える

ビュー行列

ビュー行列(view matrix)は、カメラの位置と方向についての行列である

ここでは最小限の注視行列(look-at matrix)をカメラの位置と方向で表現する

典型的な注視行列は、“目の位置”、“注視するターゲットの位置”、“上を表す方向"の3つのパラメータで構成される

$$ \hat{k} = \frac{target - eye}{\lVert target - eye\rVert} $$ $$ \hat{i} = \frac{up × \hat{k}}{\lVert up × \hat{k}\rVert} $$ $$ \hat{j} = \frac{\hat{k} × \hat{i}}{\lVert \hat{k} × \hat{i}\rVert} $$ $$ \vec{t} = \langle -\hat{i}\cdot eye, -\hat{j}\cdot eye, -\hat{k}\cdot eye \rangle $$

$$ LookAt =\begin{bmatrix} i_x & j_x & k_x & 0 \\\ i_y & j_y & k_y & 0 \\\ i_z & j_z & k_z & 0 \\\ t_x & t_y & t_z & 1 \end{bmatrix} $$

カメラを動かす手っ取り早い方法は、カメラ用のアクターを作ること

カメラアクターの位置が"目の位置"を表現し、ターゲットの位置は"カメラアクターの正面の方向にある点"になり、上方向は”$+z$“になる

これらの引数をMatrix4::CreateLookAtに渡せば、ビュー行列が得られる

Vector3 eye = mCameraActor->GetPosition();	// カメラの位置
Vector3 target = mCameraActor->GetPosition() + mCameraActor->GetForward() * 10.0f;	// カメラの正面10単位先の位置
Matrix4 view = Matrix4::CreateLookAt(eye, target, Vector3::UnitZ);
射影行列

射影行列(projection matrix)は、3次元の世界を、画面に描画される2次元の世界に射影する方法を決める

正射影(orthographic projection)は、カメラから遠く離れたオブジェクトも、カメラに近いオブジェクトも同じ大きさになる

透視射影(perspective projection)は、カメラから遠いオブジェクトが、近くにあるオブジェクトよりも小さく見える

どちらの射影にも、近接平面(near plane)と遠方平面(far plane)がある

正射影行列には、
▶ビューの幅、
▶ビューの高さ、
▶近接平面への距離、
▶遠方平面への距離
という4つのパラメータがある $$ Orthographic(正射影) =\begin{bmatrix} \frac{2}{width} & 0 & 0 & 0 \\\ 0 & \frac{2}{height} & 0 & 0 \\\ 0 & 0 & \frac{1}{far - near} & 0 \\\ 0 & 0 & \frac{near}{near - far} & 1 \end{bmatrix} $$

透視投影行列には、fov:垂直画角(vertical field of view)のパラメータが増える $$ yScale = cot(\frac{far}{2}) $$ $$ xScale = yScale\cdot \frac{height}{width} $$ $$ Perspective(透視射影) =\begin{bmatrix} xScale & 0 & 0 & 0 \\\ 0 & yScale & 0 & 0 \\\ 0 & 0 & \frac{far}{far - near} & 1 \\\ 0 & 0 & \frac{-near\cdot far}{far - near} & 0 \end{bmatrix} $$

同次座標の$w$成分を変えている

$w$成分を1に戻すには、変換された頂点の各成分をw成分で除算する透視除算(perspective divide)を行う

透視除算によって、オブジェクトがカメラから遠いほど小さく表示され、OpenGLは自動的にこれを行う

正射影行列にはMatrix4::CreateOrtho、透視行列にはMatrix4::CreatePerspectiveFOVを使う

ビュー射影の計算

ビュー射影行列は、ビュー行列と射影行列の積で表す

$$ ViewProjection = (View)(Projection) $$

画家のアルゴリズムからZバッファ法へ

画家のアルゴリズムの憂鬱

3Dゲームでは、オブジェクトの前後関係は変化する(複雑なシーンでは、ソートが性能のボトルネックになる)

画家のアルゴリズムでは大量の重ね塗り(overdraw)が発生する

3Dゲームでは、できるだけ重ね塗りを排除するため、画家のアルゴリズムはほとんど使われない

Zバッファ法

Zバッファ法は、深度バッファ法、デプスバッファ法とも呼ばれる手法で、レンダリング処理にZバッファあるいはデプスバッファと呼ばれる追加のメモリバッファを使う

Zバッファには、シーンのデータがカラーバッファと似た形(深度)で格納される

フレームをグラフィカルに表現するバッファを(カラーバッファ、Zバッファを含めた総称として)フレームバッファと呼ぶ

オブジェクトをレンダリングする際は、各ピクセルを描画する前に、そのピクセルの場所での描画オブジェクトの深度を計算する(もし深度がZバッファに書かれている深度の値よりも小さければ、そのピクセルはカラーバッファに描画され、Zバッファのピクセルの深度の値が更新される)

Renderer.cpp
Renderer::Initialize()

OpenGLでデプスバッファ法を使うには、コンテクストを作成する前に、デプスバッファのビット数を設定する

SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
Renderer::Draw()

デプスバッファ法を有効にする

glEnable(GL_DEPTH_TEST);

glClearでデプスバッファをクリアする

glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

半透明テクスチャを描画する時には、3Dオブジェクトを描画する際はアルファブレンディングを無効にし、スプライトを描画する時に有効にする

また、スプライトをレンダリングする時は、Zバッファ法を無効にする(3Dゲームでは2DスプライトをHUD表示のUIにしか使わないため)

BasicMeshシェーダー

BasicMesh.vert

Sprite.vertのコードを利用する

BasicMesh.frag

Sprite.fragのコードを利用する

Renderer.h

メッシュシェーダー用のShader*型のメンバ変数mMeshShaderを追加する

ビュー行列と射影行列のためのMatrix4型のメンバ変数mViewmProjectionを用意する

Renderer.cpp
Renderer::LoadShaders()

BasicMeshシェーダーをロードし、ビューと射影行列を初期化する

mMeshShader = new Shader();
	if (!mMeshShader->Load("Shaders/Phong.vert", "Shaders/Phong.frag")) {
		return false;
	}

mMeshShader->SetActive();
// Set the view-projection matrix
mView = Matrix4::CreateLookAt(
	Vector3::Zero, 	// カメラの位置
	Vector3::UnitX, // ターゲットの位置
	Vector3::UnitZ  // 上向き
	);
mProjection = Matrix4::CreatePerspectiveFOV(
	Math::ToRadians(70.0f),  // 水平視野
	mScreenWidth,  // ビューの幅
	mScreenHeight,  // ビューの高さ
	25.0f,  // 近接平面までの距離
	10000.0f  // 遠方平面までの距離
	);
mMeshShader->SetMatrixUniform("uViewProj", mView * mProjection);

MeshComponentクラス

MeshComponent.h

SpriteComponentと違い、描画順序の変数はない(3DメッシュはZバッファ法を使うため)

メンバデータは、割り当てられたメッシュへのポインタmMeshと、テクスチャインデックスmTextureIndexのみである

Renderer.h

MeshComponentポインタの配列mMeshCompsを持たせる

Renderer.cpp
Renderer::AddMeshComp()

MeshComponentを追加する

Renderer::RemoveMeshComp()

MeshComponentを削除する

MeshComponent.cpp

コンストラクタとデストラクタで、AddMeshComp()RemoveMeshComp()を呼び出す

MeshComponent::Draw()

ワールド行列のuniformを設定する(所有アクターのワールド行列を直接使う)

メッシュに割り当てられているテクスチャと頂点属性をアクティブにする

最後に、glDrawElements()で三角形を描画する

Renderer.cpp
Renderer::Draw()
  1. フレームバッファをクリア
  2. デプスバッファ法は有効、アルファブレンディングは無効にする
  3. すべてのメッシュを描画する
  4. すべてのスプライトを描画する(以前と同じ設定)
  5. フロントバッファとバックバッファを交換する

カメラの移動に応じて、フレームごとにビュー射影行列を再計算する

ライティング(照明)

フラグメントシェーダーで、ピクセルの最終的な色を決定する

これまではテクスチャの色を直接使ったが、ここでは照明を考慮する

頂点属性(再び)

メッシュをライティングするには、頂点法線(vertex normal)の頂点属性が必要になる

三角形の表面のピクセルは、その三角形の3つの頂点法線が補間された法線ベクトルを持つ(すべての頂点属性は、フラグメントシェーダーに送られる際に、三角形全体に補間される)

光の種類

環境光
平行光源
点光源

点光源には、RGBカラー、位置、減衰半径(falloff radius)を持つ

スポットライト

フォンの反射モデル

双方向反射率分布関数(Bidirectional Reflectance Distribution Function:BRDF)シーンに置かれたオブジェクトに光がどう影響するかの近似値を計算する

古典的なBRDFモデルとして、フォンの反射モデル(Phong reflection model)がある

フォンの反射モデルは、局所照明モデル(local lighting model)であり、光の二次的な反射を計算しない

フォンのモデルは、光の反射を"環境成分(ambient)"、“拡散反射(diffuse)"、“鏡面反射(specular)“という3つの成分に分割する

$\hat{n}$:正規化された面法線
$\hat{l}$:表面から光源への正規化されたベクトル
$\hat{v}$:表面からカメラの位置への正規化されたベクトル
$\hat{r}$:法線$\hat{n}$に関してベクトル$\hat{l}$と対称になるベクトル
$\alpha$:鏡面反射指数

$k_a$:環境色
$k_d$:拡散反射色
$k_s$:鏡面反射色

$$ Ambient = k_a $$ $$ Diffuse = k_d(\hat{n}\cdot \hat{l}) $$ $$ Specular = k_s(\hat{r}\cdot \hat{v})^\alpha $$ $$ Phong = Ambient + \sum_{\forall light} \begin{equation*} \begin{cases} Specular + Diffuse \quad (\hat{n}\cdot \hat{l} > 0) \\ 0 \quad (その他) \end{cases} \end{equation*} $$

高度な実装では、表面の色を、環境色、拡散反射色、鏡面反射色に分ける

当プロジェクトでは、最後に1回だけ乗算する処理を変更して、それぞれの色に各成分を掛ける

フラットシェーディング(flat shading)では、表面ごとに1回計算する`

グローシェーディング(Gouraud shading)では、頂点ごとに1回計算する

フォンシェーディング(Phong shading)では、ピクセルごとに1回計算する

当プロジェクトでは、フォンシェーディングを行う

ライティングを実装する

すべてのメッシュでPhongシェーダーを使うように更新する

Phong.vert

BasicMesh.vertをもとに変更する

Phong.frag

BasicMesh.fragをもとに変更する

平行光源用のDirectionalLight構造体を宣言する

struct DirectionalLight {
	// Direction of light
	vec3 mDirection;
	// Diffuse color
	vec3 mDiffuseColor;
	// Specular color
	vec3 mSpecColor;
};
Renderer.h

環境光と平行光源のためのメンバ変数を追加する

// Lighting data
Vector3 mAmbientLight;
DirectionalLight mDirLight;
Shader.cpp
Shader::SetVectorUniform()

3次元のベクトル型のuniformを設定する

Shader::SetFloatUniform()

float型のuniformを設定する

Renderer.cpp
Renderer::SetLightUniforms()

新しいuniform値を設定する

ドット記法でuDirLight構造体の特定のメンバを参照する

カメラの位置は、ビュー行列の逆行列から求める(GetTranslation()がワールド空間におけるカメラの位置)

.gpmesh

gpmeshファイルフォーマットを拡張して、メッシュの"鏡面反射指数(specular power)“をspecularPowerプロパティで指定できるようにする

Mesh.cpp
Mesh::Load

specularPowerプロパティを読み込むようにする

mSpecPower = static_cast<float>(doc["specularPower"].GetDouble());
MeshComponent.cpp
MeshComponent::Draw()

メッシュを描画する直前に、uniformのuSpecPowerを設定する

// Set specular power
shader->SetFloatUniform("uSpecPower", mMesh->GetSpecPower());
Phong.vert

ワールド空間の法線と、ワールド空間の位置座標を計算し、out変数を介して、それらをフラグメントシェーダーに送る

// Normal (in world space)
out vec3 fragNormal;
// Position (in world space)
out vec3 fragWorldPos;
Phong.frag

in変数として、fragNormalfragWorldPosを宣言する

// Normal (in world space)
in vec3 fragNormal;
// Position (in world space)
in vec3 fragWorldPos;
Phong.vert

main関数でfragNormalfragWorldPosを計算する

void main() {
	// Convert position to homogeneous coordinates
	vec4 pos = vec4(inPosition, 1.0);
	// Transform position to world space
	pos = pos * uWorldTransform;
	// Save world position
	fragWorldPos = pos.xyz;
	// Transform to clip space
	gl_Position = pos * uViewProj;

	// Transform normal into world space (w = 0)
	fragNormal = (vec4(inNormal, 0.0f) * uWorldTransform).xyz;

	// Pass along the texture coordinate to frag shader
	fragTexCoord = inTexCoord;
}

.xyzはswizzleと呼ばれる構文で、4Dベクトルから$x$、$y$、$z$の成分を抽出し、それらの値によって新しい3Dベクトルを作る(vec4 to vec3)

法線を同次座標に変換する(法線は位置ではなく、平行移動しないので$w$成分は1ではなく0)

Phong.frag

フォンの反射モデルを計算する

OpenGLは頂点法線を三角形の表面全体に線形補間するため、fragNormalを正規化する必要がある(単位ベクトルを補間したものが単位ベクトルである保証はないため)

平行光源は、ある方向から発する光であり、オブジェクト表面から光源へのベクトルは、光線の法線ベクトルを逆にして求める

dot関数はドット積を計算し、reflect関数は反射ベクトルを計算する

max関数は2つの値から最大値を選択し、pow関数はべき乗を計算し、clamp関数はベクトルの各成分を指定された範囲内に制限する

RVのドット積が負の時の問題はmax関数で防止する

void main() {
	// Surface normal
	vec3 N = normalize(fragNormal);
	// Vector from surface to light
	vec3 L = normalize(-uDirLight.mDirection);
	// Vector from surface to camera
	vec3 V = normalize(uCameraPos - fragWorldPos);
	// Reflection of -L about N
	vec3 R = normalize(reflect(-L, N));

	// Compute phong reflection
	vec3 Phong = uAmbientLight;
	float NdotL = dot(N, L);
	if (NdotL > 0) {
		vec3 Diffuse = uDirLight.mDiffuseColor * NdotL;
		vec3 Specular = uDirLight.mSpecColor * pow(max(0.0, dot(R, V)), uSpecPower);
		Phong += Diffuse + Specular;
	}

	// Final color is texture color times phong light (alpha = 1)
    outColor = texture(uTexture, fragTexCoord) * vec4(Phong, 1.0f);
}

追記

テクスチャのファイルパス取得から描画までの流れ

Mesh::Load()でメッシュ(~.gpmesh)をロード、テクスチャファイルパスを抽出

bool Mesh::Load(const std::string& fileName, Renderer* renderer) {
    // ファイルを開く、解析するなどの処理...
    // テクスチャパスの読み込み
    const rapidjson::Value& textures = doc["textures"];
    for (rapidjson::SizeType i = 0; i < textures.Size(); i++) {
        std::string texName = textures[i].GetString();
        Texture* t = renderer->GetTexture(texName);
        mTextures.emplace_back(t);
    }
    // その他のメッシュデータの読み込みと処理...
    return true;
}

Renderer::GetTexture()で指定されたファイル名のテクスチャが既に読み込まれているかをチェック、もし読み込まれていなければ新たにロードする

Texture* Renderer::GetTexture(const std::string& fileName) {
    Texture* tex = nullptr;
    auto iter = mTextures.find(fileName);
    if (iter != mTextures.end()) {
        tex = iter->second;
    } else {
        tex = new Texture();
        if (tex->Load(fileName)) {
            mTextures.emplace(fileName, tex);
        } else {
            delete tex;
            tex = nullptr;
        }
    }
    return tex;
}

Texture::Load()で実際にテクスチャファイルを読み込み、OpenGLのテクスチャとしてGPUにアップロードする

bool Texture::Load(const std::string& fileName) {
	int channels = 0;
	
	unsigned char* image = SOIL_load_image(fileName.c_str(),
										   &mWidth, &mHeight, &channels, SOIL_LOAD_AUTO);
	
	if (image == nullptr) {
		SDL_Log("SOIL failed to load image %s: %s", fileName.c_str(), SOIL_last_result());
		return false;
	}
	
	int format = GL_RGB;
	if (channels == 4) {
		format = GL_RGBA;
	}
	
	glGenTextures(1, &mTextureID);
	glBindTexture(GL_TEXTURE_2D, mTextureID);
	
	glTexImage2D(GL_TEXTURE_2D, 0, format, mWidth, mHeight, 0, format,
				 GL_UNSIGNED_BYTE, image);
	
	SOIL_free_image_data(image);
	
	// Enable linear filtering
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
	glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
	
	return true;
}

MeshComponent::Draw()でレンダリングを行う

関連付けられたMeshオブジェクトの各テキスチャをアクティブにし、シェーダーを使用してメッシュを描画する

void MeshComponent::Draw(Shader* shader) {
	if (mMesh) {
		// Set the world transform
		shader->SetMatrixUniform("uWorldTransform", 
			mOwner->GetWorldTransform());
		// Set specular power
		shader->SetFloatUniform("uSpecPower", mMesh->GetSpecPower());
		// Set the active texture
		Texture* t = mMesh->GetTexture(mTextureIndex);
		if (t) {
			t->SetActive();
		}
		// Set the mesh's vertex array as active
		VertexArray* va = mMesh->GetVertexArray();
		va->SetActive();
		// Draw
		glDrawElements(GL_TRIANGLES, va->GetNumIndices(), GL_UNSIGNED_INT, nullptr);
	}
}

Mesh::GetTexture()でアクティブにするテクスチャを取得する

mTextureIndexは、Meshオブジェクトのテクスチャ配列内でアクティブにするテクスチャのインデックス

Texture* Mesh::GetTexture(size_t index) {
	if (index < mTextures.size()) {
		return mTextures[index];
	}
	else {
		return nullptr;
	}
}

ゲームプロジェクト

まとめ

参考文献

レンダリングプログラマーに人気のあるリファレンス:
Real Time Rendering Fourth Edition

DirectX 12の使い方をカバーしたリファレンス:
Introduction to 3D Game Programming With DirectX 12
無償で公開:リンク

物理ベースレンダリングの概観:
Physically Based Rendering: From Theory to Implementation 3rd Edition
無償で公開:リンク