SCNViewのカメラをコードでオブジェクトを中心に回転させる方法

:::note
この記事は[株式会社ゆめみ Advent Calendar 2024](https://qiita.com/advent-calendar/2024/yumemi)への寄稿です。
:::

:::note
この記事で紹介するコードは、案件の都合上 `SCNView` を `UIViewRepresentable` でラップしてSwiftUIの環境で動かすものとなりますが、本記事の目的はあくまでカメラの使い方の紹介のため、具体的に `SCNView` の説明やSwiftUIでの使い方は本記事では割愛しますので、ぜひ[こちらの記事](https://qiita.com/moccow/items/e3e06ded3557a9af813d)をご参考ください。

また、本記事でご使用のモデルは[アップル公式サイト](https://developer.apple.com/jp/augmented-reality/quick-look/)からダウンロードできます。
:::

# 目的

案件で、下記のように「モデルをいくつかの角度からユーザに見せる」、と言う要件がありました。角度の変更は全てプログラムで制御する必要があって、本記事ではその要件を単純化して、ボタンをタップすると該当の角度に変えるようにしたいと思います。

| 正面 | 左側面 | 右側面 | 左前上 | 右前上 |
|:---:|:---:|:---:|:---:|:---:|
| ![Simulator Screenshot - iPhone 16 Pro - 2024-12-01 at 23.02.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/7463357b-6349-6129-88e5-4c2fe2a62bc4.png) | ![Simulator Screenshot - iPhone 16 Pro - 2024-12-01 at 23.02.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/784d92b2-ec00-b424-91fd-c98261d84cd6.png) | ![Simulator Screenshot - iPhone 16 Pro - 2024-12-01 at 23.02.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/31ffd67c-5234-cbbe-6bc6-fbe857bba16c.png) | ![Simulator Screenshot - iPhone 16 Pro - 2024-12-01 at 23.02.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/0b3972ba-8646-f0b6-9511-26dda0b49c2a.png) | ![Simulator Screenshot - iPhone 16 Pro - 2024-12-01 at 23.02.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/e284be75-308c-cebf-fea1-e2b121a46261.png) |

# 前提実装

まず最低限の前提実装として、下記のように、モデルをステージの中央に置いて、そのモデルに向けたカメラノードをルートに置いてあるとします。

```swift:ModelView.swift
struct ModelView: UIViewRepresentable {
    private var cameraNodeName: String { "CameraNode" }
    
    var cameraAngle: CameraAngle
    
    func makeUIView(context: Context) -> SCNView {
        let scnView = SCNView()
        setup(scnView: scnView)
        moveCamera(in: scnView, to: cameraAngle)
        return scnView
    }

    func updateUIView(_ scnView: SCNView, context: Context) {
        moveCamera(in: scnView, to: cameraAngle)
    }

    func setup(scnView: SCNView) {
        let modelURL = Bundle.main.url(forResource: "toy_drummer_idle", withExtension: "usdz")!
        let modelScene = try! SCNScene(url: modelURL)
        scnView.scene = modelScene
        scnView.autoenablesDefaultLighting = true
        
        let camera = SCNCamera()
        let cameraNode = SCNNode()
        cameraNode.camera = camera
        cameraNode.name = cameraNodeName

        scnView.scene!.rootNode.addChildNode(cameraNode)
        scnView.pointOfView = cameraNode
    }

    func moveCamera(in scnView: SCNView, to cameraAngle: CameraAngle) {
        // ...
    }
}
```

そして規定のカメラアングルはこのように定義されます：

```swift:CameraAngle.swift
enum CameraAngle: CaseIterable, Identifiable {
    case front
    case leftSide
    case rightSide
    case leftFrontUpper
    case rightFrontUpper
    var id: Self { self }
    var title: String {
        // ...
    }
}
```

# 見せる角度の制御

## 方法1：愚直にカメラの `position` と `eularAngle` を設定する

まず一番すぐに思いつく方法としては、カメラの `.position` と `.eularAngle` を設定する方法ですね。`position` はカメラの位置、そして `eularAngle` はカメラの回転です。両方とも `SCNVector3` 型で、`x` `y` `z` の3つのプロパティーを持ちます。ご想像の通り、`position` の場合はそれぞれX軸（左右）、Y軸（上下）とZ軸（前後）の座標で、そして `eularAngle` はX軸、Y軸とZ軸を中心とした回転角度（ラジアン）です。とすると、`CameraAngle` をこのように拡張して、`moveCamera` で設定できると思います：

```diff_swift:ModelView.swift
    func moveCamera(in scnView: SCNView, to cameraAngle: CameraAngle) {
-        // ...
+        let cameraNode = scnView.scene!.rootNode.childNode(withName: cameraNodeName, recursively: false)!
+        cameraNode.position = cameraAngle.cameraPosition
+        cameraNode.eulerAngles = cameraAngle.cameraEularAngle
    }
}

+private extension CameraAngle {
+    var cameraPosition: SCNVector3 {
+        switch self {
+        case .front:
+            .init(x: 0, y: 6, z: 20)
+        case .leftSide:
+            .init(x: -20, y: 6, z: 0)
+        case .rightSide:
+            .init(x: 20, y: 6, z: 0)
+        case .leftFrontUpper:
+            .init(x: -16, y: 18, z: 8)
+        case .rightFrontUpper:
+            .init(x: 16, y: 18, z: 8)
+        }
+    }
+    
+    var cameraEularAngle: SCNVector3 {
+        switch self {
+        case .front:
+            .init(x: 0, y: 0, z: 0)
+        case .leftSide:
+            .init(x: 0, y: -.pi/2, z: 0)
+        case .rightSide:
+            .init(x: 0, y: .pi/2, z: 0)
+        case .leftFrontUpper:
+            .init(x: -.pi*0.17, y: -.pi*0.35, z: 0)
+        case .rightFrontUpper:
+            .init(x: -.pi*0.17, y: .pi*0.35, z: 0)
+        }
+    }
+}
```

こうすれば、このモデルが下記のように角度を変えて見れます：

![by_eular_angle.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/79234d31-ec8c-685f-b6fc-649335ee53fe.gif)

ただしこの方法では一つだけ微妙な問題点があります：そう `eularAngle` がわかりにくいです。今回の場合は角度が比較的に単純なので割とさっとできたように思えますが、実際に自分で角度を変えてみて、特に `.leftFrontUpper` や `.rightFrontUpper` のような2つ以上の軸を中心とした回転があった場合、3Dの開発に慣れた方ならおそらくそこまで苦ではないですが、そうでない方（例えば筆者とか）にとってトライ＆エラーで最適な数字が見つかるまでなかなか時間がかかります。

## 方法2：`SCNLookAtConstraint` の力を借りる

実はまさにその `eularAngle` がなかなか設定しにくい問題を見越してるからか、SceneKitではカメラの向け先を限定する方法があります：そう `SCNLookAtConstraint` を使う方法です。この方法を使って、最初のカメラのセットアップで向け先を設定すれば、あとはカメラの `position` をどう変えても、`eularAngle` が勝手に変わって常に我々の見たいものが見えます：

```diff_swift:ModelView.swift
    func setup(scnView: SCNView) {
        // ...
        let camera = SCNCamera()
        let cameraNode = SCNNode()
        cameraNode.camera = camera
        cameraNode.name = cameraNodeName

+        let focusPoint = SCNNode()
+        focusPoint.position = SCNVector3(x: 0, y: 6, z: 0)
+        let constraint = SCNLookAtConstraint(target: focusPoint)
+        cameraNode.constraints = [constraint]
+
        scnView.scene!.rootNode.addChildNode(cameraNode)
        scnView.pointOfView = cameraNode
    }

    func moveCamera(in scnView: SCNView, to cameraAngle: CameraAngle) {
        let cameraNode = scnView.scene!.rootNode.childNode(withName: cameraNodeName, recursively: false)!
        cameraNode.position = cameraAngle.cameraPosition
-        cameraNode.eulerAngles = cameraAngle.cameraEularAngle
    }

// ...

-    var cameraEularAngle: SCNVector3 {
-        switch self {
-        case .front:
-            .init(x: 0, y: 0, z: 0)
-        case .leftSide:
-            .init(x: 0, y: -.pi/2, z: 0)
-        case .rightSide:
-            .init(x: 0, y: .pi/2, z: 0)
-        case .leftFrontUpper:
-            .init(x: -.pi*0.17, y: -.pi*0.35, z: 0)
-        case .rightFrontUpper:
-            .init(x: -.pi*0.17, y: .pi*0.35, z: 0)
-        }
-    }
```

:::note
このコードでは、向け先の設定にわざわざ新しいノードを作ったのは、モデルの中心とモデルの座標が必ずしも一致するとは限らないからです。今回のモデルの場合、テーブルとかの上に置くものなので、座標値は一番下になります、だからもしカメラの向け先を直接モデルに設定すると、カメラは常にモデルの足元に向けることになります。なのでそうならないように、モデルの中心を指す別のノードを作ってそれをカメラの向け先にする必要があります。
:::

このように向け先を常にモデルの中心に設定すれば、`position` を変えた時 `eularAngle` を設定しなくても、カメラは常にモデルの中心に向けます：

![by_focus.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/1b01f008-d607-d4a9-68a7-9756347ae90d.gif)

ただいざこうしてみたら、最初の正面も左側面右側面の時もまだ問題なかったのですが、斜め上からの視点にした途端なんか角度がすごい変になったり、そのあとまたカメラを正面とかに戻しても描画の角度が変になっちゃうんですよね…

でも大丈夫！実は今回のように常に地面に対して水平を保ちたい場合は、`SCNLookAtConstraint` には `isGimbalLockEnabled` と言うプロパティーがあって、これを `true` にすれば、カメラは常に地面に対して水平を保ちますので、非常に使いやすいと言えます。

```diff_swift:ModelView.swift
        let constraint = SCNLookAtConstraint(target: focusPoint)
+        constraint.isGimbalLockEnabled = true
        cameraNode.constraints = [constraint]
```

これでカメラアングルをどう変えても、描画の角度が変わりませんね！

![by_focus_with_gimbal_lock.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/04805be8-c7a0-fb5f-8a57-d34c83cba9bb.gif)

:::note
`isGimbalLockEnabled` を `true` にしないと角度が変になってしまうのも、前の章で `eularAngle` の設定が割と大変な理由も、そもそも `eularAngle` は生のデータとしてそのまま保持しているわけではないからです。[アフィン変換の知識](https://qiita.com/cyomox/items/bdb5d86b76b522cc6249)がある方なら想像がつくかと思いますが、このような描画は基本アフィン変換のような行列変換で行っており、`SCNNode` もその例外ではなく、内部では `.transform` プロパティーとして `SCNMatrix4` 型で保存されています。そしてこの行列はあくまで頂点座標の演算情報しかないので、`eularAngle` などのような情報は直接 `SCNMatrix4` から読み取れないのです。それどころか、行列は掛け算において交換則は成立しないので、`position` と `eularAngle` を行う順番を変えたり、操作を何度も重ねたりすると、最終的にできた `transform` プロパティーの値が支離滅裂になったりする可能性もあるから、なかなか一発で決まらなかったり何度か操作すると訳わからなくなったりします。
:::

ところが、これではまだまだ残念ながら話が終わりません。これまでのサンプルGIFで見た通り、この変換は全て瞬時に行われていて、UI面としてはあまり良くなく、やはりアニメーションが欲しいですよね。と言うわけでアニメーションを入れてみましょう：

```diff_swift:ModelView.swift
    func updateUIView(_ scnView: SCNView, context: Context) {
-        moveCamera(in: scnView, to: cameraAngle)
+        moveCamera(in: scnView, to: cameraAngle, animated: true)
    }

// ...

-    func moveCamera(in scnView: SCNView, to cameraAngle: CameraAngle) {
+    func moveCamera(in scnView: SCNView, to cameraAngle: CameraAngle, animated: Bool = false) {
        let cameraNode = scnView.scene!.rootNode.childNode(withName: cameraNodeName, recursively: false)!
+        if animated {
+            let moveAction = SCNAction.move(to: cameraAngle.cameraPosition, duration: 0.5)
+            moveAction.timingMode = .easeInEaseOut
+            cameraNode.runAction(moveAction)
+        } else {
            cameraNode.position = cameraAngle.cameraPosition
+        }
    }
```

するとどうでしょう？

![by_focus_animated.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/f349f72f-e033-a117-2ea8-f00df487dcac.gif)

そう、見ての通り、角度切り替え時のアニメーションの動きは、あまり気持ちのいいものではないでしょう、特に左側面から右側面に変わる時一回モデルの中にまで入ってしまって画面に一瞬真っ黒なものも映ってしまいます。なぜこうなるのかというと、SceneKitはカメラの `position` を最短経路で動かそうとするから、2点間の最短経路は当然直線なので、どうしてもこうなってしまいます。

## 方法3：中間ノードの力を借りる

ではこの問題を解決するにはどうすればいいかというと、今まで我々はずっとカメラがシーンに置いての直接な `position` を設定してきましたが、でも我々がやりたいのはやはり「回転」と「中心までの距離」を別々で変えたいだけですよね。そう、いわゆる「[極座標系](https://manabitimes.jp/math/1235)」的な考え方です。ただ `position` が受け付けるのはあくまで直交座標系の座標だけだし、仮に一回極座標を直交座標に変換したとしても、SceneKitの内部ではそれを直交座標系で処理する以上、カメラワークの問題は変わりません。ここで登場するのが「中間ノード」的なものです。そう、我々はカメラを直接シーンに置くのではなく、その中間ノードに一旦置いて、そうすればその中間ノードの回転と、カメラとその中間ノードまでの距離を別々で設定すれば、極座標系っぽいことができますよね！と言うわけで早速直して見ましょう

```diff_swift:ModelView.swift
struct ModelView: UIViewRepresentable {
    
    private var cameraNodeName: String { "CameraNode" }
+    private var cameraPivotNodeName: String { "CameraPivotNode" }

// ...

    func setup(scnView: SCNView) {
        // ...
        
        let camera = SCNCamera()
        let cameraNode = SCNNode()
        cameraNode.camera = camera
        cameraNode.name = cameraNodeName
-        let focusPoint = SCNNode()
-        focusPoint.position = SCNVector3(x: 0, y: 6, z: 0)
-        let constraint = SCNLookAtConstraint(target: focusPoint)
-        cameraNode.constraints = [constraint]
+        let cameraPivotNode = SCNNode()
+        cameraPivotNode.name = cameraPivotNodeName
+        cameraPivotNode.position = SCNVector3(x: 0, y: 6, z: 0)

-        scnView.scene!.rootNode.addChildNode(cameraNode)
+        cameraPivotNode.addChildNode(cameraNode)
+        scnView.scene!.rootNode.addChildNode(cameraPivotNode)
        scnView.pointOfView = cameraNode
    }

    func moveCamera(in scnView: SCNView, to cameraAngle: CameraAngle, animated: Bool = false) {
-        let cameraNode = scnView.scene!.rootNode.childNode(withName: cameraNodeName, recursively: false)!
+        let cameraPivotNode = scnView.scene!.rootNode.childNode(withName: cameraPivotNodeName, recursively: false)!
+        let cameraNode = cameraPivotNode.childNode(withName: cameraNodeName, recursively: false)!
        if animated {
-            let moveAction = SCNAction.move(to: cameraAngle.cameraPosition, duration: 0.5)
-            moveAction.timingMode = .easeInEaseOut
-            cameraNode.runAction(moveAction)
+            let duration: TimeInterval = 0.5
+            let pivotRotateAction = SCNAction.rotate(to: cameraAngle.cameraPivotAngle, duration: duration)
+            let cameraMoveAction = SCNAction.move(to: cameraAngle.cameraPosition, duration: duration)
+            pivotRotateAction.timingMode = .easeInEaseOut
+            cameraMoveAction.timingMode = .easeInEaseOut
+            cameraPivotNode.runAction(pivotRotateAction)
+            cameraNode.runAction(cameraMoveAction)
        } else {
+            cameraPivotNode.eulerAngles = cameraAngle.cameraPivotAngle
            cameraNode.position = cameraAngle.cameraPosition
        }
    }

// ...

private extension CameraAngle {
+    var cameraPivotAngle: SCNVector3 {
+        switch self {
+        case .front:
+                .init(x: 0, y: 0, z: 0)
+        case .leftSide:
+                .init(x: 0, y: -.pi/2, z: 0)
+        case .rightSide:
+                .init(x: 0, y: .pi/2, z: 0)
+        case .leftFrontUpper:
+                .init(x: -.pi*0.17, y: -.pi*0.35, z: 0)
+        case .rightFrontUpper:
+                .init(x: -.pi*0.17, y: .pi*0.35, z: 0)
+        }
+    }
    var cameraPosition: SCNVector3 {
        switch self {
-        case .front:
-            .init(x: 0, y: 6, z: 20)
-        case .leftSide:
-            .init(x: -20, y: 6, z: 0)
-        case .rightSide:
-            .init(x: 20, y: 6, z: 0)
-        case .leftFrontUpper:
-            .init(x: -16, y: 18, z: 8)
-        case .rightFrontUpper:
-            .init(x: 16, y: 18, z: 8)
+        case .front,
+             .leftSide,
+             .rightSide:
+            .init(x: 0, y: 0, z: 20)
+        case .leftFrontUpper,
+             .rightFrontUpper:
+            .init(x: 0, y: 0, z: 22)
        }
    }
}

+private extension SCNAction {
+    static func rotate(to eularAngle: SCNVector3, duration: TimeInterval) -> SCNAction {
+        SCNAction.rotateTo(
+            x: CGFloat(eularAngle.x),
+            y: CGFloat(eularAngle.y),
+            z: CGFloat(eularAngle.z),
+            duration: duration
+        )
+    }
+}
```

今回のコードでは、またカメラの向き先として、`cameraAngle` に代わって `cameraPivotAngle` を復活させましたが、設定対象が `cameraPivotNode` に変わって、このノードの `position` は一旦設定したら永遠に変わりませんし、カメラ自身も回転する必要がなくなるから `SCNLookAtConstraint` の必要も無くなるし、そしてそれがなくてもカメラが常にい中心に向くから角度の微調整のトライ＆エラーのコストもだいぶ減ります。何よりカメラとこのノードまでの距離だけ直行座標のZ軸の数値で設定するので、角度変わる時のアニメーションは `cameraPivotNode` の回転がメインとなり、これでアニメーションが下記のようにとてもキレイになりました：

![by_pivot.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63998/0d9d4f8e-b887-bf8b-91d1-910e12da6eb3.gif)

めでたしめでたし。

# 最後に

も、もう3D怖くない((((；゜Д゜))))ｶﾞｸｶﾞｸ