じゅころぐAR

AR/VRメインのブログ。時々ドローン。

ARCore Cloud Anchor APIをマスターする(前編)

ARCore v1.2でCloud Anchor APIが追加されてから早1ヶ月が経ってしまいました。
色々試して理解も深まってきましたし、ARKit v2で同様の機能も実装されるので、仕組みや基本的な使い方を一度整理しておきます。

ARCore v1.2全体の概要については、前回の記事にまとめてありますので、そちらを参照ください。

Cloud Anchor APIの基本

概要

Cloud Anchorで言うところのCloudは、GCP(Google Cloud Platform)になります。アプリ上で作成したAnchor(アンカー)はGCPで提供されているAPIを介して共有されます。

Anchorを複数デバイスで共有するためのネットワーク部分は、UnityのNetworking API(通称UNET)が使われています。

用語の整理

  • Cloud Anchor API
    • Cloud Anchorを共有するためのAPI
  • Cloud Anchor
    • 上記APIを介してCloudに登録され共有できるAnchor
  • Hosting
    • Cloud Anchor APIに対して、Cloud Anchorを登録する処理
    • UNETのHostと紛らわしい
  • Resolving(Resolve)
    • Cloud Anchor APIからCloud Anchorを取得し、空間上の座標に合わせる処理

GCP(Google Cloud Platform)とは?

名前の通り、Googleが提供しているクラウド・プラットフォームです。

cloud.google.com

AWSGoogle版みたいなものですが、AWSはWebコンソールで簡単に操作できるマネージドサービスが充実しているのに対し、GCPはアプリケーションに組み込んで使うWeb API群が充実している印象です。

Cloud Anchor APIについては、Web APIを直接叩くわけではなく、ARCoreの各種SDKを介して利用する形になります。

Anchorとは?

元々の単語は船が流れないように固定する錨の意味で、一般的にはAR空間上で位置を固定するために使います。

ラッキングに利用されるジャイロセンサの特性として時間とともに誤差が蓄積していくため、画像検知などで誤差を補正する必要があります。その際に既に空間上にあるオブジェクトにも同様の補正をかけないと、当然ズレて見えます
Anchorが付与されているオブジェクトは空間上の位置が固定されたものとして扱われ、自動で補正が行われます

Anchorについては、以下のURLにリファレンスがあるので目を通しておくとよいと思います。

Working with Anchors  |  ARCore  |  Google Developers

Cloud Anchorは上記の用途にプラスして、同じ空間にいる複数のデバイスで同じ位置を示すために使われます。

UNETとは?

マルチプレイを実装するためのUnity標準のAPIです。サードパーティ製の類似製品としてはPhotonが有名です。

マルチプレイでは、複数のデバイスのうちの1台がHostとなり、Serverとしての処理を行うと同時にLocal Clientとしてプレイヤーとしての処理も行います。
他のデバイスはRemote Clientとして、Hostと通信を行うことでServerとやり取りを行うプレイヤーになります。

https://docs.unity3d.com/uploads/Main/NetworkHost.png

サーバ(Server)とクライアント(Client)のやり取りを実装するAPIは、機能ごとにレイヤ分けされたHLAPI(High Level APIとして提供されています。

https://docs.unity3d.com/ja/current/uploads/Main/NetworkLayers.png

公式のマニュアルやUNETを取り扱った記事の多くは、"Network Manager""Network Transform"、"Network Identity"といった比較的上位レイヤのAPIを使っていますが、Cloud AnchorのExampleでは、Connection Managementレイヤの"Network Server"、"Network Client"が使われています。

Cloud Anchor APIを使うための準備

GCPでCloud Anchor APIを有効にする

GCPをはじめて利用する場合には、GCPの利用登録が必要です。

cloud.google.com

GCPへの登録には、Googleアカウントを使います。 登録が済んだら、適当な名前でプロジェクトを作ってください。

GCPではプロジェクト毎に使用するAPIを有効にして使います。
APIを有効にするには、"APIとサービス"から"APIとサービスの有効化"を行います。

f:id:jyuko49:20180514215238p:plain

利用できるAPIはたくさんあるので、検索窓にAPI名を入力して対象を絞り込みます。"ARCore Cloud Anchor API"と入力すれば1件だけヒットするはずです。

f:id:jyuko49:20180514224541p:plain

APIの詳細画面で"有効"ボタンをクリックすれば、プロジェクト内でAPIが利用可能になります。

f:id:jyuko49:20180514230140p:plain

GCPAPIキーを取得する

"Cloud Anchor API"にリクエストを行うには、APIキーが必要になります。

APIを有効にしたプロジェクトの"APIとサービス" > "認証情報"で"認証情報を作成"をクリックし、APIキーを選択すると、すぐにAPIキーが発行されます。

f:id:jyuko49:20180514225733p:plain

f:id:jyuko49:20180514231808p:plain

APIキーの名称変更やキーの制限は編集画面で行えます。アプリケーションの制限とAPIの制限を設定しておけば、万が一APIキーが漏洩した際に不正利用されるリスクを減らせます。

f:id:jyuko49:20180514232835p:plain

Unityのセットアップを行う

まずは、ARCore SDK for Unityのインポートです。
Githubに各バージョンのunitypackageがあるので、v1.2をUnityのプロジェクトにインポートします。

Releases · google-ar/arcore-unity-sdk · GitHub

次にAPIキーをUnityエディタ上で設定します。
入力フィールドは、"Edit" > "Project Settings" > "ARCore"と選択していくと表示されます。

f:id:jyuko49:20180510005537p:plain

ARCoreの基本設定を行う

Cloud Anchorに限らず、ARCoreを利用したアプリをビルドするためにはUnityで設定すべき項目があります。
ARCoreのチュートリアルAndroidiOSそれぞれ書かれているので忘れずにチェックしておきましょう。

Quickstart for Android  |  ARCore  |  Google Developers
Quickstart for iOS  |  ARCore  |  Google Developers

シーンの基本設定は変わっていないので、過去記事を参考に構成していってください。

jyuko49.hatenablog.com

以前のバージョンから変わっているのは、ARCoreの"Session Config"です。

f:id:jyuko49:20180514230231p:plain

f:id:jyuko49:20180605223948p:plain

ARCore v1.2で追加された垂直面(Vertical Plane)の検知、Cloud Anchorの項目が増えているので、有効になっていないと機能が使えません。

ARKitのクロスプラットフォーム対応を行う

AndroidiOSの空間共有はCloud Anchorの目玉でもあるので、ARKit対応がどう実現されているかについても軽く触れておきます。

常套手段ではありますが、シーン上にARCore用とARKit用のGame Objectがそれぞれ用意されており、初期状態は非アクティブになっています。

f:id:jyuko49:20180605225132p:plain

ARKit用のGame Objectは、Unity ARKit Pluginでトラッキングや平面検知が行えるように構成され、さらにARCore Sessionが付与された形になっています。

f:id:jyuko49:20180605232749p:plain

2つのGame Objectは、実行時のプラットフォームに応じて必要な方だけがスクリプトでアクティブになります。

public void Start()
{
    if (Application.platform != RuntimePlatform.IPhonePlayer)
    {
        ARCoreRoot.SetActive(true);
        ARKitRoot.SetActive(false);
    }
    else
    {
        ARCoreRoot.SetActive(false);
        ARKitRoot.SetActive(true);
    }
}

この切り替えでハマりがちなのが、Cameraの"Main Camera"タグです。

f:id:jyuko49:20180605230309p:plain

"Main Camera"タグが付いたObjectがシーン上に2つあると、一方が非アクティブでも意図しない動きをすることがあったので、Unityエディタで"Switch Platform"を行う際に"Main Camera"タグも付け替えています。
あまりスマートな運用ではないので、良い方法を思いついたら変えるかもしれません。

上記の対応で、AndroidiOSのどちらでビルドしても基本的には動きます。
あとは、Anchorの作成や空間上へのHitTestなどをスクリプトで行う際に、処理を分岐させてあげればOKです。

Cloud Anchorの使い方

ARCore SDK for UnityのExamplesに沿って説明します。

処理の流れ

バイスAをホスト(サーバ)、デバイスAと同一ネットワーク上にあるデバイスBをクライアントとします。

Exampleを動作させるには、以下の手順が必要です。

  1. バイスA(ホスト)がRoomを作る
  2. バイスA(ホスト)で共有したい空間を検知する
  3. バイスA(ホスト)がCloud AnchorをHostingする
  4. バイスB(クライアント)で共有したい空間を検知する
  5. バイスB(クライアント)でデバイスA(ホスト)が作ったRoomに接続する
  6. ResolvingでCloud Anchorの位置を復元する

よくある失敗パターンとしては、

  • 2,4の平面検知が不十分でResolvingに失敗
  • 3,6でCloud Anchor API(インターネット)との接続に失敗
  • 5でデバイス同士のリモート接続に失敗

あたりかと思います。
スクリプトと見比べながら、メッセージ表示によって切り分けができます。

CloudAnchor Exampleのスクリプト構成

ExampleにおけるCloud Anchorの共有処理は、以下のスクリプト群で構成されています。

  • CloudAnchorController.cs
    • メイン処理
  • CloudAnchorUIController.cs
    • UI操作時の処理、メッセージ表示処理
  • RoomSharingServer.cs
    • NetworkServerが行う処理
  • RoomSharingClient.cs
    • NetworkClientが行う処理
  • RoomSharingMsgType.cs
    • Server,Client間で送受信するメッセージ番号の定義
  • AnchorIdFromRoomRequestMessage.cs
  • AnchorIdFromRoomResponseMessage.cs
    • Server,Client間で送受信するメッセージのデータ定義
  • ARKitHelper.cs
    • ARCoreとARKitで実装が異なる部分のヘルパー

上記スクリプトは、すべて同一のName Space(GoogleARCore.Examples.CloudAnchor)のclassとして作られています。

1. Roomの作成

メイン処理のCloudAnchorController.csRoomSharingServer.csの参照が与えられており、Start()NetworkServer.Listen()でServerを起動しています。
また、RegisterHandler()AnchorIdFromRoomRequestのメッセージをClientから受信したらOnGetAnchorIdFromRoomRequestが実行されるようにしています。

public void Start()
{
  NetworkServer.Listen(8888);
  NetworkServer.RegisterHandler(RoomSharingMsgType.AnchorIdFromRoomRequest, OnGetAnchorIdFromRoomRequest);
}

上記の処理は、Remote Clientになる側のデバイスでも実行されていると思われます。
ただし、Serverの起動を行っているだけで、実際にServerとしてデータの送受信処理が実行されるのはHostになったデバイスのみです。

"Host"ボタンをクリックすることで、初期値:0000以外のRoomId(1〜9999)が発行されます。

public void OnEnterHostingModeClick()
{
    if (m_CurrentMode == ApplicationMode.Hosting)
    {
        m_CurrentMode = ApplicationMode.Ready;
        _ResetStatus();
        return;
    }

    m_CurrentMode = ApplicationMode.Hosting;
    m_CurrentRoom = Random.Range(1, 9999);
    UIController.SetRoomTextValue(m_CurrentRoom);
    UIController.ShowHostingModeBegin();
}

2. 空間の検知(Host)

Hostになったデバイスにて、Cloud Anchorを登録する前段階として空間検知を行います。

Cloud Anchorの位置を他のデバイスと共有する仕組みについて、Tangoで実装されていたエリアラーニングを考えると、Cloud AnchorのPose(transform)と周囲の空間情報(Point Cloudなど)をセットで登録し、同じ空間を別のデバイスで検知することでCloud Anchorの位置を推定していると推察できます。

Game Objectを置ける平面があればいいという訳でもなく、周囲の空間を検知してから、Cloud AnchorのHostingを行った方がResolvingが成功しやすくなります。

このあたりの特性は、Developer Guideにベストプラクティスとして記述されていますので、一読しておくとよいです。

3. Cloud AnchorのHosting

ExampleではGame Object(Andy君)を平面上に置いたタイミングでHostingが実行されています。

CloudAnchorController.cs_HostLastPlacedAnchor()が該当の処理です。

private void _HostLastPlacedAnchor()
{
#if !UNITY_IOS
    var anchor = (Anchor)m_LastPlacedAnchor;
#else
    var anchor = (UnityEngine.XR.iOS.UnityARUserAnchorComponent)m_LastPlacedAnchor;
#endif
    UIController.ShowHostingModeAttemptingHost();
    XPSession.CreateCloudAnchor(anchor).ThenAction(result =>
    {
        if (result.Response != CloudServiceResponse.Success)
        {
            UIController.ShowHostingModeBegin(
            string.Format("Failed to host cloud anchor: {0}", result.Response));
            return;
        }
   
        RoomSharingServer.SaveCloudAnchorToRoom(m_CurrentRoom, result.Anchor);
        UIController.ShowHostingModeBegin("Cloud anchor was created and saved.");
    });
}

通常のAnchorを作って、XPSession.CreateCloudAnchor(anchor)でCloudに登録します。

XPSession.CreateCloudAnchorに成功すると、CloudAnchorResultXPAnchorが入っており、XPAnchorにはCloudIdが振られています。

これをどう処理しているかというと、RoomSharingServer.SaveCloudAnchorToRoom(m_CurrentRoom, result.Anchor)で、RoomIdと合わせてServer処理のクラスに渡しています。

渡されたRoomSharingServerは、RoomIdとCloud AnchorをDictionaryに登録して保持しています。

private Dictionary<int, XPAnchor> m_RoomAnchorsDict = new Dictionary<int, XPAnchor>();

public void SaveCloudAnchorToRoom(int room, XPAnchor anchor)
{
    m_RoomAnchorsDict.Add(room, anchor);
}

4. 空間の検知(Remote Client)

Hostでの手順と同じです。
Hostingで検知した空間と同じ場所を検知させてから以降の処理を行うことで、スムーズにResolvingができます。

5. Roomに接続

UIで入力されたRoomId、IPアドレスに接続を試みます。

public void OnResolveRoomClick()
{
    var roomToResolve = UIController.GetRoomInputValue();
    if (roomToResolve == 0)
    {
        UIController.ShowResolvingModeBegin("Invalid room code.");
        return;
    }

    string ipAddress =
        UIController.GetResolveOnDeviceValue() ? k_LoopbackIpAddress : UIController.GetIpAddressInputValue();
         
    UIController.ShowResolvingModeAttemptingResolve();
    RoomSharingClient roomSharingClient = new RoomSharingClient();
    roomSharingClient.GetAnchorIdFromRoom(roomToResolve, ipAddress, (bool found, string cloudAnchorId) =>
    {
        if (!found)
        {
            UIController.ShowResolvingModeBegin("Invalid room code.");
        }
        else
        {
            _ResolveAnchorFromId(cloudAnchorId);
        }
    });
}

まず、new RoomSharingClient()でNetworkClientを作成し、roomSharingClient.GetAnchorIdFromRoom()でRoomに接続します。処理が完了したらCallbackで戻る形になっています。
Callbackに返されるのはcloudAnchorIdとなっており、Roomの接続と同時にHost(Server)がDictionaryに保持しているCloud AnchorのCloud Idを取得しようとしています。

こちらがRoomSharingClient.csの処理です。

public void GetAnchorIdFromRoom(Int32 roomId, string ipAddress, GetAnchorIdFromRoomDelegate GetAnchorIdFromRoomCallback)
{
    m_GetAnchorIdFromRoomCallback = GetAnchorIdFromRoomCallback;
    m_RoomId = roomId;
    RegisterHandler(MsgType.Connect, OnConnected);
    RegisterHandler(RoomSharingMsgType.AnchorIdFromRoomResponse, OnGetAnchorIdFromRoomResponse);
    Connect(ipAddress, 8888);
}

まず、呼び出し元に戻れるようにCallback関数をセットしています。
また、RegisterHandler()で"Connectに成功したらOnConnected"、"AnchorIdFromRoomResponseのメッセージを受信したらOnGetAnchorIdFromRoomResponse"がそれぞれ実行されるようになっています。
最後のConnect()がServerとの接続開始です。

Serverから接続完了のメッセージが届いたら、OnConnectedが呼ばれます。

private void OnConnected(NetworkMessage networkMessage)
{
    AnchorIdFromRoomRequestMessage anchorIdRequestMessage = new AnchorIdFromRoomRequestMessage
    {
        RoomId = m_RoomId
    };

    Send(RoomSharingMsgType.AnchorIdFromRoomRequest, anchorIdRequestMessage);
}

ServerへのメッセージにRoomIdを渡して、RoomIdに紐づくCloud AnchorのIDを要求します。
最後のSend()でメッセージのTypeと実際のメッセージをServerに送っています。

Clientからメッセージを受け取ったServerはというと、一番最初にRegisterHandler()で登録していたOnGetAnchorIdFromRoomRequestが応答します。

private void OnGetAnchorIdFromRoomRequest(NetworkMessage netMsg)
        {
            var roomMessage = netMsg.ReadMessage<AnchorIdFromRoomRequestMessage>();
            XPAnchor anchor;
            bool found = m_RoomAnchorsDict.TryGetValue(roomMessage.RoomId, out anchor);
            AnchorIdFromRoomResponseMessage response = new AnchorIdFromRoomResponseMessage
            {
                Found = found,
            };

    if (found)
    {
        response.AnchorId = anchor.CloudId;
    }

    NetworkServer.SendToClient(netMsg.conn.connectionId, RoomSharingMsgType.AnchorIdFromRoomResponse, response);
}

Serverがやっていることは、

メッセージからRoomIdを取り出し、
RoomIdに紐付くCloud AnchorがDictionaryにあるかを探し、
見つかったらAnchorのCloudIdをAnchorIdFromRoomResponseメッセージに乗せて、
Requestを送ってきたClientだけに送り返す

です。

再びClientに戻りまして、AnchorIdFromRoomResponseのメッセージを受信したのでRegisterHandler()で登録されていたOnGetAnchorIdFromRoomResponseが実行されます。

private void OnGetAnchorIdFromRoomResponse(NetworkMessage networkMessage)
{
    var response = networkMessage.ReadMessage<AnchorIdFromRoomResponseMessage>();
    m_GetAnchorIdFromRoomCallback(response.Found, response.AnchorId);
 }

メッセージからAnchorIdを取り出して、Callback関数に返すことができました。

整理すると、

  • メイン処理
    RoomId + IPアドレス
  • GetAnchorIdFromRoom(Client)
    Connect(IPアドレス)
  • 接続処理(Server)
    <MsgType.Connect>
  • OnConnected(Client)… RoomのCloud Anchorを要求
    <AnchorIdFromRoomRequestMessage>
  • OnGetAnchorIdFromRoomRequest(Server)… AnchorのCloudIdを返却
    <AnchorIdFromRoomRequestMessage>
  • OnGetAnchorIdFromRoomResponse(Client)... AnchorIdをCallback
    AnchorId
  • メイン処理

となります。

6. Cloud AnchorのResolving

Roomに接続する際のCallbackを改めて見てみると、AnchorIdが取得できたら_ResolveAnchorFromIdを実行となっています。

    roomSharingClient.GetAnchorIdFromRoom(roomToResolve, ipAddress, (bool found, string cloudAnchorId) =>
    {
        if (!found)
        {
            UIController.ShowResolvingModeBegin("Invalid room code.");
        }
        else
        {
            _ResolveAnchorFromId(cloudAnchorId);
        }
    });

次で最後です。ようやくここまでたどり着きました。

XPSession.ResolveCloudAnchor(cloudAnchorId)でCloud上に登録された情報を取得します。

private void _ResolveAnchorFromId(string cloudAnchorId)
{
    XPSession.ResolveCloudAnchor(cloudAnchorId).ThenAction((System.Action<CloudAnchorResult>)(result =>
    {
        if (result.Response != CloudServiceResponse.Success)
        {
            UIController.ShowResolvingModeBegin(string.Format("Resolving Error: {0}.", result.Response));
            return;
        }

        m_LastResolvedAnchor = result.Anchor;
        Instantiate(_GetAndyPrefab(), result.Anchor.transform);
        UIController.ShowResolvingModeSuccess();
    }));
}

cloudAnchorIdはServerがHostingしたCloud AnchorのCloudIdなのでAPIに接続できさえすれば取得できるはずです。
ただ、先に述べた通り、空間検知の状況によってはCloud Anchorが取得できても位置を復元できない可能性があります。

無事に復元ができたら、空間上の同じ位置にObjectを置くことができます!

まとめ

Network Managerを使わないUNETのマルチプレイ実装に加え、Roomへの接続とCloud AnchorのResolveが一連のフローになっているため混乱しがちですが、この記事をまとめているうちに、Exampleの挙動は大体理解できたかなと思います。

では、ここまででCloud Anchor APIをマスターできたかと言われると・・・実はまだ課題があります。
Cloud Anchor APIには、1分あたり30回しかHostが行えない(Resolveは300回)という帯域制限があります。また、Cloud AnchorのHosting、Rerolvingには秒単位のラグが発生することがあります。

つまり、Cloud Anchorはオブジェクトに付与するのではなく、Roomの代表点として1つだけ作成し、以降のオブジェクト共有はCloud Anchorを基点に座標変換してあげる形になりそうです。

前編としたので、後編に続ける余力があれば、Cloud Anchorを作成した後の処理について書きたいと思います。
後編書きました!