VisionPro开发 - 通过 UI 和手柄控制汽车移动


首页:漫游Apple Vision Pro

Code Repo: github.com/xuchi16/vis...

Project Path: github.com/xuchi16/vis...


本文主要包含以下内容:

  • 加载实体的基本操作和阴影设置
  • 基本的小车移动逻辑
  • UI 和手柄操控物体移动

目标及设计

这个应用我们希望实现如下基本功能

  • 主页面:控制打开和关闭 Immsersive Space,在打开的情况下控制小车移动

  • 游戏场景:包含地板和小车,实现基本的光影。小车可以在地板上移动,超过边界后会掉落

  • 手柄控制:通过手柄控制小车移动

小车是否移动依赖于用户输入,而 ECS 系统中,由 System 控制对应的 Component 移动。因此需要让不同的控制器、页面、组件等通过一个 model 共享状态,输入源(UI/手柄)更改状态,而 System 根据当前的状态控制物体移动:

  • 数据传递:定义一个 ViewModel 用于存储当前用户的输入状态,在 App 中初始化,并将其传递给各页面和组件(蓝色部分)

  • 控制流:ContentViewGameController作为输入源,将用户动作(图中绿色部分,如按下了前进/后退键)更新到 ViewModel 中,这样负责控制的MoveSystem就能够读取到,并相应地控制小车移动

基本实现

ViewModel

ViewModel 是这个应用同步状态的核心数据结构,需要记录当前用户按住了哪些按键。基本的按键包括上下左右,此外,还有左上、左下、右上、右下几个方向。但只需要定义上下左右四个方向,当用户按下左上这类复合按键时,同时将左和上置为 true 即可。

swift 复制代码
@Observable
class ViewModel {
    var forward = false
    var backward = false
    var left = false
    var right = false
}

CarControlApp

CarControlApp 作为入口,初始化 model 并传递给下一级组件。

  • 通过环境变量传递给ContentViewImmersiveView
  • 初始化时通过显式register()传递给手柄控制器
swift 复制代码
 @main
struct CarControlApp: App {
    
    @State var model = ViewModel()
    @ObservedObject var gameControllerManager = GameControllerManager()

    init() {
        MoveComponent.registerComponent()
        MoveSystem.registerSystem()
        gameControllerManager.register(model: model)
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(model)
        }
        .defaultSize(CGSize(width: 300, height: 400))

        ImmersiveSpace(id: "ImmersiveSpace") {
            ImmersiveView()
                .environment(model)
        }
    }
}

ContentView

ContentView 主要包含 2 部分功能:

  • 控制打开和关闭 Immsersive Space:可参考1. 窗口,空间容器和空间 ,这里不再赘述
  • 控制小车移动:绘制方向按钮,并将用户输入同步到 Model 中

在控制物体移动时,通常的按键习惯是长按。比如用户一直按着向前的箭头,那么小车就应该一直前进,直到用户松开,因此这里使用onLongPressGesture控制。当用户按下/松开按键时,相应地设置 Model 状态。

swift 复制代码
struct ContentView: View {
    // ...
    @Environment(ViewModel.self) var model
    
    var body: some View {
            // ...  
            VStack {
                HStack {
                    arrowButton(systemName: "arrow.up.left",  directions: [.up, .left])
                    arrowButton(systemName: "arrow.up",  directions: [.up])
                    arrowButton(systemName: "arrow.up.right",  directions: [.up, .right])
                }
                HStack {
                    arrowButton(systemName: "arrow.down.left",  directions: [.down, .left])
                    arrowButton(systemName: "arrow.down",  directions: [.down])
                    arrowButton(systemName: "arrow.down.right",  directions: [.down, .right])
                }
            }
        }
        // ...
    }
    
    private func arrowButton(systemName: String, directions: [Direction]) -> some View {
        Button(action: {}) {
            Image(systemName: systemName)
        }
        .onLongPressGesture(minimumDuration: .infinity, pressing: { isPressing in
            print("direction: (directions), pressed: (isPressing)")
            for direction in directions {
                move(direction: direction, press: isPressing)
            }
        }, perform: {})
    }
    
    func move(direction: Direction, press: Bool) {
        switch direction {
        case .up:
            model.forward = press
        case .down:
            model.backward = press
        case .left:
            model.left = press
        case .right:
            model.right = press
        }
    }
    
    enum Direction {
        case up, down, left, right
    }
    
}

ImmersiveView

ImmersiveView 主要功能是:

  • 加载汽车和地板实体,赋予对应的初始位置、材料、物理实体信息等特性

  • 为汽车增加光影效果

  • 为汽车增加MoveComponent,并传递 ViewModel,后续供 MoveSystem 使用

如果只是加载汽车和地板实体,并没有实际的重力和碰撞效果,因此需要给这些实体添加对应的物理实体PhysicsBodyComponent,并且赋予其对应的碰撞体形状CollisionComponent,这样才能产生类似真实世界的碰撞、重力等效果。

swift 复制代码
struct ImmersiveView: View {
    
    @State var floor = Entity()
    @State var car = Entity()
    
    @Environment(ViewModel.self) var model

    var body: some View {
        RealityView { content in
            // Car
            car = try! await Entity(named: "toy_car")
            car.transform.rotation = simd_quatf(angle: .pi, axis: [0, 1, 0])
            car.components[CollisionComponent.self] = CollisionComponent(shapes: [.generateBox(size: SIMD3(repeating: 1.0))])
            
            car.position = SIMD3(x: 0, y: 0.95, z: -2)
            let carBody = PhysicsBodyComponent()
            car.components[PhysicsBodyComponent.self] = carBody
            car.enumerateHierarchy { entity, stop in
                if entity is ModelEntity {
                    entity.components.set(GroundingShadowComponent(castsShadow: true))
                }
            }
            
            car.components[MoveComponent.self] = MoveComponent(model: model)
            content.add(car)
            
            // Floor
            let floorMaterial = SimpleMaterial(color: .white, roughness: 1, isMetallic: false)
            floor = ModelEntity(
                mesh: .generateBox(width: 3, height: 0.01, depth: 2),
                materials: [floorMaterial],
                collisionShape: .generateBox(width: 3, height: 0.01, depth: 2),
                mass: 0.0
            )
            floor.position = SIMD3(x: 0.0, y: 0.9, z: -2)
            var floorBody = PhysicsBodyComponent()
            floorBody.isAffectedByGravity = false
            floorBody.mode = .static
            floor.components[PhysicsBodyComponent.self] = floorBody
            content.add(floor)
        }
        .shadow(radius: 12)
    }
    
}

另外可以注意到,在为小汽车增加光影效果时,并不是简单地为实体增加GroundingShadowComponent。因为小汽车是从 USDZ 文件中加载而来,并非ModelEntity,如果只是简单添加阴影会发现并不会产生预期的效果。因此可以给Entity类型扩展一个enumerateHierarchy方法,递归地迭代其中的子结构,并且为每个ModelEntity类型的子结构添加阴影,这样就能获得预期的效果。参考文档

swift 复制代码
extension Entity {
    func enumerateHierarchy(_ body: (Entity, UnsafeMutablePointer<Bool>) -> Void) {
        var stop = false

        func enumerate(_ body: (Entity, UnsafeMutablePointer<Bool>) -> Void) {
            guard !stop else {
                return
            }

            body(self, &stop)
            
            for child in children {
                guard !stop else {
                    break
                }
                child.enumerateHierarchy(body)
            }
        }   
        enumerate(body)
    }
}

效果:

GameController

手柄控制主要功能:

  • 监控手柄连接和断开
  • 监控手柄输入:这部分上述 UI 控制类似,需要判断用户的输入并且映射到 ViewModel 中
swift 复制代码
func handleGamepadInput(_ gamepad: GCExtendedGamepad) {         let leftThumbstickX = gamepad.leftThumbstick.xAxis.value         let leftThumbstickY = gamepad.leftThumbstick.yAxis.value                 if model == nil {             return         }                 if leftThumbstickX != 0 || leftThumbstickY != 0 {             print("Left Thumbstick Moved: (leftThumbstickX), (leftThumbstickY)")             if leftThumbstickX < -sensitivity {                 model?.left = true             }             if leftThumbstickX > sensitivity {                 model?.right = true             }             if leftThumbstickY > sensitivity {                 model?.forward = true             }             if leftThumbstickY < -sensitivity {                 model?.backward = true             }                     } else {             model?.reset()             print("Left Thumbstick Released")         }     }

MoveComponent

MoveComponent 作为 Component,主要定义了运动对象相关的一些性质,如速度、转弯速度等。同时为了语义上的清晰,还定义了左和右对应的向量。

swift 复制代码
public struct MoveComponent: Component {

    let model: ViewModel
    let speed: Float = 0.3
    let turnSpeed: Float = 1.0
    
    private let left = SIMD3<Float>(0, 1, 0)
    private let right = SIMD3<Float>(0, -1, 0)
    
    func getDirection() -> SIMD3<Float> {
        if model.left {
            return left
        }
        if model.right {
            return right
        }
        return SIMD3<Float>(0, 0, 0)
    }
}

MoveSystem

MoveSystem 主要用于识别包含了MoveComponent的对象,并控制其移动。

  • 当用户控制小车前后移动时,是向小车的前方/后方而非镜头的前方/后方移动。此外,还需要根据 Component 中定义的速度,这样才能决定小车的移动

  • 当用户控制小车左右移动时,其实并非是线性的左右移动,而是控制的小车的转向

前后移动:

  1. 根据当前用户输入是向前还是向后决定移动向量forward(0, 0, 1)还是backward(0, 0, -1)
  2. 获取小车当前的方向角度将上述向量转向,从而决定移动方向。这里使用的是act(_:)方法。
  3. 将方向向量乘以标量速度,从而得到最终的移动向量
swift 复制代码
private let forward = SIMD3<Float>(0, 0, 1)
private let backward = SIMD3<Float>(0, 0, -1)
    
// ...
let deltaTime = Float(context.deltaTime)

if moveComponent.model.forward {
    let forwardDirection = entity.transform.rotation.act(forward)
    entity.transform.translation += forwardDirection * moveComponent.speed * deltaTime
}
if moveComponent.model.backward {
    let backwardDirection = entity.transform.rotation.act(backward)
    entity.transform.translation += backwardDirection * moveComponent.speed * deltaTime
}

如果用户输入同时还包含了左右移动,则需要

  • 获取期望的转向角度:simd_quatf(angle: moveComponent.turnSpeed * deltaTime, axis: moveComponent.getDirection())
  • 根据物体当前的方向entity.orientation,乘以上述转向角度,获得最终的转向方向
swift 复制代码
if moveComponent.model.left || moveComponent.model.right {
    entity.orientation = simd_mul(entity.orientation,
                                  simd_quatf(angle: moveComponent.turnSpeed * deltaTime, axis: moveComponent.getDirection()))
}

上述两组移动中还有一个共同点需要注意,当我们乘以移动或旋转速度时,都同时乘以了context.deltaTime。它指的是上次更新到这次更新之间的间隔时间。应用运行时每秒钟会有很多帧,每一帧(frame)都会调用一次 update 方法,两帧之间的间隔就是这里的deltaTime。通常我们设定的移动速度是物体每秒移动速度,如果不做上述乘法,每一帧之间都会移动我们原本预期 1 秒钟移动的距离,远超预期,因此需要在计算速度时额外乘以deltaTime来达到预期效果。

也许有同学会有疑问,那我们是否可以减小速度,将其设置为"每帧速度"呢?这里存在一个问题,帧和帧之间的间隔并不总是均匀的,而对于用户而言"秒"才是绝对的单位,因此为了让用户体感上获得一个较为稳定的速度,需要通过将速度乘以deltaTime从而获得移动距离的方式移动物体。

MoveSystem 完整的代码如下:

swift 复制代码
public struct MoveSystem: System {
    
    private let forward = SIMD3<Float>(0, 0, 1)
    private let backward = SIMD3<Float>(0, 0, -1)

    static let moveQuery = EntityQuery(where: .has(MoveComponent.self))
    
    public init(scene: RealityKit.Scene) {
    }
    
    public func update(context: SceneUpdateContext) {
        let entities = context.scene.performQuery(Self.moveQuery)
        
        for entity in entities {
            
            guard let moveComponent = entity.components[MoveComponent.self] else {
                continue
            }
            
            let deltaTime = Float(context.deltaTime)
            if moveComponent.model.forward {
                let forwardDirection = entity.transform.rotation.act(forward)
                entity.transform.translation += forwardDirection * moveComponent.speed * deltaTime
            }
            if moveComponent.model.backward {
                let backwardDirection = entity.transform.rotation.act(backward)
                entity.transform.translation += backwardDirection * moveComponent.speed * deltaTime
            }
            if moveComponent.model.left || moveComponent.model.right {
                entity.orientation = simd_mul(entity.orientation,
                                              simd_quatf(angle: moveComponent.turnSpeed * deltaTime, axis: moveComponent.getDirection()))
            }
        }
    }
    
}

最终效果

相关推荐
2401_879103689 分钟前
24.11.10 css
前端·css
ComPDFKit1 小时前
使用 PDF API 合并 PDF 文件
前端·javascript·macos
yqcoder1 小时前
react 中 memo 模块作用
前端·javascript·react.js
优雅永不过时·2 小时前
Three.js 原生 实现 react-three-fiber drei 的 磨砂反射的效果
前端·javascript·react.js·webgl·threejs·three
q567315233 小时前
用 PHP或Python加密字符串,用iOS解密
java·python·ios·缓存·php·命令模式
Hgc558886663 小时前
iOS 18.2 重磅更新:6个大动作
ios
️ 邪神3 小时前
【Android、IOS、Flutter、鸿蒙、ReactNative 】标题栏
android·flutter·ios·鸿蒙·reatnative
神夜大侠4 小时前
VUE 实现公告无缝循环滚动
前端·javascript·vue.js
明辉光焱5 小时前
【Electron】Electron Forge如何支持Element plus?
前端·javascript·vue.js·electron·node.js
lrlianmengba5 小时前
推荐一款好用的ios传输设备管理工具:AnyTrans for iOS
macos·ios·cocoa