👓

【Swift】VisionOS の content と attachment を動的に切り替える

2023/12/18に公開

初めに

今回は RealityView に表示させる content と attachments の位置をボタンによって動的に切り替える方法を共有したいと思います。
完成イメージは以下の通りです。

https://youtu.be/LYYpm2hbM-g

以下の記事では似た内容を扱っているので、よろしければご覧ください。

https://zenn.dev/koichi_51/articles/6df83fcf31982a

記事の対象者

  • Swift, SwiftUI 学習者
  • Vision OS のモデルをカスタマイズしたい方

目的

完成イメージにもあった通り、ボタンを押した際のアクションとして表示されている content や attachment の位置を切り替える実装を行いたいと思います。

また、以下のようにアニメーション付きで位置を切り替える実装も行いたいと思います。

https://youtu.be/pNRzLYdD18s

実装

まずは以下のようにボタンによって content や attachment の位置を瞬時に切り替える実装を行いたいと思います。

https://youtu.be/LYYpm2hbM-g

まず初めにコードを共有します。

RealityView { content, attachments in
    guard let entity = try? await Entity(named: "Scene", in: realityKitContentBundle) else {
    fatalError("Unalbe to load scene model")
    }
    
    guard let env = try? await EnvironmentResource(named: "Directional") else { return }
    let iblComponent = ImageBasedLightComponent(source: .single(env), intensityExponent: 10.0)
            
    entity.components[ImageBasedLightComponent.self] = iblComponent
    entity.components.set(ImageBasedLightReceiverComponent(imageBasedLight: entity))
            
    content.add(entity)
            
    if let sceneAttachment = attachments.entity(for: attachmentID) {
        content.add(sceneAttachment)
    }
} update: { content, attachments in
    if let entity = content.entities.first {
        let uniformPosition = isBehind ? SIMD3<Float>(-0.17, 0, 0) : SIMD3<Float>(0.17, 0, 0)
        let uniformRotate = isBehind ? simd_quatf(angle: .pi, axis: SIMD3<Float>(0, 1, 0)) : simd_quatf(angle: 0, axis: SIMD3<Float>(0, 1, 0))
                
        entity.transform.rotation = uniformRotate
        entity.position = uniformPosition
    }
            
    if let attachment = attachments.entity(for: attachmentID) {
        let uniformPosition = isBehind ? SIMD3<Float>(0.17, 0, 0) : SIMD3<Float>(-0.17, 0, 0)
        attachment.position = uniformPosition
    }
} placeholder: {
    ProgressView()
        .progressViewStyle(.circular)
        .controlSize(.large)
} attachments: {
    Attachment(id: attachmentID) {
        VStack {
            Text("飛行機のモデル")
		.font(.title)
            if isBehind {
                Text("後側")
                    .font(.headline)
                    .padding()
            } else {
                Text("前側")
                    .font(.headline)
                    .padding()
            }
            Button(action: {
                isBehind.toggle()
            }) {
                isBehind ? Text("前側を見る") : Text("後側を見る")
            }
        }
    }
}

それぞれ詳しくみていきましょう。

以下の部分に関しては、entity として、Scene というモデルを追加しています。
今回の場合は飛行機のモデルの定義がこの部分に当たります。

guard let entity = try? await Entity(named: "Scene", in: realityKitContentBundle) else {
    fatalError("Unalbe to load scene model")
}

以下の部分に関しては、Directionalファイルからモデルに ImageBasedLightComponent を追加しています。

guard let env = try? await EnvironmentResource(named: "Directional") else { return }
let iblComponent = ImageBasedLightComponent(source: .single(env), intensityExponent: 10.0)
            
entity.components[ImageBasedLightComponent.self] = iblComponent
entity.components.set(ImageBasedLightReceiverComponent(imageBasedLight: entity))

詳しくは以下の記事をご覧ください。

https://zenn.dev/koichi_51/articles/1fed4d0aa91c93

以下の部分では、モデルの近くに表示させる attachment を追加しています。
今回の場合の attachment は「飛行機のモデル」をタイトルにもつビューに当たります。

if let sceneAttachment = attachments.entity(for: attachmentID) {
    content.add(sceneAttachment)
}

以下の部分は update の中に記述している部分です。

if let entity = content.entities.first {
    let uniformPosition = isBehind ? SIMD3<Float>(-0.17, 0, 0) : SIMD3<Float>(0.17, 0, 0)
    let uniformRotate = isBehind ? simd_quatf(angle: .pi, axis: SIMD3<Float>(0, 1, 0)) : simd_quatf(angle: 0, axis: SIMD3<Float>(0, 1, 0))
                
    entity.transform.rotation = uniformRotate
    entity.position = uniformPosition
}

let uniformPosition = isBehind ? SIMD3<Float>(-0.17, 0, 0) : SIMD3<Float>(0.17, 0, 0) の部分では、もし isBehind の値が true つまり、モデルが後ろを向いている場合はモデルを x軸座標の -0.17 の位置に表示させ、モデルが前を向いている場合はモデルを x座標の 0.17 の位置に表示させるようにしています。
ここで切り替えることでボタンを押した際にモデルの位置を切り替えることができます。

let uniformRotate = isBehind ? simd_quatf(angle: .pi, axis: SIMD3<Float>(0, 1, 0)) : simd_quatf(angle: 0, axis: SIMD3<Float>(0, 1, 0)) の部分では、もし isBehind の値が true の場合は y 軸にモデルを1回転させ、 isBehind の値が false の場合はモデルを回転させないようにしています。
このようにすることで、isBehind の値に応じてモデルの前後の向きを切り替えることができるようになります。

以下の部分では、attachment の定義をしています。
isBehind の値に応じて、「前側」「後側」のテキストを切り替えるなどしています。
また、isBehind の値を切り替えるボタンも配置しています。

Attachment(id: attachmentID) {
    VStack {
	Text("飛行機のモデル")
	    .font(.title)
	if isBehind {
	    Text("後側")
		.font(.headline)
		.padding()
	} else {
	    Text("前側")
		.font(.headline)
		.padding()
        }
        Button(action: {
            isBehind.toggle()
        }) {
            isBehind ? Text("前側を見る") : Text("後側を見る")
	}
    }
}

以上のコードで以下のようにボタンによって content や attachment の位置や向きを変えることができるようになったのではないでしょうか?

https://youtu.be/LYYpm2hbM-g

アニメーション付きの実装

次に以下のようにアニメーションをつけて、位置やモデルの向きを切り替える実装を行なってみたいと思います。

https://youtu.be/pNRzLYdD18s

先述のボタンを押した際に瞬時に位置や向きを切り替える実装のコードのうち、update にあたる部分のみを以下のように切り替えることで実現できます。

} update: { content, attachments in
    if let entity = content.entities.first {
	let newPosition = isBehind ? SIMD3<Float>(-0.17, 0, 0) : SIMD3<Float>(0.17, 0, 0)
        let newRotation = isBehind ? simd_quatf(angle: .pi, axis: SIMD3<Float>(0, 1, 0)) : simd_quatf(angle: 0, axis: SIMD3<Float>(0, 1, 0))
                
        // アニメーションの設定
	var transform = entity.transform
	transform.translation = newPosition
	transform.rotation = newRotation

	// 3秒かけてアニメーション
	entity.move(to: transform, relativeTo: entity.parent, duration: 3, timingFunction: .easeInOut)
    }
            
    if let attachment = attachments.entity(for: attachmentID) {
	let newPosition = isBehind ? SIMD3<Float>(0.17, 0, 0) : SIMD3<Float>(-0.17, 0, 0)
                
	// アニメーションの設定
	var transform = Transform.identity
	transform.translation = newPosition
                
	// 3秒かけてアニメーション
	attachment.move(to: transform, relativeTo: attachment.parent, duration: 3, timingFunction: .easeInOut)
    }
}

それぞれ詳しくみていきましょう。

以下の部分では、newPosition として、アニメーションが実行されて終了するときにいるべき位置や向きを指定しています。アニメーションの終着点、ゴール地点と言えるでしょう。
ここでは isBehind の値によってゴール地点を切り替えています。

let newPosition = isBehind ? SIMD3<Float>(-0.17, 0, 0) : SIMD3<Float>(0.17, 0, 0)
let newRotation = isBehind ? simd_quatf(angle: .pi, axis: SIMD3<Float>(0, 1, 0)) : simd_quatf(angle: 0, axis: SIMD3<Float>(0, 1, 0))

以下の部分では、先程定義した位置と向きのゴール地点をそれぞれ translationrotation に割り振り、transform という一つの変数にしています。

// アニメーションの設定
var transform = entity.transform
transform.translation = newPosition
transform.rotation = newRotation

以下の部分では、先程指定した transoform をアニメーションとし、3秒間でアニメーションが終了するようにしています。

entity.move(to: transform, relativeTo: entity.parent, duration: 3, timingFunction: .easeInOut)

easeInOut に関しては、ドキュメントに以下のような記述がありました。
「An ease in and out animation starts slowly, increasing its speed towards the halfway point, and finally decreasing the speed towards the end of the animation」
つまり、 easeInOut のアニメーションはゆっくりと始まり、中間点に向かって速度が上がり、最後にアニメーションの終わりに向かって速度が遅くなるということです。

attachment の設定に関しても基本的には同じような実装であるので、解説を省きたいと思います。

以上のコードで以下のようにアニメーションをつけた content や attachment の位置や向きの変更を実装できたのではないでしょうか?

https://youtu.be/pNRzLYdD18s

まとめ

最後まで読んでいただいてありがとうございました。
別角度からモデルを見せたり、アニメーションをつけてモデルを移動させたりすることで、VisionOS の良さもより引き出せるかと思うので、モデルを実装する際の助けになれば嬉しいです。

誤っている点や他の実装方法等あればご指摘いただけると幸いです。

参考

https://stackoverflow.com/questions/59335075/how-to-animate-a-models-rotation-in-realitykit

https://zenn.dev/koichi_51/articles/6df83fcf31982a

https://zenn.dev/koichi_51/articles/1fed4d0aa91c93

https://developer.apple.com/documentation/swiftui/animation/easeinout

Discussion