4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

株式会社ゆめみAdvent Calendar 2024

Day 3

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

Last updated at Posted at 2024-12-02

この記事は株式会社ゆめみ Advent Calendar 2024への寄稿です。

この記事で紹介するコードは、案件の都合上 SCNViewUIViewRepresentable でラップしてSwiftUIの環境で動かすものとなりますが、本記事の目的はあくまでカメラの使い方の紹介のため、具体的に SCNView の説明やSwiftUIでの使い方は本記事では割愛しますので、ぜひこちらの記事をご参考ください。

また、本記事でご使用のモデルはアップル公式サイトからダウンロードできます。

目的

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

正面 左側面 右側面 左前上 右前上
Simulator Screenshot - iPhone 16 Pro - 2024-12-01 at 23.02.40.png Simulator Screenshot - iPhone 16 Pro - 2024-12-01 at 23.02.41.png Simulator Screenshot - iPhone 16 Pro - 2024-12-01 at 23.02.44.png Simulator Screenshot - iPhone 16 Pro - 2024-12-01 at 23.02.46.png Simulator Screenshot - iPhone 16 Pro - 2024-12-01 at 23.02.48.png

前提実装

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

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) {
        // ...
    }
}

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

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

見せる角度の制御

方法1:愚直にカメラの positioneularAngle を設定する

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

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

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

方法2:SCNLookAtConstraint の力を借りる

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

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)
-        }
-    }

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

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

by_focus.gif

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

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

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

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

by_focus_with_gimbal_lock.gif

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

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

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

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

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

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

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

めでたしめでたし。

最後に

も、もう3D怖くない((((;゜Д゜))))ガクガク

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?