T.TAO
ブログへ戻る
/9 min read/Graphics Engine

Metal #14 ポストプロセス

#ComputerGraphics#GraphicsEngine#Metal

このノートではポストプロセスを扱います。描画結果に対してスクリーン空間で行う一連の処理と、それを Metal でどうつなぐか。

HDR とトーンマッピング

ポストプロセスの前提は、8 ビットの drawable へ直接描くのではなく、HDR の中間テクスチャへ描画することです。

Swiftlet d = MTLTextureDescriptor.texture2DDescriptor(
    pixelFormat: .rgba16Float, width: w, height: h, mipmapped: false)
d.usage = [.renderTarget, .shaderRead, .shaderWrite]

理由は単純です。現実の輝度範囲は [0, 1] をはるかに超えます。太陽は白い紙より何桁も明るい。描画時にすべてを 1 で切り落とせば、ブルームは成立せず(露出過多の領域はすべて同じ 1.0 になる)、トーンマッピングも意味を失います。

トーンマッピングは HDR の範囲をディスプレイが表示できる範囲へ圧縮します。もっとも単純なのは Reinhard です。

MSLfloat3 reinhard(float3 c) { return c / (1.0 + c); }

数学的には綺麗ですが、ハイライトが白茶けます。すべての輝度を一様に圧縮するため、フィルムのような肩の柔らかいロールオフがないからです。現在の既定は ACES のカーブフィットです。

MSLfloat3 ACESFilm(float3 x) {
    const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
    return saturate((x * (a * x + b)) / (x * (c * x + d) + e));
}

暗部のコントラストを保ち、ハイライトで滑らかにロールオフし、フィルムを模した軽い色相シフトも含みます。いまや業界の事実上の既定と言ってよいでしょう。

トーンマッピングの前に露出値を掛けます。自動露出は輝度画像のミップ連鎖を作り、最終レベルから平均を読むことで実装できます。画面が明滅しないよう、時間方向のローパスフィルタを併用します。

ブルーム

ブルームは、明るい光源のまわりに実際のレンズが作るハローを模します。三段階——抽出、ぼかし、加算——の流れは《Unity Shader #13 ポストプロセス》で詳述したので、ここでは Metal 固有の点に絞ります。

現在の主流は単一半径のガウシアンではなく、段階的なダウンサンプリングと段階的なアップサンプリングです。

Plain Text1/2 ──> 1/4 ──> 1/8 ──> 1/16
                          │
1/2 <── 1/4 <── 1/8 <──────┘   (各レベルで同サイズのダウンサンプル結果と加算)

各レベルで小さな 13 タップのカーネルを、下りと上りで一度ずつ使います。結果として非常に広い到達範囲——巨大な半径のぼかしに相当——が、小カーネル数回のコストで得られます。しかも複数スケールのハローが重なるため、単一のガウシアンよりずっと自然に見えます。

ダウンサンプリング時はファイアフライに注意してください。孤立した非常に明るいピクセルは、目に見える四角いブロックになります。対処は最初のダウンサンプルで輝度加重平均(Karis average)を使うことです。

MSLfloat weight(float3 c) { return 1.0 / (1.0 + luminance(c)); }
// 算術平均ではなく加重平均

SSAO

スクリーンスペースアンビエントオクルージョンは、深度バッファから、各ピクセルが近傍のジオメトリにどれだけ遮蔽されているかを推定します。

基本のアルゴリズムはこうです。ピクセル周辺の法線半球内に複数のサンプル点を取り、スクリーン空間へ投影し、各サンプルの深度を深度バッファと比較します。記録された深度のほうが手前なら、その方向は遮蔽されています。

MSLkernel void ssao(texture2d<float> depthTex [[texture(0)]],
                 texture2d<float> normalTex [[texture(1)]],
                 texture2d<float, access::write> output [[texture(2)]],
                 constant SSAOParams &params [[buffer(0)]],
                 uint2 gid [[thread_position_in_grid]])
{
    float3 posVS = reconstructViewPos(gid, depthTex, params);
    float3 n     = normalTex.read(gid).xyz * 2.0 - 1.0;

    float occlusion = 0.0;
    for (uint i = 0; i < params.sampleCount; i++) {
        float3 samplePos = posVS + orientedHemisphereSample(i, n, gid) * params.radius;
        float2 sampleUV  = projectToUV(samplePos, params.projection);
        float  sceneZ    = linearDepth(depthTex.sample(s, sampleUV).r, params);

        // 範囲チェック:遠方のジオメトリが手前のピクセルを遮蔽してはならない
        float rangeCheck = smoothstep(0.0, 1.0, params.radius / abs(posVS.z - sceneZ));
        occlusion += (sceneZ >= samplePos.z + params.bias ? 1.0 : 0.0) * rangeCheck;
    }
    output.write(1.0 - occlusion / params.sampleCount, gid);
}

この rangeCheck は不可欠です。これがないと、手前の物体の縁が遠くの壁に偽の暗いハローを落とします。深度差の大きいサンプルまで遮蔽として数えてしまうためです。

サンプル数を大きくすることはできないので、SSAO の出力は本質的にノイジーです。標準的な処理は、4×4 のランダム回転テクスチャでノイズを高周波側へ追いやり、4×4 のぼかしで除去することです。これで 16 サンプルが 256 サンプル相当の品質に近づきます。

コンピュートかフルスクリーン三角形か

ポストプロセスは伝統的にフルスクリーン三角形とフラグメントシェーダーで行います。しかしここではコンピュートシェーダーのほうが有利なことが多いです。

スレッドグループメモリが使えます。 ぼかしカーネルでは、隣接スレッドが必要とするサンプルが大きく重なります。ブロックを協調的に threadgroup メモリへ読み込み、そこから読めば、テクスチャ帯域は数倍削減できます。

MSLkernel void blur_h(texture2d<float> src [[texture(0)]],
                   texture2d<float, access::write> dst [[texture(1)]],
                   uint2 gid [[thread_position_in_grid]],
                   uint2 lid [[thread_position_in_threadgroup]])
{
    threadgroup float4 cache[64 + 2 * RADIUS];

    // 両側の halo を含めて協調ロード
    cache[lid.x + RADIUS] = src.read(gid);
    if (lid.x < RADIUS) {
        cache[lid.x] = src.read(uint2(gid.x - RADIUS, gid.y));
        cache[lid.x + 64 + RADIUS] = src.read(uint2(gid.x + 64, gid.y));
    }
    threadgroup_barrier(mem_flags::mem_threadgroup);

    float4 sum = 0;
    for (int i = -RADIUS; i <= RADIUS; i++)
        sum += cache[lid.x + RADIUS + i] * kernelWeights[i + RADIUS];
    dst.write(sum, gid);
}

複数の出力を一度に書けます。 一つのカーネルが複数のテクスチャへ書き込めます。レンダーターゲット数やフォーマット一致の制約を受けません。

本当の scatter ができます。 フラグメントシェーダーは自分のピクセルしか書けませんが、コンピュートは任意の位置へ書けます。ヒストグラムや輝度のリダクションが可能になるのはこのためです。

レンダーパスの load/store コストがありません。 コンピュートエンコーダにアタッチメントはなく、タイルのロードも書き戻しも発生しません。

代償は固定機能のブレンドとラスタライザの最適化を失うことです。gather だけの単純な効果——トーンマッピング、カラーグレーディング——ではフルスクリーン三角形のほうが速いこともあります。

チェーンの構成

典型的な連鎖は次のとおりです。

Plain TextHDR scene ─> SSAO ─> bloom (down/up) ─> motion blur ─> tonemap + grading ─> FXAA/TAA ─> drawable

中間テクスチャは《Metal #9 レンダーパス》で述べた MTLHeap で管理し、重ならない結果どうしでメモリを共有させます。またトーンマッピングとカラーグレーディングは一つのパスにまとめ、色変換全体を 3D LUT として表現しましょう。フルスクリーンの読み書きを一往復省けます。4K ではこれは小さな額ではありません。

次回は反射と屈折を扱います。

このシリーズの記事

Metal
  1. 01Metal #0 Swift 復習
  2. 02Metal #1 初期化
  3. 03Metal #2 レンダリングパイプライン
  4. 04Metal #3 頂点関数
  5. 05Metal #4 フラグメント関数
  6. 06Metal #5 テクスチャ
  7. 07Metal #6 カメラと操作
  8. 08Metal #7 ライティング
  9. 09Metal #8 マテリアル
  10. 10Metal #9 レンダーパス
  11. 11Metal #10 シャドウ
  12. 12Metal #11 遅延レンダリング
  13. 13Metal #12 パーティクルシステム
  14. 14Metal #13 テッセレーション
  15. 15Metal #14 ポストプロセス
  16. 16Metal #15 反射と屈折
  17. 17Metal #16 アニメーション
  18. 18Metal #17 レイトレーシング(一)レンダリングアルゴリズム
  19. 19Metal #18 レイトレーシング(二)影とライティング
  20. 20Metal #19 レイトレーシング(三)性能最適化
  21. 21Metal #21 [付録] Compute Shader
  22. 22Metal #22 [付録] SwiftUI における Metal