前置き
UE5.6のソースを眺めていたら、GBufferの読み書き部分の変更に気が付きました。そこで思いつきの緩い記事を書いたので、話半分に読んでもらえればと思います。
問題の部分は以下
// this is the new encode, the older encode is the #else, keeping it around briefly until the new version is confirmed stable.
#if 1
{
// change this so that we can pack everything into the gbuffer, but leave this for now
#if GBUFFER_HAS_DIFFUSE_SAMPLE_OCCLUSION
GBuffer.GenericAO = float(GBuffer.DiffuseIndirectSampleOcclusion) * (1.0f / 255.0f);
#elif ALLOW_STATIC_LIGHTING
// No space for AO. Multiply IndirectIrradiance by AO instead of storing.
GBuffer.GenericAO = EncodeIndirectIrradiance(GBuffer.IndirectIrradiance * GBuffer.GBufferAO) + QuantizationBias * (1.0 / 255.0); // Stationary sky light path
#else
GBuffer.GenericAO = GBuffer.GBufferAO; // Movable sky light path
#endif
EncodeGBufferToMRT(Out, GBuffer, QuantizationBias);
エンジン実装で#if 1なんてのを見るのは珍しい気がします。
で、実際にコールされる EncodeGBufferToMRT という関数ですが、シェーダーソースコードには存在しません。C++コードで生成されるみたいです。この関数名で検索をかけると、ShaderGenerationUtil.cppがヒットします。
// Quick note: There are lots of instances where we do something like:
//
// CurrLine = SomeComplicatedString();
// FullStr += CurrLine;
//
// We could instead make those run on a single line, as in:
//
// FullStr += SomeComplicatedString();
//
// The problem is it then becomes a nightmare to step through and debug the lines, which is why each line is fully created before appending.
static FString CreateGBufferEncodeFunction(const FGBufferInfo& BufferInfo)
{
FString FullStr;
FullStr += TEXT("void EncodeGBufferToMRT(inout FPixelShaderOut Out, FGBufferData GBuffer, float QuantizationBias)\n");
FullStr += TEXT("{\n");
コメントに書いてある
The problem is it then becomes a nightmare to step through and debug the lines, which is why each line is fully created before appending.
という文が面白いですね。
GBufferをシェーダーコードで改造していたので「なんだか面倒くさいことになったなあ」と思ったのですが、コードを読んでいくうちに、「これ、GBuffer構造をもっとお手軽に変更できるじゃん」となったので、軽く解説していきたいと思います。
GBufferアクセスコード構築を調べる
GBufferを構築して書き込んだり読み込んだりする関数を生成する機能は GBufferInfo.cpp/hに集約されています。GBuffer.hにそのためのEnumや構造体の定義が詰まっています。プログラマー的にはここに整理されているのでGBuffer構造を理解しやすくなりました。
FGBufferInfo RENDERCORE_API FetchFullGBufferInfo(const FGBufferParams& Params)
{
// For now, we are only doing legacy. But next, we will have a switch between the old and new formats.
if (IsMobileDeferred(Params))
{
return FetchMobileGBufferInfo(Params);
}
else
{
return FetchLegacyGBufferInfo(Params);
}
}
ここでFGBufferInfo構造体にGBuffer構造を構築しています。コメントから推察するに、今後多彩なGBufferフォーマットに対応していく布石に思えます。とりあえずは、従来のDeferredとMobile Deferredの分岐で構築されます。
カラーバッファの構築部分のコメントを見ても、暫定的な実装であることが伺えます。
// this value isn't correct, because it doesn't respect the scene color format cvar, but it's ignored anyways
// so it's ok for now
Info.Targets[TargetLighting].Init(GBT_Unorm_11_11_10, TEXT("Lighting"), false, true, true, true);
Info.Slots[GBS_SceneColor] = FGBufferItem(GBS_SceneColor, GBC_Raw_Float_11_11_10, GBCH_Both);
Info.Slots[GBS_SceneColor].Packing[0] = FGBufferPacking(TargetLighting, 0, 0);
Info.Slots[GBS_SceneColor].Packing[1] = FGBufferPacking(TargetLighting, 1, 1);
Info.Slots[GBS_SceneColor].Packing[2] = FGBufferPacking(TargetLighting, 2, 2);
とはいえ、やってることは明確でわかりやすい。SceneColorはR11G11B10フォーマットで3チャンネルをパッキングしてますよ。
描画にかかわるプログラマーは一通りコードを読むとUEのGBufferに関して理解が深まると思うので、ぜひ読んでみましょう。
ここで構築されたGBuffer構造をテキスト化してシェーダーコンパイルに挿入される仕組みになっているのですが、ログに出力してみるとこんな感じになっていました。
void EncodeGBufferToMRT(inout FPixelShaderOut Out, FGBufferData GBuffer, float QuantizationBias)
{
float4 MrtFloat1 = 0.0f;
float4 MrtFloat2 = 0.0f;
uint4 MrtUint2 = 0;
float4 MrtFloat3 = 0.0f;
float4 MrtFloat4 = 0.0f;
float4 MrtFloat5 = 0.0f;
float3 WorldNormal_Compressed = EncodeNormalHelper(GBuffer.WorldNormal, 0.0f);
MrtFloat1.x = WorldNormal_Compressed.x;
MrtFloat1.y = WorldNormal_Compressed.y;
MrtFloat1.z = WorldNormal_Compressed.z;
MrtFloat1.w = GBuffer.PerObjectGBufferData.x;
MrtFloat2.x = GBuffer.Metallic.x;
MrtFloat2.y = GBuffer.Specular.x;
MrtFloat2.z = GBuffer.Roughness.x;
MrtUint2.w |= ((((GBuffer.ShadingModelID.x) >> 0) & 0x0f) << 0);
MrtUint2.w |= ((((GBuffer.SelectiveOutputMask.x) >> 0) & 0x0f) << 4);
MrtFloat3.x = GBuffer.BaseColor.x;
MrtFloat3.y = GBuffer.BaseColor.y;
MrtFloat3.z = GBuffer.BaseColor.z;
MrtFloat3.w = GBuffer.GenericAO.x;
MrtFloat5.x = GBuffer.PrecomputedShadowFactors.x;
MrtFloat5.y = GBuffer.PrecomputedShadowFactors.y;
MrtFloat5.z = GBuffer.PrecomputedShadowFactors.z;
MrtFloat5.w = GBuffer.PrecomputedShadowFactors.w;
MrtFloat4.x = GBuffer.CustomData.x;
MrtFloat4.y = GBuffer.CustomData.y;
MrtFloat4.z = GBuffer.CustomData.z;
MrtFloat4.w = GBuffer.CustomData.w;
Out.MRT[1] = MrtFloat1;
Out.MRT[2] = float4(MrtFloat2.x, MrtFloat2.y, MrtFloat2.z, float(MrtUint2.w) / 255.0f);
Out.MRT[3] = MrtFloat3;
Out.MRT[4] = MrtFloat4;
Out.MRT[5] = MrtFloat5;
Out.MRT[6] = float4(0.0f, 0.0f, 0.0f, 0.0f);
Out.MRT[7] = float4(0.0f, 0.0f, 0.0f, 0.0f);
}
ShadingModelIDとか複数のパラメーターがビット単位でパッキングされている様子がわかります。
GBuffer構造を改造してみる
さて、実際にGBuffer構造を変更してみたいと思います。デフォルトではGBufferBにMetallic,Roughness,Specularを1バイトずつ格納するのですが、以前からそんなに精度いらないよなと思っていて、実際にMetallicとSpecularを4bitずつにして1バイト空けて他の用途に使う・・・なんてことをすでにやっていました。今まではシェーダーコードに手を入れて改造していたのですが、今後はC++コードの方でもっと楽にできそうです。
実際にコードを見るとEpic側でも同じようなことを考えているみたいで、#if分岐で無効にはなっていますが、それぞれ4bitでパッキングするコードが残っています。
Info.Slots[GBS_Metallic] = FGBufferItem(GBS_Metallic, GBC_Packed_Quantized_4, GBCH_Both);
Info.Slots[GBS_Metallic].Packing[0] = FGBufferPacking(TargetGBufferB, 0, 0, 0, 0, 4);
Info.Slots[GBS_Specular] = FGBufferItem(GBS_Specular, GBC_Packed_Quantized_4, GBCH_Both);
Info.Slots[GBS_Specular].Packing[0] = FGBufferPacking(TargetGBufferB, 0, 0, 0, 4, 4);
Info.Slots[GBS_Roughness] = FGBufferItem(GBS_Roughness, GBC_Packed_Quantized_4, GBCH_Both);
Info.Slots[GBS_Roughness].Packing[0] = FGBufferPacking(TargetGBufferB, 0, 1, 0, 0, 4);
これをさらにアグレッシブに、Metallic 2bit, Specular 2bit, Roughness 4bitにしてみます。
Info.Slots[GBS_Metallic] = FGBufferItem(GBS_Metallic, GBC_Packed_Quantized_2, GBCH_Both);
Info.Slots[GBS_Metallic].Packing[0] = FGBufferPacking(TargetGBufferB, 0, 0, 0, 0, 2);
Info.Slots[GBS_Specular] = FGBufferItem(GBS_Specular, GBC_Packed_Quantized_2, GBCH_Both);
Info.Slots[GBS_Specular].Packing[0] = FGBufferPacking(TargetGBufferB, 0, 0, 0, 2, 2);
Info.Slots[GBS_Roughness] = FGBufferItem(GBS_Roughness, GBC_Packed_Quantized_4, GBCH_Both);
Info.Slots[GBS_Roughness].Packing[0] = FGBufferPacking(TargetGBufferB, 0, 0, 0, 4, 4);
こうするとGBufferが2byte空いて他の用途に使えます。
「え、そんなことして描画ちゃんとできるの?」という当然の疑問が湧きます。ここからは筆者の見解ですが、「割と大丈夫です」
そもそも、Metallicは0か1かで非金属か金属かを定義するので、極論を言えば1bitでも良いし、実際そういうシェーダーシステムもあります。ただ実際には、錆びや汚れがついた金属や、金属と他の素材が複合している場合にいきなりパキっと切り替わるとエッジが汚くなったりするので中間値を使って馴染ませます。
Specularに関してはEpicのドキュメントにこんな記述があります。
Unreal Engine では、約 4% のスペキュラ反射を表す 「0.5」をデフォルト値 として使用します。これは、ほぼすべてのマテリアルに対して正確な値です。
実際のゲーム開発でもSpecularは0.5に固定してRoughnessで調整なんてことをしたりします。テクスチャの節約だったり作業の簡略化が目的ですが、Specularを固定して他の用途に回したりすることもあります。0.5が4%なので調整幅は0から8%となり、元々幅が狭いので、0%か4%かくらいの選択で十分だし。
Roughnessは物体の表面がどれくらいザラザラしてるかを表します。極端なことを言えばだいたいの質感はRoughnessだけで表現できます。
と、言う些か乱暴な考えで上記のように各パラメーターの精度をぐっと落としてみたらどうなるかを実際に比較してみたいと思います。
では、手っ取り早く結果です。題材としてはHillside Sample Projectを使用しました。
こちらが通常のMetallic,Specular,Roughnessそれぞれ8bitで表示したものです。
そして、こちらがMetallic 2bit, Specular 2bit, Roughness 4bitで表示したもの。
まあ、ほとんど違いはわかりません。壁の汚れの濃さが若干違うように見えるくらい。本当に精度下がってるの?という疑問に答えるためにGBufferをキャプチャした画像も挙げておきます。
RGBそれぞれにMetallic,Specular,Roughnessが格納されています。
こちらはGBufferのRチャネルだけにパッキングされているので赤い画像になってます。
正直に白状すると、筆者には精度を落としても違いがわかりません。アーティストがちゃんと見ると拡大したときの細部が気になったり、細かい問題があるのかもしれません。
2bitだと割り当てられる数値は 0, 0.33, 0.66, 1.0の4段階なので、Specularがちょうど0.5にならないのもちょっと問題です。なのでそこはシェーダー側でテーブルにしたり対数補正とかしても良いかもしれません。
まとめ
まあ、という感じの少々乱暴な記事でした。GBufferを無理に詰めてどんな良いことがあるかというと
- 空いた部分に他の情報を詰めることができる。マテリアルIDだったり、NPRのための追加情報など。筆者の場合はアウトラインの濃さやマスクに使ったりしてました
- もっと頑張って詰めればGBufferの枚数を減らすことも可能かも
- 完全なNPRなどで不要な情報を省くことで若干の高速化やメモリ削減になるかも
- 独自のレンダリングのためにGBuffer構造を大幅に変えることも可能?
ただし、UE5.6現在のこの辺の実装は過渡期の様なので今後大きく変わる予感がします。コード内のコメントからもそのあたりの事情が伺えます。なので、現状はちょっとしたネタ程度に捉えてもらえば良いかと思います。いずれエンジン内のハードコーディングではなく、ユーザー定義でGBuffer構造を定義できるようにするつもりなのかもしれません。