こんにちは、コア技術本部の瀬戸です。この記事では、先日リリースした Instant Replay for Unity の新機能 リアルタイムモード についてご紹介します。
Instant Replay for Unity について
Instant Replay for Unity は Unity で実行中のゲーム画面を常時録画し、任意のタイミングで動画として書き出すことができるライブラリです。Instant Replay for Unity の概要については以前の記事をご覧ください。
リアルタイムモード
Instant Replay for Unity v0.4.0 では新機能の リアルタイムモード によって録画中のディスク負荷を大幅に軽減できるようになりました。その効果や背景についてご紹介します。
従来の仕組み
従来の Instant Replay for Unity では、キャプチャしたフレームをJPEGで圧縮してからディスク上に一時保存し、録画停止時に全てのフレームをエンコードする仕組みでした。

この仕組みはシンプルで実装が容易である一方、時間あたりのディスク書き込みが大きいという問題がありました。JPEG はフレームごとに独立した画像として保存されるため、実際に書き出される MP4 (H.264) と比べて時間あたり・品質あたりに必要なデータ量が大きくなります。そのため、録画中は常にディスクに大量のデータを書き込む必要がありました。
リアルタイムモードの仕組み
今回新たに実装した リアルタイムモード では、録画中にリアルタイムでフレームを H.264 圧縮します。これによって一時保存において時間あたりに必要なデータサイズが大幅に削減されます。

リアルタイムモードでは一時保存に必要なデータサイズが小さくなったため、一時保存にはディスクを使用せず、全てオンメモリとする設計に変更しました。例えば、30 FPS, HD サイズ (1280x720) として、ビットレートは少し抑えめに 2.5 Mbps とした場合、1 秒あたりに必要なデータサイズは約 0.3 MB となります。これを 30 秒分保存したとしても 9 MB 程度であり、メモリ上に保存しても問題になりにくいでしょう。Instant Replay for Unity をバグの再現手順の記録のために使用しているのなら、2.5 Mbps でそこそこの画質は得られますし、9 MB は開発チームに共有する上でも取り扱いやすいサイズかと思います。
また、音声に関しては従来は PCM としてメモリ上に保持していましたが、リアルタイムモードでは AAC にエンコードしてからメモリ上に保持されるようになりました。これにより音声データのメモリ使用量も削減されています。
使い方
リアルタイムモードを利用するには、従来の InstantReplaySession の代わりに RealtimeInstantReplaySession を使用します。
// 録画開始 using var session = RealtimeInstantReplaySession.CreateDefault(); // 〜 ゲームプレイ 〜 await Task.Delay(10000, ct); // 録画停止と書き出し var outputPath = await session.StopAndExportAsync(); File.Move(outputPath, Path.Combine(Application.persistentDataPath, Path.GetFileName(outputPath)));
録画時間に関しては、従来のように秒数での指定ではなく、ビットレートとメモリ上に保存する最大サイズで指定します。これらの設定は RealtimeEncodingOptions で行えます。
// デフォルト設定 var options = new RealtimeEncodingOptions { VideoOptions = new VideoEncoderOptions { Width = 1280, Height = 720, FpsHint = 30, Bitrate = 2500000 // 2.5 Mbps }, AudioOptions = new AudioEncoderOptions { SampleRate = 44100, Channels = 2, Bitrate = 128000 // 128 kbps }, MaxMemoryUsageBytes = 20 * 1024 * 1024, // 20 MiB FixedFrameRate = 30.0, // 固定フレームレートを使用しない場合はnull VideoInputQueueSize = 5, // エンコード前の生のフレームを保持する数の上限 AudioInputQueueSize = 60, // エンコード前の生の音声サンプルフレームを保持する数の上限 }; using var session = new RealtimeInstantReplaySession(options)
実行時には、前述したようなエンコード済みのデータを保持するバッファに加えて、エンコード前の生のフレームや音声サンプルがいくつか保持されます。これはキャプチャからエンコードまでの処理をパイプライン化している関係で、あるフレームや音声サンプルをエンコードしている間に次のフレームをキャプチャしている場合があるためです。これらのバッファのサイズは VideoInputQueueSize と AudioInputQueueSize で指定できます。これらの値を小さくすることでメモリ使用量を削減できる場合がありますが、エンコードが間に合わなくなるとフレームドロップの可能性が高まります。
内部実装
バッファ
リアルタイムモードでも従来と同様に、バッファから溢れたフレームは古いものから順に破棄する制御を行っています。ただし、その際には H.264 の I フレーム と P フレーム を正しく扱う必要があります。
H.264 ではフレームの完全な情報を含み単体でデコード可能な I フレーム と、前のフレームからの差分情報のみを含む P フレーム が存在します。P フレームをデコードするには直前のフレームを参照する必要があるため、エンコーダーが出力したフレーム群の一部のみを動画ファイルにする際には、必ずどこかの I フレームを動画の開始位置とする必要があります。これは、I フレームの間隔が大きくなりすぎると、ユーザーが設定した動画時間と実際の動画時間に大きなズレが生じうることを意味します。そのため I フレームの間隔が大きくなりすぎないように制御することが必要です。
エンコーダーに送ったフレームが I フレームとしてエンコードされるか P フレームとしてエンコードされるかは基本的にエンコーダーの裁量によりますが、プラットフォームによっては最大の I フレーム間隔を指定することが可能です。リアルタイムモードでは (可能な場合には) 最大でも 1 秒間隔で I フレームが生成されるように設定しています。
📝 I フレーム、P フレームに加えて、前後の I フレームと差分情報を使用してデコードを行える B フレーム (双方向予測フレーム) も存在しますが、Instant Replay for Unity のリアルタイムモードでは使用していません。B フレームが存在する場合、フレームが記録される順番(デコードされる順番)と実際に表示される順番が一致しない可能性があり、バッファ上の破棄制御が複雑になるためです。
ネイティブ API の呼び出し
リアルタイムモードでも従来と同様に各プラットフォームのエンコーダー API を使用しています。
ただし、リアルタイムモードではエンコードされたフレームの情報に従来よりも深くアクセスする必要があります。
- フレームの種別:フレームが I フレームか P フレームかを判別する必要がある
- タイムスタンプ:多重化を行う際に、最初のフレームのタイムスタンプが 0 となるように各フレームのタイムスタンプを調整する必要がある
- フレームの生データ: フレームが実際に保持するデータのサイズを知る必要がある
従来の実装では、フレームを入力すると MP4 への多重化までまとめて行ってくれるハイレベルな API を使用しているプラットフォームがあり、これらの情報にアクセスできない場合がありました。これらのプラットフォームではより低レベルな API を使用するように実装を変更する必要がありました。
プラットフォーム抽象化
Instant Replay for Unity はプラットフォームごとに異なる API を使用しているため、どこかのレイヤーでプラットフォームの違いを吸収する必要があります。従来は C# のレイヤーで ITranscoder というインターフェースを切って抽象化を行っていました。しかし、これだとプラットフォームごとに別々に FFI を行う必要があり、コードが複雑になりやすい問題がありました。言語境界でメモリ安全性を保証するのは面倒ですし間違えやすいので、FFI は少ない方が望ましいです。

これに対し、リアルタイムモードでは全てのプラットフォームで FFI を一本化することにしました。具体的にはネイティブ実装を Rust による実装に統一し、ひとつのコードベースで全てのプラットフォームをサポートできるようにしました。C# ↔︎ Rust 間で呼び出される関数は全プラットフォームで共通になり、プラットフォームごとの抽象化は Rust 側で行われます。また、FFI では Cysharp/csbindgen を使用して C# 側のバインディングを自動生成しています。これによって FFI の呼び出しはより安全になり、保守しやすくなりました。

また、Rust は (C/C++ と比べて) 複数のプラットフォームをターゲットとすることが容易になる利点もあります。ビルド時にターゲット名を指定するだけでプラットフォームを切り替えることができるのは、Unity でマルチプラットフォームなネイティブプラグインを開発する上でとても便利です。
cargo build --release --target aarch64-apple-ios # iOS cargo build --release --target aarch64-apple-darwin # macOS cargo ndk build --release # Android
Rust から各プラットフォームの API を呼ぶ方法
iOS / macOS (VideoToolbox, AudioToolbox, AVFoundation)
objc2 クレートを利用しました。やや情報が少なく手探り感はありましたが、やりたいことは全て実現できました。
Android (MediaCodec, MediaMuxer)
jni クレートを利用して Java の API を呼び出しました。Unity の AndroidJNI クラスを経由せずにネイティブから直接 JNI を呼び出せるのか確証がありませんでしたが、やってみると問題なく動作しました。
JNI の呼び出しでは JVM のポインタが必要です。ネイティブプラグインで JNI_OnLoad という関数を公開すると、ネイティブプラグインがロードされた際に JVM のポインタを受け取ることができます。
#[no_mangle] pub unsafe extern "C" fn JNI_OnLoad(vm: *mut c_void, reserved: *mut c_void) -> c_int { /* ... */ }
初めは Java API ではなく NDK を利用しようと考えていたのですが、NDK の MediaCodec 関連の API は Java 側と比べて少し不足があったため見送りました。NDK を直接利用できた方がパフォーマンスや保守性の面で望ましいので、いつか NDK 側の API が拡充されることを期待しています。
Windows (Media Foundation)
windows クレートを利用して簡単に Media Foundation の API を呼び出すことができました。Media Foundation は COM ベースの API で、windows クレートは COM オブジェクトを安全に扱うための機能を提供しています。
展望
リアルタイムモードが実装されたことによって、フレームを一旦 JPEG で圧縮する必要がなくなり、技術的により自然な実装となりました。現在は依然として従来のモードを標準として位置付けていますが、将来的にはリアルタイムモードを標準としたいと考えています。
Instant Replay for Unity は引き続き OSS として開発中です。お気づきの点があれば Issue や PR をお寄せいただけますと幸いです。