かみのメモ

コンピュータビジョン・プログラムな話題中心の勉強メモ(記事一覧は https://kamino.hatenablog.com/archive へ)

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…といった異なる環境でも同じように動きます。

しかし開発をしていると、プラットフォーム依存の込み入った処理やC# APIが用意されていないライブラリなど、.NETランタイムを通さずネイティブにコードを実行させたいケースが出てきます。 例えば、PointGrayや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#側が読み出しを始めてデータ競合が起こる危険性があります。 データ競合を回避しつつフレームレートを最速に保つには、ダブルバッファリングを実装するなどの工夫が必要になります。