ARKitで空間共有

（2017.11.10 追記：しばらく手付かずでしたが、UnityARKitPluginが古くてビルドできなくなっていたのでUnityARKitPluginを1.0.11に更新して動かしてみたところQRコード検出でクラッシュすることもなく動作しました。iOS11 betaのバグだったのでしょうか。githubの方も更新してあります。）

~~（2017.7.15 追記：iOS11 beta3にしたらCIDetectorでQRコードの検出をしようとするとアプリがクラッシュしてしまうようになりました。VNDetectBarcodesRequestを使っても同じでした。現在、詳しく調査しているところです。）~~

複数のiOS端末でARKitの空間を共有する実験をしてみました。

プロジェクトは https://github.com/rakusan/ARKitSharingDemo にあります。Assets/Scenes/ARKit を実行してください。なお、画面の回転はロックしてください。実験なので、他にも細かいことは気にせずに作ってあります。

## 概要

* QRコードを使って空間の基準点と向きを合わせます
	* QRコードは水平面に配置するものとします
* 自分の端末の位置と向きをUDPブロードキャストで他の端末に伝えます
* 他の端末の位置にCubeを表示します

## QRCodeを検出するコード

### CIDetectorでQRコードの向きを検出

空間内でのQRコードの向きを得る必要があるので、QRコードの検出には CIDetector を使いました。ZXing などでもできるかもしれませんが調べていません。

最近 swift やってなくて忘れてしまってるので、Objective-C です。

```objectivec:Assets/Plugins/iOS/QRCodeReader.m
static float qrcodeCorners[8];
static volatile BOOL reading = false;

void ReadQRCode(long long mtlTexPtr)
{
    if (reading) return;
    reading = YES;
    
    MTLTextureRef mtlTex = (__bridge MTLTextureRef) (void*) mtlTexPtr;
    CIImage *ciImage = [CIImage imageWithMTLTexture:mtlTex options:nil];
    
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        CGFloat iw = ciImage.extent.size.width;
        CGFloat ih = ciImage.extent.size.height;
        
        CIDetector *detector = [CIDetector detectorOfType:CIDetectorTypeQRCode context:nil options:nil];
        NSArray<CIFeature *> *features = [detector featuresInImage:ciImage];
        
        if (features.count > 0) {
            CIQRCodeFeature *feature = (CIQRCodeFeature*) [features objectAtIndex:0];
            
            // TODO シェアリング用のQRコードかどうかの識別を feature.messageString の内容で行う。
            
            qrcodeCorners[0] = feature.topLeft.x / iw;
            qrcodeCorners[1] = feature.topLeft.y / ih;
            qrcodeCorners[2] = feature.topRight.x / iw;
            qrcodeCorners[3] = feature.topRight.y / ih;
            qrcodeCorners[4] = feature.bottomLeft.x / iw;
            qrcodeCorners[5] = feature.bottomLeft.y / ih;
            qrcodeCorners[6] = feature.bottomRight.x / iw;
            qrcodeCorners[7] = feature.bottomRight.y / ih;
            
            UnitySendMessage("QRCodeReader", "OnReadQRCode", "");
        }
        
        reading = NO;
    });
}

void GetQRCodeCorners(int32_t **cornersPtr)
{
    float *floatArray = malloc(sizeof(float) * 8);
    memcpy(floatArray, qrcodeCorners, sizeof(qrcodeCorners));
    *cornersPtr = (int32_t*) floatArray;
}
```

* 非同期にQRコードを検出
* 検出できたら UnitySendMessage でUnity側に通知
* その際Unity側から GetQRCodeCorners を呼び出して検出結果を取得する

という感じです。実験なのでQRコードの内容は何でも動作するようになってます。

### Unity側のコード

```csharp:Assets/QRCodeReader.cs
	void Update () {
		if (!done) {
			ARTextureHandles handles = arSession.GetARVideoTextureHandles ();
			if (handles.textureY != System.IntPtr.Zero) {
				ReadQRCode (handles.textureY.ToInt64 ());
			}
		}
	}

	private void ARFrameUpdated(UnityARCamera camera) {
		Matrix4x4 tmp = new Matrix4x4 (
			camera.displayTransform.column0,
			camera.displayTransform.column1,
			camera.displayTransform.column2,
			camera.displayTransform.column3
		);
		displayTransformInverse = tmp.inverse;
	}

	private Vector2 VideoTextureToViewportPoint(Vector2 videoTexturePoint) {
		Vector4 column0 = displayTransformInverse.GetColumn(0);
		Vector4 column1 = displayTransformInverse.GetColumn(1);
		float x = column0.x * videoTexturePoint.x + column0.y * videoTexturePoint.y + column0.z;
		float y = column1.x * videoTexturePoint.x + column1.y * videoTexturePoint.y + column1.z;
		return new Vector2 (x, y);
	}

	void OnReadQRCode(string arg) {
		float[] corners = GetQRCodeCorners ();

		var topLeft     = VideoTextureToViewportPoint(new Vector2 (corners [0], corners [1]));
		var topRight    = VideoTextureToViewportPoint(new Vector2 (corners [2], corners [3]));
		var bottomLeft  = VideoTextureToViewportPoint(new Vector2 (corners [4], corners [5]));
		var bottomRight = VideoTextureToViewportPoint(new Vector2 (corners [6], corners [7]));

		HitTest (topLeft, topRight, bottomLeft, bottomRight);
	}

	private void HitTest(Vector2 topLeft, Vector2 topRight, Vector2 bottomLeft, Vector2 bottomRight) {
		Dictionary<string, List<ARHitTestResult>> results = new Dictionary<string, List<ARHitTestResult>>();
		HitTest (topLeft, results);
		HitTest (topRight, results);
		HitTest (bottomLeft, results);
		HitTest (bottomRight, results);

		foreach (var result in results) {
			List<ARHitTestResult> list = result.Value;
			if (list.Count == 4) {
				var worldTopLeft     = UnityARMatrixOps.GetPosition (list[0].worldTransform);
				//var worldTopRight    = UnityARMatrixOps.GetPosition (list[1].worldTransform);
				var worldBottomLeft  = UnityARMatrixOps.GetPosition (list[2].worldTransform);
				var worldBottomRight = UnityARMatrixOps.GetPosition (list[3].worldTransform);

				var bottomToTop = worldTopLeft - worldBottomLeft;
				var leftToRight = worldBottomRight - worldBottomLeft;
				qrcodePlane.transform.forward = bottomToTop;
				qrcodePlane.transform.position = worldBottomLeft + (bottomToTop + leftToRight) * 0.5f;
				plane.transform.localScale = new Vector3(leftToRight.magnitude, 1, bottomToTop.magnitude) * 0.1f;
				break;
			}
		}
	}

	private void HitTest(Vector2 point, Dictionary<string, List<ARHitTestResult>> results) {
		List<ARHitTestResult> hitResults = arSession.HitTest (
			new ARPoint { x = point.x, y = point.y },
			ARHitTestResultType.ARHitTestResultTypeExistingPlaneUsingExtent);

		foreach (var hitResult in hitResults) {
			string anchorIdentifier = hitResult.anchorIdentifier;
			List<ARHitTestResult> list;
			if (!results.TryGetValue (anchorIdentifier, out list)) {
				list = new List<ARHitTestResult> ();
				results.Add (anchorIdentifier, list);
			}
			list.Add (hitResult);
		}
	}
```


Update メソッドから ReadQRCode を呼び出します。その後、Objective-C 側の UnitySendMessage により OnReadQRCode が実行されます。OnReadQRCode では次のことをしています。

* QRコードの四隅の座標をビューポート座標に変換
* それらに対して全てヒットテストを行う
	* ヒットテストは水平面に対してのみ行う (ARHitTestResultType.ARHitTestResultTypeExistingPlaneUsingExtent)
* ヒットテストの結果が全て同じ水平面上にあれば、それを採用する
* ヒットテストの結果からQRコードの空間上での向きを決定する
* 青い枠のGameObject（qrcodePlaneとplane）がQRコードの周りにフィットするように調整する


## 自分の端末の位置と向きを他の端末に伝える

UDPブロードキャストしてるだけです。SharingBroadcast.cs で送信し SharingReceive.cs で受け取っています。

SharingBroadcast.cs で送信する前に、ワールド座標からQRコード基準の座標に変換し、SharingReceive.cs で受け取った後（受け取った側の）ワールド座標に変換しています。

SharingReceive.cs では他の端末の位置に表示するCubeの更新もしています。

## 他のオブジェクトの位置を合わせる

今回作ったシーンには他に Cube が一つ置いてあります。Cube.cs でその位置を合わせています。

# まとめ

HoloLensのように基準となるマーカーを何も置かなくてもできるわけではありませんが、QRコードを使って簡易的な空間共有ができました。次はARKitとTango,HoloLens間でも空間共有してみようと思います。