• トップ
  • ブログ一覧
  • 【iOS + SwiftUI】DragGestureで動かすVR動画
  • 【iOS + SwiftUI】DragGestureで動かすVR動画

    りゅうちゃん(エンジニア)りゅうちゃん(エンジニア)
    2021.11.17

    IT技術

    ドラッグで視界を操作できるVR動画を実装!

    iOSでVR動画といえば、ジャイロセンサーを使った実装のイメージが強いかもしれませんが、今回は以下のようにドラッグで視界を操作できるVR動画を実装していきます!

    今回は横画面のみの対応とするので、プロジェクトで以下のようにDevice Orientationを設定しておきましょう。

    VR動画を表示させてみる

    まずはドラッグで動かすとかを考えず、単純にVR動画を画面上に表示させてみます。

    1import SwiftUI
    2import SceneKit
    3
    4struct ContentView: View {
    5    let vrScene = VrScene()
    6    
    7    var body: some View {
    8        SceneView(scene: vrScene)
    9    }
    10}

    VR動画はSceneViewで表示させることができます。

    scene: 引数にはSCNSceneを要求されますが、これは3Dコンテンツを表示させるためのクラスです。

    今回はSCNSceneを継承したVrSceneというクラスを作成し、そこでVR動画を表示させる処理を書いていきましょう。

    SCNScene上にカメラを配置する

    最初にVR動画の視点となるカメラを追加しましょう。

    このカメラを通してVR動画上での視点を決めていく感じになります。

    1import SwiftUI
    2import AVFoundation
    3import SceneKit
    4import SpriteKit
    5import CoreMotion
    6
    7/// VR動画を表示するクラス
    8class VrScene: SCNScene {
    9    /// VR動画内での視点となるカメラ
    10    private let cameraNode: SCNNode
    11    
    12    override init() {
    13        cameraNode = SCNNode()
    14        super.init()
    15
    16        // カメラ
    17        cameraNode.camera = SCNCamera()
    18        self.rootNode.addChildNode(cameraNode)
    19    }
    20    
    21    required init?(coder: NSCoder) {
    22        fatalError("init(coder:) has not been implemented")
    23    }
    24}

    SCNCameraを初期化し、メンバ変数のcameraNode.camera に入れていますね。

    メンバ変数にしているのは、後ほどドラッグで視点を移動する際にSCNNodeの操作が必要になるからです。

    SCNSceneへのSCNNodeの追加は、self.rootNode.addChildNode(cameraNode) で行っていきます。

    ノードを使用して、シーン(空間)の構造を定義していくイメージです。

    またSCNNodeの初期化では、どこに表示するかの座標を定義していませんが、何も指定しない場合は3D空間上の中央に配置されるようになっています。

    次は動画の設定を行なっていきましょう。

    動画プレイヤーを初期化する

    3D空間に配置する以前に、動画を再生するということで動画プレイヤーを用意する必要があります。

    1/// VR動画を表示するクラス
    2class VrScene: SCNScene {
    3    ...
    4
    5    /// 動画をループ再生する
    6    private var playerLooper: AVPlayerLooper?
    7    
    8    override init() {
    9       ...
    10
    11        // ループ動画プレイヤーの生成
    12        let urlPath = Bundle.main.path(forResource: "動画ファイル名", ofType: "mp4")!
    13        let asset = AVAsset(url: URL(fileURLWithPath: urlPath))
    14        let playerItem = AVPlayerItem(asset: asset)
    15        let queuePlayer = AVQueuePlayer(playerItem: playerItem)
    16        queuePlayer.isMuted = true
    17        playerLooper = AVPlayerLooper(player: queuePlayer, templateItem: playerItem)
    18    }

    最初の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/// VR動画を表示するクラス
    2class VrScene: SCNScene {
    3    ...
    4
    5    override init() {
    6       ...
    7
    8        // SKSceneを生成する
    9        let videoScene = SKScene(size: .init(width: 1920, height: 1080))
    10        // AVPlayerからSKVideoNodeの生成する
    11        let videoNode = SKVideoNode(avPlayer: queuePlayer)
    12        // シーンと同じサイズとし、中央に配置する
    13        videoNode.position = .init(x: videoScene.size.width / 2.0, y: videoScene.size.height / 2.0)
    14        videoNode.size = videoScene.size
    15        videoNode.play()
    16        videoScene.addChild(videoNode)

    まずは動画のシーンを作成していきましょう。

    動画をシーンに入れるにはSKSceneとSKVideNodeを使用します。

    SKSceneの初期化ではsize: の指定を行っていますが、1920x1080ということで解像度の設定ですね。

    SKVideoNode(avPlayer: queuePlayer) で先ほど作ったAVQueuePlayerを入れてあげましょう。

    そうしたらvideNode.positionvideNode.size でSKSceneと同じ配置・サイズに変更します。

    あとは動画を再生するためにvideoNode.play() を実行し、SKSceneにSKVideoNodeをセットしましょう。

    次は動画を貼り付ける球状のオブジェクトを作成します。

    1/// VR動画を表示するクラス
    2class VrScene: SCNScene {
    3    ...
    4
    5    override init() {
    6       ...
    7
    8        // カメラを囲う球体
    9        let sphere = SCNSphere(radius: 20)
    10        sphere.firstMaterial?.isDoubleSided = true
    11        sphere.firstMaterial?.diffuse.contents = videoScene
    12        let sphereNode = SCNNode(geometry: sphere)
    13        self.rootNode.addChildNode(sphereNode)
    14    }

    球状のオブジェクトはSCNSphereで描画することができます。

    今回はこのオブジェクトの中にカメラを入れるので、内側も描画するためにfirstMaterial?.isDoubleSided = true を設定しています。

    firstMaterial?.diffuse.contents には先ほど作成した動画のSKSceneを代入しておきましょう。

    その後はSCNNode(geometry: sphere) でノードにして、self.rootNode.addChildNode() でシーンに追加します!

    ビルドしてシミュレーターで表示してみる

    ここまでで動画を表示するところまではできているので、ビルドして表示させてみましょう。

    以下のようになると思います。

    このままだと2点ほど問題があり、上下が逆になっているのと、VR動画の表示されている部分が動画の右端のほうになってしまっています。

    この2点を修正していきましょう。

    1// 座標系を上下逆にする
    2videoNode.yScale = -1.0
    3videoNode.play()
    4videoScene.addChild(videoNode)

    まずは上下が逆になっている問題からです。

    これはvideoNodeを初期化しているところで、yScale = -1.0 を設定すれば解決します。

    yScale はノードの高さの倍率設定で、デフォルト値が1.0なのですが、これをマイナスにすることで上下を逆転させることができます。

    次は動画の右端が表示されている問題の修正です。

    1// カメラ
    2cameraNode.camera = SCNCamera()
    3// カメラの向きが後ほど追加する動画の中央に向くように変更
    4cameraNode.orientation = .init(0, 1, 0, 0)
    5self.rootNode.addChildNode(cameraNode)

    これはカメラのorientation を設定することで解決します。

    上記のコードでは省略されていますが、SCNVector4.init(_ x: Int, _ y: Int, _ z: Int, _ w: Int) というイニシャライザを使用しています。

    横回転なのにyを設定している?と思われる方もいるかもしれませんが、これはy軸を回転させているからです。

    以下の画像でy軸がクルクル回っているというイメージがわかりやすいかもしれません。

    y軸がクルクル回っているというイメージ

    これらの値も倍率での計算となっており、1で180度回転してくれます。

    これで以下のように修正されました!

    ドラッグでカメラを操作する

    VR動画の表示・再生はできたので、いよいよ本題のドラッグでカメラを動かす実装をしていきましょう!

    まずはDragGestureをViewのほうに実装します。

    1struct ContentView: View {
    2    let vrScene = VrScene()
    3    
    4    var body: some View {
    5        SceneView(scene: vrScene)
    6            .gesture(
    7                DragGesture()
    8                    .onChanged(vrScene.drag(value:))
    9            )
    10    }
    11}

    ここは普通にGestureを追加する時と同じですね。

    引き続きVrScene.drag(value:) のほうを実装していきましょう。

    ドラッグの移動量を計算する

    まずはドラッグでどれだけカメラを移動させるかを計算していきましょう。

    1/// VR動画を表示するクラス
    2class VrScene: SCNScene {
    3    ...
    4
    5    /// 現在のカメラの位置
    6    private var currentDragVlaue: DragGesture.Value?
    7
    8    /// カメラを回転させる
    9    func drag(value: DragGesture.Value) {
    10        // ドラッグの移動値を取得
    11        if currentDragVlaue?.startLocation != value.startLocation { currentDragVlaue = nil }
    12        let dragX = value.location.x - (currentDragVlaue?.location.x ?? value.startLocation.x)
    13        let dragY = value.location.y - (currentDragVlaue?.location.y ?? value.startLocation.y)
    14
    15        // カメラを回転
    16        cameraNode.orientation = 回転させたい値
    17
    18        currentDragVlaue = value
    19    }
    20}

    ここではcurrentDragValue というメンバ変数を作成し、前回のDragGesture.Value を保存しています。

    startLocation パラメータはドラッグの開始地点を取得してくれるのですが、ドラッグ中にこれは更新されません。

    ドラッグ中は継続的に移動量からカメラを回転させる必要がありますが、startLocation をそのまま計算に使うと前回のドラッグの開始位置になってしまい、移動量がどんどん累積していってしまいます。

    なので、初回の計算以外はcurrentDragVlaue?.location から現在のドラッグの開始位置を取得し、「現在の位置 - 現在のドラッグ開始位置」という計算で移動量をxとyそれぞれで取得しています。

    これでドラッグの移動量が計算できたので、次はカメラを回転させていきましょう!

    カメラを回転させる

    まずはカメラを回転させるために、ドラッグの移動量を半円の角度である180度に変換していきます。

    180度というのは、画面いっぱいのドラッグで最大でどれだけスクロールさせるのか?という値です。

    動画の見えている範囲がちょうど180度に近いので、これに設定しています。

    つまりドラッグの感度ってことなので、お好みで設定してください。

    1/// カメラを回転させる
    2func drag(value: DragGesture.Value) {
    3    // ドラッグの移動量を取得
    4    if currentDragVlaue?.startLocation != value.startLocation { currentDragVlaue = nil }
    5    let dragX = value.location.x - (currentDragVlaue?.location.x ?? value.startLocation.x)
    6    let dragY = value.location.y - (currentDragVlaue?.location.y ?? value.startLocation.y)
    7
    8    // カメラを回転
    9    cameraNode.orientation = rotateCamera(
    10        q: cameraNode.orientation, // カメラの元々の姿勢
    11        point: cameraDragPoint(dragOffset: .init(x: dragX, y: dragY)) // ドラッグした距離を角度に変換
    12    )
    13    currentDragVlaue = value
    14}
    15    
    16    
    17/// スクロール幅のxy移動量を角度に変換
    18private func cameraDragPoint(dragOffset: CGPoint) -> CGPoint {
    19    let angle = CGFloat(180)
    20    let x = (dragOffset.x / UIScreen.main.bounds.width) * angle
    21    let y = (dragOffset.y / UIScreen.main.bounds.height) * angle
    22    return .init(x: x, y: y)
    23}

    cameraDragPoint から見ていきましょう。

    先ほども言ったように180度をangleとして取得し、(ドラッグの移動量 / 画面サイズ) * 角度 でxyそれぞれの移動量を計算しています。

    画面サイズで見てどれだけ移動したかを、180度にそのまま適用しているだけですね。

    次は上記で取得した値と現在のカメラの角度を使って、rotateCamera でカメラを回転させましょう!

    1/// カメラの回転値を取得
    2private func rotateCamera(q: SCNQuaternion, point: CGPoint) -> SCNQuaternion {
    3    // カメラの元々の姿勢
    4    let current = GLKQuaternionMake(q.x, q.y, q.z, q.w)
    5    // y軸をドラッグのx移動量まで回転させる
    6    let width = GLKQuaternionMakeWithAngleAndAxis(GLKMathDegreesToRadians(Float(point.x)), 0, 1, 0)
    7    // x軸をドラッグのy移動量まで回転させる
    8    let height = GLKQuaternionMakeWithAngleAndAxis(GLKMathDegreesToRadians(Float(point.y)), 1, 0, 0)
    9    // 新しいカメラの姿勢を設定
    10    let qp  = GLKQuaternionMultiply(GLKQuaternionMultiply(width, current), height)
    11    return SCNQuaternion(qp.x, qp.y, qp.z, qp.w)
    12}

    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軸がクルクル回っているというイメージ

    なのでy: に1を入れて、x方向への移動量でそのまま回転させるようにしましょう。

    縦回転も同じ要領でradians: にy方向への移動量を入れます。

    縦回転はx軸の回転でできるので、x: に1を指定してあげましょう!

    最後にこれらの値をGLKQuaternionMultiply でそれぞれ合わせて、SCNQuaternionに戻して値を返却します。

    シミュレーターで動かしてみる

    以上で実装は完了したので、実際に動かしてみましょう。

    横方向にも縦方向にもぐりぐり動かせるようになりましたね!

    完成品はGithubにあげたので、参考にしてみてください。

    https://github.com/ryuto-imai/VrPlayer

    また、今回表示した動画は以下のサイトのものを利用しました。

    https://360rtc.com/

    りゅうちゃん(エンジニア)

    りゅうちゃん(エンジニア)

    おすすめ記事