CORETECH ENGINEER BLOG

株式会社サイバーエージェント SGEコア技術本部 技術ブログ

Unity でテクスチャをハードウェアエンコーダーに直接渡して録画する (iOS/Android)

こんにちは、コア技術本部の瀬戸です。

私たちが OSS として公開している Instant Replay for Unity は、Unity で実行中のゲーム画面を常時録画し、任意のタイミングで直近 N 秒を動画として書き出すことができるライブラリです。

先日リリースした v1.4.0 では、iOS / macOS / Android でメモリ消費量が 20MiB 〜 30MiB 程度削減されています。今回はこのパフォーマンス改善の要である、テクスチャを直接ハードウェアエンコーダーに渡す仕組みについて解説します。

以前の実装

Instant Replay for Unity は以下のような手順で動画のエンコードを行います。

  1. 画面の描画内容をキャプチャした RenderTexture を入力する
  2. 出力動画サイズに合わせるため、GPU 上で別の RenderTexture に対して縮小コピーを行う
  3. AsyncGPUReadbackピクセルデータの取得を行う
  4. 各プラットフォームが提供するビデオエンコーダー API に渡す
  5. 以上をフレームごとに繰り返す

RenderTexture の実際のデータは GPU が握っているため、そのままでは CPU からアクセスできません。RenderTextureピクセルデータを CPU からアクセス可能な RAM にダウンロードするにはいくつかの方法がありますが、Instant Replay for Unity では AsyncGPUReadback を利用していました。しかし、利用するハードウェアエンコーダーの実装形態によっては、この AsyncGPUReadback を省略することでメモリ使用量を削減できる可能性があります。

ディスクリート GPU 上のハードウェアエンコーダーを使用する場合

PC などのディスクリート GPU 環境において、GPU 上にハードウェアエンコーダーが実装されていることがあります。Instant Replay for Unity の場合、これは AsyncGPUReadbackGPU から RAM にダウンロードしたピクセルデータを、エンコード時には再度 GPU にアップロードすることを意味します。CPU - GPU 間のデータ転送は PCIe 等を経由するため比較的コストが高く、レイテンシの増大を招きます。エンコード処理は全体を通してパイプライン化されているため、レイテンシの増大は定常的なメモリ消費量の増加につながります。

ディスクリート GPU 環境におけるリソース受け渡しのイメージ (改善前)

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

ディスクリート GPU 環境におけるリソース受け渡しのイメージ (改善後)

ユニファイドメモリ環境の場合

モバイルデバイスで多く見られるユニファイドメモリアーキテクチャの環境においては、CPU と GPU が共通の RAM を使用するため、AsyncGPUReadback は単純な RAM 上のコピーに過ぎません。通常はハードウェアエンコーダーも直接 RAM にアクセス可能であり、ディスクリート GPU 環境のようなデータ転送コストの問題は発生しません。

しかしこれもメモリ効率の観点では最適とはいえません。全体で見ると入力された RenderTexture を一旦別の RenderTexture に縮小コピーし、さらにそれを AsyncGPUReadback でコピーするという、二重のコピーが発生していることになります。

ユニファイドメモリ環境におけるリソース受け渡しのイメージ (改善前)

ディスクリート GPU 環境とは異なり AsyncGPUReadbackGPU から 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 をいくつか提供していますが、MTLTextureRenderTexture に変換する手段は見つからなかったため、MTLTexture を Unity の描画パイプラインに持ち込むアプローチは採用できませんでした。

📝 Texture2D.CreateExternalTexture() を使って MTLTextureTexture2D に変換することは可能ですが、Texture2D に対して GPU から直接書き込むことはできません。Texture2D をさらに RenderTargetIdentifier に変換すればレンダーターゲットとして指定可能ですが、実際に描画を試みるとエラーが発生して失敗しました。

解決策としてソース側の RenderTexture の方を MTLTexture に変換し、Low-level Native Plug-in Interface (LLNPI) を利用してネイティブ側で描画を行うアプローチを採用しました。具体的には次のような流れです。

  1. Unity 側で RenderTexture.GetNativeTexturePtr() を呼び出し、MTLTexture のポインタを取得する
  2. CommandBuffer.IssuePluginEventAndData() を利用してレンダリングスレッド上でコールバックを実行する
  3. コールバック内で CVMetalTextureCacheCreateTextureFromImage() を呼び出し、ターゲットとなる MTLTexture を作成する
  4. LLNPI を通してソースからターゲットに Blit する描画コマンドを発行する
  5. 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 から直接フレームをエンコーダーに渡すことができます。SurfaceAndroid の画面表示ターゲットを表現するオブジェクトとしても利用されており、Vulkan から見ると通常の画面表示と同じ要領で Surface に対して描画を行えます。

  1. ANativeWindow_fromSurface() を利用して Surface のネイティブ表現である ANativeWindow を取得する
  2. vkCreateAndroidSurfaceKHR() を利用して ANativeWindow から Vulkan の VkSurfaceKHR を作成する
  3. VkSurfaceKHR をターゲットとする VkSwapchainKHR を作成する
  4. VkSwapchainKHR から取得した VkImage に対してレンダリングを行う

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

録画時のPerfettoトレース。当該プロセスにコア一つが占有されている

この問題は Vulkan の API を通して作成されるスワップチェーンの内部バッファが、なんらかの理由でハードウェアエンコーダーの要求を満たしていないことに起因するのではないかとの仮説に立ち、スワップチェーンを利用しないアプローチを模索しました。最終的に次のようなアプローチを採用しました。

  1. Surface に対して ImageWriter を作成する
  2. ImageWriter.dequeueInputImage() を利用して Image を取得する
  3. Image.getHardwareBuffer() を利用して AHardwareBuffer を取得する
  4. AHardwareBuffer を Vulkan の VkImage としてインポートする
  5. VkImage に対してレンダリングを行う
  6. ImageWriter.queueInputImage() を利用して Imageエンコーダーに渡す

ImageWriterスワップチェーンと同様に、内部で何枚かのバッファをローテートしながらバッファの受け渡しを行う仕組みを提供するクラスです。AHardwareBufferAndroid における GPU やその他のネイティブ API 間で共有可能なバッファを表現するオブジェクトです。Vulkan では VK_ANDROID_external_memory_android_hardware_buffer 拡張機能を利用して AHardwareBufferVkImage としてインポートできます。

このアプローチを採用した結果、スワップチェーンを利用した方法ではうまく動作しなかった端末でも正常に動作するようになりました。

実際の描画コマンドの発行には 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 をお寄せいただけますと幸いです。