ARKit+GameplayKit で 例の飛行機の編隊飛行

ARKit+GameplayKit の動きを確認するため Xcode の ARKitプロジェクトに用意されている飛行機を空に飛ばしてみた。

＜完成イメージ＞
![demo2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/e7c08608-56b8-87d2-431f-a9c792cb99b9.png)　![demo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/82bb8910-c782-77a5-e850-15e870fcebae.png)
![demo2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/64244045-83e1-7581-c6f6-4a3813fce729.gif)　![demno.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/cd41c5a3-9e04-87aa-ae67-01aec3a2fa38.gif)

GameplayKit...思いかけずいい動きをしてくれる。
このサンプルは平面検知した後に表示される赤い球にスマホを向けて「発進」ボタンタップで飛行機を飛ばすことができる。

## GameplayKit とは

[Apple の GameplayKit](https://developer.apple.com/documentation/gameplaykit) のAPIドキュメントより
> Architect and organize your game logic. Incorporate common gameplay behaviors such as random number generation, artificial intelligence, pathfinding, and agent behavior.
> (Google翻訳) ゲームロジックを設計および整理します。 乱数生成、人工知能、経路探索、エージェントの動作など、一般的なゲームプレイの動作を組み込みます。

本記事ではエージェント機能（Agents, Goals, and Behaviors）を使っている。
SceneKitでゲームを作る人は少ないと思われるが、ARKit＋SceneKitでAR空間内のキャラクターに動きをつけるのに手軽に使えそうなフレームワークである（ちなみにSceneKitに依存していない）。自力でロジックを構築するのは面倒そうなルート探索機能（Pathfinding）などは用途がありそう。
GameplayKit の全体については次の参考にさせていただいたサイトにサンプルがあるので、そちらをみていただければと思います。
参考サイト：

- [ゲームロジック用フレームワーク「GameplayKit」のサンプルを作成しました](https://spritekit.tnantoka.com/update/2015/10/03/gameplaykit-sandbox/)
- [3DゲームのAIをiOSのSceneKitとGameplayKitで作る基本](https://www.atmarkit.co.jp/ait/articles/1707/10/news019.html)

参考サイトの中に３Dエージェントを利用したものがあったが、エージェントの姿勢(transform)まで設定しているものが見つからなかったので、ARKitとの組み合わせも含めて動作を確認した。

## エージェント機能（Agents, Goals, and Behaviors）

エージェント機能は 自律的にキャラクターを動作させるための仕組みを提供する。
今回のサンプルでは、自立して飛行する（ようにみえる）飛行機の位置と姿勢を求めるのに利用している。
飛行機は「ターゲット（勝手に作ったエージェント。本サンプルでは赤い球）」と呼ぶオブジェクトに向かって移動するようにしている。
このターゲットを一定時間で上へ左へと移動させることで、飛行機が上に向かったり、左に向かったり、をさせている。

以下は利用している主なクラス。

#### GKAgent3Dクラス

自律的に移動するキャラクターを表す。
本サンプルでは「ターゲット」と「飛行機」の２種類。
以下は、飛行機に設定している内容。


```swift
// 加速度（m/s/s)
$0.agent.maxAcceleration = Float.random(in: 6...8)
// 最大速度（m/s)
$0.agent.maxSpeed = Float.random(in: 11...12)
// 最初の位置
$0.agent.position = $0.node.simdPosition
// 最初の姿勢。
// rotation のX（right）が進行方向のようなので、１行目がワールド座標のZの奥側（マイナス）を見るように設定
$0.agent.rotation = simd_float3x3(
    SIMD3<Float>( 0, 0,-1),
    SIMD3<Float>( 0, 1, 0),
    SIMD3<Float>( 1, 0, 0))
// 僚機と接触しないようにエージェントの半径を設定。この半径でぶつからないように制御される
$0.agent.radius = 1.0
```
よくわからないのが rotation（APIドキュメントには「The orientation of the agent in 3D space.
としか記載がない）。
エージェントが最初に向いている向きを設定する必要がある（設定しないと、エージェントが向きを変える動作が最初に入ってしまう）のだが、どこを向けておけばよいか納得して設定できなかった。Agentの動作を確認すると、１行目(right)の方向が移動時のエージェントの正面としているように見える（実際、問題がないように見える）。なので、飛行機からみて赤い球がある方向となるZ軸の奥側（マイナス）を設定している。SceneKitは右手系なので、rightの向きに合わせて２行目(up)はY軸のプラス、３行目(forward)はX軸のプラスを設定で一応動作している。

#### GKGoalクラス
『任意のエージェントに向かう』、『任意のエージェントから遠ざかる』、『ランダムにうろつく』、『障害物を避ける』、といったエージェントの動作を表す。
このサンプルでは飛行機エージェントで利用していて次の２つのGKGoalを使っている。

```swift
// これは各飛行機がターゲットを見つけて向かう、という指定。
GKGoal(toSeekAgent: self.target.agent)
```

```swift
// これは僚機とぶつからないようにする、という指定。
GKGoal(toAvoid: agents, maxPredictionTime: 3)
```
`toAvoid` には飛行機エージェントの配列を指定。`maxPredictionTime` は、どのくらい前からぶつかることを予測し、それを回避するのか、という時間の指定。大きい値を設定すると、ぶつかりにくくなる。どのくらいが適当なのかは、エージェントの移動速度で変わってくると思うが、本サンプルでは 3 くらいでぶつからなかった。

ちなみに、本記事のタイトルを「編隊飛行」としたが、ちょっとバラバラに飛ぶ感じにしたかった（当初はミサイルで [板野サーカス](https://entry.cgworld.jp/terms/板野サーカス.html) に挑戦していた）ので、編隊飛行にマッチするであろう群れ（Flock）関連のGKGoalは利用しなかった。Flock関連の [GKGoal](https://developer.apple.com/documentation/gameplaykit/gkgoal) を使うと、複数のエージェントが一定の距離・角度を保ちつつ移動する指定ができる（っぽい。試していない）。
参考サイト：

- [Apple のサンプル AgentsCatalog: Using the Agents System in GameplayKit](https://developer.apple.com/library/archive/samplecode/AgentsCatalog/Introduction/Intro.html#//apple_ref/doc/uid/TP40016141-Intro-DontLinkElementID_2)

#### GKBehavior
`GKGoal` を取りまとめるクラス。
任意のエージェントに向かいつつ、エージェント間でぶつからないようにする、といった複数のGKGoal
を組み合わせることができる。


```swift
// ターゲットに向かいつつ、僚機との接触は避ける
$0.agent.behavior = GKBehavior(goals: [GKGoal(toSeekAgent: self.target.agent), avoid])
```
各GKGoalの影響度の重み付けもできるが今回のサンプルでは利用していない。

#### GKComponentSystem
エージェントの位置・姿勢を定期的に更新するためのクラス。
`GKComponentSystem` のインスタンスにエージェントを登録して利用する。

```swift
var agentSystem = GKComponentSystem(componentClass: GKAgent3D.self)
(略)
 ↓
// 管理対象のエージェント（ターゲットと各飛行機）を登録しておく
agentSystem.addComponent(gameObject.agent)
(略)
↓
// 描画フレーム毎にupdateを呼び出す
agentSystem.update(deltaTime: delta)
(略)
↓
// エージェントの位置・姿勢が更新されて delegateメソッド が呼び出される
func agentDidUpdate(_ agent: GKAgent) {
　・・・
}
```

## ターゲットの設定・更新処理

サンプルでは「ターゲット」というエージェントを作って、飛行機をターゲットに向かうようにしている。
ターゲットの設定は次のメソッドで行なっている。

```swift
private func setupTarget(scene: SCNScene) {
    // 見た目の目的地
    let sphere = SCNSphere(radius: 0.5)
    let material = SCNMaterial()
    material.diffuse.contents = UIColor.red
    sphere.materials = [material]
    let node = SCNNode(geometry: sphere)
    node.simdPosition = self.targetPositions[0].1   // 位置初期化
    scene.rootNode.addChildNode(node)
    // 位置計算用の目的地
    let gameObject = GameObject(node: node)
    gameObject.agent.position = node.simdPosition
    gameObject.agent.radius = 1.0
    // エージェントシステムに登録
    self.agentSystem.addComponent(gameObject.agent)
    self.target = gameObject
}
```

SceneKitのオブジェクトである `SCNNode` とGameplayKitの `GKAgent3D` をセットで管理したいため、`GameObject` という構造体を用意している。GameplayKit のお作法としては GKEntity + GKComponent で管理するのが正しいのだろうが、非常にシンプルなサンプルなので使わなかった。
ターゲットの見た目は半径50cmの赤い球にしている。ターゲットは見えている必要はないが動作確認しやすいので表示している。

このターゲットは一定時間で移動させている。
時間と位置の対応は次のテーブルで管理している。

```swift
// 時間[s]と位置のテーブル
let targetPositions: [(TimeInterval, SIMD3<Float>)] = [
    (2.0, SIMD3<Float>(0, 0, -25)),
    (6.0, SIMD3<Float>(0, 40, -25)),
    (9.0, SIMD3<Float>(30, 2, -10)),
    (13.0, SIMD3<Float>(-100, 2, -10)),
]
```

このテーブルをARフレーム毎にチェックし、ターゲットの位置を設定。

```swift
// ARフレームが更新された
func session(_ session: ARSession, didUpdate frame: ARFrame) {
    guard self.isButtonPressed else { return }

    let delta: TimeInterval
    if self.currentTime == 0 {
        // アニメーション開始
        delta = 0
        self.ships.forEach() {
            $0.node.isHidden = false
        }
    } else {
        delta = frame.timestamp - self.currentTime
    }
    self.currentTime = frame.timestamp
    
    // 位置テーブルから現在のターゲットの位置を取得
    let targetPosition = self.targetPositions.first(where: { $0.0 > self.animationTime })?.1
    if let pos = targetPosition {
        // ターゲットの位置を更新
        target.setPosition(pos)
    }
    self.animationTime += delta

    agentSystem.update(deltaTime: delta)
}
```

## 飛行機の位置・姿勢の更新処理

飛行機の位置・姿勢は `GKAgentDelegate` の `agentDidUpdate(GKAgent)` メソッドで更新。

```swift
class ViewController: UIViewController, ARSessionDelegate, GKAgentDelegate {
    // エージェント情報が更新された
    func agentDidUpdate(_ agent: GKAgent) {
        guard let agent = agent as? GKAgent3D else { return }
        let gameObject = self.ships.first(where: { $0.agent === agent })
        // 見た目の飛行機の位置をエージェントシステムが計算した位置に設定
        gameObject?.node.simdTransform = agent.transform
    }
}

extension GKAgent3D {
    var transform: simd_float4x4 {
        simd_float4x4(simd_float4( rotation.columns.0, 0),
                             simd_float4( rotation.columns.1, 0),
                             simd_float4( rotation.columns.2, 0),
                             simd_float4( position, 1))
    }
}
```

`GKAgent3D` には SCNNodeが持っているような `simd_float4x4` や `SCNMatrix4` の transformプロパティがない。position（vector_float3型。simd_float3のエイリアス）と rotation（matrix_float3x3型。simd_float3x3のエイリアス）を持っているので、この情報から`simd_float4x4`型の値を生成する処理を`GKAgent3D`のextensionに実装している。

## Scene Editor の設定

shipの子ノードをY軸90度回転しておく必要がある（こうしないと右を向いて飛んでいく）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/71605/1a7d6c65-9a03-2794-6cd7-28d7becdf5e4.png)
これも理由は不明。
この飛行機のジオメトリはもともとZ軸のプラス側を向いている。これを90度回転してX軸のプラス側を向くようにする。GameplayKitの3Dでの進行方向正面はX軸プラスということらしい。GKAgent3Dクラスの rotation もそうだが（ドキュメントのどこかに書いてあるのだろうが）Appleにはわかりやすい場所に説明・リンクを用意して欲しい。。。

## 最後に

謎な部分は残っているが、この短いコードで派手な動きができたと思う。
GameplayKit はSceneKitよりもさらにマイナーなフレームワークだと思うが、頭の引き出しに入れておくと、役立つことがあるかもしれない。

## 全体ソースコード

```swift:ViewController.swift
import ARKit
import UIKit
import SceneKit
import GameplayKit

class ViewController: UIViewController, ARSessionDelegate, GKAgentDelegate {

    @IBOutlet weak var scnView: ARSCNView!
    
    struct GameObject {
        var node: SCNNode!
        var agent = GKAgent3D()
        
        init(node: SCNNode) {
            self.node = node
        }
        
        func setPosition(_ position: SIMD3<Float>) {
            self.node.simdPosition = position
            self.agent.position = position
        }
    }
    
    var target: GameObject!
    var ships: [GameObject] = []
    // 時間[s]と位置のテーブル
    let targetPositions: [(TimeInterval, SIMD3<Float>)] = [
        (2.0, SIMD3<Float>(0, 0, -25)),
        (6.0, SIMD3<Float>(0, 40, -25)),
        (9.0, SIMD3<Float>(30, 2, -10)),
        (13.0, SIMD3<Float>(-100, 2, -10)),
    ]
    
    var agentSystem = GKComponentSystem(componentClass: GKAgent3D.self)
    var currentTime: TimeInterval = 0
    var animationTime: TimeInterval = 0
    
    private var isButtonPressed = false

    override func viewDidLoad() {
        super.viewDidLoad()
        
        let shipScene = SCNScene(named: "art.scnassets/ship.scn")!
        let ship = shipScene.rootNode.childNode(withName: "ship", recursively: true)!

        setupTarget(scene: self.scnView.scene)
        setupShips(ship: ship)
        // AR Session 開始
        self.scnView.session.delegate = self
        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = [.horizontal]
        self.scnView.session.run(configuration, options: [.removeExistingAnchors, .resetTracking])
    }

    // ARフレームが更新された
    func session(_ session: ARSession, didUpdate frame: ARFrame) {
        guard self.isButtonPressed else { return }

        let delta: TimeInterval
        if self.currentTime == 0 {
            // アニメーション開始
            delta = 0
            self.ships.forEach() {
                $0.node.isHidden = false
            }
        } else {
            delta = frame.timestamp - self.currentTime
        }
        self.currentTime = frame.timestamp
        
        // 位置テーブルから現在のターゲットの位置を取得
        let targetPosition = self.targetPositions.first(where: { $0.0 > self.animationTime })?.1
        if let pos = targetPosition {
            // ターゲットの位置を更新
            target.setPosition(pos)
        }
        self.animationTime += delta

        agentSystem.update(deltaTime: delta)
    }
    
    // エージェント情報が更新された
    func agentDidUpdate(_ agent: GKAgent) {
        guard let agent = agent as? GKAgent3D else { return }
        let gameObject = self.ships.first(where: { $0.agent === agent })
        // 見た目の飛行機の位置をエージェントシステムが計算した位置に設定
        gameObject?.node.simdTransform = agent.transform
    }
    
    // 発射ボタンが押された
    @IBAction func pressButton(_ sender: Any) {
        isButtonPressed = true
    }
}

extension ViewController {
    private func setupTarget(scene: SCNScene) {
        // 見た目の目的地
        let sphere = SCNSphere(radius: 0.5)
        let material = SCNMaterial()
        material.diffuse.contents = UIColor.red
        sphere.materials = [material]
        let node = SCNNode(geometry: sphere)
        node.simdPosition = self.targetPositions[0].1   // 位置初期化
        scene.rootNode.addChildNode(node)
        // 位置計算用の目的地
        let gameObject = GameObject(node: node)
        gameObject.agent.position = node.simdPosition
        gameObject.agent.radius = 1.0
        // エージェントシステムに登録
        self.agentSystem.addComponent(gameObject.agent)
        self.target = gameObject
    }
    
    private func setupShips(ship: SCNNode) {
        // １機目
        let ship1 = ship
        self.scnView.scene.rootNode.addChildNode(ship1)
        // ２機目。ジオメトリ は１機目のクローン
        let ship2 = ship1.clone()
        var ship2Position = ship1.simdPosition
        ship2Position.x -= 2
        ship2.simdPosition = ship2Position
        self.scnView.scene.rootNode.addChildNode(ship2)
        // ３機目。ジオメトリ は１機目のクローン
        let ship3 = ship1.clone()
        var ship3Position = ship1.simdPosition
        ship3Position.x += 2
        ship3.simdPosition = ship3Position
        self.scnView.scene.rootNode.addChildNode(ship3)

        self.ships.append(GameObject(node: ship1))
        self.ships.append(GameObject(node: ship2))
        self.ships.append(GameObject(node: ship3))
        let agents = self.ships.map { $0.agent }
        // 飛行機同士が接触しないようにするように、飛行機のリストを作る
        let avoid = GKGoal(toAvoid: agents, maxPredictionTime: 3)
    
        self.ships.forEach() {
            // 加速度（m/s/s)
            $0.agent.maxAcceleration = Float.random(in: 6...8)
            // 最大速度（m/s)
            $0.agent.maxSpeed = Float.random(in: 11...12)
            // 最初の位置
            $0.agent.position = $0.node.simdPosition
            // 最初の姿勢。
            // rotation のX（right）が進行方向のようなので、１行目がワールド座標のZの奥側（マイナス）を見るように設定
            $0.agent.rotation = simd_float3x3(
                SIMD3<Float>( 0, 0,-1),
                SIMD3<Float>( 0, 1, 0),
                SIMD3<Float>( 1, 0, 0))
            // 僚機と接触しないようにエージェントの半径を設定。この半径でぶつからないように制御される
            $0.agent.radius = 1.0
            $0.agent.delegate = self
            // ターゲットに向かいつつ、僚機との接触は避ける
            $0.agent.behavior = GKBehavior(goals: [GKGoal(toSeekAgent: self.target.agent), avoid])
            // エージェントシステムに登録
            self.agentSystem.addComponent($0.agent)
            
            $0.node.isHidden = true
        }
    }
}

extension GKAgent3D {
    var transform: simd_float4x4 {
        simd_float4x4(simd_float4( rotation.columns.0, 0),
                             simd_float4( rotation.columns.1, 0),
                             simd_float4( rotation.columns.2, 0),
                             simd_float4( position, 1))
    }
}
```