【iOS ARKit】网络传输 ARWorldMap

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 时进行。

具体代码地址:GitHub - duzhaoquan/ARkitDemo

相关推荐
Jinkey2 小时前
FlutterBasic - GetBuilder、Obx、GetX<Controller>、GetxController 有啥区别
android·flutter·ios
程序猿看视界8 小时前
如何在 UniApp 中实现 iOS 版本更新检测
ios·uniapp·版本更新
dr李四维12 小时前
iOS构建版本以及Hbuilder打iOS的ipa包全流程
前端·笔记·ios·产品运营·产品经理·xcode
️ 邪神12 小时前
【Android、IOS、Flutter、鸿蒙、ReactNative 】自定义View
flutter·ios·鸿蒙·reactnative·anroid
比格丽巴格丽抱1 天前
flutter项目苹果编译运行打包上线
flutter·ios
网络安全-老纪1 天前
iOS应用网络安全之HTTPS
web安全·ios·https
1024小神1 天前
tauri2.0版本开发苹果ios和安卓android应用,环境搭建和最后编译为apk
android·ios·tauri
lzhdim1 天前
iPhone 17 Air看点汇总:薄至6mm 刷新苹果轻薄纪录
ios·iphone
安和昂1 天前
【iOS】知乎日报第四周总结
ios
麦田里的守望者江1 天前
KMP 中的 expect 和 actual 声明
android·ios·kotlin