Forward+ Rendering

はじめに

今回の技術ブログ担当のプログラマーの小林です。再度、持ち回りがやってきました。
2度目となる今回は、Forward+ Renderingを取り上げたいと思います。

Forward+ Renderingとは

まず、Forward Renderingとは、どのようなレンダリング手法なのでしょうか。

”従来のレンダリング手法”で、光源の数に応じてジオメトリパスを走らせる必要があり、光源の数に比例してパフォーマンスが低下していく。
(Wikipedia 遅延レンダリングのメリット覧を抜粋)

と、あります。Forward Renderingは、メッシュ単位にジオメトリ、シェーディング処理を行います。そのため、メッシュ、シェイプが、m個あり、ライトがn個あるとすれば、m * n 回のライティング処理を行うことになります。

これを改善するのに、別のアプローチでレンダリングするのが、上記で引用したwikipediaにも掲載されている遅延レンダリング(Defferred Rendering)という方法です。

このレンダリングは、多数のライトを配置しても、パフォーマンス低下を抑えられる
方法のようで、UE4でもデフォルトでこのレンダリングが採用されています。
(詳しくは、他Web、書籍など多数情報が出ていますので、そちらをご覧ください。)

なれば、Forward でも、ライト数の増加させても、処理負荷を稼げるよう改良されたのが、Forward+というレンダリング方法です。(私の認識が、間違えてなければ…)

このForward+ Renderingを、私が知ったのは、2012年にAMD社から発表されたときです。その資料によれば、

視錐台を適度に分割し、その視錐台ごとにライトカリング処理を行い、影響のあるライトとのみシェーディングを行う

というものです。AMD社から発表された資料には、このレンダリングで間接光を表現されていました。当時、Leoデモを見たとき、衝撃を受けたのを覚えています。

デモを作る前に

資料には、

  • 視錐台を適度に分割、分割毎のデータを構築
  • 視錐台とのライトカリング処理(この時、交差、包含するライトのインデックスを保持していく)
  • 作成したライトインデックスリストを参照し、ライティング処理を行う

とあるので、そのように作成していきたいと思います。

実装

視錐台を適度に分割、分割毎のデータを構築


上の図はカメラ視錐台を分割したイメージです。
(図では、4×3しかしてませんが、省略してます)
青線で描いてあるのが、1つの視錘台です。ライトカリングするとき、この錘台とライト(今回は点光源なので、球)で交差判定を行います。

作成時にカメラ座標系であれば、再構築の必要もありませんし、数値も決まってきますので、作成のしやすさから、カメラ座標系の視錐台データを保持するようにしました。
なので、カリング処理時に、ライトの位置をカメラ座標系に変換してから計算する必要があります。

まず、各分割する単位に頂点を作成し、その後、カメラ位置となる座標と面情報を作成していきます。下記は、そのコードです。CPUで構築します。

// フラスタム情報を構築する
void SponzaScene::BuildFrustums(const Camera& camera)
{
	const int posCount = (numOfWidthDiv_ + 1) * (numOfHeightDiv_ + 1);
	glm::vec3* positions = new glm::vec3[posCount];

	const float dist = 1.0f / tanf(20.f * (3.141592f / 180.f));

	// タイルを構成する頂点を生成
	const float aspect = camera.GetAspcet();
	const int32_t numOfWidthStride = numOfWidthDiv_ + 1;
	for (int32_t i = 0; i < numOfHeightDiv_ + 1; ++i)
	{
		for (int32_t j = 0; j < numOfWidthDiv_ + 1; ++j)
		{
			glm::vec3& v0 = positions[(i * numOfWidthStride) + j];
			v0.x = -1.0 * aspect + j * (2.0 * aspect / numOfWidthDiv_);
			v0.y = -1.0 + i * (2.0 / numOfHeightDiv_);
			v0.z = -dist;
		}
	}

	// 各視錐台ごとに面情報を構築
	Frustum* frustums = frustumBlock_.frustums_;
	const glm::vec3 p0 = glm::vec3(0.0);
	for (int32_t i = 0; i < numOfHeightDiv_; ++i)
	{
		for (int32_t j = 0; j < numOfWidthDiv_; ++j)
		{
			int32_t k = 0;
			const int32_t index = i * numOfWidthDiv_ + j;
			// 左
			frustums[index].plane[k].MakePlane(p0,
												positions[(i + 0) * numOfWidthStride + j],
												positions[(i + 1) * numOfWidthStride + j]);
			// 右
			++k;
			frustums[index].plane[k].MakePlane(p0,
												positions[(i + 1) * numOfWidthStride + j + 1],
												positions[(i + 0) * numOfWidthStride + j + 1]);
			// 下
			++k;
			frustums[index].plane[k].MakePlane(p0,
												positions[i * numOfWidthStride + j + 1],
												positions[i * numOfWidthStride + j + 0]);
			// 上
			++k;
			frustums[index].plane[k].MakePlane(p0,
												positions[(i + 1) * numOfWidthStride + j + 0],
												positions[(i + 1) * numOfWidthStride + j + 1]);
			// 近平面
			++k;
			frustums[index].plane[k].norm = glm::vec3(0.0, 0.0, -1.0);
			frustums[index].plane[k].d = -camera.GetNear();

			// 遠平面
			++k;
			frustums[index].plane[k].norm = glm::vec3(0.0, 0.0, 1.0);
			frustums[index].plane[k].d = camera.GetFar();
		}
	}

	delete[] positions;
}

通常、タイルサイズを16×16 や 32×32 などに分割するみたいなのですが、ここでは、解像度サイズ 1280 x 720 を横16、縦9分割の 80×80 pix のタイルサイズになってます。

視錐台とのライトカリング処理

ライトカリング処理は、コンピュートシェーダを使います。前段で作成した、視錐台データ、ライトデータを、UniformBlockで、バインドします。また、ライトインデックスリストの出力先となるバッファも読み書きが可能な、ShaderStorageBlockで、用意します。

// ライト情報
struct Light
{
	int   type;				// タイプ (directional, point)
	float intensity;		// 強度
	float pad0_;
	float pad1_;

	vec4  color;

	vec4  position;			// World space(directionalの場合は、無効値)
	vec4  direction;		// World space(pointの場合は、無効値)

	vec4  range;			// x = range distance
};

// ライトセット
layout(std140) uniform LightSet
{
	Light	lights[64];
	int		lightCount;

	vec4	ambient;
	vec3	cameraPosition;
};

// ビューユニフォーム
layout(std140) uniform View
{
	mat4	viewMtx;
	int		isForwardPlusEnabled;
};


// 平面
struct Plane
{
	vec4 norm;				// xyz : 法線 , w : D
};

// 視錘台
struct Frustum
{
	Plane	plane[6];
};

layout(std140) uniform FrustumArray
{
	Frustum	frustums[WIDTH_DIV * HEIGHT_DIV];
	int		frustumCount;
};


layout(std430, binding=0) buffer LightIndices
{
	uint	indices[];
};

layout(std430, binding=1) buffer LightCounts
{
	uint	counts[];
};


layout(local_size_x=1, local_size_y=1, local_size_z=1) in;

// 視錘台と球が、交差、または包含されているか
bool InFrustum(Frustum frustum, vec4 pos, float radius)
{
	for (int face = 0; face < 6; ++face)
	{
		float d = dot(pos.xyz, frustum.plane[face].norm.xyz) + frustum.plane[face].norm.w;
		if (d < -radius)
			return false;
	}
	return true;
}

// メイン
void main(void)
{
	const uint index = gl_WorkGroupSize.x * gl_NumWorkGroups.x * gl_GlobalInvocationID.y + gl_GlobalInvocationID.x;
	const uint lightBaseIndex = index * lightCount;

	int count = 0;
	for (int i = 0; i < lightCount; ++i)
	{
		vec4 vpos = viewMtx * lights[i].position;

		bool result = InFrustum(frustums[index], vpos, lights[i].range.x);
		if (result)
		{
			// インデックスをバッファに設定
			indices[lightBaseIndex + count] = i;
			++count;
		}
	}

	// 設定数を保存
	counts[index] = count;
}

今回扱うのは、点光源のみとしています。点光源の有効範囲を半径とした球と視錘台との交差判定をし、もし、交差、内包していれば、そのインデックスを出力バッファへ格納します。

作成したライトインデックスリストを参照し、ライティング処理を行う

前段で作成したライトインデックスリストをフラグメントシェーダにバインドし、その他のデータも適切にバインドします。まず、計算しようとしているフラグメントが、どの視錐台に属するのかを計算します。その後、対応する視錐台のライトインデックスを取得し、ライティング処理を行います。

// 距離減衰
float Attenuate(float len, float range)
{
	float factor = 1.0 - (pow(len, 4.0)/pow(range, 4.0));
	return (1.0 / max(0.01f, QUADRATIC * len*len)) * clamp(factor, 0.0, 1.0);
}


// ブリンフォン
float BRDF_BlinnPhong(vec3 N, vec3 L, vec3 V, float shininess)
{
	vec3 H = normalize(V + L);
	return pow( max(dot(N,H), 0.f), shininess );
}


// フラグメントシェーダ メイン
void main(void)
{
	int tileX = int(gl_FragCoord.x / TileWidthSize);
	int tileY = int(gl_FragCoord.y / TileHeightSize);
	int tileIndex = tileY * NumOfWidthDiv + tileX;

	vec3 directDiffuse = vec3(0.0, 0.0, 0.0);
	vec3 directSpecular = vec3(0.0, 0.0, 0.0);
	vec3 N = normalize(fsin.oNormal);
	vec3 V = normalize(cameraPosition - fsin.oPosi);
	vec3 Kd = (fsin.oColor.xyz * ScaleToLinear) * (texture2D(texAlbedo, fsin.oTexcoord).xyz * ScaleToLinear);
	vec3 Ks = Kd;

	for (int index = 0; index < counts[tileIndex]; index++)
	{
		const uint lightIndex = indices[tileIndex * MaxOfLightIndex + index];
		if (lightIndex >= MaxOfLightIndex)
			continue;

		vec3 litLinear = lights[lightIndex].color.xyz * ScaleToLinear;
		vec3 litPosi = lights[lightIndex].position.xyz;
		float intensity = lights[lightIndex].intensity;
		float litRange = lights[lightIndex].range.x;

		if (lights[lightIndex].type == DIRECTIONAL)
		{
			vec3 L = lights[lightIndex].direction.xyz;
			float LdotN = max(dot(L, N), 0.f);

			directDiffuse += fsin.oColor.xyz * texture2D(texAlbedo, fsin.oTexcoord).xyz * litLinear * LdotN;
			if (LdotN > 0.0)
			{
				directSpecular += BRDF_BlinnPhong(N, L, V, 50) * litLinear;
			}
		}
		else if (lights[lightIndex].type == POINT)
		{
			vec3 lightDiffVec = litPosi - fsin.oPosi;
			float distSqToLight = dot(lightDiffVec, lightDiffVec);
			float attenuate = Attenuate(sqrt(distSqToLight), litRange);
			vec3 L = normalize(lightDiffVec);

			float LdotN = max(dot(L, N), 0.f);
			vec3 lightColor = Kd * litLinear * LdotN * attenuate * intensity;
			directDiffuse += lightColor;

			if ((LdotN * attenuate * intensity) > 0.0)
			{
				directSpecular += Ks * litLinear * BRDF_BlinnPhong(N, L, V, 50) * attenuate * intensity;
			}
		}
	}

	vec3 finalColor = (directDiffuse + directSpecular + Kd * 0.02) * ScaleTosRGB;

	fragColor = vec4(finalColor, 1.f);
}

点光源の距離減衰ですが、通常の距離減衰(1 / 距離^2)させると、かなり距離が離れていても、わずかながら、光が届いてしまいます。カリングした有効範囲で、確実にFallOffするように、点光源計算を変えています。

負荷

デモシーンには、64個の点光源をランダムに配置し、単純な(上下と横)移動をするようにしています。今回採用したシェーディングは、BlinnPhoneシェーディングなので、シェーディング負荷はあまりないかとも思われたのですが、そこそこ、効果が出ました。アングルによっても負荷は、変化してきますが、概ね2倍以上の負荷軽減がされています。奥行きがあるロケーションであると、やはり、ライト数が増えていくので、負荷が高くなる傾向があります。(これを解消するために、Forward++というのがあります。)

デモ動画を張り付けておきます。
途中で、ヒートマップが表示されますが、内訳は以下の通りです。

ライト数
8未満
8以上
16以上
24以上
32以上
48以上

1タイルのサイズを16×16,32×32などに変更してみると、処理負荷が変わってくるかもしれません。が、今回はやっていません。

Forward Forward+
TopView 8.9ms 1.9ms
View 10.3ms 3.5ms

ですが、想像以上に負荷が軽減されたので、手ごたえを感じています。

TopView


View

上記のデモでは、CrytekSponzaモデルデータを使用しています。
Portions of this software are included under license © 2004-2018 Crytek GmbH. All rights reserved.

終わりに

いかがだったでしょうか? 駆け足ではありますが、個人的にサンプルデモを作成したときのことを書いてみましたが、ご覧頂い方たちに何か得られるものがあれば、幸いです。