はじめに
モバイルゲーム開発において、実機デバッグ中に何か問題が起きた際、一連のデバッグログをまとめてSlack(あるいはそれに類するアプリケーション)の専用チャンネルに送信して開発メンバーに共有する...なんて運用は、割と一般的に使われているかと思います。
更にそのログにスクリーンショットを添付する程度であれば、特に悩むことなく可能です。けれども、折角であれば直近数十秒のプレイ中動画が自動的にデバッグログに添付出来たら不具合状況の共有具合が綿密になってとても便利ですよね。
というわけで、実装してみましょう。
(ちなみに最近のモバイル向けOSであれば標準機能で動画撮影があり、これを利用するのが何の工数も発生しないのでプログラマ的には一番ラクではあります。が、デバッグ中にいちいちコントロールセンターを開いて撮影開始ボタンを押して、いちいちカメラロールから撮影した動画を探して添付して...といった不毛な手間が気になるので今回は考慮に入れません。とはいえ、現状の実際のゲーム開発の現場では普通にこれで運用されてそうな気がしますね)
概説
実際の処理の流れは以下のようになります。あ、新しめのUnity+URP環境が前提ですよ。
- ゲーム画面のスクリーンキャプチャを行う
- キャプチャ画像の解像度を、扱いに困らないサイズに縮小する
- 非同期GPUリードバックを行いメインメモリに転送する
- 非同期でjpgへのエンコードを行う
-
必要に応じたタイミングで、ストレージにjpgを非同期で連番出力
[扱いに困らないフレームレートを維持するフレーム間隔で1-5を繰り返す] - FFmpegKitで連番画像を動画に変換し、Slackに投げる
まず実機上での動画キャプチャですが、直接ゲーム画面をmp4で撮影するような機能がUnityに存在するわけではありません。ので、スクリーンキャプチャの連番画像を作成しそれを動画化する、という手順を取ります。
ここで重要となるのは、可能な限り非同期処理を徹底するという点です。
今回の一連の処理はモバイル実機で行うには負荷が高く、何も考えずに作ってしまうとメインスレッドを占有してしまい、肝心のゲームがカクついたりフレームレートが低下してしまったりします。可能な限り非同期APIを呼び、可能な限りサブスレッドに逃がすようにしましょう。まあ今更言われなくてもゲームプログラマであれば「メインスレッドで重たいことはしちゃダメ」なんて話は億万回聞いているとは思いますが、一応...
というわけで、一つ一つのフローを簡単に解説をしていきます。
1.ゲーム画面のスクリーンキャプチャを行う
ScreenCapture.CaptureScreenshotIntoRenderTexture を利用するか、RendererFeatureのRenderingData.cameraData.renderer.cameraColorTargetを拾います。RenderPassEventをBeforeRenderingPostProcessing辺りにしておけばレンダリング結果をそのまま使えます。
次の作業でRendererFeatureを使うので後者の方が一見するとシンプルなのですが、"Screen Space - Overlay"なCanvasなどURP管理外な描画(シンプルに描画したUIなど)が含まれないことに注意が必要です。
私はUI描画を専用のRenderTextureに描画する仕組みを用意していますのでRendererFeature内でUI合成のコンポジットを行っていますが、まあややこしいことを考えたくなければ(RenderTextureの取り回しが面倒ですが)前者のCaptureScreenshotIntoRenderTextureを使いましょう。
2.キャプチャ画像の解像度を、扱いに困らないサイズに縮小する
フル解像度をそのままぶん回すのはパフォーマンス的に無理がある為、RendererFeatureを使ってスクリーンキャプチャ画像を適度なサイズに縮小します。
拘りたいのであれば、ComputeShaderを用いて平均画素法などのリサイズアルゴリズムを適用するとかしても良いかと思いますが、そこまで頑張る理由がなければPixelShaderでただのBilinear縮小、でも特に問題はないでしょう。
3.非同期GPUリードバックを行いメインメモリに転送する
AsyncGPUReadback.Request を利用します。私の知る限り、Unity標準機能を使う限りにおいて非同期でGPUリードバックを行う選択肢は他にありません。
他にGPUリードバックを伴うAPIは以下に列挙するようなものがありますが、そのどれもがメインスレッドをブロックしますので今回の用途には不適です。使うべきではありません。
- ScreenCapture.CaptureScreenshot
- Texture2D.ReadPixels
- GraphicsBuffer.GetData / ComputeBuffer.GetData
4.非同期でjpgへのエンコードを行う
AsyncGPUReadback.Request の完了時に手に入るGetData()のNativeArrayを使い、ImageConversion.EncodeArrayToJPG を使ってjpgへエンコードします。画質重視ならpngでも構いませんが、処理負荷やストレージ負荷を考えるとまあjpgが妥当でしょう。
EncodeArrayToJPGはUnityにしては珍しく!スレッドセーフなAPIなので、サブスレッドに逃がすことが出来ます。またEncodeArrayToJPGはNativeArrayに対応しておらずArrayのみを受け付けますので、先にToArray()で変換コピーしておきます。
ここで一つ重要なポイント。GetData()から手に入るNativeArrayは少し特殊で、(本来NativeArrayはユーザーが自前でDisposeしなければならないことになっていますが)次フレームのタイミングでUnityEngine側で自動的に開放処理が呼ばれる仕組みになっているようです。
つまりDisposeを呼ぶ必要はなく、それ自体は良いのですが、次フレームで勝手に開放されてしまうので今回のような非同期処理だと(フレームを跨いでしまった場合に)挙動が不安定になる可能性があります。ですので、仕方なくToArray()での変換コピーはメインスレッドで行うようにしています。メインスレッドで重たいことは極力やりたくないのですけどね...。
5.必要に応じたタイミングで、ストレージにjpgを非同期で連番出力
File.WriteAllBytes を用いてjpgをストレージに出力します。EncodeArrayToJPGと同じサブスレッド内で呼び出すので、File.WriteAllBytesAsync である必要はありません。ファイル名は連番画像として読み出せるように適当に決めておきます。
この出力は非同期で呼んでいてもI/Oリソースをだいぶ阻害してしまうので、本筋のゲームへ負荷が掛かってしまうリスクがあります。特に低スペック端末ではこれがカクつきの原因になりかねません。
jpgエンコード後すぐに行うのではなく、メモリにjpgを溜めておいて余裕のあるタイミングを見計らって一気にストレージへ出力するとこの問題は解決しますが、このやり方の場合、アプリがクラッシュした際にメモリに溜めている分のjpgが飛びます。良し悪しなのでタイトル毎に判断しましょう。
ここまでの処理は基本的にほぼScriptableRenderPass.Execute()内で完結するかと思います。
X.1-5を繰り返す
以上の処理を毎フレーム呼び出す、というのは負荷の面で厳しいですし、そもそもバグ報告用の動画であればそこまで高いフレームレートは不要かと思います。扱いに困らない、適度なフレーム間隔で繰り返します。
もちろん、次フレーム分のキャプチャを開始する時点でまだjpgエンコードが終わっていなければ当然スキップします。
また、古い連番画像は削除しないとストレージが溜まっていく一方なので、過去数十秒を超える不要な分は適宜削除します。
6.FFmpegKitで連番画像を動画に変換し、Slackに投げる
モバイルでFFmpegを利用する為に"FFmpegKit"をアプリに組み込み、連番画像を動画に変換します。
FFmpegKitはFFmpeg同等のオプションを利用出来るので、例えば20fpsのmp4動画を作るなら
"-r 20 -i f%07d.jpg -vcodec libx264 debugout.mp4"
みたいなフォーマットでランタイムで動画を作成出来ます。FFmpegのオプションはネット上にいくらでも情報が転がっていますから各自ググりましょう。
後はこの動画をSlackに放り投げるだけです。簡単ですね。
この動画作成/送信に関しては実際にバグ報告のログを転送するタイミングで行うでしょうから、メインスレッドのブロック云々は気にしなくて良いので気楽ですね。
なお、もしFFmpegKitを組み込むことがゲーム製作上の諸々の理由で問題になるのであれば、連番画像をzipにでも固めてSlackに投げ、ActionsでFFmpegなりImageMagickなりをキックして動画/AnimeGIFを生成する、みたいな仕組みを用意するのが良いかと思います。
動画撮影は出来た。で?
さて肝心の使い方ですが、基本的にデバッグビルドであれば常時キャプチャを続けておきます。そしてバグが発生した際など任意のタイミングで、デバッグログをSlackに投げるのと一緒に、直近数十秒分の連番画像を動画化して添付します。
またアプリがクラッシュした際には、次にアプリを起動したタイミングで、残っている連番画像を用いてクラッシュ直前までの動画を送信するなんてことが可能です。このようなクラッシュレポート機能が不要であり、かつ端末のメモリに余裕があるのであれば、jpgをわざわざストレージに出力せずメモリに溜め込むだけ、みたいな運用も有りだと思います。タイトルによってデバッグの方向性は変わってくるのは自明ですので、臨機応変にいきましょう。
システムの流用
今回の「ゲームプレイの邪魔にならないキャプチャ画像出力」という仕組みは、別の使い道が結構あります。
例えば独自のプロファイリングログを計測する際のサムネイル画像作成に使えます。Unity標準のProfilerログはサムネイル画像が用意されておらず、どのシーンをキャプチャしたものなのか分かりにくいんですよね。そういったこともあり、私は独自のプロファイラを常時走らせており、そのサムネイル画像をこのシステムで生成しています。このプロファイラについてのエントリもいつか新調するかもしれません。
また、スパイク/ヒッチングが確認されたフレームのスクリーンショットを撮影しておくことでパフォーマンス上怪しい箇所を特定しやすくしたり、なんて使い方も出来ますね。
といった感じで、アイディア次第で利用価値は結構あるはずです。
ただし、リリース版でゲーム画面のキャプチャ画像を利用したいといった状況では、今回のシステムとは別の専用の仕組みを用意した方が良いかとは思います。あくまでデバッグに特化したものとして用意しています。
さいごに
今回はデバッグ時のバグ報告を主目的としたゲーム画面の動画キャプチャを実現する方法について解説しました。
実際のコード自体は小規模なもので大層なものではありませんが、ゲーム中に常時走らせることを前提とする性質上、限界まで負荷を軽くする必要があり、その配慮には結構気を遣っているんですよ、なんてことを読み取ってもらえると、このエントリを書いた甲斐があるというものです。
あと今回ほぼ触れていませんが、マネージドメモリ汚染の問題もあります。ここについてはデバッグ専用機能だし...ってことである程度割り切ってしまっています。本来であればその辺りも含めてちゃんと考慮するべきなんでしょうね。というかNativeArrayで全部完結するようなAPIを用意して欲しいと夢想するのは高望みし過ぎですかね...