前言
对ARKit感兴趣的同学,可以订阅ARKit教程专题
源代码地址在这里
正文
在上一章中,我们学习了如何检测矩形,如何利用Vision框架以及如何将检测到的曲面转换为ARKit平面。
在这一章,我们将会:
- 修复该平面方向不准确的问题。
- 使用QR码检测升级矩形检测。
- 添加一些用户交互。
- 首先显示图像,然后在平面中显示旋转木马。
- 播放视频。
前一章中生成的ARPlane的问题在于它始终是垂直的,无论检测到的表面的方向如何。
打开ViewController.swift然后在touchesBegan()方法中添加如下代码:
// 1
let request = VNDetectRectanglesRequest{ (request, error) in
// Access the first result in the array,
// after converting to an array
// of VNRectangleObservation
// 2
guard let results = request.results?.compactMap({ $0 as? VNRectangleObservation }), let result = results.first else {
print ("[Vision] VNRequest produced no result") return
}
// 3
let coordinates: [matrix_float4x4] = [ result.topLeft, result.topRight, result.bottomRight, result.bottomLeft].compactMap{
guard let hitFeature = currentFrame.hitTest( $0, types: .featurePoint).first else { return nil }
return hitFeature.worldTransform
}
guard coordinates.count == 4 else { return }
// 4
DispatchQueue.main.async { self.removeBillboard()
let (topLeft, topRight, bottomRight, bottomLeft) = ( coordinates[0], coordinates[1], coordinates[2], coordinates[3] )
self.createBillboard(topLeft: topLeft, topRight: topRight, bottomRight: bottomRight, bottomLeft: bottomLeft)
}
}
上面的代码作用如下:
- 1: 启动矩形检测请求。
- 2: 检查是否有结果并抓住第一个。
- 3: 将包含2D空间中的四个矩形顶点的结果投影到分析的ARKit表面上以获得3D坐标。
- 4: 使用四个3D坐标创建新的广告牌。
在第3步中,我们使用当前帧的ARKit会话的hitTest()方法将2D坐标投影到3D空间,并为每个空间返回了worldTransform属性。
世界坐标系
worldTransform属性是命中测试结果相对于世界坐标系的位置和方向。世界坐标系是世界的起源; 3D世界中的所有对象都具有相对于它的位置和方向。
世界坐标系由ARKit在会话开始时设置,它取决于手机的位置和方向。我们可以执行测试以查看转换:在hitTest调用和随后的保护声明之后添加临时打印语句 - 我们完成测试后可以删除它:
print(coordinates[0])
这将打印检测到的矩形的左上角世界变换。
运行应用程序并检测矩形。使用View▸Debug Area▸Activate Console菜单项打开Xcode控制台(如果尚未显示),我们将注意到应用程序打印了这样的矩阵,在此重新格式化以便于阅读:
simd_float4x4([
[1.0, 0.0, 0.0, 0.0)],
[0.0, 1.0, 0.0, 0.0)],
[0.0, 0.0, 1.0, 0.0)],
[-0.0293431, -0.238044, -0.290515, 1.0)]
])
它由4列组成,这些列在此表示为行。第四列指定位置,而其他三列用于缩放和定向。
该矩阵仅保存位置数据,因为没有指定方向,平面定向存在错误。
ARKit允许我们通过ARConfiguration的worldAlignment属性更改世界坐标系的确定方式。世界对齐定义会话如何将设备运动映射到3D场景坐标系。 WorldAlignment枚举中定义了三个值:
- gravity:坐标系的y轴与重力平行,其原点和x-z方向是设备的初始位置和方向。
当我们想要创建虚拟世界并根据当前摄像机位置放置对象时,这非常有用,它们在重力方向上垂直定向。
- gravityAndHeading:与重力相同,增加的x轴指向东方,z轴指向南方。
无论会话开始时的摄像机方向如何,方向始终固定。当我们必须使用现实世界中存在的参考点时,可以使用此选项。
- camera: 锁定坐标系以匹配摄像机位置和方向,并在摄像机移动时跟随。
在前两种情况下,原点的y轴与重力平行。面向世界原点的节点将始终具有垂直于地面的垂直方向,这就是应用程序中实际发生的情况。
使用第三个选项,世界原点始终固定在相机上,它随着相机一起移动和旋转 - 当相机移动时,世界上的所有物体都会更新它们的位置和方向,因为它们的位置是相对的世界起源和世界起源并未固定。
由于我们最有可能指向垂直于我们尝试检测的矩形的方向,因此该选项可确保生成的平面几乎正确定向。
camera world alignment
在ViewController中,找到viewWillAppear()并在实例化配置变量后添加以下行:
configuration.worldAlignment = .camera
运行应用程序。现在,每次检测到矩形时,生成的平面将始终指向摄像机。我们可以在下一页看到这个说明。
从摄像机到矩形中心的虚线垂直于矩形标识的平面。
检测QR码
我们打开ViewController.swift文件,找到touchesBegan()方法,在do/catch回调中添加以下代码:
let request = VNDetectRectanglesRequest { (request, error) in
// Access the first result in the array,
// after converting to an array
// of VNRectangleObservation
guard let results = request.results?.compactMap({
$0 as? VNRectangleObservation }),
let result = results.first else {
print ("[Vision] VNRequest produced no result")
return
}
...
}
要检测矩形,我们使用的是VNDetectRectanglesRequest,它会生成一个VNRectangleObservation数组,并传递给在检测过程中调用的闭包。
Vision也实现了其他检测类型:
- Horizon: 使用VNDetectHorizonRequest类。这决定了图像中的水平角度。
- Faces: 使用VNDetectFaceRectanglesRequest和VNDetectFaceLandmarksRequest类。这会检测包含面部和面部特征的矩形。
- Text: 使用VNDetectTextRectanglesRequest类。这会检测包含可识别文本的区域。
- 矩形和对象跟踪:使用VNTrackRectangleRequest和VNTrackObjectRequest类。这会跟踪我们之前检测到的任何矩形和对象。
还有一些其他类用于机器学习和图像对齐,以及读取QR代码所需的类:VNDetectBarcodesRequest。
我们做一下如下替换:
- 用VNDetectBarcodesRequest替换VNDetectRectanglesRequest。
- 用VNBarcodeObservation替换VNRectangleObservation。
替换后的前一个代码段应该是这样的:
let request = VNDetectBarcodesRequest { (request, error) in
// Access the first result in the array,
// after converting to an array
// of VNBarcodeObservation
guard let results = request.results?.compactMap({
$0 as? VNBarcodeObservation }),
let result = results.first else {
print ("[Vision] VNRequest produced no result")
return
}
...
}
为了方便测试,我们可以扫描下面的二维码:
扫描出来的结果如下:
{ "url": "https://www.raywenderlich.com" }
目前,QR码中编码的数据无关紧要;我们只需要检测一些东西。
构建并运行应用程序。然后,将相机对准QR码并点击屏幕以触发检测。重复此操作,直到我们的应用检测到QR码并在其上创建一个平面。
显示一张图片
第一步是在平面上显示图像。找到ViewController.swift,并滚动到render(_:nodeFor :)。在switch语句中,在billboard.billboardAnchor的末尾,添加以下代码:
let image = UIImage(named: "logo_1")!
setBillboardImage(image: image)
这会加载一个图像并将其传递给setBillboardImage(),这是我们接下来要编写的一个方法。
在render(_:nodeFor :)中创建的广告牌节点 - 与在touchesBegun(_:with)中创建的ARKit锚点相关联的广告牌节点 - 是包含几何体的SceneKit节点。在在本例子中,这是一个SCNPlane。
SceneKit中的所有几何都继承自SCNGeometry,它代表3D模型并定义其外观和形状。正如我们在第4章中所学到的,我们可以访问几个属性来确定外观。我们现在最感兴趣的属性是diffuse。
来到ViewController.swift在下面添加如下代码:
func setBillboardImage(image: UIImage) {
// 1 let material = SCNMaterial()
// 2 material.isDoubleSided = true
// 3
DispatchQueue.main.async {
// https://forums.developer.apple.com/thread/89423
// A UIView can be assigned to a material
// 4 let imageView = UIImageView(image: image) material.diffuse.contents = imageView
// 5
self.billboard?.billboardNode?.geometry?.materials = [material]
}
}
上面的代码作用如下:
- 1: 创建一个SCNMaterial成员变量
- 2: 将其isDoubledSide设置为true,以便将材质应用于两侧。请注意,如果此属性为false,则当相机指向其背面时,该平面将不可见。
- 3: 切换到主线程以处理UI工作。
- 4: 使用作为参数传递的图像创建UIImageView。然后,它将该图像分配给材质的diffuse.contents属性。
- 5: 最后,它使用materials属性(即数组)将新材质设置为几何。由于我们使用的是单一材质,因此我们只需创建一个包含一个元素的数组。
运行程序,扫描QR码,结果如下:
我们还可以为材质指定UIImage,而不是将其装入UIImageView。在某些情况下,这可能是我们的最佳选择,例如当我们拥有透明图像时,因为它不适用于UIImageView,更常见的是,使用UIView继承的类。
如果我们正在测试,请暂时更改此行:
material.diffuse.contents = imageView
改为:
material.diffuse.contents = image
显示一个图像轮播
如果UIImageView是UIView的子类 - 这意味着我们可以附加任何从它继承的视图或组件 - 包括一个视图控制器。
我们添加三个文件:
- BillboardView.swift
- BillboardViewController.swift
- BillboardViewController.xib
这三个文件实现了一个视图控制器和一个视图来显示和处理一个简单的轮播。该轮播允许用户显示列表中的图像并通过一对前一个和下一个按钮导航它们。
这里没有涉及ARKit或SceneKit - 它都是UIKit。实现是基本的,因此除了图像属性由视图控制器公开并用于指定要显示的图像之外,没有什么值得一提的。
接下来,需要显示这个视图控制器。
在ViewController.swift中找到setBillboardImage(image:)方法,现在需要把参数改一下,改成如下所示:
func setBillboardImages(_ images: [UIImage]) {
这样我们需要传入的是一个图像的数组。
接下来,我们需要使用视图控制器替换图像视图。在调度队列代码块内,找到以下两行:
let imageView = UIImageView(image: image)
material.diffuse.contents = imageView
把这两行代码用如下代码替换:
// 1
let billboardViewController = BillboardViewController( nibName: "BillboardViewController", bundle: nil)
// 2
billboardViewController.images = images
// 3
material.diffuse.contents = billboardViewController.view
上面的代码作用如下:
- 1: 创建billboardViewController成员变量。
- 2: 将其images属性设置为要在轮播中显示的图像列表。
- 3: 把billboardViewController添加到ARKit平面中。
要跟踪这个新实例化的视图控制器,我们将它存储在BillboardContainer结构中,因为我们已经使用它来存储有关广告牌的数据。
在刚刚添加的三行之后插入这行代码:
self.billboard?.viewController = billboardViewController
BillboardContainer还没有该属性。打开BillboardContainer.swift并将以下属性添加到结构中:
var viewController: BillboardViewController?
最后要做的是设置要显示的图像。在上一次使用图像视图的测试中,我们可以在renderer()方法中设置图像。但从风格的角度来看,这不是这个代码的最佳位置。
现在下面的这两行代码可以删除掉了:
let image = UIImage(named: "logo_1")!
self.setBillboardImage(image: image)
找到addBillboardNode()方法,然后添加如下代码:
let images = [ "logo_1", "logo_2", "logo_3", "logo_4", "logo_5" ].map { UIImage(named: $0)! }
setBillboardImages(images)
运行程序,效果如下:
这是一个简单的方法:锚的中心必须逆时针旋转90度。在ViewController.swift中找到createBillboard()找到如下代码:
let anchor = ARAnchor(transform: plane.center)
把这一行代码替换成:
// 1
let rotation =
SCNMatrix4MakeRotation(Float.pi / 2.0, 0.0, 0.0, 1.0)
// 2
let rotatedCenter =
plane.center * matrix_float4x4(rotation)
let anchor = ARAnchor(transform: rotatedCenter)
上面代码作用如下:
- 1: 我们可以围绕z轴顺时针创建90度的旋转矩阵。
- 2: 然后,将该旋转应用于平面中心,并将结果用作锚变换。
直觉上,我们可能会认为正z轴是从设备朝向外部世界的,但是随着我们正在使用的相机世界对齐,这是相反的方式。因此,如果从相对侧观察平面,则旋转必须是顺时针方向。运行程序,效果如下:
播放视频
打开BillboardViewController.xib文件,让下面的播放按钮可见。
打开BillBoardViewController.xib,给播放按钮添加点击事件。
在BillboardViewController类中。请注意,它声明了一个具有相同名称和类型的计算属性,该属性转发到视图的属性:
var delegate: BillboardViewDelegate? {
get { return _view.delegate }
set { _view.delegate = newValue}
}
由于此视图控制器是在ViewController中创建的,因此需要在那里初始化委托属性。
在ViewController中,转到setBillboardImages(_ :) - 创建BillboardViewController -添加如下代码:
billboardViewController.delegate = self
给ViewController.swift再添加一个扩展:
extension AdViewController: BillboardViewDelegate {
func billboardViewDidSelectPlayVideo(
_ view: BillboardView) {
createVideo()
}
}
在createBillboard()之后添加如下代码:
func createVideo() {
guard let billboard = self.billboard else { return }
let rotation = SCNMatrix4MakeRotation(Float.pi / 2.0, 0.0, 0.0, 1.0)
let rotatedCenter = billboard.plane.center * matrix_float4x4(rotation)
// 1
let anchor = ARAnchor(transform: rotatedCenter)
// 2
sceneView.session.add(anchor: anchor)
// 3
self.billboard?.videoAnchor = anchor
}
上面的代码作用如下:
- 1: 创建新锚点,以与广告牌相同的位置为中心。
- 2: 把锚点添加到场景中。
- 3: 保存锚点,以便我们以后可以访问它。
Billboard Container还没有那个视频Anchor属性,所以还需要添加另一个属性。打开BillboardContainer.swift添加如下代码:
var videoAnchor: ARAnchor?
var videoNode: SCNNode?
一个用于我们在上一个代码段中创建的锚点;另一个是SceneKit节点。
在hasBillboardNode之后添加以下代码:
var hasVideoNode: Bool { return self.videoNode != nil }
我们将使用它来确定是否存在活动视频节点。
最后,在初始化程序中将两个新属性初始化为nil:
self.videoAnchor = nil
self.videoNode = nil
现在我们需要添加相应的SceneKit节点。
在ViewController的渲染器(_:nodeFor :)中,我们将看到一个switch语句:
switch anchor {
case billboard.billboardAnchor:
let billboardNode = addBillboardNode()
node = billboardNode
default:
break
}
在此添加一个新案例,为视频锚点创建一个新节点;在默认情况之前添加它:
case (let videoAnchor)
where videoAnchor == billboard.videoAnchor:
node = addVideoPlayerNode()
这将使用addVideoPlayerNode()创建一个新节点。
现在,在addBillboardNode()之后添加以下新方法:
func addVideoPlayerNode() -> SCNNode? {
guard let billboard = self.billboard else { return nil }
// 1
let billboardSize = CGSize(width: billboard.plane.width, height: billboard.plane.height / 2)
let frameSize = CGSize(width: 1024, height: 512)
let videoUrl = URL(string:"https://www.rmp-streaming.com/media/bbb-360p.mp4")!
// 2
let player = AVPlayer(url: videoUrl)
let videoPlayerNode = SKVideoNode(avPlayer: player)
videoPlayerNode.size = frameSize
videoPlayerNode.position = CGPoint(x: frameSize.width / 2, y: frameSize.height / 2 )
videoPlayerNode.zRotation = CGFloat.pi
// 3
let spritekitScene = SKScene(size: frameSize)
spritekitScene.addChild(videoPlayerNode)
// 4
let plane = SCNPlane(width: billboardSize.width, height: billboardSize.height )
plane.firstMaterial!.isDoubleSided = true
plane.firstMaterial!.diffuse.contents = spritekitScene
let node = SCNNode(geometry: plane)
// 5
self.billboard?.videoNode = node
// 6
self.billboard?.billboardNode?.isHidden = true
videoPlayerNode.play()
return node
}
上面代码作用如下:
- 1: 处理一些变量初始化。
- 2: 创建视频播放器和SpriteKit视频节点。
- 3: 创建一个新的SpriteKit场景并将视频节点添加到它。
- 4; 设置一个新的SceneKit平面,然后将其添加到节点;与我们对广告牌所做的相似,但使用SpriteKit场景代替漫反射内容。
- 5: 保存新创建的节点以供将来参考。
- 6: 隐藏广告牌节点并启动视频播放器。
注意:你可能想知道为什么在第2步中围绕z轴旋转视频播放器。它是一个未记录的“特征”,它导致SpriteKit节点在逆时针旋转90度的SceneKit节点上呈现。在此处,旋转180度以补偿在createVideo()方法中应用于ARKit锚点的顺时针旋转90度。
运行程序,显示结果如下:
移除掉视频播放器
我们需要添加一个停止播放器的方法。 我们在removeBillboard()后添加以下方法:
func removeVideo() {
if let videoAnchor = billboard?.videoAnchor {
// 1
sceneView.session.remove(anchor: videoAnchor)
// 2
billboard?.videoNode?.removeFromParentNode()
// 3
billboard?.videoAnchor = nil
billboard?.videoNode = nil
}
}
上面代码作用如下:
- 1: 从ARKit会话中删除锚点。
- 2: 从其父节点中删除SceneKit节点。
- 3: 清除存储在广告牌容器中的引用。
将以下代码添加到touchesBegan(_:with :)主体的开头:
// 1
if billboard?.hasVideoNode == true {
// 2
billboard?.billboardNode?.isHidden = false
// 3
removeVideo()
// 4
return
}
上面的代码作用如下:
- 1: 检查是否有video节点。
- 2: 如果有,就显示广告牌。
- 3: 调用removeVideo()移除掉video。
- 4: return退出当前方法。
运行程序,并在播放视频时随时点击屏幕以停止播放。
上一章 | 目录 | 下一章 |
---|