Bd17c6861790bb91af6177e07c8a4727
34-用 RealityKit 创建 app

ARKit系列文章目录

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 CombineAdvances 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 物体都不会再露出来了。

追踪游戏状态

要追踪游戏状态,最方便的是使用实体-组件系统。
实体与组件系统,有很多好处:

  • 可通过继承来组合
  • 提高重用
  • 灵活可扩展
top Created with Sketch.