こんにちは、コア技術本部の瀬戸です。
私たちが OSS として公開している Instant Replay for Unity は、Unity で実行中のゲーム画面を常時録画し、任意のタイミングで直近 N 秒を動画として書き出すことができるライブラリです。
先日リリースした v1.4.0 では、iOS / macOS / Android でメモリ消費量が 20MiB 〜 30MiB 程度削減されています。今回はこのパフォーマンス改善の要である、テクスチャを直接ハードウェアエンコーダーに渡す仕組みについて解説します。
以前の実装
Instant Replay for Unity は以下のような手順で動画のエンコードを行います。
- 画面の描画内容をキャプチャした
RenderTextureを入力する - 出力動画サイズに合わせるため、GPU 上で別の
RenderTextureに対して縮小コピーを行う AsyncGPUReadbackでピクセルデータの取得を行う- 各プラットフォームが提供するビデオエンコーダー API に渡す
- 以上をフレームごとに繰り返す
RenderTexture の実際のデータは GPU が握っているため、そのままでは CPU からアクセスできません。RenderTexture のピクセルデータを CPU からアクセス可能な RAM にダウンロードするにはいくつかの方法がありますが、Instant Replay for Unity では AsyncGPUReadback を利用していました。しかし、利用するハードウェアエンコーダーの実装形態によっては、この AsyncGPUReadback を省略することでメモリ使用量を削減できる可能性があります。
ディスクリート GPU 上のハードウェアエンコーダーを使用する場合
PC などのディスクリート GPU 環境において、GPU 上にハードウェアエンコーダーが実装されていることがあります。Instant Replay for Unity の場合、これは AsyncGPUReadback で GPU から RAM にダウンロードしたピクセルデータを、エンコード時には再度 GPU にアップロードすることを意味します。CPU - GPU 間のデータ転送は PCIe 等を経由するため比較的コストが高く、レイテンシの増大を招きます。エンコード処理は全体を通してパイプライン化されているため、レイテンシの増大は定常的なメモリ消費量の増加につながります。

理想的には RAM を経由せず、GPU 上の RenderTexture から直接ハードウェアエンコーダーにデータを渡したいところです。

ユニファイドメモリ環境の場合
モバイルデバイスで多く見られるユニファイドメモリアーキテクチャの環境においては、CPU と GPU が共通の RAM を使用するため、AsyncGPUReadback は単純な RAM 上のコピーに過ぎません。通常はハードウェアエンコーダーも直接 RAM にアクセス可能であり、ディスクリート GPU 環境のようなデータ転送コストの問題は発生しません。
しかしこれもメモリ効率の観点では最適とはいえません。全体で見ると入力された RenderTexture を一旦別の RenderTexture に縮小コピーし、さらにそれを AsyncGPUReadback でコピーするという、二重のコピーが発生していることになります。

ディスクリート GPU 環境とは異なり AsyncGPUReadback に GPU から CPU へデータを転送するという役割はないため、AsyncGPUReadback による二段目のコピーはハードウェアのレベルでは無意味です。
RenderTexture のデータを直接ハードウェアエンコーダーに渡すことができれば、無駄なバッファの確保とコピーを減らせるはずです。

ディスクリート GPU でもユニファイドメモリでも、ハードウェアエンコーダーがビデオメモリに直接アクセスできるならば AsyncGPUReadback によるデータのダウンロードやコピーは原理的に不要です。RenderTexture のデータを、リードバックを行わずに直接ハードウェアエンコーダーに渡すことができれば、全体のメモリ使用量を削減できます。
新しい実装
Instant Replay for Unity は iOS / Android / macOS / Windows / Linux の 5 つのプラットフォームをサポートしていますが、特にメモリ要件の厳しい iOS / Android をターゲットにリードバックを回避する実装を導入しました。また iOS と完全に同一の実装で macOS もサポートできるため、macOS についても同様の改善が得られます。
iOS / macOS (Metal)
iOS / macOS 標準のビデオエンコーダー API である Video Toolbox で入力に使用される CVPixelBuffer は、GPU 上のテクスチャとメモリを共有することができます。ただし、そのためのテクスチャは専用の API CVMetalTextureCache を通じて作成する必要があります。
func CVMetalTextureCacheCreate( _ allocator: CFAllocator?, _ cacheAttributes: CFDictionary?, _ metalDevice: any MTLDevice, _ textureAttributes: CFDictionary?, _ cacheOut: UnsafeMutablePointer<CVMetalTextureCache?> ) -> CVReturn func CVMetalTextureCacheCreateTextureFromImage( _ allocator: CFAllocator?, _ textureCache: CVMetalTextureCache, _ sourceImage: CVImageBuffer, _ textureAttributes: CFDictionary?, _ pixelFormat: MTLPixelFormat, _ width: Int, _ height: Int, _ planeIndex: Int, _ textureOut: UnsafeMutablePointer<CVMetalTexture?> ) -> CVReturn
戻り値の CVMetalTexture を通して Metal の MTLTexture にアクセスできます。
func CVMetalTextureGetTexture(_ image: CVMetalTexture) -> (any MTLTexture)?
したがって、入力される RenderTexture からこの MTLTexture に対して内容をコピーする必要があります。
Unity はこのようなグラフィックス API 固有のテクスチャハンドルを Unity のテクスチャと相互に変換するための API をいくつか提供していますが、MTLTexture を RenderTexture に変換する手段は見つからなかったため、MTLTexture を Unity の描画パイプラインに持ち込むアプローチは採用できませんでした。
📝
Texture2D.CreateExternalTexture()を使ってMTLTextureをTexture2Dに変換することは可能ですが、Texture2Dに対して GPU から直接書き込むことはできません。Texture2DをさらにRenderTargetIdentifierに変換すればレンダーターゲットとして指定可能ですが、実際に描画を試みるとエラーが発生して失敗しました。
解決策としてソース側の RenderTexture の方を MTLTexture に変換し、Low-level Native Plug-in Interface (LLNPI) を利用してネイティブ側で描画を行うアプローチを採用しました。具体的には次のような流れです。
- Unity 側で
RenderTexture.GetNativeTexturePtr()を呼び出し、MTLTextureのポインタを取得する CommandBuffer.IssuePluginEventAndData()を利用してレンダリングスレッド上でコールバックを実行する- コールバック内で
CVMetalTextureCacheCreateTextureFromImage()を呼び出し、ターゲットとなるMTLTextureを作成する - LLNPI を通してソースからターゲットに Blit する描画コマンドを発行する
MTLCommandBuffer.addCompletedHandler()を利用して描画の完了を待機し、エンコーダーに渡す
LLNPI による描画コマンドの発行については以下の記事を参考に実装しました。

計測
iPhone 12 実機で Instruments の All Heap & Anonymous VM を確認し、以前の実装と比べて 20MiB - 30MiB 程度の削減が確認できました。(1280x1280, 30FPS)
Android (Vulkan)
Android ではグラフィックス API として OpenGL ES と Vulkan が利用可能です。今後は Vulkan への移行が進んでいくと考えられるため、 Instant Replay for Unity では Vulkan 向けにリードバックを回避する実装を導入しました。
Android 標準のビデオエンコーダー API である MediaCodec では、MediaCodec.createInputSurface() によって作成した Surface を通して、GPU から直接フレームをエンコーダーに渡すことができます。Surface は Android の画面表示ターゲットを表現するオブジェクトとしても利用されており、Vulkan から見ると通常の画面表示と同じ要領で Surface に対して描画を行えます。
ANativeWindow_fromSurface()を利用してSurfaceのネイティブ表現であるANativeWindowを取得するvkCreateAndroidSurfaceKHR()を利用してANativeWindowから Vulkan のVkSurfaceKHRを作成するVkSurfaceKHRをターゲットとするVkSwapchainKHRを作成するVkSwapchainKHRから取得したVkImageに対してレンダリングを行う

当初、Instant Replay for Unity では上記の手順で Surface に対して描画を試み、一部の端末では正常にエンコードが行われましたが、特定の端末 (特に Google Pixel など Tensor 系の SoC に実装された c2.exynos.h264.encoder) では FPS が極端に低下する問題が発生しました。Perfetto を使用してプロファイリングを行った結果、プロセス samsumg.hardware.media.c2 が CPU 時間を大量に消費しており、なんらかの低速なソフトウェア実装にフォールバックされていることが推測されましたが、原因までは特定できませんでした。

この問題は Vulkan の API を通して作成されるスワップチェーンの内部バッファが、なんらかの理由でハードウェアエンコーダーの要求を満たしていないことに起因するのではないかとの仮説に立ち、スワップチェーンを利用しないアプローチを模索しました。最終的に次のようなアプローチを採用しました。
Surfaceに対してImageWriterを作成するImageWriter.dequeueInputImage()を利用してImageを取得するImage.getHardwareBuffer()を利用してAHardwareBufferを取得するAHardwareBufferを Vulkan のVkImageとしてインポートするVkImageに対してレンダリングを行うImageWriter.queueInputImage()を利用してImageをエンコーダーに渡す

ImageWriter はスワップチェーンと同様に、内部で何枚かのバッファをローテートしながらバッファの受け渡しを行う仕組みを提供するクラスです。AHardwareBuffer は Android における GPU やその他のネイティブ API 間で共有可能なバッファを表現するオブジェクトです。Vulkan では VK_ANDROID_external_memory_android_hardware_buffer 拡張機能を利用して AHardwareBuffer を VkImage としてインポートできます。
このアプローチを採用した結果、スワップチェーンを利用した方法ではうまく動作しなかった端末でも正常に動作するようになりました。
実際の描画コマンドの発行には Metal 実装と同様に LLNPI を利用しています。Vulkan における LLNPI の使い方については Web 上にほとんど情報がなく、LLNPI のヘッダに書かれたコメントや、Unity が公開しているサンプルコードを頼りに実装しました。
計測
Google Pixel 6a 実機で Android Studio の Live Telemetry を確認し、以前の実装と比べて 20MiB - 30MiB 程度のメモリ使用量の削減が確認できました。(1920x1080, 30FPS)
おわりに
Instant Replay for Unity は汎用的な録画機能を提供していますが、社内では主に実機デバッグ時のバグレポートに直前の操作手順を記録した動画を添付する用途で、デバッグビルド限定で利用されています。リリースビルドとデバッグビルドでパフォーマンス条件に大きな差があっては困るので、軽量に画面録画できることが重要です。今回の改善によって特にメモリ使用量の面でパフォーマンスが向上したため、より快適に利用できるようになったと思います。
Instant Replay for Unity は引き続き OSS として開発中です。お気づきの点があれば Issue や PR をお寄せいただけますと幸いです。