このノートでは遅延レンダリングと、Apple Silicon 上で Metal だけが実現できるその一形態を扱います。
なぜ遅延させるのか
フォワードレンダリングのコストは O(オブジェクト数 × 光源数) です。さらに悪いことに、遮蔽されるフラグメントもライティング計算を完走してから深度テストで捨てられます。オーバードローが増えるほど無駄も増えます。
遅延レンダリングはシェーディングを二段階に分けます。
- ジオメトリパス — 各ピクセルの表面属性(albedo、法線、roughness、深度)を一組のテクスチャに書き込みます。これらをまとめて G-Buffer と呼びます。ここでは属性だけを書き、ライティングは行いません。
- ライティングパス — 画面上の各ピクセルについて属性を読み戻し、一度だけライティングします。
各ピクセルはちょうど一度だけシェーディングされ、シーンの複雑さに依存しません。コストは O(オブジェクト数 + 光源数 × 被覆ピクセル) となり、数百の動的光源が一気に現実的になります。
G-Buffer の構成
G-Buffer の設計は遅延レンダリングの中心的なトレードオフです。保存するデータが多いほど柔軟になり、帯域も線形に増えます。
実用的な配置は次のとおり。
| アタッチメント | フォーマット | 内容 |
|---|---|---|
| 0 | .rgba8Unorm_srgb | albedo.rgb, AO |
| 1 | .rgba16Float | normal.xy(八面体符号化), roughness, metallic |
| 2 | .depth32Float | 深度 |
いくつかの判断は説明に値します。
ワールド座標は保存しません。 位置は深度とスクリーン座標から復元できます。保存すればピクセルあたり 12 バイトを無駄にするだけです。
MSLfloat3 reconstructWorldPos(float2 uv, float depth, float4x4 invViewProj) {
float4 ndc = float4(uv * 2.0 - 1.0, depth, 1.0);
ndc.y = -ndc.y;
float4 world = invViewProj * ndc;
return world.xyz / world.w;
}
法線は八面体符号化します。 単位ベクトルの自由度は 2 なので、3 成分は冗長です。八面体写像は単位球面を正方形へ写し、16 ビット 2 成分で 8 ビット 3 成分よりはるかに良い精度が得られます。
MSLfloat2 octEncode(float3 n) {
n /= (abs(n.x) + abs(n.y) + abs(n.z));
float2 e = n.xy;
if (n.z < 0) e = (1.0 - abs(e.yx)) * sign(e);
return e * 0.5 + 0.5;
}
G-Buffer の storeAction。 ライティングパスが同一フレーム・同一エンコーダで直後に消費するなら、Apple GPU では .dontCare にできます。これが次節の要点です。
ライティングパス
ライティングパスはフルスクリーンの三角形を描き、G-Buffer を読み、光源について累積します。
三角形二枚のクアッドではなく一枚の三角形を使うのは、クアッドの対角線上の 2×2 ブロックが両方の三角形で一度ずつシェーディングされ、無駄な継ぎ目が生じるからです。画面を覆う一枚の大きな三角形にはその継ぎ目がなく、頂点バッファも不要です。
MSLvertex VertexOut fullscreen_vertex(uint vid [[vertex_id]]) {
float2 uv = float2((vid << 1) & 2, vid & 2);
VertexOut out;
out.position = float4(uv * float2(2, -2) + float2(-1, 1), 0, 1);
out.uv = uv;
return out;
}
有限の range を持つポイントライトとスポットライトにはさらなる最適化があります。フルスクリーンではなく光源のバウンディング球や円錐を描き、ボリューム内のピクセルだけをシェーディングします。ステンシルテストと組み合わせれば、ボリュームの外側と背後のピクセルへの無駄な処理も避けられます。
遅延レンダリングの代償
遅延レンダリングは無償ではありません。
- 半透明が扱えません。 G-Buffer はピクセルあたり一つの表面しか保持できません。半透明オブジェクトはあとからフォワードで描く必要があり、エンジンは二系統のシェーディング経路を抱えることになります。
- MSAA が非常に高価になります。 ハードウェア MSAA はラスタライズ時に働きますが、遅延シェーディングはその後に行われます。正しく MSAA を行うにはライティングパスでサンプルごとにシェーディングする必要があります。遅延レンダリングのエンジンがこぞって FXAA や TAA へ移った理由です。
- 帯域。 G-Buffer を書いて読み戻すのは実際のメモリトラフィックです。モバイルではこれが決定的な不利になることが少なくありません。
- マテリアルモデルが制約されます。 G-Buffer の配置は固定なので、追加パラメータを要する素材——肌、髪、布——にはマテリアル ID と分岐が必要になります。
Apple Silicon:シングルパス遅延レンダリング
最後に挙げた帯域の不利は、Apple GPU ではほぼ完全に取り除けます。
TBDR アーキテクチャでは、描画中は各タイルのアタッチメントデータがオンチップメモリにあります。Metal はこれを公開しています。同一レンダーパス内の後続の描画は、タイルメモリからアタッチメント値を直接読めるのです。デバイスメモリを往復する必要がありません。
条件は三つ。
1. G-Buffer のテクスチャを memoryless で宣言する。
Swiftd.storageMode = .memoryless
d.usage = .renderTarget
これでデバイスメモリをまったく占有しなくなります。
2. ジオメトリパスとライティングパスを同一のレンダーパスディスクリプタに置く。 二つの描画グループとし、あいだで endEncoding() を呼ばないこと。
3. ライティングのフラグメント関数は、テクスチャサンプリングではなく [[color(n)]] 入力でアタッチメントを読み戻す。
MSLstruct GBufferData {
float4 albedo [[color(0)]];
float4 normal [[color(1)]];
float depth [[color(2)]];
};
fragment float4 deferred_lighting(VertexOut in [[stage_in]],
GBufferData gbuffer, // タイルメモリから直接読む
constant Light *lights [[buffer(13)]])
{
float3 n = octDecode(gbuffer.normal.xy);
// ... ライティング ...
}
結果として G-Buffer はチップの外へ出ません。帯域コストはほぼゼロになり、32 MB のデバイスメモリも節約できます。これは Metal が他 API に対して持つ実質的な利点です。Vulkan の subpass は似た意図を表現しますが、デスクトップ GPU では実際にデータをオンチップに留めるわけではありません。
さらに進んで tile shading を使えば、同一パス内にコンピュートディスパッチを挿入し、タイルメモリを直接操作できます。タイル単位の光源カリングはこの方式で実装でき、あるタイルの光源リストを計算して、そのままそのタイルのライティング描画が消費します。途中でデバイスメモリに触れることは一度もありません。
遅延させないほうがよい場合
潮流はむしろ揺り戻しています。Forward+(タイルフォワード)は、半透明と MSAA への対応を保ったまま、コンピュートによるカリングで多光源問題を解決します。Apple Silicon ではハードウェアがもともとタイル単位で動くため Forward+ は自然に収まり、既定としてはこちらが優ることが多いです。
遅延が依然として有効なのは、光源が非常に多く、その大半が小さく動的な場合(夜の街)や、スクリーン空間の効果を多用する場合——SSAO、SSR、スクリーンスペースシャドウ——です。これらはどのみち深度と法線のバッファを必要とし、G-Buffer がそれを与えてくれます。
次回はパーティクルシステムを扱い、初めてコンピュートシェーダーでシミュレーションを行います。