
【iOS + SwiftUI】DragGestureで動かすVR動画
2021.12.21
ドラッグで視界を操作できるVR動画を実装!
iOSでVR動画といえば、ジャイロセンサーを使った実装のイメージが強いかもしれませんが、今回は以下のようにドラッグで視界を操作できるVR動画を実装していきます!
今回は横画面のみの対応とするので、プロジェクトで以下のようにDevice Orientationを設定しておきましょう。
VR動画を表示させてみる
まずはドラッグで動かすとかを考えず、単純にVR動画を画面上に表示させてみます。
1 2 3 4 5 6 7 8 9 10 | import SwiftUI import SceneKit struct ContentView: View { let vrScene = VrScene() var body: some View { SceneView(scene: vrScene) } } |
VR動画はSceneViewで表示させることができます。
scene: 引数にはSCNSceneを要求されますが、これは3Dコンテンツを表示させるためのクラスです。
今回はSCNSceneを継承したVrSceneというクラスを作成し、そこでVR動画を表示させる処理を書いていきましょう。
SCNScene上にカメラを配置する
最初にVR動画の視点となるカメラを追加しましょう。
このカメラを通してVR動画上での視点を決めていく感じになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | import SwiftUI import AVFoundation import SceneKit import SpriteKit import CoreMotion /// VR動画を表示するクラス class VrScene: SCNScene { /// VR動画内での視点となるカメラ private let cameraNode: SCNNode override init() { cameraNode = SCNNode() super.init() // カメラ cameraNode.camera = SCNCamera() self.rootNode.addChildNode(cameraNode) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } |
SCNCameraを初期化し、メンバ変数の cameraNode.camera に入れていますね。
メンバ変数にしているのは、後ほどドラッグで視点を移動する際にSCNNodeの操作が必要になるからです。
SCNSceneへのSCNNodeの追加は、 self.rootNode.addChildNode(cameraNode) で行っていきます。
ノードを使用して、シーン(空間)の構造を定義していくイメージです。
またSCNNodeの初期化では、どこに表示するかの座標を定義していませんが、何も指定しない場合は3D空間上の中央に配置されるようになっています。
次は動画の設定を行なっていきましょう。
動画プレイヤーを初期化する
3D空間に配置する以前に、動画を再生するということで動画プレイヤーを用意する必要があります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | /// VR動画を表示するクラス class VrScene: SCNScene { ... /// 動画をループ再生する private var playerLooper: AVPlayerLooper? override init() { ... // ループ動画プレイヤーの生成 let urlPath = Bundle.main.path(forResource: "動画ファイル名", ofType: "mp4")! let asset = AVAsset(url: URL(fileURLWithPath: urlPath)) let playerItem = AVPlayerItem(asset: asset) let queuePlayer = AVQueuePlayer(playerItem: playerItem) queuePlayer.isMuted = true playerLooper = AVPlayerLooper(player: queuePlayer, templateItem: playerItem) } |
最初の urlPath では動画がどこにあるのかを記述しています。
今回はローカルファイルとして動画を配置してあるので、 Bundle.main.path でPath情報を取得し、 URL(fileURLWithPath:) でAVAssetに渡していますね。
動画ファイルをプロジェクトに追加した際には、Build PhasesのCopy Bundle Resourceへの追加も忘れずやっておきましょう。
もしWeb上の動画を使う場合は、 URL(string: "動画のURL") に変えればいけるはずです。
AVAssetができたら、 AVPlayerItem(asset:) -> AVQueuePlayer(playerItem:) の順に初期化していきましょう。
queuePlayer.isMuted = true はその名の通り、無音にする設定です。
動画によっては音がうるさい場合もあるので、お好みで設定してください。
最後にAVPlayerLooperをメンバ変数に入れていますが、これはその名の通りループ再生するプレイヤーになっています。
これで動画プレイヤーはできたので、次は3D空間上に配置していきましょう!
SCNScene上に動画を配置する
表示する動画には、以下のような全天球動画と呼ばれるものを使用します。
普通に表示させると歪んでしまうため、今回の動画配置では球状のオブジェクトに動画を貼り付け、その球の内側からカメラを回していくことになります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | /// VR動画を表示するクラス class VrScene: SCNScene { ... override init() { ... // SKSceneを生成する let videoScene = SKScene(size: .init(width: 1920, height: 1080)) // AVPlayerからSKVideoNodeの生成する let videoNode = SKVideoNode(avPlayer: queuePlayer) // シーンと同じサイズとし、中央に配置する videoNode.position = .init(x: videoScene.size.width / 2.0, y: videoScene.size.height / 2.0) videoNode.size = videoScene.size videoNode.play() videoScene.addChild(videoNode) |
まずは動画のシーンを作成していきましょう。
動画をシーンに入れるにはSKSceneとSKVideNodeを使用します。
SKSceneの初期化では size: の指定を行っていますが、1920x1080ということで解像度の設定ですね。
SKVideoNode(avPlayer: queuePlayer) で先ほど作ったAVQueuePlayerを入れてあげましょう。
そうしたら videNode.position と videNode.size でSKSceneと同じ配置・サイズに変更します。
あとは動画を再生するために videoNode.play() を実行し、SKSceneにSKVideoNodeをセットしましょう。
次は動画を貼り付ける球状のオブジェクトを作成します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | /// VR動画を表示するクラス class VrScene: SCNScene { ... override init() { ... // カメラを囲う球体 let sphere = SCNSphere(radius: 20) sphere.firstMaterial?.isDoubleSided = true sphere.firstMaterial?.diffuse.contents = videoScene let sphereNode = SCNNode(geometry: sphere) self.rootNode.addChildNode(sphereNode) } |
球状のオブジェクトはSCNSphereで描画することができます。
今回はこのオブジェクトの中にカメラを入れるので、内側も描画するために firstMaterial?.isDoubleSided = true を設定しています。
firstMaterial?.diffuse.contents には先ほど作成した動画のSKSceneを代入しておきましょう。
その後は SCNNode(geometry: sphere) でノードにして、 self.rootNode.addChildNode() でシーンに追加します!
ビルドしてシミュレーターで表示してみる
ここまでで動画を表示するところまではできているので、ビルドして表示させてみましょう。
以下のようになると思います。
このままだと2点ほど問題があり、上下が逆になっているのと、VR動画の表示されている部分が動画の右端のほうになってしまっています。
この2点を修正していきましょう。
1 2 3 4 | // 座標系を上下逆にする videoNode.yScale = -1.0 videoNode.play() videoScene.addChild(videoNode) |
まずは上下が逆になっている問題からです。
これはvideoNodeを初期化しているところで、 yScale = -1.0 を設定すれば解決します。
yScale はノードの高さの倍率設定で、デフォルト値が1.0なのですが、これをマイナスにすることで上下を逆転させることができます。
次は動画の右端が表示されている問題の修正です。
1 2 3 4 5 | // カメラ cameraNode.camera = SCNCamera() // カメラの向きが後ほど追加する動画の中央に向くように変更 cameraNode.orientation = .init(0, 1, 0, 0) self.rootNode.addChildNode(cameraNode) |
これはカメラの orientation を設定することで解決します。
上記のコードでは省略されていますが、 SCNVector4.init(_ x: Int, _ y: Int, _ z: Int, _ w: Int) というイニシャライザを使用しています。
横回転なのにyを設定している?と思われる方もいるかもしれませんが、これはy軸を回転させているからです。
以下の画像でy軸がクルクル回っているというイメージがわかりやすいかもしれません。
これらの値も倍率での計算となっており、1で180度回転してくれます。
これで以下のように修正されました!
ドラッグでカメラを操作する
VR動画の表示・再生はできたので、いよいよ本題のドラッグでカメラを動かす実装をしていきましょう!
まずはDragGestureをViewのほうに実装します。
1 2 3 4 5 6 7 8 9 10 11 | struct ContentView: View { let vrScene = VrScene() var body: some View { SceneView(scene: vrScene) .gesture( DragGesture() .onChanged(vrScene.drag(value:)) ) } } |
ここは普通にGestureを追加する時と同じですね。
引き続き VrScene.drag(value:) のほうを実装していきましょう。
ドラッグの移動量を計算する
まずはドラッグでどれだけカメラを移動させるかを計算していきましょう。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | /// VR動画を表示するクラス class VrScene: SCNScene { ... /// 現在のカメラの位置 private var currentDragVlaue: DragGesture.Value? /// カメラを回転させる func drag(value: DragGesture.Value) { // ドラッグの移動値を取得 if currentDragVlaue?.startLocation != value.startLocation { currentDragVlaue = nil } let dragX = value.location.x - (currentDragVlaue?.location.x ?? value.startLocation.x) let dragY = value.location.y - (currentDragVlaue?.location.y ?? value.startLocation.y) // カメラを回転 cameraNode.orientation = 回転させたい値 currentDragVlaue = value } } |
ここでは currentDragValue というメンバ変数を作成し、前回の DragGesture.Value を保存しています。
startLocation パラメータはドラッグの開始地点を取得してくれるのですが、ドラッグ中にこれは更新されません。
ドラッグ中は継続的に移動量からカメラを回転させる必要がありますが、 startLocation をそのまま計算に使うと前回のドラッグの開始位置になってしまい、移動量がどんどん累積していってしまいます。
なので、初回の計算以外は currentDragVlaue?.location から現在のドラッグの開始位置を取得し、「現在の位置 - 現在のドラッグ開始位置」という計算で移動量をxとyそれぞれで取得しています。
これでドラッグの移動量が計算できたので、次はカメラを回転させていきましょう!
カメラを回転させる
まずはカメラを回転させるために、ドラッグの移動量を半円の角度である180度に変換していきます。
180度というのは、画面いっぱいのドラッグで最大でどれだけスクロールさせるのか?という値です。
動画の見えている範囲がちょうど180度に近いので、これに設定しています。
つまりドラッグの感度ってことなので、お好みで設定してください。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | /// カメラを回転させる func drag(value: DragGesture.Value) { // ドラッグの移動量を取得 if currentDragVlaue?.startLocation != value.startLocation { currentDragVlaue = nil } let dragX = value.location.x - (currentDragVlaue?.location.x ?? value.startLocation.x) let dragY = value.location.y - (currentDragVlaue?.location.y ?? value.startLocation.y) // カメラを回転 cameraNode.orientation = rotateCamera( q: cameraNode.orientation, // カメラの元々の姿勢 point: cameraDragPoint(dragOffset: .init(x: dragX, y: dragY)) // ドラッグした距離を角度に変換 ) currentDragVlaue = value } /// スクロール幅のxy移動量を角度に変換 private func cameraDragPoint(dragOffset: CGPoint) -> CGPoint { let angle = CGFloat(180) let x = (dragOffset.x / UIScreen.main.bounds.width) * angle let y = (dragOffset.y / UIScreen.main.bounds.height) * angle return .init(x: x, y: y) } |
cameraDragPoint から見ていきましょう。
先ほども言ったように180度をangleとして取得し、(ドラッグの移動量 / 画面サイズ) * 角度 でxyそれぞれの移動量を計算しています。
画面サイズで見てどれだけ移動したかを、180度にそのまま適用しているだけですね。
次は上記で取得した値と現在のカメラの角度を使って、 rotateCamera でカメラを回転させましょう!
1 2 3 4 5 6 7 8 9 10 11 12 | /// カメラの回転値を取得 private func rotateCamera(q: SCNQuaternion, point: CGPoint) -> SCNQuaternion { // カメラの元々の姿勢 let current = GLKQuaternionMake(q.x, q.y, q.z, q.w) // y軸をドラッグのx移動量まで回転させる let width = GLKQuaternionMakeWithAngleAndAxis(GLKMathDegreesToRadians(Float(point.x)), 0, 1, 0) // x軸をドラッグのy移動量まで回転させる let height = GLKQuaternionMakeWithAngleAndAxis(GLKMathDegreesToRadians(Float(point.y)), 1, 0, 0) // 新しいカメラの姿勢を設定 let qp = GLKQuaternionMultiply(GLKQuaternionMultiply(width, current), height) return SCNQuaternion(qp.x, qp.y, qp.z, qp.w) } |
cameraNode.orientation が要求する型はSCNQuaternionですが、回転の計算をするためにGLKQuaternionに変換します。
受け取った現在のカメラの姿勢であるSCNQuaternionの値を、 GLKQuaternionMake(x:, y:, z:, w:) で変換してあげましょう。
GLKQuaternionの姿勢を変えたい場合は GLKQuaternionMultiply() によって、GLKQuaternion同士を掛けることで可能です。
まずは横回転の値をGLKQuaternionで取得しましょう。
回転の値の取得は GLKQuaternionMakeWithAngleAndAxis(_ radians: Float, _ x: Float, _ y: Float, _ z: Float) で行います。
radians: には GLKMathDegreesToRadians(Float(point.x)) で、DragGestureから取得したx方向への移動量を指定しましょう。
x:, y:, z: には radians: で指定した値を基準として、どの方向にどの倍率で動くかを指定します。
少し前にも言った通り、横回転はy軸の回転ですね。
なので y: に1を入れて、x方向への移動量でそのまま回転させるようにしましょう。
縦回転も同じ要領で radians: にy方向への移動量を入れます。
縦回転はx軸の回転でできるので、 x: に1を指定してあげましょう!
最後にこれらの値を GLKQuaternionMultiply でそれぞれ合わせて、SCNQuaternionに戻して値を返却します。
シミュレーターで動かしてみる
以上で実装は完了したので、実際に動かしてみましょう。
横方向にも縦方向にもぐりぐり動かせるようになりましたね!
完成品はGithubにあげたので、参考にしてみてください。
https://github.com/ryuto-imai/VrPlayer
また、今回表示した動画は以下のサイトのものを利用しました。
書いた人はこんな人

IT技術10月 27, 2023Jiraの自動化
IT技術8月 16, 2023Lighthouseで計測したパフォーマンススコアのばらつきを減らす方法
IT技術11月 7, 2022Ruby on Rails&GraphQLのエラーレスポンス
IT技術7月 13, 2022Ruby on Rails & GraphQLの環境構築と実装