固まらないスクリーンショットの撮り方

ハッピーおしゃれタイムでの写真撮影についてご紹介します。

サンプルコードは実際のはぴおしゃのコードから持ってきていますが、余分なところを削っている関係で動かないかもしれないかもしれません。動作確認ができたらここを消します。というか、そもそも Photo クラスや ServerPhoto クラスがないので動かないですね!そのうちサンプルコード書きます……。

写真の撮影の難しさは固まらないようにすること

ハッピーおしゃれタイムではライブ中、つまり音ゲーの最中に写真を撮ります。このとき、固まったりしたら困ってしまいます。

Unity を使う場合に普通にカメラを使って写真を撮るとこんなコードになるはずです。

PhotoCamera.cs
using UnityEngine;
using UnityEngine.Rendering.PostProcessing;
namespace Idol
{
[RequireComponent(typeof(Camera))]
public class PhotoCamera : MonoBehaviour
{
Camera photoCamera;
int width = 1920;
int height = 1080;
void Awake()
{
photoCamera = GetComponent<Camera>();
}
public void Prepare()
{
photoCamera.targetTexture = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Default)
{
antiAliasing = 8
};
photoCamera.enabled = true;
}
public Photo Shoot()
{
var texture = new Texture2D(photoCamera.targetTexture.width, photoCamera.targetTexture.height, TextureFormat.ARGB32, false);
RenderTexture.active = photoCamera.targetTexture;
texture.ReadPixels(new Rect(0, 0, texture.width, texture.height), 0, 0);
texture.Apply();
var photo = Photo.Create(texture);
RenderTexture.active = null;
return photo;
}
public void Finish()
{
var rt = photoCamera.targetTexture;
if (rt != null)
{
Destroy(rt);
}
photoCamera.enabled = false;
}
}
}

これはかなり時間のかかる処理になり、一瞬フリーズします。なぜかというと CPU の処理の中で、RenderTexture つまり GPU の結果を待っているからです。この待ち時間を解消するためには、同期的に待つのではなく非同期的に GPU を待てば良いことになります。

AsyncGPUReadback で読み込むと非同期で GPU を待てる

Unity には AsyncGPUReadback というクラスがあり、これを使うことで非同期で GPU の結果を待つことができます。具体的にはぴおしゃでは UniTask を使って以下のようにしています。

PhotoCamera.cs
using System.Linq;
using UniRx.Async;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.PostProcessing;
namespace Idol
{
[RequireComponent(typeof(Camera))]
public class PhotoCamera : MonoBehaviour
{
Camera photoCamera;
int width = 1920;
int height = 1080;
void Awake()
{
photoCamera = GetComponent<Camera>();
}
public void Prepare(bool withPreview)
{
photoCamera.targetTexture = new RenderTexture(width, height, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.Default)
{
antiAliasing = 8
};
photoCamera.enabled = true;
}
public async UniTask<ServerPhoto> ShootAsync()
{
var rt = photoCamera.targetTexture;
var request = AsyncGPUReadback.Request(rt, 0);
await UniTask.WaitUntil(() => request.done);
var rawByteArray = request.GetData<byte>().ToArray();
var graphicsFormat = rt.graphicsFormat;
var width = (uint)rt.width;
var height = (uint)rt.height;
await UniTask.SwitchToThreadPool();
var imageBytes = ImageConversion.EncodeArrayToPNG(rawByteArray, graphicsFormat, width, height);
var photo = ServerPhoto.Create(imageBytes);
await UniTask.SwitchToMainThread();
return photo;
}
public void Finish()
{
var rt = photoCamera.targetTexture;
if (rt != null)
{
Destroy(rt);
}
photoCamera.enabled = false;
}
}
}

AsyncGPUReadback.Request() で帰ってきた Request を毎フレーム done かどうかを調べて、done だったらその request から情報をもらいます。私の記憶だとこの情報が取れるのが done になったそのフレームだけだった記憶があるので、done になったらすぐにデータを読み込みましょう。

上記のコードでは無視していますが、エラーになった場合も考慮しないといけませんし、AsyncGPUReadback に対応しているかどうかも、 SystemInfo.supportsAsyncGPUReadback で先にチェックして実装を切り替えたほうがよいです。あと RenderTexture が Finish が呼ばれない場合はリークするので気をつけてください!

なお、はぴおしゃではネットワーク経由で png を受け渡しする関係で png に変換して byte array をもらっていますが、Texture2D が欲しい場合には tex.SetPixels32(request.GetData<Color32>().ToArray() でいけるはずです。

また、png への変換は Texture2D.EncodeToPNG() だと Unity のスレッドからじゃないとだめですが、ImageConversion.EncodeArrayToPNG() だと他のスレッドからでも呼び出せるので、スレッドプールに切り替えて変換しています。UniTask はこのあたりの柔軟性が素晴らしいですね!