古典的な遅延シェーディングの秋 2014

おなじみの遅延シェーディング (Deferred Shading) を実装しました。昨年の夏に OpenGL で実装して以来、1年ぶりになります(こちらの記事を参照)。今回実装したのは、いわゆる古典的遅延シェーディング (Classic Deferred Shading) と呼ばれ12広く知られている遅延シェーディングです。今回は触れませんが、この遅延シェーディングを発展させたテクニックには Light Pre-PassForward+ があります。

遅延シェーディング

遅延シェーディングについてざっくり説明すると:

  • 最初のパスでは、レンダーターゲット(またはフレームバッファ)にオブジェクトのアルベド(テクスチャカラー)や法線、深度、ワールド空間での位置などを書き込みます。(A)
  • 次のパスでは、レンダーターゲットをサンプリングしながらシェーディングを行います。(B)

最初のパス (A) でシェーディングを行わないところが肝です。このとき、オブジェクトのジオメトリ情報を書き込んだバッファをジオメトリバッファ (Geometry Buffer) と言います。慣例的に G-Buffer と呼ばれています。次のパス (B) では、この G-Buffer を使って、シェーディングを行います。シェーディングを遅らせることから、遅延シェーディングと呼ばれています。

(図 1) 今回の遅延シェーディングの最終結果。Half Lambert  シェーディング後に SSAO とポストプロセスエフェクト(FXAA, 色収差、ケラレ)をかけている。
(図 1) 今回の遅延シェーディングの最終結果。Half Lambert シェーディング後に SSAO とポストプロセスエフェクト(FXAA, 色収差、ケラレ)を追加している。

遅延シェーディングの長所は、リアルタイムにおいて複数の光源を扱いやすくなることです。その一方で、半透明オブジェクトの扱いが少々難しくなるデメリットがあります。他にも MSAA との相性が悪いという短所もありますが、後述するポストプロセスエフェクトとの相性の良さから FXAA や SMAA といった代替案を用いることがあります。 また G-Buffer に書き込むためにマルチレンダーターゲット (MRT) を利用します。動作には MRT 対応のグラフィックスデバイスが必須になりますが、これについては、ここ最近のゲームプレイ環境がほとんど MRT に対応しているため問題ありません。最新のモバイル端末も MRT に対応し始めています。

遅延シェーディングの副次的な効果として、ポストプロセスエフェクトを追加しやすくなる利点があります。被写界深度ブラーやフォグ、モーションブラーのようなポストプロセスエフェクトをはじめ、SSAO (Screen Space Ambient Occlusion) や SSIL (Screen Space Indirect Lighting), RLR (Real-Time Local Reflections) などのスクリーンスペース系のエフェクトとも相性が良いです。

今回は、平行光源による Half Lambert シェーディングの他に SSAO を追加しました。AO が加わったため、見た目がぐっといい感じになりました(図 1 参照)。その他に、色収差エフェクトとケラレ、被写界深度ブラーをポストエフェクトとして追加しています。アンチエイリアスには FXAA を使用しました。SSAO や色収差、被写界深度ブラーについては日を改めてお話ししたいと思います。

今年の G-Buffer

G-Buffer の構成は次のようにしました:

(表1) G-Buffer の構成

Name Format Usage
- D24S8 Depth, Stencil
RT0 R8G8B8A8_UNORM Albedo (RGBA)
RT1 R10G10B10A2_UNORM World-space normal (XYZ), None (Alpha)
RT2 R32_FLOAT Depth

G-Buffer の各フォーマットについては、アプリケーションの要件やレンダラの実装に大きく左右されるので、毎回手探りで設定しているような気がします。

(図 2) 左上から右に向かって、(C) アルベド、(D) ワールド空間の法線、(E) 深度。左下から右に向かって、(F) SSAO、(G) ランバートシェーディングと SSAO, (H) ポストプロセスエフェクトを加えた最終結果。
(図 2) 左上から右に向かって、(C) アルベド、(D) ワールド空間の法線、(E) 深度。左下から右に向かって、(F) SSAO、(G) ランバートシェーディングと SSAO, (H) ポストプロセスエフェクトを加えた最終結果。

レンダーターゲットのフォーマット

サーフェスフォーマットにおけるコンポーネントとは、R (Red) や G (Green) あるいは D (Depth), S (Stencil) といったカラーチャンネルや成分のことを示します。例えば、R8G8B8A8 や R16G16, D24S8 はそれぞれ 4 成分(4 コンポーネント)、3 成分、2 成分のフォーマットとなります。G-Buffer の各レンダーターゲットのサーフェスフォーマットを決める際、このコンポーネントについて考慮する必要があります:

  • コンポーネントの数 (R, RG, RGB, RGBA)
  • コンポーネントのサイズ (R8, R16, R32 など)
  • コンポーネントの型や取りうる値の範囲(NORM, UNORM, UINT, FLOAT など)

表 1 に示した各レンダーターゲットは、コンポーネントの数やサイズ、型は異なっていますが、サーフェスフォーマットのサイズはすべて 32-bit に揃えています。これは、NVIDIA が 2004 年に発表した資料に基づいています3

また、Direct3D (DXGI) では 24-bit フォーマット (R8G8B8_UNORM, D24) などの非 2 のべき乗フォーマットは廃止されています。OpenGL を利用する場合でも、移植やマルチプラットフォームを考慮するのであれば 32-bit, 64-bit または 128-bit フォーマットを使うのがよさそうです。

法線のフォーマット

正規化された法線情報を G-Buffer に格納するにはいくつか手法が存在しています。 まず手始めに R16G16_FLOAT (いわゆる FP16 フォーマット)を試してみました。本来 3 次元の法線情報を half2 フォーマットに変換して G-Buffer に格納し、ライティングパスなどで使用するときはデコードして元の 3 次元法線として扱います。ただ実装方法がよくなかったようで、デコードされた法線の長さが 0 になるケースが見られました。 次に、フォーマットとして R16G16B16A16_FLOAT を試してみました。そのまま xyz の 3 成分を保存でき、面倒なエンコード・デコード処理を必要としません。しかし、64-bit フォーマットとなるため、アルベドや深度といった他の G-Buffer の変更が必要になります。次に、フォーマットとして R10G10B10A2_UNORM を試してみました。こちらは UNORM なので 0.0 から 1.0 の範囲に法線をエンコードする必要があります。目立った問題が出なかったため、こちらを採用しました。

法線のエンコード・デコード

法線を G-Buffer に格納する際、エンコード・デコード処理を行います。 例えば、R10G10B10A2_UNORM フォーマットに書き込む際、法線の各成分を 0.0 から 1.0 の範囲におさめる必要があります。正規化されたワールド空間(あるいはビュー空間)の法線は各成分が -1.0 から 1.0 の範囲にあります。そこで次のように、0.0 から 1.0 の範囲にエンコードしてから G-Buffer に出力します。

vec3 EncodeNormal(in vec3 normal)
{
    return normalize(normal) * 0.5 + 0.5;
}

void main()
{
    vec3 normal = EncodeNormal(In.WolrdNormal.xyz);
    NormalOut = vec4(normal.xyz, 1.0);
}

G-Buffer から法線を取得するときは、サンプルした法線にデコード処理をかけます。例えば次のようになります。

vec3 DecodeNormal(in vec3 normal)
{
    return normal * 2.0 - 1.0;
}

void main()
{
    vec3 normalGBuffer = texture(NormalSampler, In.TextureCoord.xy).xyz;
    vec3 worldNormal = DecodeNormal(normalGBuffer);
}

G-Buffer の内容をクリアするとき、レンダーターゲットの初期色について少し注意が必要です。上記のエンコード方法では、長さ 0 の法線ベクトル vec3(0, 0, 0) はエンコード後 vec(0.5, 0.5, 0.5) となります。これは 256 色の RGB 値で (R, G, B) = (127, 127, 127) となるため、法線の初期値を零ベクトルにするときは、白でも黒でもなくグレーで塗りつぶすことになります。

法線のエンコード・デコード処理はレンダーターゲットのフォーマットに依存し、切っても切れない関係にあります。後述する R32G32B32A32_FLOATR16G16B16A16_FLOAT といった浮動小数点フォーマットを使う場合は特にデコード・エンコード処理を考える必要がなくなります。 G-Buffer に法線を格納する場合、エンコード・デコードにかかる処理時間や、G-Buffer を占めるフォーマットサイズ、法線の精度・エラー数などが評価対象になります。

深度

深度値を格納するにあたって、それなりにサイズの大きなフォーマットを使用しました。深度情報を扱う際に気をつけることとして、Near および Far クリップを適切に指定4する必要があります。深度値は、エッジ計算や被写界深度ブラーなどのポストプロセスエフェクトで使用する他、 後述する G-Buffer からワールド座標の位置を求めるときにも使用します。

深度値といっても、ビュー空間の深度 (A) や、射影変換後によって得られる深度 (B) などがあります。

vec4 worldPosition = matrices.Model * vec4(position.xyz, 1.0);
gl_Position = (matrices.Projection * (matrices.View * worldPosition));

// (A) View-space depth
float ViewSpaceDepth = (matrices.View * worldPosition).z;

// (B) Perspective depth
float ScreenSpaceDepth = gl_Position.z / gl_Position.w;

これらの深度値を G-Buffer に書き込む際は、レンダーターゲットのフォーマットに従って、エンコードする必要があります。例えば (A) の場合、ファークリップ面の距離(以下 FarClip)を利用して 0.0 から 1.0 にエンコードした後、G-Buffer に書き込みます。

void EncodeDepth(in float depth, in float farClip)
{
    return depth / farClip;
}

// PixelShader
void main()
{
    const float FarClip = 500.0;
    float depth = EncodeDepth(In.ViewSpaceDepth.x, FarClip);
    Out.Depth = vec4(depth);
}

サンプリングした深度をビュー空間の深度にデコードするときは FarClip をかけてあげます。

void DecodeDepth(in float depth, in float farClip)
{
    return depth * farClip;
}

// PixelShader
void main()
{
    const float FarClip = 500.0;
    float depthGBuffer = texture(AlbedoTexture, In.TextureCoord.xy).x;
    float viewSpaceDepth = DecodeDepth(depthGBuffer, FarClip);
}

(B) の深度値については、0.0 から 1.0 の範囲に収まっているので特にエンコードやデコードは必要ありません。今回は (A) の方法を利用しました。(A) と (B) それぞれの深度バッファを図 3 に示します。 NVIDIA GameWorks による Direct3D の遅延シェーディングサンプル8では (A) の方法を利用しているようです。

(図 3) 深度バッファの比較。左が (A) ビュー空間の深度、右が (B) 射影変換後の z/w から求める深度値。
(図 3) 深度バッファの比較。左が (A) ビュー空間の深度、右が (B) 射影変換後の z/w から求める深度値。

(A) の深度値を使って、(B) の深度値を求めることもできます。次のようにします。

float ToPerspectiveDepth(in float viewDepth)
{
    vec4 projectedPosition = (matrices.ViewToProjection * vec4(0, 0, viewDepth, 1.0));
    return projectedPosition.z / projectedPosition.w;
}

// PixelShader
void main()
{
    const float FarClip = 500.0;
    float depthGBuffer = texture(AlbedoTexture, In.TextureCoord.xy).x;
    float viewSpaceDepth = ToLinearDepthDecodeDepth(depthGBuffer, FarClip);
    float perspectiveDepth = ToPerspectiveDepth(viewSpaceDepth);
}

さらに、射影変換行列を展開して、ニアクリップ・ファークリップの距離から求めることも出来ます。次の例は、左手座標系の透視投影変換行列を基に (B) の深度値を計算しています。

float ToPerspectiveDepth(in float viewDepth)
{
    const float FarClip = 500.0;
    const float NearClip = 10.0;

    ///@note Left-hand coordinate system
    float a = FarClip / (FarClip - NearClip);
    float b = (NearClip * FarClip) / (NearClip - FarClip);
    float z = viewDepth * a + b;
    float w = viewDepth;
    return z / w;
}

深度情報からワールド空間の位置を求める

ランバートシェーディング (Lambertian Shading) などで、ジオメトリのワールド空間の位置を参照することがあります。そこで、G-Buffer からワールド空間の位置を取得できる必要があります。 表1 を見てわかる通り、G-Buffer にはワールド空間またはビュー空間の位置を格納していません。実装によっては、G-Buffer に位置を書き込むこともありますが、今回は深度情報とビュープロジェクションの逆行列から位置を求めることにしました。

vec3 DepthToPosition(in vec2 textureCoord, in float depth)
{
    vec4 projectedPosition = vec4(textureCoord.xy * vec2(2, -2) + vec2(-1, 1), 0.0, 1.0);

    vec3 viewPosition = (projectionToView * projectedPosition).xyz;
    vec3 viewRay = vec3(viewPosition.xy / projectedPosition.z, 1.0);
    return viewRay * depth;
}

// PixelShader
void main()
{
    float viewDepth = DecodeDepth(texture(tex3_DepthSampler, In.TextureCoord.xy).x, FarClip);
    vec3 viewPosition = DepthToPosition(In.TextureCoord.xy, viewDepth);
    vec3 worldPosition = (matrices.ViewToWorld * vec4(viewPosition, 1.0)).xyz;
}

EDIT (Nov 3, 2016)
この DepthToPosition の実装は誤りがあり、正しく深度からワールド空間の位置を復元できません。これについて次の記事を書きました。 "G-Buffer の深度値からワールド空間の位置を復元した秋 2016" をお読みください。

お手軽な実装としての浮動小数点バッファ

遅延シェーディングの実装をより単純にするには G-Buffer の各レンダーターゲットのフォーマットを R32G32B32A32_FLOAT のような 128-bit フォーマットにするとよさそうです。GL で言うところの GL_RGBA32UI, GL_RGBA32FGL_DEPTH_COMPONENT32F が該当します。特に GL_RGBA32F のように、1 コンポーネントあたり 32 ビット、4 コンポーネント合わせて 128 ビットとなる浮動小数点バッファを 128-bit (FP32) と表現することもあります。 0.0 から 1.0 の範囲内でしか表現できなかった 8-bit の UNORM とは異なり、FP325 を直接書き込めるため次のメリットがあります。

  • 1 コンポーネントあたり 32-bit もあるため精度が十分ある
  • 法線や深度値を 0.0 から 1.0 の範囲にエンコードせずに格納できる
  • ワールド空間またはビュー空間の位置をそのまま G-Buffer に格納できる

G-Buffer への書き込み・読み込み処理がとても簡素になり、GLSL や HLSL などのシェーダプログラムがよりシンプルになります。

短所として、帯域幅が 128-bit となり、R8G8B8A8 のような 32-bit フォーマットに比べて、フレームバッファのサイズが大きくなります。とてつもなく解像度の高いモバイル端末が普及している時代なので、GPU の性能(特にフィルレート)やディスプレイサイズによって使用するフォーマットを選択したほうが良さそうです。

とはいえ、最近のデスクトップで動かす分には問題ありません。例えば、OpenGL SuperBible では、この 128-bit (FP32) フォーマットを使用した遅延シェーディングのサンプルを公開6しています。わずらわしい法線のエンコード処理や、深度と逆行列からワールド空間の位置を求めるといった計算が必要ないため、チュートリアルやサンプルに適しています。

32-bit と 128-bit フォーマットの折衷案として、64-bit の浮動小数点フォーマット (FP16) を利用する方法があります。書籍『リアルタイムシャドウ』では現在のハードウェアの性能を考慮して著者の経験からこの FP16 フォーマットを推奨7しています。 また、NVIDIA GameWorks が公開している Direct3D 向けのサンプルアプリケーションでは、DXGI_FORMAT_R16G16B16A16_FLOAT のように 16-bit 4 成分の 64-bit フォーマットを G-Buffer に使用しています。FP32 と同様、FP16 ではわずらわしい法線のエンコード処理を特別必要としません。 今回は 32-bit フォーマットを使用しましたが、新たに実装する機会があれば 64-bit (FP16) フォーマットを使ってみたいです。

参考文献


  1. Epic Games が公開している SIGGRAPH 2012 の発表資料 The Technology Behind the "Unreal Engine 4 Elemental demo" では Classic deferred shading という語が出てきます。 

  2. Crytek が公開している Triangle Game Conference 2009 の発表資料 A bit more deferred - CryEngine 3 では Classic Deferred Rendering について言及しています。 

  3. 次の資料には MRT のフォーマットについて "All must have the same number of bits" そして "You can mix RTs with different number of channels" とあります。 http://http.download.nvidia.com/developer/presentations/2004/6800_Leagues/6800_Leagues_Deferred_Shading.pdf (PDF) 

  4. Near, Far クリップを適切に指定しないと、ポリゴンがちらつくことがあります。このピクセルのちらつきを artifact と言います。 

  5. 32-bit の浮動小数点数のこと。 

  6. GitHub の openglsuperbible/sb6code から Deferred Shading のサンプルコードを閲覧することが出来ます。 

  7. 『リアルタイムシャドウ』Elmar Eisemann, Michael Schwarz, Ulf Assarsson, Michael Wimmer 著、中本浩訳。日本語版 305 ページ参照。G-Buffer について「4 つの 16 ビットバッファを用いるとよい。」とあります。 

  8. GitHub で公開されている NVIDIA GameWorks のサンプルコードリポジトリ NVIDIAGameWorks/D3DSamples のこと。 

Leave a Reply