Edited at
ARKitDay 16

cocos2d-xでARKitを使う

More than 1 year has passed since last update.


はじめに

cocos2d-xといえばモバイルアプリ開発黎明期からあるクロスプラットフォームゲーム開発フレームワークで、今での多くのゲームがcocos2d-xを用いて開発されています(最近だとドラゴンクエストライバルズもcocos2d-x製です)。ただ最近はUnityに人気をとられていてあまり活気がないように思えます

名前に2dと入っていますが、実は3Dゲームも十分開発できます

UnityやUnreal Engineなどのバリバリ3DなゲームエンジンではARKitのプラグインなんかが提供されているみたいですが、残念ながらcocos2d-xにはそのようなものはないので自作することになります。

「cocos2d-x ARKit」などと検索してもUnityの記事などがひっかかるばかりでcocosで使った話は全然見つかりません😢

そんな中でもARKitをcocos2d-xで実装する機会があったため、せっかくなのでその時の実装を紹介したいと思います


諸注意

C++とObjective-C++とSwiftのコードが混在しますが、ファイルの拡張子が.cppの場合はC++、.mmの場合はObjective-C++、.swiftの場合はSwiftのコードと区別がつくようにしてあります

Swiftはver4.0.3です


0. 実装方針

ざっとARKitのAPIを確認したところ、ARFrameがもつcaputuredImageからイメージバッファを取得して頑張って変換してcocos2d-xにそれを転送、OpenGLで描画するのが正攻法かな?と思いました

しかし私はMetalもOpenGLも詳しくないマンなのでそんな高度なことはできません🙄

そこで今回は”cocos2d-xの背景を透過&その裏にARSCNViewを置く”という実装でやることにしました

図にするとこんな感じです

スクリーンショット 2017-09-08 23.55.33.png

cocosの本体は🍎iOS的にはCCEAGLViewというものになっていて、UIKit側でこれとARSCNViewをいい感じに重ねるだけでカメラ画像と3Dモデルを同時に表示できそうです

(ちなみに図中の🥑はAndroidを示していてこちらはARCoreの場合の話になります)


サンプルScene

こちらが今回AR対応を行うサンプルSceneになります。これをカスタムしてAR対応させます

Simulator Screen Shot - iPhone 8 - 2017-12-12 at 12.53.37.png


HelloWorldScene.cpp

bool HelloWorld::init()

{
if (!Scene::init()) return false;

auto visibleSize = Director::getInstance()->getVisibleSize();

// カメラの設置
auto camera = Camera::createPerspective(60, visibleSize.width/visibleSize.height, .1f, 1000.f);
// カメラ背景色の設定
camera->setBackgroundBrush(CameraBackgroundColorBrush::create({ .176f, .522f, .937f, 1.f }, 1.f));
camera->setDepth(0);
camera->setPosition3D({ 0.f, 150.f, 250.f });
camera->setCameraFlag(CameraFlag::USER1);
this->addChild(camera);

// 3Dモデルの設置&アニメーション
auto human = Sprite3D::create("girl.c3b");
human->setCameraMask(static_cast<int>(CameraFlag::USER1));
this->addChild(human);

if (auto animation = Animation3D::create("girl.c3b", "Take 001"))
{
auto animate = Animate3D::create(animation);
human->runAction(RepeatForever::create(animate));
}

// なんとなく床も置いておく
auto floor = Sprite::create("HelloWorld.png");
floor->setRotation3D({ -90.f, 0.f, 0.f });
floor->setCameraMask(static_cast<int>(CameraFlag::USER1));
human->addChild(floor);

// カメラをモデルに向ける
camera->lookAt(Vec3::UNIT_Y * 100.f);

return true;
}


3Dモデルを置いただけの単純なSceneです


1. cocos2d-xのViewの背景を透過させる

cocosのViewの裏にUIKitのViewを表示できるようにしたい

テストとして裏に簡単なUITableViewを設置して、これがcocos側で描画したモデルと一緒に表示される状態を目指します


うまくいかなかったやつ

とりあえず、iOS側から透過を有効にしてみましょう


足りない例.mm

CCEAGLView* eaglview = (__bridge CCEAGLView*)Director::getInstance()->getOpenGLView()->getEAGLView();

eaglview.opaque = NO;

これだけじゃ足りないです。EAGLViewが持つバッファ的に透過色がないなら、そもそも透過しません

そういえば、上のサンプルコード内にカメラの背景色を指定している部分がありました。

ではこれを追加すればいいのではないか?


実はできない例.cpp

camera->setBackgroundBrush(CameraBackgroundColorBrush::create(Color4F(0.f, 0.f, 0.f, 0.f), 1.f));


カメラ背景色に透明色を指定するという方法です。これは良さそうですが、実はだめです。

cocos2d-x v3.16(現在の最新)時点ではここの引数のColor4Fで指定したalpha値は無視されます。この問題はPRを投げて修正してもらいました。

しかしこれでもだめです。

alpha 0.f
alpha .5f

Simulator Screen Shot - iPhone 8 - 2017-12-12 at 13.48.54.png
Simulator Screen Shot - iPhone 8 - 2017-12-12 at 13.53.22.png

どうもこのカメラの裏にあるとされている何か真っ黒なバッファと合成され黒っぽくなってしまいます。glBlendFuncを変えたりしてもうまくいきませんでした👻


うまくいったやつ

試行錯誤の末、最終的に自前でバッファ全部クリアしたらうまくいきました


CameraBackgroundDepthBrushClear.h

#include "cocos2d.h"


class CameraBackgroundDepthBrushClear : public cocos2d::CameraBackgroundBrush
{
public:
static CameraBackgroundDepthBrushClear* create(float depth);

protected:
float clearDepth { 1.f };

protected:
CameraBackgroundDepthBrushClear();
virtual ~CameraBackgroundDepthBrushClear();
virtual bool init(float depth);

public:
virtual void drawBackground(cocos2d::Camera* camera) override;
virtual BrushType getBrushType() const override { return BrushType::DEPTH; }
};



CameraBackgroundDepthBrushClear.cpp

void CameraBackgroundDepthBrushClear::drawBackground(cocos2d::Camera* camera)

{
glDepthMask(true);
glClearDepth(this->clearDepth);
glClearColor(0, 0, 0, 0);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glDepthMask(false);

cocos2d::RenderState::StateBlock::_defaultState->setDepthWrite(false);
}


このCameraBackgroundDepthBrushClearを3DカメラにsetBackgroundBrushしてあげるとこうなります

Simulator Screen Shot - iPhone 8 - 2017-12-12 at 14.37.03.png

見事UITableViewがそのまま表示されています!


2. ARCameraとcocosのカメラを同期させる


下準備

まずはARKit用のViewControllerを作成して背景に設置します


ARViewController.swift

import UIKit

import SceneKit
import ARKit

@available(iOS 11.0, *)
class ARViewController: UIViewController {

private var arView: ARSCNView!
private var currentAnchor: ARAnchor?

override func viewDidLoad() {
super.viewDidLoad()

arView = ARSCNView(frame: UIScreen.main.bounds)
arView.delegate = self

self.view = arView
}

override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)

let config = ARWorldTrackingConfiguration()
config.planeDetection = .horizontal

arView.session.run(config)
}

override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)

arView.session.pause()
}
}


こちらはSwiftで作成しているためObjective-C++から使用するためにはObjective-C Generated Interface Headerをimportする必要がありますが、そちらについては省略します

cocos2d-xでは基底のViewContollerとして RootViewController というものを使用していますが、これに新たにViewを追加できるように少しいじってあげる必要があります


RootViewController.mm

- (void)loadView {

// Initialize the CCEAGLView
CCEAGLView *eaglView = [CCEAGLView viewWithFrame: [UIScreen mainScreen].bounds
pixelFormat: (__bridge NSString *)cocos2d::GLViewImpl::_pixelFormat
depthFormat: cocos2d::GLViewImpl::_depthFormat
preserveBackbuffer: NO
sharegroup: nil
multiSampling: YES
numberOfSamples: 2 ];
_eaglView = eaglView;

(省略)

// あとからViewを差し込めるように1枚ViewをかませてEAGLViewを設置する
UIView* baseView = [[UIView alloc] initWithFrame: [UIScreen.mainScreen bounds]];
[baseView addSubview: eaglView];
self.view = baseView;
}


コード中コメントにあるとおり、ViewをかませることであとからARSCNViewを入れられるように隙間を作りました

そして、こちらのコードでARViewControllerをRootViewControllerに差し込みます


ARHelper.mm

void ARHelper::startARSession()

{
if (@available(iOS 11.0, *))
{
// 透過を有効にする
CCEAGLView* eaglview = (__bridge CCEAGLView*)Director::getInstance()->getOpenGLView()->getEAGLView();
eaglview.opaque = NO;

UIViewController* rootViewController = [UIApplication sharedApplication].keyWindow.rootViewController;

// 新しいViewContollerを差し込む
ARViewController* arvc = [[ARViewController alloc] init];
[rootViewController addChildViewController: arvc];
[rootViewController.view addSubview: arvc.view];
[rootViewController.view bringSubviewToFront: eaglview];
}
}


ARHelperというネイティブ間の連携をなんでもやりそうなUtilクラスを用意しました


カメラを同期させる

さてようやくカメラの同期部分です

ARKitのARCameraが持つ情報の中で、cocos2d-xに伝えるべき部分は2つです


  • Transform

  • Projection

どちらも4x4行列であり、transformはカメラの位置と回転情報(とスケール)を保持しています。projectionはカメラの投影範囲を表しています。fovやアスペクト比、nearPlane、farPlaneが保持されています

これらの行列をcocos2d-xのカメラに対して同期させることで、ARKit空間とcocos空間を同じ視点から捉えることができるようになります。

残りとして空間のスケール(現実世界の1インチがcocos世界の何ptに対応するか)がありますが、これはプロジェクトで用いる3Dモデルの大きさによって最適な値が変わるのと、ちょっとめんどくさかったので定数で調整するようにしました


さて、コードを見ていきましょう。流れとしては


  1. ARKitのupdate

  2. cocos2d-xに行列の値を伝える

  3. cocos2d-xのupdate

  4. 受け取っていた行列をカメラに与える

という流れになります。なぜARKitのupdate時に直接cocosのカメラに値を伝えないかというと、ARKitが動いてるスレッドとcocosが動いてるスレッドが異なっているために、ARKit側のスレッドからcocos世界のオブジェクトをいじると予期せぬ不具合が起こりうるためです(実際起こった)

まずはARKitのupdate(描画更新処理)からです


ARViewController

// MARK:- ARSCNViewDelegate

@available(iOS 11.0, *)
@objc extension ARViewController: ARSCNViewDelegate {
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
if currentAnchor == nil {
currentAnchor = anchor
}
}

func renderer(_ renderer: SCNSceneRenderer, willRenderScene scene: SCNScene, atTime time: TimeInterval) {
guard let anchor = currentAnchor else { return }
guard let currentFrame = arView.session.currentFrame else { return }

let transform = anchorToCameraTransform(anchor: anchor)
let projection = SCNMatrix4(currentFrame.camera.projectionMatrix)

ARKitHelper.cameraMatrixUpdated(transform, projection: projection)
}

private func anchorToCameraTransform(anchor: ARAnchor) -> SCNMatrix4 {
guard let originToCameraTransform = arView.pointOfView?.worldTransform else { return SCNMatrix4Identity }
let originToAnchorTransform = SCNMatrix4(anchor.transform)
let anchorToCameraTransform = SCNMatrix4Mult(originToCameraTransform, SCNMatrix4Invert(originToAnchorTransform))

return anchorToCameraTransform
}
}


(注意: extensionに @objc をつけて下さい。これつけないとコンパイルエラーになります)

update処理に該当する関数は func renderer(_ renderer: SCNSceneRenderer, willRenderScene scene: SCNScene, atTime time: TimeInterval) になります

最初にARAnchorが取得されていることが前提になっています。これはARKit世界における原点(0, 0, 0)の座標は予測不可能であり扱いづらいことから、任意のアンカー1つを原点とみなすためです

ここでは一番最初に見つかったアンカーを原点アンカーとしています

ARKit世界における任意の1つのアンカーからカメラに対するtransformを、cocos世界における原点からカメラへのtransformとして扱います。行列演算でカメラtransformを計算してcocos世界に渡しています

渡すときに次のクラスを使用しています


ARKitHelper.mm

@implementation ARKitHelper

+ (void) cameraMatrixUpdated: (SCNMatrix4) worldTransform projection:(SCNMatrix4) projection {
Mat4 cocosWorldTransform { SCNMatrix4ToGLKMatrix4(worldTransform).m };
Mat4 cocosProjection { SCNMatrix4ToGLKMatrix4(projection).m };

ARHelper::cameraMatrixUpdated(cocosWorldTransform, cocosProjection);
}

@end


ARKitHelperクラスなるものを作り、swiftからc++へアクセスできるようにしました。ただ型の変換をしているだけです(ARHelperと名前が似てるので注意)


ARHelper.mm

void ARHelper::cameraMatrixUpdated(const Mat4& worldTransform, const Mat4& projection)

{
// NOTICE: この関数はcocosのスレッドとは非同期で呼ばれる
AREvent* event = ARManager::getInstance()->getEvent();
event->setCameraWorldTransform(worldTransform);
event->setCameraProjectionMatrix(projection);
}

ARHelper.mmはまだネイティブ連携用クラスのC++寄り部分です。ここから適当な場所に行列の値を保存することでようやくC++の世界にたどり着くことができます

AREventクラスは行列を保持できるだけのコンテナです

あとは何らかの方法で保存した行列をカメラに渡して適用するだけです。今回はEventDispatcherを使い毎フレームARManagerが持つAREventをdispatchする方針にしました

そこでEventDispatcherからEventを受け取って自身の位置を自動で更新するカメラである、ARCameraなるクラスを作成しました


ARCamera.cpp

namespace

{
// projection行列からfovの値だけを取り出す
float extractFov(const Mat4& projectionMatrix)
{
return CC_RADIANS_TO_DEGREES(2.f * atanf(1.f / projectionMatrix.m[5]));
}
}

ARCamera::ARCamera()
{
// カメラ背景を透過できるようにする
this->setBackgroundBrush(CameraBackgroundDepthBrushClear::create(1.f));

// EventDispatcherからAREventを受け取れるようにする
auto eventDispatcher = Director::getInstance()->getEventDispatcher();
eventDispatcher->addCustomEventListener(AREvent::EventName, [this](EventCustom* e){
if (!this->isRunning()) return;
auto arEvent = static_cast<AREvent*>(e);

this->updateCameraTransform(arEvent->getCameraWorldTransform(), arEvent->getCameraProjectionMatrix());
});
}

void ARCamera::setFieldOfView(float fov)
{
// cocos2d-xのCameraはデフォルトだとfovのみを設定する関数を持たないため追加
if (_type == Type::PERSPECTIVE)
{
_fieldOfView = fov;
Mat4::createPerspective(_fieldOfView, _aspectRatio, _nearPlane, _farPlane, &_projection);
_viewProjectionDirty = true;
_frustumDirty = true;
}
}

void ARCamera::updateCameraTransform(const Mat4& worldTransform, const Mat4& projectionMatrix)
{
// transformから位置と回転情報を取り出す(ただしここでの位置は正規化されている)
Vec3 position;
Quaternion quat;

worldTransform.getTranslation(&position);
worldTransform.getRotation(&quat);

// 画面の大きさから正規化された位置をcocos向けに戻してさらに適当な比率を書けて世界の大きさを合わせている
const Size& winSize = Director::getInstance()->getWinSize();
constexpr float theBestScaleForYourModels = 1.4f;
const float scale = fmin(winSize.width, winSize.height) * theBestScaleForYourModels;
this->setPosition3D(position * scale);
this->setRotationQuat(quat);

// fovだけ取り出す。P行列が持つその他の値はcocos側で指定したものを使いたいため
const float fov { extractFov(projectionMatrix) };
this->setFieldOfView(fov);
}


SceneKitで用いられる座標は最初から正規化されているので何もせずcocosに渡してきました。最後のここで正規化されてる座標を画面の大きさからからそれらしい座標に変換してます。あんまりCameraは正規化されているとか意識したくない。。。

とても長くなりましたが、これでcocos2d-xの3Dモデルを現実世界に表示できます😆!

IMG_1590.jpg


3. hitTestの実装

ここまで書くのに力尽きてしまったので簡単に述べます


ARViewController.swift

    @objc func hitTest(glNormalizedPoint: CGPoint) {

guard let currentFrame = arView.session.currentFrame else { return }

// OpenGL座標をUIKit座標に変換
let tapPointNormalized = CGPoint(x: glNormalizedPoint.x, y: 1.0 - glNormalizedPoint.y)

// 画面の回転を考慮して座標を変換
let videoSize = view.bounds.size
let tapPointTransform = currentFrame.displayTransform(for: UIApplication.shared.statusBarOrientation, viewportSize: videoSize)
let testPoint = tapPointNormalized.applying(tapPointTransform)

// hitTestする
let results = currentFrame.hitTest(testPoint, types: .existingPlane)
guard let result = results.first else { return }

// 結果の保持
currentAnchor = ARAnchor(transform: result.worldTransform)
}


何らかの方法でARViewControllerにタップされた座標を渡してhitTestを呼びます

このとき画面の回転を考慮することを忘れないようにね!ってことが言いたいだけでした


おわりに

ちょっとトリッキーな方法だけどこれでcocosでもARできるよ!

今回作成したサンプルプロジェクトはこちらのリポジトリに公開されています

Swift使ったせいやAndroidのARCoreを意識した実装の都合で余計なクラスが混じっててわかりにくなってしまい申し訳ないです

サンプルではカメラの位置しかcocos2d-xに伝えていませんが、同じような実装で認識したAnchorも伝えることができcocos2d-xからでも一通りのことはできるんじゃないかと思います

間違い、改善点などありましたらコメントよろしくお願いします