Code Repo: github.com/xuchi16/vis...
Project Path: github.com/xuchi16/vis...
本文主要包含以下内容:
- 加载实体的基本操作和阴影设置
- 基本的小车移动逻辑
- UI 和手柄操控物体移动
目标及设计
这个应用我们希望实现如下基本功能
-
主页面:控制打开和关闭 Immsersive Space,在打开的情况下控制小车移动
-
游戏场景:包含地板和小车,实现基本的光影。小车可以在地板上移动,超过边界后会掉落
-
手柄控制:通过手柄控制小车移动
小车是否移动依赖于用户输入,而 ECS 系统中,由 System 控制对应的 Component 移动。因此需要让不同的控制器、页面、组件等通过一个 model 共享状态,输入源(UI/手柄)更改状态,而 System 根据当前的状态控制物体移动:
-
数据传递:定义一个 ViewModel 用于存储当前用户的输入状态,在 App 中初始化,并将其传递给各页面和组件(蓝色部分)
-
控制流:
ContentView
和GameController
作为输入源,将用户动作(图中绿色部分,如按下了前进/后退键)更新到 ViewModel 中,这样负责控制的MoveSystem
就能够读取到,并相应地控制小车移动
基本实现
ViewModel
ViewModel 是这个应用同步状态的核心数据结构,需要记录当前用户按住了哪些按键。基本的按键包括上下左右,此外,还有左上、左下、右上、右下几个方向。但只需要定义上下左右四个方向,当用户按下左上这类复合按键时,同时将左和上置为 true 即可。
swift
@Observable
class ViewModel {
var forward = false
var backward = false
var left = false
var right = false
}
CarControlApp
CarControlApp 作为入口,初始化 model 并传递给下一级组件。
- 通过环境变量传递给
ContentView
和ImmersiveView
- 初始化时通过显式
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 中定义的速度,这样才能决定小车的移动
-
当用户控制小车左右移动时,其实并非是线性的左右移动,而是控制的小车的转向
前后移动:
- 根据当前用户输入是向前还是向后决定移动向量
forward(0, 0, 1)
还是backward(0, 0, -1)
- 获取小车当前的方向角度将上述向量转向,从而决定移动方向。这里使用的是act(_:)方法。
- 将方向向量乘以标量速度,从而得到最终的移动向量
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()))
}
}
}
}