e.blog

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

Unity向けのNDI SDKのPlugin化を通してC++実装の扱い方の勘所を押さえる

概要

今回はNDI SDKという、ネットワーク越しに動画などを送受信する技術のPlugin化を考えます。(実用に耐えるものではなく、あくまで学ぶ目的)

その上で、C++実装をUnity側で利用したい場合にどうするのか、どういう知識が必要なのかについて簡単にまとめます。また、C++側で生成されたピクセルデータを使ってUnity側のテクスチャに反映させる処理も解説します。今回はこのNDIデバイスからの信号受信およびそのビデオフレームをUnityのテクスチャに反映する、というところまでを、どうやって調べてどう実装したかを順を追って説明していきたいと思います。

実際に作ってみたキャプチャがこちら↓

今回実装したものはGitHubにアップしてあります。

github.com


必要知識

DLLを扱っていくにあたって知っておいたほうがいい内容について書いていきます。(特に今回のPlugin化の説明に必要な部分に絞っています)

最初に頭出しをすると以下の内容です。

  • P/Invoke
  • アンマネージド領域向けの構造体定義

の2点です。

P/Invoke

P/Invokeとは、マネージドコード側からアンマネージドなコードを呼び出す仕組みのことです。ドキュメントから引用すると以下。

P/Invoke は、アンマネージド ライブラリ内の構造体、コールバック、および関数をマネージド コードからアクセスできるようにするテクノロジです。P/Invoke API のほとんどは、 SystemSystem.Runtime.InteropServices の 2 つの名前空間に含まれます。 これら 2 つの名前空間を使用すると、ネイティブ コンポーネントと通信する方法を記述するツールを利用できます。

docs.microsoft.com

ドキュメントに掲載されていたコード断片を引用すると以下のようになります。

using System;
using System.Runtime.InteropServices;

public class Program
{
    // Import user32.dll (containing the function we need) and define
    // the method corresponding to the native function.
    [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
    private static extern int MessageBox(IntPtr hWnd, string lpText, string lpCaption, uint uType);

    public static void Main(string[] args)
    {
        // Invoke the function as a regular managed method.
        MessageBox(IntPtr.Zero, "Command-line message box", "Attention!", 0);
    }
}

System.Runtime.InteropServices 名前空間に含まれる DllImport Attributeを指定することで、該当のメソッドがDLLで定義されていることをシステムに伝えることができます。上記の例では MessageBox というメソッドがDLL内で定義されていることを伝えているわけです。サンプル以外にも引数を取ることができ、例えば EntryPoint などは定義したメソッドとDLL内で定義されている関数名が違う場合に利用することができます。

それ以外にも関数の呼び出し規約の指定などもあるので、詳細についてはドキュメントをご覧ください。

docs.microsoft.com

アンマネージド領域向けの構造体定義

アンマネージド領域向けの構造体の定義は少し工夫する必要があります。なぜ工夫が必要かと言うと、C#コンパイラは場合によっては融通を利かせて、適当に定義した構造体でもメモリレイアウトに優しい配置に変えてくれたりします。しかし、これがアンマネージド領域とやり取りするための構造体だとむしろ余計なお世話になってしまいます。

このあたりは「C# メモリアラインメント」などのワードで検索してみると色々情報が見つかるかと思います。

詳細はこちらの記事が詳しいので興味がある方は見てみてください。

ufcpp.net

工夫その1. StructLayoutAttribute

StructLayoutAttributeとは、アンマネージド領域の構造体の領域を定義する際のメモリレイアウトについて指定するための属性です。これを指定することで、C#コンパイラがメモリレイアウトを最適化してしまうのを防ぐことができます。

docs.microsoft.com

工夫その2. MarshalAsAttribute

MarshalAsAttributeはマーシャリングのための属性です。ドキュメントから引用すると、

マネージド コードとアンマネージド コードとの間のデータのマーシャリング方法を示します。

と記載があります。そもそもマーシャリングとはなんでしょうか?

docs.microsoft.com

マーシャリング

マーシャリングを以下の記事から引用すると、

マーシャリングとは、異なる技術基盤で実装されたコンピュータプログラム間で、データの交換や機能の呼び出しができるようデータ形式の変換などを行うこと。

と書かれています。ざっくりと言ってしまうと、シリアライズ/デシリアライズと同義と似た概念と言えるかと思います。

e-words.jp

ちなみに Marshal英単語の意味を調べてみると以下のように記載がありました。

  • 《軍事》〔部隊を〕整列[集結]させる
  • 〔人を〕集める、組織化する
  • 〔考えや事実などを〕整理する、まとめる
  • 〔儀式などで人を〕先導する、案内する

この単語の意味からもなんとなく意味が推測できるのではないかなと思います。

ドキュメントのコード断片を引用すると、

decimal _money;   

public decimal Money 
{
   [return: MarshalAs(UnmanagedType.Currency)]
   get { return this._money; }
   [param: MarshalAs(UnmanagedType.Currency)]
   set { this._money = value; }
}

こんな感じで、 returnparam (引数)に指定することができます。

ドキュメントを見てみるとこの列挙体はいくつかあり、ネイティブ側でどういう値として扱ってほしいかを指定するものとなっています。(例えば UnmanagedType.U1 は「1 バイト符号なし整数」)

docs.microsoft.com

以上で必要な知識の説明は終わりです。次からは実際にどうやって調査したかを解説していきます。

NDI SDKをインストールする

NDI SDKをインストールします。SDKはフォームを入力してダウンロードリンクをメールでもらうことでダウンロードできます。リンク先ページの「Software Developer Kit」のところにある「Download」ボタンを押すとフォームが表示されるので必要な情報を入力します。メールをもらったら、そこにダウンロードリンクが記載されているので必要なSDKをダウンロードしインストールします。(今回の例ではfor Windows版をインストールします)

NDI SDKの移植方法を探る

冒頭で書いた通り、今回はゼロベースでNDI SDKをUnityに移植する方法を考えます。なのでまずはDLLがどう使われているのか、どうUnityに移植したらいいかのヒントを見つけるところから解説していきます。

ライブラリの実装サンプルを見る

Unity Pluginが公式で提供されていないものを対応させるという視点から実装を行います。どう使われているのかから探るためサンプルの実装を見ていきます。

Visual Stuidioのソリューションを開く

NDI SDKには幸いにしてC#のサンプルソリューションが付属しているのでそれを開きます。デフォルトでは C:\Program Files\NewTek\NDI 4 SDK にインストールされているので、ここにある NDI SDK Examples.sln ファイルをVisual Studioで開きます。

開くと以下のように、ソリューション一覧にサンプルが並んでいます。


エラーが表示されたら

場合によっては開いた際にエラーかワーニングが出るかもしれません。自分の環境では.NET Frameworkのバージョンに関するワーニングが表示されました。基本はOKを押していくだけで大丈夫ですが、プロジェクトごとに違うバージョンが指定されていることがあり、そのせいでビルドが行えなかったのでその場合はプロジェクトのプロパティを開いて.NET Frameworkのバージョンを合わせます。

以下の画像のように、ソリューションエクスプローラ内の該当プロジェクトを右クリックしプロパティを開きます。そして開かれたウィンドウ内の対象のフレームワークの部分を4.6.1に変更します。(異なっていた場合)

すべてのプロジェクトでバージョンを合わせるとビルドが通るようになります。


Managed NDI Recvを実行してみる

Visual Studioの以下の画像の部分をManaged NDI Recvに変更し、その右側にある開始ボタンを押下すると対象プロジェクトを起動することができます。

ビルドが成功し起動すると以下のようなウィンドウが表示されます。

このウィンドウは、NDIデバイスからの信号を受信し、その映像を中央のビューに表示するというデモです。つまり、冒頭で書いた通り、今回のNDIデバイスからの信号受信およびそのビデオフレームをUnityのテクスチャに反映する方法について調べるのに適した対象というわけです。なのでこのプロジェクトを紐解いていきましょう。

MainWindow.xamlを見る

解析に際してまずは対象プロジェクトのメインウィンドウのスクリプトを見てみます。ソリューションエクスプローラMainWindow.xaml を展開すると MainWindow.xaml.cs というC#ファイルがあるのでこれを開きます。これは MainWindow クラスの partial クラスになっていて、デザインウィンドウで配置されたUIなどにイベント処理などを追加していくためのファイルです。(すみません、Visual Studioに詳しくないのでざっくりとした説明です)

ファイルを開くと比較的短いコードが表示されます。そしてその後半部分に以下のような記載があります。

public Finder FindInstance
{
    get { return _findInstance; }
}
private Finder _findInstance = new Finder(true);

Finderクラスを見る

Finder の名前からするにNDIデバイスを探すクラスのようです。これを開いてみると、確かにNDIデバイスを検索する処理が書かれていました。

そしてコンストラクタを見てみると以下のように記述があります。

if (!NDIlib.initialize())
{
    // ...
}

名前から察するにライブラリの初期化でしょう。ライブラリ、つまりDLLへのアクセスをしていそうな箇所を見つけました。さっそく初期化部分の実装を見てみると以下のようになっていました。

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_initialize", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
[return: MarshalAsAttribute(UnmanagedType.U1)]
internal static extern bool initialize_64( );

必要知識のところで解説した DLLImport Attributeが登場しました。ここがまさにライブラリ部分を呼び出している箇所というわけですね。定義を見るとライブラリ側の初期化処理を呼び出し、成功したかどうかを bool で返す仕様のようです。

なんとなくどう実装されているかが見えてきました。もう少し先に進んで続く処理がどうなっているのか見てみましょう。

// how we want our find to operate
NDIlib.find_create_t findDesc = new NDIlib.find_create_t()
{
    p_groups = groupsNamePtr,
    show_local_sources = showLocalSources,
    p_extra_ips = extraIpsPtr
};

// create our find instance
_findInstancePtr = NDIlib.find_create_v2(ref findDesc);

// ... 中略

// start up a thread to update on
_findThread = new Thread(FindThreadProc) { IsBackground = true, Name = "NdiFindThread" };
_findThread.Start();

NDIlib クラスのメソッド呼び出しがいくつかあります。またその引数には、事前に生成した構造体を渡しているのが分かります。これがまさに、前述した StructLayout Attributeを指定した構造体を利用している部分です。この構造体の定義を見てみましょう。

[StructLayoutAttribute(LayoutKind.Sequential)]
public struct find_create_t
{
    // Do we want to incluide the list of NDI sources that are running
    // on the local machine ?
    // If TRUE then local sources will be visible, if FALSE then they
    // will not.
    [MarshalAsAttribute(UnmanagedType.U1)]
    public bool   show_local_sources;

    // Which groups do you want to search in for sources
    public IntPtr  p_groups;

    // The list of additional IP addresses that exist that we should query for
    // sources on. For instance, if you want to find the sources on a remote machine
    // that is not on your local sub-net then you can put a comma seperated list of
    // those IP addresses here and those sources will be available locally even though
    // they are not mDNS discoverable. An example might be "12.0.0.8,13.0.12.8".
    // When none is specified the registry is used.
    // Default = NULL;
    public IntPtr  p_extra_ips;
}

MarshalAsAttribute が指定されていますね。レイアウトを指定することで、C++で作られたライブラリからの戻り値として構造体を利用できるようになるというわけなんですね。

ちなみに Processing.NDI.Lib.x64.dll は以下の場所に保存されています。

C:\Program Files\NewTek\NDI 4 SDK\Bin\x64

このDLLをUnityの Plugins フォルダにインポートしてC#のサンプルのように記述すればNDI SDKを利用することができそうです。

映像の受信部分を見てみる

映像の受信部分も調査してみましょう。映像の受信部分はサンプルプロジェクトの黒いビューの部分です。Visual Studioで該当箇所を選択するとそれに紐づくXAMLファイル内の記述も一緒にフォーカスされます。

NDI:ReceiveView という部分がフォーカスされているのが分かると思います。これはC#のクラスになっているので、参照へジャンプを行うとその実装を見ることができます。

実装を眺めてみると取っ掛かりになりそうなメソッドがありました。 ConnectReceiveThreadProc です。 Connect メソッドは検知したNDIデバイスが選択された際に呼ばれそのソースに対して接続を試み、接続が正常にできたらそのデバイスからの映像データを受信するという流れになっています。

受信処理は ReceiveThreadProc が担当しています。このクラスを紐解いていけばUnityに移植することができそうです。


Visual StudioによるC#アプリ開発

ここからは少し脱線して、Visual Studioを使ってのアプリ開発について少し補足しておきます。(というより自分用のメモですw)なので、NDI SDKの移植についてのみ知りたい方はここはスキップしても大丈夫です。

DependencyPropertyとPropertyMetadata

最初、 Connect メソッドがどう呼ばれているのかよく分かりませんでした。なにかのコールバックから該当メソッドを呼んでいるのは分かったのですが、それを実際に呼び出している箇所が見当たらない。そこで、きっとVisual Studioでの開発フローがあるのだと思い調べました。

結論から言うと、見出しに書いた2点、つまり DependencyPropertyPropertyMetadata が関係していました。

DependencyPropertyとは

ドキュメントから引用すると以下のように説明されています。

Represents a dependency property that is registered with the dependency property system. Dependency properties provide support for value expressions, data binding, animation, and property change notification.

要約すると、データバインディングのためのクラスで、そうしたシステムに対しての登録などをサポートするもののよう。

また、「チュートリアル: XAML デザイナーでデータにバインドする」から引用すると、

XAML デザイナーで、アートボードと [プロパティ] ウィンドウを使用してデータ バインディング プロパティを設定できます。 このチュートリアルの例では、データをコントロールにバインドする方法を示します。 具体的には、ItemCount という名前の DependencyProperty を持つ簡単なショッピング カート クラスを作成した後、ItemCount プロパティを TextBlock コントロールの Text プロパティにバインドする方法を説明します。

とあることから、XAMLから設定して利用するためのクラスっぽいです。

ドキュメント

docs.microsoft.com

データのバインド

データバインディングの手順は以下。(なお、詳細についてはドキュメントを参照)

  • バインディングに利用するクラスを定義する
  • (1)のクラスは DependencyProperty クラスを継承したもの
  • すると、 GetValue , SetValue メソッドが継承されるので、バインディングに使用するプロパティにて利用する。(詳細は*1を参照)
  • UIデザイナーでUIを配置し、その DataContext がどれかを指定する

*1 DependencyObject を継承しているサンプルの ShoppingCart クラス。

public class ShoppingCart : DependencyObject
{
    public int ItemCount
    {
        get { return (int)GetValue(ItemCountProperty); }
        set { SetValue(ItemCountProperty, value); }
    }

    public static readonly DependencyProperty ItemCountProperty =
        DependencyProperty.Register("ItemCount", typeof(int), typeof(ShoppingCart), new PropertyMetadata(0));
}

UI配置後のXAMLText="{Binding ItemCount}" という形でバインディングされているのが分かります。これにより、 ItemCount = 33; などのように値を設定するだけで、自動的にUIが書き換わる仕組みになっているのです。(そのためインスタンスの生成処理などは自動的にされる)

<Grid>
    <Grid HorizontalAlignment="Left" Height="100" Margin="87,63,0,0" VerticalAlignment="Top" Width="100">
        <Grid.DataContext>
            <local:ShoppingCart/>
        </Grid.DataContext>
        <TextBlock HorizontalAlignment="Left" Margin="33,59,0,0" TextWrapping="Wrap" Text="{Binding ItemCount}" VerticalAlignment="Top"/>
    </Grid>
</Grid>

PropertyMetadataについて

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

登録時の条件など、特定の種類に適用されるときの依存関係プロパティの動作を定義します。

docs.microsoft.com

PropertyMetadataのコンストラク

コンストラクタはいくつかのオーバーロードがあり、そのうち、第2引数に取るのは変更時のコールバック関数。

PropertyMetadata(Object, PropertyChangedCallback);

指定した既定値と PropertyMetadata 実装参照を使用して、 PropertyChangedCallback クラスの新しいインスタンスを初期化します。

ここまでのまとめ

Visual Studioでの開発ではこの「データバインディング」を活用してシンプルにプログラムできる仕組みが備わっています。そのため、逆にコードから処理を追おうとすると混乱が生じます。が、分かってしまえばそこまでむずかしいものでもないと思います。

今回の例では DependencyProperty という仕組みによってUIの連携として対象のNDIデバイスを取得、設定されるという流れになっていました。

ここで分かったことは、発見したNDIデバイスはリストから選択された時点で自動的にビューに渡されているということです。なのでUnityへの移植ではここは考えず、発見したNDIデバイスを利用して受信処理を書けばいいということが分かりました。


Unityへ移植する

サンプルプロジェクトを見ながら必要な情報が揃いました。ここからは実際にUnityへ移植する作業になります。

DLLをインポート

なにはなくともDLL自体がないと始まりません。ということで、DLLをUnityへインポートします。Unity内でDLLを利用する場合は Plugins フォルダを作成し、その中に配置します。DLLのファイルは以下の場所にあります。

C:\Program Files\NewTek\NDI 4 SDK\Bin\x64

Pluginsフォルダに入れる

構造体を定義する

まずは構造体を定義しましょう。サンプルプロジェクトで定義されているものをそのまま持ってくればよさそうです。今回必要になる部分に絞って持ってくると以下のようになります。

※ 構造体定義に合わせて必要な enum も定義しています。

public static class NDIlib
{
    public enum recv_color_format_e
    {
        // No alpha channel: BGRX Alpha channel: BGRA
        recv_color_format_BGRX_BGRA = 0,

        // No alpha channel: UYVY Alpha channel: BGRA
        recv_color_format_UYVY_BGRA = 1,

        // No alpha channel: RGBX Alpha channel: RGBA
        recv_color_format_RGBX_RGBA = 2,

        // No alpha channel: UYVY Alpha channel: RGBA
        recv_color_format_UYVY_RGBA = 3,

        // On Windows there are some APIs that require bottom to top images in RGBA format. Specifying
        // this format will return images in this format. The image data pointer will still point to the
        // "top" of the image, althought he stride will be negative. You can get the "bottom" line of the image
        // using : video_data.p_data + (video_data.yres - 1)*video_data.line_stride_in_bytes
        recv_color_format_BGRX_BGRA_flipped = 200,

        // Read the SDK documentation to understand the pros and cons of this format.
        recv_color_format_fastest = 100,

        // Legacy definitions for backwards compatibility
        recv_color_format_e_BGRX_BGRA = recv_color_format_BGRX_BGRA,
        recv_color_format_e_UYVY_BGRA = recv_color_format_UYVY_BGRA,
        recv_color_format_e_RGBX_RGBA = recv_color_format_RGBX_RGBA,
        recv_color_format_e_UYVY_RGBA = recv_color_format_UYVY_RGBA
    }

    public enum recv_bandwidth_e
    {
        recv_bandwidth_metadata_only = -10,
        recv_bandwidth_audio_only = 10,
        recv_bandwidth_lowest = 0,
        recv_bandwidth_highest = 100,
    }
    
    public enum FourCC_type_e
    {
        // YCbCr color space
        FourCC_type_UYVY = 0x59565955,

        // 4:2:0 formats
        NDIlib_FourCC_video_type_YV12 = 0x32315659,
        NDIlib_FourCC_video_type_NV12 = 0x3231564E,
        NDIlib_FourCC_video_type_I420 = 0x30323449,

        // BGRA
        FourCC_type_BGRA = 0x41524742,
        FourCC_type_BGRX = 0x58524742,

        // RGBA
        FourCC_type_RGBA = 0x41424752,
        FourCC_type_RGBX = 0x58424752,

        // This is a UYVY buffer followed immediately by an alpha channel buffer.
        // If the stride of the YCbCr component is "stride", then the alpha channel
        // starts at image_ptr + yres*stride. The alpha channel stride is stride/2.
        FourCC_type_UYVA = 0x41565955
    }

    public enum frame_type_e
    {
        frame_type_none = 0,
        frame_type_video = 1,
        frame_type_audio = 2,
        frame_type_metadata =3,
        frame_type_error = 4,
        
        frame_type_status_change = 100,
    }

    public enum frame_format_type_e
    {
        frame_format_type_progressive = 1,
        frame_format_type_interleaved = 0,
        frame_format_type_field_0 = 2,
        frame_format_type_field_1 = 3,
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct video_frame_v2_t
    {
        public int xres;
        public int yres;
        public FourCC_type_e FourCC;
        public int frame_rate_N;
        public int frame_rate_D;
        public float picture_aspect_ratio;
        public frame_format_type_e frame_format_type;
        public Int64 timecode;
        public IntPtr p_data;
        public int line_stride_in_bytes;
        public IntPtr p_metadata;
        public Int64 timestamp;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct audio_frame_v2_t
    {
        public int sample_rate;
        public int no_channels;
        public int no_samples;
        public Int64 timecode;
        public IntPtr p_data;
        public int channels_stride_in_bytes;
        public IntPtr p_metadata;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct metadata_frame_t
    {
        public int length;
        public Int64 timecode;
        public IntPtr p_data;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct find_create_t
    {
        [MarshalAs(UnmanagedType.U1)] public bool show_local_sources;
        public IntPtr p_groups;
        public IntPtr p_extra_ips;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct source_t
    {
        public IntPtr p_ndi_name;
        public IntPtr p_url_address;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct recv_create_v3_t
    {
        public source_t source_to_connect_to;
        public recv_color_format_e color_format;
        public recv_bandwidth_e bandwidth;

        [MarshalAs(UnmanagedType.U1)]
        public bool allow_video_fields;

        public IntPtr p_ndi_recv_name;
    }

    [StructLayout(LayoutKind.Sequential)]
    public struct tally_t
    {
        [MarshalAs(UnmanagedType.U1)] public bool on_program;
        [MarshalAs(UnmanagedType.U1)] public bool on_preview;
    }

    // ... 以下略
}

基本的にはサンプルプロジェクトからそのまま持ってくればいいでしょう。もし自分で定義したい場合はメモリレイアウトに注意しながら構造体を定義します。また忘れずに StructLayoutAttributeLayoutKind.Sequential に指定します。

これで構造体を使う準備ができました。次にDLLで定義された関数を呼び出すためのメソッドを定義しましょう。

DLL側の関数を呼び出すメソッドを定義する

これも同様にコピーしてくれば大丈夫ですが、今回はあくまで学ぶためのものなのでCPUなどの細かい制約は気にせず、Windowsのx64向けにだけ動けばいいことにして作業を進めます。すると以下のようになります。

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_initialize", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
[return: MarshalAs(UnmanagedType.U1)]
public static extern bool Initialize();

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_find_create_v2", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr find_create_v2(ref find_create_t p_create_setings);

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_find_destroy", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
public static extern void find_destroy(IntPtr p_instance);

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_find_wait_for_sources", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
[return: MarshalAs(UnmanagedType.U1)]
public static extern bool find_wait_for_sources(IntPtr p_instance, UInt32 timeout_in_ms);

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_find_get_current_sources", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr find_get_current_sources(IntPtr p_instance, ref UInt32 p_no_sources);

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_recv_create_v3", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr recv_create_v3(ref recv_create_v3_t p_create_settings);

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_recv_set_tally", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
public static extern bool recv_set_tally(IntPtr p_instance, ref tally_t p_tally);

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_recv_capture_v2", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
public static extern frame_type_e recv_capture_v2(IntPtr p_instance, ref video_frame_v2_t p_video_data, ref audio_frame_v2_t p_audio_data, ref metadata_frame_t p_metadata, UInt32 timeout_in_ms);

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_recv_ptz_is_supported", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
[return: MarshalAs(UnmanagedType.U1)]
public static extern bool recv_ptz_is_supported(IntPtr p_instance);

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_recv_recording_is_supported", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
[return: MarshalAs(UnmanagedType.U1)]
public static extern bool recv_recording_is_supported(IntPtr p_instance);

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_recv_get_web_control", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr recv_get_web_control(IntPtr p_instance);

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_recv_free_string", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
public static extern void recv_free_string(IntPtr p_instance, IntPtr p_string);

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_recv_free_video_v2", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
public static extern void recv_free_video_v2(IntPtr p_instance, ref video_frame_v2_t p_video_data);

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_recv_free_audio_v2", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
public static extern void recv_free_audio_v2(IntPtr p_intance, ref audio_frame_v2_t p_audio_data);

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_recv_free_metadata", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
public static extern void recv_free_metadata(IntPtr p_instance, ref metadata_frame_t p_metadata);

[DllImport("Processing.NDI.Lib.x64.dll", EntryPoint = "NDIlib_recv_destroy", ExactSpelling = true, CallingConvention = CallingConvention.Cdecl)]
public static extern void recv_destroy(IntPtr p_instance);

x64に決め打ちしているので参照するDLLも Processing.NDI.Lib.x64.dll にしています。実際の開発ではCPUアーキテクチャに従って適切にDLLを参照するように分岐を作る必要がある点に注意してください。


CallingConvention = CallingConvention.Cdeclとは

ちょっとだけ脱線して CallingConvention について補足しておきます。ここはCPUがどうやって関数などを実行するかについての話になるので実装を進めたい人はスキップしても大丈夫です。

CallingConvention は列挙型になっています。いくつか値があるので詳細はドキュメントをご覧ください。

docs.microsoft.com

関数呼び出しには「呼び出し規約」と呼ばれるものがあります。Microsoftのドキュメントは以下です。

docs.microsoft.com

ドキュメントから引用すると以下のように説明されています。

Visual C/C++ コンパイラには、内部関数と外部関数の呼び出し規約がいくつか用意されています。 これらの異なる方法を理解することは、プログラムをデバッグし、コードをアセンブリ言語ルーチンとリンクするときに役立ちます。

この話題に関するトピックでは、呼び出し規則の違い、引数の渡し方、関数による値の返し方について説明します。 naked 関数の呼び出し (独自のプロローグおよびエピローグ コードを記述できる高度な機能) についても説明します。

規約と呼ばれるように、関数を呼び出す際の取り決めを定義するものです。例えば「どうやって引数を渡すのか」というようなことを規定します。これはABI(Application Binary Interface)に含まれるものです。

この指定が必要な理由は、コンパイルされた内容がコンパイラや設定に依存するためDLL内 の関数がどう定義されているか、を決めないと正確に関数が呼び出せないためです。なのでこうした指定が必要というわけなんですね。

以下の記事はC++クラスのインスタンスメソッドを無理やりC#から呼び出す実装を模索している記事です。これも参考になるので興味がある方は見てみてください。

qiita.com


閑話休題

NDIデバイスの検索

サンプルプロジェクトを参考に、UnityでNDIデバイスの検索部分を実装します。基本的にはサンプルプロジェクトから持ってくるだけで十分ですが、いくつかはUnity用に変更します。

検索部分の重要な部分だけを抜き出すと以下になります。

private void Find()
{
    IntPtr groupsPtr = IntPtr.Zero;
    IntPtr extraIpsPtr = IntPtr.Zero;

    NDIlib.find_create_t findDesc = new NDIlib.find_create_t()
    {
        p_groups = groupsPtr,
        show_local_sources = true,
        p_extra_ips = extraIpsPtr,
    };

    // Create out find instance.
    _findInstancePtr = NDIlib.find_create_v2(ref findDesc);

    // ... 中略

    Task.Run(() => { SearchForWhile(1.0f); });
}

find_create_t 構造体を作成し、DLL側のメソッドを実行しています。ここで、検索用機能を持ったインスタンスを生成しています。これを利用して実際の検索を行います。検索処理は以下です。

private void SearchForWhile(float minutes)
{
    DateTime startTime = DateTime.Now;

    while (!_stopFinder && DateTime.Now - startTime < TimeSpan.FromMinutes(minutes))
    {
        if (!NDIlib.find_wait_for_sources(_findInstancePtr, 1000))
        {
            Debug.Log("No change to the sources found.");
            continue;
        }

        // Get the updated list of sources.
        uint numSources = 0;
        IntPtr p_sources = NDIlib.find_get_current_sources(_findInstancePtr, ref numSources);

        // Continue if no device is found.
        if (numSources == 0)
        {
            continue;
        }

        int sourceSizeInBytes = Marshal.SizeOf(typeof(NDIlib.source_t));

        for (int i = 0; i < numSources; i++)
        {
            IntPtr p = IntPtr.Add(p_sources, (i * sourceSizeInBytes));

            NDIlib.source_t src = (NDIlib.source_t)Marshal.PtrToStructure(p, typeof(NDIlib.source_t));

            // .Net doesn't handle marshaling UTF-8 strings properly.
            String name = NDIlib.Utf8ToString(src.p_ndi_name);

            if (_sourceList.All(item => item.Name != name))
            {
                NDIlib.Source source = new NDIlib.Source(src);
                _sourceList.Add(source);

                _ndiReceiver.Connect(source);
            }
        }
    }
}

find_wait_for_sources 関数によってネットワーク内にNDIデバイスがあるかを監視し、もしあれば処理を続ける形になっています。実際にデバイスソースを取得するのは find_get_current_sources 関数です。そしてもしデバイスが見つかったら、レシーバクラスにそのソースを渡し、データの取得を開始します。

データの取得

NDIデバイスが見つかったら次は該当デバイスからデータを取得します。取得に際して取得機能を持つインスタンスの生成をまず行います。生成処理は以下です。

public void Connect(NDIlib.Source source)
{
    // ... 前略

    NDIlib.source_t source_t = new NDIlib.source_t()
    {
        p_ndi_name = NDIlib.StringToUtf8(source.Name),
    };

    NDIlib.recv_create_v3_t recvDescription = new NDIlib.recv_create_v3_t()
    {
        source_to_connect_to = source_t,
        color_format = NDIlib.recv_color_format_e.recv_color_format_BGRX_BGRA,
        bandwidth = NDIlib.recv_bandwidth_e.recv_bandwidth_highest,
        allow_video_fields = false,
        p_ndi_recv_name = NDIlib.StringToUtf8(ReceiveName),
    };

    // Create a new instance connected to this source.
    _recvInstancePtr = NDIlib.recv_create_v3(ref recvDescription);

    // ... 中略

    // Start up a thread to receive on
    _receiveThread = new Thread(ReceiveThreadProc) {IsBackground = true, Name = "NdiExampleReceiveThread"};
    _receiveThread.Start();
}

生成処理の部分に絞って掲載しました。これで実データを取得する準備が整いました。では実際にどうやってデータを取得し画面に表示されているかを見ていきましょう。

ビデオデータを取得して画面に表示する

ビデオデータの取得は以下のようになっています。実はNDIでは動画データ以外に音声やメタデータの送受信も行えるので、どのデータを取得したのかの分岐が必要になります。が、今回はビデオデータに絞っているのでそれ以外の部分の掲載は省略します。詳細についてはGitHubのソースをご覧ください。

private void ReceiveThreadProc()
{
    while (!_exitThread && _recvInstancePtr != IntPtr.Zero)
    {
        // The descriptors.
        NDIlib.video_frame_v2_t videoFrame = new NDIlib.video_frame_v2_t();
        NDIlib.audio_frame_v2_t audioFrame = new NDIlib.audio_frame_v2_t();
        NDIlib.metadata_frame_t metadataFrame = new NDIlib.metadata_frame_t();

        switch (NDIlib.recv_capture_v2(_recvInstancePtr, ref videoFrame, ref audioFrame, ref metadataFrame, 500))
        {
            // ... 中略

            case NDIlib.frame_type_e.frame_type_video:
                // If not enabled, just discard
                // this can also occasionally happen when changing sources.
                if (!_videoEnabled || videoFrame.p_data == IntPtr.Zero)
                {
                    // always free received frames.
                    NDIlib.recv_free_video_v2(_recvInstancePtr, ref videoFrame);
                    break;
                }

                // We need to be on the UI thread to write to our texture.
                _mainThreadContext.Post(d =>
                {
                    // get all our info so that we can free the frame
                    int yres = videoFrame.yres;
                    int xres = videoFrame.xres;

                    int stride = videoFrame.line_stride_in_bytes;
                    int bufferSize = yres * stride;

                    if (_texture == null)
                    {
                        _texture = new Texture2D(xres, yres, TextureFormat.BGRA32, false);
                        _image.texture = _texture;
                    }

                    _texture.LoadRawTextureData(videoFrame.p_data, bufferSize);
                    _texture.Apply();

                    NDIlib.recv_free_video_v2(_recvInstancePtr, ref videoFrame);
                }, null);

                break;

            // ... 中略
        }
    }
}

データの取得は今までの流れと大きく変わりません。NDI SDKのDLL関数を呼び出してビデオフレームデータを取得しています。

バッファをテクスチャに設定する

Unityのテクスチャへの反映はどうしているのか詳しく見てみましょう。

if (_texture == null)
{
    _texture = new Texture2D(xres, yres, TextureFormat.BGRA32, false);
    _image.texture = _texture;
}

_texture.LoadRawTextureData(videoFrame.p_data, bufferSize);
_texture.Apply();

ポイントは、テクスチャの作成と LoadRawTextureData によるデータの設定です。テクスチャはmipmapを使用しない形で生成し、フォーマットとして BGRA32 を指定します。NDIから取得するデータがそういう並びで格納されているためです。

そしてテクスチャに対して実データを流し込みます。流し込むには LoadRawTextureData を使います。実はこのメソッド、第一引数に IntPtr 型の値を取ることができます。第二引数にはバッファのサイズを指定します。NDI SDKから返されるデータは素直なbyte配列になっているため、フォーマットとバッファサイズを指定するだけでうまく画像が表示される、というわけなんですね。

ちなみに最初、mipmapを作る指定でテクスチャを生成していたため、必要なサイズを満たしていないよというエラーが表示されてしまいました。(バッファのサイズを見ると画角 x 4(RGBAチャンネル)というサイズだったのに、 Texture2D 側ではそれより大きいサイズを要求していた)

まとめ

今回の移植作業を通して、DLLを利用する勘所とどうやって移植を進めていけばいいかがだいぶクリアになりました。C#などで利用されることが期待されているDLLの場合、C++クラスのインスタンスメソッドなどにC#からアクセスすることができないため、第一引数にインスタンスのポインタを渡す、というのが基本になっているようです。(そして実際の処理などはC++側で行う)

C++側には普通のデータ型( int とか)と、メモリレイアウトを合わせた構造体が利用できるのでこれらを巧みに使って処理を進めていきます。

実際にUnity Pluginがないものを移植するというケースはそう多くないと思いますが、利用する場合にも有用な知識があるのでなにかの役に立てば幸いです。

その他メモ

今回の実装では利用しませんでしたが、作っていく途中で調べたことや知っておくといいことなどをまとめておきます。

IntPtrの変換メモ

今回、 IntPtr が多く登場しました。基本的にDLL側で作成されたデータを利用する場合、ポインタ経由でやり取りします。特に、C++クラスのインスタンスメソッドにはC#側ではアクセスすることができません。なので、インスタンスの保持にはポインタを使う必要があるわけです。

それを利用する場合はDLLにexportされた関数にポインタを渡して処理をしていくというのが基本の流れになります。


ちなみに以下の記事では、無理やりC++クラスのインスタンスメソッドにアクセスする方法が書かれています。プログラムの深い知識とプラットフォーム固有の仕組みを知らないとならないため、気軽に使えるものではないと思いますが色々な知識を知ることができるので興味がある人は見てみてください。

qiita.com


さて、この IntPtr を利用して実際に中身を触る方法についてドキュメントに記載があるので引用しておきます。

docs.microsoft.com

using System;
using System.Runtime.InteropServices;
using UnityEngine;

public class IntPtrTest : MonoBehaviour
{
    private void Start()
    {
        string stringA = "I seem to be turned around!";
        int copyLen = stringA.Length;

        IntPtr sptr = Marshal.StringToHGlobalAnsi(stringA);
        IntPtr dptr = Marshal.AllocHGlobal(copyLen + 1);

        unsafe
        {
            byte* src = (byte*) sptr.ToPointer();
            byte* dst = (byte*) dptr.ToPointer();

            if (copyLen > 0)
            {
                src += copyLen - 1;

                while (copyLen-- > 0)
                {
                    *dst++ = *src--;
                }

                *dst = 0;
            }
        }

        string stringB = Marshal.PtrToStringAnsi(dptr);

        Debug.Log(stringA);
        Debug.Log(stringB);

        Marshal.FreeHGlobal(dptr);
        Marshal.FreeHGlobal(sptr);
    }
}

サンプルなので回りくどく、C#側で確保した string をポインタに変更し、さらにポインタ経由で文字列を逆順にするというものになっています。C言語ではおなじみのポインタの進め方などもできて、どういうふうに扱うかがなんとなく分かるかと思います。

また、対象のポインタがバイト配列( byte[] )であることが明確な場合はこちらの記事のようにコピーして利用することもできます。

 byte[] managedArray = new byte[size];
 Marshal.Copy(pnt, managedArray, 0, size);

stackoverflow.com

C++のクラスをDLLにExportする

今回はSDKを利用するという視点で解説を行いました。が、やはり実際に自分の手でDLLを作ってそれを利用するとさらに理解が深まると思います。ということで、少し前にDLLを作ってUnityで使う方法についての記事を書いているのでよかったら見てみてください。ただ、あまり内容が多くなかったので勉強も兼ねて英語で書いたものになります。

edom18.medium.com