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

165 阅读13分钟

今年的 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编码数值
  • 最小化尺寸,快速序列化和反序列化
  • 为网络通信构建的二进制数据
  • 不适用于持久化储存
  • 不适用于数据结构变化的场景

BitStreamCodable

具体的实现过程,是两个协议BitStreamEncodableBitStreamDecodable,而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模拟旗帜效果
  • 音效的使用