JINMUSOFTWARE

RealityKitで3Dモデルを慣性で回転させる【SwiftUI】

慣性で回転

目的

前回、RealityKit、RealityViewを使用して3Dモデルを表示して回転させることができました。前回の記事

今回は、慣性回転を実装します。

3Dモデルを回すと、回転が少し続くように修正していきます。

条件、環境

  • xcode 26.6
  • RealityViewを使用しますのでiOS18以上が必要になります。

実装

全コードです。冗長な部分が含まれます。

Swift
import RealityKit
import SwiftUI

/// 3Dモデルの表示とスワイプによる回転操作のサンプルです。
///
/// スワイプに追従して上下左右に回転する
/// 回転中に指を離すと惰性で少しの間回転する
///
/// === Note ===
/// 座標の指定方法は[x, y, z]。回転の場合は軸になる。
/// 型がSIMD3<Float>であることに注意。
/// 例)let init_position: SIMD3<Float> = [0, 0, 0]
/// 3次元上での回転角の名称
///  yaw 左右の首振り、垂直軸
///  pitch 上下を向く傾き
///  roll 左右の傾き、前後軸の回転
/// スワイプ量がOvershootして回転がjumpする現象は実機でのみ発生した。
struct Case7: View {
    @State private var modelEntity: Entity?
    @State private var yaw: Float = 0  // 左右
    @State private var pitch: Float = 0  // 上下
    @State private var dragFrameCount: Int = 0
    @State private var lastTranslation: CGSize = .zero
    //
    @State private var velocityYaw: Float = 0
    @State private var velocityPitch: Float = 0
    @State private var inertiaTask: Task<Void, Never>?
    // 最終移動時間
    // 移動か停止の判断のため。
    @State private var lastMoveTime = Date()

    var body: some View {
        // 3Dモデルの表示
        RealityView { content in
            // USDZ読み込み
            if let entity = try? await Entity(named: "monkey5") {
                // シーンへ追加
                content.add(entity)
                // entity保存
                modelEntity = entity
            }
        }
        // スワイプで回転させるためのジェスチャーを追加
        .gesture(
            // minimumDistanceを0にすると、なんか不安定
            // 8フレーム目付近でもジャンプがあった。
            // minimumDistanceを1以上にすると安定する。
            // 大体3フレーム目までにジャンプがある。
            // coordinateSpaceはよくわからない。
            // DragGesture(minimumDistance: 0, coordinateSpace: .local)
            DragGesture(minimumDistance: 1)
                .onChanged { value in
                    // 動き始めたらTask停止
                    inertiaTask?.cancel()

                    // Entityがあるか確認
                    guard let entity = modelEntity else { return }

                    // フレーム数をカウント
                    // 先頭フレームを1〜3枚捨てたいのでカウントしている
                    dragFrameCount += 1

                    // 直前のフレームからの「差分(delta)」を計算
                    // 差分を使うことで、Overshoot気味のフレームを捨てることができる。
                    // translationのwidthとheightは、ドラッグ開始位置からの累積値
                    // なので、そのまま使うとジャンプする。
                    // 差分を取る必要がある。
                    let deltaX = Float(
                        value.translation.width - lastTranslation.width
                    )
                    let deltaY = Float(
                        value.translation.height - lastTranslation.height
                    )

                    // 現在の translation を次のフレームのために記憶
                    lastTranslation = value.translation

                    // DEBUG: フレーム数と差分を表示して確認
                    // 1〜3フレームあたりで移動量に大きい値があることに気づける。
                    // ゆっくりスワイプしてみるとわかる。
                    // 回転のジャンプは実機のみ発生した。
                    // print("\(dragFrameCount):\(deltaY)")
                    
                    // DEBUG:
                    print(value.predictedEndTranslation)

                    // Overshoot気味のフレームを捨てる
                    // きっとiOSのドラッグジェスチャーの仕様なのだろう。
                    if dragFrameCount <= 2 {
                        // ジャスチャー開始時の大きめの値は使わずに捨てる
                        // minimumDistanceの値と関連するようだ。
                        // minimumDistanceを1にすると安定し、1〜3フレーム目に
                        // Overshootが収まるようになった。
                        // なので3フレーム目までは捨てることにした。
                        return
                    }

                    // 最終移動時刻保存
                    // 停止時間を測定するために保存している。
                    // 指の動きを停止させてから離した場合を判断するため。
                    if deltaX != 0 || deltaY != 0 {
                        lastMoveTime = Date()
                    }

                    // 速度保存
                    velocityYaw = deltaX * 0.01
                    velocityPitch = deltaY * 0.01

                    // 回転量調整、0.01がちょうどいい感じ。
                    yaw += deltaX * 0.01
                    pitch += deltaY * 0.01

                    // 上下回転を90°までに制限
                    // 上下回転に制限があったほうが見やすいらしい。
                    pitch = max(
                        -.pi / 2 + 0.01,
                        min(.pi / 2 - 0.01, pitch)
                    )

                    // Y軸回転
                    let yRotation = simd_quatf(
                        angle: yaw,
                        axis: [0, 1, 0]
                    )

                    // X軸回転
                    let xRotation = simd_quatf(
                        angle: pitch,
                        axis: [1, 0, 0]
                    )

                    // 回転を適用
                    entity.transform.rotation = yRotation * xRotation
                }
                .onEnded { _ in
                    // 指を離したらリセット
                    dragFrameCount = 0
                    lastTranslation = .zero
                    // DEBUG:どうやらコードからコンソールはクリアできないようだ。
                    // よって、切れ目を入れてLogを見やすくする。
                    print("---")

                    // 停止判定
                    // これが無いとマウス(指)を停止させてから離しても惰性回転が始まってしまう。
                    // 0.1s以上停止してから話すと回転しない。
                    // velocityYaw,velocityPitchをクリアしてもいいかも?
                    if Date().timeIntervalSince(lastMoveTime) > 0.1 {
                        return
                    }

                    // 惰性回転
                    guard let entity = modelEntity else { return }

                    inertiaTask = Task {

                        while !Task.isCancelled {

                            velocityYaw *= 0.95
                            velocityPitch *= 0.95

                            if abs(velocityYaw) < 0.001
                                && abs(velocityPitch) < 0.001
                            {
                                break
                            }

                            await MainActor.run {

                                yaw += velocityYaw
                                pitch += velocityPitch

                                pitch = max(
                                    -.pi / 2 + 0.01,
                                    min(.pi / 2 - 0.01, pitch)
                                )

                                let yRotation = simd_quatf(
                                    angle: yaw,
                                    axis: [0, 1, 0]
                                )

                                let xRotation = simd_quatf(
                                    angle: pitch,
                                    axis: [1, 0, 0]
                                )

                                entity.transform.rotation =
                                    yRotation * xRotation
                            }

                            try? await Task.sleep(for: .milliseconds(16))
                        }
                    }
                }
        )
    }
}

#Preview {
    Case7()
}

解説

3Dモデルの表示

Swift
// 3Dモデルの表示
RealityView { content in
    // USDZ読み込み
    if let entity = try? await Entity(named: "monkey5") {
        content.add(entity)
        modelEntity = entity
    }
}
.gesture(~~~)
  • 前回と同じですね。
  • 3Dモデルのファイル(*.usdz)は、Project Navigatorに追加しておきます。

スワイプの調整

Swift
.gesture(
  DragGesture(minimumDistance: 1)

minimumDistanceの値を大きくし過ぎるとスワイプの開始の反応が遅れる。大きくする必要は無いと考えたので、今回は「1」とした。

  • 0だとジャンプ値の発生フレームがばらついた。1~10フレームの間。
  • 1以上だと1~3フレーム目にジャンプ値が発生した。

慣性で回転し続ける

指を離した時、その移動量を保持して回転し続け、時間と共に減算すれば、慣性で回転しているように見えるはず。

なので速度を保存します。

Swift
// 速度保存
velocityYaw = deltaX * 0.01
velocityPitch = deltaY * 0.01

タスクで慣性回転処理します。

Swift
inertiaTask = Task {

    while !Task.isCancelled {
        // 減衰
        velocityYaw *= 0.95
        velocityPitch *= 0.95

        // 下限速度確認
        if abs(velocityYaw) < 0.001
            && abs(velocityPitch) < 0.001
        {
            break
        }

        // 回転処理
        await MainActor.run {
            yaw += velocityYaw
            pitch += velocityPitch

            pitch = max(
                -.pi / 2 + 0.01,
                min(.pi / 2 - 0.01, pitch)
            )

            let yRotation = simd_quatf(
                angle: yaw,
                axis: [0, 1, 0]
            )

            let xRotation = simd_quatf(
                angle: pitch,
                axis: [1, 0, 0]
            )

            entity.transform.rotation =
                yRotation * xRotation
        }

        // loop wait
        try? await Task.sleep(for: .milliseconds(16))
    }
}

注意:

この処理をTaskではなく、subscribeで実装するほうが適切とする意見もあるかも。私はまだ判断できない。

最終移動時刻の保存

3Dモデルの回転を停止させる意図がある場合は指を止めてから離すと思います。

しかし、指の移動を完全に停止させてから指を離しても、ほんの少しだけモデルは回転してしまいます。

原因は、指の移動を停止するその最後のわずかな移動量も記録され、指を離したときにその移動量が消費されるからです。

よって、「指を離したときの時間」と「最後の移動量の記録時刻」との差が大きれば指が停止していた判断して、回転しないようにします。

Swift
// 最終移動時刻保存
// 停止時間を測定するために保存している。
// 指の動きを停止させてから離した場合を判断するため。
if deltaX != 0 || deltaY != 0 {
    lastMoveTime = Date()
}

0.1秒以上記録時刻の差があれば回転処理を行わない。

Swift
// 停止判定
// これが無いとマウス(指)を停止させてから離しても惰性回転が始まってしまう。
// 0.1s以上停止してから話すと回転しない。
if Date().timeIntervalSince(lastMoveTime) > 0.1 {
    return
}

結果

3Dモデルが慣性で回転しているとこと
  • 0:00 慣性回転の様子
  • 0:15 回転を停止させている様子

以上です。最後までお読みいただき、ありがとうございました。
少しでも参考になれば幸いです。

関連記事

参考