🐨

Unity(3Dアプリ)上でAzure Communication Serviceで接続した相手側のカメラ映像を表示できるようにする

2024/02/20に公開1

やりたいこと

以下の記事ではWebアプリとHololens2間で通話できるところまで実装しました。

https://zenn.dev/headwaters/articles/9b272d32812bcc

しかし、音声だけでなく相手(Web側)のカメラ映像をHololens2上でも表示させたいです。
Webアプリのように簡単に出来るのかと思ってましたが、一筋縄ではいかなかったので共有です。

そもそもUnityアプリ上で表示させることは出来るのか

Microsoftの公式ドキュメントにサンプルコードがあるので試してみましたが、表示されませんでした。

そもそも「OnRemoteParticipantsUpdatedAsync」関数が実行されないのと、uriを取得した後の処理(Unity上に表示させる処理)が記載されてないため、どのみち難しそうです。

https://learn.microsoft.com/ja-jp/azure/communication-services/quickstarts/voice-video-calling/get-started-with-video-calling?pivots=platform-unity

UWPアプリを同時に起動させるたら一応できる

ですので、代替案としてカメラ表示用のUWPアプリを別途作成して、UnityアプリとUWPアプリの二つをHololens2上で同時に立ち上げることで一応実現はできます。

しかし、Webアプリ側としてはUnityアプリ(3Dオブジェクトなどが写っている方)の映像を見たいため、Hololens2上では「こちら側のカメラ映像を送るアカウント」と「相手側のカメラ映像を表示するアカウント」の2ユーザー分必要になってしまいます。
その分通話料がかかりますし、何より処理がかなり複雑になるので正直避けたいです。

以下イメージ図

【Hololens2からWeb側に情報を送信する時】



【WebからHololens2側に情報を送信する時】

実際に作ってみた

  1. Unity上で通話開始ボタンを押したら、「Launcher.LaunchUri」を使ってUWPアプリを起動させる
  2. その次にWebアプリ側にSignalRを使って通話を開始したことをメッセージ送信
  3. 指定のグループIDの通話に参加をする
  4. UWPアプリでは起動したタイミングでACSへの認証処理をして、指定のグループIDの通話に参加をする
  5. Unityアプリ側からSignalR経由でメッセージを受け取ったタイミングで、通話参加するためのボタンを活性させて、参加できるようにする
  6. Webアプリ上ではUWPアプリのカメラ映像が渡ってきても描画はせず、3Dアプリの映像のみWeb上に表示させる

MicrosoftにUnity上で表示できないか問い合わせてみた

Microsoftのサポートに問い合わせてみたところ、「RawVideo」形式だと実現できるとのことで、修正版のサンプルコードをいただきました。
日本語版のドキュメントはまだ修正されてませんが、英語版でしたら更新されてます。

https://learn.microsoft.com/en-us/azure/communication-services/quickstarts/voice-video-calling/get-started-raw-media-access?pivots=platform-unity

実装

1. ACSへの接続処理を実装

ACSManager.cs
using Azure.Communication.Calling.UnityClient;
using System;
using System.Linq;
using System.Threading.Tasks;
using UnityEngine;


public class ACSManager : MonoBehaviour
{
    private CallClient callClient;
    private CallAgent callAgent;
    private CommunicationCall call;
    private DeviceManager deviceManager;
    private LocalOutgoingVideoStream cameraStream;

    public IncomingVideo incomingVideoPlayer;

    void Start()
    {
        callClient = new CallClient();
        Task.Run(async () => { 
                var callAgentOptions = new CallAgentOptions() {
                    DisplayName = $"{Environment.MachineName}/{Environment.UserName}",
                };

                var tokenCredential = new CallTokenCredential("<<Replace with auth token>>");
                callAgent = await callClient.CreateCallAgent(tokenCredential, callAgentOptions);
            });
    }

    public void OnStartCall()
    {
        Task.Run(async () =>
        {
             deviceManager = await callClient.GetDeviceManager();
             var videoDeviceInfo = deviceManager.Cameras[0];
             var _localVideoStream = new LocalOutgoingVideoStream[1];
             _localVideoStream[0] = new LocalOutgoingVideoStream(videoDeviceInfo);

            var startCallOptions = new StartCallOptions() {
                OutgoingAudioOptions = new OutgoingAudioOptions {
                    IsMuted = true //Audio is not the focus of this sample
                },
                IncomingAudioOptions = new IncomingAudioOptions {
                    IsMuted = true //Audio is not the focus of this sample
                },
                OutgoingVideoOptions = new OutgoingVideoOptions() {
                    Streams = _localVideoStream
                },
                IncomingVideoOptions = new IncomingVideoOptions {
                    StreamKind = VideoStreamKind.RawIncoming,
                    FrameKind = RawVideoFrameKind.Buffer
                }
            };

            call = await callAgent.StartCallAsync(new CallIdentifier[] { 
                new UserCallIdentifier("<<Replace with callee id>>") }, 
                startCallOptions);
            
            call.StateChanged += OnStateChanged;
            call.RemoteParticipantsUpdated += OnRemoteParticipantsUpdatedAsync;
        });
    }

    public async void OnHangUp()
    {
        if (call != null)
        {
            try
            {
                await call.HangUpAsync(new HangUpOptions() { ForEveryone = false });
            }
            catch (Exception ex)
            {
                Debug.Log(ex);
            }
        }
    }

    private void OnStateChanged(object sender, PropertyChangedEventArgs args)
    {
        var call = (CommunicationCall)sender;
        if (call.State == CallState.Connected) {
            var remoteParticipant = call.RemoteParticipants.First();
            OnRawIncomingVideoStreamStateChanged(remoteParticipant.IncomingVideoStreams.First());
        }
    }

    private async void OnRemoteParticipantsUpdatedAsync(object sender, ParticipantsUpdatedEventArgs args)
    {
        foreach (var participant in args.RemovedParticipants)
        {
            foreach (var incomingVideoStream in participant.IncomingVideoStreams)
            {
                var remoteVideoStream = incomingVideoStream as RemoteIncomingVideoStream;
                if (remoteVideoStream != null)
                {
                    await remoteVideoStream.StopPreviewAsync();
                }
            }
            // Tear down the event handler on the departing participant
            participant.VideoStreamStateChanged -= OnRawVideoStreamStateChanged;
        }

        foreach (var participant in args.AddedParticipants)
        {
            participant.VideoStreamStateChanged += OnRawVideoStreamStateChanged;
        }
    }

    private void OnRawVideoStreamStateChanged(object sender, VideoStreamStateChangedEventArgs e)
    {
        CallVideoStream callVideoStream = e.Stream;

        switch (callVideoStream.Direction)
        {
            case StreamDirection.Outgoing:
                break;
            case StreamDirection.Incoming:
                OnRawIncomingVideoStreamStateChanged(callVideoStream as IncomingVideoStream);
                break;
        }
    }

    private void OnRawIncomingVideoStreamStateChanged(IncomingVideoStream incomingVideoStream)
    {
        switch (incomingVideoStream.State)
        {
            case VideoStreamState.Available:
                {
                    var rawIncomingVideoStream = incomingVideoStream as RawIncomingVideoStream;
                    rawIncomingVideoStream.RawVideoFrameReceived += OnRawVideoFrameReceived;
                    rawIncomingVideoStream.Start();
                    break;
                }
            case VideoStreamState.Stopped:
                break;
            case VideoStreamState.NotAvailable:
                break;
        }
    }

    private void OnRawVideoFrameReceived(object sender, RawVideoFrameReceivedEventArgs e)
    {
        incomingVideoPlayer.RenderRawVideoFrame(e.Frame);
    }

}


2. 相手側の映像の描画処理を実装

IncomingVideo.cs
using Azure.Communication.Calling.UnityClient;
using System;
using System.Collections.Concurrent;
using UnityEngine;

public class IncomingVideo : MonoBehaviour
{
    private struct PendingFrame
    {
        public RawVideoFrame frame;
        public RawVideoFrameKind kind;
    }

    ConcurrentQueue<PendingFrame> pendingIncomingFrames = new ConcurrentQueue<PendingFrame>();

    public RenderTexture rawIncomingVideoRenderTexture;

    private void Update()
    {
        if (pendingIncomingFrames.TryDequeue(out PendingFrame pendingFrame))
        {
            switch (pendingFrame.kind)
            {
                case RawVideoFrameKind.Buffer:
                    var videoFrameBuffer = pendingFrame.frame as RawVideoFrameBuffer;
                    VideoStreamFormat videoFormat = videoFrameBuffer.StreamFormat;
                    int width = videoFormat.Width;
                    int height = videoFormat.Height;
                    var texture = new Texture2D(width, height, TextureFormat.RGBA32, mipChain: false);

                    var buffers = videoFrameBuffer.Buffers;
                    NativeBuffer buffer = buffers.Count > 0 ? buffers[0] : null;
                    buffer.GetData(out IntPtr bytes, out int signedSize);

                    texture.LoadRawTextureData(bytes, signedSize);
                    texture.Apply();

                    Graphics.Blit(source: texture, dest: rawIncomingVideoRenderTexture);
                    break;

                case RawVideoFrameKind.Texture:
                    break;
            }
            pendingFrame.frame.Dispose();
        }
    }

    public void RenderRawVideoFrame(RawVideoFrame rawVideoFrame)
    {
        var videoFrameBuffer = rawVideoFrame as RawVideoFrameBuffer;
        pendingIncomingFrames.Enqueue(new PendingFrame() {
                frame = rawVideoFrame,
                kind = RawVideoFrameKind.Buffer });
    }
}


3. カメラ映像を表示するためのオブジェクトを用意

始めにサンプルコードのGitHubからIncomingVideoPlayerTextureをダウンロードして、プロジェクトにアップします。

https://github.com/Azure-Samples/communication-services-dotnet-quickstarts/blob/main/Unity/RawVideo/Assets/VideoPlayer/IncomingVideoTexture.renderTexture



canvasを作成して、その中に「RawImage」オブジェクトを作成します。



Inspectorの「Add Componet」からIncomingVideo.csを追加します。

続いて先ほどダウンロードしたIncomingVideoPlayerTextureをRawImage内のTextureとIncomingVideoのRaw Incoming Videoに紐づけます。


4. 通話を開始・終了するボタンを用意

通話開始ボタン(OnStartCall関数)


通話終了ボタン(OnHangUp関数)


5. 最後にACSManagerを用意

検証

Unityアプリ上で表示させることができました!
しかし処理がかなり不安定ですぐ落ちてしまうのと、表示しているカメラ映像の遅延が気になります...
これだとUWPアプリを使った方法のほうがまだマシなので、修正していきます。

パフォーマンス面の修正

1. 処理が重すぎて1分ほどで落ちる

Update関数内で毎フレーム重たい処理をしているのが原因かもしれません。
既存のコードだと、毎回Textureを作成してましたので、初めの一回のみ作成するように修正します。

IncomingVideo.cs
public class IncomingVideo : MonoBehaviour
{
    // ↓ 追加
    private Texture2D texture;
    private struct PendingFrame
    {
        public RawVideoFrame frame;
        public RawVideoFrameKind kind;
    }

    CallVideoStream stream;

...

    private void Update()
    {
        if (pendingIncomingFrames.TryDequeue(out PendingFrame pendingFrame))
        {
            switch (pendingFrame.kind)
            {
                case RawVideoFrameKind.Buffer:
                    var videoFrameBuffer = latestFrame.frame as RawVideoFrameBuffer;
                    VideoStreamFormat videoFormat = videoFrameBuffer.StreamFormat;
                    int width = videoFormat.Width;
                    int height = videoFormat.Height;
                    // ↓追加
                    if (texture == null)
                    {
                        texture = new Texture2D(width, height, TextureFormat.RGBA32, mipChain: false);
                    }
    
                    var buffers = videoFrameBuffer.Buffers;

...


2. 表示される映像が2,3秒ほど遅延している

こちらは渡ってくるカメラ映像を全て処理しようとして、どんどん処理が追い付かず溜まっているから遅延が発生しているようです。
ですので最新のフレーム情報のみ取得して処理するように修正します。

IncomingVideo.cs
public class IncomingVideo : MonoBehaviour
{

...

    private void Update()
    {
        // ↓最新のフレームをlatestFrameに格納して、それだけ処理をする
        PendingFrame latestFrame = default;
        while (pendingIncomingFrames.TryDequeue(out PendingFrame pendingFrame))
        {
            if (latestFrame.frame != null)
            {
                latestFrame.frame.Dispose();
            }
            latestFrame = pendingFrame;
        }

        if (latestFrame.frame != null)
        {
            switch (latestFrame.kind)
            {
                case RawVideoFrameKind.Buffer:
                    var videoFrameBuffer = latestFrame.frame as RawVideoFrameBuffer;
                    VideoStreamFormat videoFormat = videoFrameBuffer.StreamFormat;
                    int width = videoFormat.Width;
                    int height = videoFormat.Height;
                    if (texture == null)
                    {
                        texture = new Texture2D(width, height, TextureFormat.RGBA32, mipChain: false);
                    }

                    var buffers = videoFrameBuffer.Buffers;
                    NativeBuffer buffer = buffers.Count > 0 ? buffers[0] : null;
                    buffer.GetData(out IntPtr bytes, out int signedSize);

                    texture.LoadRawTextureData(bytes, signedSize);
                    texture.Apply();

                    Graphics.Blit(source: texture, dest: rawIncomingVideoRenderTexture);
                    break;

                case RawVideoFrameKind.Texture:
                    // RenderHardwareFrame(pendingFrame.frame);
                    break;
            }
            latestFrame.frame.Dispose();
        }
    }

...

結果

遅延もほぼ無くなって、動作もかなり安定しました。
UWPアプリを使った時の方法より格段に処理が単純になったのでよかったです。

ヘッドウォータース

Discussion