e.blog

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

LiDARセンサーのデータを使ってUnityで点群を描画する

概要

今回はiPadiPhoneに搭載されているLiDARセンサーから得られる深度情報を使って、立体的に点群位置を計算する方法について書いていきます。

具体的な動作は以下の動画をご覧ください。画面中央に表示されているのが、センサーから得られた情報を元に点の位置を計算しそれを可視化している点群です。背景はカメラが映している実際のシーンです。この計算方法についてまとめます。

GitHub

また今回の実装はGitHubにもアップしてあるので、実際に動くものが見たい方はそちらをご覧ください。

github.com



実装解説

今回の実装にあたり、TokyoYoshidaさんこちらのリポジトリの実装と、KeijiroさんRcam2のリポジトリを参考にさせていただきました。

実装概要

まずは大まかな流れから見ていきましょう。

利用するデータ

LiDARセンサー付きiPad or iPhoneから以下のデータを取得します。

  • 深度データ
  • RGBデータ
  • カメラデータ

深度データ

深度データとは、以下のような深度値を格納したテクスチャのことです。これによって対象までの距離を知ることが出来ます。(以下の画像はUnityのカメラで撮影したものですが、基本的な概念は変わりません)

RGBデータ

RGBデータはそのままカラーの映像テクスチャです。言い換えればカメラ画像です。こちらは説明不要ですね。

カメラデータ

最後のカメラデータは、以下で説明するカメラ自体のデータのことです。具体的にはカメラの焦点距離解像度などです。

データから点群の座標を計算

細かい説明に入る前に位置を計算しているコードを概観してみましょう。

uint2 gridPoint = id.xy * _GridPointsScale;
float2 uv = float2(id.xy) / _DepthResolution;

float depth = _DepthMap.SampleLevel(_LinearClamp, uv, 0).x * 1000.0;
float xrw = (float(gridPoint.x) - _IntrinsicsVector.z) * depth / _IntrinsicsVector.x;
float yrw = (float(gridPoint.y) - _IntrinsicsVector.w) * depth / _IntrinsicsVector.y;
float3 prw = float3(xrw, yrw, depth);

float s = 0.001;
float4 pos = mul(float4x4(
    s, 0, 0, 0,
    0, s, 0, 0,
    0, 0, s, 0,
    0, 0, 0, 1
    ), float4(prw, 1.0));
pos = mul(_TransformMatrix, float4(pos.xyz, 1.0));

最後に求めた pos の値が点群ひとつの位置です。これを必要数分並べたのが冒頭の点群表示というわけです。位置の計算だけに関して言えば20行に満たないコードです。順を追って見ていきましょう。

点群のX, Y位置を求める

uint2 gridPoint = id.xy * _GridPointsScale;

id.xy は起動されたスレッドの X, Y 位置、言い換えると(今回の場合は)テクスチャの X, Y 位置と考えてもらって差し支えありません。その位置に対して _GridPointScale を掛けています。これがなにかと言うと、撮影されたカメラの解像度に比べて深度マップの解像度が異なるためです。そのためカメラ解像度と深度マップの比率を元に位置を合わせる必要があるわけです。(逆に言えば、解像度が合っていればこの計算は不要ということになります)

コンピュートシェーダに値を送っているところの計算を見てみると以下のようになります。

_pointCloudParticle.GridPointsScale = new Vector4(
    (float)metadata.cameraResolution.x / (float)metadata.depthResolution.x,
    (float)metadata.cameraResolution.y / (float)metadata.depthResolution.y,
    0, 0);

カメラ自体の解像度を深度マップの解像度で割った値を設定しているのが分かるかと思います。

なぜこれをしているかというと、サイズが異なるということは、言い換えれば X, Y のスケールと Z のスケールとが合わなくなってしまうということです。結果、点群の位置がおかしくなってしまうわけです。そのためにスケールを合わせているわけなんですね。

なぜX, Yを求めるのか? Zのスケールが合わないって?

ここで、なぜ X, Y を求める必要があるのかですが、これはピンホールカメラの仕組みを理解すると分かります。以下のスライドがとても分かりやすく解説してくれているので詳細はそちらをご覧ください。

www.slideshare.net

ここでは、中の画像を引用させてもらいつつなぜ計算が必要かについて説明します。

原点となっているのがピンホールカメラで言うところのホール部分です。そして右側の青い三角形がリアルな被写体までの関係を表し、左側の緑の三角形がカメラの画像センサー素子までの関係を表しています。

言い換えると、青い三角形のリアル被写体を、緑の三角形側の画像に転写していることを示しているわけです。そしてセンサー側の素子がカメラ解像度と一致します。

深度マップの場合、保存されているのは深度情報です。上の図で言うと Z の値ということですね。

そして前述の通り、カメラの解像度と深度マップのサイズが合っていないため、そのまま利用してしまうと上図での X, Y, Z のうち X, Y の値が(スケールが)異なってしまうわけですね。そのための補正が前述の話になります。言い換えると、Z の値に変化がないのに X, Y の値だけ変化してしまっているわけですね。

カメラデータの中身を紐解く

ARFoundationから得られるカメラデータをもう少し詳しく見てみましょう。まず最初に、得られるデータは以下になります。

// ARCameraManagerのインスタンスから情報を取得する
ARCameraManager.TryGetIntrinsics(out XRCameraIntrinsics intrinsics);

得られるデータ( XRCameraIntrinsics )は以下です。

[StructLayout(LayoutKind.Sequential)]
public struct XRCameraIntrinsics : IEquatable<XRCameraIntrinsics>
{
    mmary>
    /// The focal length in pixels.
    /// </summary>
    /// <value>
    /// The focal length in pixels.
    /// </value>
    /// <remarks>
    /// The focal length is the distance between the camera's pinhole and the image plane.
    /// In a pinhole camera, the x and y values would be the same, but these can vary for
    /// real cameras.
    /// </remarks>
    public Vector2 focalLength => m_FocalLength;

    /// <summary>
    /// The principal point from the top-left corner of the image, expressed in pixels.
    /// </summary>
    /// <value>
    /// The principal point from the top-left corner of the image, expressed in pixels.
    /// </value>
    /// <remarks>
    /// The principal point is the point of intersection between the image plane and a line perpendicular to the
    /// image plane passing through the camera's pinhole.
    /// </remarks>
    public Vector2 principalPoint => m_PrincipalPoint;

    /// <summary>
    /// The dimensions of the image in pixels.
    /// </summary>
    public Vector2Int resolution => m_Resolution;

    // 後略
}

主に注目すべきは focalLengthprincipalPoint の2点です。 focalLength は日本語に訳すと「焦点距離」、すなわち前述の焦点距離を意味しています。そして他方、 principalPoint はコードにも説明が載っているので引用すると、

The principal point is the point of intersection between the image plane and a line perpendicular to the image plane passing through the camera's pinhole.

principalPoint は、イメージ平面、つまり先ほどの原点を含む平面と、ピンホールを通過する、画像平面と垂直な線との交点ということになります。

前述のスライドから引用させていただくと、次に示す画像が分かりやすいでしょう。

つまり、画像中心と光学中心のオフセット、というわけです。

深度マップに保存されているデータから点群位置を復元

さて、上記までで画像から点群位置を復元するための情報が集まりました。具体的に言うと、画像位置の X, Y および保存されている Z の値を用いて、ピクセルの、現実空間での位置を求めるための道具が揃ったということです。

実は冒頭で掲載した復元のための計算は、ここで求めた行列の「逆行列を掛けて」いることと考えることができます。もう一度、該当部分を抜き出してみましょう。

uint2 gridPoint = id.xy * _GridPointsScale;
float2 uv = float2(id.xy) / _DepthResolution;

float depth = _DepthMap.SampleLevel(_LinearClamp, uv, 0).x * 1000.0;
float xrw = (float(gridPoint.x) - _IntrinsicsVector.z) * depth / _IntrinsicsVector.x;
float yrw = (float(gridPoint.y) - _IntrinsicsVector.w) * depth / _IntrinsicsVector.y;

uv は前述の x, y の値を求めていると言い換えることができます。そしてその下の計算部分がまさに「逆行列を掛けている」と考えられる部分です。ただ、実際に行列を利用しているわけではありません。なぜなら、そもそも逆行列の意味は、対象行列に対して掛けることで単位行列にするもの、言い換えれば「行列の作用をなかったことにするもの」と言えます。つまり、「逆変換」をしてあげればいいわけですね。幸い、今回の行列はシンプルです。まず普通に行列を適用すると以下のようになります。( Z は画像での λ

Zx = f * X
x = f * X / Z

x は上記での uv.x です。ここで求めたいのは X の値ですね。なので上記式を変形すると、

X = x * Z / f

上のプログラムと形が似ていることが分かるかと思います。補足しておくと _IntrinsicsVector.xy には focalLength が、 _IntrinsicsVector.zw には principalPoint が格納されています。プログラムで float(gridPoint.x) - _IntrinsicsVector.z) となっているのは引用させていただいたスライドで書かれている「オフセット」を考慮したものです。細かいことを抜きに考えれば上の式と同じ形になっていることが分かるかと思います。

実は点群の位置復元のための計算は以上となります。仕組みが分かってしまえば計算自体はまったくむずかしいところはありません。あとは求めた位置をパーティクルに与え、対象のパーティクルの色をRGB映像からサンプリングしてやれば無事、冒頭の動画のように点群を復元することができるというわけです。

まとめ

各データがなにを意味し、それをどう取り扱えばいいかが分かれば計算自体は複雑ではないことが分かったかと思います。結局のところ、RGBのデータもX, Yのデータも、そして深度も、すべてテクスチャになっていることが理解できれば、あとはそこからデータを取り出し簡単な計算をするだけで済みます。あとはここから様々なエフェクトを考えることもできるでしょう。ただ、これを通信で送ろうとすると少しだけ問題が出てきます。精度の問題です。テクスチャに保存されているデータの精度を落とさずに通信相手に届ける必要があるため、そこは少し工夫が必要となります。とはいえ、通信相手側での計算ロジックは変わらないので、通信部分さえなんとかなれば遠隔で点群を復元することも可能でしょう。

再掲となりますが、実際に動作するコードはGitHubにアップしてあります。

github.com

UnityでGraphQL.Clientを使ってAWS AppSyncを利用する

この記事はUnity Advent Calendar 2021の11日目の記事です。

概要

今回はNuGetからインストールできるGraphQL.Clientを利用して、AWSのAppSyncを利用する手順を書いていこうと思います。

特にSubscriptionを行うには手順が必要でこれを知るのにかなり苦戦しました。

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

github.com



GraphQLとは

GraphQLについては以下の記事がとても参考になりました。

eh-career.com

上記記事から引用させていただくと以下のように説明されています。

まずGraphQLとは何でしょうか。GraphQLは、Facebookが開発しているWeb APIのための規格で、「クエリ言語」と「スキーマ言語」からなります。

クエリ言語は、GraphQL APIのリクエストのための言語で、これはさらにデータ取得系のquery、データ更新系のmutation、サーバーサイドからのイベントの通知であるsubscriptionの3種類があります。なお、この記事では、総称としてのクエリ言語は「クエリ言語」と書き、クエリの3種のひとつであるqueryは「query」と書くことにします。


スキーマ言語は、GraphQL APIの仕様を記述するための言語です。リクエストされたクエリは、スキーマ言語で記述したスキーマに従ってGraphQL処理系により実行されて、レスポンスを生成します。


GraphQLは、クエリがレスポンスデータの構造と似ていて情報量が多いこと、そしてスキーマによる型付けにより型安全な運用ができることが特徴となっています。

これを自分の言葉で解説すると、

APIスキーマ言語によって定義し、それに則したクエリを実行することでサーバ側の任意の処理を実行することができる。

という感じでしょうか。そして基本的にAPIとして必要なものは「データ取得」「データ更新」「特定処理の実行」なので、それらを querymutationに分けていると考えるといいと思います。また最近では、サーバ側のデータが更新されたことをリアルタイムに知りたい場合があり、これを実現するのが subscription ということになります。

引用した文章と同様、本記事でもクエリ言語クエリ、それぞれの個別の処理は querymutationsubscription と記載することにします。

詳細については上記記事など有用な記事がたくさんあるのでそちらに譲ります。ここでは、冒頭のクライアントを利用してUnity上でそれぞれのクエリが適切に実行できる状態まで実装した内容をまとめていきたいと思います。

AWS AppSyncの設定

まずはAWSのAppSync側からセットアップを行っていきます。今回はAWS側で用意してくれている「サンプルプロジェクトから開始する」を選択して開始します。順番に手順を見ていきましょう。

APIを作成

まずはAWSAppSyncコンソールを開きます。そしてAppSyncコンソール上で「APIを作成」ボタンを押下してプロジェクトを作成します。

サンプルプロジェクトの「イベントアプリ」を選択して「開始」を押します。

しばらく待つとDynamoDBの作成が終わり、AppSyncの操作画面に遷移します。ここではスキーマの定義やクエリのテストなどが行えるようになっています。

スキーマを見てみる

今回は「イベントアプリ」というサンプルプロジェクトなので、イベントを管理するためのアプリを想定したスキーマが最初から定義されています。主な型として Event があります。以下のような形で定義されています。

type Event {
    id: ID!
    name: String
    where: String
    when: String
    description: String
    # Paginate through all comments belonging to an individual post.
    comments(limit: Int = 10, nextToken: String): CommentConnection
}

Event typeは ID 型である必須のフィールド id と、名前や場所などの情報が含まれていることが確認できます。

クエリを見てみる

次に、テストできるクエリを見てみましょう。

コンソールの左側の「クエリ」からクエリテストビューを開くと、デフォルトでいくつかのクエリが設定されています。そのうち、イベントのリストを取得する query は以下のようになっています。

query ListEvents {
  listEvents {
    items {
      id
      name
    }
  }
}

これは ListEvents という query 名で、 Query としてスキーマに登録されている型の中で定義されている listEvents を呼び出していることを示しています。(ちなみに Query は以下のように定義されています)

type Query {
    # Get a single event by id.
    getEvent(id: ID!): Event
    # Paginate through events.
    listEvents(filter: TableEventFilterInput, limit: Int = 10, nextToken: String): EventConnection
}

schema {
    query: Query
    mutation: Mutation
    subscription: Subscription
}

この定義からも分かるように、 listEvents を実行した場合のレスポンスは EventConnection 型となっています。これも見てみると、

type EventConnection {
    items: [Event]
    nextToken: String
}

となっており、 Event のリストが返ることが分かります。

スキーマを定義しそれを呼び出す

以上のように、ほしい情報、ほしいアクションをスキーマとして定義し、それを querymutationsubscription という形で呼び出す、というのが一連の流れとなります。またとても大事な点として、リクエストしたクエリがほぼそのままの構造でレスポンスとして返ってくるという点です。

どういうことかと言うと、 GraphQL ではスキーマに定義されているものであれば形を省略しても問題ないようになっています。例えば例の Query は以下のようになっています。

type Query {
    // 中略
    listEvents(filter: TableEventFilterInput, limit: Int, nextToken: String): EventConnection
}

type EventConnection {
    items: [Event]
    nextToken: String
}

type Event {
    id: ID!
    name: String
    where: String
    when: String
    description: String
    # Paginate through all comments belonging to an individual post.
    comments(limit: Int, nextToken: String): CommentConnection
}

そして listEvents の戻りの型は EventConnection になっており、その中身は items という名前の配列で、中身は Event ということが分かります。つまり、最終的には Event のリストを取得するという、名前通りの振る舞いをするわけです。そして Event にはいくつかのパラメータがあります。(ex. name

しかし、クエリを実行する際はこれをすべて指定する必要はなく、冒頭で紹介したように「ほしいパラメータだけ」を指定してクエリを実行することができます。例を再掲すると以下。

query ListEvents {
  listEvents {
    items {
      id
      name
    }
  }
}

するとサーバからは、 items という配列に Eventidname だけが含まれたデータが返されます。言い換えるとリクエストからレスポンスの型を想像することができるとも言えます。

これがREST APIだと、どういうレスポンスが返ってくるかドキュメントを読まないと分かりません。しかし、 GraphQL ならリクエストからレスポンスが想像できるため実装が容易になります。これはとても大きなメリットです。

Unityによる実装

さてでは実際にUnityで実装していきましょう。Unityによる実装はこちらの記事を参考にさせていただきました。

qiita.com

NuGetをインストール

今回はNuGetで管理されている GraphQL.Client を利用して実装を行うため、まずはNuGetをインストールします。インストールは以下のパッケージをインポートすることで行なえます。

github.com

NuGetForUnityがインポートできたらWindowメニューにNuGetが追加されるので「Manage NuGet Package」をクリックしてGraphQLを検索、インストールします。

今回必要なパッケージは以下の2つです。

  • GraphQL.Client
  • GraphQL.Client.Serializer.Newtonsoft

queryを実行する

まずは query の実行から見てみましょう。

public async void SendQuery()
{
    GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());
    graphQLClient.HttpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey);

    GraphQLRequest query = new GraphQLRequest
    {
        Query = _queryInputField.text,
    };

    var response = await graphQLClient.SendQueryAsync<QueryResponse>(query, CancellationToken.None);

    Debug.Log($"[Query] {JsonConvert.SerializeObject(response.Data)}");
}

実行はさほど長くありませんね。以下から、どういうことをやっているのか見ていきましょう。

GraphQLHttpClientを作成

まずは GraphQLHttpClient オブジェクトを生成し、リクエスト先のURLやヘッダを適切に設定します。

GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());
graphQLClient.HttpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey);

GraphQLRequestを作成

次に、クエリとなる GraphQLRequest オブジェクトを生成します。今回はデータ取得の query です。_queryInputField.text にはUIに自由に query を書けるようにしているので、それを指定しています。

GraphQLRequest query = new GraphQLRequest
{
    Query = _queryInputField.text,
};

リクエストを投げてレスポンスを得る

最後に、生成したクエリを送信してレスポンスを受け取ります。

var response = await graphQLClient.SendQueryAsync<QueryResponse>(query, CancellationToken.None);

Debug.Log($"[Query] {JsonConvert.SerializeObject(response.Data)}");

これを実行すると以下のようにレスポンスを受け取ることができます。

レスポンスを受け取る型の定義

GraphQLのレスポンスを受け取るためには、専用のクラスを定義する必要があります。

SendQueryAsync メソッドの戻り値は GraphQLResponse<T> となっていて、この T が、取得したいレスポンスの型を定義したclass になります。GraphQLResponse<T> クラスには T 型の値が設定された Data というプロパティがあるので、これを経由して実際に取得した値にアクセスします。

ちなみにサンプルの例では以下のようにクラスを定義しています。

public class EventType
{
    public string id { get; set; }
    public string name { get; set; }
    public string where { get; set; }
    public string when { get; set; }
    public string description { get; set; }
}

public class QueryResponse
{
    public EventType getEvent { get; set; }
}

スキーマに定義しているものと型、名称が一致しているのが分かるかと思います。ちなみに注意点として、関数のように呼び出した場合、関数名がパラメータ名として返ってくるので QueryResponse では getEvent という名前のフィールドを定義しています。

実際のqueryとレスポンスの例は以下です。

query MyQuery {
  getEvent(id: "c49ceb83-17f5-43b3-a511-98c3721841d2") {
    id
    name
  }
}
{
  "data": {
    "getEvent": {
      "id": "c49ceb83-17f5-43b3-a511-98c3721841d2",
      "name": "My First Event"
    }
  }
}

mutationを実行する

さて、次は更新を伴う mutation です。といっても、こちらは query と大差ありません。さっとコードを見てみましょう。

public async void SendMutation()
{
    GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());
    graphQLClient.HttpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey);

    GraphQLRequest request = new GraphQLRequest
    {
        Query = _mutationInputField.text,
    };

    var response = await graphQLClient.SendQueryAsync<CreateCommentResponse>(request, CancellationToken.None);

    Debug.Log($"[Mutation] {JsonConvert.SerializeObject(response.Data)}");
}

Query に渡しているテキスト以外、まったく同じことが分かります。ちなみに mutation は以下のようになります。( query 同様、更新したい内容だけを含めることができます)

mutation MyMutation {
  commentOnEvent(content: "hogehoge", createdAt: "2021/11/10", eventId: "c49ceb83-17f5-43b3-a511-98c3721841d2") {
    commentId
    content
    createdAt
    eventId
  }
}

取得・更新の流れはなんとなく分かったかと思います。次はリアルタイムに値の更新を受け取る方法です。

subscriptionでリアルタイム通知を受け取る

実は今回一番書きたかったのはここですw

これを実現するのにだいぶ苦戦しました。なぜかと言うと、他のリクエストに比べていくつかの前処理が必要になるからです。

まずは処理の流れをざっと見てみましょう。

public async void AddSubscription()
{
    GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());

    AppSyncHeader appSyncHeader = new AppSyncHeader
    {
        Host = _host,
        ApiKey = _apiKey,
    };

    string header = appSyncHeader.ToBase64String();

    graphQLClient.Options.WebSocketEndPoint = new Uri($"wss://{_realtimeHost}/graphql?header={header}&payload=e30=");
    graphQLClient.Options.PreprocessRequest = (req, client) =>
    {
        GraphQLHttpRequest result = new AuthorizedAppSyncHttpRequest(req, _apiKey)
        {
            ["data"] = JsonConvert.SerializeObject(req),
            ["extensions"] = new
            {
                authorization = appSyncHeader,
            }
        };
        return Task.FromResult(result);
    };

    await graphQLClient.InitializeWebsocketConnection();

    Debug.Log("Initialized a web scoket connection.");

    GraphQLRequest request = new GraphQLRequest
    {
        Query = _subscriptionInputField.text,
    };

    var subscriptionStream = graphQLClient.CreateSubscriptionStream<SubscriptionResponse>(request, ex => { Debug.Log(ex); });
    _subscription = subscriptionStream.Subscribe(
        response => Debug.Log($"[Subscription] {JsonConvert.SerializeObject(response.Data)}"),
        exception => Debug.Log(exception),
        () => Debug.Log("Completed."));
}

リアルタイムな通知はWebSocketを利用する

サーバの変更をリアルタイムに受け取るための通信は WebSocket を使っています。そのため、通常のクエリを送るエンドポイントとは別に、WebSocketのエンドポイントを設定する必要があります。その設定をしているのが以下です。

// WebSocket向けのエンドポイントを設定する
graphQLClient.Options.WebSocketEndPoint = new Uri($"wss://{_realtimeHost}/graphql?header={header}&payload=e30=");

// ... 中略 ...

// WebSocketのコネクションを初期化する
await graphQLClient.InitializeWebsocketConnection();

PreprocessRequestを用いてヘッダを調整する

さて、話はこれだけでは終わりません。

どうやらAWSのAppSyncは少し特殊な仕様になっているらしく、以下のように、リクエスト前にヘッダを調整しないとならないようです。

// AuthorizedAppSyncHttpRequestクラスの定義
public class AuthorizedAppSyncHttpRequest : GraphQLHttpRequest
{
    private readonly string _authorization;

    public AuthorizedAppSyncHttpRequest(GraphQLRequest request, string authorization) : base(request)
        => _authorization = authorization;

    public override HttpRequestMessage ToHttpRequestMessage(GraphQLHttpClientOptions options, IGraphQLJsonSerializer serializer)
    {
        HttpRequestMessage result = base.ToHttpRequestMessage(options, serializer);
        result.Headers.Add("X-Api-Key", _authorization);
        return result;
    }
}


// 上記クラスを実際に使うところ
graphQLClient.Options.PreprocessRequest = (req, client) =>
{
    GraphQLHttpRequest result = new AuthorizedAppSyncHttpRequest(req, _apiKey)
    {
        ["data"] = JsonConvert.SerializeObject(req),
        ["extensions"] = new
        {
            authorization = appSyncHeader,
        }
    };
    return Task.FromResult(result);
};

具体的になにをしているかと言うと、リクエストヘッダに対して data にリクエスト自体をJSON化したものを、そして extensionsX-Api-Key をキーにAuthorizationトークンを指定したデータを付与しています。これを付与することで、AppSyncに対して正常にSubscriptionできるようになります。

あとは通常の手順通りSubscriptionの処理を実装するのみです。

Subscriptionする

最後、Subscriptionの処理を見てみましょう。

GraphQLRequest request = new GraphQLRequest
{
    Query = _subscriptionInputField.text,
};

var subscriptionStream = graphQLClient.CreateSubscriptionStream<SubscriptionResponse>(request, ex => { Debug.Log(ex); });
_subscription = subscriptionStream.Subscribe(
    response => Debug.Log($"[Subscription] {JsonConvert.SerializeObject(response.Data)}"),
    exception => Debug.Log(exception),
    () => Debug.Log("Completed."));

クエリを指定する部分はQueryやMutationと代わりありませんね。そして最後の部分。 CreateSubscriptionStream を生成しているのが相違点です。リアルタイム通信なのでストリームなわけですね。

ストリームを作成したら、そのストリームに対して Subscribe してその結果を受け取ります。

実際に動かし、AppSyncのコンソールから mutation を実行した結果がこちらです↓

Subscriptionのログが表示され、更新された内容が出力されているのが確認できます。これで無事、リアルタイムに更新を受け取ることができるようになりました。

コード全文

最後に、そんなに長くはないので今回のサンプルコードを全部載せておきます。

using System;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using GraphQL;
using UnityEngine;
using GraphQL.Client.Http;
using GraphQL.Client.Serializer.Newtonsoft;
using Newtonsoft.Json;
using GraphQL.Client.Abstractions;
using TMPro;

public class GraphQLHelloWorld : MonoBehaviour
{
    [SerializeField] private string _host = "example12345.appsync-api.us-east-2.amazonaws.com";
    [SerializeField] private string _realtimeHost = "example12345.appsync-realtime-api.us-east-2.amazonaws.com";
    [SerializeField] private string _apiKey = "YOUR_API_KEY_HERE";

    [SerializeField] private TMP_InputField _queryInputField;
    [SerializeField] private TMP_InputField _mutationInputField;
    [SerializeField] private TMP_InputField _subscriptionInputField;
        
    public class EventType
    {
        public string id { get; set; }
        public string name { get; set; }
        public string where { get; set; }
        public string when { get; set; }
        public string description { get; set; }
    }

    public class CommentType
    {
        public string eventId { get; set; }
        public string commentId { get; set; }
        public string content { get; set; }
        public string createdAt { get; set; }
    }

    public class QueryResponse
    {
        public EventType getEvent { get; set; }
    }

    public class CreateMutationResponse
    {
        public EventType createEvent { get; set; }
    }

    public class CreateCommentResponse
    {
        public CommentType commentOnEvent { get; set; }
    }

    public class SubscriptionResponse
    {
        public CommentType subscribeToEventComments { get; set; }
    }

    private void OnDestroy()
    {
        _subscription?.Dispose();
    }

    private class AppSyncHeader
    {
        [JsonProperty("host")] public string Host { get; set; }

        [JsonProperty("x-api-key")] public string ApiKey { get; set; }

        public string ToJson()
        {
            return JsonConvert.SerializeObject(this);
        }

        public string ToBase64String()
        {
            return Convert.ToBase64String(Encoding.UTF8.GetBytes(ToJson()));
        }
    }

    public class AuthorizedAppSyncHttpRequest : GraphQLHttpRequest
    {
        private readonly string _authorization;

        public AuthorizedAppSyncHttpRequest(GraphQLRequest request, string authorization) : base(request)
            => _authorization = authorization;

        public override HttpRequestMessage ToHttpRequestMessage(GraphQLHttpClientOptions options, IGraphQLJsonSerializer serializer)
        {
            HttpRequestMessage result = base.ToHttpRequestMessage(options, serializer);
            result.Headers.Add("X-Api-Key", _authorization);
            return result;
        }
    }

    private IDisposable _subscription;

    public async void OnClickQuery()
    {
        GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());
        graphQLClient.HttpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey);

        GraphQLRequest query = new GraphQLRequest
        {
            Query = _queryInputField.text,
        };

        var response = await graphQLClient.SendQueryAsync<QueryResponse>(query, CancellationToken.None);

        Debug.Log($"[Query] {JsonConvert.SerializeObject(response.Data)}");
    }

    public async void OnClickMutation()
    {
        GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());
        graphQLClient.HttpClient.DefaultRequestHeaders.Add("x-api-key", _apiKey);

        GraphQLRequest request = new GraphQLRequest
        {
            Query = _mutationInputField.text,
        };

        var response = await graphQLClient.SendQueryAsync<CreateCommentResponse>(request, CancellationToken.None);

        Debug.Log($"[Mutation] {JsonConvert.SerializeObject(response.Data)}");
    }

    public async void OnClickSubscription()
    {
        GraphQLHttpClient graphQLClient = new GraphQLHttpClient($"https://{_host}/graphql", new NewtonsoftJsonSerializer());

        AppSyncHeader appSyncHeader = new AppSyncHeader
        {
            Host = _host,
            ApiKey = _apiKey,
        };

        string header = appSyncHeader.ToBase64String();

        graphQLClient.Options.WebSocketEndPoint = new Uri($"wss://{_realtimeHost}/graphql?header={header}&payload=e30=");
        graphQLClient.Options.PreprocessRequest = (req, client) =>
        {
            GraphQLHttpRequest result = new AuthorizedAppSyncHttpRequest(req, _apiKey)
            {
                ["data"] = JsonConvert.SerializeObject(req),
                ["extensions"] = new
                {
                    authorization = appSyncHeader,
                }
            };
            return Task.FromResult(result);
        };

        await graphQLClient.InitializeWebsocketConnection();

        Debug.Log("Initialized a web scoket connection.");

        GraphQLRequest request = new GraphQLRequest
        {
            Query = _subscriptionInputField.text,
        };

        var subscriptionStream = graphQLClient.CreateSubscriptionStream<SubscriptionResponse>(request, ex => { Debug.Log(ex); });
        _subscription = subscriptionStream.Subscribe(
            response => Debug.Log($"[Subscription] {JsonConvert.SerializeObject(response.Data)}"),
            exception => Debug.Log(exception),
            () => Debug.Log("Completed."));
    }
}

ハマったポイント

ここでは、今回のサンプル制作を通していくつかハマった点について調べた記事などを残しておこうと思います。

Subscriptionのリアルタイム更新が動かない

前述のように、subscription の実装に苦戦しました。調べたところAppSyncの実装がその他のGraphQLの実装とやや異なることが問題と分かりました。それでも情報があまりにも少なく、仕方なく開発中のGitHubのissueに質問を投げてみました。

github.com

すると以下のように回答があり、サンプルコードを公開してくれている人のリンクを教えてもらいました。

I've not worked with AppSync myself, but @bjorg created a sample app which might help you along:

https://github.com/bjorg/GraphQlAppSyncTest

実際にAppSyncに対応しているコードがあるのはこちらです。

github.com

こちらの実装を参考にしなんとか無事、subscription を利用してリアルタイムにデータを受け取ることができるようになりました。

実際のコードについては本文で示した通りです。

Subscriptionの実行でエラーになる

AWS AppSyncのコンソールには、手軽にQueryを試せるビューがあります。そこで Subscription を実行した際、エラーが表示される場合があります。これは Subscription 以外がQueryに並んでいると起きるようなので、以下のようにそれ以外を消してから実行するとうまく行きました。

Unity Androidのネイティブプラグイン(C++)でOpenGLのテクスチャデータをNativeArrayにコピーする

概要

以前、iOSのネイティブプラグイン側でテクスチャを保存するという記事を書きました。

edom18.hateblo.jp

今回はこれと似た、Androidのネイティブプラグインを作成してテクスチャをコピーする方法について書きたいと思います。

今回の実装にあたり、こちらの記事を大いに参考にさせていただきました。ありがとうございます。

qiita.com

今回実装したものの動作はこちら。(地味すぎてよく分かりませんがw)

実際のプロジェクトは以下にアップしてあります。

github.com



低レベルネイティブプラグインプラグインインターフェース

低レベルネイティブプラグインインターフェースは、Unityが用意してくれているネイティブプラグインを実装する際に利用できる機能です。この手順に沿って実装することで、各種Graphics APIなどの情報にアクセスすることができるようになります。

docs.unity3d.com

凹みさんも過去に記事を公開してくれているので、これ自体に興味がある方は見てみるとより詳しく知ることができると思います。

tips.hecomi.com

必要な関数を公開して情報を受け取る

インターフェース、と名前が付いている通り、必要な関数を定義し公開することで、Unity側で適切にデータを受け渡してくれる、というものです。具体的には以下のようにプラグイン側を実装することで、対象プラットフォームのGraphics APIなどに簡単にアクセスすることができるようになります。

static IUnityInterfaces* s_UnityInterfaces = NULL;
static IUnityGraphics* s_Graphics = NULL;

extern "C" void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API UnityPluginLoad(IUnityInterfaces* unityInterfaces)
{
    LOG_PRINTF("Called a load callback.");

    s_UnityInterfaces = unityInterfaces;
    s_Graphics = s_UnityInterfaces->Get<IUnityGraphics>();
    s_Graphics->RegisterDeviceEventCallback(OnGraphicsDeviceEvent);

    OnGraphicsDeviceEvent(kUnityGfxDeviceEventInitialize);
}

extern "C" void UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API UnityPluginUnload()
{
    s_Graphics->UnregisterDeviceEventCallback(OnGraphicsDeviceEvent);
}

IUnityGraphics インターフェースのヘッダファイルの場所

インターフェースの定義は、例えば以下のような場所に保存されているためそれをコピーして利用します。

C:\Program Files\Unity\Hub\Editor\2019.4.14f1\Editor\Data\PluginAPI

Android Studioプロジェクトを用意

まずはAndroid Studioプロジェクトを用意します。プロジェクト作成はUnityを利用します。

UnityからAndroidStudio用プロジェクトの作成

以下の手順でAndroid Studioプロジェクトを作成します。

  • Unityを起動しプロジェクトを作成する(例: AndroidPlugin )
  • AndroidにスイッチしバンドルIDを設定する(例: com.example.androidplugin
  • ExportProjectDevelopmentBuildをチェックしExportを押す
  • Unityプロジェクトと同じフォルダにExport用フォルダを作成し、フォルダに移動してから「フォルダの選択」を押す(例: AndroidPlugin\Android
  • AndroidStudio用プロジェクトが生成される

Android Studioを設定する

前段で作成したAndroid Studioプロジェクトを、プラグイン作成用に設定していきます。

Unityプラグイン用のフォルダに、プラグインとなるファイルを追加していきます。作成したプロジェクトをAndroid Studioで開き、プラグイン名( unityLibrary )内に以下のファイルを配置します。

  • unityLibrary/src/main/ 内に cpp フォルダを作成する
  • cpp フォルダ内に CmakeLists.txt ファイルを作成する(この時点では中身は空でOK)
  • unityLibraryコンテキストメニューから LinkC++ProjectWithGradle を選択してリンクする

Android Studio周りのエラー解消

自分の環境ではセットアップ中に色々エラーが出たので解消のために色々調べる必要がありました。これらエラーも環境によって異なると思うので、エラーが出た際の参考にしてください。(出なかった場合はスキップしてください)

ライセンス周りのエラー

以下のようなライセンス周りのエラーが出たのでその解消法です。

Failed to install the following Android SDK packages as some licences have not been accepted.
   ndk-bundle NDK
   patcher;v4 SDK Patch Applier v4
To build this project, accept the SDK license agreements and install the missing components using the Android Studio SDK Manager.
Alternatively, to transfer the license agreements from one workstation to another, see http://d.android.com/r/studio-ui/export-licenses.html

Using Android SDK: C:\Program Files\Unity\Hub\Editor\2019.4.14f1\Editor\Data\PlaybackEngines\AndroidPlayer\SDK

結論から言うと、該当のパッケージなどのライセンスに同意していないというもの。なので、以下の記事を参考に、必要なパッケージをインストールすることでライセンスに同意することができました。

※ ちなみに管理者権限がないと書き込み失敗するので管理者権限でコマンドプロンプトを開始すること。

$ /path/to/AndroidSDK/tools/bin/sdkmanager "patcher;v4"
# 具体的なパス例: C:\Path\To\UnityHub\2019.4.14f1\Editor\Data\PlaybackEngines\AndroidPlayer\SDK\tools\bin\sdkmanager 

qiita.com

qiita.com

NDK周りのエラー

NDK周りのエラーが出ていたのでNDKのフォルダパスを適切に設定して解消しました。

NDK is missing a "platforms" directory.
If you are using NDK, verify the ndk.dir is set to a valid NDK directory.  It is currently set to C:\Path\To\Sdk\ndk-bundle.

設定はウィンドウメニューの File > Project Structure... を開き、 SDK Location 内の Android NDK location に設定します。

これでNDKの設定でエラーが解消されましたが、その後さらにいくつかのモジュールインストールでコケていたので、Android Stduioを管理者権限で起動することで解決できました。


IUnityGraphics インターフェースの追加

冒頭で書いた、Unityが用意してくれているインターフェースをプロジェクトに追加します。

前述の PluginAPI フォルダを cpp フォルダにコピーし、 Unity とrenameします。

cppファイルの追加

cpp フォルダ内に、プラグインの実装となるcppファイルを追加します。(例: copy-texture-data.cpp

最終的に以下の状態になっていればOKです。

CMakeLists.txtを編集

CMakeLists.txt ファイルを編集し、C++プロジェクトをビルドするための準備を行います。具体的には以下のように記載します。(参考にさせていただいた記事から引用させていただいています)

# For more information about using CMake with Android Studio, read the
# documentation: https://d.android.com/studio/projects/add-native-code.html

cmake_minimum_required(VERSION 3.4.1)

# Creates and names a library, sets it as either STATIC
# or SHARED, and provides the relative paths to its source code.
# You can define multiple libraries, and CMake builds them for you.
# Gradle automatically packages shared libraries with your APK.

add_library(# Sets the name of the library.
        native-lib

        # Sets the library as a shared library.
        SHARED

        # Provides a relative path to your source file(s).
        copy-texture-data.cpp)

# Specifies a path to native header files.
include_directories(Unity/)

# Searches for a specified prebuilt library and stores the path as a
# variable. Because CMake includes system libraries in the search path by
# default, you only need to specify the name of the public NDK library
# you want to add. CMake verifies that the library exists before
# completing its build.

find_library(# Sets the name of the path variable.
        log-lib

        # Specifies the name of the NDK library that
        # you want CMake to locate.
        log)

# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.

target_link_libraries(# Specifies the target library.
        native-lib

        # Links the target library to the log library
        # included in the NDK.
        ${log-lib}
        GLESv2)

cmakeC/C++プロジェクトをビルドするために利用されるコマンドです。詳細については「cmake cpp build」などで検索してみてください。

プラグインの実装

以上でAndroid Studioの設定が完了しました。ここからは実際にコードを書いてプラグイン部分を作成していきます。

今回は以下のコードを書きました。

#include "IUnityInterface.h"
#include "IUnityGraphics.h"
#include <math.h>
#include <stdio.h>
#include <assert.h>
#include <GLES3/gl3.h>

static GLuint  g_textureId = NULL;
static int     g_texWidth;
static int     g_texHeight;
static void*   g_data = NULL;

static void UNITY_INTERFACE_API OnRenderEvent(int eventID);

#define LOG_PRINTF printf

extern "C" bool SetNativeTexture(void* textureId, void* data, int width, int height)
{
    g_textureId = (GLuint)(size_t)textureId;
    g_data = data;
    g_texWidth = width;
    g_texHeight = height;

    LOG_PRINTF("SetNativeTexture:%d, %d, %d", g_textureId, g_texWidth, g_texHeight);

    return true;
}

extern "C" void FinishNativeTexture()
{
    if (g_data != NULL)
    {
        delete[] g_data;
    }
    g_data = NULL;
}

extern "C" UnityRenderingEvent UNITY_INTERFACE_EXPORT UNITY_INTERFACE_API GetRenderEventFunc()
{
    return OnRenderEvent;
}

static void ReadPixels()
{
    int currentFBOWrite;
    glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &currentFBOWrite);
    glBindFramebuffer(GL_READ_FRAMEBUFFER, currentFBOWrite);

    glReadPixels(0, 0, g_texWidth, g_texHeight, GL_RGBA, GL_UNSIGNED_BYTE, g_data);
}

static void UNITY_INTERFACE_API OnRenderEvent(int eventID)
{
    ReadPixels();
}

実装の内容はそこまで多くありません。メインとなる処理に絞って説明していきます。

ネイティブ側にテクスチャポインタおよびデータポインタを渡す

まず最初に見るのが SetNativeTexture 関数です。

extern "C" bool SetNativeTexture(void* textureId, void* data, int width, int height)
{
    g_textureId = (GLuint)(size_t)textureId;
    g_data = data;
    g_texWidth = width;
    g_texHeight = height;

    LOG_PRINTF("SetNativeTexture:%d, %d, %d", g_textureId, g_texWidth, g_texHeight);

    return true;
}

この関数は、C#側からテクスチャポインタとデータポインタが渡され、それを保持します。それぞれ void* 型として渡されます。テクスチャに関してはテクスチャIDに変換して保持しておきます。

g_textureId = (GLuint)(size_t)textureId;

それ以外については static 変数に保持しておきます。

テクスチャからデータを読み出す

次に見るのが ReadPixels 関数です。これが今回のプラグインのメイン部分です。といってもコード量は全然多くありません。

static void ReadPixels()
{
    int currentFBOWrite;
    glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &currentFBOWrite);
    glBindFramebuffer(GL_READ_FRAMEBUFFER, currentFBOWrite);

    glReadPixels(0, 0, g_texWidth, g_texHeight, GL_RGBA, GL_UNSIGNED_BYTE, g_data);
}

ここで行っているのは、現在アタッチされているFrameBufferからの値を読み出し、データポインタとして渡された位置への書き込みです。

この関数が呼び出される前にC#側でバッファを設定しているので、ここでは GL_DRAW_FRAMEBUFFER_BINDING されているバッファを取得し、それをバインドしています。

そして glReadPixels 関数を呼び出してデータを読み出しています。

ネイティブ側の関数を呼び出す

最後に見るのは OnRenderEvent 関数と GetRenderEventFunc 関数です。ここで行っているのは、C#側から適切に呼び出せるようにするための定義です。細かい説明よりも、C#側のコードを見たほうが早いと思うので、どう使われているかを見てみましょう。

GL.IssuePluginEvent(GetRenderEventFunc(), 1);

GL.IssuePluginEventUnityが用意しているAPIで、ネイティブコードプラグインにユーザーが定義したイベントを送信します。端的に言えば、プラグインの関数を呼び出します。

GetRenderEventFunc 関数は関数ポインタを返す関数になっています。つまり、ポインタが示す関数を呼び出している、というわけです。

Pluginをビルドする

C++側の実装が終わったらAndroid StudioでPluginをビルドします。以下の Make Project からビルドを実行します。すると unityLibrary 内に build フォルダが生成されます。そして build/intermediates/cmake/debug/obj/armeabi-v7a/libnative-lib.so の場所にファイルが生成されているので、これをUnityの Plugins/Android フォルダに配置します。

※ 参考にした記事ではGradleのタスクを追加し、それが自動的に Plugins/Android に配置されるようになっていたのですが、実行タスクとして選択できなかったので今回の例では手動でコピーしました。

以上がプラグイン側の実装になります。次はC#側の実装を見てみましょう。

C#側を実装する(プラグインの機能を呼び出す)

まずはコード全文を載せます。その後に、主要部分について説明します。

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

public class CopyTextureData : MonoBehaviour
{
    [SerializeField] private RawImage _rawImage = null;
    [SerializeField] private RawImage _resultImage = null;
    [SerializeField] private int _width = 512;
    [SerializeField] private int _height = 512;
    [SerializeField] private Camera _camera;

    private RenderTexture _renderTexture;
    private Texture2D _result;
    private NativeArray<byte> _nativeArray;

    [DllImport("copytexturedata")]
    unsafe private static extern bool SetNativeTexture(IntPtr textureId1, void* data, int width, int height);

    [DllImport("copytexturedata")]
    private static extern void FinishNativeTexture();

    [DllImport("copytexturedata")]
    private static extern IntPtr GetRenderEventFunc();

    unsafe private void Start()
    {
        _width = Screen.width;
        _height = Screen.height;
        
        _renderTexture = new RenderTexture(_width, _height, 0, RenderTextureFormat.ARGB32);
        _renderTexture.Create();

        _result = new Texture2D(_width, _height, TextureFormat.RGBA32, false);
        _resultImage.texture = _result;

        _rawImage.texture = _renderTexture;
        _camera.targetTexture = _renderTexture;

        _nativeArray = new NativeArray<byte>(_width * _height * 4, Allocator.Persistent);

        if (!SetNativeTexture(_renderTexture.GetNativeTexturePtr(), _nativeArray.GetUnsafePtr(), _renderTexture.width, _renderTexture.height))
        {
            return;
        }

        StartCoroutine(NativeTextureRenderLoop());
    }

    private void OnDestroy()
    {
        _renderTexture.Release();
        _nativeArray.Dispose();
        FinishNativeTexture();
    }

    private void IssueEvent()
    {
        RenderTexture back = RenderTexture.active;
        RenderTexture.active = _renderTexture;
        GL.IssuePluginEvent(GetRenderEventFunc(), 1);
        RenderTexture.active = back;

        _result.SetPixelData(_nativeArray, 0, 0);
        _result.Apply();
    }

    private IEnumerator NativeTextureRenderLoop()
    {
        while (true)
        {
            yield return new WaitForEndOfFrame();
            IssueEvent();
        }
    }
}

プラグイン側の関数の利用を宣言

プラグインの機能を呼び出すには以下のように宣言します。

[DllImport("copytexturedata")]
unsafe private static extern bool SetNativeTexture(IntPtr textureId1, void* data, int width, int height);

[DllImport("copytexturedata")]
private static extern void FinishNativeTexture();

[DllImport("copytexturedata")]
private static extern IntPtr GetRenderEventFunc();

なお、 DllImport については過去に(英語ですが)記事を書いているので興味がある方は見てみてください。

edom18.medium.com

定義を見てもらうと分かりますが unsafe がついているため、プロジェクトの設定かAssembly Definition Fileなどで unsafe なコードが書けるように設定する必要があります。

ポインタをネイティブ側に渡す

以下のコードが、ネイティブ側にポインタを渡している箇所です。

_nativeArray = new NativeArray<byte>(_width * _height * 4, Allocator.Persistent);
if (!SetNativeTexture(_renderTexture.GetNativeTexturePtr(), _nativeArray.GetUnsafePtr(), _renderTexture.width, _renderTexture.height))
{
    return;
}

Texture クラスには GetNativeTexturePtr() メソッドがあるのでこれを、また NativeArray<T> には GetUnsafePtr() メソッドがあるのでこれを利用してポインタを渡しています。

なお、なぜ NativeArray<T> を使っているかというと、 glReadPixels 関数を実行すると、マネージドメモリの場合にInvalidエラーが出てしまうためです。そのため、 NativeArray<T> を使ってアンマネージドなメモリを確保し、そのポインタを渡しているというわけです。

ネイティブ実装を呼び出してデータをコピーする

最後に、ネイティブ側の実装を呼び出しているところを見てみましょう。

private void IssueEvent()
{
    RenderTexture back = RenderTexture.active;
    RenderTexture.active = _renderTexture;
    GL.IssuePluginEvent(GetRenderEventFunc(), 1);
    RenderTexture.active = back;

    _result.SetPixelData(_nativeArray, 0, 0);
    _result.Apply();
}

上記メソッドを毎フレーム呼び出すことで、冒頭の動画のように、 RenderTexture の内容をコピーしています。

ネイティブ実装側でも書きましたが、 RenderTexture.active に呼び出したい RenderTexture を設定してネイティブ実装を呼び出しています。こうすることでテクスチャからのデータを読み出しているというわけです。

後半部分は実際にデータコピーが成功しているかを示すためのデバッグ処理です。 NativeArray<T> のデータをテクスチャに適用することで確認を行っています。

その他の機能

ちょっと余談として、ネイティブ側でテクスチャ自体にデータを設定する方法を備忘録として残しておきます。

今回の実装ではテクスチャの内容を配列にコピーするというものでしたが、以下はテクスチャ自体に配列のデータを適用するというものです。

glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, g_texWidth, g_texHeight, GL_RGBA, GL_UNSIGNED_BYTE, bytes);

なお、 bytesu_char* 型の配列データです。

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;
    }
}