見出し画像

Vision Pro の サンプル解説 (2) - Happy Beam

以下の記事が面白かったので、簡単にまとめました。

Happy Beam


前回

1. はじめに

Happy Beam」は、不機嫌な雲が空間を漂い、人々が手でハートの形を作ってビームを投影して遊ぶゲームの仕組みを説明します。ユーザーは雲に向かってビームを向けて応援し、スコアカウンターは各プレーヤーがどれだけ雲を応援したかを記録します。

2. SwiftUI によるインターフェースの作成

今回は、「ようこと」「操作説明」「スコアボード」「ゲーム終了」の画面を表示するSwiftUIビューで、「Happy Beam」がユーザーにインターフェイスをどのように提示するかを説明します。

・ようこそ

・操作説明

・スコアボード

・ゲーム終了

以下は、ゲームプレイの各フェーズを表示するビューを示しています。

struct HappyBeam: View {
    @Environment(\.openImmersiveSpace) private var openImmersiveSpace
    @Environment(GameModel.self) var gameModel
    
    @State private var session: GroupSession<HeartProjection>? = nil
    @State private var timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
    @State private var subscriptions = Set<AnyCancellable>()
    
    var body: some View {
        let gameState = GameScreen.from(state: gameModel)
        VStack {
            Spacer()
            Group {
                switch gameState {
                case .start:
                    Start()
                case .soloPlay:
                    SoloPlay()
                case .lobby:
                    Lobby()
                case .soloScore:
                    SoloScore()
                case .multiPlay:
                    MultiPlay()
                case .multiScore:
                    MultiScore()
                }
            }
            .glassBackgroundEffect(
                in: RoundedRectangle(
                    cornerRadius: 32,
                    style: .continuous
                )
            )
        }
    }
}

3Dコンテンツが表示され始めると、ゲームはImmersiveSpaceを開き、メインウィンドウの外側およびユーザーの周囲にコンテンツを表示します。

@main
struct HappyBeamApp: App {
    @State private var gameModel = GameModel()
    @State private var immersionState: ImmersionStyle = .mixed
    
    var body: some SwiftUI.Scene {
        WindowGroup("HappyBeam", id: "happyBeamApp") {
            HappyBeam()
                .environmentObject(gameModel)
        }
        .windowStyle(.plain)
        
        ImmersiveSpace(id: "happyBeam") {
            HappyBeamSpace(gestureModel: HeartGestureModelContainer.heartGestureModel)
                .environmentObject(gameModel)
        }
        .immersionStyle(selection: $immersionState, in: .mixed)
    }
}

HappyBeamコンテナビューは、openImmersiveSpace への依存関係を宣言します。

@Environment(\.openImmersiveSpace) private var openImmersiveSpace

その後、3Dコンテンツの表示を開始するときに、その依存関係を使用してアプリの宣言から空間を開きます。

if gameModel.countDown == 0 {
    Task {
        await openImmersiveSpace(id: "happyBeam")
    }
}

3. ARKit によるハートジェスチャーの検出

「Happy Beam」は、「ARKit」の3Dハンドトラッキングで、中央のハート型の手のジェスチャーを認識します。ハンド トラッキングを使用するには、実行セッションとユーザーからの承認が必要です。

「NSHandsTrackingUsageDescription」でアプリがハンド トラッキングの許可を要求する理由をプレーヤーに説明します。

Task {
    do {
        try await session.run([handTrackingProvider])
    } catch {
        print("ARKitSession error:", error)
    }
}

アプリが Window または Volume のみを表示している場合、ハンドトラッキング データは利用できません。ImmersiveSpaceで利用できます。

「ARKit」を使用すると、ユースケースと意図したエクスペリエンスに応じたレベルの精度でジェスチャを検出できます。

以下は、人の親指と人差し指が触れそうになっているかどうかをチェックします。

// 全ジョイントの位置をワールド座標で取得します。
let originFromLeftHandThumbKnuckleTransform = matrix_multiply(
    leftHandAnchor.originFromAnchorTransform, leftHandThumbKnuckle.anchorFromJointTransform
).columns.3.xyz
let originFromLeftHandThumbTipTransform = matrix_multiply(
    leftHandAnchor.originFromAnchorTransform, leftHandThumbTipPosition.anchorFromJointTransform
).columns.3.xyz
let originFromLeftHandIndexFingerTipTransform = matrix_multiply(
    leftHandAnchor.originFromAnchorTransform, leftHandIndexFingerTip.anchorFromJointTransform
).columns.3.xyz
let originFromRightHandThumbKnuckleTransform = matrix_multiply(
    rightHandAnchor.originFromAnchorTransform, rightHandThumbKnuckle.anchorFromJointTransform
).columns.3.xyz
let originFromRightHandThumbTipTransform = matrix_multiply(
    rightHandAnchor.originFromAnchorTransform, rightHandThumbTipPosition.anchorFromJointTransform
).columns.3.xyz
let originFromRightHandIndexFingerTipTransform = matrix_multiply(
    rightHandAnchor.originFromAnchorTransform, rightHandIndexFingerTip.anchorFromJointTransform
).columns.3.xyz


let indexFingersDistance = distance(originFromLeftHandIndexFingerTipTransform, originFromRightHandIndexFingerTipTransform)
let thumbsDistance = distance(originFromLeftHandThumbTipTransform, originFromRightHandThumbTipTransform)


// ハートジェスチャの検出は、人差し指の先端の中心間の距離と親指の先端の中心間の距離がそれぞれ4センチメートル未満の場合に真
let isHeartShapeGesture = indexFingersDistance < 0.04 && thumbsDistance < 0.04
if !isHeartShapeGesture {
    return nil
}


// ハートジェスチャーの中央の位置を計算
let halfway = (originFromRightHandIndexFingerTipTransform - originFromLeftHandThumbTipTransform) / 2
let heartMidpoint = originFromRightHandIndexFingerTipTransform - halfway


// 左の親指の関節から右の親指の関節までのベクトルを計算し正規化 (X軸)
let xAxis = normalize(originFromRightHandThumbKnuckleTransform - originFromLeftHandThumbKnuckleTransform)


// 右親指の先端から右人差し指の先端までのベクトルを計算し正規化 (Y軸)
let yAxis = normalize(originFromRightHandIndexFingerTipTransform - originFromRightHandThumbTipTransform)


let zAxis = normalize(cross(xAxis, yAxis))


// 3つの軸と中点ベクトルからハートジェスチャの最終変換を作成
let heartMidpointWorldTransform = simd_matrix(
    SIMD4(xAxis.x, xAxis.y, xAxis.z, 0),
    SIMD4(yAxis.x, yAxis.y, yAxis.z, 0),
    SIMD4(zAxis.x, zAxis.y, zAxis.z, 0),
    SIMD4(heartMidpoint.x, heartMidpoint.y, heartMidpoint.z, 1)
)
return heartMidpointWorldTransform

4. 複数入力のサポート

「Happy Beam」は、次の入力をサポートしています。

・ハートジェスチャー (ARKit)

・ドラッグジェスチャー

・アクセシビリティコンポーネント (RealityKit)

・ゲーム コントローラ

5. RealityKit による3Dコンテンツ表示

アプリ内の3Dコンテンツは、「Reality Composer Pro」からエクスポートするアセットで提供します。ImmersiveSpaceを表す「RealityView」に各アセットを配置します。

以下は、「Happy Beam」がゲーム開始時に雲を生成する仕組みと、床設置型ビームプロジェクターのマテリアルを示しています。ゲームではスコアを維持するために衝突検出が使用されているため (衝突するとビームが不機嫌そうな雲を元気づけます)、関係する可能性のあるモデルごとに衝突形状を作成します。

@MainActor
func placeCloud(start: Point3D, end: Point3D, speed: Double) async throws -> Entity {
    let cloud = await loadFromRealityComposerPro(
        named: BundleAssets.cloudEntity,
        fromSceneNamed: BundleAssets.cloudScene
    )!
        .clone(recursive: true)
    
    cloud.generateCollisionShapes(recursive: true)
    cloud.components[PhysicsBodyComponent.self] = PhysicsBodyComponent()
    
    var accessibilityComponent = AccessibilityComponent()
    accessibilityComponent.label = "Cloud"
    accessibilityComponent.value = "Grumpy"
    accessibilityComponent.isAccessibilityElement = true
    accessibilityComponent.traits = [.button, .playsSound]
    accessibilityComponent.systemActions = [.activate]
    cloud.components[AccessibilityComponent.self] = accessibilityComponent
    
    let animation = cloudMovementAnimations[cloudPathsIndex]
    
    cloud.playAnimation(animation, transitionDuration: 1.0, startsPaused: false)
    cloudAnimate(cloud, kind: .sadBlink, shouldRepeat: false)
    spaceOrigin.addChild(cloud)
    
    return cloud
}

6. Group Activities によるSharePlayサポート

「FaceTime」通話中の「SharePlay」をサポートするには、visionOSの「Group Activities」フレームワークを使用します。スコア、アクティブ プレーヤーのリスト、各プレーヤーの投影されたビームの位置を同期します。

【ノート】
Apple Vision Pro developer kit」 を使用する開発者は、「Persona Preview Profile」をインストールすることで、デバイス上で空間SharePlayエクスペリエンスをテストできます。

たとえ結果的にわずかに遅れる可能性があるとしても、正確であることが重要な情報を送信するには、信頼できるチャネルを使用してください。 以下は、「Happy Beam」がスコアメッセージに応じてゲームモデルのスコア状態を更新する方法を示しています。

sessionInfo.reliableMessenger = GroupSessionMessenger(session: newSession, deliveryMode: .reliable)


Task {
    for await (message, sender) in sessionInfo!.reliableMessenger!.messages(of: ScoreMessage.self) {
        gameModel.clouds[message.cloudID].isHappy = true
        gameModel
            .players
            .filter { $0.name == sender.source.id.asPlayerName }
            .first!
            .score += 1
    }
}

低遅延要件のデータを送信するには、信頼性の低いメッセンジャーを使用してください。 配信モードの信頼性が低いため、一部のメッセージが送信されない可能性があります。「Happy Beam」は、通話の各参加者が「FaceTime」で空間オプションを選択したときに、信頼性の低いモードを使用してビームの位置にライブ更新を送信します。

sessionInfo.messenger = GroupSessionMessenger(session: newSession, deliveryMode: .unreliable)

以下は、「Happy Beam」が各メッセージのビームデータをシリアル化する方法を示しています。

// プレーヤーが空間オプションを選択した場合、FaceTime通話中に各プレーヤーのビームデータを送信
func sendBeamPositionUpdate(_ pose: Pose3D) {
    if let sessionInfo = sessionInfo, let session = sessionInfo.session, let messenger = sessionInfo.messenger {
        let everyoneElse = session.activeParticipants.subtracting([session.localParticipant])
        
        if isShowingBeam, gameModel.isSpatial {
            messenger.send(BeamMessage(pose: pose), to: .only(everyoneElse)) { error in
                if let error = error { print("Message failure:", error) }
            }
        }
    }
}

次回



この記事が気に入ったらサポートをしてみませんか?