2019年WWDC的《 Session 605 - Building Apps with RealityKit 》 主要内容速览:
- 记忆卡片游戏原型搭建
- 添加细节
- 追踪游戏状态
- 添加多人游戏
说明
WWDC 的这个 session,主要讲述了一个记忆卡片小游戏的制作。包含了大量原理的讲解和代码说明,对 RealityKit 框架的学习非常有用。游戏主要玩法如下图,玩家点击打开卡片,如果连续两张是一样的则匹配成功并消失,若不成功则转回去并继续。
整个制作过程主要分为 4 个步骤:
- 原型搭建:将 AR 物体集合在一起,并添加一些简单的交互
- 添加细节:加载 AR 艺术素材,改进性能和 AR 渲染
- 追踪游戏状态:使用实体-组件系统来追踪游戏状态
- 添加多人游戏:添加网络支持和多人游戏
原型搭建
AR 的整个结构如下
- ARView:是整个 AR 的入口,如同窗户一样
- Scene:持有所有的 AR 物体
- Anchor:虚拟物体与现实的连接点,用来放置虚拟物体
- Entity:用来表示虚拟物体。游戏中每张卡片都是一个 Entity
下面我们从 Anchor 开始,讲解各个组件的创建和加载过程。
锚定Anchoring
RealityKit 中的锚点是基于 ARKit 的。首先创建一个AnchorEntity,并声明锚点的类型,然后添加到Scene中去。这样锚点就会自动追踪现实中的目标物体。
常见锚点类型有以下几种:
在本游戏中,我们只需要一个锚点,一个水平的,20cm 见方大小的平面锚点。用来在现实世界中放置游戏底座。
// Memory Cards Prototype
import UIKit
import RealityKit
class ViewController: UIViewController {
@IBOutlet var arView: ARView!
override func viewDidLoad() {
super.viewDidLoad()
// Create an anchor for a horizontal plane with a minimum area of 20 cm2
// 创建一个水平平面的锚点,至少 20cm 大。ARKit 中的单位是米,所以这里需要传 0.2 代表 20cm
let anchor = AnchorEntity(plane: .horizontal, minimumBounds: [0.2, 0.2])
arView.scene.addAnchor(anchor)
}
// Attach content to anchor here
加载模型素材
接下来,就需要加载模型素材了。
RealityKit 支持 usdz 和 Reality 文件格式。支持同步和异步加载,这里我们先用同步方式加载,后面再讲异步加载方法。加载素材的同时,会自动导入组件继承关系,网格数据,材质贴图,及动画。
代码如下:
// Load Model Assets
var cardTemplates: [Entity] = []
// Load the model asset for each card
for index in 1...8 {
let assetName = "memory_card_\(index)"
let cardTemplate = try! Entity.loadModel(named: assetName)
cardTemplates.append(cardTemplate)
}
创建卡片
整个游戏中,共有 16 张卡片,其中共有 8 种不同类型,每种 2 张
// Create Cards
var cards: [Entity] = []
for cardTemplate in cardTemplates {
// Clone each card template twice
for _ in 1...2 {
cards.append(cardTemplate.clone(recursive: true))
}
}
因为每种有 2 个,我们当然可以重新调用 load 方法再加载一遍,但是更合理的是使用clone方法来复制
克隆实体
clone方法可以
- 创建同样的复本
- 引用同样的素材
- 可以递归克隆(克隆子元素)
- 克隆的是一份复本,不是一个实例。比如:移除原始对象的一个子元素,克隆对象仍然是两个子元素,不受影响
cardTemplate.clone(recursive: true)
创建底盘
下方卡片的布局如图:
代码如下:
// Build the Board
// Shuffle the cards so they are randomly ordered
// 打乱顺序
cards.shuffle()
// Position the shuffled cards in a 4-by-4 grid
for (index, card) in cards.enumerated() {
let x = Float(index % 4) - 1.5 let z = Float(index / 4) - 1.5
// Set the position of the card
card.position = [x * 0.1, 0, z * 0.1]
// Add the card to the anchor
anchor.addChild(card)
}
添加交互
我们希望,当用户点击卡片时,卡片能翻转过来。如何确认用户点击的卡片呢?这就用到了Hit test方法,它可以将点击从 2D 屏幕空间转化到 3D 的 AR 空间
Hit Testing
Hit Testing 将屏幕上的点转为一条射线,并射进 3D 场景中。RealityKit 会找到并返回所有与射线相交的物体。
ARView 提供多种方法:entity(at point)返回离摄像机最近的实体;entities(at point)则返回与射线相交的所有实体。
// Hit Testing
@IBAction func onTap(_ sender: UITapGestureRecognizer) {
let tapLocation = sender.location(in: arView)
// Get the entity at the location we've tapped, if one exists
if let card = arView.entity(at: tapLocation) {
// For testing purposes, print the name of the tapped entity
print(card.name)
}
// Add interaction code here
}
碰撞形状
还有一件事需要注意,如果想要hit testing能正常工作,我们需要给实体添加碰撞形状(Collision shape)。
碰撞形状是一个简单的几何体,比如立方体。它们非常容易在相交和碰撞计算中被找到。如果没有碰撞形状,实体是不可点击的。
// Adding Collision Shapes
var cardTemplates: [ModelEntity] = []
// Load the model asset for each card
for index in 1...8 {
let assetName = "memory_card_\(index)"
let cardTemplate = try! Entity.loadModel(named: assetName)
// Generate collision shapes for the card so we can interact with it
cardTemplate.generateCollisionShapes(recursive: true)
// Give the card a name so we'll know what we're interacting with
cardTemplate.name = assetName
cardTemplates.append(cardTemplate)
}
动画
RealityKit 内置了动画支持。支持两种动画:
- 变换动画(Transform animation):如位置动画(Position),旋转(Rotation),缩放(Scale)
- 素材动画: 3D 素材本身自带的各种动画
同时,RealityKit 还支持给这两种动画添加
Completion handler,它可以让你知道动画什么时候结束。
此处,我们给游戏中的物体添加变换动画。动画的时间函数有下面几种:
- Linear:线性动画
- Ease in:渐入动画
- Ease out:渐出动画
- Ease in and out:渐入渐出动画
- Cubic bezier:自定义的贝塞尔曲线动画
// Adding Transform Animation, Flip Face-Up
// Copy card's current transform
var flipUpTransform = card.transform
// Set the card to rotate to π radians (180 degrees)
flipUpTransform.rotation = simd_quatf(angle: .pi, axis: [1, 0, 0])
// Move the card to the new transform over 0.25 seconds
let flipUpController = card.move(to: flipUpTransform,
relativeTo: card.parent,
duration: 0.25,
timingFunction: .easeInOut)
flipUpController.completionHandler {
// Card is done flipping face-up
}
添加动画后,整个框架就基本完成了,效果如图:
添加细节
接下来,我们需要给游戏添加更多细节。
高级素材
前面,我们已经加载了卡片,我们还给游戏准备了精美的 3D 模型素材。
3D 素材的加载,也可以用Entity.loadModel()来同步加载,但是如果素材很大,将会花费很多时间,可能会阻塞 app 的运行。另外,素材越多,加载时间也会越长。这时,就需要使用异步加载。
素材异步加载
Entity.loadModelAsync()方法可以让我们以非阻塞 app 的方式在后台加载素材。同时在加载完成后收到回调,另外,可以将多个加载请求组合起来,一起完成。
代码如下:
// Asynchronous Loading
// Load all eight models asynchronously
_ = Entity.loadModelAsync(named: "vintage_car_green")
.append(Entity.loadModelAsync(named: "vintage_car_yellow"))
.append(Entity.loadModelAsync(named: "vintage_robot_blue"))
.append(Entity.loadModelAsync(named: "vintage_robot_red"))
.append(Entity.loadModelAsync(named: "vintage_drummer_red"))
.append(Entity.loadModelAsync(named: "vintage_drummer_green"))
.append(Entity.loadModelAsync(named: "vintage_plane_green"))
.append(Entity.loadModelAsync(named: "vintage_plane_yellow"))
.collect()
.sink { models in
// All models have been loaded
}
同步加载和异步加载效果对比
注意:该加载的 API 是在新的 Swift 框架Combine中引入的。更多信息可以查看Introducing Combine和Advances in Foundation
遮蔽Occlusion
加载完 3D 素材后,发现了新的问题:我们可以看到平面下方的模型。这会严重破坏 AR 场景的真实性。
为了解决这个问题,我们需要使用遮蔽功能。它可以让 AR 物体部分或全部消失,并显示出摄像头中的画面。
这里,我们创建一个遮蔽平面,以遮挡 3D 物体
// Adding Occlusion Plane
// Create plane mesh, 0.5 meters wide & 0.5 meters deep
let planeMesh = MeshResource.generatePlane(width: 0.5, depth: 0.5)
// Create occlusion material
let material = OcclusionMaterial()
// Create ModelEntity using mesh and materials
let occlusionPlane = ModelEntity(mesh: planeMesh, materials: [material])
// Position plane below game board
occlusionPlane.position.y = -0.001
// Add to anchor
anchor.addChild(occlusionPlane)
但是,仅仅使用一个平面来遮蔽 AR 物体是不够的,因为在 3D 世界中,如果换个角度看,就可能仍然看到这些物体。
所以,我们实际上需要一个立方体盒子来遮蔽所有物体。
/ Adding Occlusion Box
// Create box mesh, 0.5 meters on all sides
let boxSize: Float = 0.5
let boxMesh = MeshResource.generateBox(size: boxSize)
// Create Occlusion Material
let material = OcclusionMaterial()
// Create ModelEntity using mesh and materials
let occlusionBox = ModelEntity(mesh: boxMesh, materials: [material])
// Position box with top slightly below game board
occlusionBox.position.y = -boxSize / 2 - 0.001
// Add to anchor
anchor.addChild(occlusionBox)
这样一来,无论从什么角度观看,3D 物体都不会再露出来了。
追踪游戏状态
要追踪游戏状态,最方便的是使用实体-组件系统。 实体与组件系统,有很多好处:
- 可通过继承来组合
- 提高重用
- 灵活可扩展
比如,我们的卡片
我们使用了 RealityKit 中的模型实体(model entity)来表示我们的卡片。它给我们提供了一系列的组件,让我们可以表示常见的虚拟物体。我们使用
model组件来表示外观,collision组件来处理 hit testing。模型实体(model entity)同时也包含了一个physics组件,可以让实体移动及和其它物体产生真实的交互,只是我们这个游戏中没有利用到。RealityKit 允许我们自定义一个实体,通过使用实体-组件设计,你可以只包含你需要的行为,去除不需要的行为,并可以自己添加新的行为。
下面我们自定义一下组件,我们想要移除Physics支持,并添加一个Card组件。
Card中包含下面属性:
- Hidden/Revealed
- Kind
所以,下面我们要先自定义一个Card组件。
自定义组件
组件(component)到底是什么?RealityKit 组件中本质是一个结构体,它包含了你定义的属性,并遵守component protocol,这样就可以附加(attach)到一个实体上去。并可以很好的实现Codable协议,这会在后面的多用户章节用过。下面我们就实现一个自定义的 CardComponent 组件,并遵守Codable协议。
// Declare Card Component
struct CardComponent: Component, Codable {
var revealed = false
var kind = ""
}
// Load a model
let entity = try! Entity.loadModel(named: "memory_card_1")
// Remove Physics Body Component
entity.physicsBody = nil
// Add Card Component
entity.components[CardComponent.self] = CardComponent()
// Modify kind property
entity.components[CardComponent.self]?.kind = "memory_card_1"
自定义实体
上面的代码中,我们使用的是系统的 model entity。但是,我们其实可以更进一步,直接使用自定义的实体。整个过程与自定义组件类似
//Declare custom entity
class CardEntity: Entity, HasModel, HasCollision {
// Card property for convenient access to card state
public var card: CardComponent {
get { return components[CardComponent.self] ?? CardComponent() }
set { components[CardComponent.self] = newValue }
}
}
本质上就是将上一步中的部分代码,移入到自定义的 entity 内部。下面给卡片本身添加翻转和隐藏的方法
// Extend CardEntity with additional methods
extension CardEntity {
// Animate, change state
func reveal() {
// Update revealed property
card.revealed = true
// Flip card over to reveal contents
var transform = self.transform
transform.rotation = simd_quatf(angle: .pi, axis: [1, 0, 0])
move(to: transform, relativeTo: parent, duration: 0.25, timingFunction: .easeInOut)
}
}
// Tap handler using CardEntity
@IBAction func onTap(_ sender: UITapGestureRecognizer) {
// Entity under cursor, if it’s a CardEntity
if let cardEntity = arView.entity(at: sender.location(in: arView)) as? CardEntity {
// Check card’s revealed state
if cardEntity.card.revealed {
// Hide card
cardEntity.hide()
} else {
// Reveal card
cardEntity.reveal()
}
}
}
最终效果如图:
添加多人游戏
最后,还需要添加多人游戏支持。
主要有下面步骤:
- 设计 host,client:设计好游戏中的主机与客户机角色
- 建立连接:建立网络连接
- 启用多人协作会话:ARKit 3 中的 Collaborative Session
- 放置同步锚点
- 管理所有权
适配多人游戏
// MultipeerConnectivity Session Setup
import MultipeerConnectivity
// Create Multipeer Session
let myPeerID = MCPeerID(displayName: "Memory Game")
let mcSession = MCSession(peer: myPeerID, securityIdentity: nil,
encryptionPreference: .required)
// Advertise or Browse, depending on role
if role == .host {
// Host Creates MCNearbyServiceAdvertiser and Starts Advertising
} else {
// Client Creates MCNearbyServiceBrowser and Starts Browsing
}
// Use Multipeer session to Synchronize RealityKit scene
arView.scene.synchronizationService = try? MultipeerConnectivityService(session: mcSession)
AR 中的多人协作网络,还记得这张图么?
开启的代码如下:
// Create a new tracking configuration
let config = ARWorldTrackingConfiguration()
// Enable collaboration
config.isCollaborationEnabled = true
// Instruct ARKit to use the configuration
arView.session.run(config, options: [])
同步锚点
// Host - Tap to place board
@IBAction func onTap(_ sender: UITapGestureRecognizer) {
// Find position under cursor
// 找到的平面
guard let result = arView.raycast(sender.location(in: arView),
allowing: .existingPlaneGeometry, alignment: .horizontal).first else {
return
}
// Create ARKit ARAnchor and add to ARSession
// 创建 ARAnchor , session 会自动处理同步
let arAnchor = ARAnchor(name: "Memory Game Board", transform: result.worldTransform)
arView.session.add(anchor: arAnchor)
// Create a RealityKit AnchorEntity and add to the scene
let anchorEntity = AnchorEntity(anchor: arAnchor)
arView.scene.addAnchor(anchorEntity)
// Add the game board to the scene here
}
完成后的效果如图,点击 Host,Client 中就会自动看到变化:
但是,这样还有另一个问题:点击 Client 中的卡片,只有 Client 中有反应,Host 中无变化:
这就是实体的所有权问题
实体所有权(Entity Ownership)
所有权,就是指对一个实体进行修改的权力。同一时间内,一个实体只能有一个所有者,默认情况下就是实体的创建者。
如下图,实体是在 Host 中被创建的,虽然在 Client 中也能看到,但是不能直接修改。
本质上,在 Host 中创建一个实体,会自动通过 ARSession 同步到 Client 中。也就是说在 Client 中有另外一个实体,但是两个设备的 ARSession 认为各自设备上的实体是同一个实体,即“同一个”物体,即存在于 Host 上,又存在于 Client 上,这就引出了所有权问题。
要修改的话,就必须转移所有权。对实体进行配置,指定哪些是可转移的,及怎样转移所有权
所有权转移(Ownership Transfer)
如下图所示,当所有者 Host 翻转卡片时,动画会自动同步到 Client 中。这里 RealityKit 只是发送了动画的相关数据,而不是 transfrom 中的每一帧数据,所以动画非常流畅。
但是,当 Client 试图去翻转卡片时,却只能修改自己本地的实体,并可能在真正所有者修改后被覆盖。不能同步到网络中的其他设备上去,因为 Client 没有这个实体的所有权。
正确的做法是,Client 先向 Host 申请所有权转移,在获得所有权之后,再对实体进行修改操作,这些修改会自动同步到其他设备上。
代码如下:
// Client asks for ownership to reveal card
// Request ownership
card.requestOwnership { result in
// Test if ownership was granted
// 获取到所有权后
if result == .granted {
// Reveal card
card.reveal()
} else {
}
// Allow player to select a different card
}
同时,我们还需要修改 CardEntity 的reveal()方法,将所有权转移模式改为手动,这样实际上就是拒绝了转移。
// Reveal card and decline transfer requests
extension CardEntity {
// Animate, change state
func reveal() {
// Don’t automatically accept ownership requests
synchronization?.ownershipTransferMode = .manual
// Update revealed property
card.revealed = true
// Flip card over to reveal contents
}
}
还需要修改 CardEntity 的hide()方法,将所有权转移模式改为自动,这样实际上就是同意了转移。
// Hide card and accept transfer requests
extension CardEntity {
// Animate, change state
func hide() {
// Automatically accept transfer requests
synchronization?.ownershipTransferMode = .autoAccept
// Update revealed property
card.revealed = false
// Flip card over to hide contents
}
}
需要注意的是,在所有权问题上 Host 并不特殊,当 Host 试图修改某个实体时,它也需要去申请所有权,然后才能修改。
选中效果
游戏此时已经基本完成了,但是还有一个问题:当一个卡片被选中中,玩家无法区分是被自己选中的,还是被其他玩家选中的。
这时,我们就需要在每个人的本地(自己的设备上)添加一个指示器,这个指示器只能自己看到,别人无需看到。
只存在本地的实体(Local-Only Entities)
只存在本地的实体(Local-Only Entities)正是为了解决这个问题而生的。
本地实体的状态只对本地客户可见,它移除了同步功能,并且子元素也不会被共享。代码如下:
// Add local only selection indicator when revealing
extension CardEntity {
func reveal() {
card.revealed = true
synchronization?.ownershipTransferMode = .manual
// Create local-only selection indicator
let selection = SelectionEntity()
selection.position.y = 0.1
// Remove synchronization component
selection.synchronization = nil
// Add as child
addChild(selection)
}
}
// Remove selection indicator on hide
extension CardEntity {
func hide() {
card.revealed = false
synchronization?.ownershipTransferMode = .autoAccept
// Iterate children looking for Selection Entity
for child in children where child is SelectionEntity {
// Remove child and exit loop
child.removeFromParent()
break
}
}
}
最终效果如图,自己选中的卡片,下方会有一个白色圆环。Host 和 Client 都只能看到自己选中的卡片。
总结
经过这 4 步,完整展示了整个 app 的关键代码和主要逻辑。 从本期 Session 中,展示了 RealityKit 如何更快更简单的构建 AR 应用。 演示中只演示了 2 台设备的情况,但事实上,这些代码可以无需修改就支持更多设备。
参考
WWDC 2019视频