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

233 阅读7分钟

今年的 Session 605 - Inside SwiftShot: Creating an AR Game 上演示了一个叫SwiftShot的多人游戏Demo
其中涉及到的内容非常多,上篇是对wwdc605内容的简单总结,而本篇是对官方未说明的部分进行补充说明。
注释版代码

说明

ARKit文章目录

本文是对官方 demo 内容的讲解。是对上一篇官方解读的补充。


文件结构

其他技术点

WWDC605 中对 GameplayKit,metal 旗帜模拟,AVAudio 处理 Midi 音频都只是简单几句带过,相信你和我一样都对此很感兴趣,我们就一起研究一下代码吧。

GameplayKit

GamePlayKit给游戏开发者带来了全新的游戏架构(“实体组件系统”)和一些通用模式(比如:状态机,Goal-agent-behavior系统等),同时它还提供了大量游戏算法,比如寻路算法,模糊逻辑和规则系统等。

在这个官方 Demo 中没有用到人工智能策略和复杂逻辑,所以我们专注于了解GKEntityGKComponent两个类:

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.swiftFlag.metal两个文件里面。

先看Flag.swift文件中,有两个类ClothSimMetalNodeMetalClothSimulator

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.

updateNormalsmoothNormal只是求向量叉乘再累加的,比较容易理解。

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.swiftFlag.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 文件中也给出流程解释,我也在项目中进行了翻译。

注释版代码