Nearby InteractionとARKitを使った高精度の距離計測と空間へのPlane配置

Nearby InteractionとARKitを使ったサンプルを実装しました。
Nearby Interactionの処理から取得できる数値を元に、1m離れるごとに空間上に板を自動配置しています。

iPhone11 ProとiPhone13 Proの2台の端末を使用。
どちらの端末もU1チップを搭載しており、高精度の距離測定が可能です。

![000.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63205/e187801a-980c-775a-9bd7-04296b98dc0d.gif)

GitHubにアップしています。
https://github.com/satoshi0212/AR_100Days/tree/main/AR_100Days/Days/Day59

この実装含め、仮想カメラ/AR/映像表現などの情報更新をTwitterで投稿しています。
良かったらフォローお願いします。
https://twitter.com/shmdevelop

## 実装ポイント

iPhone11 ProとiPhone13 Proに同じアプリを入れ起動。
iPhone11 Proを机に置き、iPhone13 Proを手に持って離れていきます。

WiFiは不要(のはず)。

MultipeerConnectivityとNearby Interactionの2者が登場するため少し流れが複雑ですが、一度動かすと理解できると思います。

### MultipeerConnectivityの取り回し

AppleのサンプルからMPCSession関連の取り回しの実装を引用。

https://developer.apple.com/documentation/nearbyinteraction/implementing_interactions_between_users_in_close_proximity

```swift

/*
See LICENSE folder for this sample’s licensing information.

Abstract:
A class that manages peer discovery-token exchange over the local network by using MultipeerConnectivity.
*/

import Foundation
import MultipeerConnectivity

private struct MPCSessionConstants {
    static let kKeyIdentity: String = "identity"
}

class MPCSession: NSObject, MCSessionDelegate, MCNearbyServiceBrowserDelegate, MCNearbyServiceAdvertiserDelegate {
    var peerDataHandler: ((Data, MCPeerID) -> Void)?
    var peerConnectedHandler: ((MCPeerID) -> Void)?
    var peerDisconnectedHandler: ((MCPeerID) -> Void)?
    private let serviceString: String
    private let mcSession: MCSession
    private let localPeerID = MCPeerID(displayName: UIDevice.current.name)
    private let mcAdvertiser: MCNearbyServiceAdvertiser
    private let mcBrowser: MCNearbyServiceBrowser
    private let identityString: String
    private let maxNumPeers: Int

    init(service: String, identity: String, maxPeers: Int) {
        serviceString = service
        identityString = identity
        mcSession = MCSession(peer: localPeerID, securityIdentity: nil, encryptionPreference: .required)
        mcAdvertiser = MCNearbyServiceAdvertiser(peer: localPeerID,
                                                 discoveryInfo: [MPCSessionConstants.kKeyIdentity: identityString],
                                                 serviceType: serviceString)
        mcBrowser = MCNearbyServiceBrowser(peer: localPeerID, serviceType: serviceString)
        maxNumPeers = maxPeers

        super.init()
        mcSession.delegate = self
        mcAdvertiser.delegate = self
        mcBrowser.delegate = self
    }

    // MARK: - `MPCSession` public methods.

    func start() {
        mcAdvertiser.startAdvertisingPeer()
        mcBrowser.startBrowsingForPeers()
    }

    func suspend() {
        mcAdvertiser.stopAdvertisingPeer()
        mcBrowser.stopBrowsingForPeers()
    }

    func invalidate() {
        suspend()
        mcSession.disconnect()
    }

    func sendDataToAllPeers(data: Data) {
        sendData(data: data, peers: mcSession.connectedPeers, mode: .reliable)
    }

    func sendData(data: Data, peers: [MCPeerID], mode: MCSessionSendDataMode) {
        do {
            try mcSession.send(data, toPeers: peers, with: mode)
        } catch let error {
            NSLog("Error sending data: \(error)")
        }
    }

    // MARK: - `MPCSession` private methods.

    private func peerConnected(peerID: MCPeerID) {
        if let handler = peerConnectedHandler {
            DispatchQueue.main.async {
                handler(peerID)
            }
        }
        if mcSession.connectedPeers.count == maxNumPeers {
            self.suspend()
        }
    }

    private func peerDisconnected(peerID: MCPeerID) {
        if let handler = peerDisconnectedHandler {
            DispatchQueue.main.async {
                handler(peerID)
            }
        }

        if mcSession.connectedPeers.count < maxNumPeers {
            self.start()
        }
    }

    // MARK: - `MCSessionDelegate`.

    internal func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
        switch state {
        case .connected:
            peerConnected(peerID: peerID)
        case .notConnected:
            peerDisconnected(peerID: peerID)
        case .connecting:
            break
        @unknown default:
            fatalError("Unhandled MCSessionState")
        }
    }

    internal func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
        if let handler = peerDataHandler {
            DispatchQueue.main.async {
                handler(data, peerID)
            }
        }
    }

    internal func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) {
        // The sample app intentional omits this implementation.
    }

    internal func session(_ session: MCSession,
                          didStartReceivingResourceWithName resourceName: String,
                          fromPeer peerID: MCPeerID,
                          with progress: Progress) {
        // The sample app intentional omits this implementation.
    }

    internal func session(_ session: MCSession,
                          didFinishReceivingResourceWithName resourceName: String,
                          fromPeer peerID: MCPeerID,
                          at localURL: URL?,
                          withError error: Error?) {
        // The sample app intentional omits this implementation.
    }

    // MARK: - `MCNearbyServiceBrowserDelegate`.

    internal func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String: String]?) {
        guard let identityValue = info?[MPCSessionConstants.kKeyIdentity] else {
            return
        }
        if identityValue == identityString && mcSession.connectedPeers.count < maxNumPeers {
            browser.invitePeer(peerID, to: mcSession, withContext: nil, timeout: 10)
        }
    }

    internal func browser(_ browser: MCNearbyServiceBrowser, lostPeer peerID: MCPeerID) {
        // The sample app intentional omits this implementation.
    }

    // MARK: - `MCNearbyServiceAdvertiserDelegate`.

    internal func advertiser(_ advertiser: MCNearbyServiceAdvertiser,
                             didReceiveInvitationFromPeer peerID: MCPeerID,
                             withContext context: Data?,
                             invitationHandler: @escaping (Bool, MCSession?) -> Void) {
        // Accept the invitation only if the number of peers is less than the maximum.
        if self.mcSession.connectedPeers.count < maxNumPeers {
            invitationHandler(true, mcSession)
        }
    }
}
```

### MPCSessionの開始とNIDiscoveryToken交換後のNISession開始

MultipeerConnectivityによりNearby Interaction用のトークンを交換します。

```swift

    // MARK: - Discovery token sharing and receiving using MPC.

    func startupMPC() {
        if mpc == nil {
            mpc = MPCSession(service: "ar100days", identity: "com.example.nearbyinteraction", maxPeers: 1)
            mpc?.peerConnectedHandler = connectedToPeer
            mpc?.peerDataHandler = dataReceivedHandler
            mpc?.peerDisconnectedHandler = disconnectedFromPeer
        }
        mpc?.invalidate()
        mpc?.start()
    }

    func dataReceivedHandler(data: Data, peer: MCPeerID) {
        guard let discoveryToken = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NIDiscoveryToken.self, from: data) else {
            fatalError("Unexpectedly failed to decode discovery token.")
        }
        peerDidShareDiscoveryToken(peer: peer, token: discoveryToken)
    }

    func shareMyDiscoveryToken(token: NIDiscoveryToken) {
        guard let encodedData = try?  NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else {
            fatalError("Unexpectedly failed to encode discovery token.")
        }
        mpc?.sendDataToAllPeers(data: encodedData)
        sharedTokenWithPeer = true
    }

    func peerDidShareDiscoveryToken(peer: MCPeerID, token: NIDiscoveryToken) {
        if connectedPeer != peer {
            fatalError("Received token from unexpected peer.")
        }
        peerDiscoveryToken = token
        let config = NINearbyPeerConfiguration(peerToken: token)
        session?.run(config)
    }

```

### NISessionDelegate関連処理

Nearby Interactionのdelegate処理。didUpdateで表示更新を行う。

```swift

    // MARK: - NISessionDelegate

    func session(_ session: NISession, didUpdate nearbyObjects: [NINearbyObject]) {
        guard let peerToken = peerDiscoveryToken else {
            fatalError("don't have peer token")
        }

        let peerObj = nearbyObjects.first { (obj) -> Bool in
            return obj.discoveryToken == peerToken
        }

        guard let nearbyObjectUpdate = peerObj else {
            return
        }

        updateVisualization(peer: nearbyObjectUpdate)
    }

    func dataReceivedHandler(data: Data, peer: MCPeerID) {
        guard let discoveryToken = try? NSKeyedUnarchiver.unarchivedObject(ofClass: NIDiscoveryToken.self, from: data) else {
            fatalError("Unexpectedly failed to decode discovery token.")
        }
        peerDidShareDiscoveryToken(peer: peer, token: discoveryToken)
    }

    func shareMyDiscoveryToken(token: NIDiscoveryToken) {
        guard let encodedData = try?  NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: true) else {
            fatalError("Unexpectedly failed to encode discovery token.")
        }
        mpc?.sendDataToAllPeers(data: encodedData)
        sharedTokenWithPeer = true
    }

    func peerDidShareDiscoveryToken(peer: MCPeerID, token: NIDiscoveryToken) {
        if connectedPeer != peer {
            fatalError("Received token from unexpected peer.")
        }
        peerDiscoveryToken = token
        let config = NINearbyPeerConfiguration(peerToken: token)
        session?.run(config)
    }
```

### 1m毎に空間にノードとテキストを配置

Dictionaryのキーとして距離を持ち、まだ配置していない場合にSCNNodeを配置。
距離の数値も表示したくなったためSCNTextも配置。表示が滑らかになる調整もしています。

```swift

    private func addNode(distance: Float) {
        guard
            let camera = sceneView.pointOfView
        else { return }

        let node = SCNNode()
        node.geometry = SCNPlane(width: 0.6, height: 0.3)
        let material = SCNMaterial()
        material.isDoubleSided = true
        material.diffuse.contents = UIColor(red: 0.5, green: 0.5, blue: 0.9, alpha: 0.5)
        node.geometry?.materials = [material]

        let position = SCNVector3(x: 0, y: 0, z: 0)
        node.position = camera.convertPosition(position, to: nil)
        node.eulerAngles = camera.eulerAngles
        sceneView.scene.rootNode.addChildNode(node)

        let textGeometry = SCNText(string: String(format: "%0.2f m", distance), extrusionDepth: 0.8)
        textGeometry.font = UIFont(name: "HiraginoSans-W6", size: 100)
        textGeometry.firstMaterial?.diffuse.contents = UIColor.white

        let textNode = SCNNode(geometry: textGeometry)

        let (min, max) = (textNode.boundingBox)
        let w = Float(max.x - min.x)
        let h = Float(max.y - min.y)
        textNode.pivot = SCNMatrix4MakeTranslation(w/2 + min.x, h/2 + min.y, 0)

        textNode.position = camera.convertPosition(position, to: nil)
        textNode.eulerAngles = camera.eulerAngles
        textNode.scale = SCNVector3(0.0005, 0.0005, 0.0005)
        sceneView.scene.rootNode.addChildNode(textNode)
    }

    private func updateVisualization(peer: NINearbyObject) {

        guard let distance = peer.distance else { return }

        let _distance = Int(distance)
        if _distance > 0 && !placedNodeMeters.contains(_distance) {
            placedNodeMeters.append(_distance)
            addNode(distance: Float(_distance))
        }

        UIView.animate(withDuration: 0.3, animations: {
            self.animate(peer: peer)
        })
    }
```

## さいごに

iPhoneの端末同士が背を向けている状態が一番精度が高まります。間に人がいるなどで遮蔽物がある場合や、端末の背面が向きあっていない場合に精度が落ちます。9mまで、と思い込んでいましたがそれ以上の距離でも高精度で計測できていました。とは言え離れるほど不安定になるとは思います。手元のテストでは直線で36mまで計測できました。