Apple Vision Proでの 立体動画アプリの実装と40の工夫

-- Views

September 23, 25

スライド概要

シェア

またはPlayer版

埋め込む »CMSなどでJSが使えない場合

ダウンロード

関連スライド

各ページのテキスト
1.

iOSDC 2025 Apple Vision Proでの 立体動画アプリの実装と40の工夫 服部 智 @shmdevelop

2.

服部 智 Cyber AI Productions visionOS Expert at Cyber Agent Host: visionOS Engineer Meetup GitHub: satoshi0212 X: @shmdevelop

3.

空間にCGと情報がピタッと存在している状態が好き!

4.

今回話すこと visionOSでの空間UI作りで 試行錯誤して得た知見

5.

visionOSで開発する人が これから通る or 通ってきた道...

6.

「皆に使ってもらうアプリを作るぞ!」

7.

「展示会や体験会で触ってもらう予定!」

8.

Apple公式サンプルは一通り動かした ドキュメント、動画も見てみた

9.

何とか繋ぎ合わせて作った

10.

が...

11.

頑張って作ったアプリのイメージ図

12.

(これ、説明なしで体験できると思えないな...)

13.

そもそも 「指でタップ操作」とか 「視線でコントロール」 を前提にすると 体験の流れが難しいぞ...

14.

ストレスなく体験してもらうには どういったことを意識すると良いか

15.

スマホUI進化: 成熟した 一方 空間UI進化: まだ発展中

16.

https://developer.apple.com/jp/design/human-interface-guidelines/designing-for-visionos

17.

体験会、展示会に対応したアプリ

18.

100人以上の方に体験してもらいながら 改善サイクルを何度か回した事例

19.

ZELVISION XRプロジェクト Jリーグ初の取り組み FC町田ゼルビアのファンに 8K VR180映像を Apple Vision Proの 没入体験として提供し、 スポーツ体験の新たな価値を 実証するプロジェクト

20.

岩﨑謙汰 / イワケン • 株式会社サイバーエージェント XR研究所 所長 ミッション「XR事業の種を作る」 XRコミュニティを盛り上げる人生 • 所属経歴 東京工業大学 数理・計算科学系 修士号 (2016~2018) 株式会社サイバーエージェント, エンジニア入社 (2018~) 株式会社サイバーエージェント, XR研究所 所長 (2024~) • @iwaken71

21.

Apple Vision Proを初めて触る方のみ 複数の体験者へ同時案内 限られた時間とスペース 体験案内役(オペレーター)は最大2名

22.

キャリブレーションをする時間がない メガネの方でも体験できるようにしたい 化粧・髪型崩れを避けたい

23.

今回話すこと visionOSでの空間UI作りで 試行錯誤して得た知見

24.

立体動画視聴アプリの実装と動作概要

25.

立体動画視聴アプリ Immersive Video を視聴するアプリ

26.

Canon EOS R5C RF5.2mm F2.8 L DUAL FISHEYE

28.

Build compelling spatial photo and video experiences https://developer.apple.com/videos/play/wwdc2024/10166

29.

立体動画視聴アプリの実装と動作概要 コア機能 動画の情報取得 投影用半球メッシュ作成 プレイヤー

30.

立体動画視聴アプリの実装と動作概要 https://www.docswell.com/s/satoshi0212/K9VDPJ-2025-04-13-114049 112ページに渡って、撮影含めたパイプラインと実装を解説しています。

31.

ZELVISION XRの動作の様子

33.

今セッションの本題

34.

1. ゲストモードなし、キャリブレーションなしにした 21. Modeによるシンプルな画面切り替えにした 2. "戻る"をジェスチャーでできるようにした 22. UIを視界に収め手前にボタン群を配置した ユ 3. カスタムジェスチャーは1種のみにした 4. チュートリアル画面を設けた ザ 5. 空間にロゴ表示した 6. 視線方向に追従するようにした 23. メッセージ表示機能 24. タイムアップ機能を設けた 開 発 体 8. ずっと流れているBGMを付けた 験 9. 動画再生終了後に自動でメニュー画面に戻るようにした 28. 再生終了と戻るの判定 29. フェードイン、アウト、Opacity設定の拡張 10. 再生終了後、次のコンテンツをスクロールしながら表示した 30. Systemを使った連続処理 11. オペレータ用リセットジェスチャーを設けた 自 作 31. カルーセルを作成した 不 具 合 34. 円形ゲージが見た目上も完了するようにした 12. オペレータ用ジェスチャーが暴発しないようにした 13. BGMとSEで操作を感知できるようにした 32. カスタムボタンを作成した 33. ジェスチャー継続をゲージ表示するようにした 14. 移動せず座って体験できるようにした 15. アタッチメントを使いアプリをずっとアクティブにした 16. 再生終了後、ユーザーに自分で端末を外してもらうようにした 17. アダプタを使い、眼鏡の方でも体験できるようにした 開 発 26. 動画の長さを取得し表示した 27. Entityへの日本語テキスト表示追加 7. 再生開始で向きを固定するようにした 運 用 25. なめらかな視線追従になるよう実装した 18. 開発用メニューを上部に設置した 19. 開発用シークバーを付けた 20. 動画はアプリ内に埋め込まずファイルを選び持つようにした 考 え 方 35. CollisionとBillboardの不具合を回避した 36. 日本語が変なフォントにならないための実装 37. BGM音量が変わってしまう不具合を回避した 38. 実機で動かして何度か改善サイクル回す 39. シンプルにする 40. 観察する

35.

ユーザー体験

36.

1. ゲストモードなし、キャリブレーションなしにした 3〜5分の短縮。突破できないユーザへの対応不要。 視線によるコントロールをしない割り切りなので大きな分岐点。 補足: 内容次第です。別の体験ではキャリブレーションから提供もしています。

37.

2. "戻る"をジェスチャーでできるようにした 動画 直感的な操作。ゲージと音での操作フィードバック。

38.

3. カスタムジェスチャーは1種のみにした 体験者が迷わないよう可能な限りシンプルに。 "いいね"ジェスチャーが分かりやすくポジティブだったので採用。

39.

4. チュートリアル画面を設けた ジェスチャーを実際に体験。同時に説明も。

40.

5. 空間にロゴ表示する画面を設けた チュートリアル後、空間に物が存在する感を伝えつつ世界観の導入。 ローディング処理進行中。この後一覧画面なので、その前段階が欲しかった。

41.

6. UIが視線方向に追従するようにした 動画 ベストではないが一つの解。Digital Crown長押しでホームポジションは再設定できるが、 オペレーターがその状態を感知して体験者に声を掛けて長押し、の手順を避けたかった。

42.

7. 再生開始で向きを固定するようにした 動画 再生開始したらそちらを正面として扱った。これが自然な体験だと思える。

43.

8. ずっと流れているBGMを付けた 動画 体験への高まり演出。始まっている感。 サッカー応援のチャントにする案もあったが、本編で出てくるためあえて薄味にした。

44.

9. 動画再生終了後に自動でメニュー画面に戻るようにした 動画 あえて操作させる必要ないようにするため、操作なしで戻るようにした。 あえて操作させる、操作させないの判断に正解はあるのか。

45.

10. 再生終了後、次のコンテンツをスクロールしながら表示した 動画 今の場所を見失わないための工夫。細かい親切心。

46.

1. ゲストモードなし、キャリブレーションなしにした 21. Modeによるシンプルな画面切り替えにした 2. "戻る"をジェスチャーでできるようにした 22. UIを視界に収め手前にボタン群を配置した ユ 3. カスタムジェスチャーは1種のみにした 4. チュートリアル画面を設けた ザ 5. 空間にロゴ表示した 6. 視線方向に追従するようにした 23. メッセージ表示機能 24. タイムアップ機能を設けた 開 発 体 8. ずっと流れているBGMを付けた 験 9. 動画再生終了後に自動でメニュー画面に戻るようにした 28. 再生終了と戻るの判定 29. フェードイン、アウト、Opacity設定の拡張 10. 再生終了後、次のコンテンツをスクロールしながら表示した 30. Systemを使った連続処理 11. オペレータ用リセットジェスチャーを設けた 自 作 31. カルーセルを作成した 不 具 合 34. 円形ゲージが見た目上も完了するようにした 12. オペレータ用ジェスチャーが暴発しないようにした 13. BGMとSEで操作を感知できるようにした 32. カスタムボタンを作成した 33. ジェスチャー継続をゲージ表示するようにした 14. 移動せず座って体験できるようにした 15. アタッチメントを使いアプリをずっとアクティブにした 16. 再生終了後、ユーザーに自分で端末を外してもらうようにした 17. アダプタを使い、眼鏡の方でも体験できるようにした 開 発 26. 動画の長さを取得し表示した 27. Entityへの日本語テキスト表示追加 7. 再生開始で向きを固定するようにした 運 用 25. なめらかな視線追従になるよう実装した 18. 開発用メニューを上部に設置した 19. 開発用シークバーを付けた 20. 動画はアプリ内に埋め込まずファイルを選び持つようにした 考 え 方 35. CollisionとBillboardの不具合を回避した 36. 日本語が変なフォントにならないための実装 37. BGM音量が変わってしまう不具合を回避した 38. 実機で動かして何度か改善サイクル回す 39. シンプルにする 40. 観察する

47.

運用

48.

11. オペレータ用リセットジェスチャーを設けた 動画 オペレーターがシンプルな操作で初期化できるように。 これは良い機能だった。運用で多用。

49.

12. オペレータ用ジェスチャーが暴発しないようにした 左手はオペレーターのみが使用。かつ頭より上で操作が必須。 体験者が無意識に左手でピースサインしていても問題なし。

50.

13. BGMとSEで操作を感知できるようにした 動画 開始していること、画面進行していること、操作成功を感知できる。 限界はあるが現場ではかなり有効だった。

51.

14. 移動せず座って体験できるようにした 提供したい体験次第。 説明、体験、トラブル対応まで最小人数で制御可能。

52.

15. アタッチメントを使いアプリをずっとアクティブにした アプリは約2時間ずっと起動しっぱなしにできた。 BGMによりアプリ起動中判別もでき、状態管理も素朴になった。

53.

16. 再生終了後ユーザーに自分で端末を外してもらうようにした オペレーターの案内が一つ減る 効果的でした

54.

17. アダプタを使い、眼鏡の方でも体験できるようにした これは体験会形式では必須と思える対応 3割は眼鏡の方が来ます!

55.

1. ゲストモードなし、キャリブレーションなしにした 21. Modeによるシンプルな画面切り替えにした 2. "戻る"をジェスチャーでできるようにした 22. UIを視界に収め手前にボタン群を配置した ユ 3. カスタムジェスチャーは1種のみにした 4. チュートリアル画面を設けた ザ 5. 空間にロゴ表示した 6. 視線方向に追従するようにした 23. メッセージ表示機能 24. タイムアップ機能を設けた 開 発 体 8. ずっと流れているBGMを付けた 験 9. 動画再生終了後に自動でメニュー画面に戻るようにした 28. 再生終了と戻るの判定 29. フェードイン、アウト、Opacity設定の拡張 10. 再生終了後、次のコンテンツをスクロールしながら表示した 30. Systemを使った連続処理 11. オペレータ用リセットジェスチャーを設けた 自 作 31. カルーセルを作成した 不 具 合 34. 円形ゲージが見た目上も完了するようにした 12. オペレータ用ジェスチャーが暴発しないようにした 13. BGMとSEで操作を感知できるようにした 32. カスタムボタンを作成した 33. ジェスチャー継続をゲージ表示するようにした 14. 移動せず座って体験できるようにした 15. アタッチメントを使いアプリをずっとアクティブにした 16. 再生終了後、ユーザーに自分で端末を外してもらうようにした 17. アダプタを使い、眼鏡の方でも体験できるようにした 開 発 26. 動画の長さを取得し表示した 27. Entityへの日本語テキスト表示追加 7. 再生開始で向きを固定するようにした 運 用 25. なめらかな視線追従になるよう実装した 18. 開発用メニューを上部に設置した 19. 開発用シークバーを付けた 20. 動画はアプリ内に埋め込まずファイルを選び持つようにした 考 え 方 35. CollisionとBillboardの不具合を回避した 36. 日本語が変なフォントにならないための実装 37. BGM音量が変わってしまう不具合を回避した 38. 実機で動かして何度か改善サイクル回す 39. シンプルにする 40. 観察する

56.

開発

57.

18. 開発用メニューを上部に設置した 動画 デバッグ、開発用メニューです。ファイル選択など。 裏に置いてしまうと開発時に振り返るのがとても面倒。

58.

19. 開発用シークバーを付けた 動画 始めと終わりの確認が重要で開発に必須。 空間体験でも似た機能は必要。体験者には存在を案内しない。

59.

20. 動画はアプリ内に埋め込まずファイルを選び持つようにした 10GB超ファイルのリソースの扱い 1. リソース埋め込み アプリ内埋め込みリソースは安定するが入れ替えが面倒 2. ダウンロード 3. 都度ファイル読み込み 4. 一度だけファイル読み込み 5. ストリーミング AirDrop経由でAVPに渡しファイル選択で入れ替え可能に ダウンロードの方がスマートなケースあるが 開発サイクルを回す人数や編成次第

60.

21. Modeによるシンプルな画面切り替えにした enum AppMode: String { case loading case tutorial case splash case selection case playing } AppModelで持つModeの変化で画面切り替えを制御。より良い実装あるかも。 今回の規模ではこれで十分だった。テスト用に特定画面表示する場合も楽。

61.

ImmersiveView.swift ZStack { if appModel.appMode == .loading { LoadingView() .environment(appModel) } else if appModel.appMode == .tutorial { TutorialView() .environment(appModel) .environment(handTrackingModel) .environment(headTrackingModel) .frame(depth: 0) } else if appModel.appMode == .splash { SplashView() .environment(appModel) .frame(depth: 0) } else if appModel.appMode == .selection { SelectionView() .environment(appModel) .environment(handTrackingModel) .environment(headTrackingModel) .frame(depth: 0) } else if appModel.appMode == .playing { VideoScreenView(selectedStream: appModel.selectedStream, baseTransform: headTrackingModel.originFromDeviceTransform()) .environment(appModel) .environment(handTrackingModel) .environment(headTrackingModel) .frame(depth: 0) } }

62.

ImmersiveView.swift ZStack { if appModel.appMode == .loading { LoadingView() .environment(appModel) } else if appModel.appMode == .tutorial { TutorialView() .environment(appModel) .environment(handTrackingModel) .environment(headTrackingModel) .frame(depth: 0) } else if appModel.appMode == .splash { SplashView() .environment(appModel) .frame(depth: 0) } else if appModel.appMode == .selection { SelectionView() .environment(appModel) .environment(handTrackingModel) .environment(headTrackingModel) .frame(depth: 0) } else if appModel.appMode == .playing { VideoScreenView(selectedStream: appModel.selectedStream, baseTransform: headTrackingModel.originFromDeviceTransform()) .environment(appModel) .environment(handTrackingModel) .environment(headTrackingModel) .frame(depth: 0) } }

63.

22. UIを視界に収め手前にボタン群を配置した 手動操作。見渡す必要なし。指針の一つとして有効では。 "見渡して発見する感動" は切り捨て。

64.

23. メッセージ表示機能 汎用のメッセージ表示機能を作り横展開した。 もっと洗練できそう。

65.

24. タイムアップ機能を設けた

66.

24. タイムアップ機能を設けた 動画

67.

24. タイムアップ機能を設けた 体験時間のデザイン 前述の画面遷移とメッセージ機能により実装は楽。 オペレーターが個別に時間計測したり、特定の体験 者が機器を占有してしまう状態を回避。 カウントダウン開始は1つ目の動画を再生したら開 始。タイマー終了時、あえて動画は終了しない。 体験者の良心に任せる。

68.

25. なめらかな視線追従になるよう実装した やや遅れて追従するようにした。 ぴったり追従すると違和感があった。 動画

69.

26. 動画の長さを取得し表示した 情報表示するかしないかの判断 体験時間が限られていることもあり、コンテンツ の長さを知るのは心理的にも必要 見慣れたYoutubeの時間表示を参考にした

70.

27. Entityへの日本語テキスト表示追加 少し実装をする必要があった

71.

27. Entityへの日本語テキスト表示追加 UIGraphicsBeginImageContextWithOptions(size, false, 0) let attributed = NSAttributedString(string: text, attributes: attributes) attributed.draw(in: CGRect(origin: .zero, size: size)) let image = UIGraphicsGetImageFromCurrentImageContext()! UIGraphicsEndImageContext() let texture = try! TextureResource(image: image.cgImage!, options: .init(semantic: nil)) var material = UnlitMaterial(texture: texture) material.color = .init(tint: .white, texture: .init(texture)) material.blending = .transparent(opacity: PhysicallyBasedMaterial.Opacity(floatLiteral: 1.0)) let mesh = MeshResource.generatePlane(width: planeSize.x, height: planeSize.y) let entity = ModelEntity(mesh: mesh, materials: [material]) return entity

72.

28. 再生終了と戻るの判定 .onChange(of: videoPlayer.shouldGoToSelectionView) { _, value in if value { appModel.videoExperienceCoordinator.isPlayReachedEnd = true appModel.appMode = .selection } } .onChange(of: handTrackingModel.thumbsUpDetector.isConfirmed) { _, value in if value { sePlayer.play() appModel.appMode = .selection } } // 再生終了後の次コンテンツへのスクロール Task { if appModel.videoExperienceCoordinator.isPlayReachedEnd { try await Task.sleep(for: .seconds(2)) appModel.videoExperienceCoordinator.isPlayReachedEnd = false carouselModel.moveRight() } } それぞれシナリオ異なるので区別して制御するのを忘れずに

73.

29. フェードイン、アウト、Opacity設定の拡張 var opacity: Float { get { return components[OpacityComponent.self]?.opacity ?? 1.0 } set { if !components.has(OpacityComponent.self) { components[OpacityComponent.self] = OpacityComponent(opacity: newValue) } else { components[OpacityComponent.self]?.opacity = newValue } } } フェードイン、アウトは多用するため使いまわせるよう実装

74.
[beta]
Entity+.swift
@MainActor
func setOpacity(_ opacity: Float, animated: Bool, duration: TimeInterval = 0.2, delay: TimeInterval = 0,
completion: (() -> Void) = {}) async {
guard animated, let scene else {
self.opacity = opacity
return
}
if !components.has(OpacityComponent.self) {
components[OpacityComponent.self] = OpacityComponent(opacity: 1.0)
}
let animation = FromToByAnimation(name: "Entity/setOpacity", to: opacity, duration: duration,
timing: .linear, isAdditive: false, bindTarget: .opacity, delay: delay)
do {

}

let animationResource: AnimationResource = try .generate(with: animation)
let animationPlaybackController = playAnimation(animationResource)
let filtered = scene.publisher(for: AnimationEvents.PlaybackTerminated.self)
.filter { $0.playbackController == animationPlaybackController }
_ = filtered.values.filter { $0.playbackController.isComplete }
completion()
} catch {
print("Could not generate animation: \(error.localizedDescription)")
}

@MainActor
func setOpacity(_ opacity: Float) {
self.opacity = opacity
}

75.
[beta]
30. Systemを使った連続処理
struct ClosureComponent: Component {
let closure: (TimeInterval) -> Void
init (closure: @escaping (TimeInterval) -> Void) {
self.closure = closure
ClosureSystem.registerSystem()
}
}
struct ClosureSystem: System {
static let query = EntityQuery(where: .has(ClosureComponent.self))
init(scene: RealityKit.Scene) {}
func update(context: SceneUpdateContext) {
for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
guard let comp = entity.components[ClosureComponent.self] else { continue }
comp.closure(context.deltaTime)
}
}
}

連続処理を何でやるのか。Timer、DisplayLink、etc.

76.

1. ゲストモードなし、キャリブレーションなしにした 21. Modeによるシンプルな画面切り替えにした 2. "戻る"をジェスチャーでできるようにした 22. UIを視界に収め手前にボタン群を配置した ユ 3. カスタムジェスチャーは1種のみにした 4. チュートリアル画面を設けた ザ 5. 空間にロゴ表示した 6. 視線方向に追従するようにした 23. メッセージ表示機能 24. タイムアップ機能を設けた 開 発 体 8. ずっと流れているBGMを付けた 験 9. 動画再生終了後に自動でメニュー画面に戻るようにした 28. 再生終了と戻るの判定 29. フェードイン、アウト、Opacity設定の拡張 10. 再生終了後、次のコンテンツをスクロールしながら表示した 30. Systemを使った連続処理 11. オペレータ用リセットジェスチャーを設けた 自 作 31. カルーセルを作成した 不 具 合 34. 円形ゲージが見た目上も完了するようにした 12. オペレータ用ジェスチャーが暴発しないようにした 13. BGMとSEで操作を感知できるようにした 32. カスタムボタンを作成した 33. ジェスチャー継続をゲージ表示するようにした 14. 移動せず座って体験できるようにした 15. アタッチメントを使いアプリをずっとアクティブにした 16. 再生終了後、ユーザーに自分で端末を外してもらうようにした 17. アダプタを使い、眼鏡の方でも体験できるようにした 開 発 26. 動画の長さを取得し表示した 27. Entityへの日本語テキスト表示追加 7. 再生開始で向きを固定するようにした 運 用 25. なめらかな視線追従になるよう実装した 18. 開発用メニューを上部に設置した 19. 開発用シークバーを付けた 20. 動画はアプリ内に埋め込まずファイルを選び持つようにした 考 え 方 35. CollisionとBillboardの不具合を回避した 36. 日本語が変なフォントにならないための実装 37. BGM音量が変わってしまう不具合を回避した 38. 実機で動かして何度か改善サイクル回す 39. シンプルにする 40. 観察する

77.

自作

78.

31. カルーセルを作成した Entity + Attachmentでガタつきが解消せず Entity群で実現

84.

32. カスタムボタンを作成した 体験者がボタンをなかなか押せないことを観測 → 直接タッチする操作感の良いボタンを作ると決断 (カスタムUIは必要最低限にしたい気持ち)

85.

32. カスタムボタン 押し込み時にアニメーションを付けた 単純に薄いBoxだと挙動が破綻 → 手前5cmの反応エリア、奥25cmの押し込み中エリア。状態管理。

86.

32. カスタムボタン 動画

87.

33. ジェスチャー継続をゲージ表示するようにした 意図せず発火しないために継続判定 状態可視化が大事でした 動画

88.

1. ゲストモードなし、キャリブレーションなしにした 21. Modeによるシンプルな画面切り替えにした 2. "戻る"をジェスチャーでできるようにした 22. UIを視界に収め手前にボタン群を配置した ユ 3. カスタムジェスチャーは1種のみにした 4. チュートリアル画面を設けた ザ 5. 空間にロゴ表示した 6. 視線方向に追従するようにした 23. メッセージ表示機能 24. タイムアップ機能を設けた 開 発 体 8. ずっと流れているBGMを付けた 験 9. 動画再生終了後に自動でメニュー画面に戻るようにした 28. 再生終了と戻るの判定 29. フェードイン、アウト、Opacity設定の拡張 10. 再生終了後、次のコンテンツをスクロールしながら表示した 30. Systemを使った連続処理 11. オペレータ用リセットジェスチャーを設けた 自 作 31. カルーセルを作成した 不 具 合 34. 円形ゲージが見た目上も完了するようにした 12. オペレータ用ジェスチャーが暴発しないようにした 13. BGMとSEで操作を感知できるようにした 32. カスタムボタンを作成した 33. ジェスチャー継続をゲージ表示するようにした 14. 移動せず座って体験できるようにした 15. アタッチメントを使いアプリをずっとアクティブにした 16. 再生終了後、ユーザーに自分で端末を外してもらうようにした 17. アダプタを使い、眼鏡の方でも体験できるようにした 開 発 26. 動画の長さを取得し表示した 27. Entityへの日本語テキスト表示追加 7. 再生開始で向きを固定するようにした 運 用 25. なめらかな視線追従になるよう実装した 18. 開発用メニューを上部に設置した 19. 開発用シークバーを付けた 20. 動画はアプリ内に埋め込まずファイルを選び持つようにした 考 え 方 35. CollisionとBillboardの不具合を回避した 36. 日本語が変なフォントにならないための実装 37. BGM音量が変わってしまう不具合を回避した 38. 実機で動かして何度か改善サイクル回す 39. シンプルにする 40. 観察する

89.

不具合

90.

34. 円形ゲージが見た目上も完了するようにした progress達成と同時に完了するとゲージが完了しない (不具合と言うか自分の実装ミス...)

91.
[beta]
34. 円形ゲージが見た目上も完了するようにした

private func startTimer() {
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: 0.016, repeats: true) { [weak self] _ in
guard let self = self, let start = self.startTime else { return }
let elapsed = Date().timeIntervalSince(start)
self.progress = min(CGFloat(elapsed / self.requiredDuration), 1.0)
if elapsed > self.requiredDuration + 0.16 {
10フレーム分の超過を判定に追加
self.isConfirmed = true
self.timer?.invalidate()
playSound(filename: "se_001", filetype: "mp3")
}
}
}

progress達成と同時に完了するとゲージが完了しない

92.

34. 円形ゲージが見た目上も完了するようにした

93.

35. CollisionとBillboardの不具合を回避した BillboardComponentとgenerateCollisionShapesを同時に設定すると Collisionが回転に追従しない挙動

94.
[beta]
35. CollisionとBillboardの不具合を回避した
private func updateBillboardAnchor(deltaTime: TimeInterval, billboardAnchor: Entity) {
let currentTransform = headTrackingModel.originFromDeviceTransform()
var targetPosition = currentTransform.translation() - 0.6 * currentTransform.forward()
targetPosition.y += 0.05
let ratio = Float(pow(0.96, deltaTime / (16 * 1E-3)))
let newPosition = ratio * billboardAnchor.position(relativeTo: nil) + (1 - ratio) * targetPosition
billboardAnchor.setPosition(newPosition, relativeTo: nil)
let direction = normalize(currentTransform.translation()
- billboardAnchor.position(relativeTo: nil)) * -1
let rotation = simd_quaternion(SIMD3<Float>(0, 0, -1), direction)
billboardAnchor.orientation = rotation
let gazeDirection = normalize(currentTransform.forward())
let dotProduct = dot(gazeDirection, SIMD3<Float>(0, 1, 0))
let angleRadians = acos(dotProduct)
let angleDegrees = angleRadians * (180.0 / .pi)
billboardAnchor.isEnabled = (angleDegrees < 130 && !appModel.isShowingMenu)
}

BillboardComponentを使わず手動制御

95.

35. CollisionとBillboardの不具合を回避した

96.

36. 日本語が変なフォントにならないための実装 Text("メニューに戻る".japaneseAttributedString) .font(.largeTitle) .fontWeight(.bold) .frame(minWidth: 800, minHeight: 280) extension String { var japaneseAttributedString: AttributedString { var ret = AttributedString(self) ret.languageIdentifier = "ja" return ret } } visionOS 2、言語設定 Englishで発生していた

97.

37. BGM音量が変わってしまう不具合を回避した AudioServicesPlaySystemSound(9999) and private var audioPlayer: AVAudioPlayer? private func playSound(filename: String, filetype: String) { if let path = Bundle.main.path(forResource: filename, ofType: filetype) { let url = URL(fileURLWithPath: path) audioPlayer = try! AVAudioPlayer(contentsOf: url) audioPlayer?.play() } } 再生方式が複数ある場合にBGM音量が爆音になる挙動

98.

1. ゲストモードなし、キャリブレーションなしにした 21. Modeによるシンプルな画面切り替えにした 2. "戻る"をジェスチャーでできるようにした 22. UIを視界に収め手前にボタン群を配置した ユ 3. カスタムジェスチャーは1種のみにした 4. チュートリアル画面を設けた ザ 5. 空間にロゴ表示した 6. 視線方向に追従するようにした 23. メッセージ表示機能 24. タイムアップ機能を設けた 開 発 体 8. ずっと流れているBGMを付けた 験 9. 動画再生終了後に自動でメニュー画面に戻るようにした 28. 再生終了と戻るの判定 29. フェードイン、アウト、Opacity設定の拡張 10. 再生終了後、次のコンテンツをスクロールしながら表示した 30. Systemを使った連続処理 11. オペレータ用リセットジェスチャーを設けた 自 作 31. カルーセルを作成した 不 具 合 34. 円形ゲージが見た目上も完了するようにした 12. オペレータ用ジェスチャーが暴発しないようにした 13. BGMとSEで操作を感知できるようにした 32. カスタムボタンを作成した 33. ジェスチャー継続をゲージ表示するようにした 14. 移動せず座って体験できるようにした 15. アタッチメントを使いアプリをずっとアクティブにした 16. 再生終了後、ユーザーに自分で端末を外してもらうようにした 17. アダプタを使い、眼鏡の方でも体験できるようにした 開 発 26. 動画の長さを取得し表示した 27. Entityへの日本語テキスト表示追加 7. 再生開始で向きを固定するようにした 運 用 25. なめらかな視線追従になるよう実装した 18. 開発用メニューを上部に設置した 19. 開発用シークバーを付けた 20. 動画はアプリ内に埋め込まずファイルを選び持つようにした 考 え 方 35. CollisionとBillboardの不具合を回避した 36. 日本語が変なフォントにならないための実装 37. BGM音量が変わってしまう不具合を回避した 38. 実機で動かして何度か改善サイクル回す 39. シンプルにする 40. 観察する

99.

考え方

100.

38. 実機で動かして何度か改善サイクル回す 実機で動かすと気付くことがあまりにも多い 動作キャプチャ映像で見るより数倍迫力が出る 毎週、隔週でサイクルを回した 毎回10件以上の気付きと改善

101.

39. シンプルにする そもそもAVPを被るのが初めての人を対象にしている 迷わせない ジェスチャーは1つのみ 常にコンセプトに立ち返る必要がある 余談: 体験者が操作する場面はあえて設けた

102.

40. 観察する 操作につまづくポイントが浮かび上がってくる 例: 押し込み操作ができない 反応が良いところも見えてくる 映像内でハイタッチする箇所 体験後に感想を聞くと体験後がさみしい印象にならない、など 毎回必ず収穫があった

103.

まとめ X: @shmdevelop 現在も改善を重ねておりこの内容も日々アップデート Xで最新情報共有してます