转载自:DavidWang 【已授权】
原文链接:ARKit之路-RealityKit中的事件系统
  作者简介:汪祥春,计算机科学与技术专业硕士,全国信标委虚拟现实与增强现实标准工作组成员(CNITSC)、中国增强现实核心技术产业联盟成员(CARA)、华为布道师(Huawei Developer Experts),拥有深厚软件工程专业背景,十余年项目实施管理经验。著有《ARCore之路-Untiy开发从入门到实践》、《AR开发权威指南-ARFoundation》、《ARKit原生开发入门精粹-RealityKit+Swift+SwiftUI》。


  *该章为我使用SwiftUI构建ARkit应用提供了思路和想法,对汪老师表示感谢。

  苹果公司在2019年推出了一个名为Combine的声明性异步事件处理框架,通过采用Combine框架,可以集成事件处理代码并消除嵌套闭包和基于约定的回调,使代码更易于阅读和维护。Combine框架API都是使用类型安全的泛型实现,可以无缝接入已有工程,用于处理各类事件。

  在Combine框架中,最重要的3个组成部分为:Publisher(发布者)、Subscriber(订阅者)、Operator(操作变换),它们之间的关系如下图所示。

  Combine事件处理机制是典型的观察者模式(与RxSwift中的Observable、Observer几乎完全一致),RealityKit中的事件处理机制完全借用了Combine,也包括Publisher、Subscriber、Operator 3个组成部分,并且所有的Publisher都遵循Event协议。目前,RealityKit所有的事件如下表所示。

所属事件类型 事件 描述说明
场景 SceneEvents.AnchoredStateChanged 当场景中的ARAnchor(包括所有遵循HasAnchoring协议的实体)状态发生改变时触发
- SceneEvents.Update 该事件每帧都会触发,因此可以执行自定义帧更新逻辑
动画 AnimationEvents.PlaybackCompleted 当动画播放完毕时触发
- AnimationEvents.PlaybackLooped当动画循环播放时触发
- AnimationEvents.PlaybackTerminated 当动画被中止时触发,包括播放完毕或者被中断
音频 AudioEvents.PlaybackCompleted 当音频播放完毕时触发
碰撞 CollisionEvents.Began 当两个带碰撞器的对象碰撞开始时触发,每次碰撞只触发一次
- CollisionEvents.Updated 当两个带碰撞器的对象碰撞接触后每帧都会触发
- CollisionEvents.Ended 当两个带碰撞器的对象发生碰撞后脱离接触时触发,每次碰撞只触发一次
网络同步 SynchronizationEvents.OwnershipChanged 当一个实体对象的所有权属性发生改变时触发
- SynchronizationEvents.OwnershipRequest 当一个网络参与者申请对某个实体对象的所有权时触发

RealityKit中的所有事件都可以通过subscribe()方法订阅监听,并且所有的事件处理遵循相同的步骤与流程,因此只要理解掌握一种事件处理方法就可以推广到所有其他类型事件的处理中。

  下面我们以碰撞事件(Collision Events)为例讲解RealityKit事件的一般处理方法。通常,在需要处理某类事件之前先要订阅(subscribe)该事件,在RealityKit中,我们使用scene完成订阅操作,典型代码如代码清单1所示。

let beginSubscribe = self.scene.subscribe(
    to: CollisionEvents.Began.self) { event in
        print("碰撞开始")
    }

  通过这种方式订阅,场景中任何实体对象与其他实体对象发生碰撞时都会触发该事件,因此,同一次碰撞会触发两次该事件(发生碰撞的实体A触发一次,实体B触发),可以通过event参数(event.entityA和event.entityB)获取到发生碰撞的两个实体对象。除此之外,我们可以只订阅某个特定实体对象的碰撞事件,典型代码如代码清单2所示。

let myBox = CustomBox()
let beginSubscribe = self.scene.subscribe(
     to: CollisionEvents.Began.self,
     on: myBox) { event in
         print("碰撞开始")
     }

  细心的读者可能已经注意到,在代码清单1和代码清单2中,我们都使用一个变量(beginSubscribe)保存了订阅事件,这是因为,如果没有变量保存订阅事件的引用,RealityKit将不会触发该事件(RealityKit这么处理的原因是确保事件只在需要的时候触发,如果处理事件的订阅者已经失效,从提高效率与内存管理方面考虑,则没有理由再触发该事件)。所以要想事件正确触发,必须确保有变量保存事件订阅引用。
  保存订阅事件引用对防止事件过滥使用有帮助,下面的示例我们将事件订阅与引用都放置到实体对象中,进一步简化处理逻辑,提高代码的优雅度,完整的代码如代码清单3所示。

import SwiftUI
import RealityKit
import ARKit
import Combine
struct ContentView : View {
    var body: some View {
        return ARViewContainer().edgesIgnoringSafeArea(.all)
    }
}

struct ARViewContainer: UIViewRepresentable {
    func makeUIView(context: Context) -> ARView {
        let arView = ARView(frame: .zero)
        let config = ARWorldTrackingConfiguration()
        config.planeDetection = .horizontal
        arView.session.run(config, options: [])
        arView.setupGestures()
        return arView
    }
    func updateUIView(_ uiView: ARView, context: Context) {}
}

extension ARView{
    func setupGestures() {
        let tap = UITapGestureRecognizer(target: self, action: #selector(self.handleTap(_:)))
        self.addGestureRecognizer(tap)
    }
    
    @objc func handleTap(_ sender: UITapGestureRecognizer? = nil) {
        guard let touchInView = sender?.location(in: self) else {
            return
        }
        guard let raycastQuery = self.makeRaycastQuery(from: touchInView, allowing: .existingPlaneInfinite,alignment: .horizontal) else {
            return
        }
        guard let result = self.session.raycast(raycastQuery).first else {return}
        let transformation = Transform(matrix: result.worldTransform)
        let box = CustomEntity(color: .yellow,position: transformation.translation)
        self.installGestures(.all, for: box)
        box.addCollisions(scene: self.scene)
        self.scene.addAnchor(box)
    }
}
//自定义实体类
class CustomEntity: Entity, HasModel, HasAnchoring, HasCollision {
    var subscribes: [Cancellable] = []
    required init(color: UIColor) {
        super.init()
        self.components[CollisionComponent] = CollisionComponent(
            shapes: [.generateBox(size: [0.1,0.1,0.1])],
            mode: .default,
            filter: CollisionFilter(group: CollisionGroup(rawValue: 1), mask: CollisionGroup(rawValue: 1))
        )
        self.components[ModelComponent] = ModelComponent(
            mesh: .generateBox(size: [0.1,0.1,0.1]),
            materials: [SimpleMaterial(color: color,isMetallic: False)]
       )
    }
    
    convenience init(color: UIColor, position: SIMD3<Float>) {
        self.init(color: color)
        self.position = position
    }
    
    required init() {
        fatalError("init()没有执行,初始化不成功")
    }
    
    func addCollisions(scene: Scene) {
        subscribes.append(scene.subscribe(to: CollisionEvents.Began.self, on: self) { event in
            guard let box = event.entityA as? CustomEntity else {
                return
            }
            box.model?.materials = [SimpleMaterial(color: .red, isMetallic: False)]
            
        })
        subscribes.append(scene.subscribe(to: CollisionEvents.Ended.self, on: self) { event in
            guard let box = event.entityA as? CustomEntity else {
               return
            }
            box.model?.materials = [SimpleMaterial(color: .yellow, isMetallic: False)]
        })
    }
}

  在上述代码清单中,我们自定义了一个customEntity实体类,该实体类继承了Entity类并遵循了HasModel、HasAnchoring、HasCollision协议,因此能正常渲染显示、发生碰撞,并可以直接添加到scene.anchors集合中。需要注意的是在customEntity类中,我们定义了subscribes数组,专用于保存事件订阅引用,addCollisions()方法用于订阅碰撞事件,并根据碰撞事件开始与结束修改立方体的颜色。

  在主逻辑中,我们添加了屏幕点击手势,用于在检测到的平面上放置自定义立方体对象,因为几乎所有的工作都在customEntity类中处理,主逻辑变得很清晰,代码也更清爽。

  运行本示例,在检测到平面时通过点击屏幕生成立方体,使用屏幕手势操作立方体,当两个立方体发生碰撞时会同时改变颜色,效果如下图所示。
在这里插入图片描述

  本节案例演示了RealityKit事件处理的基本流程,在RealityKit中,所有的事件处理都遵循相同的机制,因此通过一个案例就能理解所有操作原理,可以采用同样的方式处理所有RealityKit事件。