23-苹果官方AR多人弹弓射击Demo解读(上)

今年的 Session 605 - Inside SwiftShot: Creating an AR Game 上演示了一个叫SwiftShot的多人游戏Demo
其中涉及到的内容非常多,本文是对wwdc605内容的简单总结.
注释版代码

说明

ARKit文章目录


SwiftShot 内部的奥秘

SwiftShot开发中用到了技术及说明:

  • ARKit:用来在物理世界中识别和渲染场景.
  • SceneKit:管理和绘制3D场景,物理效果模拟.
  • Metal:支撑SceneKit中的阴影和渲染,还有稍后讲到的旗帜模拟.
  • GameplayKit:控制并共享游戏中对象的行为.
  • Multi-peer connectivity:处理网络层,包括附近设备互相发现,同步和加密.
  • AVFaundation:氛围音乐和音效.
  • Swift:类型安全,性能高,先进特性如:protocol extensions等,让我们专注游戏性无需过多关注代码层面的崩溃等问题.

建立一个共享的坐标系空间

建立一个多人共享的坐标系空间有多种方式

  • 图片检测
  • 物体检测
  • 世界地图共享
  • 固定安装的iBeacons

在SwiftShot中,我们先扫描外部环境,让ARKit建立起世界地图,然后将其序列化为data数据,并传输到其它设备上;
然后对方设备将地图数据加载到ARKit中,并用它来识别出同一个平面.

这样一来,我们就在真实世界中共享了这些参考点,这样在每个人的设备上都会在同一位置识别并渲染出同一块平面.

状态共享

保存

实现过程:

  • 第一个设备扫描一块区域,捕捉特征点
  • 向ARSession请求世界地图
  • 序列化到磁盘
​sceneView.session.getCurrentWorldMap { map, error in
    if let error = error { print(error); return }
    guard let map = map, let data = try? NSKeyedArchiver.archivedData (withRootObject: map, requiringSecureCoding: true) else { return }
    // save or send over network
    }
}

Ad-hoc(特殊点对点网络)游戏

通过网络共享过程:

  • 点对点(peer-to-peer)网络连接
  • 加密传输中的数据
  • 在UI上引导用户来重定位

固定设施

对于游戏下面的桌子这样的固定设施,你可以先从多个角度扫描,捕捉特有的特征点,建立世界地图,然后保存在每个设备上.为了让定位更准确,你还可以给每张桌子装上iBeacon,通过关联iBeacon的id和各个世界地图,就可以在SwiftShot中靠近不同桌子自动加载对应的地图.

在实际应用中,你可以加载app中内置的地图,也可以从云端下载地图并加载,都可以让你的app在不同设备上共享一个世界地图.

加载

// Unarchive data to ARWorldMap
let worldMap = try NSKeyedUnarchiver.unarchivedObject(ofClass: ARWorldMap.self, from: data)

// Create tracking configuration
let configuration = ARWorldTrackingConfiguration() configuration.initialWorldMap = worldMap

// Run session
sceneView.session.run(configuration, options: [.resetTracking, .removeExistingAnchors])

隐私

ARWorldMap使用了你周围的特征点信息:

  • ARKit本身不包含经度/纬度信息
  • 可能会包含个人敏感信息

因此,应将ARWorldMap视为用户隐私数据来保护:

  • 在保存和传输中都进行加密
  • 在扩展使用时(如分享给他人或保存到云端),应给用户提示

ARAnchor

在SwiftShot中,当地图信息传输给他人后,我们还需要告诉他人:平面到底在什么位置,这就用到了ARAnchor.

当你创建ARAnchor时,除了4x4的变换矩阵外,还可以指定一个名字.当ARKit序列化数据时,这些信息也会被传输到其他设备上.

let anchor = ARAnchor(name: “Touched”, transform: transform) 
session.add(anchor: anchor)

为了更好的表示游戏的位置,我们自定义了一个ARAnchor的子类,叫BoardAnchor:

class BoardAnchor: ARAnchor {
    private(set) var size: CGSize
    init(name: String, transform: float4x4, size: CGSize) {
        self.size = size
        super.init(name: name, transform: transform)
    }
    required init?(coder aDecoder: NSCoder) {
        self.size = aDecoder.decodeCGSize(forKey: "size")
        super.init(coder: aDecoder)
    }
    override func encode(with aCoder: NSCoder) {
        super.encode(with: aCoder)
        aCoder.encode(size, forKey: "size")
    }

Multipeer Connectivity网络

Multipeer Connectivity可以帮助我们完成连接:

  • peer-to-peer连接,无需中央服务器(在SwiftShot中是用第一台设备作为服务器,控制整个游戏的.但Multipeer Connectivity技术本身无此要求)
  • 内置加密和鉴权(authentication),在在SwiftShot中,用不到鉴权功能,只用了加密功能.
  • 网络广播和发现,让用户通过API广播游戏信息,以便其它人加入.

在SwiftShot中的具体做法是:

  • 一个设备开始游戏并创建session,开启广播
  • 其他设备在菜单中看到session列表
  • 用户选择游戏并加入
    • 设备发送请求
    • 广播设备接受或拒绝
  • 一旦session建立,设备就成为了网络中的一员.

在Multipeer Connectivity,共有三种方式来发送数据,其中Data packets可以一对多发送,后面两者只能一对一:

  • Data packets
  • Resources as URLs
  • Streams

因此,在SwiftShot中,我们用Data packets来发送游戏中的事件和物理效果数据;用Resources来传输世界地图;Streams在本例中没有使用.

Multipeer Connectivity底层使用了UDP协议来传输数据,UPD的低延迟特性十分有助于提高游戏的体验.但是UPD原生并不能保证传输有效性,所以Multipeer Connectivity提供了方法,让你选择reliably(可靠的)或unreliably(不可靠的).

当你选择reliably(可靠的)时,Multipeer Connectivity会自动丢包重试,无需自己在代码中处理,即使是一对多向网络中所有成员广播也可以处理.

/**
枚举关联值
可以让枚举值对应的原始值不是唯一的, 而是一个变量.
如gameAction是另一个枚举,包含了抓起弹弓,拉起弹弓等状态;
PhysicsSyncData则是个结构体,稍后分析
*/
​enum Action {
    case gameAction(GameAction)
    case boardSetup(BoardSetupAction)
    case physics(PhysicsSyncData)
}
// 如果结构体所有成员都是Codable,则结构体自身可直接写上Codable协议,可完成序列化
struct HitCatapult: Codable {
    var catapultID: Int
    var hitPosition: float3
    var hitVel: float3
    var vortex: Bool
}

// 枚举不会因为成员遵守Codable协议而自动遵守,所有需要我们自己处理
extension Action: Codable {
    init(from decoder: Decoder) throws { /* */ }
    func encode(to encoder: Encoder) throws { /* */ }
}

​// Sending physics data
func send(_ syncData: PhysicsSyncData) throws {
    let action = Action.physics(syncData)
    let encoder = PropertyListEncoder()
    encoder.outputFormat = .binary
    let data = try encoder.encode(action)
     try session.send(data, toPeers: peers, with: .unreliable)
}

物理效果

在SwiftShot中,物理效果非常重要,决定了游戏好玩程度:

  • 使用SceneKit内置的物理引擎:自动处理渲染,更新物体位置,并在代理中返回碰撞信息
  • 使用一个设备作为"server"来控制客户端的更新,以保证同步.
  • 只共享和游戏状态相关的数据:每个客户端运行自己的物理引擎,本地模拟那些不太重要的数据如弹弓的晃动,粒子效果等;只有用户相关数据如弹弓,子弹,盒子等是共享的.
  • 为了更真实的效果,在物理引擎中,物体的尺寸比看到的尺寸放大了10倍:至于为什么缩放,是因为物理引擎对不同尺寸同样形状物体的模拟效果不一样,要知道在游戏中,只要看起来是正确的,玩起来是好玩的,那它就是正确的...

物理数据优化

在游戏中,我们需要传输很多数据,如位置,速度,角速度,朝向等

这些数据有很大的优化空间,比如位置是由x,y,z三个浮点数表示的,在浮点数中有符号位,指数,尾数三部分,最大可表示10^38,这对我们来说太大了.

浮点类型的单精度值具有 4 个字节,
包括一个符号位、一个 8 位 (excess-127) 二进制指数和一个 23 位尾数。
尾数表示一个介于 1.0 和 2.0 之间的数。

对于物理引擎来说,桌子长度是27米(放大了10倍,桌子其实是2.7米),加上两边的活动区域也不过80米长.

在编码时,我们就可以通过将位置规范化到0~80这个区间来去掉符号位,这样所有值都是正的,即整张桌子都在坐标系原点的正方向.
同时,又因为位置系数是[0,1]区间,指数部分就也不需要了,只需要把这个系数值保存在尾数部分就行了.
比如,尾数部分是1,那么系数就是1.0,实际值为80*1.0 = 80;如果是0,那就代表0;

这样编码后,就极大地优化了游戏表现.同样的,其他数据也这样编码,速度,角速度,朝向等.总体来看,编码后的长度减少了一半多,同时精度能达到毫米级.

但是,我们在发送的属性列表(propety list)中仍然有大量冗余数据---每个数据都带有一个name.我们并不需要这些数据,所有我们实现一个新的序列化方法,叫BitStream.

Encoding—BitStream

技术特点:

  • Bit-packed编码数值
top Created with Sketch.