今年的 Session 605 - Inside SwiftShot: Creating an AR Game 上演示了一个叫SwiftShot的多人游戏Demo
其中涉及到的内容非常多,上篇是对wwdc605内容的简单总结,而本篇是对官方未说明的部分进行补充说明。
注释版代码
说明
本文是对官方 demo 内容的讲解。是对上一篇官方解读的补充。
文件结构
其他技术点
WWDC605 中对 GameplayKit,metal 旗帜模拟,AVAudio 处理 Midi 音频都只是简单几句带过,相信你和我一样都对此很感兴趣,我们就一起研究一下代码吧。
GameplayKit
GamePlayKit给游戏开发者带来了全新的游戏架构(“实体组件系统”)和一些通用模式(比如:状态机,Goal-agent-behavior系统等),同时它还提供了大量游戏算法,比如寻路算法,模糊逻辑和规则系统等。
在这个官方 Demo 中没有用到人工智能策略和复杂逻辑,所以我们专注于了解GKEntity和GKComponent两个类:
GKEntity类(实体):可以容纳很多组件的容器,根据自己的需求来加入相应的Component组件。 GKComponent类(组件):代表一种功能和行为,不同的组件代表不同的功能。
实用功能: (1)方便通过聚合多种组件,构建复杂的新实体Entity。 (2)不同的实体GKEntity,通过聚合不同种类的组件GKComponent来实现。 (3)不必重复写组件,组件可以复用,也易于扩展。 (4)实体可以实现动态添加组件,以动态获得或者删除某些功能。
创建组件时,通过继承可复用部分进行子类化创建,然后创建GKEntity并使用addComponent:方法进行组件捆绑。
在runtime运行时,基于组件开发的游戏需要周期性调用逻辑方法,所以我们可以使用update/render loop相关方法,比如Spritekit中的update:方法或SceneKit中的renderer: updateAtTime:方法,或基于CADisplayLink的timer自定义一个渲染器为每一个组件进行更新。Gameplaykit提供了两种机制去调用update更新:
• 实体:调用每个实体的updateWithDeltaTime:方法,去更新每一个组件,如果游戏中实体和组件较少,这个方法会很快速。
• 组件:通过GKComponentSystem来处理特定的组件类实例,当你调用updateWithDeltaTime:方法时,它会更新它所管理的所有组件。因为一个组件系统(GKComponentSystem)不需要了解你的游戏层次结构,这个功能可以很好地在复杂的对象关系中运行。
整个逻辑是:GameViewController是最上层,各种Components(GKComponent)是最低层组件,GameObject(GKEntity)对应游戏中的实体:
GameViewController-->GameManager-->GameObjectManager-->GameObject-->Components
metal 实现旗帜模拟
实现是在Flag.swift和Flag.metal两个文件里面。
先看
Flag.swift文件中,有两个类ClothSimMetalNode和MetalClothSimulator。
ClothSimMetalNode只有一个作用:在init方法中创建一个指定尺寸的几何体,并创建顶点,法线,uv,还有顶点索引。
MetalClothSimulator两个作用:在init方法中创建管线并关联三个 shader;传递数据给管线中 shader。
// 与 Flag.metal 中的三个 shader 关联起来
functionClothSim = defaultLibrary.makeFunction(name: "updateVertex")!
functionNormalUpdate = defaultLibrary.makeFunction(name: "updateNormal")!
functionNormalSmooth = defaultLibrary.makeFunction(name: "smoothNormal")!
// 创建计算管线
do {
pipelineStateClothSim = try device.makeComputePipelineState(function: functionClothSim)
pipelineStateNormalUpdate = try device.makeComputePipelineState(function: functionNormalUpdate)
pipelineStateNormalSmooth = try device.makeComputePipelineState(function: functionNormalSmooth)
} catch {
fatalError("\(error)")
}
最终,deform(_ mesh: ClothSimMetalNode, simData: SimulationData)将所有数据传递给管线及对应的 shader 中。
三个着色器中updateVertex计算旗帜的力和变形;
updateNormal计算每个点的法线;
smoothNormal取每个点与周围点的法线,取平均值得到平滑的法线;
所以最终得到一面平滑的旗帜,而又无需大量多边形.每一帧画面中,shader会匹配几何体的位置,这样就充分发挥了GPU的潜力,不会影响CPU.
updateNormal和smoothNormal只是求向量叉乘再累加的,比较容易理解。
updateVertex涉及了物理学,受力与加速度,速度,位移之间的关系。而且内部布料拉力的计算是调用ApplyForce(inVertices, inVelocities, pos, vel, x, y, dx, dy, str)函数获取的。
// 定义的宏,以方便调用,当满足condition条件时,调用ApplyForce(inVertices, inVelocities, pos, vel, x, y, dx, dy, str)并累加到 force 上
#define APPLY_FORCE(condition, dx, dy, str) \
if(condition) \
{ \
force += ApplyForce(inVertices, inVelocities, pos, vel, x, y, dx, dy, str); \
}
// 计算内部弹力(拉力)的函数
float3 ApplyForce(const device float3* positions,
const device float3* velocities,
const float3 pos,
const float3 vel,
const uint x,
const uint y,
const uint dx,
const uint dy,
const float constraintStrength)
{
// Get the distance between the point and its neighbour of interest
// 获取点与其邻近点之间的距离(3D 空间中的距离,即变形程度向量)
float3 deltaP = GetPosition(positions, x+dx, y+dy) - pos;
float len = length(deltaP);
float nominalLen = sqrt( float(dx*dx+dy*dy) );//名义上的距离( 2D平面未拉伸扭转时的距离)
float sf = (len - nominalLen) * constraintStrength; // Spring force弹力
// Get the velocity difference between the same two points:
// 获得相同两点之间的速度差异:
float3 deltaV = GetVelocity(velocities, x+dx, y+dy) - vel;
float invLen = 1/len;//距离的倒数,相当于除以距离(3D 空间的长度)
// Dot that with the positional delta to get spring motion:
// 将其与位置偏移量点乘,得到弹性运动:
float sv = dot(deltaP, deltaV) * invLen;
// 点乘又叫向量的内积、数量积,是一个向量和它在另一个向量上的投影的长度的乘积;是标量。
// 点乘反映着两个向量的“相似度”,两个向量越“相似”,它们的点乘越大。
// 下面是计算力的公式:sf 是布料在 3D 空间变形产生的弹力;sv 是布料点运动速度及方向不同,产生的力;fric 是运动摩擦,这里值取 1;del/invLen 就是变形向量除以长度,即单位长度变形量(变形程度)
// Force = -(sf + sv * fric) * del/invLen.
sf += sv; // friction = 1
sf *= invLen;
deltaP *= sf;
return deltaP;
}
为了方便大家理解,我已经对Flag.swift和Flag.metal添加了详细的注释。建议直接阅读注释版源码。
MIDI音效
其中MusicCoordinator是播放音乐的,只需要普通播放器就够了,所以使用的是AVAudioPlayer。
SFXCoordinator是播放音效的,需要考虑播放位置,发音物体的材质等,所以它持有了AudioSampler的子类CatapultAudioSampler和CollisionAudioSampler。它直接操作触发CatapultAudioSampler来生成弹弓的各种音效。而CollisionAudioSampler则是作为GameAudioComponent的一部分被触发的。
这样,当拉伸弹弓时,SFXCoordinator会根据拉伸的长度,生成不同的参数,并调用CatapultAudioSampler生成不同的声音。而CatapultAudioSampler最终是依靠内部的AUSamplerNode来真正改变声音效果的。
// 简化后的代码如下
let audioNode: AUSamplerNode //真正改变声音是靠它
//开始拉弹弓
func startStretch() {
audioNode.sendController(11, withValue: 127, onChannel: 0)
play(note: Note.stretch, velocity: 105, autoStop: false)
pitchBend = 0
}
//播放音效
func play(note: UInt8, velocity: UInt8, autoStop: Bool = true) {
guard loaded.condition == 1 else {
os_log(.error, "Cannot play because loading is not complete")
return
}
audioNode.startNote(note, withVelocity: velocity, onChannel: 0)
if autoStop {//几秒后自动停止
after {
self.audioNode.stopNote(note, onChannel: 0)
}
}
}
//设置弹弓弯曲度,同时会改变声音的参数,产生不同的音效
var pitchBend: Float = 0 {
didSet {
// MIDI pitch bend is a 14-bit value from 0..16383, with zero pitch bend
// applied at 8192.
let intVal = UInt16(8192 + clamp(pitchBend, -1, 1) * 8191)
audioNode.sendPitchBend(intVal, onChannel: 0)
}
}
我们可以看到,最核心的代码只有 4 行,且实际上都是由AUSamplerNode类完成的:
//采样器创建时加载配置,presetUrl值是 ball.aupreset 等
audioNode.loadPreset(at: presetUrl)
//播放时,提前设置参数
audioNode.sendController(11, withValue: 127, onChannel: 0)
//开始播放
audioNode.startNote(note, withVelocity: velocity, onChannel: 0)
//根据弯曲度改变声音
audioNode.sendPitchBend(intVal, onChannel: 0)
//停止播放
audioNode.stopNote(note, onChannel: 0)
利用AVFoundation对MIDI乐器的支持,利用AVAudio中的Unit Midi Instrument,将录制的基本音效与AU preset文件结合,来创建一个自定义的Midi乐器.
总结
苹果的这个 Demo 项目是目前为止,官方已公开 AR 相关项目代码中,结构最复杂,涉及知识点最多,代码量也最大的项目。官方为了方便大家理解学习,专门在 WWDC 上讲了整体逻辑和网络相关,AR 相关知识(见我上一篇文章),另外还在项目的 README.md 文件中也给出流程解释,我也在项目中进行了翻译。