今年的 Session 605 - Inside SwiftShot: Creating an AR Game 上演示了一个叫SwiftShot的多人游戏Demo
其中涉及到的内容非常多,本文是对wwdc605内容的简单总结.
注释版代码
说明
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编码数值
- 最小化尺寸,快速序列化和反序列化
- 为网络通信构建的二进制数据
- 不适用于持久化储存
- 不适用于数据结构变化的场景
BitStreamCodable
具体的实现过程,是两个协议BitStreamEncodable
和BitStreamDecodable
,而BitStreamDecodable
是两者的组合.我们只要让我们的数据类型遵守该协议并实现相应方法就可以了,不管是float3或simd都可以.
// BitStreamCodable protocols
protocol BitStreamEncodable {
func encode(to bitStream: inout WritableBitStream) throws
}
protocol BitStreamDecodable {
init(from bitStream: inout ReadableBitStream) throws
}
typealias BitStreamCodable = BitStreamEncodable & BitStreamDecodable
extension float3: BitStreamCodable {
/* ... */
}
编码器:minValue最小值, maxValue最大值, bits精度位数
write(_ value: Float, to stream: inout WritableBitStream)
方法中,先求出传入的value在值区间(最大值-最小值)的比例ratio,得到一个在[0, 1]范围内的浮点数,再根据bits表示的最大值转换ratio的精度为整数bitPattern,得到一个在[0, 2^bits - 1]范围内的整数,最后写入stream中,因为范围是[0, 2^bits - 1],所以只需要写入bits位就可以了.
举例:
minValue = -5.0, maxValue = 5.0, bits=4,则maxBitValue= 2^4 -1 = 15;
假设传入value=1.0,则ratio=(1.0-(-5.0))/(5.0-(-5.0))=0.6;
bitPattern=0.6*15=9;
只需写入4-bit数据,就足以表示[0,15]范围内的整数了
// Compressing floats for encoding
struct FloatCompressor {
var minValue: Float
var maxValue: Float
var bits: Int
private var maxBitValue: Double { pow(2.0, Double(bits)) - 1 }
func write(_ value: Float, to stream: inout WritableBitStream) {
let ratio = Double((value - minValue) / (maxValue - minValue))
let clampedRatio = max(0.0, min(1.0, ratio))
let bitPattern = UInt32(clampedRatio * maxBitValue)
stream.appendUInt32(bitPattern, numberOfBits: bits)
}
}
现在再来看枚举,普通的编码过程中,encoding key是字符串,而在BitStream中我们用intage类型来做为key:
// BitStream Encoding Enums
enum Action: BitStreamCodable {
case gameAction(GameAction)
case boardSetup(BoardSetupAction)
case physics(PhysicsSyncData)
enum CodingKeys: UInt32 {
case gameAction case boardSetup
case physics
}
func encode(to bitStream: inout WritableBitStream) throws {
switch self {
case .gameAction(let gameAction):
bitStream.appendUInt32(CodingKeys.gameAction.rawValue, numberOfBits: 2)
try gameAction.encode(to: &bitStream)
// ...
}
但是这份代码有点小问题,在encode(to:)
方法中,因为我们知道gameAction
这个枚举只有三个值,所以只需要2-bit就够编码了.但是如果其他情况下枚举是4,5,6个值,就需要不停地手动改编码位数.
不过在Swift4.2中,有个新特征可以帮助我们:case iterable,让我们的枚举遵守这个协议,就会自动多出一个成员变量allCases
,里面包含了枚举的所有选项.这样我们就能自动计算枚举的长度,并得到所需的编码长度.
具体实现如下:
// Using CaseIterable to determine bits needed to encode
extension Action.CodingKeys: CaseIterable {}
extension RawRepresentable where Self: CaseIterable, RawValue: FixedWidthInteger {
static var bits: Int {
let casesCount = RawValue(allCases.count) - 1
return RawValue.bitWidth - casesCount.leadingZeroBitCount
}
}
extension WritableBitStream {
mutating func appendEnum<T>(_ value: T)
where T: CaseIterable & RawRepresentable, T: FixedWidthInteger {
appendUInt32(UInt32(value.rawValue), numberOfBits: type(of: value).bits)
}
}
在XC性能测试中,BitStream性能非常不错:1/10的尺寸,2倍编码速度,10倍解码速度
结合使用不同的编码
在BitStream的帮助下,我们大幅提升了性能,游戏中有200多个物体的情况下仍能保持60帧运行.
但是,我们在项目中用到了两种不同的编码:BitStream和Codable,这时就需要swift帮助我们把两者结合起来.
// Combining Codable and BitStreamCodable
extension BitStreamCodable where Self: Codable {
func encode(to bitStream: inout WritableBitStream) throws {
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
let data = try encoder.encode(self)
bitStream.append(data)
}
init(from bitStream: inout ReadableBitStream) throws {
let data = try bitStream.readData()
let decoder = PropertyListDecoder()
self = try decoder.decode(Self.self, from: data)
}
}
struct StartGameMusicTime: Codable, BitStreamCodable { /* ... */ }
素材导入和管理
对于素材来说,可以用普通商业应用来创建模型,导出为.dae格式,然后用commandLine Tool来转换为SceneKit使用的格式,这样就得到了结构化的数据,想用哪一部分的数据,就可以拿出来重新组合使用.
考虑到性能问题,还需要根据不同距离使用不同的细节等级来优化性能
- 近处的物体有较多的细节纹理和多边形
- 远处的物体有更少的细节纹理,更少的多边形
物理形体(Physics bodies)是也做了优化
- 出于性能方面考虑,应尽量使用预定义的类型(box, sphere, 等)
- 如果没有指定,SceneKit将会自动根据模型构建凸多面体,但这样性能不佳.
关于素材的示例,可以看项目的素材,如项目中的圆柱体木头,弹弓,和游戏中未用到但仍包含在项目中的小鸡
旗帜模拟
这个迎风而动的旗子,让游戏效果更加真实.这个效果可以用物理引擎中的布料模拟(cloth simulation)功能来完成,但这里我们决定用Metal在GPU上完成.
首先创建一个SceneKit静态素材,并在运行时将苹果Logo印在旗子上.
然后创建一个Swift类来封装Metal command queue
- 创建Metal command queue,并插入游戏信息,如风的方向
- 将结果应用到SceneKit模型上
command queue包含了一个自定义的Metal compute shader,这原本是c语言版本写的,但Metal是基于c++的,所以很容易就迁移过来. 还有另一个compute shader来计算表面的法线(normal).所以最终得到一面平滑的旗帜,而又无需大量多边形.每一帧画面中,shader会匹配几何体的位置,这样就充分发挥了GPU的潜力,不会影响CPU.
compute shader的作用:
- 计算网格(mesh)上的力
- 产生顶点和阴影
动态音频
游戏中的音频可以让游戏更加吸引人,更加真实.
- 3D空间中有位置信息的声音,让效果更真实
- 注意不要增加太多app大小,提高运行速度
- 利用AVFoundation对MIDI乐器的支持,利用AVAudio中的Unit Midi Instrument,将录制的基本音效与AU preset文件结合,来创建一个自定义的Midi乐器.
在游戏中,我们并不是简单将音效放在固定位置进行播放,而是会根据不同的速度和位置,播放出不同的音效,比如下面所示,在拉弹弓时,速度不同,拉力不同,音效就不同,给用户以不同的反馈.
对于游戏中的碰撞音效,我们也是这样处理的,每个人在自己的设备上,根据不同位置和类型的碰撞,在不同位置播放稍加修饰的MID音效,
在每个人的设备上播放音效,也确保了音效能根据设备的不同位置,而不同,远处声音小,近处声音大.
开发小技巧
最后,还有一个开发中的小技巧,将swift作为脚本语言使用,可以在运行时,根据plist文件来加载游戏中的音效.swift脚本的开发及测试,可以用playground来完成,完成后只要在最上方加上#!/usr/bin/swift
即可.
#!/usr/bin/swift
// Audio asset processing
import Foundation
if CommandLine.argc < 3 {
print("Usage: clean_preset <path/to/input_preset> <path/to/output_preset>")
exit(1)
}
let inputPath = CommandLine.arguments[1]
let outputPath = CommandLine.arguments[2]
let inputURL = URL(fileURLWithPath: inputPath)
let outputURL = URL(fileURLWithPath: outputPath)
var plist: [String: Any]?
// ...
总结
本session中,苹果以SwiftShot这个demo为例,介绍了AR中的很多实用型技术:
- SwiftShot中的状态和世界地图分享处理
- Multipeer Connectivity网络连接
- 物理数据BitStream编码
- 素材管理
- 用shader模拟旗帜效果
- 音效的使用