[Unity]ARCollaborationDataを使った空間共有メモ[ARKit]


# はじめに

UnityでiOS向けにARを使った空間共有をしてみたいと思ったのだが、ARWorldMapを使った記事はいくつか見つかるのだが、Collaborative Session(ARCollaborationData)を使った記事は、せいぜいUnity純正のサンプルを使ってみた記事ぐらいしか見つからなかった（探し方が悪い説はある）。
ただし、Unity純正のサンプルでは、アンカーを共有するだけで、動的にオブジェクトを生成したりはしないので、今回作りたかったものには参考にならなかった。

なので、Collaborative Sessionを使って空間共有する方法を模索してみた。

# 作るもの

今回作るものは、スマホ画面をタップした時に、タップした場所にCubeを生成し、また、生成したCubeは空間共有している別の端末にも表示するものとする。

# 方針

・[Unity純正のサンプル](https://github.com/Unity-Technologies/arfoundation-samples)を基に作成する。
・ARWorldMapを使わずに、ARCollaborationDataを使う。
・通信はMultipeerConnectivityを使う。これは、Unity公式サンプルのものをそのまま使用する。

# 解決すべき問題

いくつか解決すべき問題があるので、問題点と解決方法をあげていく。


## 問題１：端末ごとの座標系が違う

これはどういうことかと言うと、AR空間上の原点の位置や回転が、端末ごとにバラバラだということである。
原点が違うので、端末Aで作成したオブジェクトの位置を別の端末Bに送信した場合、端末Aの座標を端末Bでそのまま使うとまるっきり意図しない場所にオブジェクトが生成されてしまう。

## 問題１の解決方法

[Unity純正のサンプル](https://github.com/Unity-Technologies/arfoundation-samples)に、ARCollaborationDataを使ったサンプルがあるのだが、そのサンプルは複数の端末でアンカーを共有するというものになっている(アンカー以外のオブジェクトは共有しない）。
ここで重要なのは、 **アンカーを共有できること** である。

アンカーが共有できるのであれば、アンカーからの相対位置や回転を送信すれば、別の端末でも共有しているアンカーを使って元の位置を特定できる。

また、共有するアンカーはたかだか１つだけで良いだろう（複数あっても、どのアンカーからの相対座標なのか混乱するだけで益はない）。

というわけで結論は、 **「端末それぞれにアンカーを１つだけ作成し、そこからの相対座標を通信でやり取りする」** ということになる。


## 実装：アンカーの作成

端末１台につき１つ、座標の基準点となるアンカーを作成する。


```csharp:AnchorCreator.cs
public class AnchorCreator : MonoBehaviour {
    // ... （略）

    [SerializeField]
    float m_minDistance = 0.2f;

    ARAnchor m_Anchor;

    public ARAnchor MainAnchor {
        get => m_Anchor;
    }

    void Update() {
        // 基準となるアンカーがすでに存在する場合には、何もしない。
        if(m_Anchor != null) return;

        // Raycast against planes and feature points
        const TrackableType trackableTypes =
            TrackableType.FeaturePoint |
            TrackableType.PlaneWithinPolygon;

        // Perform the raycast
        var centerPosition = new Vector3(m_Camera.transform.position.x, m_Camera.transform.position.y, 0);
        if (m_RaycastManager.Raycast(centerPosition, s_Hits, trackableTypes))
        {
            // Raycast hits are sorted by distance, so the first one will be the closest hit.
            var hit = s_Hits[0];

            Debug.LogFormat("hit.distance: {0}", hit.distance);
            if(hit.distance <= m_minDistance) return;  // 端末からの距離が近すぎるアンカーは生成しない。

            // 基準となるアンカーを作成する
            m_Anchor = CreateAnchor(hit);
        }
    }
}
```

> コメントに「端末からの距離が近すぎるアンカーは生成しない」とあるが、距離が近すぎるデメリットがあるのかどうかは定かではない。
> これをしないと、アプリ起動時に端末がある位置そのものがアンカーになりやすいので、位置がずれやすそうな気がして、いれてある。




## 問題２：独自データの判別

通信するデータは同じMCSessionを利用して通信しているのだが、データには、 **"空間共有するためのARCollaborationData"** と、 **"独自のデータ（動的に生成したCubeの位置と回転のデータ）"** がある。

そのため、２種類のデータを区別するための手段が必要になる。

## 問題２の解決方法

今回は、独自データの最初の４バイトに固有の値を入れておくことにより、区別出来るようにした。

## 実装

### 独自データを送るときの最初の４バイト

```csharp:ObjectDataSerializer.cs
public static class ObjectDataSerializer {
    // 送信データの最初の４バイト
    private static byte[] AppId = {(byte)'D', (byte)'E', (byte)'M', (byte)'O'};

    // ... （略）
}
```


### 送信側

```csharp:Spawner.cs
    Vector3 pos = m_camera.transform.position;
    Quaternion rot = m_camera.transform.rotation;

    Instantiate(m_LocalCubePrefab, pos, rot);

    //
    // Send to Peers
    //
    MCSession session = m_CollaborativeSession.Session;
    if (session != null && session.ConnectedPeerCount > 0){
        // 基準となるアンカーを取得
        ARAnchor anchor = m_anchorCreator.MainAnchor;
        // ARAnchor からの相対座標を求める。
        var localPos = anchor.transform.InverseTransformPoint(pos);
        var localRot = rot * Quaternion.Inverse(anchor.transform.rotation);

        SerializedObjectData serializedObjectData = ObjectDataSerializer.Serialize(anchor.trackableId, ObjectType.Cube, localPos, localRot);
        NativeArray<byte> ary = serializedObjectData.GetNativeArray();
        var data = NSData.CreateWithBytesNoCopy(ary);

        session.SendToAllPeers(data, MCSessionSendDataMode.Reliable);
    }
```


### 受信側

受信したデータは、ARCollaborationDataと独自データの２種類にわかれる。
２種類のデータを区別するために、独自データをDeserializeするTryDeserialize関数を呼んで、ヌルでなければ独自データだったと判断し、ヌルだった場合は、ARCollaborationDataであると判断して処理をする。

```csharp:CollaborativeSession.cs
void Update() {
    // ... （略）

    // Check for incoming data
    while (m_MCSession.ReceivedDataQueueSize > 0) {
        if(OnIncomingDataReceived != null){
            OnIncomingDataReceived();
        }

        using (var data = m_MCSession.DequeueReceivedData()){
            // TryDeserializeで独自データを復元できないときは、ARCollaborationDataと判断する。
            if(m_tryDeserializable == null || (! m_tryDeserializable.TryDeserialize(data.Bytes))){
                using (var collaborationData = new ARCollaborationData(data.Bytes)) {
                    if (collaborationData.valid) {
                        subsystem.UpdateWithCollaborationData(collaborationData);
                        if (collaborationData.priority == ARCollaborationDataPriority.Critical) {
                            Debug.Log($"Received {data.Bytes.Length} bytes of collaboration data.");
                        }
                    } else {
                        Debug.Log($"Received {data.Bytes.Length} bytes from remote, but the collaboration data was not valid.");
                    }
                }
            }
        }
    }
}
```

```csharp:Spawner.cs
public bool TryDeserialize(NativeSlice<byte> bytes){
    ObjectData data = ObjectDataSerializer.TryDeserialize(bytes);
    if(data == null){
        return false;
    }

    ARAnchor anchor = m_anchorManager.GetAnchor(data.Id);
    if(anchor == null) return false;

    // ARAnchorからの相対座標をワールド座標に変換する。
    var globalPos = anchor.transform.TransformPoint(data.Position);
    var globalRot = anchor.transform.rotation * data.Rotation;

    switch(data.Type){
    case ObjectType.Cube:
        Instantiate(m_RemoteCubePrefab, globalPos, globalRot);
        break;
    }

    return true;
}
```

```csharp:ObjectDataSerializer.cs
public static ObjectData TryDeserialize(NativeSlice<byte> slice){
    if(slice.Length != 56){
        // throw new Exception($"illegal data size {slice.Length}.");
        return null;
    }

    byte[] bytes = slice.ToArray();

    //
    // Check AppId
    //
    if(bytes[0] != AppId[0] ||
       bytes[1] != AppId[1] ||
       bytes[2] != AppId[2] ||
       bytes[3] != AppId[3])
    {
        Debug.LogFormat("unmatched AppId. {0},{1},{2},{3}", (char)bytes[0], (char)bytes[1], (char)bytes[2], (char)bytes[3]);
        return null;
    }

    //
    // Check Protocol Version
    //
    Debug.LogFormat("Protocol Major Version: {0}, Minor Version: {1}", BitConverter.ToInt16(bytes, 4), BitConverter.ToInt16(bytes, 6));

    //
    // Check Checksum
    //
    Debug.LogFormat("Checksum...  bytes[8]: {0}, bytes[9]: {1}", bytes[8], bytes[9]);
    byte checksum = bytes[9];
    byte calcedChecksum = CalcChecksum(bytes);

    if(checksum != calcedChecksum){
        Debug.LogFormat("unmatched checksum. {0} not equal {1}", checksum, calcedChecksum);
        return null;
    }

    ObjectType typ = (ObjectType)BitConverter.ToInt16(bytes, 10);

    ulong id1 = BitConverter.ToUInt64(bytes, 12);
    ulong id2 = BitConverter.ToUInt64(bytes, 12 + 8);

    float pos_x = BitConverter.ToSingle(bytes, 12 + 8 + 8);
    float pos_y = BitConverter.ToSingle(bytes, 12 + 8 + 8 + 4);
    float pos_z = BitConverter.ToSingle(bytes, 12 + 8 + 8 + 4 * 2);

    float rot_x = BitConverter.ToSingle(bytes, 12 + 8 + 8 + 4 * 3);
    float rot_y = BitConverter.ToSingle(bytes, 12 + 8 + 8 + 4 * 4);
    float rot_z = BitConverter.ToSingle(bytes, 12 + 8 + 8 + 4 * 5);
    float rot_w = BitConverter.ToSingle(bytes, 12 + 8 + 8 + 4 * 6);

    TrackableId id = new TrackableId(id1, id2);
    Vector3 position = new Vector3(pos_x, pos_y, pos_z);
    Quaternion rotation = new Quaternion(rot_x, rot_y, rot_z, rot_w);

    return new ObjectData(id, typ, position, rotation);
}
```



# 出来たもの

[https://github.com/JunSuzukiJapan/ARKitCollaboration](https://github.com/JunSuzukiJapan/ARKitCollaboration)


# 参考

[ARKit3のCollaborative Sessionという機能](https://qiita.com/SAyanada9/items/d6005fbdec49881c738d)
[カメラの向いている方向とオブジェクトの角度の差を計算する](https://qiita.com/chiepomme/items/a91de616d746b6653a4a)
[【Unity】ワールド座標(グローバル座標）・ローカル座標](https://dkrevel.com/unity-explain/space/)
[ARKitを用いてAR空間共有を試してみる](https://recruit.gmo.jp/engineer/jisedai/blog/arkit_share_ar_experience/)