e.blog

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

OpenCV for Unityで画像変換する備忘録

概要

プロジェクトでポストイットを判別したいという話があり、それを実現するためにOpenCVに手を出してみました。使う上での備忘録をメモしていきます。

なお、今回紹介する内容はおもちゃラボさんの以下の記事を、OpenCV for Unityに置き換えて実装しなおしたものになります。その過程でいくつかの違いがあったのでそれを主にメモしています。

nn-hokuson.hatenablog.com

今回記載した内容はGitHubにアップしてあります。(ただしOpenCV for Unityは当然含まれていないので、実際に動かす場合はご自身でご購入ください)

github.com



OpenCV for Unityとは

OpenCV for Unityは、OpenCVをUnity上で扱えるようにしてくれるアセットです。iOSAndroidもサポートされており、とても幅広く使えるアセットになっています。ただしアセットストアで販売されていて $104.50 とやや高めです。

assetstore.unity.com

単語・機能

OpenCV for Unityを利用する上で最初に知っておいたほうがいい知識を簡単にまとめておきます。(あくまで今回のサンプルを実装する上での内容です)

CvType

CV_8UC1 などと定義されている enum の値。

OpenCVで利用する行列の種類を示すもの。頻繁に登場するのでどんな値があるのか以下の記事を参考に把握しておくといいでしょう。

tech-blog.s-yoshiki.com

ちなみに値の意味は以下だと思われます。(詳細は上記記事を参照)

CV_{ビット数}{符号有無 (U|S|F)}C{チャンネル数}

Matクラス

画像を取り扱うメインとなるクラス。 Matrix の略で、画像を2次元配列として行列で表したもの。rowscols があり、ピクセルを行列で扱うための機能が提供されている。基本はこれを利用しながら画像を処理していく。

なお、実装はネイティブでされており、必要なくなったら自分で破棄する必要がある点に注意です。

OpenCVForUnity.UnityUtils

Unityのテクスチャを Mat 型に変換したり、あるいはその逆をしてくたりといったユーティリティを提供してくれます。

OpenCVForUnity.ImgprocModule

この名前空間に所属する Imgproc クラスが、基本的なOpenCVの処理を実現してくれるメソッドを提供してくれます。例えば Imgproc.cvtColor(mat, gray, Imgproc.COLOR_RGBA2GRAY); で画像をグレースケール化したりします。

2x2行列を作る

冒頭の Mat のところでも書きましたが、 CvType を利用して好きな数の行列を作成することが出来ます。OpenCVを利用する上で知っておくと便利だと思ったのでメモしておきます。

Mat mat = new Mat(2, 2, CvType.CV_8UC1);
mat.put(0, 0, 1, 2, 3, 4);

Debug.Log(test.dump());
Debug.Log(test.row(0).dump());

以下からは、冒頭のおもちゃラボさんの記事を参考に、様々なOpenCVの処理を実現していくコード断片を載せていきます。基本的に逆引き的な感じで書いています。

画像(Texture2D)をMatに変換する

まずは、なにはなくともテクスチャの情報を Mat に変換する必要があります。ということで変換処理。 OpenCVForUnity.UnityUtils を利用します。

using OpenCVForUnity.CoreModule;
using OpenCVForUnity.UnityUtils;

// テクスチャをMat化
Mat mat = new Mat(texture.height, texture.width, CvType.CV_8UC4); // 8bit Unsigned 4 channels.
Utils.texture2DToMat(texture, mat);

冒頭でも書いた通り CvType.CV_8UC4CvType)がありますね。これは符号なし8bitが4チャンネル、つまりRGBA分ある行列を生成する、という意味になります。

なお注意点として、 Mat 型の生成は widthheight が、 Texture2D を生成するときと引数の順番が異なるので気をつけてください。

Matを画像(Texture2D)に変換する

上記の逆バージョン。こちらも OpenCVForUnity.UnityUtils を利用します。 Mat には cols()rows() があり、行列のサイズを取得できるのでこれを利用してテクスチャを生成しています。

using OpenCVForUnity.UnityUtils;

Texture2D texture = new Texture2D(mat.cols(), mat.rows(), TextureFormat.RGBA32, false);
Utils.matToTexture2D(mat, texture);

WebCamTextureをMatに変換する

OpenCVを利用する上で、WebCamからの映像に効果を付け加えるというのはポピュラーな方法でしょう。OpenCV for Unityには WebCamTexture から Mat 型に変換するためのユーティリティも備わっています。

using OpenCVForUnity.UnityUtils;
using OpenCVForUnity.ImgprocModule;

private WebCamTexture _webCamTexture;
private Color32[] _colors;
private Mat _rgbaMat;
private int _textureWidth;
private int _textureHeight;

// 各種インスタンス生成
_webCamTexuture = ...; // WebCamTextureの取得作法に則る
_rgbaMat = new Mat(_textureHeight, _textureWidth, CvType.CV_8UC4, new Scalar(0, 0, 0, 255));
_colors = new Color32[_textureWidth * _textureHeight];

// 変換
Utils.webCamTextureToMat(_webCamTexture, _rgbaMat, _colors);

画像をグレースケール化する

いよいよここからOpenCVらしさが出てきます。生成した Mat を利用して様々な処理を施していきます。ここではグレースケール化する手順を見てみましょう。

using OpenCVForUnity.CoreModule;
using OpenCVForUnity.UnityUtils;
using OpenCVForUnity.ImgprocModule;

// テクスチャをMat化
Mat mat = new Mat(texture.height, texture.width, CvType.CV_8UC4);
Utils.texture2DToMat(texture, mat);

// 画像をグレースケール化
Mat gray = new Mat(texture.height, texture.width, CvType.CV_8UC1);
Imgproc.cvtColor(mat, gray, Imgproc.COLOR_RGBA2GRAY);

OpenCVForUnity.ImgprocModule 名前空間にある Imgproc クラスのメソッドを利用します。ここでは cvtColor (Convert Colorの略だと思う)を使っています。

変換結果は第2引数に渡した Mat 型のインスタンスに格納されるので、事前に生成してメソッドに渡します。よく見ると CvType.CV_8UC1 となっているのが分かります。これは、グレースケール化したあとはRGBAの4チャンネルではなく1チャンネルのみで表されるため、1チャンネル分の行列を確保している、ということになります。

画像を2値化する

上記でグレースケール化した画像をさらに2値化してみます。基本的な手順はグレースケール化と同様です。

using OpenCVForUnity.CoreModule;
using OpenCVForUnity.UnityUtils;
using OpenCVForUnity.ImgprocModule;

// テクスチャをMat化
Mat mat = new Mat(texture.height, texture.width, CvType.CV_8UC4);
Utils.texture2DToMat(texture, mat);

// 画像をグレースケール化
Mat gray = new Mat(texture.height, texture.width, CvType.CV_8UC1);
Imgproc.cvtColor(mat, gray, Imgproc.COLOR_RGBA2GRAY);

Mat bin = new Mat(texture.height, texture.width, CvType.CV_8UC1);
Imgproc.threshold(gray, bin, 127.0, 255.0, Imgproc.THRESH_BINARY);

ガウシアンブラーをかける

次は画像にブラーをかけます。ブラーをかけるには GaussianBlur メソッドを使います。第一、第二引数はグレースケールなどと同様です。第三引数はカーネルサイズです。第四、第五引数はぼかしの強さに影響を与えます。

いくつかのオーラーロードがあり、シンプルなメソッドシグネチャは以下になります。

static void GaussianBlur (Mat src, Mat dst, Size ksize, double sigmaX)

ガウシアンブラー自体についても過去に記事を書いているので興味があれば読んでみてください。

edom18.hateblo.jp

using OpenCVForUnity.CoreModule;
using OpenCVForUnity.UnityUtils;
using OpenCVForUnity.ImgprocModule;

// テクスチャをMat化
Mat mat = new Mat(texture.height, texture.width, CvType.CV_8UC4);
Utils.texture2DToMat(texture, mat);

Mat blur = new Mat();
Imgproc.GaussianBlur(mat, blur, new Size(11, 11), 0);

Sobelフィルターを適用する

Sobelフィルター。実行にはその名の通りな Soble を使います。画像テクセルの上下左右の値の差を利用して輪郭の検出を行うようなフィルターです。

using OpenCVForUnity.UnityUtils;
using OpenCVForUnity.ImgprocModule;

private Texture2D _texture;

Mat mat = new Mat(_texture.height, _texure.width, CvType.CV_8UC4);
Mat gray = new Mat(_texture.height, _texture.width, CvType.CV_8UC1);
Mat sobel = new Mat();
Utils.texture2DToMat(_texture, mat);

Imgproc.cvtColor(mat, gray, Imgproc.COLOR_RGBA2GRAY);
Imgproc.Sobel(gray, sobel, -1, 1, 0);

マスクを作る

とあるエリアをくり抜いたり、といったマスク効果を実現します。

using OpenCVForUnity.CoreModule;
using OpenCVForUnity.UnityUtils;
using OpenCVForUnity.ImgprocModule;

private Texture2D _texture;

Mat mat = new Mat(_texture.height, _texture.width, CvType.CV_8UC4);
Utils.texture2DToMat(_texture, mat);

Mat maskMat = new Mat(_texture.height, _texture.width, CvType.CV_8UC4, new Scalar(0, 0, 0, 255));

// OpenCVの機能で円を描く
// 第3引数は半径、第5引数は線の太さ。-1を指定すると塗りつぶし
Imgproc.circle(maskMat, new Point(mat.width() / 2, mat.height() / 2), 300, new Scalar(255, 255, 255, 255), -1);

Mat dst = new Mat();
Core.bitwise_and(mat, maskMat, dst);

射影変換

画像に対して射影変換を行う処理です。「射影」と名前がある通り、画像の中の任意の形状を別の形状に変換する処理です。よく見るものとしては紙のドキュメント検出などが挙げられるでしょう。撮影した紙を変形して正面から見たような形に変換するアレです。

using OpenCVForUnity.UnityUtils;
using OpenCVForUnity.ImgprocModule;

private Texture2D _texture;
private double _rightTop = 200.0;
private double _rightBottom = 200.0;

Mat mat = new Mat(_texture.height, _texture.width, CvType.CV_8UC4);
Mat outMat = mat.clone();
Utils.texturew2DToMat(_texture, mat);

Mat srcMat = new Mat(4, 1, CvType.CV_32FC2);
Mat dstMat = new Mat(4, 1, CvType.CV_32FC2);
srcMat.put(0, 0,
    0.0, 0.0,
    mat.cols(), 0.0,
    0.0, mat.rows(),
    mat.cols(), mat.rows());
dstMat.put(0, 0,
    0.0, 0.0,
    mat.cols(), _rightTop,
    0.0, mat.rows(),
    mat.cols(), mat.rows() - _rightBottom);

// 変形用のMatを取得する
Mat transformMat = Imgproc.getPerspectiveTransform(srcMat, dstMat);

// 変形を適用する
Imgproc.warpPerspective(mat, outMat, transformMat, new Size(mat.cols(), mat.rows()));

カラー画像からチャンネルごとの要素を取得する

いわゆるRGBAの各チャンネルごとの要素を別々の Mat に分割する処理です。

using OpenCVForUnity.CoreModule;
using OpenCVForUnity.UnityUtils;
using OpenCVForUnity.ImgprocModule;

private Texture2D _texture;

Mat mat = new Mat(_texture.height, _texture.width, CvType.CV_8UC4);
List<Mat> rgba = new List<Mat>();
Core.split(mat, rgba);

輪郭検出

画像処理などを施したあとに特定領域を調べる、ということはよくあると思います。その際に、輪郭を検出し、その輪郭を表示するというサンプルコードです。キャプチャを見てもらうとどういうことをやっているのか一目瞭然だと思います。

Mat mat = new Mat(_texture.height, _texture.width, CvType.CV_8UC4);
Utils.texture2DToMat(_texture, mat);

// グレースケール化して、
Mat gray = new Mat(_texture.height, _texture.width, CvType.CV_8UC4);
Imgproc.cvtColor(mat, gray, Imgproc.COLOR_RGB2GRAY);

// 画像を2値化したものを利用する
Mat bin = new Mat(_texture.height, _texture.width, CvType.CV_8UC1);

Imgproc.threshold(gray, bin, 0, 255, Imgproc.THRESH_BINARY_INV | Imgproc.THRESH_OTSU);

// 輪郭の抽出
List<MatOfPoint> contours = new List<MatOfPoint>();
Mat hierarchy = new Mat();
Imgproc.findContours(bin, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE);

// 輪郭の表示
for (int i = 0; i < contours.Count; ++i)
{
    Imgproc.drawContours(mat, contours, i, new Scalar(255, 0, 0, 255), 2, 8, hierarchy, 0, new Point());
}

輪郭の近似

上記で検出した輪郭はかなり細かい点が抽出されます。そのため、なにかしらの処理で利用するにはやや細かすぎるため、その輪郭をある程度丸め込んで扱いやすいようにしてくれるのがこの近似です。細かい点については以下の記事が分かりやすかったのでそちらを参照ください。

labs.eecs.tottori-u.ac.jp

private void Find4PointContours(Mat image, List<MatOfPoint> contours, Mat hierarchy)
{
    contours.Clear();
    List<MatOfPoint> tmpContours = new List<MatOfPoint>();

    Imgproc.findContours(image, tmpContours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE);

    foreach (var cnt in tmpContours)
    {
        MatOfInt hull = new MatOfInt();
        Imgproc.convexHull(cnt, hull, false);

        Point[] cntArr = cnt.toArray();
        int[] hullArr = hull.toArray();
        Point[] pts = new Point[hullArr.Length];
        for (int i = 0; i < hullArr.Length; ++i)
        {
            pts[i] = cntArr[hullArr[i]];
        }

        MatOfPoint2f ptsFC2 = new MatOfPoint2f(pts);
        MatOfPoint2f approxFC2 = new MatOfPoint2f();
        MatOfPoint approxSC2 = new MatOfPoint();

        double arcLen = Imgproc.arcLength(ptsFC2, true);
        Imgproc.approxPolyDP(ptsFC2, approxFC2, 0.02 * arcLen, true);
        approxFC2.convertTo(approxSC2, CvType.CV_32S);

        if (approxSC2.size().area() >= 5) continue;

        contours.Add(approxSC2);
    }
}

輪郭検出したエリアをくり抜く

OpenCVでありそうな処理としては、認識したエリアをくり抜く(抽出する)ということがあるでしょう。その際に利用するのが submat メソッドです。さらに便利なことに、前述の輪郭検出で検出した輪郭情報を元に矩形を求めてくれるメソッドもあるので、これを利用することで手軽に該当位置をくり抜くことができます。

using OpenCVForUnity.CoreModule;
using OpenCVForUnity.UnityUtils;
using OpenCVForUnity.ImgprocModule;

Rect rect = Imgproc.boundingRect(contours[0]);
Mat submat = mat.submat(rect);

参考にした記事

stackoverflow.com

labs.eecs.tottori-u.ac.jp

Oculus Questのビデオパススルーを試す

概要

Oculus QuestでビデオパススルーAPIが公開され、開発者でも利用できるようになったので試してみました。いくつか設定で(2021/08/13時点では)いくつかハマりポイントがあるのでメモしておこうと思います。これらについては@gtk2kさんと@korinVRさんのツイートを参考にさせていただきました。

実際にビルドして画面をキャプチャしてみたのが以下のツイート。



Passthroughを有効化する

ドキュメントには以下のように記載があります。

※ 本記事の最後に書かれているサンプルシーンは設定済みになっているので、ここはあくまで既存プロジェクトを変更する際に必要になる処理です。

Implement Passthrough

Oculus Integration SDK for Unity contains the necessary APIs and settings to implement passthrough.

Prerequisites

  • Download the latest Oculus Integration SDK from the Unity Asset Store or from the Oculus Integration SDK Archive page.
  • Make sure the Oculus headset is using v31 or higher version. You can check this from Quick Settings > Settings > About and checking the Version number setting.
  • Since Passthrough API is an experimental feature, put your device in Experimental Mode by using the following command: adb shell setprop debug.oculus.experimentalEnabled 1. This step needs to be executed after each reboot. Without this setting, passthrough will not show up in your app.

Enable Passthrough

An app needs to opt in to use passthrough. This is needed both for build-time steps (additions to the Android manifest) and to initialize system resources at runtime.

  • From the Hierarchy tab, select OVRCameraRig to open the OVR Manager settings in the Inspector tab.
  • Under the Experimental section, select Experimental Features Enabled and Passthrough Capability Enabled. These two options enable the build-time components for using passthrough.
  • Under Insight Passthrough, select Enable Passthrough. This initializes passthrough during app startup. To initialize passthrough later, leave the checkbox unchecked and enable OVRManager::isInsightPassthroughEnabled from a script.
  • Add an OVRPassthroughLayer script component to OVRCameraRig.
  • The Projection Surface setting determines whether the passthrough rendering uses an automatic environment depth reconstruction or a user-defined surface.
  • The controls in the Compositing section work the same as in OVRPassthroughLayer: depending on the Placement setting, passthrough will be composited as an overlay or as an underlay. If multiple layers are present including OVRPassthroughLayers, use the Composition Depth setting to define the ordering between the layers.
  • Add multiple instances of OVRPassthroughLayer to your scene, each with its own configuration. A maximum of three instances can be active at any given time.

Player Settingを適切に設定する

Color SpaceをLinearに

Player Setting > Other Settings > Rendering > Color SpaceLinear に変更します。

ArchitecturesをARM64に

Player Setting > Other Settings > Configuration > Target ArchitecturesARM64 に変更します。

Scripting BackendをIL2CPPに

Player Setting > Other Settings > Configuration > Scripting BackendIL2CPP に変更します。

experimentalな設定を有効化する

adb コマンドを利用して以下のフラグをオンにする必要があるようです。

$ adb shell setprop debug.oculus.experimentalEnabled 1

なお、ドキュメントに

This step needs to be executed after each reboot.

と書かれているように、リブートごとに実行する必要がある点に注意が必要です。

AndroidManifest.xmlに設定を追記する

ビデオパススルーを有効化するために、AndroidManifest.xmlに以下の2点を追加します。

<uses-feature android:name="com.oculus.experimental.enabled" android:required="true" />
<uses-feature android:name="com.oculus.feature.PASSTHROUGH" android:required="true" />

実際に自分がビルドした設定は以下です。これを Assets/Plugins/Android/AndroidManifest.xml として保存します。

<?xml version="1.0" encoding="utf-8"?>
<manifest
    xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.unity3d.player"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-feature android:name="com.oculus.experimental.enabled" android:required="true" />
    <uses-feature android:name="com.oculus.feature.PASSTHROUGH" android:required="true" />

    <application>
        <activity android:name="com.unity3d.player.UnityPlayerActivity"
                  android:theme="@style/UnityThemeSelector">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <meta-data android:name="unityplayer.UnityActivity" android:value="true" />
        </activity>
    </application>
</manifest>

Show Splash Screenをオンにする

どうも、この設定をオフにしているとパススルーAPIの初期化に失敗するよう。

Oculus Integrationをv31にupgrade

Oculus QuestのOSバージョンが31以上か確認

ビデオパススルーを利用するにはOSバージョンが31以上である必要があるので、使用しているQuestのOSがバージョンアップされているか確認します。

Passthroughシーンをビルド

Oculus Integrationが最新になっていれば、以下の場所にパススルー用のシーンが含まれているのでこれをビルドします。

これをビルドして実行すると、冒頭の動画のようなシーンを見ることができます。(もし初期化失敗している場合は3Dオブジェクトのみが見え、真っ暗な空間になってしまいます)

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

シーンビューライクなカメラ操作をするスクリプト

概要

XRの開発中、デバッグ目的でGameビューの視点をシーンビューのカメラと同じように操作したいなと思って簡単にスクリプトを書きました。というメモ。

動作はこんな感じ。

シーンビュー同様、右クリックしながらWASDで移動、QEで上昇下降、マウス移動で視点回転。
あとShiftキー押すと移動速度が倍速になる。

デバッグに重宝すると思うので記事にしてみました。

using UnityEngine;

public class CameraController : MonoBehaviour
{
    [SerializeField] private Transform _target = null;
    [SerializeField] private float _moveSpeed = 10f;
    [SerializeField] private float _rotateSpeed = 20f;
    [SerializeField] private float _boost = 2f;

    private bool _isMoveMode = false;
    private Vector3 _prevPos = Vector3.zero;

    private float MoveSpeed
    {
        get
        {
            float speed = _moveSpeed * Time.deltaTime;

            if (Input.GetKey(KeyCode.LeftShift))
            {
                speed *= _boost;
            }

            return speed;
        }
    }

    private float RotateSpeed => _rotateSpeed * Time.deltaTime;

    #region ### MonoBehaviour ###

    private void Update()
    {
        if (Input.GetMouseButtonDown(1))
        {
            StartMove();
        }

        if (Input.GetMouseButtonUp(1))
        {
            EndMove();
        }

        if (_isMoveMode)
        {
            TryMove();
            TryRotate();
        }
    }

    private void Reset()
    {
        _target = transform;
    }

    #endregion ### MonoBehaviour ###

    private void StartMove()
    {
        _isMoveMode = true;
        _prevPos = Input.mousePosition;
    }

    private void EndMove()
    {
        _isMoveMode = false;
    }

    private void TryMove()
    {
        if (Input.GetKey(KeyCode.W))
        {
            _target.position += _target.forward * MoveSpeed;
        }

        if (Input.GetKey(KeyCode.A))
        {
            _target.position += -_target.right * MoveSpeed;
        }

        if (Input.GetKey(KeyCode.S))
        {
            _target.position += -_target.forward * MoveSpeed;
        }

        if (Input.GetKey(KeyCode.D))
        {
            _target.position += _target.right * MoveSpeed;
        }

        if (Input.GetKey(KeyCode.Q))
        {
            _target.position += -_target.up * MoveSpeed;
        }

        if (Input.GetKey(KeyCode.E))
        {
            _target.position += _target.up * MoveSpeed;
        }
    }

    private void TryRotate()
    {
        Vector3 delta = Input.mousePosition - _prevPos;

        transform.Rotate(Vector3.up, delta.x * RotateSpeed, Space.World);

        Vector3 rightAxis = Vector3.Cross(transform.forward, Vector3.up);
        transform.Rotate(rightAxis.normalized, delta.y * RotateSpeed, Space.World);

        _prevPos = Input.mousePosition;
    }
}

UnityでWebSocketを使ってブラウザと通信する

概要

WebブラウザからUnityへ(そしてビルドしたアプリにも)データを送信したいことがあったのでWebSocketをUnityで利用する方法およびWebブラウザからデータを送信する方法を試してみたのでそのメモです。

Unity側のWebSocketの利用は以下のライブラリを利用させていただきました。

github.com

ちなみにサーバ側はNode.jsとwsモジュールを利用しています。

今回のサンプルはGitHubにアップしてあるので実際の動作を確認した人はこちらを参照ください。

github.com



ライブラリをビルドする

冒頭のプロジェクトをgit cloneするかzipファイルをダウンロードしプロジェクトをビルドします。
手順は以下の通りです。

プロジェクト内にあるwebsocket-sharp.slnVisual Studioで開きます。

不要なプロジェクトの削除

最初に含まれているいくつかのサンプルがあるとビルドに失敗するようなので該当のプロジェクトを削除します。
具体的にはExampleとついているプロジェクトすべてです。

フォルダごと削除するか、Visual Studioの「ソリューションエクスプローラ」から右クリック→削除を実行することで削除することができます。

ソリューションをビルド

削除後、websocket-sharpソリューションをビルドします。ビルドターゲットはReleaseを選びます。
以下の図のように、ソリューション構成をReleaseに変更します。

その後、ソリューションエクスプローラからwebsocket-sharpを選択、右クリックからビルドを選択することでビルドできます。(もしうまく行かない場合はリビルドを選ぶとうまく行くかもしれません)

不要プロジェクトの削除などについては以下の記事を参考にさせていただきました。

qiita.com

DLLをプロジェクトに配置

ビルドが正常にできたら以下のパスにDLLファイルが生成されます。それをUnityプロジェクトのPluginsフォルダにコピーします。

DLLの場所

/path/to/project/websocket-sharp/bin/Release/websocket-sharp.dll

C#でWebSocketを扱う

これで無事、C#でWebSocketを扱う準備が整ったのでC#のコードを書いていきます。

WebSocketサーバに接続する

まずはC#側のコードから見ていきましょう。
今回は冒頭で紹介したライブラリを使って実装しています。

まずは簡単に、WebSocketサーバに接続するコードです。

using System.Threading;
using UnityEngine;
using UnityEngine.UI;
using WebSocketSharp;

public class MessageTest : MonoBehaviour
{
    [SerializeField] private string _serverAddress = "localhost";
    [SerializeField] private int _serverPort = 3000;

    private WebSocket _webSocket = null;

    private void Start()
    {
        _webSocket = new WebSocket($"ws://{_serverAddress}:{_serverPort}/");

        // Event handling.
        _webSocket.OnOpen += (sender, args) => { Debug.Log("WebSocket opened."); };
        _webSocket.OnMessage += (sender, args) => { Debug.Log("OnMessage"); };
        _webSocket.OnError += (sender, args) => { Debug.Log($"WebScoket Error Message: {args.Message}"); };
        _webSocket.OnClose += (sender, args) => { Debug.Log("WebScoket closed"); };

        _webSocket.Connect();
    }
}

WebSocketクラスを、URLを引数にインスタンス化してConnectメソッドを呼ぶだけです。シンプルですね。
いくつかコールバックがあるので必要に応じて設定します。

特にOnMessageイベントは必須になるでしょう。引数に渡ってくるMessageEventArgsに送信されてきたデータへの参照があるので適宜これを利用します。

サーバを立てる

今回はNode.jsを利用してWebSocketサーバを立ててテストしました。
サーバ側のコードは以下の通りです。今回はテストなので受信したメッセージを接続しているクライアントにそのまま流しているだけです。

WebSocketのサーバにはwsモジュールを利用しました。

var WebSocket = require('ws');
var ws = WebSocket.Server;
var wss = new ws({port: 3000});

console.log(wss.clients);

wss.brodcast = function (data) {
    this.clients.forEach(function each(client) {
        if (client.readyState === WebSocket.OPEN) {
            client.send(data);
        }
    });
};

wss.on('connection', function (ws) {
    ws.on('message', function (message) {
        var now = new Date();
        console.log(now.toLocaleString() + ' Received.');
        wss.brodcast(message);
    });
});

wsモジュールを読み込んでポート3000で待ち受けているだけですね。
そして来たメッセージをそのままブロードキャストしているだけです。

Webブラウザからデータを送信する

次に、今回の主目的であるWebブラウザからUnityに向かってデータを送信する部分を見てみます。

まずは簡単にHTMLを組みます。(あくまでデータ送信のためのUIを作るため)

<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body>
    <h1>WebSocket x Unityサンプル(Message Sample)</h1>

    <p>
        <input id="serverAddress" value="localhost" />
        <input id="serverPort" value="3000" />
        <input type="button" id="Connect" value="Connect" />
        <input type="button" id="Disconnect" value="Disconnect" />
    </p>

    <p>
        <span id="eventType"></span>
        <span id="dispMsg"></span>
    </p>

    <p>
        <input id="inputField" />
        <input type="button" id="Send" value="Send Message" />
    </p>
</body>
<script type="text/javascript" src="main.js"></script>
</html>

上記サンプルではテキストメッセージを送るだけのシンプルなものです。
実際にデータの送信を行っているWebブラウザ側で実行されているJSは以下です。

class WebSocketController {
    connection = null;
    eventTypeField = null;
    messageDisplayField = null;

    constructor(eventTypeField, messageDisplayField) {
        this.eventTypeField = eventTypeField;
        this.messageDisplayField = messageDisplayField;
    }

    connect(url, port) {
        const URL = "ws://" + url + ":" + port + "/";
        console.log("Will connect to " + URL);

        // Connect to the server.
        this.connection = new WebSocket(URL);

        // 接続通知
        this.connection.onopen = (event) => {
            this.eventTypeField.innerHTML = "通信接続イベント受信";
            this.messageDisplayField.innerHTML = event.data === undefined ? "--" : event.data;
        };

        //エラー発生
        this.connection.onerror = (error) => {
            this.eventTypeField.innerHTML = "エラー発生イベント受信";
            this.messageDisplayField.innerHTML = error.data;
        };

        //メッセージ受信
        this.connection.onmessage = (event) => {
            console.log("Received a message [" + event.data + "]");

            this.eventTypeField.innerHTML = "メッセージ受信";
            this.messageDisplayField.innerHTML = event.data;
        };

        //切断
        this.connection.onclose = () => {
            this.eventTypeField.innerHTML = "通信切断イベント受信";
            this.messageDisplayField.innerHTML = "";
        };
    }

    disconnect() {
        console.log("Will disconnect from " + URL);
        this.connection.close();
    }

    sendMessage(message) {
        console.log("Will send message.");
        this.connection.send(message);
    }
}

let eventTypeField = document.getElementById("eventType");
let messageDisplayField = document.getElementById("dispMsg");

const ws = new WebSocketController(eventTypeField, messageDisplayField);

document.getElementById("Connect").addEventListener('click', () => {
    let url = document.getElementById("serverAddress").value;
    let port = document.getElementById("serverPort").value;
    ws.connect(url, port);
});

document.getElementById("Disconnect").addEventListener('click', ws.disconnect);

document.getElementById("Send").addEventListener('click', () => {
    var message = document.getElementById("inputField").value;
    ws.sendMessage(message);
});

JS書くの久しぶりなので全然モダンな感じじゃないですが目をつぶってくださいw

やっていることはWebSocketサーバに接続して、Inputに入力されているテキストを送るだけのシンプルなものです。

テキストの送信は非常にシンプルに、WebSocketクラスのsendMessageメソッドに文字列を渡すだけです。
これでUnity側にテキストがしっかりと送信されます。

受信したテキストをログ出力するには以下のようにします。

_webSocket.OnMessage += (sender, args) => { Debug.Log(args.Data); };

受信した文字列はMessageEventArgsクラスのDataプロパティに格納されているので、それを参照することで文字列を簡単に取り出すことができます。

WebSocketを利用してバイナリデータを送る

ここからは応用編です。文字列は非常に簡単に送ることができましたが、バイナリデータ、例えば画像などを送る場合は少し工夫をしないとなりません。

特に今回は、様々なデータを送って処理できるようにするために、バイナリデータのフォーマット決めて色々なデータを送れるようにすることを意識して作ったものをメモとして残しておきます。

まずはJS側から(つまり送信側から)のコードを見てみます。
WebSocketControllerクラスに関しては上のコードと同様なので割愛します。sendMessageはbyte配列をそのまま受け付けるので変更せずに利用することができます)

まずは簡単に仕様から。

データ・フォーマットとしては以下を想定しています。
(カッコ内の数字はバイト数です)

+------------------+-----------------
| データタイプ(1) |  データ本体
+------------------+-------------

byte配列の先頭に、データ・タイプを示す1byteのデータを入れます。
0ならカラー情報(文字列)、1なら画像ファイルという具合です。

WebSocketControllerクラスにデータ・タイプを示す値を追加しています。(ここだけ追加された部分です)

class WebSocketController {
    static MessageType = {
        Color: 0,
        Image: 1,
    };
    // 後略
}

実際のデータの組み立てと送信部分は以下のようになります。

カラー情報の送信

最初はカラー情報の送信です。ブラウザのColor Pickerから色を選択したらそれを文字列としてUnity側に送るものです。

function changedColor(evt) {
    var colorData = getColorData(evt.target.value);
    var array = new Uint8Array(colorData);
    var buffer = new ArrayBuffer(array.byteLength + 1);
    var data = new Uint8Array(buffer);
    data.set([WebSocketController.MessageType.Color], 0);
    data.set(array, 1);
    ws.sendMessage(data.buffer);
}

function getColorData(colorStr) {
    return new TextEncoder().encode(colorStr).buffer;
}

changedColor関数はPickerのchangeイベントのコールバックです。
文字列のbyte配列データはTextEncoderクラスを使って行いました。

そして取得したバイト列をUint8Array型配列として参照するようにしつつ、データ・タイプ用に1byteプラスしたArrayBufferインスタンスを生成します。

生成したArrayBufferも同様にUint8Array型配列として参照できるようにしておきます。
この時点で生成されたbuffer変数はまだ空の状態なので、1バイト目にデータ・タイプを設定します。

具体的には以下の部分ですね。

data.set([WebSocketController.MessageType.Color], 0);

Uint8Arrayなどの型配列にはsetメソッドがあるのでそれでデータを設定します。
そしてその後ろに続くように文字列をバイト配列化したデータを追加します。

data.set(array, 1);

第二引数に指定しているのは書き込むデータのオフセットです。
すでに1バイトのデータ・タイプが設定されているのでオフセットは1ということですね。

これで無事、データの準備ができたのでWebSocket経由で送ることができます。

画像データの送信

C#側の(つまり受信側の)コードを見る前に画像データの送信部分も見ておきましょう。

function changedFile(evt) {
    var files = evt.target.files;

    if (files.length <= 0) {
        return;
    }

    var file = files.item(0);
    var reader = new FileReader();

    reader.onload = function () {
        var array = new Uint8Array(reader.result);

        var buffer = new ArrayBuffer(array.byteLength + 1);
        var data = new Uint8Array(buffer);
        data.set([WebSocketController.MessageType.Image], 0);
        data.set(array, 1);

        console.log(data.buffer.byteLength);

        ws.sendMessage(data.buffer);
    }

    reader.readAsArrayBuffer(file);
}

changedFileInputのファイルが変更された際に呼び出されるコールバックです。
選択されたファイルをFileReaderを使って読み出し、それをカラー情報のときと同様にデータ・タイプを先頭にセットして送信しています。

バイト配列の取り出し方が少し異なるだけで基本的にはやっていることは同じです。

C#側で受信する

最後に、ブラウザから送信されてきたデータの受信部分を書いて終わりにしたいと思います。

といっても、バイト配列を適宜操作するだけなのでバイトデータの取り扱いに慣れていればそんなにむずかしいことはしていません。

private void Connect()
{
    // 前略

    SynchronizationContext context = SynchronizationContext.Current;

    // 中略

    _webSocket.OnMessage += (sender, args) =>
    {
        Debug.Log("OnMessage");

        context.Post(_ => { HandleMessage(args.RawData); }, null);
    };
    // 後略
}

// ----------------------------

private void HandleMessage(byte[] data)
{
    DataType type = GetDataType(data);

    switch (type)
    {
        case DataType.Color:
            HandleAsColor(data);
            break;

        case DataType.Image:
            HandleAsImage(data);
            break;
    }
}

private void HandleAsColor(byte[] data)
{
    string colorStr = Encoding.UTF8.GetString(data, 1, 7);

    if (ColorUtility.TryParseHtmlString(colorStr, out Color color))
    {
        Debug.Log(color);
        _material.color = color;
    }
}

private void HandleAsImage(byte[] data)
{
    byte[] texData = new byte[data.Length - 1];
    Array.Copy(data, 1, texData, 0, texData.Length);

    Texture2D tex = new Texture2D(1, 1);
    tex.LoadImage(texData);
    tex.Apply();
    
    _material.mainTexture = tex;

    Debug.Log(tex.width);
}

private DataType GetDataType(byte[] data)
{
    if (data == null || data.Length == 0)
    {
        return DataType.None;
    }

    return (DataType)data[0];
}

実際に操作している部分だけを抜き出しました。
まず冒頭の、メッセージ受信時のコールバック登録です。

private void Connect()
{
    // 前略

    SynchronizationContext context = SynchronizationContext.Current;

    // 中略

    _webSocket.OnMessage += (sender, args) =>
    {
        Debug.Log("OnMessage");

        context.Post(_ => { HandleMessage(args.RawData); }, null);
    };
    // 後略
}

WebSocketの受信イベントは非同期で呼び出されるのでメインスレッド以外で実行されます。
そのため、メインスレッドで実行できるように準備しておきます。(SynchronizationContextの部分です)

そして受信した際には、イベント引数のRawDataにバイト配列が設定されているのでそれを利用して処理しています。

処理に関してはバイト配列から必要なバイト数分取り出して処理をしているだけなので特に解説はいらないでしょう。

ひとつだけ補足しておくと、今回のサンプルコードを書いていて知ったのがColorUtility.TryParseHtmlStringです。16進数などで表された色情報をColor構造体に変換してくれるユーティリティです。
ブラウザのColor Pickerはまさに16進数でカラー情報を表現しているので、受け取った文字列をそのままユーティリティに投げて色情報として受け取っているわけです。

あとはこれをマテリアルなどに設定してやればOKというわけですね。

まとめ

WebSocketを使うとシンプルにデータの送受信が行えることが分かりました。
特にモックなどで「外からこういうデータをさっと渡したいな」みたいなことをやる場合には非常に重宝しそうです。

一方で、Photonなどが提供してくれているような仕組みを作ろうとするとがっつりとフレームワークとして開発することになるので、結局Photonとか使ったほうがいいよね、とはなりそうです。

ちなみに余談ですが、Photonには、今回利用したWebSocketのDLLが梱包されているらしく、後追いでPhotonをインポートしたら定義がバッティングしてエラーが出てしまいました。
なのでPhotonを利用している人は特になにもしなくても今回のコードがそのまま動きます。

UnityでWebAssemblyを扱う

概要

今回はUnityと、ビルドしたアプリからWebAssemblyを利用する手順についてまとめたいと思います。

もともと、ビルド後のアプリに対してコードを追加することができないかなと思っていたところ、たるこすさんの以下の記事を見かけたのがきっかけです。

たるこすさんはこれをさらに汎用化し、VRCなどで振る舞いとモデルを組み合わせたものを使えるようにする、というすばらしい思想で個人プロジェクトを作っているようなので興味がある方はご覧になってください。

zenn.dev

今回の実装サンプルはGitHubにアップしてあります。

github.com

WebAssemblyとは

Wikipediaから引用させてもらうと以下ように説明されています。

WebAssemblyは、ウェブブラウザのクライアントサイドスクリプトとして動作するプログラミング言語(低水準言語)である。wasmとも称されており、ブラウザ上でバイナリフォーマットの形で実行可能であることを特徴とする。2017年現在開発が進められており、最初の目標としてCとC++からのコンパイルをサポートすることを目指している他、Rustがバージョン1.14以降で、Goがバージョン1.11以降で、Kotlin/Nativeがバージョン0.4以降でで対応するなど、他のプログラミング言語のサポートも進められている。

ブラウザ上でバイナリフォーマットのプログラムを実行させることを目的としているわけですね。
ただブラウザ上で動くということはクロスプラットフォームでもあるわけで、その汎用性から注目されているようです。

WebAssemblyをUnityで扱えるようにする

さてではこのWebAssemblyをどのようにしてUnityで利用したらいいのでしょうか。
色々調べてみたところ、C#からWebAssemblyを利用できるようにする以下のGitHubプロジェクトを見つけました。今回はこれを使って行きたいと思います。

github.com

WebAssemblyを作る

上記セットアップを終えたらUnityで(C#から)WebAssemblyを扱うことができるようになります。

しかし使えるようになっても肝心のWebAssemblyのファイルがないと始まりませんね。
ということでWebAssemblyファイルを準備します。

以下のサイトで言及されていたWebサービスが手軽に生成できるのでとてもよかったです。

www.freecodecamp.org

Webサービスはこちら↓

https://mbebenita.github.io/WasmExplorer/

ちなみに上記サイトはCとC++両方でコードを書けてWebAssemblyにコンパイルできます。が、C++だとマングリングされてしまって、C#から利用する際に関数名などが分からなくなってしまうのでCでコンパイルするといいでしょう。

今回はサンプルとしてTest関数を以下のように定義してコンパイルします。

int Test(int a)
{
  return a + 123;
}

コマンドラインでCをWebAssemblyに変換する

いちおう正攻法というか、サービスを利用せずにC/C++からwasmファイルにコンパイルする方法も書いておきます。
手順についてはMozillaのMDNで解説されているのでそれに従います。

developer.mozilla.org

Emscripten環境を構築する

Emscriptenを利用してwasmファイルにコンパイルするようです。なのでまずは環境を作ります。
環境構築自体はとても簡単で、こちらのセットアップ手順を実行するだけです。

まずはGitHubからリポジトリをクローンします。

# Get the emsdk repo
$ git clone https://github.com/emscripten-core/emsdk.git

# Enter that directory
$ cd emsdk

※ Python3.6以降が必要なので、もし環境がない人は別途Pythonのセットアップが必要になります。
自分はAnacondaを使っているので、そちらのコマンドプロンプトで実行しました。

次に、必要なツールをインストールします。

以下はMacOSでの手順です。

# Download and install the latest SDK tools.
$ ./emsdk install latest

# Make the "latest" SDK "active" for the current user. (writes .emscripten file)
$ ./emsdk activate latest

# Activate PATH and other environment variables in the current terminal
$ source ./emsdk_env.sh

自分はWindowsなので、Windowsでの手順も書いておきます。(MacOSとちょっとだけ異なります)
ドキュメントにも以下のように注釈があります。

On Windows, run emsdk instead of ./emsdk, and emsdk_env.bat instead of source ./emsdk_env.sh.

これに従って操作を書き直すと以下の手順になります。

$ emsdk install latest

$ emsdk activate latest

$ emsdk_env.bat

上記を実行するとemccコマンドが利用できるようになります。

Cでコードを書く

今回はサンプルなので簡単なコードでコンパイルしてみます。
以下のように(上記と同じ)Test関数をひとつだけ定義したcファイルを作成します。

#include <emscripten/emscripten.h>

int EMSCRIPTEN_KEEPALIVE Test(int a)
{
  return a + 123;
}

なお、EMSCRIPTEN_KEEPALIVEを関数の頭に入れておかないと、利用されていない関数はコンパイラによって削除されてしまうので注意が必要です。
このマクロを使う場合は<emscripten/emscripten.h>をインクルードする必要があります。

CコードをWebAssemblyにコンパイルする

準備ができました。あとはこれをwasmコンパイルします。コンパイルにはemccコマンドを利用します。
今回の場合、hello.cファイルを作成したので以下のようにコマンドを実行しました。

$ emcc hello.c -s WASM=1

これでwasmファイルが生成されます。

C#からWebAssemblyを読み込んで利用する

すべての準備が終わりました。あとは最初に紹介したライブラリを使って実際にwasmファイルを読み込んで実行してみましょう。

using System.Collections.Generic;
using UnityEngine;
using Wasm;
using Wasm.Interpret;

public class WebAssemblyTest : MonoBehaviour
{
    private void Start()
    {
        // wasmファイルを読み込む
        string path = Application.streamingAssetsPath + "/test.wasm";
        WasmFile file = WasmFile.ReadBinary(path);

        // importerを生成
        var importer = new PredefinedImporter();

        // wasmをインスタンス化
        ModuleInstance module = ModuleInstance.Instantiate(file, importer);

        // インスタンスから、定義済み関数の取得を試みる
        if (module.ExportedFunctions.TryGetValue("Test", out FunctionDefinition funcDef))
        {
            // 関数が見つかったらそれを実行
            IReadOnlyList<object> results = funcDef.Invoke(new object[] { 1, });
            Debug.Log(results[0]);
        }
    }
}

非常にシンプルな記述でWebAssemblyを利用できるのが分かるかと思います。
上で紹介したサービスでコンパイルしたwasmファイルをストリーミングアセット配下に起き、ランタイムで読み込んで実行するとしっかりと計算されているのが確認できます。

しっかりと引数の1123が計算されているのが分かりますね。

C側からC#で定義した関数を呼び出す

C側のコードを実行できることが確認できました。
次は逆、つまりC#側に定義されている関数を呼び出す方法を見てみます。

Cコードを以下のように変更します。

int GetParam();

int Test(int a)
{
  int param = GetParam();
  return a + 123 + param;
}

GetParamが定義されていませんが、それをTest関数内で利用しています。このGetParamC#側で用意するわけですね。

C#側で定義したものを呼び出せるようにするには以下のようにimporterに定義を設定します。

// C#側の関数の定義
private IReadOnlyList<object> GetParam(IReadOnlyList<object> args)
{
    return new object[] { 100, };
}

// ------------------

// 上記関数をimporterに設定
var importer = new PredefinedImporter();
importer.DefineFunction(
    "GetParam",
    new DelegateFunctionDefinition(
        new WasmValueType[] { },
        new [] { WasmValueType.Int32, },
        GetParam));

定義を設定するにはimporterのDefineFunctionメソッドを利用します。
第一引数にはC側で利用を想定している関数名を文字列で、第二引数にはDelegateFunctionDefinitionクラスのインスタンスを渡します。

DelegateFunctionDefinitionクラスのコンストラクタは以下のシグネチャを持ちます。

public void DefineFunction(string name, FunctionDefinition definition);

実行すると、しっかりと100が足されているのが分かるります。
これでC#側で定義したコードをC側から呼び出すことができました。

これができれば、あとはMonoBehaviour側とやり取りするためのインターフェースを定義しておけば自由にコードを実行することができるようになります。

サーバからWebAssemblyをダウンロードして利用する

最後に、サーバからバイナリを取得して実行する部分もメモしておこうと思います。
といってもやることはシンプルです。

まず、サーバにアクセスするときと同様にUnityWebRequestを使ってデータを取得します。
取得後、byte配列をストリーム経由で読み出せるようにセットアップしてWasmFileクラスのコンストラクタに渡せばOKです。

コード断片を載せておきます。

// UnityWebRequestのGetリクエストを生成
UnityWebRequest req = UnityWebRequest.Get(_url);

// 取得されたらコールバックで処理
req.SendWebRequest().completed += operation =>
{
    // メモリストリームを生成
    MemoryStream stream = new MemoryStream();

    // ストリームに、取得したbyte配列を設定
    stream.Write(req.downloadHandler.data, 0, req.downloadHandler.data.Length);
    stream.Seek(0, SeekOrigin.Begin);

    // メモリストリームから`WasmFile`オブジェクトを生成
    WasmFile file = WasmFile.ReadBinary(stream);

    // 以下は上記サンプルと同様の手順で関数を呼び出す
};

最後に

ライブラリのおかげでだいぶ簡単にWebAssemblyが利用できることが分かったかと思います。
iOS/Androidの実機でも動作することが確認できたので、ちょっとした機能の追加など、WebAssemblyを利用することで可能となることが出てくると思います。

自分は特に今、ARアプリを作っていて、さらにARならではの新しい体験を作ることがメインなのでこうした「あとから追加」の機能は色々なところで利用できそうです。

UnityでAWSのS3を扱うためのメモ

目次

概要

今回は訳合って、画像をサーバにアップロードしてそれを扱いたいなと思い、AWSのS3(Amazon Simple Storage Service)をUnity(特にモバイルプラットフォーム)から利用したくて色々ハマったのでそのメモです。

今回の目的はS3を利用して画像をアップロード、ダウンロードする方法についてのまとめです。

ドキュメントはこちら。

docs.aws.amazon.com

今回の実装にあたりこちらの記事を参考にさせていただきました。

qiita.com

実現すること

  • Unityを利用してAmazon S3に画像のアップロード、ダウンロードを行う

テスト環境

  • Unity 2020.1.14f1
  • Oculus Quest 2

フロー

  • AWS SDK for .NETをダウンロードしてインポートする
  • Identity Pool IDを取得する
  • S3ののバケットを作成する
  • IAMの設定

AWS SDK for .NETをダウンロード・インポートする

参考にした記事ではAWS Mobile SDK for Unityをインポート、と書かれていますが現在はどうやらこれはサポート外らしく、これの代わりにAWS SDK for .NETを利用する必要があります。

ダウンロードは以下の公式サイトに書かれているzip圧縮されたものをダウンロードしてきます。
(Download the following ZIP file: aws-sdk-netstandard2.0.zipと書かれている箇所。リンクはこちら

docs.aws.amazon.com

上記について公式のブログでも発表されています。

aws.amazon.com

必要なDLLをPluginsフォルダにコピー

上記zipファイルを解凍するとたくさんのDLLが出てきます。が、すべてを利用する必要はなく、自分が利用したいAWSのサービス用のDLLのみをコピーします。
ただし、AWSSDK.Core.dllだけはすべてのDLLが参照しているのでコピーが必要です。

今回はS3へ画像のアップロード・ダウンロードを行うのが目的なので以下のDLLをコピーしました。

  • AWSSDK.Core
  • AWSSDK.S3
  • AWSSDK.CognitoIdentity
  • AWSSDK.SecurityToken

依存しているDLLもコピー(依存関係の解決)

さて、実はこれだけで話は終わりません。というのもこのSDKは元々はNuGet*1)で提供されているもので、NuGet経由でインストールした場合はそれに紐づく依存関係も解決した上でインストールしてくれます。

が、今回は手動で上記DLLたちをコピーしているので手動でこの依存関係を解決しないとなりません。

このあたりについては以下の記事を参考にさせていただきました。(めちゃ助かりました)

qiita.com

依存方法の解決方法としてはMicrosoft自身が方法を記載してくれているのでそちらを参考にすることができます。

docs.microsoft.com

参考にした記事でざっくりした説明がされているので引用させていただくと、

ざっくり言うと、NuGetのパッケージ提供ページの「Dependencies」から依存関係を辿り、各パッケージのページにてパッケージをダウンロード、拡張子を.nupkgから.zipに変更して解凍するとdllが得られるのでこれを繰り返す、というものです。

ということで、これに従って依存しているDLLを探していきます。
まずはNuGetのパッケージ提供ページへ行き、「AWS SDK」と検索するとパッケージの一覧が表示されます。

まずはAWSSDK.Coreのページを見てみます。すると以下のように詳細が表示され、その中に依存が記載されている箇所があります。

赤線を引いたところがポイントです。
.NETStandard 2.0と書かれている部分が依存しているDLLの情報です。(今回のSDKが.NET Standard 2.0用のものなので)

親切にリンクが張られているのでそのまま該当ページに飛びます。

すると同様に詳細ページが開きます。

まずは右側にあるDownload packageからパッケージをダウンロードします。
そしてページ下部にはさらに依存を示す情報が載っているので、以後同様に依存しているパッケージをダウンロードします。

最終的には以下のパッケージが必要になります。

  • Microsoft.Bcl.AsyncInterfaces
  • System.Threading.Tasks.Extensions
  • System.Runtime.CompilerServices.Unsafe

ダウンロードが終わったら、記事に記載されている手順に従って拡張子を.nupkg.zipに変更した上でzipファイルを解凍します。

解凍するといくつかのファイルが展開されるので、lib/netstandard2.0配下にあるDLLを、AWS SDKと同様にコピーします。

必要なDLLをすべてコピーし終わるとエラーが消えていると思います。

ちなみに依存を解決する前には以下のようなエラーが出ていると思います。

Assembly 'Assets/Plugins/AWSSDK.Core.dll' will not be loaded due to errors:
Unable to resolve reference 'Microsoft.Bcl.AsyncInterfaces'. Is the assembly missing or incompatible with the current platform?

AWS CoreがまさにMicrosoft.Bcl.AsyncInterfacesに依存していることを示すエラーですね。

ここから先はS3側のセットアップになります。

Identity Pool IDを取得する

以下の手順に従ってIdentity Pool IDを取得します。

  • Cognito Consoleを開く
  • IDプールの管理ボタンをクリック
  • 新しいIDプールの作成ボタンをクリック
  • 任意のIDプール名を入力
  • 認証されていない ID に対してアクセスを有効にするチェックボックスをオンにする
  • 許可をクリック

上記を実行すると最後にプラットフォーム選択画面が表示されるので、そこでUnityを選びます。
すると以下のようなコードが表示されるのでコピーしておきます。(実装時に利用します)

CognitoAWSCredentials credentials = new CognitoAWSCredentials (
    "us-east-2:XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", // Identity Pool ID
    RegionEndpoint.USEast2 // Region
);

バケットの作成

以下の手順に従ってバケットを作成します。

これでバケットが作成されます。

IAMの設定

  • IAM Management Console*2)を開く
  • 左のメニューからロールをクリック
  • ロール名の中からCognito_<Identity_Pool_Name>Unauth_Roleを選択
  • ポリシーをアタッチをクリック
  • AmazonS3FullAccessを付与(検索して出てきた左記のチェックボックスをオンにしてポリシーのアタッチをクリック)

最初に参考にした記事ではポリシーを作成、という手順だったのですが手順通りにしても項目が見つかりませんでした。
ただやりたいことはアクセス権の付与なので、上記の方法で動作が確認できました。

C#で実装する

以上でセットアップと準備が終わりました。あとはC#で実際のコードを書いていきます。
以下から、ひとつずつ実装を見ていきます。

Credentialのセットアップ

Identity Pool IDを取得するのところで出てきたコードを利用します。
CognitoAWSCredentialsオブジェクトを生成しますが、一度生成したら使い回せるのでAwakeなどのタイミングで生成するといいでしょう。

[SerializeField] private string _poolID = "POOL_ID";
[SerializeField] private string _bucketName = "Bucket Name";

private void Awake()
{
    _credentials = new CognitoAWSCredentials(_poolID, RegionEndpoint.USEast2);
}

ここで生成したCognitoAWSCredentialsをダウンロード・アップロード時に利用します。

画像のダウンロード

まずは画像のダウンロードから見ていきましょう。

for .NET版になってからasync/awaitが使えるようになったので簡単にコードを記述することができるようになりました。
フローは、

  1. Getリクエストオブジェクトを生成する
  2. AmazonS3Clientオブジェクトを生成する
  3. (2)のクライアントを使ってリクエストをawaitする
  4. 取得したデータをメモリに読み込む
  5. byteデータを画像化

というステップです。

コードはそこまで長くないので、上記フローを意識して見てもらえると分かるかと思います。

public async UniTask<Texture2D> DownloadImage(string fileName)
{
    GetObjectRequest request = new GetObjectRequest
    {
        BucketName = _bucketName,
        Key = fileName,
    };

    AmazonS3Client client = new AmazonS3Client(_credentials, Region);

    byte[] data = null;

    Debug.Log("Will download a texture from AWS S3.");

    GetObjectResponse result = await client.GetObjectAsync(request);

    Debug.Log("Received a response.");

    MemoryStream stream = new MemoryStream();
    result.ResponseStream.CopyTo(stream);
    data = stream.ToArray();

    Texture2D tex = new Texture2D(1, 1);
    tex.LoadImage(data);
    tex.Apply();

    return tex;
}

画像のアップロード

次に画像のアップロードです。
以下の例はメモリから読み出してアップロードするという手順になります。

ちなみに、for Unity版だとPostObjectRequestオブジェクトを利用していたのですが、これがrenameされたようです。(UploadPartRequestを使う)

フローは以下の通りです。

  1. Texture2Dをbyte配列に変換する
  2. MemoryStreamを作成し、変換したbyte配列を書き込む
  3. UploadPartリクエストオブジェクトを生成する
  4. AmazonS3Clientオブジェクトを生成する
  5. (4)のクライアントを使ってリクエストを投げる(必要があればawaitする)

というステップです。

こちらもコードはそんなに長くないのでそのまま見てもらえれば分かるかと思います。

public async UniTask<string> UploadImage(Texture2D image)
{
    string filename = "Images/" + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + ".png";

    AmazonS3Client client = new AmazonS3Client(_credentials, RegionEndpoint.GetBySystemName(RegionEndpoint.USEast2.SystemName));

    byte[] data = image.EncodeToPNG();

    MemoryStream stream = new MemoryStream(data.Length);
    stream.Write(data, 0, data.Length);
    stream.Seek(0, SeekOrigin.Begin);

    var request = new UploadPartRequest
    {
        BucketName = _bucketName,
        Key = filename,
        InputStream = stream,
    };

    UploadPartResponse result = await client.UploadPartAsync(request);

    Debug.Log(result);

    stream.Close();

    return filename;
}

ひとつだけ注意点があって、MemoryStreamに書き込んだあとはシーケンスを戻す必要があります。

MemoryStream stream = new MemoryStream(data.Length);
stream.Write(data, 0, data.Length);
stream.Seek(0, SeekOrigin.Begin);

この最後の行(stream.Seek(0, SeekOrigin.Begin))を実行しないと0バイトの画像がアップされてしまうので注意してください。

ファイルからアップロードする

今回はすでにメモリにあるものをアップロードするためにMemoryStreamを利用しました。が、ファイルからアップロードしたいという場合もあると思います。

その場合でも、FileStreamを作ってリクエストに設定してあげればOKです。(なので逆を返せばストリームであればなんでも渡せるってことですね)

コード断片だけ書いておきます。

FileStream stream = new FileStream("path/to/file", FileMode.Open, FileAccess.Read, FileShare.Read);

var request = new UploadPartRequest
{
    BucketName = _bucketName,
    Key = filename,
    InputStream = stream,
};

まとめ

調べてすぐに出てくるfor Unity版がすでにサポートされていないっていうのはハマりポイントでした。
それ以外にも、解説してくれている記事を参考にセットアップするも、微妙に内容が異なって「これでいいのか・・?」と不安になりつつセットアップしていくのが大変でした。

今回の記事が誰かの役に立てば幸いです。

*1:NuGetは.NET Framework用のパッケージマネージャ

*2:Identity and Access Management