构建多用户的 AR 应用

这篇文档翻译自 Apple 的官方文档:Creating a Multiuser AR Experience


在这篇文档里面 Apple 提供了一个范例程序。这个范例程序验证了使用两个或者更多的 iOS 12 以上的设备进行共享的 AR 体验。在开始探索代码之前,你可以自行尝试编译运行这个 APP 以了解这个 APP 提供的用户体验(Xcode 工程代码请前往原文中下载)。

  1. 在一台设备上运行这个 APP。你可以观察一下本地的环境,然后单击屏幕以在现实世界中放置一个 3D 角色;
  2. 在第二台设备上运行这个 APP。在两个设备屏幕上都可以看到一条提示信息,显示两台设备都自动加入了一个共享会话;
  3. 点击设备上 Send World Map 南,确保第二台设备处于第一台设备曾经访问过的区域,或者二者所处的环境极其相似;
  4. 第二台设备会显示它收到了一个地图,并打算使用。这个过程成功以后,两台设备都可以在同一个现实世界位点现实一个虚拟的内容。

我们沿着下面的步骤来观察这个 APP 如何使用 ARWorldMap 类来存储并恢复 ARKit 的地图状态,并使用 Multipeer Connectivity 框架来在临近的设备之间传输数据。

1 Getting Started

这个工程要求 Xcode 10.0, iOS 12.0 以及具备有 A9 或者更新的处理器的多台 iOS 设备。

2 运行 AR Session 并放置 AR 内容

这个 APP 扩展了构建一个基础的 ARKit 应用的工作流。APP 首先定义一个 ARWorldTrackingConfiguration 并启用平面探测,然后将这个配置传递给绑定到 ARSCNViewARSession 运行。

UITapGestureRecognizer 探测到屏幕上的一次点击,那么 handleSceneTap 方法会使用 ARKit 的 kit-testing 方法来找到点击指向的现实世界中的三维位置,然后放置一个 ARAnchor 来标记这个位置。当 ARKit 调用代理方法 renderer(_:didAdd:for:) 时,APP 会为 ARSCNView 载入一个 3D 模型并现实在锚点的位置。

3 连接到 Peer 设备

范例中的 MultipeerSession 类提供了围绕 "Multipeer Connectivity" 特性的一个简单抽象。当主要的 view controller 创建了一个 Multipeer Session 实例时,它会开始运行一个 MCNearbySeviceAdvertiser 来广播设备参与 Multipeer 会话的能力,并运行一个 MCNearbyServiceBrower 来寻找其他设备:

1
2
3
4
5
6
7
8
9
10
session = MCSession(peer: myPeerID, securityIdentity: nil, encryptionPreference: .required)
session.delegate = self

serviceAdvertiser = MCNearbyServiceAdvertiser(peer: myPeerID, discoveryInfo: nil, serviceType: MultipeerSession.serviceType)
serviceAdvertiser.delegate = self
serviceAdvertiser.startAdvertisingPeer()

serviceBrowser = MCNearbyServiceBrowser(peer: myPeerID, serviceType: MultipeerSession.serviceType)
serviceBrowser.delegate = self
serviceBrowser.startBrowsingForPeers()

MCNearbyServiceBrowser 找到了另一台设备,即调用 browser(_:foundPeer:withDiscoveryInfo:) 代理方法。为了邀请另一台设备加入一个共享会话,需要调用 browser 的 invitePeer(_:to:withContext:timeout:) 方法:

1
2
3
4
public func browser(_ browser: MCNearbyServiceBrowser, foundPeer peerID: MCPeerID, withDiscoveryInfo info: [String: String]?) {
// Invite the new peer to the session.
browser.invitePeer(peerID, to: session, withContext: nil, timeout: 10)
}

当另一台设备收到了这个邀请时,MCNearbyServiceAdvertiser 会调用 advertiser(_:didReceiveInvitationFromPeer:withContext:invitationHandler:) 代理方法。为了接受邀请,需要调用这个函数参数中的 invitationHandler

1
2
3
4
func advertiser(_ advertiser: MCNearbyServiceAdvertiser, didReceiveInvitationFromPeer peerID: MCPeerID, withContext context: Data?, invitationHandler: @escaping (Bool, MCSession?) -> Void) {
// Call handler to accept invitation and join the session.
invitationHandler(true, self.session)
}

Important

这个 APP 会自动加入它找到的第一个临近会话。取决于你想要创建的 AR APP 的功能,你可能需要更加精确地控制广播、邀请以及接受邀请的行为。这部分内容请参考 Multipeer Connectivity 的文档。

在 Multipeer 会话中,所有的参与者从定义上来说都是对等的,不存在明显的区分 Host 和 Guest 角色。不过在你自己的 APP 中,你可能需要自行定义这些角色。例如 AR 游戏可能需要一个 Host 角色来扮演裁判。

4 捕获并发送 AR World Map

ARWorldMap 对象包含了 ARKit 用来定位设备位置的所有空间信息的一个 Snapshot(快照)。在不同的设备之间进行可靠的地图共享涉及两个步骤:

  1. 找到合适的时机来捕获地图;
  2. 捕获并传输地图。

ARKit 提供了一个名为 worldMappingStatus 的值来表示现在是否是一个好的捕获世界地图的实际。这个 APP 使用这个值来向 Send World Map 按钮提供视觉反馈:

1
2
3
4
5
6
7
8
9
10
11
switch frame.worldMappingStatus {
case .notAvailable, .limited:
sendMapButton.isEnabled = false
case .extending:
sendMapButton.isEnabled = !multipeerSession.connectedPeers.isEmpty
case .mapped:
sendMapButton.isEnabled = !multipeerSession.connectedPeers.isEmpty
@unknown default:
sendMapButton.isEnabled = false
}
mappingStatusLabel.text = frame.worldMappingStatus.description

当用户按下 Send World Map 按钮时,APP 调用 getCurrentWorldMap(completionHandler:) 函数来从当前运行的 ARSession 中获取世界地图,然后使用 NSKeyedArchiever 将其序列化成一个 Data 对象。并发送到另一台 Multipeer 设备。

1
2
3
4
5
6
7
sceneView.session.getCurrentWorldMap { worldMap, error in
guard let map = worldMap
else { print("Error: \(error!.localizedDescription)"); return }
guard let data = try? NSKeyedArchiver.archivedData(withRootObject: map, requiringSecureCoding: true)
else { fatalError("can't encode map") }
self.multipeerSession.sendToAllPeers(data)
}

5 接受并处理地图数据

当设备收到另一个设备的地图数据时,代理方法 session(_:didReceive:fromPeer:) 会被调用。为了利用地图数据,APP 使用 NSKeyedUnarchiver 来反序列化出来一个 ARWorldMap 对象,并使用这个地图对象来创建一个新的 ARWorldTrackignConfiguration

1
2
3
4
5
6
7
8
9
10
if let worldMap = try NSKeyedUnarchiver.unarchivedObject(ofClass: ARWorldMap.self, from: data) {
// Run the session with the received world map.
let configuration = ARWorldTrackingConfiguration()
configuration.planeDetection = .horizontal
configuration.initialWorldMap = worldMap
sceneView.session.run(configuration, options: [.resetTracking, .removeExistingAnchors])

// Remember who provided the map for showing UI feedback.
mapProvider = peer
}

ARKit 接下来回尝试重新本地化 (Relocalize) 新的世界地图,即协调地图数据中的空间信息与自己感知到的本地信息。为了取得最好的结果:

  1. 在分享世界地图之前,粗略地用发送设备扫描本地环境;
  2. 将接受设备放置在发送设备的旁边,以确保二者能够看到同样的环境;

6 分享 AR 内容与用户行为

在分享世界地图的同时,所有现存的锚点也被共享了。在这个 APP 中,这意味着当接收设备完成 relocalize 过程时,接收设备可以显示发送设备在共享地图前放置的三维物体。不过,传输并重建世界地图是非常费时的操作。因此只有在新设备加入的时候我们会进行一次这样的操作。

为了持续地同步不同设备的 AR 体验,令每个用户的行为都可以作用到不同设备的 AR 场景,设备之间在完成 relocalization 之后,只需要继续共吸纳过用户行为相关的信息。例如在这个 APP 中,用户通过单击 AR 场景来放置一个 3D 角色。这个角色是静态的,因此另一个设备只需要知道这个角色的位置和朝向就可以重现这个操作。

这个 APP 在不同的设备之间共享 ARAnchor 对象来实现虚拟角色之间的通信。当一个用户在场景中单击时,这个 APP 创建一个锚点,并加入到本地的 ARSession 中,然后将 ARAnchor 序列化成 Data,并发送给其他设备:

1
2
3
4
5
6
7
8
// Place an anchor for a virtual character. The model appears in renderer(_:didAdd:for:).
let anchor = ARAnchor(name: "panda", transform: hitTestResult.worldTransform)
sceneView.session.add(anchor: anchor)

// Send the anchor info to peers, so they can place the same content.
guard let data = try? NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true)
else { fatalError("can't encode anchor") }
self.multipeerSession.sendToAllPeers(data)

当其他设备收到这些数据时,他们检查数据中是否包含了 ARAnchor。如果有,那就将其解码并加入自己的会话:

1
2
3
4
if let anchor = try NSKeyedUnarchiver.unarchivedObject(ofClass: ARAnchor.self, from: data) {
// Add anchor to the session, ARSCNView delegate adds visible content.
sceneView.session.add(anchor: anchor)
}

这里给出的只是一种同步策略,这个需要根据应用场景具体进行设计。