このノートではレンダーパスを扱います。画面ではなくテクスチャへ描画する方法と、Metal の目立たないながら性能を決定づけるアクション設定について。
レンダーパスとは何か
一つの MTLRenderCommandEncoder が一つのレンダーパスです。アタッチメント(カラー、深度、ステンシル)の組をバインドし、それらに対して一連の描画を実行して終了します。別のターゲットへ描くには新しいエンコーダが必要です。
Swiftlet descriptor = MTLRenderPassDescriptor()
descriptor.colorAttachments[0].texture = offscreenTexture
descriptor.colorAttachments[0].loadAction = .clear
descriptor.colorAttachments[0].storeAction = .store
descriptor.colorAttachments[0].clearColor = MTLClearColor(red: 0, green: 0, blue: 0, alpha: 1)
descriptor.depthAttachment.texture = depthTexture
descriptor.depthAttachment.loadAction = .clear
descriptor.depthAttachment.storeAction = .dontCare // この後 depth は不要
descriptor.depthAttachment.clearDepth = 1.0
let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor)!
// ... 描画 ...
encoder.endEncoding()
load / store アクション:TBDR でもっとも重要なスイッチ
この二つの設定はデスクトップ GPU ではさほど影響しませんが、Apple のタイルベース遅延レンダリング(TBDR)アーキテクチャではメモリ帯域を直接左右します。
Apple の GPU は画面をタイルに分割し、描画中は各タイルのアタッチメントデータを完全にオンチップメモリ——小さいが極めて高速な SRAM——に置きます。パスの流れはこうです。
- load — デバイスメモリからタイルメモリへアタッチメントを読み込む
- タイルメモリ内ですべての描画を実行する
- store — タイルメモリをデバイスメモリへ書き戻す
load / store アクションが制御するのは、まさに手順 1 と 3 です。
| loadAction | 意味 |
|---|---|
.clear | デバイスメモリを読まず、タイルメモリをクリア値で埋める。最速 |
.load | デバイスメモリから既存の内容を読む。帯域コストがかかる |
.dontCare | 内容は未定義。全ピクセルを書くと保証できるときに最速 |
| storeAction | 意味 |
|---|---|
.store | デバイスメモリへ書き戻す |
.dontCare | 破棄する。帯域ゼロ |
.multisampleResolve | MSAA を解決し、解決後の結果だけを書き戻す |
重要な結論。深度バッファの storeAction はほぼ常に .dontCare にすべきです。 深度はパス内部の可視性判定に使われるものであり、後続のパスが読む(SSAO、ソフトパーティクル)のでないかぎり、デバイスメモリへ書き戻すのは純粋な無駄です。4K の depth32 バッファは 32 MB。毎フレーム一度書くだけで、不要な 2 GB/s の帯域になります。
同様に、MSAA のカラーアタッチメントは .store ではなく .multisampleResolve を使うべきです。4× MSAA の生アタッチメントは解決後の画像の 4 倍の大きさであり、書き戻す理由はありません。
Swiftdescriptor.colorAttachments[0].texture = msaaTexture
descriptor.colorAttachments[0].resolveTexture = drawable.texture
descriptor.colorAttachments[0].storeAction = .multisampleResolve
いずれも数行の変更で、二桁パーセントの改善が日常的に得られます。習慣にしてください。レンダーパスを開くたびに、各アタッチメントのこの二つのアクションをどうすべきか意識的に決める。
オフスクリーンレンダリング
テクスチャへの描画は、シャドウマップ、ポストプロセス、リフレクションプローブの基礎です。ターゲットは .renderTarget 用途を宣言する必要があります。
Swiftfunc makeRenderTarget(size: CGSize, format: MTLPixelFormat) -> MTLTexture {
let d = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: format,
width: Int(size.width), height: Int(size.height),
mipmapped: false)
d.usage = [.renderTarget, .shaderRead]
d.storageMode = .private
return device.makeTexture(descriptor: d)!
}
storageMode = .private は重要です。このテクスチャは GPU だけが使い、CPU が見る必要はないため、ドライバは圧縮タイルフォーマットなど最適な配置を自由に選べます。
iOS と Apple Silicon にはさらに強い選択肢があります。.memoryless です。この種のテクスチャはデバイスメモリをまったく占有せず、タイルメモリ内にのみ存在します。深度バッファ、MSAA の中間アタッチメント、G-Buffer の中間層はいずれも memoryless にできます。ただし単一のパス内で使われることが条件です。
Swiftd.storageMode = .memoryless
d.usage = .renderTarget // memoryless はパスをまたいで shaderRead できない
4K の深度バッファが 32 MB から 0 MB になります。モバイル機ではこれは実質的な節約です。
フレームの構成
典型的なフレームはパスの連鎖です。
Swiftfunc draw(in view: MTKView) {
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let drawable = view.currentDrawable else { return }
// Pass 1: シャドウマップ
encodeShadowPass(commandBuffer)
// Pass 2: メイン描画。HDR オフスクリーンテクスチャへ
encodeMainPass(commandBuffer, target: hdrTexture)
// Pass 3: ブルーム(複数のサブパスを含みうる)
encodeBloomPass(commandBuffer)
// Pass 4: トーンマッピングと drawable への出力
encodeTonemapPass(commandBuffer, target: drawable.texture)
commandBuffer.present(drawable)
commandBuffer.commit()
}
注目すべき点をいくつか。
すべてのパスが一つのコマンドバッファを共有します。 パスごとに作る理由はありません。サブミットには固定のオーバーヘッドがあり、同一バッファ内のパス間に必要な同期は Metal が自動で挿入します。
パス間の依存は暗黙です。 Metal が資源の読み書きを追跡し、バリアを挿入します。バリアを手で書く Vulkan とは対照的で、書くのははるかに楽ですが、同期コストが見えにくくなるという面もあります。どこで消えているかは GPU フレームキャプチャで確認する必要があります。
currentDrawable の取得はできるだけ遅らせます。 空き drawable を待ってブロックしうるためです。それに依存しない処理はすべて、取得前にエンコードしておきましょう。
HDR の中間テクスチャには .rgba16Float を使います。 8 ビットの drawable へ直接描くと、1.0 を超える値が既に切り捨てられているため、ブルームもトーンマッピングも意味を失います。
MTLHeap による一時資源の管理
ポストプロセスの連鎖には「一度使って捨てる」中間テクスチャが多数あります。毎フレーム生成・破棄するのは無償ではなく、すべて保持し続けるのはメモリの浪費です。MTLHeap がその答えです。あらかじめ確保したブロックの中で、テクスチャをエイリアスさせられます。
Swiftlet heapDescriptor = MTLHeapDescriptor()
heapDescriptor.size = 64 * 1024 * 1024
heapDescriptor.storageMode = .private
let heap = device.makeHeap(descriptor: heapDescriptor)!
let tempA = heap.makeTexture(descriptor: descA)!
// tempA を使い終えたら
tempA.makeAliasable()
// このメモリを新しいテクスチャが再利用できる
let tempB = heap.makeTexture(descriptor: descB)!
寿命が重ならない二つのテクスチャは、同じ物理メモリを共有できます。複雑なポストチェーンでは、中間テクスチャのメモリを半減させることが日常的にあります。これはまさに、現代のエンジンのレンダーグラフが行っていることでもあります。パス間の依存を解析し、資源のエイリアスと load/store アクションを自動的に導出する。
次回はシャドウマップを題材に、実際のマルチパスの流れを追います。