この記事は株式会社ゆめみ Advent Calendar 2024への寄稿です。
目的
案件で、下記のように「モデルをいくつかの角度からユーザに見せる」、と言う要件がありました。角度の変更は全てプログラムで制御する必要があって、本記事ではその要件を単純化して、ボタンをタップすると該当の角度に変えるようにしたいと思います。
正面 | 左側面 | 右側面 | 左前上 | 右前上 |
---|---|---|---|---|
前提実装
まず最低限の前提実装として、下記のように、モデルをステージの中央に置いて、そのモデルに向けたカメラノードをルートに置いてあるとします。
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) {
// ...
}
}
そして規定のカメラアングルはこのように定義されます:
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
で設定できると思います:
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)
+ }
+ }
+}
こうすれば、このモデルが下記のように角度を変えて見れます:
ただしこの方法では一つだけ微妙な問題点があります:そう eularAngle
がわかりにくいです。今回の場合は角度が比較的に単純なので割とさっとできたように思えますが、実際に自分で角度を変えてみて、特に .leftFrontUpper
や .rightFrontUpper
のような2つ以上の軸を中心とした回転があった場合、3Dの開発に慣れた方ならおそらくそこまで苦ではないですが、そうでない方(例えば筆者とか)にとってトライ&エラーで最適な数字が見つかるまでなかなか時間がかかります。
方法2:SCNLookAtConstraint
の力を借りる
実はまさにその eularAngle
がなかなか設定しにくい問題を見越してるからか、SceneKitではカメラの向け先を限定する方法があります:そう SCNLookAtConstraint
を使う方法です。この方法を使って、最初のカメラのセットアップで向け先を設定すれば、あとはカメラの position
をどう変えても、eularAngle
が勝手に変わって常に我々の見たいものが見えます:
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
を設定しなくても、カメラは常にモデルの中心に向けます:
ただいざこうしてみたら、最初の正面も左側面右側面の時もまだ問題なかったのですが、斜め上からの視点にした途端なんか角度がすごい変になったり、そのあとまたカメラを正面とかに戻しても描画の角度が変になっちゃうんですよね…
でも大丈夫!実は今回のように常に地面に対して水平を保ちたい場合は、SCNLookAtConstraint
には isGimbalLockEnabled
と言うプロパティーがあって、これを true
にすれば、カメラは常に地面に対して水平を保ちますので、非常に使いやすいと言えます。
let constraint = SCNLookAtConstraint(target: focusPoint)
+ constraint.isGimbalLockEnabled = true
cameraNode.constraints = [constraint]
これでカメラアングルをどう変えても、描画の角度が変わりませんね!
isGimbalLockEnabled
を true
にしないと角度が変になってしまうのも、前の章で eularAngle
の設定が割と大変な理由も、そもそも eularAngle
は生のデータとしてそのまま保持しているわけではないからです。アフィン変換の知識がある方なら想像がつくかと思いますが、このような描画は基本アフィン変換のような行列変換で行っており、SCNNode
もその例外ではなく、内部では .transform
プロパティーとして SCNMatrix4
型で保存されています。そしてこの行列はあくまで頂点座標の演算情報しかないので、eularAngle
などのような情報は直接 SCNMatrix4
から読み取れないのです。それどころか、行列は掛け算において交換則は成立しないので、position
と eularAngle
を行う順番を変えたり、操作を何度も重ねたりすると、最終的にできた transform
プロパティーの値が支離滅裂になったりする可能性もあるから、なかなか一発で決まらなかったり何度か操作すると訳わからなくなったりします。
ところが、これではまだまだ残念ながら話が終わりません。これまでのサンプルGIFで見た通り、この変換は全て瞬時に行われていて、UI面としてはあまり良くなく、やはりアニメーションが欲しいですよね。と言うわけでアニメーションを入れてみましょう:
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
+ }
}
するとどうでしょう?
そう、見ての通り、角度切り替え時のアニメーションの動きは、あまり気持ちのいいものではないでしょう、特に左側面から右側面に変わる時一回モデルの中にまで入ってしまって画面に一瞬真っ黒なものも映ってしまいます。なぜこうなるのかというと、SceneKitはカメラの position
を最短経路で動かそうとするから、2点間の最短経路は当然直線なので、どうしてもこうなってしまいます。
方法3:中間ノードの力を借りる
ではこの問題を解決するにはどうすればいいかというと、今まで我々はずっとカメラがシーンに置いての直接な position
を設定してきましたが、でも我々がやりたいのはやはり「回転」と「中心までの距離」を別々で変えたいだけですよね。そう、いわゆる「極座標系」的な考え方です。ただ position
が受け付けるのはあくまで直交座標系の座標だけだし、仮に一回極座標を直交座標に変換したとしても、SceneKitの内部ではそれを直交座標系で処理する以上、カメラワークの問題は変わりません。ここで登場するのが「中間ノード」的なものです。そう、我々はカメラを直接シーンに置くのではなく、その中間ノードに一旦置いて、そうすればその中間ノードの回転と、カメラとその中間ノードまでの距離を別々で設定すれば、極座標系っぽいことができますよね!と言うわけで早速直して見ましょう
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
の回転がメインとなり、これでアニメーションが下記のようにとてもキレイになりました:
めでたしめでたし。
最後に
も、もう3D怖くない((((;゜Д゜))))ガクガク