Nearby InteractionとARKitを使ったサンプルを実装しました。
Nearby Interactionの処理から取得できる数値を元に、1m離れるごとに空間上に板を自動配置しています。
iPhone11 ProとiPhone13 Proの2台の端末を使用。
どちらの端末もU1チップを搭載しており、高精度の距離測定が可能です。
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関連の取り回しの実装を引用。
/*
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用のトークンを交換します。
// 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で表示更新を行う。
// 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も配置。表示が滑らかになる調整もしています。
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まで計測できました。