Objective-C
iOS
vuforia
AR
SceneKit

SceneKit + Vuforia でARアプリを作ろう

More than 1 year has passed since last update.

iOS Second Stage Advent Calendar 2015 - Qiita 8日目です。Qiitaも初投稿です。よろしくお願いいたします。

TL;DR

Apple純正3DフレームワークのSceneKit(とVuforia)でARアプリを簡単に作ろう!

サンプルのGIFアニメ.gif

(フルの動画はこちら
SceneKitとVuforiaを用いて,iOSネイティブで拡張現実(Augmented Relity; AR)アプリを作る方法について書きたいと思います。自分で必要だったため実装したのですが,SceneKitでARを実装する資料がなく試行錯誤したので,似たようなことがしたい方のためになればと思います。

よくあるARのサンプルではOpenGLやUnityを利用したものが多いですが,SceneKitはApple純正の3Dフレームワークなので,通常のUIKitアプリに組み込みやすいことが利点です。また物理演算をARと組み合わせる,という処理などもSceneKitのおかげで簡単に作れます。OpenGLの関数を一つ一つ呼んでいたら日が暮れそうですよね。

SceneKitとは

SceneKit is a high-level 3D graphics framework that helps you create 3D animated scenes and effects in your apps. It incorporates a physics engine, a particle generator, and easy ways to script the actions of 3D objects so you can describe your scene in terms of its content — geometry, materials, lights, and cameras — then animate it by describing changes to those objects.

SceneKit - Apple Developer

SceneKitは,3Dのゲームなどを簡単に作れるフレームワークです。元々Mac用のフレームワークでしたが,iOS8からiOSでも使えるようになりました。SCNViewをサブビューとして配置し,SCNViewの持つSCNSceneオブジェクトに対して,モデルや光源,カメラを追加するだけで,3Dで自動的に描画されます。一方で,OpenGLやMetalへの組み込み方法も提供しています。今回はその仕組みを使って,Vuforiaが描画したカメラからの入力にSceneKitのオブジェクトを重ねて描画します。

Vuforiaとは

Vuforia(ヴューフォリア)はQualcommが開発したAR向けモバイルビジョンプラットフォームです。現在はPTCに6500万ドル(!)で買収されています。iOSやAndroid, Unity向けにARライブラリを提供していて,ウォーターマークが1日に1度表示される,などの制限を別にすれば基本的な機能はすべて無償で使うことができます。詳しい価格はコチラ

SDKは静的ライブラリ(.a)として提供されているので,Swiftが使えません。
このせいでSwiftで書いたコードをObjective-Cに書き直すというツライ作業が発生しました。:cry:

サンプルのビルド・実行

サンプルをビルド,実行する流れは以下のようになります。

  1. Vuforia Developer Portalから登録,ログインします。
  2. SDKサンプルプロジェクトをダウンロードします。
  3. サンプルプロジェクトを,SDKのsamplesフォルダに移動します。(公式の実にシンプルな説明はコチラ。)
  4. LicenceManagerから,アプリ内に組み込むLicense Keyを発行します。
  5. サンプルのSampleApplicationSession.mmを開いて,Vuforia::setInitParameters(mVuforiaInitFlags,"*発行したLicense Key*");)と書き換えます。この辺りは[Log] Xcode + vuforiaでARアプリを動かしてみる - しめ鯖日記さんにとても詳しく書いてあります。とても参考になりました。ありがとうございました!
  6. ターゲットがSimulatorだとビルドが通らないので,実機向けにビルドします(ここで若干ハマりました…)。

これで,サンプルをお手持ちのiPhoneで試せるかと思います。色々なサンプルを,アプリの中から試すことができます。例えば,以下の様なサンプルが入っています。

  • Image Tragets - Vuforiaのクラウド側に登録した画像をカメラに見せることで,ARで3Dオブジェクトを表示するサンプル。クラウド側で画像処理をすることで,非常に高精度なトラッキングを可能としています。
  • Cylinder Targets, Multi Targets - 円柱・直方体をマーカーとして使うサンプル。なんと立体物までマーカーにできます。
  • User Defined Targets - ユーザーがその場で撮った平面をマーカーとしてARするサンプル。なんでもマーカーにできます。すごい! 以降は,この最後のUser Defined Targetsを元に,SceneKitで描画するようにしたいと思います。

ARの基本

理論

SceneKitでARを実現するために,簡単にARの原理を紹介したいと思います。ここでいうARとは,すなわち,カメラで撮った2次元の画像内に写っているマーカーが,カメラに対して3次元的にどこに存在するかを復元することです。このプロセスは,以下のようになります。

1.マーカー座標系(世界座標系とも言います。マーカーの位置を原点とした座標系;3次元)を,カメラ座標系(カメラを原点とした座標系;3次元)に変換する。つまりカメラから見たマーカーの相対的な位置を求めます。
2. カメラ座標系(3次元)から,最終的な,ユーザーに見える2次元の座標系に変換

これを式で表すと,以下のようになります(同次座標系です)。

AR式.png

(本当はここに,レンズ歪み行列,というものが入りますが,最近のカメラのレンズはほとんど歪んでないので,無視しても違和感なく合成できる場合が多いです。)

パット見は魔法のように見えるARでも,内部パラメータ行列と外部パラメータ行列が得られれば,CGと現実のマーカや物体の位置合わせは,ただの行列の掛け算でできるということです。

内部パラメータ行列(intrinsic matrix)

内部パラメータ行列はカメラの焦点距離などの情報を表しています。カメラに固有ですので,例えば,私のiPhone 6sと,あなたの6sでは同じ内部パラメータ行列を使い回すことができます。Vuforiaには予め色んなカメラの内部パラメータ情報が登録されています。

この内部パラメータ行列が正しくないと,カメラからの入力(いわゆる現実)と,描画したオブジェクトの遠近感が合わず,いかにも合成したような,浮いているような見え方になってしまいます。

未知のカメラの内部パラメータ行列を求める方法は,市松模様の写真を撮りまくる,Zhangの方法という手法が有名です。興味のある方は調べてみてください。OpenCVにも実装されています。

外部パラメータ行列(extrinsic matrix)

外部パラメータ行列は,3次元の回転と平行移動を表す行列です。3Dグラフィックスのプログラミングや線形代数の授業などで見たことがある方も多いのではないでしょうか。(私は今まであんまり詳しくありませんでした)

これによって,マーカー座標系(例えばマーカーの中心を(0, 0, 0)とする)を元に配置するオブジェクトが,カメラの位置を原点としたときにどこにあるのか,どう見えるのかを求めることができます。

外部パラメータ行列は,ユーザーがカメラをどう持っているか,マーカーがどこに置かれているかによって変わるので,毎フレーム求める必要があります。Vuforiaは,これを非常に精度良くやってくれるライブラリです。

理論的には,外部パラメータは,PnP(Perspective n-Point)問題を解くことによって得ることができます。

マーカーを平面だと仮定すると,3次元の同一平面上の4点と,それが変換後の2次元画像中ではどこにあるか,という対応,及び前述した内部パラメータを用いることによって,他の3次元中の点が2次元画像中ではどこに対応するかを推定する問題です。OpenCV2にもsolvePNPという関数として用意されています。

実装

今までは理論的な話でしたが,実際には毎フレームこのような行列計算をせず,ライブラリに任せることが多いです。例えば,OpenGLでは,内部パラメータ行列を元に透視投影行列をセットし,外部パラメータ行列を元にモデルビュー行列をセットしてあげれば,現実でのカメラとマーカーの関係を,そのまま再現することができます。VuforiaのサンプルはOpenGLで実装されているため,まさしくこの処理を行っています。

UserDefinedTargetsEAGLView.mm内の,renderFrameQCARメソッドがその部分です。
Vuforiaが計算してくれた外部パラメータを元にモデルビュー行列を取得している処理は

QCAR::Matrix44F modelViewMatrix = QCAR::Tool::convertPose2GLMatrix(result->getPose());`

で,内部パラメータから透視投影行列を取得している処理は

&vapp.projectionMatrix.data[0]

です。vappApplicationSessionのインスタンスです。あとはOpenGL特有の計算がうじゃーっと続きます。OpenGL難しそうですよね。ARしたいだけなのに…。

SceneKitでAR!

さて,ようやく,このrenderFrameQCAR内でOpenGLで描画している部分を,SceneKitで描くようにしましょう!

プロパティなどの追加

まず,SceneKit.frameworkをプロジェクトに追加しましょう。

この後,通常でしたらSCNViewをViewControllerに追加して…となるのですが,今回はVuforiaが用意してくれたカメラからの映像を描画しているOpenGLコンテキストに描画していくため,SCNRendererを使います。UserDefinedTargetsEAGLView.hにプロパティを追加しましょう。

#import <SceneKit/SceneKit.h> // インポートします

@interface UserDefinedTargetsEAGLView : UIView <UIGLViewProtocol, GLResourceHandler>

// 色々と書いてあって…

@property (nonatomic, strong) SCNRenderer *renderer; // レンダラ
@property (nonatomic, strong) SCNNode *cameraNode; // カメラを保持するノード
@property (nonatomic, assign) CFAbsoluteTime startTime; // シーン中の経過時間

また,今回View側へSCNScene(SceneKitが描画するシーンを表すオブジェクト)を,外部から渡したかったのでデータソースパターンのようにしました。

@protocol UserDefinedTargetsEAGLViewSceneSource <NSObject>

- (SCNScene *)sceneForEAGLView:(UIView *)view;

@end

@property (weak, nonatomic) id<UserDefinedTargetsEAGLViewSceneSource> sceneSource;

渡すSCNSceneの作り方は,Qiitaやweb上に資料があるので,そちらを見ていただければと思います。例えばこちらの記事は大変参考になりました。ありがとうございます。
[Swift] iOS8から対応したSceneKitを触ってみたメモ

ひとつだけ,Vuforiaが返す外部パラメータの単位と合わせるために,SceneKit側のオブジェクトも,10~100などの大きさで作ることが必要です。

SCNRendererの初期化

- (void)setupRenderer {
    self.renderer = [SCNRenderer rendererWithContext:context options:nil];
    self.renderer.autoenablesDefaultLighting = YES;
    self.renderer.playing = YES;

    if (self.sceneSource != nil) {
        self.renderer.scene = [self.sceneSource sceneForEAGLView:self];

        SCNCamera *camera = [SCNCamera camera];
        self.cameraNode = [SCNNode node];
        self.cameraNode.camera = camera;
        [self.renderer.scene.rootNode addChildNode:self.cameraNode];
        self.renderer.pointOfView = self.cameraNode;
    }

}

このような処理を,UserDefinedTargetsEAGLView- (id)initWithFrame:appSession:の後に呼んでください。SCNRendererを初期化して,Vuforiaのサンプル側で用意されているOpenGLコンテキストを設定し,またカメラをシーン内に追加します。

さて,あとは,このカメラに内部パラメータと外部パラメータをセットするだけ…なのですが,今まで3D周りに疎かったため,非常に悩みました。ARの式やVuforiaから得られる外部パラメータ行列は,物体をカメラから見た座標にする行列であり,OpenGLではこれをそのままモデルビュー行列として使えばよいのですが,あいにくSceneKitにはそのような行列はありません。ということで,マーカーを基準としたときに今カメラはどこにあるのか(位置・回転)をSCNCamera(のノード)にセットしなければなりません。それはどうやって求めれば良いのだろう?と悩んだのですが,単純に,外部パラメータの逆行列を求めればOKです。ということで,Vuforiaの行列をSceneKitの行列に変換するメソッドを追加します。

ひとつ注意ですが,OpenGLやSceneKitの行列の値はcolumn-majorで格納されています。行列の要素を使って何かする場合は,気をつける必要があります。

// Vuforiaの行列をSceneKitの行列に変換するメソッド
- (SCNMatrix4)SCNMatrix4FromQCARMatrix44:(QCAR::Matrix44F)matrix {
    GLKMatrix4 glkMatrix;

    for(int i=0; i<16; i++) {
        glkMatrix.m[i] = matrix.data[i];
    }

    return SCNMatrix4FromGLKMatrix4(glkMatrix);

}

// 逆行列を計算してcameraNodeに設定するメソッド
- (void)setCameraMatrix:(QCAR::Matrix44F)matrix {
    SCNMatrix4 extrinsic = [self SCNMatrix4FromQCARMatrix44:matrix];
    SCNMatrix4 inverted = SCNMatrix4Invert(extrinsic); // 逆行列
    self.cameraNode.transform = inverted; // カメラのノードのtransformにセット

    NSLog(@"position = %lf, %lf, %lf", self.cameraNode.position.x, self.cameraNode.position.y, self.cameraNode.position.z); // デバッグ用
}

また,内部パラメータ行列を元に,透視投影行列を設定するメソッドも定義します。

- (void)setProjectionMatrix:(QCAR::Matrix44F)matrix {
    self.cameraNode.camera.projectionTransform = [self SCNMatrix4FromQCARMatrix44:matrix];
}

さて,あとはこれをrenderFrameQCARメソッド内で呼び出し,レンダリングするだけです。rendeFrameQCARメソッドを以下のように書き換えます。

// *** QCAR will call this method periodically on a background thread ***
- (void)renderFrameQCAR
{
    [self setFramebuffer];

    // Clear colour and depth buffers
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    // Render video background and retrieve tracking state
    QCAR::State state = QCAR::Renderer::getInstance().begin();
    QCAR::Renderer::getInstance().drawVideoBackground();

    glEnable(GL_DEPTH_TEST);
    // We must detect if background reflection is active and adjust the culling direction.
    // If the reflection is active, this means the pose matrix has been reflected as well,
    // therefore standard counter clockwise face culling will result in "inside out" models.
    glEnable(GL_CULL_FACE);
    glCullFace(GL_BACK);
    if(QCAR::Renderer::getInstance().getVideoBackgroundConfig().mReflection == QCAR::VIDEO_BACKGROUND_REFLECTION_ON)
        glFrontFace(GL_CW);  //Front camera
    else
        glFrontFace(GL_CCW);   //Back camera

    // Render the RefFree UI elements depending on the current state
    refFreeFrame->render();

    [self setProjectionMatrix:vapp.projectionMatrix]; // 透視投影行列は毎フレーム変わらないので本当は毎フレーム呼び出さなくていいです。


    for (int i = 0; i < state.getNumTrackableResults(); ++i) {
        // Get the trackable
        const QCAR::TrackableResult* result = state.getTrackableResult(i);
        //const QCAR::Trackable& trackable = result->getTrackable();
        QCAR::Matrix44F modelViewMatrix = QCAR::Tool::convertPose2GLMatrix(result->getPose()); // モデルビュー行列取得

        ApplicationUtils::translatePoseMatrix(0.0f, 0.0f, kObjectScale, &modelViewMatrix.data[0]);
        ApplicationUtils::scalePoseMatrix(kObjectScale, kObjectScale, kObjectScale, &modelViewMatrix.data[0]);

        [self setCameraMatrix:modelViewMatrix]; // SCNCameraにセット
        [self.renderer renderAtTime:CFAbsoluteTimeGetCurrent() - self.startTime]; // SCNRendererでOpenGLコンテキスト内にレンダリング

        ApplicationUtils::checkGlError("EAGLView renderFrameQCAR");
    }

    glDisable(GL_DEPTH_TEST);
    glDisable(GL_CULL_FACE);

    QCAR::Renderer::getInstance().end();
    [self presentFramebuffer];

}

これで,あとはSCNSceneを思い通りに作るだけです!ここまで読んでくださってありがとうございました。長かったですね。SCNSceneはライティングやモデルのインポートなど色々できるので,是非試してみてください!

余談

最初はOpenCVも組み合わせてPnP問題解いたりして実装していたのですが,ちょっとでもカメラが動くとガクガクになってしまって,精度も出ませんでした。Vuforiaすごい…!

あと,サンプル用にスターウォーズのポスターを元にR2D2やBB8のモデルを描画しようとしたのですが,インターネットで見つけたR2D2の.daeモデルをSceneKitにインポートすると,ポスターの1/100ぐらいの極ミニミニR2D2が表示されてしまってうまく行きませんでした…。インポートして作ったSCNNodescaleプロパティなどいじってみたのですが,何も変わらず。どなたかご存知の方いらっしゃいましたらご教示ください…。

あとは照明環境の推定などなどでもっとリアルなARを手軽にできるようにしたいですね!

TODO

(2015.12.18追記)
SCNRendererをそのまま使っているので,SCNNodeをタップしたら,などなどを自分で実装しなければなりません。OpenGL周りの,マウスでオブジェクトをクリックするサンプルなどが使えそうです。

SCNRendererSCNSceneRendererプロトコルに準拠しているので,hitTest:optionsが使えます。CGPointを渡して,それを三次元空間に変換したときにSCNNodeと重なるかを判定してくれるメソッドです。ですが,ここでハマッたのですが,hitTest:optionsが第1引数として受け付けるCGPointの座標系は,SCNSceneRendererを実装しているクラスによって異なります。SCNViewの場合は,UITapGestureRecognizerなどのlocationInViewをそのまま渡せばよいのですが,今回の場合,SCNRendererを用いているので座標系はOpenGLの座標系(左下が原点)となります。また,Retinaの場合Viewportのサイズは2倍になります。ですので,UITapGestureRecognizerから得られたタップ位置は,x, y方向に2倍して,さらにyを,Viewportの高さから引く(左上原点を左下原点に変換する)必要がありました。その値をhitTest:optionsに渡したところ,無事タップ位置のSCNNodeを取得することができました!

参考文献

藤本 雄一郎, 青砥 隆仁, 浦西 友樹, 大倉 史生, 小枝 正直, 中島 悠太, 山本 豪志朗, OpenCV3プログラミングブック

OpenCV3を元に,ARやプロジェクションマッピングの仕組みを説明してある本で,大変勉強になりました。