凹みTips

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

Unity でカメラの映像を別ウィンドウ表示してみた

はじめに

Oculus Rift のデモを行っていると、HMD をつけていない観客は左右に分割された Oculus Rift 用の映像を見ることになり、デモ効果が薄れてしまいます(本当はフルスクリーンや 3D メガネで立体視したい)。ということを、Twitter@koukiwf さんが指摘されていまして、その解決法をブロマガの方で提案されています。

そこで、次のように述べられていました。

unity側で複数窓出せるようになるまでの、力ずく対症法です。

先日のエントリ(Unity で OpenCV で作成したテクスチャをネイティブプラグイン経由で利用してみた - 凹みTips)を利用すれば、もしかして逆に Unity の世界のテクスチャを外の世界で使えるんじゃないかと思い、試してみましたので共有します。

環境

デモ

今回の内容では、大画面時に実用に耐えうるフレームレートがまだ出ていません。。

原理の説明

原理的には単純で、Render Texture (プロ版限定)を利用してカメラ画をテクスチャ化し、その生データを DLL (bundle) 経由で貰ってきて、それを DLL 側で生成したウィンドウに描画するという形になります。まずは C++ 側で必要な最小限のコードを以下に示します。

externalWindow.h

extern "C" {
    void openWindow(const char* window_name);
    void destroyWindow(const char* window_name);
    void setTexture(const char* window_name, unsigned char* const data, int width, int height);
}

externalWindow.cpp

#include <opencv2/opencv.hpp>
#include "externalWindow.h"

void openWindow(const char* window_name)
{
    cv::namedWindow(window_name, CV_WINDOW_AUTOSIZE | CV_WINDOW_FREERATIO);
}

void destroyWindow(const char* window_name)
{
    cv::destroyWindow(window_name);
}

void setTexture(const char* window_name, unsigned char* const data, int width, int height)
{
    cv::Mat img(height, width, CV_8UC4, data);
    cv::imshow(window_name, img);
}

別ウィンドウを開くのとテクスチャの表示には OpenCV を利用しています(OpenCV の利用は前回のエントリを参照)。これを XCode で 32bit ビルドして bundle を作成します。次に、これを利用する Unity 側のコードを示します。

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

public class ExternalWindow : MonoBehaviour {
	[DllImport ("OpenCVTest")]
	private static extern IntPtr openWindow(string windowName);
	[DllImport ("OpenCVTest")]
	private static extern void destroyWindow(string windowName);
	[DllImport ("OpenCVTest")]
	private static extern void setTexture(string windowName, IntPtr data, int width, int height);

	public string        windowName = "My Camera";
	public RenderTexture renderTexture;
	public Camera        renderedCamera;

	private Texture2D cameraTexture_;
	private Color32[] cameraTexturePixels_;
	private GCHandle  cameraTexturePixelsHandle_;
	private IntPtr    cameraTexturePixelsPtr_;

	void Awake()
	{
		openWindow(windowName);
		cameraTexture_ = new Texture2D(renderTexture.width, renderTexture.width, TextureFormat.ARGB32, false);
		renderedCamera.targetTexture = renderTexture;
	}

	void Update()
	{
		RenderTexture activeRenderTexture = RenderTexture.active;
		RenderTexture.active = renderTexture;
		renderedCamera.Render();
		cameraTexture_.ReadPixels(new Rect(0.0f, 0.0f, renderTexture.width, renderTexture.height), 0, 0);
		cameraTexture_.Apply();
		RenderTexture.active = activeRenderTexture;

		cameraTexturePixels_       = cameraTexture_.GetPixels32();
		cameraTexturePixelsHandle_ = GCHandle.Alloc(cameraTexturePixels_, GCHandleType.Pinned);
		cameraTexturePixelsPtr_    = cameraTexturePixelsHandle_.AddrOfPinnedObject();
		setTexture(windowName, cameraTexturePixelsPtr_, cameraTexture_.width, cameraTexture_.height);
		cameraTexturePixelsHandle_.Free();
	}

	void OnApplicationQuit()
	{
		destroyWindow(windowName);
	}
}

Render Texture 周りのコードは以下の Unity のフォーラムを参考にしています。

これを任意の GameObject にアタッチして、大きさやレンダリングの方法を指定した Render Texture と、その Render Texture をアタッチしたターゲットとなるカメラをエディタで指定して実行すると、その Render Texture を別ウィンドウに描画する形になります。
ただ、小さなサイズの場合は問題ないのですが、テクスチャサイズが大きくなるとフレームレートが顕著に低くなってしまいます。RenderTexture への描画、カメラ画の ReadPixels での取得、またそれにより GetPixels32 の再呼び出し、ネイティブ側の描画などなど色々重い要素が含まれています。そこで色々と対策を講じてみました。

ネイティブの別スレッド化

ネイティブ側の描画でブロッキングが起きているのでココを別スレッド化してみます。

ExternalWindow.h

extern "C" {
    void* openWindow(const char* window_name, int width, int height);
    void destroyWindow(void* window);
    void drawTextureOnWindow(void* window, unsigned char* const data);
}

ExternalWindow.cpp

#include <opencv2/opencv.hpp>
#include <string>
#include <cstdio>
#include <thread>
#include "externalWindow.h"


class ExternalWindow
{
public:
    ExternalWindow(const std::string& window_name, int width, int height)
    : window_name_(window_name), width_(width), height_(height),
      texture_(height, width, CV_8UC4)
    {
        cv::namedWindow(window_name_, CV_WINDOW_AUTOSIZE | CV_WINDOW_FREERATIO);
    }
    
    ~ExternalWindow()
    {
        if ( drawThread_.joinable() ) {
            drawThread_.join();
        }
        cv::destroyWindow(window_name_.c_str());
    }
    
    void draw(unsigned char* data)
    {
        if ( drawThread_.joinable() ) {
            drawThread_.join();
        }
        std::memcpy(texture_.data, data, texture_.total() * texture_.elemSize());
        drawThread_ = std::thread([this] {
            cv::Mat flippedTexture, colorConvertedTexture;
            cv::flip(texture_, flippedTexture, 0);
            cv::cvtColor(flippedTexture, colorConvertedTexture, CV_RGBA2BGRA);
            cv::imshow(window_name_, colorConvertedTexture);
        });
    }
    
private:
    const std::string window_name_;
    const int width_, height_;
    cv::Mat texture_;
    std::thread drawThread_;
};


void* openWindow(const char* window_name, int width, int height)
{
    return new ExternalWindow(window_name, width, height);
}


void destroyWindow(void* window)
{
    delete static_cast<ExternalWindow*>(window);
}


void drawTextureOnWindow(void* window, unsigned char* const data)
{
    static_cast<ExternalWindow*>(window)->draw(data);
}

これでちょっと軽くなりました。OpenCV で扱うテクスチャ画と Unity 側のテクスチャ画の x 軸反転と、RGBA <-> BGRA 変換も別スレッドで行っています。

OnRenderImage の利用

先程は RenderTexture.active を差し替えていましたが、プロ版であれば使えるポストエフェクトの OnRenderImage を利用すると以下の様な短いコードでカメラ画を利用できます。

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

public class ExternalWindow : MonoBehaviour {
	[DllImport ("ExternalWindow")]
	private static extern IntPtr openWindow(string windowName, int width, int height);
	[DllImport ("ExternalWindow")]
	private static extern void destroyWindow(IntPtr window);
	[DllImport ("ExternalWindow")]
	private static extern void drawTextureOnWindow(IntPtr window, IntPtr data);

	private IntPtr window_;

	public string windowName = "My Camera";
	public RenderTexture renderTexture;

	private Texture2D cameraTexture_;
	private Color32[] cameraTexturePixels_;
	private GCHandle  cameraTexturePixelsHandle_;
	private IntPtr    cameraTexturePixelsPtr_;

	void Start()
	{
		window_ = openWindow(windowName, Screen.width, Screen.height);
		cameraTexture_ = new Texture2D(Screen.width, Screen.height, TextureFormat.ARGB32, false);
	}

	void OnRenderImage(RenderTexture src, RenderTexture dest)
	{
		cameraTexture_.ReadPixels(new Rect(0.0f, 0.0f, src.width, src.height), 0, 0);
		cameraTexturePixels_       = cameraTexture_.GetPixels32();
		cameraTexturePixelsHandle_ = GCHandle.Alloc(cameraTexturePixels_, GCHandleType.Pinned);
		cameraTexturePixelsPtr_    = cameraTexturePixelsHandle_.AddrOfPinnedObject();

		drawTextureOnWindow(window_, cameraTexturePixelsPtr_);

		Graphics.Blit(src, dest);
	}

	void OnApplicationQuit()
	{
		cameraTexturePixelsHandle_.Free();
		destroyWindow(window_);
	}
}

ただこれでも未だ重いです。。

おわりに

次回は、前回のエントリで書いた Low-level Native Plugin Interface を利用して高速化を試みたいと思います。