【Unity】NatCorderで動画ファイルを作成する

UnityのアセットであるNatCorderで動画ファイルを作成する方法についてまとめました。

Unity2019.4.0

NatCorder?

NatCorderはUnityでmp4などの動画ファイルを作成するための有料アセットです。

assetstore.unity.com

ゲームカメラやレンダーテクスチャ、自前のピクセルデータなどから簡単に動画ファイルを作ることができます。
サウンドの録音も可能です。

またPCだけでなくiOSAndroidなどのモバイルデバイスにも対応しています。
mp4以外にもアニメーションGIFやJPEGのシーケンスとして書き出すこともできます。

本記事ではこのアセットの使い方についてまとめます。

ゲームカメラを録画する

まずよくある使い方として、ゲームカメラをそのまま録画してみます。
録画は以下の手順で行います。

  1. MP4Recorderを生成(mp4出力の場合)
  2. CameraInputを作成、録画開始
  3. MP4Recorder.FinishWriting()で録画終了、保存

これを実装したコードは以下の通りです。

using System;
using System.Threading.Tasks;
using UnityEngine;
using NatSuite.Recorders;
using NatSuite.Recorders.Inputs;
using NatSuite.Recorders.Clocks;

public class GameCameraRecorder : IDisposable
{
    private readonly Camera _camera;
    private readonly IMediaRecorder _recorder;
    private CameraInput _cameraInput;

    public bool IsRunnning { get; private set; }
    public bool IsDisposed { get; private set; }

    public GameCameraRecorder(Camera camera, int width, int height)
    {
        _camera = camera;

        // Recorderを作成
        var frameRate = 30; // フレームレートは動画なので今回は30固定
        _recorder = new MP4Recorder(width, height, frameRate);
    }

    public void StartRecording()
    {
        if (IsRunnning)
        {
            throw new InvalidOperationException();
        }
        if (IsDisposed)
        {
            throw new ObjectDisposedException(GetType().Name);
        }

        // CameraInputを作成することで録画が開始される
        // これがRecorderに対して毎フレーム情報を送信する
        _cameraInput = new CameraInput(_recorder, new RealtimeClock(), _camera);
        IsRunnning = true;
    }

    public async Task<string> EndRecordingAsync()
    {
        if (!IsRunnning)
        {
            throw new InvalidOperationException();
        }
        if (IsDisposed)
        {
            throw new ObjectDisposedException(GetType().Name);
        }

        // CameraInputは録画を終了する前にDisposeしてRecorderへの情報送信を止める
        _cameraInput.Dispose();
        _cameraInput = null;
        // 動画を保存し、保存先のパスを得る
        // パスは変更できない仕様なので特定の場所に移したければFile.IOで移動する
        return await _recorder.FinishWriting();
    }

    public void Dispose()
    {
        if (IsDisposed)
        {
            throw new ObjectDisposedException(GetType().Name);
        }

        if (_cameraInput != null)
        {
            _cameraInput.Dispose();
        }
        IsDisposed = true;
    }
}

詳細はコメントに記述しました。
このクラスを以下のようにして使用します。

using UnityEngine;

public class Example : MonoBehaviour
{
    [SerializeField] private Camera _camera;
    private GameCameraRecorder _recorder;

    public void Start()
    {
        _recorder = new GameCameraRecorder(_camera, Screen.width, Screen.height);
    }

    private void OnGUI()
    {
        var width = Screen.width;
        var height = width * 0.1f;
        var buttonRect = new Rect(0, 0, width, height);
        if (GUI.Button(buttonRect, "Start") && !_recorder.IsRunnning && !_recorder.IsDisposed)
        {
            _recorder.StartRecording();
            Debug.Log("Start");
        }
        buttonRect.y += height + 8;
        if (GUI.Button(buttonRect, "End") && _recorder.IsRunnning && !_recorder.IsDisposed)
        {
            var _ = _recorder
                .EndRecordingAsync()
                .ContinueWith(x => Debug.Log($"End: {x.Result}"));
        }
    }

    private void OnDestroy()
    {
        _recorder.Dispose();
    }
}

Startボタンを押して録画を開始、Endボタンを押して終了します。
ログ出力されたパスにmp4が出力されていれば成功です。

Screen Space - OverlayにしているUIは描画できないので注意

注意点として、Camera InputはScreen Space - OverlayにしているCanvasを録画できません。
これはUnityの制約によるもので、以下のスレッドで議論されています。

forum.unity.com

UIを録画したい場合にはCanvasのRenderModeを変更する必要があります。

音声付きで録画する

次に音声付きで録画をしてみます。
音声を含めるためにはAudioInputを生成します。

using System;
using System.Threading.Tasks;
using UnityEngine;
using NatSuite.Recorders;
using NatSuite.Recorders.Inputs;
using NatSuite.Recorders.Clocks;

public class GameCameraRecorder : IDisposable
{
    private readonly Camera _camera;
    private readonly IMediaRecorder _recorder;
    private readonly AudioListener _audioListener;
    private CameraInput _cameraInput;
    private AudioInput _audioInput;

    public bool IsRunnning { get; private set; }
    public bool IsDisposed { get; private set; }

    public GameCameraRecorder(Camera camera, int width, int height)
    {
        _camera = camera;
        var frameRate = 30;
        // SampleRateとChannel Countを指定する必要がある
        var sampleRate = AudioSettings.outputSampleRate;
        var channelCount = (int)AudioSettings.speakerMode;
        _recorder = new MP4Recorder(width, height, frameRate, sampleRate, channelCount);
        
        // カメラのAudioListnerを録音する
        _audioListener = _camera.GetComponent<AudioListener>();
    }

    public void StartRecording()
    {
        if (IsRunnning)
        {
            throw new InvalidOperationException();
        }
        if (IsDisposed)
        {
            throw new ObjectDisposedException(GetType().Name);
        }

        var clock = new RealtimeClock();
        _cameraInput = new CameraInput(_recorder, clock, _camera);
        // 音声も動画に入れるためにAudioInputを生成
        // ClockはCameraInputに使ったものと同じものを必ず使う(音ズレしないように)
        _audioInput = new AudioInput(_recorder, clock, _audioListener);
        IsRunnning = true;
    }

    public async Task<string> EndRecordingAsync()
    {
        if (!IsRunnning)
        {
            throw new InvalidOperationException();
        }
        if (IsDisposed)
        {
            throw new ObjectDisposedException(GetType().Name);
        }

        _cameraInput.Dispose();
        _cameraInput = null;
        _audioInput.Dispose();
        _audioInput = null;
        return await _recorder.FinishWriting();
    }

    public void Dispose()
    {
        if (IsDisposed)
        {
            throw new ObjectDisposedException(GetType().Name);
        }

        if (_cameraInput != null)
        {
            _cameraInput.Dispose();
        }
        if (_audioInput != null)
        {
            _audioInput.Dispose();
        }
        IsDisposed = true;
    }
}

使い方は前節と同様です。

iOSAndroid実機でカメラロールに保存する

さて次にこのようにして生成した動画ファイルをスマホのカメラロールに保存します。
カメラロールに保存するには、NatShareという無料のアセットを使います。

assetstore.unity.com

NatShareはメディアをSNSにシェアしたりするためのアセットですが、今回はこれのうちカメラロールに保存する機能のみを使用します。
使い方としては上記のアセットをインポートした後に以下のコメント部分を追記するだけです。

using System;
using System.Threading.Tasks;
using UnityEngine;
using NatSuite.Recorders;
using NatSuite.Recorders.Inputs;
using NatSuite.Recorders.Clocks;
using NatSuite.Sharing;

public class GameCameraRecorder : IDisposable
{
    private readonly Camera _camera;
    private readonly IMediaRecorder _recorder;
    private readonly AudioListener _audioListener;
    private CameraInput _cameraInput;
    private AudioInput _audioInput;

    public bool IsRunnning { get; private set; }
    public bool IsDisposed { get; private set; }

    public GameCameraRecorder(Camera camera, int width, int height)
    {
        _camera = camera;
        var frameRate = 30;
        var sampleRate = AudioSettings.outputSampleRate;
        var channelCount = (int)AudioSettings.speakerMode;
        _recorder = new MP4Recorder(width, height, frameRate, sampleRate, channelCount);
        
        _audioListener = _camera.GetComponent<AudioListener>();
    }

    public void StartRecording()
    {
        if (IsRunnning)
        {
            throw new InvalidOperationException();
        }
        if (IsDisposed)
        {
            throw new ObjectDisposedException(GetType().Name);
        }

        var clock = new RealtimeClock();
        _cameraInput = new CameraInput(_recorder, clock, _camera);
        _audioInput = new AudioInput(_recorder, clock, _audioListener);
        IsRunnning = true;
    }

    public async Task<string> EndRecordingAsync()
    {
        if (!IsRunnning)
        {
            throw new InvalidOperationException();
        }
        if (IsDisposed)
        {
            throw new ObjectDisposedException(GetType().Name);
        }

        _cameraInput.Dispose();
        _cameraInput = null;
        _audioInput.Dispose();
        _audioInput = null;
        var path = await _recorder.FinishWriting();

        // カメラロールに保存
        var payload = new SavePayload();
        payload.AddMedia(path);
        await payload.Commit();
        return path;
    }

    public void Dispose()
    {
        if (IsDisposed)
        {
            throw new ObjectDisposedException(GetType().Name);
        }

        if (_cameraInput != null)
        {
            _cameraInput.Dispose();
        }
        if (_audioInput != null)
        {
            _audioInput.Dispose();
        }
        IsDisposed = true;
    }
}
Android用の設定

AndroidはProject SettingsからMinimum API Levelを「Android 5.1 'Lollipop' (API Level 22)」以上に設定する必要があります。

f:id:halya_11:20201111112738p:plain
API Level

iOS用の設定

iOSの場合にはinfo.plistに以下の二つを追加する必要があります。

  • NSPhotoLibraryUsageDescription
  • NSPhotoLibraryAddUsageDescription

Unityのスクリプトで設定するには以下のようにします。

using System.IO;
#if UNITY_IOS
using UnityEditor;
using UnityEditor.Build;
using UnityEditor.Build.Reporting;
using UnityEditor.iOS.Xcode;

public class iOSBuildPostProcess : IPostprocessBuildWithReport
{
    public int callbackOrder => 0;

    public void OnPostprocessBuild(BuildReport report)
    {
        if (report.summary.platform != BuildTarget.iOS)
        {
            return;
        }

        var path = Path.Combine(report.summary.outputPath, "Info.plist");
        var plist = new PlistDocument();
        plist.ReadFromFile(path);
        plist.root.SetString("NSPhotoLibraryUsageDescription", "許可を求めるダイアログで説明を表示したければここに記述");
        plist.root.SetString("NSPhotoLibraryAddUsageDescription", "許可を求めるダイアログで説明を表示したければここに記述");
        plist.WriteToFile(path);
    }
}
#endif

オフラインレコーディング

少し応用的な使い方として、フレームごとのレンダリング結果(テクスチャ)の配列から動画を作るオフラインレンダリングが行えます。
オフラインレンダリングを行うためにはCameraInputを使わずに以下のようにRecorder.CommitFrame()を呼びます。
またRealtimeClockの代わりにFixedIntervalClockを使用します。

using System.Linq;
using NatSuite.Recorders;
using NatSuite.Recorders.Clocks;
using UnityEngine;

public class Example : MonoBehaviour
{
    public async void Start()
    {
        const int width = 128;
        const int height = 128;
        const int frameRate = 30;
        
        // FixedIntervalClockをFrameRateを指定して生成
        var clock = new FixedIntervalClock(frameRate);

        // Recorderを生成
        var recorder = new MP4Recorder(width, height, frameRate);
        
        // フレームごとのテクスチャの配列を用意しておく
        var colors = Enumerable.Range(0, width * height)
            .Select(_ => (Color32) Color.magenta)
            .ToArray();
        var frames = Enumerable.Range(0, 100)
            .Select(x =>
            {
                // ※フォーマットに注意
                var texture = new Texture2D(width, height, TextureFormat.RGBA32, false, false);
                texture.SetPixels32(colors);
                return texture;
            })
            .ToArray();
        
        // 全てのフレームをコミットする
        foreach (var frame in frames)
        {
            // FixedIntervalClock.timestampが呼ばれるたびに経過時間がフレーム数分追加されていく
            recorder.CommitFrame(frame.GetPixels32(), clock.timestamp);
        }
        
        var path = await recorder.FinishWriting();
        Debug.Log(path);
    }
}

ちなみにコミットは別スレッドで行ったほうが良いです。
この場合、GetPixel32()はUnityのメソッドなのでメインスレッドからしか呼べないので、あらかじめColor32の配列としてフレーム情報を保持するように変更します。
(NatCorderの公式ドキュメントだと現時点では別スレッドでGetPixel32()を呼んでますがこれはエラーになります)

var frames = Enumerable.Range(0, 100)
    .Select(x =>
    {
        var texture = new Texture2D(width, height, TextureFormat.RGBA32, false, false);
        texture.SetPixels32(colors);
        // この時点でGetPixels32()を呼んでおく
        return texture.GetPixels32();
    })
    .ToArray();

// スレッド化
var path = await Task.Run(() =>
{
    foreach (var frame in frames)
    {
        recorder.CommitFrame(frame, clock.timestamp);
    }
    return recorder.FinishWriting();
});
Debug.Log(path);

なお音を含めたオフラインレコーディングについてはもう少し複雑になるので別の記事にまとめます。

パフォーマンスについて

解像度を調整する

一般的に動画に必要な解像度はカメラの解像度よりも低くて済みます。
カメラの解像度で録画すると録画のための処理負荷が大きくなるため、動画として必要な解像度で録画するのが良さそうです。

動画の解像度はRecorderのコンストラクタで設定できます。

_recorder = new MP4Recorder(width, height, frameRate, sampleRate, channelCount);
フレームレートを落とす

動画に必要なフレームレートは30あれば十分なので、
ゲームが60FPSで動いていても動画のためにコミットするフレームは30フレームに調整するべきです。

CameraInputのframeSkipフィールドに値を設定すると、そのフレーム数だけコミット間隔を空けます。
例えばフレームのコミット数を半分にしたければ1を、1/3にしたければ2を設定します。

_cameraInput = new CameraInput(_recorder, clock, _camera)
{
    frameSkip = 1
};
マルチスレッド化

フレームをコミットする処理はスレッドセーフです。
またこの処理の負荷は大きいので、できる限りフレームのコミット処理は別スレッドに移すべきです。

参考

docs.natsuite.io