かみのメモ

コンピュータビジョン・プログラムな話題中心の勉強メモ

Unity Native PluginでC++の処理を非同期に呼び出す

前の研究室で「UnityでOpenCV使いたい」とか「UnityからArduinoにアクセスしたらゲームが固まった」とかよく耳にしたので、それに応えるつもりで記事を書いてみます。

今回の記事では

  1. UnityからC++の関数を呼び出す方法
  2. C++の重い処理を非同期に呼び出す方法

の2つを紹介します。

UnityにはNative Pluginという機能が用意されており、.NET Frameworkに依存しないネイティブのコードを呼び出すことができます。 普段のUnity開発ではアセットとC#を駆使してコードを書きますが、OpenCVを使った画像処理や外部センサーとの通信のような、込み入った処理はなかなか実装しづらい部分があります。 そこで、そうした処理をC++で実装してからNative PluginとしてUnityと連動させてみよう、というのがこの記事の趣旨です。

スポンサーリンク

もくじ

1. はじめに

1.1. Native Pluginとは?

Unityはプログラムの実行基盤として、クロスプラットフォーム版の.NET FrameworkであるMonoを採用しています。 Monoがマルチプラットフォームに対応しているおかげで、私達が書いたC#コードはWindowsOSXiOSAndroid…といった異なる環境でも同じように動きます。

しかし、Unity開発をしていると、プラットフォーム依存の込み入った処理やC# APIが用意されていないライブラリなど、.NETランタイムを通さずネイティブにコードを実行させたいケースが出てきます。 例えば、PointGreyやXimeaのような産業用カメラのSDKや、OpenCVなどのOSSは.NETランタイムをサポートしていません。

これらのプログラムを.NET Framework上で動かすためには、.NETランタイムで動く形式にビルドし直す必要があるのですが、ソースコードを入手した上で大幅に手を加えることになるので非常に手間がかかります。

またMonoでは、本家.NET Frameworkからいくつかの機能が削られているため、C#だけでは処理が書きにくい場面も出てきます。

こうしたケースに備えて用意されているのがNative Pluginという機能です。 この機能を使えば、Unityとは別にコンパイルしたネイティブコードの関数をC#から呼び出すことができます。 つまり、C++など別の言語で実装した処理をUnity側から利用できるようになります

一応、Native Pluginを使うデメリットを挙げておくと、①Unityと別口でコンパイルする手間が増える、②クロスプラットフォーム開発のコストが高くなる、③Unityエディタを通してデバッグしづらい、の3つでしょう。

とはいえ、ある程度C++開発に慣れている人や「研究や遊びで使うだけだからこのPCで動けばいい」と割り切ってしまえる人にとっては、非常に頼りになる機能だと思います。

1.2. Unityで非同期処理が必要になる場面

話は変わって、Unityで非同期処理が必要になる場面についてのお話。

Unityは、動的に実行したい処理をUpdate()関数の中に書いておくというスタイルを採っており、この関数が毎フレーム呼び出されることでゲームが進行していきます。

逆に言えば、Update()関数の中には1フレーム分の時間を超える処理を書いてはいけません。 ここに重い処理を書いてしまうとフレームレートが低下し、最悪の場合Unityがフリーズしてします。

しかし、カメラ画像の読み込みやシリアル通信といったUnityと非同期に動いている相手が絡む処理は、呼び出す度に応答時間が変わります。 呼び出すタイミングが悪かったり、デバイスの問題で一時的に通信が途絶したりすれば、長時間処理が返らないこともあります。 また、ライブラリの重い処理を呼び出す場合にも、同様の現象が生じます。

こうした事情から、Unityから外部リソースにアクセスするときはフレームレートを保つため、処理をごく短時間で完了させるもしくはそう見せかけるような工夫が必要になります。

今回はこれを非同期処理で実現してみます。

2. Native Pluginの使い方

まずはNative Pluginの使い方を紹介します。

とはいえ、Google先生に聞いてもらえば他の記事がたくさん見つかると思うので、ここでは概要だけを説明します。 参考になる公式ドキュメントはこちらです。

以下、C/C++を使う前提で話を進めます。

2.1. C++プロジェクトの作成

Visual Studio

Windowsの場合は、ネイティブのC/C++プロジェクトをDLLとしてビルドします。 「新しいプロジェクト->言語:C++->プラットフォーム:Windows->プロジェクトタイプ:ライブラリ->ダイナミックリンクライブラリ(DLL)」でプロジェクトを作成すればよいです(Visual Studio 2019)。

XCode

Macの場合は、C/C++プロジェクトをBundle(動的ロード用のライブラリ)としてビルドします。 「new project->macOS->Bundle」でプロジェクトを作成しましょう(XCode10)。

CMake

CMakeを使う場合は、以下のようなCMakeLists.txtを書けばよいです。

cmake_minimum_required(VERSION 3.9)

project(nativeplugintest CXX)

set(MYLIB_SRC
  mylib.cpp
)

if (APPLE)
add_library(mylib MODULE ${MYLIB_SRC})
set_target_properties(mylib PROPERTIES BUNDLE TRUE)
else()
add_library(mylib SHARED ${MYLIB_SRC})
endif()

Linux/Android/iOSでの動作は検証できていないので、他の方の記事を参照してください(ごめんなさい!)

2.2. C++コードを書く

次に、Plugin側の関数を実装します。

プロジェクトに適当なC++ファイルを作成し、以下のコードを追加します。 Visual Studioの場合はdllmain.cppというファイルが自動で作成されるので、そこに追記すればOKです。

#ifdef _WIN32
#define UNITYCALLCONV __stdcall
#define UNITYEXPORT __declspec(dllexport)
#else
#define UNITYCALLCONV
#define UNITYEXPORT
#endif

extern "C" {
UNITYEXPORT int UNITYCALLCONV getNumber() { return 777; }
}

ここでは、int型の値777を返すgetNumber()という関数を定義しています。

Native PluginはC言語の呼び出し規約を利用するため、関数の定義をextern "C"で囲んでいます。 また、Windowsの場合は、デフォルトの呼び出し規約を__cdeclではなく__stdcallにした上で、エクスポートすることを明示するために__declspec(dllexport)を付記しなければならないようです(Visual StudioからDLLプロジェクトを作成した場合はいらないっぽい?)。

2.3. C++ライブラリをビルドする

次に、C++プロジェクトをビルドしてライブラリファイルを作成します。

Visual Studio

「ビルド->ソリューションのビルド」でビルドが実行されます。 ▷ボタンを押してもビルドは実行されますが、デバッグ対象のプログラムがないので、ビルド後にエラーメッセージが表示されます。 作成されたライブラリは<ソリューションフォルダ>/Release/xxx.dllなどに置かれているはずです。

XCode

▷ボタンでビルドすると、project navigatorに作成されたxxx.bundleというライブラリファイルが表示されます。 これを次の項で説明するフォルダにドラッグ&ドロップすればOKです(実際のファイルは~/Library/Developer/Xcode/....とかに置かれています)。

CMake

プロジェクトのフォルダ内でターミナルを開き、以下のコマンドを実行しましょう。

mkdir build
cd build
cmake ..
cmake --build .

ビルドに成功すれば<プロジェクトフォルダ>/build/...以下のどこかにライブラリファイルが置かれているはずです。

2.4. C#コードを書く

UnityプロジェクトにAssets/Pluginsというフォルダを作成し、先ほど作成したライブラリファイル(dll or bundle or so)をその中にコピーしましょう。 32ビット/64ビットでロードするライブラリを分けたい場合は、Assets/Plugins/(x86 or x86_64)という名前でフォルダを作成すればよいです。

このフォルダにライブラリファイルを置いた時点で、Unityが勝手にプラグインとして読み込んでくれます。 ただし注意点として、Unityは一度ロードしたプラグインはリロードしてくれません。 つまり、ライブラリファイルを更新したときはUnity自体を再起動しなければなりません。 再起動が面倒であれば、mylib2mylib3のように名前を少しずつ変えながら追加していけばいいと思います。

次に、C++で実装したgetNumber()関数を呼び出すC#コードを書いてみます。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Runtime.InteropServices;

public class NativePluginTest : MonoBehaviour
{
    [DllImport("mylib")] // "mylib"の部分は自分が作成したライブラリファイルの名前
    // もし静的リンクライブラリをNativePluginとして使うなら [DllImport("__Internal")]
    private static extern int getNumber();

    void Update() {
        var num = getNumber();
        Debug.Log(num);
    }
}

書き終わったら、適当なオブジェクトにアタッチしておきましょう。

2.5. 実行してみる

準備が終わったらゲームをデバッグ実行してみます。 下のようにConsoleに777デバッグメッセージが表示されていれば成功です。

f:id:kamino-dev:20190502034042p:plain
実行結果

このように「C++に関数を作る->ビルドしてライブラリファイルをコピーする->C#コードから呼び出す」という手順を繰り返しながらNativePluginを開発していきます。 ライブラリファイルをコピーしてUnityを再起動する手順が割と面倒なので、先にC++だけでデバッグしておいて、最後にUnityと連動させる方がスムーズに開発できるかもしれませんね。

もちろん、C++をビルドするときに各種SDKやライブラリをリンクしてやれば、カメラ・ArduinoOpenCVなどの機能を利用した関数を作ることもできます。

3. Native Plugineで非同期処理

次に、Unityのフレームレートを落とさないために、C++の重い処理を非同期に実行する方法を紹介します。

3.1. 非同期処理が必要な例

まずは、非同期処理が必要になる例をお見せします。

先ほど紹介したgetNumber()は非常に処理が軽い関数なので、Unity側にほとんど負荷がかかりません。 そこで今度は少し処理を重くしてみます。

雑な実装ですが、0~100msの負荷がランダムにかかるように変更して、Unity側から呼び出してみましょう。

#include <random>
#include <thread>
#include <chrono>

extern "C" {
UNITYEXPORT int UNITYCALLCONV getNumber() {
  std::this_thread::sleep_for(std::chrono::milliseconds((int)(100.0 * rand() / RAND_MAX)));
  return 777;
}
}

実行時のProfilerの様子です。

f:id:kamino-dev:20190211184503p:plain
同期処理にしたときの実行時間

NativePluginTest.Update()の呼び出しに時間を食われて、ところどころ15FPSも出せていないことがわかります。 このままではカクつきがひどく、アプリとしては使えそうにありません。

3.2. 単純な非同期処理の実装

そこで、重くなったgetNumber()を非同期で呼び出してみます。

実装の方針はいくつかあるのですが、ここではgetNumber()Arduinoなど外部デバイスからのデータ取得に見立てて、「できるだけ最新のデータをUpdate()内で使いたい」ものとします。 これを実現するため、ひたすらgetNumber()を呼び出して値を取得し続けるスレッドと、最後に取得できた値を返す関数を作成してみます。

C++コード

#include <atomic>
#include <random>
#include <thread>
#include <chrono>

#ifdef _WIN32
#define UNITYCALLCONV __stdcall
#define UNITYEXPORT __declspec(dllexport)
#else
#define UNITYCALLCONV
#define UNITYEXPORT
#endif

extern "C" {
std::thread getNumberThread;  // threadの情報を保持するための変数
std::atomic<int> latestData;  // 最後に取得できた値を保持するための変数
std::atomic<bool> exitFlag;   // threadに終了を通知するためのフラグ

int getNumber() {
  std::this_thread::sleep_for(std::chrono::milliseconds((int)(100.0 * rand() / RAND_MAX)));
  return 777;
}

UNITYEXPORT void UNITYCALLCONV initThread() {
  latestData = 0;
  exitFlag = false;

  // getNumber()を繰り返し呼び出しlatestDataを更新し続けるthreadを作成・開始する
  getNumberThread = std::thread([] {
    while (!exitFlag) { latestData = getNumber(); }
  });
}

UNITYEXPORT int UNITYCALLCONV getLatestNumber() { return latestData; }

UNITYEXPORT void UNITYCALLCONV terminateThread() {
  exitFlag = true;         // threadに終了を通知する
  getNumberThread.join();  // threadが終了するまで待つ
}
}

スレッドの作成にはC++11から追加されたstd::threadを利用しています。 スレッド内の処理を無限ループにすると、Unityを起動している間ずっとスレッドが生き残ってしまうので、exitFlagが立ったら終了するように実装しています。

また注意として、latestDataexitFlagatomic変数として宣言する必要があります。 これらの変数は、メインスレッドとgetNumber用のスレッドから同時にアクセスされる可能性があるからです。 C++では、複数のスレッドが同時に同じ変数にアクセスしたとき(データ競合といいます)の動作を"未定義"として禁止しています。 未定義というのは言葉の通り「何が起こるかわからない」ということで、最悪プログラムがクラッシュしてしまいます。 atomic変数として宣言しておけば、こうしたデータ競合を自動で防いでくれます。

次にUnity側のコードです。

C#コード

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Runtime.InteropServices;

public class NativePluginTest : MonoBehaviour
{
    [DllImport("mylib")]
    private static extern int getLatestNumber();
    [DllImport("mylib")]
    private static extern void initThread();
    [DllImport("mylib")]
    private static extern void terminateThread();

    void Start() {
        // getNumber用スレッドを開始
        initThread();
    }

    void Update() {
        // 最近取得した値を読み込む
        var num = getLatestNumber();
        Debug.Log(num);
    }

    void OnDestroy() {
        // getNumber用スレッドを終了
        terminateThread();
    }
}

以上のコードを実行したときのProfilerの様子です。

f:id:kamino-dev:20190211184546p:plain
非同期処理にしたときの実行時間

処理時間が短くなり、十分なフレームレートを出せていることがわかります。

4. おわりに

以上、UnityからC++の関数を呼び出す方法と、C++の重い処理を非同期に呼び出す方法の2つを紹介しました。

今回、C++C#でやり取りしたデータは単なるint型でしたが、C#側から配列のポインタを渡しC++側から書き込むことも可能です。 この方法を利用すれば、OpenCVで画像を処理してそれをテクスチャとしてUnityに取り込む、といった処理も実現できます。 このあたりは凹みさんの記事が参考になります。

tips.hecomi.com

ただしこれを非同期に実行すると、C++側から書き込んでいる途中にC#側が読み出しを始めてデータ競合が起こる危険があります。 データ競合を回避しつつフレームレートを最速に保つには、ダブルバッファリングを実装するなどの工夫が必要になります。