e.blog

主にUnity/UE周りのことについてまとめていきます

NDI SDKをUnity向けにPlugin化し映像を配信する

f:id:edo_m18:20220205121623p:plain

概要

以前、UnityでNDI SDKを利用して映像を受信するプラグインを作る記事を書きました。

今回はこれを拡張して映像配信部分の実装について書きたいと思います。具体的には、配信映像の準備とそのエンコード、およびDLLの利用法についてまとめます。

なお、今回はAndroidからNDIを利用して映像を配信する部分を解説します。NDIを利用する上で相手側のデバイスの検索や受信については以前の記事に書いたのでここでは割愛します。

※ データのエンコードなどについてはKeijiroさんのKlak NDIを参考にさせていただきました。

実際に動かしているところは以下のような感じです。ちょっと分かりづらいですが、Android側の映像をPCに転送している動画です。



はじめに

コード量はそこまで多くないので、まずは NDISender クラスの全体を見てみましょう。

using System;
using System.Collections;
using System.Runtime.InteropServices;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using UnityEngine;
using UnityEngine.UI;

namespace NDIPlugin
{
    public class NDISender : MonoBehaviour
    {
        [SerializeField] private string _ndiName;
        [SerializeField] private ComputeShader _encodeCompute;
        [SerializeField] private bool _enableAlpha = false;
        [SerializeField] private GameObject _frameTextureSourceContainer;
        [SerializeField] private int _frameRateNumerator = 30000;
        [SerializeField] private int _frameRateDenominator = 1001;

        [SerializeField] private RawImage _preview;

        private IFrameTextureSource _frameTextureSource;
        private IntPtr _sendInstance;
        private FormatConverter _formatConverter;
        private int _width;
        private int _height;

        private NativeArray<byte>? _nativeArray;
        private byte[] _bytes;

        private void Start()
        {
            WifiManager.Instance.SetupNetwork();

            if (!NDIlib.Initialize())
            {
                Debug.Log("NDIlib can't be initialized.");
                return;
            }

            _frameTextureSource = _frameTextureSourceContainer.GetComponent<IFrameTextureSource>();

            _formatConverter = new FormatConverter(_encodeCompute);

            IntPtr nname = Marshal.StringToHGlobalAnsi(_ndiName);
            NDIlib.SendSettings sendSettings = new NDIlib.SendSettings { NdiName = nname };
            _sendInstance = NDIlib.send_create(sendSettings);
            Marshal.FreeHGlobal(nname);

            if (_sendInstance == IntPtr.Zero)
            {
                Debug.LogError("NDI can't create a send instance.");
                return;
            }

            StartCoroutine(CaptureCoroutine());
        }

        private void OnDestroy()
        {
            ReleaseInternalObjects();
        }

        private void ReleaseInternalObjects()
        {
            if (_sendInstance != IntPtr.Zero)
            {
                NDIlib.send_destroy(_sendInstance);
                _sendInstance = IntPtr.Zero;
            }

            if (_nativeArray != null)
            {
                _nativeArray.Value.Dispose();
                _nativeArray = null;
            }
        }

        private IEnumerator CaptureCoroutine()
        {
            for (var eof = new WaitForEndOfFrame(); true;)
            {
                yield return eof;

                ComputeBuffer converted = Capture();
                if (converted == null)
                {
                    continue;
                }

                Send(converted);
            }
        }

        private ComputeBuffer Capture()
        {
// #if !UNITY_EDITOR && UNITY_ANDROID
//             bool vflip = true;
// #else
//             bool vflip = false;
// #endif
            bool vflip = true;
            if (!_frameTextureSource.IsReady) return null;

            Texture texture = _frameTextureSource.GetTexture();
            _preview.texture = texture;

            _width = texture.width;
            _height = texture.height;

            ComputeBuffer converted = _formatConverter.Encode(texture, _enableAlpha, vflip);

            return converted;
        }

        private unsafe void Send(ComputeBuffer buffer)
        {
            if (_nativeArray == null)
            {
                int length = Utils.FrameDataCount(_width, _height, _enableAlpha) * 4;
                _nativeArray = new NativeArray<byte>(length, Allocator.Persistent);

                _bytes = new byte[length];
            }

            buffer.GetData(_bytes);
            _nativeArray.Value.CopyFrom(_bytes);

            void* pdata = NativeArrayUnsafeUtility.GetUnsafeReadOnlyPtr(_nativeArray.Value);

            // Data size verification
            if (_nativeArray.Value.Length / sizeof(uint) != Utils.FrameDataCount(_width, _height, _enableAlpha))
            {
                return;
            }

            // Frame data setup
            var frame = new NDIlib.video_frame_v2_t
            {
                xres = _width,
                yres = _height,
                line_stride_in_bytes = _width * 2,
                frame_rate_N = _frameRateNumerator,
                frame_rate_D = _frameRateDenominator,
                FourCC = _enableAlpha ? NDIlib.FourCC_type_e.FourCC_type_UYVA : NDIlib.FourCC_type_e.FourCC_type_UYVY,
                frame_format_type = NDIlib.frame_format_type_e.frame_format_type_progressive,
                p_data = (IntPtr)pdata,
                p_metadata = IntPtr.Zero,
            };

            // Send via NDI
            NDIlib.send_send_video_async_v2(_sendInstance, frame);
        }
    }
}

全体のフロー

まずは全体のフローを概観してから詳細を見ていきましょう。まず大きくは以下のフローで映像配信を行っています。

  1. NDIの初期化
  2. NDIのSenderインスタンスの生成
  3. 画面のキャプチャ
  4. キャプチャした画面の変換
  5. 映像の配信(Send)

という流れになります。特に、3~5を毎フレーム実行することでゲーム画面を配信し続けることになります。

NDIの初期化

まずはNDIの初期化を行います。具体的には以下の部分です。

WifiManager.Instance.SetupNetwork();

if (!NDIlib.Initialize())
{
    Debug.Log("NDIlib can't be initialized.");
    return;
}

最初の行の Wifimanager については後述します。初期化はライブラリの初期化メソッドを呼ぶだけです。初期化に失敗した場合 false が返るのでチェックしています。

WifiManagerの作成

冒頭で行っている処理はこの WifiManager をセットアップすることです。これを行わないとAndroidでは正常に動作しないため、Androidの場合にのみ実行するようにしています。

公式ドキュメントにこう書かれており、ネットワーク内のNDIデバイスを検知するのに必要なようなので作成します。

Because Android handles discovery differently than other NDI platforms, some additional work is needed. The NDI library requires use of the “NsdManager” from Android and, unfortunately, there is no way for a third-party library to do this on its own. As long as an NDI sender, finder, or receiver is instantiated, an instance of the NsdManager will need to exist to ensure that Android’s Network Service Discovery Manager is running and available to NDI. This is normally done by adding the following code to the beginning of your long running activities: At some point before creating an NDI sender, finder, or receiver, instantiate the NsdManager: You will also need to ensure that your application has configured to have the correct privileges required for this functionality to operate.

以下のようにして servicediscovery オブジェクトを取得する必要があり、この SetupNetwork メソッドを、NDIの各種機能を使う前に呼び出すことで無事にNDIが利用できるようになります。

この処理はこちらの記事を参考にさせていただきました。

namespace NDISample
{
    public class WifiManager
    {
#if UNITY_ANDROID && !UNITY_EDITOR
        private AndroidJavaObject _nsdManager;
#endif

        private static WifiManager _instance = new WifiManager();

        public static WifiManager Instance => _instance;

        private WifiManager()
        {
        }

        public void SetupNetwork()
        {
            // The NDI SDK for Android uses NsdManager to search for NDI video sources on the local network.
            // So we need to create and maintain an instance of NSDManager before performing Find, Send and Recv operations.
#if UNITY_ANDROID && !UNITY_EDITOR
            using (AndroidJavaObject activity = new AndroidJavaObject("com.unity3d.player.UnityPlayer").GetStatic<AndroidJavaObject>("currentActivity"))
            {
                using (AndroidJavaObject context = activity.Call<AndroidJavaObject>("getApplicationContext"))
                {
                    using (AndroidJavaObject nsdManager = context.Call<AndroidJavaObject>("getSystemService", "servicediscovery"))
                    {
                        _nsdManager = nsdManager;
                    }
                }
            }
#endif
        }
    }
}

NDI Senderインスタンスの生成

次に行うのはNDIライブラリが提供してくれているSenderのインスタンスを生成することです。コードを抜き出すと以下の部分です。

IntPtr nname = Marshal.StringToHGlobalAnsi(_ndiName);
NDIlib.SendSettings sendSettings = new NDIlib.SendSettings { NdiName = nname };
_sendInstance = NDIlib.send_create(sendSettings);
Marshal.FreeHGlobal(nname);

if (_sendInstance == IntPtr.Zero)
{
    Debug.LogError("NDI can't create a send instance.");
    return;
}

_ndiNameC#string 型なのでC++側に渡せるように変換します。

ドキュメントから説明を引用すると、

マネージド String の内容をアンマネージド メモリにコピーし、コピー時に ANSI 形式に変換します。

とあるので、マネージド領域の文字列をC++側で使える領域メモリにコピーしてくれるわけですね。

NDI Senderの生成には以下の構造体を渡す必要があるため、それを生成しているのが以下の箇所です。

NDIlib.SendSettings sendSettings = new NDIlib.SendSettings { NdiName = nname };

また SendSetting の定義は以下のようになっています。

[StructLayout(LayoutKind.Sequential)]
public struct SendSettings
{
    public IntPtr NdiName;
    public IntPtr Groups;
    [MarshalAs(UnmanagedType.U1)] public bool ClockVideo;
    [MarshalAs(UnmanagedType.U1)] public bool ClockAudio;
}

必須となるのは NdiName だけのようなので、今回はそれを指定してNDI Senderのインスタンスを生成しています。

画面のキャプチャ

上記までで初期化と準備が整ったので、ここからは実際にキャプチャを実行し映像を配信する部分を見ていきます。

画面キャプチャのためのコルーチンを起動

まず最初に、フレームの最後の状態をキャプチャするためコルーチンを起動します。

// コルーチンの起動
StartCoroutine(CaptureCoroutine());

// フレームの最後に画面をキャプチャし、それを変換、送信する
private IEnumerator CaptureCoroutine()
{
    for (var eof = new WaitForEndOfFrame(); true;)
    {
        yield return eof;

        ComputeBuffer converted = Capture();
        if (converted == null)
        {
            continue;
        }

        Send(converted);
    }
}

画面キャプチャ処理

実際に画面のキャプチャを行っているのは以下の部分になります。

private ComputeBuffer Capture()
{
// #if !UNITY_EDITOR && UNITY_ANDROID
//             bool vflip = true;
// #else
//             bool vflip = false;
// #endif
    bool vflip = true;
    if (!_frameTextureSource.IsReady) return null;

    Texture texture = _frameTextureSource.GetTexture();
    _preview.texture = texture;

    _width = texture.width;
    _height = texture.height;

    ComputeBuffer converted = _formatConverter.Encode(texture, _enableAlpha, vflip);

    return converted;
}

Texture texture = _frameTextureSource.GetTexture(); の部分が送信するテクスチャを取得している処理です。ここはインターフェースになっていて、画面のキャプチャ以外にも、Webカメラの映像を配信する、というような使い方もできるようにしてあります。(今回は画面をキャプチャした RenderTexture が渡ってきています)

そして得られたテクスチャをコンバータに渡してNDIに送信できるデータに変換します。

テクスチャをNDIに適合した形に変換する

変換を行っている処理は以下の部分になります。変換結果は ComputeBuffer として返されます。

public ComputeBuffer Encode(Texture source, bool enableAlpha, bool vflip)
{
    int width = source.width;
    int height = source.height;
    int dataCount = Utils.FrameDataCount(width, height, enableAlpha);

    // Reallocate the output buffer when the output size was changed.
    if (_encoderOutput != null && _encoderOutput.count != dataCount)
    {
        ReleaseBuffers();
    }

    // Output buffer allocation
    if (_encoderOutput == null)
    {
        _encoderOutput = new ComputeBuffer(dataCount, 4);
    }

    // Compute thread dispatching
    int pass = enableAlpha ? 1 : 0;
    _encoderCompute.SetInt("VFlip", vflip ? -1 : 1);
    _encoderCompute.SetTexture(pass, "Source", source);
    _encoderCompute.SetBuffer(pass, "Destination", _encoderOutput);
    _encoderCompute.Dispatch(pass, width / 16, height / 8, 1);

    return _encoderOutput;
}

冒頭ではデータカウントをチェックし、現在確保されているバッファのサイズと違う場合はバッファを生成し直します。(基本的に毎フレーム送信される映像のサイズが変わることはほぼないので、配信情報が変更された、などの特殊ケースの対応と考えるといいでしょう)

カウントの計算処理は以下のようになっています。

public static int FrameDataCount(int width, int height, bool alpha) => width * height * (alpha ? 3 : 2) / 4;

チェック後はコンピュートシェーダにテクスチャを送り、 ComputeBuffer にデータを詰め込みます。その起動処理は以下の部分です。

int pass = enableAlpha ? 1 : 0;
_encoderCompute.SetInt("VFlip", vflip ? -1 : 1);
_encoderCompute.SetTexture(pass, "Source", source);
_encoderCompute.SetBuffer(pass, "Destination", _encoderOutput);
_encoderCompute.Dispatch(pass, width / 16, height / 8, 1);

VFlip は縦の変換が必要な場合に指定します。また、アルファがあるかないかによって起動するカーネルを変更しています。ちょっと分かりづらいですが、ここではアルファが有効な場合はカーネルインデックス 1カーネルを、そうじゃない場合は 0カーネルを起動します。カーネルの実装は以下のようになっています。

// インデックス 0 のカーネル
[numthreads(8, 8, 1)]
void EncodeUYVY(uint2 id : SV_DispatchThreadID)
{
    uint2 sp = id * uint2(2, 1);

    float4 s0 = Source[sp + uint2(0, 0)];
    float4 s1 = Source[sp + uint2(1, 0)];

    float3 yuv1 = RGB2YUV(s0.xyz);
    float3 yuv2 = RGB2YUV(s1.xyz);

    float u = (yuv1.y + yuv2.y) / 2;
    float v = (yuv1.z + yuv2.z) / 2;
    float4 uyvy = float4(u, yuv1.x, v, yuv2.x);

    uint w, h;
    Source.GetDimensions(w, h);

    uint sy = VFlip < 0 ? h - 1 - id.y : id.y;
    Destination[sy * w / 2 + id.x] = PackUYVY(uyvy);
}
// インデックス 1 のカーネル
[numthreads(4, 8, 1)]
void EncodeUYVA(uint2 id : SV_DispatchThreadID)
{
    uint2 sp = id * uint2(4, 1);

    float4 s0 = Source[sp + uint2(0, 0)];
    float4 s1 = Source[sp + uint2(1, 0)];
    float4 s2 = Source[sp + uint2(2, 0)];
    float4 s3 = Source[sp + uint2(3, 0)];

    float3 yuv0 = RGB2YUV(s0.xyz);
    float3 yuv1 = RGB2YUV(s1.xyz);
    float3 yuv2 = RGB2YUV(s2.xyz);
    float3 yuv3 = RGB2YUV(s3.xyz);

    float u01 = (yuv0.y + yuv1.y) / 2;
    float v01 = (yuv0.z + yuv1.z) / 2;

    float u23 = (yuv2.y + yuv3.y) / 2;
    float v23 = (yuv2.z + yuv3.z) / 2;

    float4 uyvy01 = float4(u01, yuv0.x, v01, yuv1.x);
    float4 uyvy23 = float4(u23, yuv2.x, v23, yuv3.x);

    uint w, h;
    Source.GetDimensions(w, h);

    uint sy = VFlip < 0 ? h - 1 - id.y : id.y;
    uint dp1 = sy * w / 2 + id.x * 2;
    uint dp2 = sy * w / 4 + id.x + w * h / 2;

    Destination[dp1 + 0] = PackUYVY(uyvy01);
    Destination[dp1 + 1] = PackUYVY(uyvy23);
    Destination[dp2] = PackAAAA(float4(s0.w, s1.w, s2.w, s3.w));
}

不透明画像はUYVYフォーマットに変換

まずはインデックス 0カーネルから見ていきましょう。0番のカーネルは不透明画像を変換します。変換先フォーマットはUYVYです。詳細は以下の記事が参考になります。

www.argocorp.com

概要を引用させてもらうと

UYVY は基本的には16ビットのカラーフォーマットです。RGBフォーマットとは違い、それにはred,green,blueのような値は含んでおりません。その代わりに輝度と色度を使用します。Yは輝度を表し、U (or Cb) と V (or Cr) が色度を表します。

ということです。

また、データ構造としてやや特殊なフォーマットになっているのでここで少し詳しく見てみましょう。

記事から説明を引用させてもらうと、

// UYVY ではイメージは上から順にで保存されていくので左上のピクセルからバイト0として始まります。
// 4バイト分が隣り合う2つのピクセルの色を表現します。
//
// [ U0 | Y0 | V0 | Y1 ]
//
// Y0 はピクセル0の輝度を、Y1 がピクセル1の輝度を表します。
// U0 と V0 は2つのピクセルの色を表現します。
struct UYVYQuad
{
     BYTE U0;
     BYTE Y0;
     BYTE V0;
     BYTE Y1;
};

と書かれています。つまり、UYVYフォーマットでは2ピクセルを単位としてデータを保存しているということになります。だいぶ特殊なフォーマットに感じますね。

簡単に2ピクセルだけのデータを考えてみましょう。UYVYフォーマットでは隣あうふたつのピクセルを4バイトで表すのでした。2pxのデータということは width = 2height = 1 となります。そして2ピクセルに対して4バイトのデータを扱うことになるのでその倍率は 2 です。そして4バイトでひとつ分のデータとなるので、データ数(data count)を計算する場合はそれを4バイトの 4 で割ることで得られます。

これが、 width * height * (alpha ? 3 : 2) / 4; の計算式の正体です。(不透明、つまり alphafalse のときは 2 倍されている)

半透明画像はUYVAフォーマットに変換

半透明の場合のフォーマットがドキュメントに載っていなかったので、あくまで実装コードからの推測になりますが、UYVAの場合は、4ピクセルに対して12バイトのデータを扱います。つまり倍率は 3 です。アルファ付きの場合に 3 倍になっているのはここから来ています。

保存している部分を見てみると

Destination[dp1 + 0] = PackUYVY(uyvy01);
Destination[dp1 + 1] = PackUYVY(uyvy23);
Destination[dp2] = PackAAAA(float4(s0.w, s1.w, s2.w, s3.w));

となって、バッファに対して3要素格納しているのが分かります。(ピクセルは4px分取り出しています)

変換の詳細については以下のMicrosoftのドキュメントを参照してください。ここで紹介したフォーマット以外の説明もあります。

docs.microsoft.com

キャプチャした映像を送信する

前段までで画面のキャプチャおよびNDIに送信するための画像フォーマットの変換が完了しました。あとはSDKを利用してこれを送信します。送信部分のコードを再掲するので詳しく見ていきましょう。

private unsafe void Send(ComputeBuffer buffer)
{
    if (_nativeArray == null)
    {
        int length = Utils.FrameDataCount(_width, _height, _enableAlpha) * 4;
        _nativeArray = new NativeArray<byte>(length, Allocator.Persistent);

        _bytes = new byte[length];
    }

    buffer.GetData(_bytes);
    _nativeArray.Value.CopyFrom(_bytes);

    void* pdata = NativeArrayUnsafeUtility.GetUnsafeReadOnlyPtr(_nativeArray.Value);

    // Data size verification
    if (_nativeArray.Value.Length / sizeof(uint) != Utils.FrameDataCount(_width, _height, _enableAlpha))
    {
        return;
    }

    // Frame data setup
    var frame = new NDIlib.video_frame_v2_t
    {
        xres = _width,
        yres = _height,
        line_stride_in_bytes = _width * 2,
        frame_rate_N = _frameRateNumerator,
        frame_rate_D = _frameRateDenominator,
        FourCC = _enableAlpha ? NDIlib.FourCC_type_e.FourCC_type_UYVA : NDIlib.FourCC_type_e.FourCC_type_UYVY,
        frame_format_type = NDIlib.frame_format_type_e.frame_format_type_progressive,
        p_data = (IntPtr)pdata,
        p_metadata = IntPtr.Zero,
    };

    // Send via NDI
    NDIlib.send_send_video_async_v2(_sendInstance, frame);
}

冒頭で行っているのは初回のみ NativeArray の領域確保です。そのあとの処理が、バッファからデータを読み出している箇所になります。

buffer.GetData(_bytes);
_nativeArray.Value.CopyFrom(_bytes);

void* pdata = NativeArrayUnsafeUtility.GetUnsafeReadOnlyPtr(_nativeArray.Value);

ComputeBuffer から NativeArray へ変換しています。そして最後の部分は NativeArray のポインタを取得しています。SDKC++で書かれており、アンマネージド領域で該当データを扱う必要があるためポインタを取得しているわけです。

そして取得したデータに誤りがないか(要素数に問題がないか)確認をした後、 NDIlib.video_frame_v2_t 構造体に必要な情報を詰めて送信を実行しています。

この構造体を生成する際に、アルファの有無やサイズなどと一緒に、前述の NativeArray のポインタを設定しています。

そして最後に NDIlib.send_send_video_async_v2() メソッドからネイティブ実装を呼び出し、生成した構造体を引数に送信を行っています。

第一引数に NDI Senderインスタンスを渡しているのは、C#側ではC++クラスのインスタンスをダイレクトに生成することができず、ポインタという形でしか保持できないためDLLを利用したネイティブ実装側のクラスのメソッド呼び出しはこういう形になるのが一般的です。

これで無事、映像の送信をすることができました。