はじめに

こんにちは!システム開発部のYです。
担当した企業案件で、Full Immersive空間内である場所に移動させたいという要件がありました。
ユーザー自身のポジションを直接変更することはできなかったため、別のアプローチで「それらしく見せる」方法を検討し、実現しました。今回はその手法についてまとめていきたいと思います。

環境

  • Swift Version: 5.9.2
  • Xcode: 15.2
  • visionOS: 1.0〜1.3
  • Reality Composer Pro: 1.0
  • macOS: 14.5以降

空間内の移動について

前置きでも述べたように、ユーザー自身のポジションを直接変更することはできません。
しかし、別のアプローチとして、空間そのものを動かすことで、ユーザーが移動しているように見せることが可能です。

ただし、注意点があります。以下のガイドラインに記載されている通り、空間全体を移動させることで、ユーザーが酔いやすくなる可能性があります。

固定された座標系を用意することを検討する。固定された領域の中で完結している視覚的な動きの方が、人間は楽に処理することができます。反対に、例えばプレイヤーに自動的に空間を移動させるゲームで、周囲のエリア全体が動いているように見えると、気分が悪くなってしまうことがあります。
https://developer.apple.com/jp/design/human-interface-guidelines/motion#visionOS


実際の動画をご覧ください
※モデルをタップすると、モデルの前に移動します

実装について

指定ポジションへの移動

1. プロジェクトの作成

まず、Xcodeを開き、新規プロジェクト作成時に「visionOS」を選択します。その際、Immersive Space の設定は「Full」にしてください。

作成したプロジェクトをビルドして実行すると、次のようなアプリが起動します。
ウィンドウが表示され、その中央に「Show Immersive Space」というボタンが配置されています。このボタンを押すと、Full Immersive Spaceに入り、2つの球体が表示されます。

2. Reality Composer Proの変更

Reality Composer Proを開き、「Immersive.usda」を選択して編集を行います。
「Sphere_Left」と「Sphere_Right」を削除し、移動がわかりやすいように、Cubeを使用して床を作成し、目の前にモデルを配置します。

今回使用するモデルは、サンプルにある「Drummer.usdz」です。
このモデルをタップで検知できるようにするため、以下の設定として、CollisionとInputTargetを追加します。

3. ImmersiveView.swiftの変更

プロジェクト作成後の、コードは以下になっています。

import SwiftUI
import RealityKit
import RealityKitContent

struct ImmersiveView: View {

    @State private var immersiveContentEntity = Entity()

    var body: some View {
        RealityView { content in
            // Add the initial RealityKit content
            if let immersiveContentEntity = try? await Entity(named: "Immersive", in: realityKitContentBundle) {
                content.add(immersiveContentEntity)

                // Add an ImageBasedLight for the immersive content
                guard let resource = try? await EnvironmentResource(named: "ImageBasedLight") else { return }
                let iblComponent = ImageBasedLightComponent(source: .single(resource), intensityExponent: 0.25)
                immersiveContentEntity.components.set(iblComponent)
                immersiveContentEntity.components.set(ImageBasedLightReceiverComponent(imageBasedLight: immersiveContentEntity))

                // Put skybox here.  See example in World project available at
                // https://developer.apple.com/
            }
        }
    }
}

Entityのタップ検知をするために、tapGestureを追加します。
以下のように、変更していきます。
ここまででビルドして実行し、モデルをタップするとDrummerという文字列が出力されます。

import SwiftUI
import RealityKit
import RealityKitContent

struct ImmersiveView: View {
    
    var body: some View {
        RealityView { content in
            // Add the initial RealityKit content
            if let immersiveContentEntity = try? await Entity(named: "Immersive", in: realityKitContentBundle) {
                content.add(immersiveContentEntity)

                // Add an ImageBasedLight for the immersive content
                guard let resource = try? await EnvironmentResource(named: "ImageBasedLight") else { return }
                let iblComponent = ImageBasedLightComponent(source: .single(resource), intensityExponent: 0.25)
                immersiveContentEntity.components.set(iblComponent)
                immersiveContentEntity.components.set(ImageBasedLightReceiverComponent(imageBasedLight: immersiveContentEntity))
            }
        }
        .gesture(tapGesture)
    }
        
    var tapGesture: some Gesture {
        TapGesture()
            .targetedToAnyEntity()
            .onEnded { value in
                
                switch value.entity.name {
                case "Drummer":
                    print("Drummer")
                    
                default: break
                }
            }
    }
}

タップした時に、空間が移動しているように見える実装を行います。
読み込んだEntityを操作したいので、immersiveContentEntityという変数を作成し、保持するように変更します。
次では、immersiveContentEntityをmoveさせています。

import SwiftUI
import RealityKit
import RealityKitContent

struct ImmersiveView: View {
    
    @State private var immersiveContentEntity = Entity()
    
    var body: some View {
        RealityView { content in
            // Add the initial RealityKit content
            if let immersiveContentEntity = try? await Entity(named: "Immersive", in: realityKitContentBundle) {
                self.immersiveContentEntity = immersiveContentEntity
                
                content.add(immersiveContentEntity)

                // Add an ImageBasedLight for the immersive content
                guard let resource = try? await EnvironmentResource(named: "ImageBasedLight") else { return }
                let iblComponent = ImageBasedLightComponent(source: .single(resource), intensityExponent: 0.25)
                immersiveContentEntity.components.set(iblComponent)
                immersiveContentEntity.components.set(ImageBasedLightReceiverComponent(imageBasedLight: immersiveContentEntity))
            }
        }
        .gesture(tapGesture)
    }
        
    var tapGesture: some Gesture {
        TapGesture()
            .targetedToAnyEntity()
            .onEnded { value in
                
                switch value.entity.name {
                case "Drummer":
                    // 動かすPosition
                    let newPosition = SIMD3<Float>(0, 0, 7)
                    
                    var newTransform = self.immersiveContentEntity.transform
                    newTransform.translation = newPosition
                    self.immersiveContentEntity.move(to: newTransform, relativeTo: nil, duration: 1.0, timingFunction: .linear)
                    
                default: break
                }
            }
    }
}

ここまででタップすると、immersiveContentEntityのポジションが変わって空間が移動し、あたかも自分が移動しているように見えます。

指定したアンカーへの移動

先ほどは指定したポジションへの移動でしたが、Reality Composer Proで設置したポジションへ移動させたいという事があると思います。

1. Reality Composer Proを変更

Reality Composer Proを開き、「Immersive.usda」を選択し変更します。
新しいTransform「DrummerAnchor」を作成します。
作成したTransformを次のように、Drummerの前辺りに設置します。

2. ImmersiveView.swiftの変更

Reality Composer Proで設置した、「DrummerAnchor」を取得し移動します。
取得するコードと、読み込んだDrummerAnchorを使用するので、
drummerAnchor
という変数を作成し、保持するように変更します。

import SwiftUI
import RealityKit
import RealityKitContent

struct ImmersiveView: View {
    
    @State private var immersiveContentEntity = Entity()

    @State private var drummerAnchor = Entity()
    
    var body: some View {
        RealityView { content in
            // Add the initial RealityKit content
            if let immersiveContentEntity = try? await Entity(named: "Immersive", in: realityKitContentBundle) {
                self.immersiveContentEntity = immersiveContentEntity
                
                if let drummerAnchor = immersiveContentEntity.findEntity(named: "DrummerAnchor") {
                    self.drummerAnchor = drummerAnchor
                }
                
                content.add(immersiveContentEntity)

                // Add an ImageBasedLight for the immersive content
                guard let resource = try? await EnvironmentResource(named: "ImageBasedLight") else { return }
                let iblComponent = ImageBasedLightComponent(source: .single(resource), intensityExponent: 0.25)
                immersiveContentEntity.components.set(iblComponent)
                immersiveContentEntity.components.set(ImageBasedLightReceiverComponent(imageBasedLight: immersiveContentEntity))

                // Put skybox here.  See example in World project available at
                // https://developer.apple.com/
            }
        }
        .gesture(tapGesture)
    }
}

また、移動する処理をEntityのExtensionに移動させました。
moveToEntityが指定されたエンティティに移動させる処理です。指定されたエンティティのポジションを取得し、ポジションを反転させて移動させています。

extension Entity {
    
    /// 指定されたエンティティに移動する
    @discardableResult
    func moveToEntity(_ movePointEntity: Entity, relativeTo parent: Entity?, duration: TimeInterval = 1.0, timingFunction: AnimationTimingFunction = .linear) -> AnimationPlaybackController? {
        let movePointTranslation = movePointEntity.transform.translation
        let newTranslation = movePointTranslation * -1
        return self.moveToPosition(newTranslation, relativeTo: parent, duration: duration, timingFunction: timingFunction)
    }
    
    /// アニメーションしながらエンティティを指定箇所に動かす
    @discardableResult
    func moveToPosition(_ position: SIMD3<Float>, relativeTo parent: Entity?, duration: TimeInterval = 1.0, timingFunction: AnimationTimingFunction = .linear) -> AnimationPlaybackController {
        return self.move(
            to: Transform(scale: self.transform.scale, rotation: self.transform.rotation, translation: position),
            relativeTo: parent,
            duration: duration,
            timingFunction: timingFunction
        )
    }
}

以下全コードです。

import SwiftUI
import RealityKit
import RealityKitContent

struct ImmersiveView: View {
    
    @State private var immersiveContentEntity = Entity()

    @State private var drummerAnchor = Entity()
    
    var body: some View {
        RealityView { content in
            // Add the initial RealityKit content
            if let immersiveContentEntity = try? await Entity(named: "Immersive", in: realityKitContentBundle) {
                self.immersiveContentEntity = immersiveContentEntity
                
                if let drummerAnchor = immersiveContentEntity.findEntity(named: "DrummerAnchor") {
                    self.drummerAnchor = drummerAnchor
                }
                
                content.add(immersiveContentEntity)

                // Add an ImageBasedLight for the immersive content
                guard let resource = try? await EnvironmentResource(named: "ImageBasedLight") else { return }
                let iblComponent = ImageBasedLightComponent(source: .single(resource), intensityExponent: 0.25)
                immersiveContentEntity.components.set(iblComponent)
                immersiveContentEntity.components.set(ImageBasedLightReceiverComponent(imageBasedLight: immersiveContentEntity))
            }
        }
        .gesture(tapGesture)
    }
        
    var tapGesture: some Gesture {
        TapGesture()
            .targetedToAnyEntity()
            .onEnded { value in
                
                switch value.entity.name {
                case "Drummer":
                    self.immersiveContentEntity.moveToEntity(self.drummerAnchor, relativeTo: nil, duration: 1, timingFunction: .linear)

                default: break
                }
            }
    }
}

extension Entity {
    
    /// 指定されたエンティティに移動する
    @discardableResult
    func moveToEntity(_ movePointEntity: Entity, relativeTo parent: Entity?, duration: TimeInterval = 1.0, timingFunction: AnimationTimingFunction = .linear) -> AnimationPlaybackController? {
        let movePointTranslation = movePointEntity.transform.translation
        let newTranslation = movePointTranslation * -1
        return self.moveToPosition(newTranslation, relativeTo: parent, duration: duration, timingFunction: timingFunction)
    }
    
    /// アニメーションしながらエンティティを指定箇所に動かす
    @discardableResult
    func moveToPosition(_ position: SIMD3<Float>, relativeTo parent: Entity?, duration: TimeInterval = 1.0, timingFunction: AnimationTimingFunction = .linear) -> AnimationPlaybackController {
        return self.move(
            to: Transform(scale: self.transform.scale, rotation: self.transform.rotation, translation: position),
            relativeTo: parent,
            duration: duration,
            timingFunction: timingFunction
        )
    }
}

まとめ

空間内を移動する演出の実装方法についてまとめました。
コード自体は非常にシンプルに実装できましたが、実際に空間内を移動する体験はユーザーに大きな負担を与えやすく、酔いを引き起こす可能性があります。そのため、適切な場面で慎重に活用することが重要です。
この記事が皆様のお役に立てれば幸いです。また、誤りや改善点があれば、ぜひ教えていただけると幸いです。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる