ARKit 可以利用 ARWorldMap 在应用中断后进行状态恢复、继续AR 进程。一个用户也可以将ARWorldMap 发送给其他用户,当其他用户接收并加载 ARWorldMap 后,就可以在相同的物理环境看到同样的虚拟元素,达到共享 AR体验的目的。
在ARKit 中,ARWorldMap 可以保存的 ARSession状态包括对物理环境的感知(特征点信息)、地标信息、用户添加到场景中的ARAnchor,但不包括虚拟元素本身。ARWorldMap 并不会存储虚拟元素,理解这点对我们还原场景非常重要,由于虚拟元素都依赖于 ARAnchor,因此 ARAnchor 就成为最重要的桥梁,有通过ARAnchor,我们才能再度恢复场景。
除了可以将 ARWorldMap 存储到本地文件系统中供本机应用稍后加载继续 AR体验,也可以通过网络将 ARWorldMap 传输到其他移动设备上供其他设备共享 AR 体验。我们通过网络传输使用ARWorldMap,网络传输采用 Multipeer Connectivity 通信框架,Multipeer Connectivity 点对点通信框架特别适合物理距离很近的设备通过 WiFi、蓝牙直连。
为便于代码的理解,这里对 Multipeer Connectivity 框架进行必要简述,更详细的信息需读者查阅相关资料。Multipeer Connectivity 框架是点对点通信框架,即任何一方既可以作为主机也可以作为客户机参与通信,进行通信时,该框架使用 MCNearbyServiceAdvertiser 向外广播自身服务,使用 MCNearbyServiceBrowser搜索发现(Discovering)可用的服务。
根据通信进程,该框架的使用可分成两个阶段:发现阶段与会话通信阶段。假设有两台设备A和B,A先作为主机广播自身服务,B作为客户机搜索可用服务,一旦B发现了A就尝试与其建立连接,在经过A同意后二者建立连接。当连接建立后即可进行数据通信,进入会话通信阶段。
在应用程序转到后台时,Multipeer Connectivity 框架会暂停广播与搜索发现并断开已连接的会话,在回到前台后,该框架会自动恢复广播与发现,但会话还需要重新建立连接。利用网络传输 ARWorldMap的代码如下所示。
//
// ARWorldMapShare.swift
// ARKitDeamo
//
// Created by zhaoquan du on 2024/2/22.
//
import SwiftUI
import RealityKit
import ARKit
import Combine
import MultipeerConnectivity
struct ARWorldMapShare: View {
var viewModel: ViewModel = ViewModel()
var body: some View {
ARWorldMapShareContainer(viewModel: viewModel)
.overlay(
VStack{
Spacer()
Button(action: {viewModel.saveWorldMap()}) {
Text("发送AR环境信息")
.frame(width:250,height:50)
.font(.system(size: 17))
.foregroundColor(.black)
.background(Color.white)
.opacity(0.6)
}
.cornerRadius(10)
Spacer().frame(height: 40)
}
).edgesIgnoringSafeArea(.all).navigationTitle("共享ARWorldMap")
}
class ViewModel: NSObject,ARSessionDelegate{
var arView: ARView? = nil
var multipeerSession: MultipeerSession? = nil
var planeEntity : ModelEntity? = nil
var raycastResult : ARRaycastResult?
var isPlaced = false
var robotAnchor: AnchorEntity?
let robotAnchorName = "drummerRobot"
func createPlane() {
if multipeerSession == nil {
multipeerSession = MultipeerSession(receivedDataHandler: reciveData(_:from:), peerJoinedHandler: peerJoined(_:), peerLeftHandler: peerLeft(_:), peerDiscoveredHandler: peerDiscovery(_:))
}
guard let arView = arView else {
return
}
if let an = arView.scene.anchors.first(where: { an in
an.name == "setModelPlane"
}){
arView.scene.anchors.remove(an)
}
let planeMesh = MeshResource.generatePlane(width: 0.15, depth: 0.15)
var planeMaterial = SimpleMaterial(color:.white,isMetallic: false)
planeEntity = ModelEntity(mesh: planeMesh, materials: [planeMaterial])
let planeAnchor = AnchorEntity(plane: .horizontal)
do {
let planeMesh = MeshResource.generatePlane(width: 0.15, depth: 0.15)
var planeMaterial = SimpleMaterial(color: SimpleMaterial.Color.red, isMetallic: false)
planeMaterial.color = try SimpleMaterial.BaseColor(tint:UIColor.yellow.withAlphaComponent(0.9999), texture: MaterialParameters.Texture(TextureResource.load(named: "AR_Placement_Indicator")))
planeEntity = ModelEntity(mesh: planeMesh, materials: [planeMaterial])
planeAnchor.addChild(planeEntity!)
planeAnchor.name = "setModelPlane"
arView.scene.addAnchor(planeAnchor)
} catch let error {
print("加载文件失败:\(error)")
}
}
func saveWorldMap() {
print("save:\(String(describing: arView))")
self.arView?.session.getCurrentWorldMap(completionHandler: {[weak self] loadWorld, error in
guard let worldMap = loadWorld else {
print("当前无法获取ARWorldMap:\(error!.localizedDescription)")
return
}
do {
let data = try NSKeyedArchiver.archivedData(withRootObject: worldMap, requiringSecureCoding: true)
self?.multipeerSession?.sendToAllPeers(data, reliably: true)
print("ARWorldMap已发送")
} catch {
fatalError("无法序列化ARWorldMap: \(error.localizedDescription)")
}
})
}
func reciveData(_ data: Data,from peer: MCPeerID) {
var worldMap: ARWorldMap?
do {
worldMap = try NSKeyedUnarchiver.unarchivedObject(ofClass: ARWorldMap.self, from: data)
} catch let error {
print("ARWorldMap文件格式不正确:\(error)")
}
guard let worldMap = worldMap else {
print("无法解压ARWorldMap")
return
}
print("收到ARWorldMap")
let config = ARWorldTrackingConfiguration()
config.planeDetection = .horizontal
config.initialWorldMap = worldMap
self.arView?.session.run(config,options: [.resetTracking, .removeExistingAnchors])
}
func peerDiscovery(_ peer: MCPeerID) -> Bool{
guard let multipeerSession = multipeerSession else {
return false
}
if multipeerSession.connectedPeers.count > 3{
return false
}
return true
}
func peerJoined(_ peer: MCPeerID) {
}
func peerLeft(_ peer: MCPeerID) {
}
func setupGesture(){
let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap))
self.arView?.addGestureRecognizer(tap)
}
@objc func handleTap(sender: UITapGestureRecognizer){
sender.isEnabled = false
sender.removeTarget(nil, action: nil)
isPlaced = true
let anchor = ARAnchor(name: robotAnchorName, transform: raycastResult?.worldTransform ?? simd_float4x4())
self.arView?.session.add(anchor: anchor)
robotAnchor = AnchorEntity(anchor: anchor)
do {
let robot = try ModelEntity.load(named: "toy_drummer")
robotAnchor?.addChild(robot)
robot.scale = [0.01,0.01,0.01]
self.arView?.scene.addAnchor(robotAnchor!)
print("Total animation count : \(robot.availableAnimations.count)")
robot.playAnimation(robot.availableAnimations[0].repeat())
} catch {
print("找不到USDZ文件")
}
planeEntity?.removeFromParent()
planeEntity = nil
}
func session(_ session: ARSession, didUpdate frame: ARFrame) {
guard !isPlaced, let arView = arView else{
return
}
//射线检测
guard let result = arView.raycast(from: arView.center, allowing: .estimatedPlane, alignment: .horizontal).first else {
return
}
raycastResult = result
planeEntity?.setTransformMatrix(result.worldTransform, relativeTo: nil)
}
func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {
guard !anchors.isEmpty,robotAnchor == nil else {
return
}
var panchor: ARAnchor? = nil
for anchor in anchors {
if anchor.name == robotAnchorName {
panchor = anchor
break
}
}
guard let pAnchor = panchor else {
return
}
//放置虚拟元素
robotAnchor = AnchorEntity(anchor: pAnchor)
do {
let robot = try ModelEntity.load(named: "toy_drummer")
robotAnchor?.addChild(robot)
robot.scale = [0.01,0.01,0.01]
self.arView?.scene.addAnchor(robotAnchor!)
print("Total animation count : \(robot.availableAnimations.count)")
robot.playAnimation(robot.availableAnimations[0].repeat())
} catch {
print("找不到USDZ文件")
}
isPlaced = true
planeEntity?.removeFromParent()
planeEntity = nil
print("加载模型成功")
}
}
}
struct ARWorldMapShareContainer: UIViewRepresentable {
var viewModel: ARWorldMapShare.ViewModel
func makeUIView(context: Context) -> some ARView {
let arView = ARView(frame: .zero)
return arView
}
func updateUIView(_ uiView: UIViewType, context: Context) {
let config = ARWorldTrackingConfiguration()
config.planeDetection = .horizontal
uiView.session.run(config)
uiView.session.delegate = viewModel
viewModel.arView = uiView
viewModel.createPlane()
viewModel.setupGesture()
}
}
#Preview {
ARWorldMapShare()
}
在使用 Multipeer Connectivity 框架时,每个服务都需要一个类型标识符(serviceType),该标识符用于在多服务主机的情况下区分不同主机,这个标识符由 ASCII 字符、数字和""组成,最多15个字符,且至少包含一个ASCII 字符,不得以""开头或结尾,也不得两个""连用。在进行通信前可以将 sessionType设置为 host(主机)、peer(客户机)、both(既可以是主机也可以是客户机)三者之一,用于设置设备的通信类型。
同时在两台设备A和B上运行本案例(确保两台设备连接到同一个WiFi 网络或者都打开蓝牙),在A设备检测到的平面上添加机器人模型后单击"发送地图"按钮,在A、B 连接顺畅的情况下可以看到B设备的ARSession 会重启,当环境匹配成功后虚拟机器人模型会出现在B设备中,并且其所在物理世界中的位置与A设备中的一致,如图所示。
直接使用 ARAnchor 名字进行虚拟元素关联的方式不会附带更多的自定义信息,在某些场景下,可能需要更多的场景状态数据,这时我们可以通过继承 ARAnchor,自定义 ARAnchor 子类来实现这一目标,通过自定义的 ARAnchor 于类可以携带更多关于应用运行时的状态信息。
在 ARKit 中,每生成一个 ARFrame 对象就会更新一次所有与当前 ARSession 关联的ARAnchor,并且ARAnchor 对象是不可变的,这意味着,ARKit 会将所有的ARAnchor 从一个 ARFrame 对象复制到另一个ARFrame 对象。当创建继承自 ARAnchor 的子类时,为确保这些子类在保存与加载 ARWorldMap 时正常工作,子类创建应当遵循以下原则:
(1)子类应当完全遵循ARAnchorCopying 协议,必须提供 init(anchor:)方法,ARKit 需要调用 init(anchor:)方法将其从一个 ARFrame 复制到另一个 ARFrame。同时,在该构造方法中,需要确保自定义的变量值被正确地复制。
(2) 子类应当完全遵循 NSSecureCoding 协议,重写 encode(with:)和 init(coder:)方法,确保自定义变量能正确地被序列化和反序列化。
(3) 子类判等的条件是其identifier 值相等。
(4) 只有那些不遵循 ARTrackable 协议的ARAnchor 才能被保存进ARWorldMap,即类似 ARFaceAnchor、ARBodyAnchor 这类反映实时变化的 ARAnchor 不会通过 ARWorldMap 共享。当使用 getCurrent WorldMap(completion Handler:)方法创建 AR WorldMap 时,所有的非可跟踪(Trackable)的ARAnchor 都将自动被保存。
除此之外,使用 getCurrentWorldMap(completion Handler:)方法获取的当前场景 ARSession 运行状态数据的可用性与当前 ARFrame 的状态有关,ARFrame.WorldMappingStatus 为 mapped 时获取的 ARWorldMap 数据最可信,反之则可能不准确,从而影响场景恢复,所以在获取 ARWorldMap 时最好选择 ARFrame状态 ARFrame. WorldMappingStatus 为 mapped 时进行。