凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Low-Level Native Plugin Interface を利用してネイティブから Unity のテクスチャを高速に更新する方法を調べてみた

はじめに

Unityネイティブ側(C/C++ 等)で作成したテクスチャを利用する方法はいくつかあって、テラシュールブログさんにまとめられています。

私もこれ以外の方法として、よく使う Texture2D.SetPixels() を使う方法と Low-Level Native Plugin Interface(低レベルネイティブプラグインインターフェース)をざっくりと紹介しました。

そこで本エントリでは最も高速にネイティブ側とやりとり出来る Low-Level Native Plugin Interface 及び CreateExternalTexture() / UpdateExternalTexture() を使った利用例について、OpenCV から取得したカメラ画を効率よく利用するサンプルを紹介したいと思います。

Unity 側のコードは同じなのですが各プラットフォーム毎にネイティブ側の扱いが変わるので、まずは簡単のために Mac OS X を仮定して進めていきます。

追記(2016/01/10)

Windows 編も書きました。

tips.hecomi.com

追記(2019/08/24)

また、Unity 2017.2.0b10 からは次のようなプラットフォーム非依存な API も用意されています。

tips.hecomi.com

仕組み

例えば Texture2D.SetPixels() および Texture2D.Apply() を利用する方法では、まずネイティブからテクスチャデータを C#Color32[] 型のポインタを通じてコピーし、SetPixels() および Apply() で対象のテクスチャを更新、GPU へアップロードする、という経路をたどります。

// 適当なテクスチャ
var texture = new Texture2D(width, height, TextureFormat.RGB24, false);

// テクスチャのサイズと同じサイズの Color32[] 型のコピーするための領域を作成し、
// GC されないようにして配列の先頭のポインタを取得
var pixels  = texture_.GetPixels32();
var handle  = GCHandle.Alloc(pixels_, GCHandleType.Pinned);
var pointer = pixels_handle_.AddrOfPinnedObject();

// 配列のポインタをネイティブ側に渡してテクスチャを更新(ここは別スレッドに出来る)
updateTexture(pointer, width, height);

// ネイティブからもらった pixels をテクスチャにコピーして
// GPU 側へアップロードして反映する(このコピーが無駄で重い)
texture_.SetPixels32(pixels);
texture_.Apply();

この経路を辿ると Unity が内部でテクスチャのデータをよしなにハンドルし、裏側で各プラットフォームに合わせた処理を行ってくれるという利点があります。ユーザが DirectXOpenGL を意識する必要はありません。しかしながら高速化のためにはこれらの不要なコピーを避けられるはずで、ネイティブから直接 GPU へテクスチャのデータをアップロードすれば良いはずです。

これを理解するために少し TextureTexture2DTexture の派生クラス)の話になるのですが、Texture には GetNativeTexturePtr() という関数が用意されていて、これを呼ぶと System.IntPtr 型の値が返ってきます。この値はテクスチャのハンドルになっていて、例えば Windows であれば DirectX 11 なら ID3D11Texture2D*MacLinuxAndroid なら OpenGL または OpenGL ES 2.0 の GLuintiOS なら Metal の id<MTLTexture> といった感じです。

つまり、これらのテクスチャハンドルを直接ネイティブ側で生成して Unity 側に通知するか、Unity 側で生成したテクスチャのハンドルをネイティブ側に渡してそこに描画してもらえば一番速いわけです。

前者が CreativeExternalTexture() および UpdateExternalTexture() を利用するテラシュールブログさんで言及されている方法で、後者が Low-Level Native Plugin Interface でジェネレ−ティブテクスチャ生成のために説明されている方法になります。この 2 つは作法は異なりますが、根っこのところは同じです。

コード量的にはほぼ同じなので、ネイティブ側の事情に合わせて使い分ければ良いと思います。

コード(C++ 側)

C++ 側ではサンプルとして以下のようなコードを用意してみます。

#include <opencv2/opencv.hpp>
#include <OpenGL/gl.h>
#include <thread>


class Camera
{
public:
    explicit Camera(int device)
        : camera_(device)
    {
        startCapture();
    }

    ~Camera()
    {
        stopCapture();
        camera_.release();
    }

private:
    void startCapture()
    {
        thread_ = std::thread([this] {
            isRunning_ = true;
            while (isRunning_) {
                camera_ >> image_;
            }
        });
    }

    void stopCapture()
    {
        isRunning_ = false;
        if (thread_.joinable()) {
            thread_.join();
        }
    }

public:
    void update()
    {
        glBindTexture(GL_TEXTURE_2D, texture_);
        glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
        glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
        // カメラ画は 3 byte 毎に色が並ぶが 1, 2, 4 しか境界は指定できないので 1 を指定
        glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
        glTexImage2D(
            GL_TEXTURE_2D,
            0,
            GL_RGB8, // RGB それぞれ 8 bit で計 24 bit
            getWidth(),
            getHeight(),
            0,
            GL_BGR, // OpenCV では色は BGR の順に並ぶ
            GL_UNSIGNED_BYTE,
            image_.data);
        glBindTexture(GL_TEXTURE_2D, 0);
    }

    bool isOpened() const
    {
        return camera_.isOpened();
    }

    int getWidth() const
    {
        return image_.cols;
    }

    int getHeight() const
    {
        return image_.rows;
    }

    int getTexturePtr() const
    {
        return texture_;
    }

    void setTexturePtr(void* ptr)
    {
        texture_ = (GLuint)(size_t)ptr;
    }

    void createTexture()
    {
        glGenTextures(1, &texture_);
    }

private:
    cv::VideoCapture camera_;
    cv::Mat image_;
    GLuint texture_ = 0;

    std::thread thread_;
    bool isRunning_ = false;
};


extern "C"
{
    using DebugFuncPtr = void(*)(const char*);
    using UnityRenderEvent = void(*)(int);

    namespace
    {
        DebugFuncPtr debug;
        Camera* g_pCamera;
        GLuint g_pTexture;
    }

    void* getCamera(int device)
    {
        g_pCamera = new Camera(device);
        return g_pCamera;
    }

    void releaseCamera(void* ptr)
    {
        auto camera = reinterpret_cast<Camera*>(ptr);
        delete camera;
    }

    bool isCameraOpened(void* ptr)
    {
        auto camera = reinterpret_cast<Camera*>(ptr);
        return camera->isOpened();
    }

    int getCameraWidth(void* ptr)
    {
        auto camera = reinterpret_cast<Camera*>(ptr);
        return camera->getWidth();
    }

    int getCameraHeight(void* ptr)
    {
        auto camera = reinterpret_cast<Camera*>(ptr);
        return camera->getHeight();
    }

    int getCameraTexturePtr(void* ptr)
    {
        auto camera = reinterpret_cast<Camera*>(ptr);
        return camera->getTexturePtr();
    }

    void updateCamera(void* ptr)
    {
        auto camera = reinterpret_cast<Camera*>(ptr);
        camera->update();
    }

    void setCameraTexturePtr(void* ptr, void* pTexture)
    {
        auto camera = reinterpret_cast<Camera*>(ptr);
        camera->setTexturePtr(pTexture);
    }

    void createTexture(void* ptr)
    {
        auto camera = reinterpret_cast<Camera*>(ptr);
        camera->createTexture();
    }

    void onRenderEvent(int eventId)
    {
        g_pCamera->update();
    }

    UnityRenderEvent getRenderEventFunc()
    {
        return onRenderEvent;
    }
}

OpenCV でのカメラキャプチャ用の cv::VideoCapture、キャプチャした画の保存用の cv::MatOpenGL のテクスチャを指す GLuint をまとめたクラス Camera を作成し、このクラスのインスタンスを生成・破棄・操作する APIC# 向けに公開している形になります。バックグラウンドのスレッドでカメラ画を随時更新し、メインスレッドから呼ばれる Camera::update()OpenGL の世界へテクスチャを公開します。

これを XCode でビルドします。プロジェクトは適当に適当にポチポチ設定して下さい。

f:id:hecomi:20160104113454p:plain f:id:hecomi:20160104113515p:plain f:id:hecomi:20160104113529p:plain

ビルドして Unity の Assets/Plugins/x86_64 以下に生成された bundle を置きます。

コード(C# 側)

Unity 側のテクスチャを利用

次に Unity 側でテクスチャを生成し、そのハンドルを渡すコードを書いてみましょう。

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

public class OpenCvCamera : MonoBehaviour
{
    [DllImport ("opencv_camera")]
    private static extern IntPtr getCamera(int device);
    [DllImport ("opencv_camera")]
    private static extern void releaseCamera(IntPtr ptr);
    [DllImport ("opencv_camera")]
    private static extern int getCameraWidth(IntPtr ptr);
    [DllImport ("opencv_camera")]
    private static extern int getCameraHeight(IntPtr ptr);
    [DllImport ("opencv_camera")]
    private static extern void updateCamera(IntPtr ptr);
    [DllImport ("opencv_camera")]
    private static extern void setCameraTexturePtr(IntPtr ptr, IntPtr texture);

    private IntPtr camera_ = IntPtr.Zero;

    void Start()
    {
        camera_ = getCamera(0);

        var tex = new Texture2D(
            getCameraWidth(camera_),
            getCameraWidth(camera_),
            TextureFormat.RGB24,
            false, /* mipmap */
            true /* linear */);
        GetComponent<Renderer>().material.mainTexture = tex;

        setCameraTexturePtr(camera_, tex.GetNativeTexturePtr());
    }

    void OnDestroy()
    {
        releaseCamera(camera_);
    }

    void Update()
    {
        updateCamera(camera_);
    }
}

カメラに合うように横・縦幅およびフォーマットを指定したテクスチャを生成しています。このスクリプトを適当なオブジェクトにアタッチして実行すると以下のようになります。

f:id:hecomi:20160104181922p:plain

External Texture を生成

C++ 側で既にテクスチャを生成済みであれば、そのテクスチャハンドルを貰ってきて以下のように CreateExternalTexture() を利用して Unity の世界で使えるようにラップし、UpdateExternalTexture() します(最初はこれは更新のたびに行わないと勘違いしていましたが、初回のみで問題ありません)。

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

public class OpenCvCamera : MonoBehaviour
{
    [DllImport ("opencv_camera")]
    private static extern IntPtr getCamera(int device);
    [DllImport ("opencv_camera")]
    private static extern void releaseCamera(IntPtr ptr);
    [DllImport ("opencv_camera")]
    private static extern void createTexture(IntPtr ptr);
    [DllImport ("opencv_camera")]
    private static extern int getCameraWidth(IntPtr ptr);
    [DllImport ("opencv_camera")]
    private static extern int getCameraHeight(IntPtr ptr);
    [DllImport ("opencv_camera")]
    private static extern void updateCamera(IntPtr ptr);
    [DllImport ("opencv_camera")]
    private static extern int getCameraTexturePtr(IntPtr ptr);

    private IntPtr camera_ = IntPtr.Zero;

    void Start()
    {
        camera_ = getCamera(0);
        createTexture(camera_);

        var ptr = (IntPtr)getCameraTexturePtr(camera_);
        var tex = Texture2D.CreateExternalTexture(
            getCameraWidth(camera_),
            getCameraWidth(camera_),
            TextureFormat.RGB24,
            false,
            true,
            ptr);
        tex.UpdateExternalTexture(ptr);

        GetComponent<Renderer>().material.mainTexture = tex;
    }

    void OnDestroy()
    {
        releaseCamera(camera_);
    }

    void Update()
    {
        updateCamera(camera_);
    }
}

結果は同じになります。

レンダリングスレッドから実行

Low-Level Native Plugin Interface を利用して Unity のレンダリングスレッドで実行するように変更することで高速化が望めます(レンダリングスレッドがカツカツの場合はダメですが)。以下のようにコードを修正します。

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

public class OpenCvCamera : MonoBehaviour
{
    [DllImport ("opencv_camera")]
    private static extern IntPtr getCamera(int device);
    [DllImport ("opencv_camera")]
    private static extern void releaseCamera(IntPtr ptr);
    [DllImport ("opencv_camera")]
    private static extern void createTexture(IntPtr ptr);
    [DllImport ("opencv_camera")]
    private static extern int getCameraWidth(IntPtr ptr);
    [DllImport ("opencv_camera")]
    private static extern int getCameraHeight(IntPtr ptr);
    [DllImport ("opencv_camera")]
    private static extern void updateCamera(IntPtr ptr);
    [DllImport ("opencv_camera")]
    private static extern int getCameraTexturePtr(IntPtr ptr);
    [DllImport ("opencv_camera")]
    private static extern IntPtr getRenderEventFunc();

    private IntPtr camera_ = IntPtr.Zero;

    void Start()
    {
        camera_ = getCamera(0);
        createTexture(camera_);

        var ptr = (IntPtr)getCameraTexturePtr(camera_);
        var tex = Texture2D.CreateExternalTexture(
            getCameraWidth(camera_),
            getCameraWidth(camera_),
            TextureFormat.RGB24,
            false,
            true,
            ptr);
        tex.UpdateExternalTexture(ptr);

        GetComponent<Renderer>().material.mainTexture = tex;

        StartCoroutine(OnRender());
    }

    void OnDestroy()
    {
        releaseCamera(camera_);
    }

    IEnumerator OnRender()
    {
        for (;;) {
            yield return new WaitForEndOfFrame();
            GL.IssuePluginEvent(getRenderEventFunc(), 0);
        }
    }
}

GL.IssuePluginEvent(System.IntPtr func, int eventId) で第1引数にネイティブ側からもらった関数ポインタを渡すことで、この関数をレンダリングスレッドで実行できます。Unity 5.2 よりも前は UnityRenderEvent() という固定の名前の関数を呼ぶ形式でしたが、Unity 5.2 よりこの形式へと変更になりました。正確には UnityPluginLoad(IUnityInterfaces*) および UnityPluginUnload() をネイティブ側で定義し、グラフィックドライバを調べなければならない(OpenGLDirectX のバージョン等に応じた処理を行わなければならない)のですが、本エントリでは取り敢えず Mac OS X を仮定しているので、ここは省略します。

これによりタイムラインを見てみると以下のように改善されていることが分かります。

メインスレッドで実行

f:id:hecomi:20160104181956p:plain

レンダリングスレッドで実行

f:id:hecomi:20160104182022p:plain

レンダリングスレッドに空きがあれば、何もないシーンと同等のパフォーマンスが望めます。

空のシーン

f:id:hecomi:20160104182042p:plain

おわりに

カメラを利用した画像の取り込みや動画デコード処理、ジェネレーティブテクスチャの生成などに広く役に立つと思います。時間ができたら Windows 編なども書きたいと思います。

最後になりましたが、Twitter で困っていたら助けて下さった皆様、ありがとうございました(-人-)。