見出し画像

【MiRZA】MR合成した映像をアプリ内で録画する

はじめに

ARグラスアプリにおいて、 ユーザーが体験している映像を記録・共有する機能は重要な要素の1つです。

MiRZAのような光学シースルー方式のデバイスでは、 現実の映像はディスプレイに投影されないため、 外部の物理カメラの映像とディスプレイに投影されている映像を合成する必要があります。

この記事では、 MiRZA用のアプリ内でユーザーが体験している映像を合成し、 録画する機能の実装について紹介します。

★クリックして動画を再生★

1. 環境

  • Unity 2022.3.36f1

  • Snapdragon Spaces SDK 1.0.1

2. サンプル

録画機能を実装したサンプルは以下でupmパッケージとして公開しています。

https://github.com/gaprot/MiRZA-Recorder

2-1. Spaces SDKのセットアップ

以下を参考に、 Spaces SDKの導入とSpacesのサンプルシーンの動作確認を行います。

https://www.devices.nttqonoq.com/developer/doc/setup/setup-guide

2-2. Camera Accessの有効化

以下を参考にOpenXR FeatureのCameraAccessを有効化します。

https://www.devices.nttqonoq.com/developer/doc/features/camera-frame-access

2-3. サンプルパッケージのインポート

PackageManager / Install package from git URLから以下をインポートします。

https://github.com/gaprot/MiRZA-Recorder.git?path=Assets/Upft/MRRecorder

パッケージのインポートが完了したら、 Samplesをインポートします。

2-4. シーンのセットアップ

録画するシーンを開き、 MainCameraのオブジェクト上の ARCameraManager が有効化されていることを確認します。

2-3でインポートしたRecorderプレハブをHierarchyへ配置します。
その後、 RecorderExampleコンポーネントのCameraManagerとSceneCameraにそれぞれMainCameraのオブジェクトを参照させてください。

2-5. 実機で実行

赤いボタンを押すと録画が開始し、 緑のボタンを押すと録画が停止されます。
録画した動画は、 /storage/emulated/0/Android/data/com.hoge.huga/files 内に保存されます。
録画品質はLow, Medium, Highから選択することができ、 それぞれの解像度は以下の通りです。

注意

  • リアルタイムにフレーム画像の合成・エンコードを行うため、 負荷は大きくなります
    利用するコンテンツや用途に合わせて録画品質を調整してください

  • 音声の録音には未対応になります

3. 実装

主な流れは以下のとおりです。

  1. グラスの物理カメラのフレーム画像を取得

  2. Unityのシーンカメラのフレーム画像を取得

  3. 1の画像を背景として、 2の画像と合成

  4. 3の画像をエンコードし、 動画として保存

3-1. メインカメラのフレーム画像を取得する

MiRZAでのカメラフィード取得には、ARFoundationのARCameraManagerを使用します。
以下に主要な実装を示します。

public sealed class VideoRecorder
{
    private readonly ARCameraManager _cameraManager;
    
    public VideoRecorder(ARCameraManager cameraManager) 
    {
        _cameraManager = cameraManager;
    }

    public void StartRecording(VideoEncodingOptions options)
    {
        // 録画開始時にフレーム取得を開始
        _cameraManager.frameReceived += OnFrameReceived;
    }

    private void OnFrameReceived(ARCameraFrameEventArgs args)
    {
        // カメラ画像を取得
        if (!_cameraManager.TryAcquireLatestCpuImage(out var image))
        {
            return;
        }

        try 
        {
            var outputDimensions = image.dimensions;
            var timestamp = (long)(image.timestamp * MICROSECONDS_PER_SECOND);
            
            // 非同期に変換
            image.ConvertAsync(
                conversionParams: new(image, TextureFormat.RGBA32)
                {
                    inputRect = new RectInt(0, 0, image.width, image.height),
                    outputDimensions = outputDimensions,
                    transformation = XRCpuImage.Transformation.None
                },
                onComplete: (status, param, data) => 
                    OnConverted(status, outputDimensions, data, timestamp));
        }
        finally
        {
            image.Dispose();
        }
    }

    private void OnConverted(
        XRCpuImage.AsyncConversionStatus status,
        Vector2Int outputDimensions, 
        NativeArray<byte> data,
        long timestamp)
    {
        if (status != XRCpuImage.AsyncConversionStatus.Ready) return;

        // シーンカメラ画像との合成用にTexture2Dへ変換
        var camTexture = new Texture2D(
            width: outputDimensions.x,
            height: outputDimensions.y,
            textureFormat: TextureFormat.RGBA32,
            mipChain: false);
            
        using var rawData = camTexture.GetRawTextureData<byte>();
        data.CopyTo(rawData);
        camTexture.Apply();

        // 以降の処理へ
    }
}

3-2. シーンカメラのフレーム画像と合成する

MR映像を合成するためには、物理カメラのフィードとシーンカメラの映像を合成します。
これらはそれぞれ異なる視野角を持つため、スケーリングを行った上で合成する必要があります。

public class FrameBlender : IDisposable
{
    // スケーリング用の係数. 値は実機での試行錯誤によって仮定
    private const float FOV_SCALE_FACTOR = 78f;
    private readonly BlendMaterial _blendMaterial = new();
    private Texture2D _captureTex;

    public FrameBlender(Resolution resolution)
    {
        _captureTex = new Texture2D(
            resolution.Width,
            resolution.Height, 
            TextureFormat.ARGB32, 
            false);
    }

    public unsafe NativeArray<byte> Blend(Texture2D cameraTexture, Camera sceneCamera)
    {
        var sceneRT = RenderTexture.GetTemporary(_captureTex.width, _captureTex.height, 0,
            RenderTextureFormat.ARGB32);
        var physicalRT = RenderTexture.GetTemporary(_captureTex.width, _captureTex.height, 0,
            RenderTextureFormat.ARGB32);
        var compositeRT = RenderTexture.GetTemporary(_captureTex.width, _captureTex.height, 0,
            RenderTextureFormat.ARGB32);

        try
        {
            // シーンカメラとの映像スケールを調整
            var sceneFOV = sceneCamera.fieldOfView;
            var fovRatio = sceneFOV / FOV_SCALE_FACTOR;

            // シーンカメラの映像を取得
            var prevTarget = sceneCamera.targetTexture;
            sceneCamera.targetTexture = sceneRT;
            sceneCamera.Render();
            sceneCamera.targetTexture = prevTarget;

            // 物理カメラのフィードをテクスチャに描画
            Graphics.Blit(cameraTexture, physicalRT);
            
            // 2つの映像を合成
            _blendMaterial.Blend(sceneRT, physicalRT, fovRatio);
            Graphics.Blit(sceneRT, compositeRT, _blendMaterial.Material);

            // 合成結果を取得
            RenderTexture.active = compositeRT;
            _captureTex.ReadPixels(new Rect(0, 0, _captureTex.width, _captureTex.height), 0, 0);
            _captureTex.Apply();

            // エンコード用のバイト配列を生成
            var length = _captureTex.width * _captureTex.height * 4;
            var nativeData = new NativeArray<byte>(length, Allocator.Persistent);
            fixed (void* texPtr = _captureTex.GetRawTextureData())
            {
                UnsafeUtility.MemCpy(nativeData.GetUnsafePtr(), texPtr, length);
            }
            return nativeData;
        }
        finally
        {
            RenderTexture.ReleaseTemporary(sceneRT);
            RenderTexture.ReleaseTemporary(physicalRT);
            RenderTexture.ReleaseTemporary(compositeRT);
        }
    }

    public void Dispose()
    {
        if (_captureTex != null)
        {
            UnityEngine.Object.Destroy(_captureTex);
            _captureTex = null;
        }

        _blendMaterial?.Dispose();
    }
}

合成処理は以下のように行われます。

  1. シーンカメラの映像をRenderTextureに描画

  2. カメラフィードを別のRenderTextureに描画

  3. シーンカメラと物理カメラのFOV比率を用いて映像のスケールを調整
    (スケーリング用のパラメータは実機での試行錯誤により78と設定)

  4. エンコード用のバイトデータに変換

2つの映像の合成処理には専用のシェーダーを使用しています。
シェーダーでは物理カメラの映像を背景として、シーンカメラの映像を視野角に応じてスケーリングして重ね合わせます。

合成用のシェーダー(MRBlend.shader)は以下のようになります。

Shader "Hidden/MRBlend"
{
    Properties
    {
        _MainTex ("Scene Texture", 2D) = "black" {}
        _CameraTex ("Camera Feed", 2D) = "black" {}
        _SceneScale ("Scene Scale", Vector) = (1, 1, 0, 0)
    }
    
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        
        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            
            #include "UnityCG.cginc"
            
            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };
            
            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };
            
            sampler2D _MainTex;
            sampler2D _CameraTex;
            float4 _SceneScale;
            
            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }
            
            fixed4 frag (v2f i) : SV_Target
            {
                float2 centeredUV = i.uv - 0.5;
                float2 scaledUV = centeredUV * _SceneScale.xy;
                scaledUV += 0.5;
                bool validUV = all(scaledUV >= 0 && scaledUV <= 1);
                
                // シーン映像と物理カメラ映像を合成
                fixed4 sceneColor = validUV ? tex2D(_MainTex, scaledUV) : fixed4(0,0,0,0);
                fixed4 cameraColor = tex2D(_CameraTex, i.uv);
                
                // アルファブレンド
                return lerp(cameraColor, sceneColor, sceneColor.a);
            }
            ENDCG
        }
    }
}

このシェーダーでは以下の処理を行っています。

  1. UV座標の中心を原点とした座標系に変換

  2. シーンカメラの視野角に合わせてUV座標をスケーリング

  3. スケーリングしたUV座標を元の範囲(0-1)に戻す

  4. UV座標が有効範囲内の場合のみシーン映像を表示

  5. シーン映像のアルファ値に応じて物理カメラの映像と合成

3-3. 動画としてエンコードする

AndroidのMediaCodec APIは、動画や音声のエンコード/デコードを行うための低レベルAPIです。
今回の録画機能では、以下の2つのAPIを主に使用します。

  • MediaCodec: H.264形式での動画エンコード

  • MediaMuxer: エンコードされたデータをMP4へ

詳細はこちら
MediaCodec | Android Developers

MediaCodecの基本的な処理の流れは以下のようになります。

  1. エンコーダーの作成と設定

  2. 入力バッファの取得と映像データの書き込み

  3. 出力バッファからエンコード済みデータの取得

  4. MediaMuxerを使用してMP4ファイルへの書き出し

UnityでネイティブのAPIを呼び出すためにAndroidJavaObject等を経由しますが、 以降では必要なメソッド呼び出しなどをラップしたMediaCodec, MediaFormat, MediaMuxerクラスを定義して利用しています。

まず、エンコーダーの作成と設定です。
H.264エンコーダーを作成し、映像の解像度やビットレートなどの基本的なパラメータを設定します。

var format = new MediaFormat();
format.SetString("mime", MediaFormat.MIMETYPE_VIDEO_AVC);
format.SetInteger("width", resolution.Width);
format.SetInteger("height", resolution.Height);
format.SetInteger("frame-rate", resolution.FrameRate);
format.SetInteger("bitrate", MediaFormat.DEFAULT_BIT_RATE);
format.SetInteger("i-frame-interval", 1);
format.SetInteger("color-format", MediaCodec.COLOR_FORMAT_YUV420_FLEXIBLE);

color-format について、 カメラとシーンを合成した画像データはARGB32形式ですが、多くのハードウェアエンコーダーはYUV420形式での入力を要求します。そのため、エンコード前に色空間を変換する必要があります。

エンコード処理は以下のように、 フレームごとに入力バッファを取得し、 フレームデータを書き込んでいきます。

public void ProcessFrame(NativeArray<byte> frameData, long timestamp)
{
    // ARGB32からYUV420に変換
    using var yuvData = ColorConverter.ConvertArgb32ToYuv420(frameData, resolution);

    // 入力バッファの取得
    var inputBufferId = codec.DequeueInputBuffer(TIMEOUT_US);
    if (inputBufferId < 0) return;

    var inputBuffer = codec.GetInputBuffer(inputBufferId);
    
    // YUVデータを入力バッファに書き込み
    unsafe
    {
        var rawBuffer = inputBuffer.GetRawObject();
        var dstPtr = AndroidJNI.GetDirectBufferAddress(rawBuffer);
        var srcPtr = (sbyte*)yuvData.GetUnsafePtr();
        Buffer.MemoryCopy(srcPtr, dstPtr, capacity, yuvData.Length);
    }

    // エンコード処理をキュー
    codec.QueueInputBuffer(
        inputBufferId, 
        0, 
        yuvData.Length,
        timestamp,
        0);
}

エンコードされたデータは出力バッファから取得し、 MediaMuxerを使ってMP4ファイルに書き出します。

private void DrainEncoder()
{
    var outputBufferId = codec.DequeueOutputBuffer(codec.BufferInfo, TIMEOUT_US);
    
    if (outputBufferId >= 0)
    {
        var outputBuffer = codec.GetOutputBuffer(outputBufferId);
        var size = codec.BufferInfo.GetSize();
        
        if (size != 0 && muxer.IsStarted)
        {
            muxer.WriteSampleData(0, outputBuffer, codec.BufferInfo);
        }
        
        codec.ReleaseOutputBuffer(outputBufferId, false);
    }
    else if (outputBufferId == (int)MediaCodec.InfoCode.OutputFormatChanged)
    {
        // 初回フレームのエンコード完了時にフォーマットが確定
        if (!muxer.IsStarted)
        {
            var format = codec.GetOutputFormat();
            muxer.AddTrack(format);
            muxer.Start();
        }
    }
}

録画終了時には EndOfStream フラグを送信して残りのフレームを処理します。

private void SendEndOfStream()
{
    var inputBufferId = codec.DequeueInputBuffer(TIMEOUT_US);
    if (inputBufferId >= 0)
    {
        codec.QueueInputBuffer(inputBufferId, 0, 0, 0, 
            (int)MediaCodec.BufferFlag.EndOfStream);
    }
}

このように、 フレームごとにYUV420形式に変換してエンコードし、 MP4ファイルに書き出すことでMR合成映像を録画しています。

4. おわりに

MiRZAでのMR映像録画機能の実装について紹介しました。
現在の実装では基本的な録画機能を実現していますが、 音声の録音対応やパフォーマンス面での課題があります。
今後はこれらの改善をしつつ、 本家SDKに録画機能が実装されることも期待したいと思います。


#MiRZA #MiRZAアプリ #ARグラスアプリ #SpacesSDK


いいなと思ったら応援しよう!