visionOS示例代码:Happy Beam

1,930 阅读2分钟

利用完整空间使用ARKit创建一个有趣的游戏。

下载地址

visionOS 1.0+ Xcode 15.0+

概述

在visionOS中,您可以使用多种不同的框架创建有趣的动态游戏和应用程序,以创建新的空间体验:RealityKit、ARKit、SwiftUI和Group Activities。这个示例介绍了Happy Beam,一个游戏,在这个游戏中,您和您的朋友可以在FaceTime通话中一起玩耍。

您将学习游戏的机制,其中脾气暴躁的云在空间中漂浮,人们通过用手做一个心形来投射光束。人们将光束对准云朵,使它们高兴起来,计分器会记录每个玩家为云朵打气的情况。

使用SwiftUI设计游戏界面

visionOS中的大多数应用程序都以窗口的形式启动,根据应用程序的需求打开不同类型的场景。

在这里,您可以看到Happy Beam如何使用多个SwiftUI视图呈现有趣的界面,显示欢迎屏幕、给出说明的教练屏幕、记分板和游戏结束屏幕。

欢迎窗口

A screenshot showing the welcome screen window. It instructs people to cheer up grumpy clouds by shining a happy beam with your heart. There are two buttons: Play Solo and Play with Friends.

说明

A screenshot showing the two input options in the game: Make a heart with two hands, or use a pinch gesture or a compatible device.

记分板

A screenshot showing the in-game scoreboard window with a backdrop of a living room populated by the game's grumpy clouds. The scoreboard shows the score, a back button, a mute button, a time left indicator, and a pause button.

结束窗口

A screenshot showing the final score window. It shows some happy clouds, how many clouds you cheered up, and has options for playing again and going back to the main menu.

以下是应用程序中显示游戏每个阶段的主要视图:

struct HappyBeam: View {
    @Environment(.openImmersiveSpace) private var openImmersiveSpace
    @EnvironmentObject var gameModel: 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内容开始出现时,游戏会打开一个沉浸式空间,以在主窗口之外和人的周围呈现内容。

@main
struct HappyBeamApp: App {
    @StateObject 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")
    }
}

使用ARKit检测心形手势

Happy Beam应用程序使用ARKit在visionOS中支持的3D手部跟踪功能来识别中央的心形手势。使用手部跟踪需要运行会话并得到佩戴者的授权。它使用NSHandsTrackingUsageDescription用户信息键来向玩家解释应用程序请求手部跟踪权限的原因。

A screenshot showing someone making a heart gesture with their hands. A beam projects from the center of the player's hands and extends into a living room where grumpy clouds float toward the player.

显示某人用手做心形手势的截图。从玩家的手中心射出一束光,延伸到一个客厅,愁闷的云朵朝着玩家漂浮。

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

当您的应用程序仅显示窗口或体积时,无法获取手部跟踪数据。相反,当您呈现沉浸式空间时,才能获取这些数据,就像前面的示例中一样。

您可以根据您的用例和预期体验的精度要求来检测手势。例如,Happy Beam可以要求严格的手指关节定位,以紧密地呈现心形。然而,它提示人们做一个心形手势,并使用启发式方法来指示何时手势足够接近。

以下检查一个人的拇指和食指是否几乎接触:

func computeTransformOfUserPerformedHeartGesture() -> simd_float4x4? {
    // 获取最新的手部锚点,如果它们中的任何一个没有被跟踪,则返回false。
    guard let leftHandAnchor = latestHandTracking.left,
          let rightHandAnchor = latestHandTracking.right,
          leftHandAnchor.isTracked, rightHandAnchor.isTracked else {
        return nil
    }
    
    // 获取所有所需的关节并检查它们是否被跟踪。
    let leftHandThumbKnuckle = leftHandAnchor.skeleton.joint(named: .handThumbKnuckle)
    let leftHandThumbTipPosition = leftHandAnchor.skeleton.joint(named: .handThumbTip)
    let leftHandIndexFingerTip = leftHandAnchor.skeleton.joint(named: .handIndexFingerTip)
    let rightHandThumbKnuckle = rightHandAnchor.skeleton.joint(named: .handThumbKnuckle)
    let rightHandThumbTipPosition = rightHandAnchor.skeleton.joint(named: .handThumbTip)
    let rightHandIndexFingerTip = rightHandAnchor.skeleton.joint(named: .handIndexFingerTip)
    
    guard leftHandIndexFingerTip.isTracked && leftHandThumbTipPosition.isTracked &&
            rightHandIndexFingerTip.isTracked && rightHandThumbTipPosition.isTracked &&
            leftHandThumbKnuckle.isTracked &&  rightHandThumbKnuckle.isTracked else {
        return nil
    }
    
    // 获取所有关节的世界坐标位置。
    let leftHandThumbKnuckleWorldPosition = matrix_multiply(leftHandAnchor.transform, leftHandThumbKnuckle.rootTransform).columns.3.xyz
    let leftHandThumbTipWorldPosition = matrix_multiply(leftHandAnchor.transform, leftHandThumbTipPosition.rootTransform).columns.3.xyz
    let leftHandIndexFingerTipWorldPosition = matrix_multiply(leftHandAnchor.transform, leftHandIndexFingerTip.rootTransform).columns.3.xyz
    let rightHandThumbKnuckleWorldPosition = matrix_multiply(rightHandAnchor.transform, rightHandThumbKnuckle.rootTransform).columns.3.xyz
    let rightHandThumbTipWorldPosition = matrix_multiply(rightHandAnchor.transform, rightHandThumbTipPosition.rootTransform).columns.3.xyz
    let rightHandIndexFingerTipWorldPosition = matrix_multiply(rightHandAnchor.transform, rightHandIndexFingerTip.rootTransform).columns.3.xyz
    
    let indexFingersDistance = distance(leftHandIndexFingerTipWorldPosition, rightHandIndexFingerTipWorldPosition)
    let thumbsDistance = distance(leftHandThumbTipWorldPosition, rightHandThumbTipWorldPosition)
    
    // 当食指中心之间的距离和拇指尖之间的距离都小于四厘米时,心形手势检测为true。
    let isHeartShapeGesture = indexFingersDistance < 0.04 && thumbsDistance < 0.04
    if !isHeartShapeGesture {
        return nil
    }
    
    // 计算心形手势中点的位置。
    let halfway = (rightHandIndexFingerTipWorldPosition - leftHandThumbTipWorldPosition)/2
    let heartMidpoint = rightHandIndexFingerTipWorldPosition - halfway
    
    // 计算从左拇指关节到右拇指关节的向量并进行归一化(x轴)。
    let xAxis = normalize(rightHandThumbKnuckleWorldPosition - leftHandThumbKnuckleWorldPosition)
    
    // 计算从右拇指尖到右食指尖的向量并进行归一化(y轴)。
    let yAxis = normalize(rightHandIndexFingerTipWorldPosition - rightHandThumbTipWorldPosition)
    
    let zAxis = normalize(cross(xAxis, yAxis))
    
    // 从三个轴和中点向量创建心形手势的最终变换。
    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
}

为了支持辅助功能和一般用户偏好,将多种输入方式包含在使用手部跟踪作为一种输入形式的应用程序中。

Happy Beam支持以下几种输入方式:

  1. 一个显示某人用手做心形手势的截图。从玩家的手中心射出一束光,延伸到一个客厅,愁闷的云朵朝着玩家漂浮。 使用具有自定义心形手势的ARKit交互式手部输入。 A screenshot showing someone making a heart gesture with their hands. A beam projects from the center of the player's hands and extends into a living room where grumpy clouds float toward the player.

  2. 一个显示一个3D心形悬停在一个塔顶上的截图。 使用拖动手势输入,将固定的光束在其平台上旋转。 A screenshot showing a 3D heart perched on top of a tower that holds it.

  3. 一个显示某人使用VoiceOver玩Happy Beam的截图。画中画显示某人双手放在膝盖上,进行VoiceOver手势以激活元素。在游戏中,一个云朵显示鼓励动画,VoiceOver用一个矩形突出显示该云朵,以显示它是焦点元素。 使用RealityKit的辅助功能组件来支持自定义的鼓励云朵操作。 A screenshot showing someone using VoiceOver to play Happy Beam. A picture-in-picture shows someone with their hands in their lap making the VoiceOver gesture to activate an element. In the game, one of the clouds displays a cheer up animation and VoiceOver highlights that cloud in a rectangle to show that it's the focused element.

  4. 一个显示某人使用游戏控制器玩Happy Beam的截图。 游戏控制器支持,通过Switch Control使光束的控制更加交互。 A screenshot showing someone using a game controller to play Happy Beam.

使用RealityKit显示3D内容

应用程序中的3D内容以从Reality Composer Pro导出的资源形式呈现。您将每个资源放置在表示您的沉浸式空间的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
}

为多人游戏体验添加SharePlay支持

您可以在visionOS中使用Group Activities框架来支持FaceTime通话期间的SharePlay。Happy Beam使用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中的Spatial选项时,向每个玩家发送光束数据。
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) }
            }
        }
    }
}