📈

Apple Vision Pro開発の第一歩: XcodeとUnityで始める簡単開発ガイド

2024/07/19に公開2

apple-vision-pro-first-step.png

はじめに

MESONで実施中の MESON Apple Vision Proアドベントカレンダー #2 22日目の記事となります。
前日の記事はこちらになります。

この記事は、これからApple Vision Proで開発(visionOS向けの開発)を始める方向けに、開発の第一歩を踏み出す一助となるような内容をまとめたものです。

まず、開発に必要なものを紹介し、次に開発環境の準備を2つのパターンで説明します。
最後にプロジェクトの作成から動作確認までの手順を、MixedRealityの簡易的なアプリケーションを例に解説します。

開発に必要なもの

visionOS向けの開発には、Appleシリコンを搭載したMacが必要です。
シミュレーターを使って、テストを行うこともできますが、ハンドトラッキングや平面検知などの機能のテストには、実機デバイスが必要となります。

開発環境の準備

visionOS向けの開発環境を構築する方法として、Xcodeを使った開発とUnityを使った開発の2つのパターンを紹介します。

Xcodeを使った開発

Xcodeを使った開発では、Xcode 15が必要となります。

  1. XcodeをApp Storeからダウンロードしてインストール
    AppStore01.png

  2. 初回起動時に、開発するプラットフォームを選択する画面が表示されるので、visionOSを選択しインストール
    開発に必要なSDKやシミュレーターがインストールされます。
    ※ β版時代の情報となりますが、この記事に掲載している内容に近しい画面が表示されます。

Unityを使った開発

Unityを使った開発でも、Xcode 15が必要となるため、Xcodeを使った開発と同様の設定が必要です。
また、visionOS向けの開発を行うためには、Unity Editorのバージョン 2022.3.19f1以上が必要です。
Unity Editorのインストールは、Unity Hubから行うことができます。
UnityHub01.png

開発言語とフレームワーク・ツール、公式サンプルの紹介

ここでは、これから開発を始めるにあたり、使用する言語やフレームワーク、ツールキットについて簡単に紹介します。
また、公式サンプルを確認することで、どのようにプログラムを書くのか参考にすることができます。

Xcodeを使った開発

開発言語

Swift

フレームワーク

名称 概要
SwiftUI ユーザーインターフェイスを構築するための宣言型フレームワーク
RealityKit 高品質な拡張現実(AR)体験を提供するためのフレームワーク
ARkit 拡張現実(AR)フレームワーク

公式サンプル

visionOS 2.0のベータリリースに伴い、公式のサンプルも2.0のものに更新されているものが多いですが、ここに情報が集まっています。
https://developer.apple.com/documentation/visionos

Unityを使った開発

開発言語

C#

ライブラリおよびツールキット

名称 概要
AR Foundation クロスプラットフォームでのAR開発をサポートするライブラリ
XR Interaction Toolkit ARおよびVRアプリケーションにおけるインタラクションシステムを構築するためのツールキット
XR Hands 手のトラッキングとジェスチャー認識をサポートするライブラリ
PolySpatial visionOS向けの開発をサポートするライブラリ

公式サンプル

PolySpatialを導入した際に、Package Managerからインポートできるサンプルがあります。
また、以下のページにアプリケーション作成時のテンプレートとして利用できるサンプルが公開されています。
https://drive.google.com/drive/folders/1Oe-6bBCCmk7okbK832HWiYFbM8mV0XrZ

ハンズオン

ここでは、平面検知、ハンドトラッキングを利用した簡単なMixedRealityのアプリケーションを作成します。
Xcodeを使った開発とUnityを使った開発の2つのパターンで、ほぼ同等のアプリケーションを作成しています。

Xcodeを使った開発

1. プロジェクトの作成

Xcodeを起動し、visionOS向けの新規プロジェクトを作成します。
Xcode01.png
Xcode02.png

項目 設定内容
Team Xcodeを使った開発で登録したアカウントを選択
Initial Scene Volumeを選択
Immersive Space Renderer RealityKitを選択
Immersive Space Mixedを選択

2. ビルドと実行

2.1. シミュレーターでの実行

Xcodeで Simulatorを選択し、実行します。
Xcode06.png

2.2. 実機デバイスでの実行

  1. Xcodeで Window > Devices and Simulatorsを選択し、ウインドウを開きます。
  2. 実機デバイスの 設定 > リモートデバイス メニューから開発しているMacを選択します。
    • Xcodeのウインドウに実機デバイスが表示されるので、選択すると接続されます。
      Xcode11.png
  3. シミュレーターでの実行と同様に、実行ボタンを押すことで実機デバイスでのテストが可能です。

3. 平面検知の実装

平面検知とハンドトラッキングは、ARKitを使用して実装します。
ARKitのセッションとして同じものを利用するため、平面検知限定のプログラム名ではなく、ARInteractionManager.swiftとしてコードを追加していきます。

3.1. 水平面のみ検知する

.horizontalのみを指定することで、水平面に絞ります。

ARInteractionManager.swift
private let planeData = PlaneDetectionProvider(alignments: [.horizontal])

3.2. 地面のみ処理を行う

.floorのみを処理するようにすることで、地面に限定できます。

ARInteractionManager.swift
func processPlaneDetectionUpdateHandler() async
{
    for await update in planeData.anchorUpdates
    {
        // 処理を地面のみに限定する
        guard update.anchor.classification == .floor else
        {
            continue
        }
        
        switch update.event
        {
        case .added, .updated:
            await handlePlaneUpdate(update.anchor)
        case .removed:
            await handlePlaneRemoval(update.anchor)
        }
    }
}
完成した ARInteractionManager.swift のコード
ARInteractionManager.swift
import ARKit
import RealityKit

final class ARInteractionManager: ObservableObject
{
    // ARInteractionManager全体を表すエンティティ
    @Published var scene = Entity()
    
    // ARKitセッションのインスタンス
    private let session = ARKitSession()
    // .horizontalのみを指定することで、水平面に絞る
    private let planeData = PlaneDetectionProvider(alignments: [.horizontal])
    
    @MainActor private var planeEntities: [UUID: Entity] = [:]
    
    func runARSession() async
    {
        do
        {
            // 平面検知を開始
            try await session.run([planeData])
        }
        catch
        {
            print("Error in runARSession: \(error)")
        }
    }
    
    func processPlaneDetectionUpdateHandler() async
    {
        for await update in planeData.anchorUpdates
        {
            // 処理を地面のみに限定する
            guard update.anchor.classification == .floor else
            {
                continue
            }
            
            switch update.event
            {
            case .added, .updated:
                await handlePlaneUpdate(update.anchor)
            case .removed:
                await handlePlaneRemoval(update.anchor)
            }
        }
    }
    
    @MainActor
    private func handlePlaneUpdate(_ anchor: PlaneAnchor)
    {
        if planeEntities[anchor.id] == nil
        {
            createPlaneEntity(anchor)
        }
        
        updatePlaneEntity(anchor)
    }

    @MainActor
    private func createPlaneEntity(_ anchor: PlaneAnchor)
    {
        let entity = Entity()
        
        // 現実世界の地面を表示するため、透明のマテリアルを作成
        let material = UnlitMaterial(color: .white.withAlphaComponent(0))
        
        // 検知した平面メッシュを持つエンティティを作成
        let collisionEntity = ModelEntity(
            mesh: .generatePlane(width: anchor.geometry.extent.width, height: anchor.geometry.extent.height),
            materials: [material]
        )
        collisionEntity.transform = Transform(matrix: anchor.geometry.extent.anchorFromExtentTransform)
        
        // メッシュをベースにCollisionコンポーネント設定
        guard let mesh = collisionEntity.model?.mesh else
        {
            print("CollisionEntity has no mesh.")
            return
        }
        let shape = ShapeResource.generateConvex(from: mesh)
        collisionEntity.components.set(CollisionComponent(shapes:[shape]))
        
        // 静的なPhysicsBosyコンポーネントを設定
        let physicsBody = PhysicsBodyComponent(massProperties: .default, material: .default, mode: .static)
        collisionEntity.components.set(physicsBody)
        
        entity.addChild(collisionEntity)
        planeEntities[anchor.id] = entity
        scene.addChild(entity)
    }
    
    @MainActor
    private func updatePlaneEntity(_ anchor: PlaneAnchor)
    {
        guard let entity = planeEntities[anchor.id] else
        {
            return
        }
        
        entity.transform = Transform(matrix: anchor.originFromAnchorTransform)
        
        if let collisionEntity = entity.children.first as? ModelEntity
        {
            // 更新を検知した平面に合わせてメッシュを更新
            collisionEntity.model?.mesh = .generatePlane(width: anchor.geometry.extent.width, height: anchor.geometry.extent.height)
            collisionEntity.transform = Transform(matrix: anchor.geometry.extent.anchorFromExtentTransform)
            
            // 更新したメッシュに合わせて、Collisionコンポーネントを更新
            if var collisionComponent = collisionEntity.components[CollisionComponent.self]
            {
                guard let mesh = collisionEntity.model?.mesh else
                {
                    print("CollisionEntity has no mesh.")
                    return
                }
                let shape = ShapeResource.generateConvex(from: mesh)
                collisionComponent.shapes = [shape]
                collisionEntity.components.set(collisionComponent)
            }
        }
    }

    @MainActor
    private func handlePlaneRemoval(_ anchor: PlaneAnchor)
    {
        planeEntities[anchor.id]?.removeFromParent()
        planeEntities.removeValue(forKey: anchor.id)
    }
}

3.3. Immersive Spaceの処理に平面検知を追加

平面検知は、Immersive Spaceでのみ利用できます。
ImmersiveView.swiftがImmersive Spaceでの処理を行うプログラムです。
ARInteractionManagerのインスタンスを作成し、インスタンスが管理するシーンをコンテンツに追加するとともに、 ARkitのセッションの開始と平面検知の更新処理開始を行なっています。

ImmersiveView.swift
import SwiftUI
import RealityKit
import RealityKitContent

struct ImmersiveView: View {
    @StateObject private var arInteractionManager = ARInteractionManager()
    
    var body: some View {
        RealityView { content in
            // ARInteractionManagerが管理するシーンをRealityViewのコンテンツに追加
            // これにより、ARKitセッションの更新やインタラクションの結果が自動的にRealityViewに反映される
            content.add(arInteractionManager.scene)
        }
        .task
        {
            // ARKitセッションの開始
            await arInteractionManager.runARSession()
            // 平面検知の更新処理
            await arInteractionManager.processPlaneDetectionUpdateHandler()
        }
    }
}

3.4. アプリケーションがワールドセンシングデータにアクセスが必要な理由を追加

平面検知を利用する場合、Info.plistに追加が必要となります。
Imformation Property Listに、NSWorldSensingUsageDescription Keyを追加、Typeを String に設定し、Valueに平面検知が必要な理由を記述します。
Xcode03.png

4. ハンドトラッキングの実装

ここでは、あらかじめ定義されているハンドジェスチャーによるオブジェクトの移動と、カスタムハンドジェスチャの足掛かりとなる手の関節の可視化を行います。

4.1. 手の関節を可視化: ハンドトラッキングの準備

ARInteractionManager.swiftに機能を追記して行きます。

ARInteractionManager.swift
private let handTracking = HandTrackingProvider()

func runARSession() async
{
    do
    {
        // 平面検知とハンドトラッキングを開始
        try await session.run([planeData, handTracking])
    }
    catch
    {
        print("Error in runARSession: \(error)")
    }
}

4.2. 手の関節を可視化: 手の動きを検知

ハンドトラッキングの更新処理を行う。

ARInteractionManager.swift
func processHandTrackingUpdateHander() async
{
    for await update in handTracking.anchorUpdates
    {
        switch update.event
        {
        case .updated:
            await handleHandUpdate(update)
        default:
            break
        }
    }
}

4.3. 手の関節を可視化: 可視化処理

トラッキングしている手の関節をループで処理し、可視化するオブジェクトを配置しています。

ARInteractionManager.swift
private func handleSkeletonUpdate(_ handSkeleton: HandSkeleton, _ rootTransform: float4x4, _ prefix: String) async
{
    // 手の全ての関節をループで処理
    for joint in handSkeleton.allJoints
    {
        // 関節に名称を付与し、関節のモデル表示が行われているかを判定する
        let name = "\(prefix)-\(joint.name)"
        // 名称が存在した場合
        if let entity = scene.findEntity(named: name)
        {
            // トラッキングされている場合
            if joint.isTracked
            {
                // joint.anchorFromJointTransform: アンカーから関節への変換行列
                // rootTransform * joint.anchorFromJointTransform により原点からのTransformが取得できる
                entity.setTransformMatrix(rootTransform * joint.anchorFromJointTransform, relativeTo: nil)
                entity.isEnabled = true
            }
            else
            {
                entity.isEnabled = false
            }
        }
        else
        {
            guard joint.isTracked else
            {
                continue
            }
            
            do
            {
                // 関節のモデルをロードし、Entitiyを作成
                let entity = try await Entity(named: "Joint")
                entity.name = name
                entity.setTransformMatrix(rootTransform * joint.anchorFromJointTransform, relativeTo: nil)
                scene.addChild(entity)
            }
            catch
            {
                print("Failed to create entity: \(error)")
            }
        }
    }
}
完成した ARInteractionManager.swift のコード
ARInteractionManager.swift
import ARKit
import RealityKit

final class ARInteractionManager: ObservableObject
{
    // ARInteractionManager全体を表すエンティティ
    @Published var scene = Entity()
    
    // 最新の手のトラッキング情報
    @Published var latestHandTracking: (leftHand: HandAnchor?, rightHand: HandAnchor?)
    
    // ARKitセッションのインスタンス
    private let session = ARKitSession()
    // .horizontalのみを指定することで、水平面に絞る
    private let planeData = PlaneDetectionProvider(alignments: [.horizontal])
    
    @MainActor private var planeEntities: [UUID: Entity] = [:]
    
    private let handTracking = HandTrackingProvider()

    func runARSession() async
    {
        do
        {
            // 平面検知とハンドトラッキングを開始
            try await session.run([planeData, handTracking])
        }
        catch
        {
            print("Error in runARSession: \(error)")
        }
    }
    
    func processPlaneDetectionUpdateHandler() async
    {
        for await update in planeData.anchorUpdates
        {
            // 処理を地面のみに限定する
            guard update.anchor.classification == .floor else
            {
                continue
            }
            
            switch update.event
            {
            case .added, .updated:
                await handlePlaneUpdate(update.anchor)
            case .removed:
                await handlePlaneRemoval(update.anchor)
            }
        }
    }
    
    @MainActor
    private func handlePlaneUpdate(_ anchor: PlaneAnchor)
    {
        if planeEntities[anchor.id] == nil
        {
            createPlaneEntity(anchor)
        }
        
        updatePlaneEntity(anchor)
    }
    
    @MainActor
    private func createPlaneEntity(_ anchor: PlaneAnchor)
    {
        let entity = Entity()
        
        // 現実世界の地面を表示するため、透明のマテリアルを作成
        let material = UnlitMaterial(color: .white.withAlphaComponent(0))
        
        // 検知した平面メッシュを持つエンティティを作成
        let collisionEntity = ModelEntity(
            mesh: .generatePlane(width: anchor.geometry.extent.width, height: anchor.geometry.extent.height),
            materials: [material]
        )
        collisionEntity.transform = Transform(matrix: anchor.geometry.extent.anchorFromExtentTransform)
        
        // メッシュをベースにCollisionコンポーネント設定
        guard let mesh = collisionEntity.model?.mesh else
        {
            print("CollisionEntity has no mesh.")
            return
        }
        let shape = ShapeResource.generateConvex(from: mesh)
        collisionEntity.components.set(CollisionComponent(shapes:[shape]))
        
        // 静的なPhysicsBosyコンポーネントを設定
        let physicsBody = PhysicsBodyComponent(massProperties: .default, material: .default, mode: .static)
        collisionEntity.components.set(physicsBody)
        
        entity.addChild(collisionEntity)
        planeEntities[anchor.id] = entity
        scene.addChild(entity)
    }
    
    @MainActor
    private func updatePlaneEntity(_ anchor: PlaneAnchor)
    {
        guard let entity = planeEntities[anchor.id] else
        {
            return
        }
        
        entity.transform = Transform(matrix: anchor.originFromAnchorTransform)
        
        if let collisionEntity = entity.children.first as? ModelEntity
        {
            // 更新を検知した平面に合わせてメッシュを更新
            collisionEntity.model?.mesh = .generatePlane(width: anchor.geometry.extent.width, height: anchor.geometry.extent.height)
            collisionEntity.transform = Transform(matrix: anchor.geometry.extent.anchorFromExtentTransform)
            
            // 更新したメッシュに合わせて、Collisionコンポーネントを更新
            if var collisionComponent = collisionEntity.components[CollisionComponent.self]
            {
                guard let mesh = collisionEntity.model?.mesh else
                {
                    print("CollisionEntity has no mesh.")
                    return
                }
                let shape = ShapeResource.generateConvex(from: mesh)
                collisionComponent.shapes = [shape]
                collisionEntity.components.set(collisionComponent)
            }
        }
    }

    @MainActor
    private func handlePlaneRemoval(_ anchor: PlaneAnchor)
    {
        planeEntities[anchor.id]?.removeFromParent()
        planeEntities.removeValue(forKey: anchor.id)
    }
    
    func processHandTrackingUpdateHander() async
    {
        for await update in handTracking.anchorUpdates
        {
            switch update.event
            {
            case .updated:
                await handleHandUpdate(update)
            default:
                break
            }
        }
    }
    
    @MainActor
    private func handleHandUpdate(_ update: AnchorUpdate<HandAnchor>) async
    {
        let anchor = update.anchor
        
        // トラッキングが行われていて、手の関節の位置を取得できている場合は処理を続ける
        guard anchor.isTracked, let handSkelton = anchor.handSkeleton else
        {
            return
        }
        
        // 最新のトラッキング情報を更新
        latestHandTracking = handTracking.latestAnchors
        
        // 原点からアンカーへの変換行列を取得
        let rootTransform = anchor.originFromAnchorTransform
        
        // 関節の更新処理を実行
        await handleSkeletonUpdate(handSkelton, rootTransform, anchor.chirality.description)
    }
    
    @MainActor
    private func handleSkeletonUpdate(_ handSkeleton: HandSkeleton, _ rootTransform: float4x4, _ prefix: String) async
    {
        // 手の全ての関節をループで処理
        for joint in handSkeleton.allJoints
        {
            // 関節に名称を付与し、関節のモデル表示が行われているかを判定する
            let name = "\(prefix)-\(joint.name)"
            // 名称が存在した場合
            if let entity = scene.findEntity(named: name)
            {
                // トラッキングされている場合
                if joint.isTracked
                {
                    // joint.anchorFromJointTransform: アンカーから関節への変換行列
                    // rootTransform * joint.anchorFromJointTransform により原点からのTransformが取得できる
                    entity.setTransformMatrix(rootTransform * joint.anchorFromJointTransform, relativeTo: nil)
                    entity.isEnabled = true
                }
                else
                {
                    entity.isEnabled = false
                }
            }
            else
            {
                guard joint.isTracked else
                {
                    continue
                }
                
                do
                {
                    // 関節のモデルをロードし、Entitiyを作成
                    let entity = try await Entity(named: "Joint")
                    entity.name = name
                    entity.setTransformMatrix(rootTransform * joint.anchorFromJointTransform, relativeTo: nil)
                    scene.addChild(entity)
                }
                catch
                {
                    print("Failed to create entity: \(error)")
                }
            }
        }
    }
}

4.4. 手の関節の可視化: Immersive Spaceの処理にハンドトラッキングを追加

平面検知と同様にハンドトラッキングもImmersive Spaceのみで利用できるため、ImmersiveView.swiftにハンドトラッキング処理を追加します。
セッションの開始後、平面検知とハンドトラッキングの処理を並行して起動します。

ImmersiveView.swift
.task
{
    // ARKitセッションの開始
    await arInteractionManager.runARSession()
    
    // 平面検知とハンドトラッキングの非同期タスクを同時に開始
    async let planeDetectionTask: () = arInteractionManager.processPlaneDetectionUpdateHandler()
    async let handTrackingTask: () = arInteractionManager.processHandTrackingUpdateHander()
    
    // タスク完了を待機
    await planeDetectionTask
    await handTrackingTask
}

4.5. 手の関節の可視化: アプリケーションがハンドトラッキングデータにアクセスが必要な理由を追加

ハンドトラッキングを利用する場合、Info.plistに追加が必要となります。
Imformation Property Listに、NSHandsTrackingUsageDescription Keyを追加、Typeを String に設定し、Valueにハンドトラッキングが必要な理由を記述します。
Xcode05.png

4.6. ハンドジェスチャでオブジェクトを移動: シーンの作成

移動するオブジェクトは、Reality Composer Proでシーンを作成し、あらかじめ球体(Sphere)オブジェクトを配置しておきます。
RealityComposerPro02.png

  1. File > New > Scene... からシーンを作成
    ここでは、HandGestureという名前のシーンを作成します。
  2. プラスボタンの Primitive ShapeからSphereを追加
    Sphereを追加し、Transformを以下のように調整します。
    • Position: (0, 1.5, -0.5)
    • Scale: (0.5, 0.5, 0.5)
  3. ハンドジェスチャでオブジェクトを移動するために、Physics BodyCollisionInput Target3つのコンポーネントを追加します。
    コンポーネントの追加は、Add Componentボタンから行います。
    • Physics Body: Affected by Gravity のチェックを外します。
    • Collision: Shapeを Sphere に設定します。

4.7. ハンドジェスチャでオブジェクトを移動: シーンの読み込み

ImmersiveView.swiftに、作成したシーンを読み込む処理を追加します。

ImmersiveView.swift
if let handGestureScene = try? await Entity(named:"HandGesture", in: realityKitContentBundle)
{
    guard let sphere =  handGestureScene.findEntity(named: "Sphere") as? ModelEntity else
    {
        return
    }
    // ホバーエフェクトを追加
    sphere.components.set(HoverEffectComponent())
    sphereEntity = sphere
    arInteractionManager.scene.addChild(sphereEntity)
}

4.8. ハンドジェスチャでオブジェクトを移動: オブジェクトの移動処理

ImmersiveView.swiftに、ドラッグジェスチャを追加し、オブジェクトの移動処理を行います。

ImmsesiveView.swift
.gesture(
    DragGesture()
        .targetedToEntity(sphereEntity)
        .onChanged(
            { value in
                // ドラッグの位置を3D空間に変換
                let location3D = value.convert(value.location3D, from: .local, to: sphereEntity.parent!)
                if dragOffset == nil
                {
                    // 初回のドラック時のオフセットを計算
                    dragOffset = sphereEntity.position - location3D
                }
                if let offset = dragOffset
                {
                    // 球体の位置を更新
                    sphereEntity.position = location3D + offset
                }
            }
        )
        .onEnded(
            { _ in
                // ドラッグ終了時にオフセットをリセット
                dragOffset = nil
                if var physicsBodyComponent = sphereEntity.components[PhysicsBodyComponent.self]
                {
                    // 重力の影響を有効にする
                    physicsBodyComponent.isAffectedByGravity = true
                    sphereEntity.components.set(physicsBodyComponent)
                }
            }
        )
)
完成した ImmersiveView.swift のコード
ImmersiveView.swift
import SwiftUI
import RealityKit
import RealityKitContent

struct ImmersiveView: View {
    @StateObject private var arInteractionManager = ARInteractionManager()
    
    // 球体のエンティティを保持
    @State private var sphereEntity: Entity = Entity()
    // ドラッグ操作時のオフセットを保持
    @State private var dragOffset:SIMD3<Float>?
    
    var body: some View {
        RealityView { content in
            // ARInteractionManagerが管理するシーンをRealityViewのコンテンツに追加
            // これにより、ARKitセッションの更新やインタラクションの結果が自動的にRealityViewに反映される
            content.add(arInteractionManager.scene)
            
            if let handGestureScene = try? await Entity(named:"HandGesture", in: realityKitContentBundle)
            {
                guard let sphere =  handGestureScene.findEntity(named: "Sphere") as? ModelEntity else
                {
                    return
                }
                // ホバーエフェクトを追加
                sphere.components.set(HoverEffectComponent())
                sphereEntity = sphere
                arInteractionManager.scene.addChild(sphereEntity)
            }
        }
        .task
        {
            // ARKitセッションの開始
            await arInteractionManager.runARSession()
            
            // 平面検知とハンドトラッキングの非同期タスクを同時に開始
            async let planeDetectionTask: () = arInteractionManager.processPlaneDetectionUpdateHandler()
            async let handTrackingTask: () = arInteractionManager.processHandTrackingUpdateHander()
            
            // タスク完了を待機
            await planeDetectionTask
            await handTrackingTask
        }
        .gesture(
            DragGesture()
                .targetedToEntity(sphereEntity)
                .onChanged(
                    { value in
                        // ドラッグの位置を3D空間に変換
                        let location3D = value.convert(value.location3D, from: .local, to: sphereEntity.parent!)
                        if dragOffset == nil
                        {
                            // 初回のドラック時のオフセットを計算
                            dragOffset = sphereEntity.position - location3D
                        }
                        if let offset = dragOffset
                        {
                            // 球体の位置を更新
                            sphereEntity.position = location3D + offset
                        }
                    }
                )
                .onEnded(
                    { _ in
                        // ドラッグ終了時にオフセットをリセット
                        dragOffset = nil
                        if var physicsBodyComponent = sphereEntity.components[PhysicsBodyComponent.self]
                        {
                            // 重力の影響を有効にする
                            physicsBodyComponent.isAffectedByGravity = true
                            sphereEntity.components.set(physicsBodyComponent)
                        }
                    }
                )
        )
    }
}

5. アプリ開始時に没入空間に遷移

平面検知、ハンドトラッキングともに、没入空間での処理を行うため、アプリケーションの起動時に没入空間に遷移するように設定します。

5.1. Application Scene Manifestの変更

Preferred Default Scene Session RoleImmersive Space Application Session Role に変更します。
Xcode09.png

5.2. Appファイルの変更

プロジェクトを作成した際に、プロジェクト名 + App という名前のファイルが作成されています。
このファイルがアプリケーションのエントリーポイントとなります。

直接、ImmersiveViewを呼び出すように変更します。

import SwiftUI

@main
struct FirstStepAppApp: App {
    var body: some Scene {
        ImmersiveSpace(id: "ImmersiveSpace") {
            ImmersiveView()
        }
    }
}

6. 完成動画

https://youtu.be/GKOnLj7snc8

Unityを使った開発

1. プロジェクトの作成

Unity Hubを起動し、Universal 3Dを選択して新規プロジェクトを作成します。
Unity01.png

2. プロジェクトの設定

2.1. ビルドターゲットを visionOS に設定

File > Build Settingsを開き、PlatformvisionOS を選択、Switch Platformをクリックします。
Unity02.png

2.2. Company Name、Bundle Identifierの設定

Edit > Project Settings > Playerを開き、Company NameBundle Identifierを設定します。
Unity03.png

2.3. XR Plugin Managementのインストール

Edit > Project Settings > XR Plugin Managementを開き、Install XR Plugin Managementをクリックします。
Unity04.png

2.3.1. XR Plugin Managementの設定

visionOS settingsで、Apple visionOS を選択します。
Unity05.png

2.3.2. Project Validationの修正

Project Validationで、Fix Allをクリックします。
Unity06.png

2.3.3. Apple visionOSの設定

App ModeMixed Reality - Volume or Immersive Space に設定します。
また、Hand Tracking Usage Descriptionにハンドトラッキングデータにアクセスが必要な理由、World Sensing Usage Descriptionにワールドセンシングデータにアクセスが必要な理由を記述します。
Unity08.png

2.3.4. PolySpatialのサンプルをインポート

開発の参考になる他、実際の開発においても利用できるアセットが含まれているのでインポートしておきます。
Unity10.png

2.3.5. XR Interaction Toolkitのインストールとサンプルのインポート
  1. XR Interaction Toolkitをインストール
    Unity14.png
  2. サンプルのインポート
    • 開発の参考になる他、実際の開発においても利用できるアセットが含まれています。
      Unity20.png

3. ビルドと実行

3.1. シミュレーターでの実行

  1. Edit > Project Settings > Playerを開き、Target SDKSimulator SDK を選択します。
    Unity11.png
  2. File > Build Settings を開き、Build をクリックします。
    • ビルドが成功すると、Xcodeのプロジェクトが作成されていますので、Unity-VisionOS.xcodeprojをダブルクリックしてXcodeを起動、プログラムを実行します。
      Xcode07.png

3.2. 実機デバイスでの実行

  1. Edit > Project Settings > Player を開き、Target SDKDevice SDK を選択します。
    Unity13.png
  2. File > Build Settings を開き、Build をクリックします。
    • ビルドが成功すると、Xcodeのプロジェクトが作成されていますので、Unity-VisionOS.xcodeprojをダブルクリックしてXcodeを起動します。
  3. XcodeのSigning & Capabilitiesで、Automatically manage signingをチェック、Teamを選択します。
    Xcode08.png
  4. Xcodeを使った開発の 2.2. 実機デバイスでの実行 の手順に従って実行します。

3.3. Play to Device

PolySpatialでは、Unity EditorからデバイスやvisionOSシミュレーターへの再生機能が用意されています。
わざわざ、Xcodeを介してビルドすることなく、Unity Editorからデバイスやシミュレーターへの再生が可能です。

3.3.1. シミュレーター、もしくは実機にPlay To Device Hostアプリケーションをインストール
  1. TestFlightアプリケーションをApp Storeからインストールします。
  2. このページのDevice TestFlight Linkから、Play To Device Hostアプリケーションをインストールします。
3.3.2. シミュレーター、もしくは実機でPlay To Device Hostアプリケーションを起動

接続URLが表示されます。

3.3.3. Unity EditorでPlay To Deviceの設定

Window > PolySpatial > Play To Deviceを選択します。

  1. 3.3.2で表示されたURLを、入力してAdd Deviceをクリックします。
    Unity27.png
  2. 登録したデバイスを選択して、接続を有効にします。
    Unity28.png
3.3.4. Unity Editorで実行

Play Modeを開始すると、Unity Editorからデバイスやシミュレーターへの再生が可能です。

4. ベースシーンの作成

公式で配布されているベーステンプレートから不要なものを削ったシーンを、新規シーンから作成してみます。

4.1. Volume Cameraコンポーネントの追加と設定

GameObject > XR > Set Volume Cameraを選択し、Volume Cameraコンポーネントを追加します。

  • Volume Window Configurationに、Unbounded_VolumeCameraConfiguration を設定します。
    Unity15.png

4.2. AR Sessionコンポーネントの追加

GameObject > XR > AR Session を選択し、AR Sessionコンポーネントを追加します。

4.3. XR Originコンポーネントの追加と設定

GameObject > XR > XR Origin (AR)を選択し、XR Originコンポーネントを追加します。
これにより、XR Origin (XR Rig)、XR Interaction Managerが追加されます。
Main Camera とXR Origin (XR Rig)に配置されている Left ControllerRight Controller は不要なので削除します。
Unity16.png

4.4. XR Origin コンポーネント、Input Action Managerコンポーネントの設定

  • Camera Y Offsetを0に設定します。
  • Action AssetsPolySpatialInputActions を設定します。
    Unity19.png

4.5. Tracked Pose Driverコンポーネントの設定

Position Inputの Action、Rotation InputのActionに、下記の内容でそれぞれBindingを追加します。

  • <PolySpatialXRHMD>/centerEyePosition
  • <PolySpatialXRHMD>/centerEyeRotation
    Unity17.png
    Unity18.png

4.6. シーンの保存

ここまでで、ベースのシーンができましたので、保存します。
Unity21.png

5. 平面検知の実装

地面を認識できるように平面検知の機能を設定していきます。

5.1. XR Origin (XR Rig)に AR Plane Managerコンポーネントを追加

Detection ModeHorizontal に設定します。
Unity22.png

5.2. 平面用のプレハブを作成

5.2.1. 透明のマテリアルを作成

現実の地面が見えるよう透明のマテリアルを作成します。
Assets > Create > Materialを選択し、マテリアルのアセットを作成します。

  • Shader: Universal Render Pipeline/Unlit
  • Surface Type: Transparent
  • Base Map: (0, 0, 0, 0)
    Unity23.png
5.2.2. 平面用のオブジェクトを作成
  1. GameObject > Create Emptyを選択し、空のオブジェクトAR Planeを作成します。
  2. 作成したAR Planeに下記のコンポーネントを追加します。
  • ARPlane
  • ARPlaneMeshVisualizer
  • MeshCollider
  • MeshFilter
  • MeshRenderer

MeshRendererのMaterialsに、作成した透明のマテリアルを設定します。
Unity24.png

5.2.3. 平面用オブジェクトをプレハブ化

AR Planeをプレハブ化し、シーンから削除します。
Unity25.png

5.3. AR Plane Managerコンポーネントの設定

Unity26.png

6. ハンドトラッキングの実装

6.1. 手の関節の可視化

これは、PolySpatialのサンプルで既に実現されています。

  1. Main Cameraと同じ階層で、GameObject > Create Emptyを選択し、空のオブジェクトHand Managerを作成します。
  2. 作成したHand ManagerにHandVisualizerコンポーネントを追加します。
  3. HandVisualizerコンポーネントのJoint Visual PrefabJointVisuals を設定します。
    Unity29.png

6.2. ハンドジェスチャでオブジェクトを移動: ハンドジェスチャなど空間ポインタの入力を利用

PolySpatialで、XRTouchSpeceInteractorコンポーネントが提供されています。
これは、XR Interaction Toolkitを利用して空間ポインタ入力を受け付けるInteractorコンポーネントです。
今回は、これを利用してオブジェクトを移動する処理を実装します。

  1. GameObject > Create Emptyを選択し、空のオブジェクトXRTouchSpaceInteractorを作成します。
  2. XRTouchSpaceInteractorコンポーネントを追加します。
    • Spatial Pointer: Touch/Primary Touch
  3. 同様に、空のオブジェクトXRTouchSpaceInteractor Secondaryを作成し、XRTouchSpaceInteractorコンポーネントを追加します。
    • Spatial Pointer: Touch/Secondary World Touch
      Unity30.png

6.3. ハンドジェスチャでオブジェクトを移動: 移動するオブジェクトの作成

  1. GameObject > 3D Object > Sphereを選択し、Sphereを作成します。
    • Position: (0, 1.5, 0.5)
    • Scale: (0.5, 0.5, 0.5)
  2. 以下のコンポーネントを追加します。
    • Rigidbody
      • Is Kinematic: 有効
    • XRGrabInteractable
      • Interactable Events > Select Exitecd: RigidbodyIs Kinematicを無効
    • XRGeneralGrabTransfomer
    • VisionOSHoverEffect
      Unity31.png
      Unity32.png

7. 完成動画

https://youtu.be/qU8Hmoa9Lj8

まとめ

この記事では、XcodeとUnityを使ったApple Vision Pro開発の第一歩を紹介しました。

Xcodeを使った開発

SwiftUIやRealityKitを利用したコードの記述が必要です。公式サンプルや豊富なドキュメントを参考にしながら進めることで、visionOS向けのアプリケーションを効率的に開発できます。

Unityを使った開発

Unityを使った開発では、PolySpatial、AR Foundation、XR Interaction Toolkitなどのアセットを活用することで、コーディングを最小限に抑えた開発が可能です。ただし、Unity Proライセンスが必要になるため、個人開発者には敷居が高い場合があります。

どちらのアプローチにも利点がありますが、開発するアプリケーションの目的や開発者のスキルセットに応じて選択するのが良いでしょう。今後も、visionOSやApple Vision Proに関する最新情報をフォローし、開発を進めていきましょう。

書いた人

ボブ

高野 剛

Unixシステムのインフラ構築・運用を経験後、ECサイトを中心としたWebアプリ開発、プロジェクトマネージメントに従事する。
ミニオン好きが高じて、USJに通い続ける中、XRアトラクションに魅了される。
自分が感動したことを他の人にも体験してもらいたいという思いから、転職を決意し、XRの学校での1年間の学びを経てMESONへ入社。

X

MESON Works

MESONの制作実績一覧もあります。ご興味ある方はぜひ見てみてください。

MESON Cases

Discussion

ゆーまっくすゆーまっくす

実機がある場合、Unityのみでの開発は可能ですか?

Bob MatocaBob Matoca

ご確認ありがとうございます!
実機にアプリをビルドするためには、Xcodeが必要なため、Xcodeをインストール・設定しておくことは必要になります。
Unity上のビルドで作成されるのは、Xcode用のプロジェクトファイルとなります。

※ Play To Deviceは、あくまで実機再生が可能ということであり、実際にアプリケーションがインストールされるわけではありません。