iOS版 使用ARKit和Swift创建交互式Domino游戏
在下面的教程中,我将向您展示如何使用Swift和ARKit制作有趣的Domino游戏。
这就是我们要做的:
dominoes.gif先决条件
这是一个中级教程,要求您对Swift有一个很好的理解,以及ARKit和SceneKit的一些基本知识。
接下来,需要Xcode 9(或更高版本)和运行iOS 11(或更高版本)的ARKit支持的设备。
现在让我们开始吧!
设置初始ARKit场景
打开Xcode并选择File> New Project。然后,选择增强现实应用程序模板,然后按下一步。
1.png我们将我们的项目命名为ARDominoes,选择Swift作为语言,选择SceneKit作为内容技术。
2.png我们选择的增强现实应用程序 Augmented Reality App模板带有一些预先编写的代码,可以为我们设置场景并且几乎可以使用了。我们只需更改一行代码即可。
请注意,本教程的所有代码都将在ViewController类中编写。
在viewDidLoad
,将行let scene = SCNScene(named: "art.scnassets/ship.scn")!
更改为let scene = SCNScene()
,因为我们只想要一个空场景而不是宇宙飞船!
平面检测
我们要做的第一件事是添加plane detection
到我们的场景中。简单地说,平面检测是在现实世界中找到任何水平(或垂直)平面。
首先,让我们创建一个空字典,以便我们可以保持对ARKit检测到的平面的引用。将以下行添加到您的类的顶部:
var detectedPlanes: [String : SCNNode] = [:]
在viewWillAppear
,在let configuration = ARWorldTrackingConfiguration()
以下添加代码,
configuration.planeDetection = .horizontal
通过将planeDetection值设置为.horizontal
,ARKit会尝试自动检测平面。一旦检测到表面,就会调用委托方法renderer:didAddNode:forAnchor
。
将以下方法添加到您的类:
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
// 1
guard let planeAnchor = anchor as? ARPlaneAnchor else { return }
// 2
let plane = SCNPlane(width: CGFloat(planeAnchor.extent.x), height: CGFloat(planeAnchor.extent.z))
let planeNode = SCNNode(geometry: plane)
planeNode.position = SCNVector3Make(planeAnchor.center.x,
planeAnchor.center.y,
planeAnchor.center.z)
// 3
planeNode.opacity = 0.3
// 4
planeNode.rotation = SCNVector4Make(1, 0, 0, -Float.pi / 2.0)
node.addChildNode(planeNode)
// 5
detectedPlanes[planeAnchor.identifier.uuidString] = planeNode
}
- 首先,我们必须确保我们的锚是一个平面锚。
- ARPlaneAnchor为我们提供了检测到的曲面的中心和范围(宽度和高度)值。我们使用范围值来创建平面几何体并使用中心值来定位节点。
- 我们将平面不透明度设置为30%,因此它不会完全遮挡地板。
- 由于SCNPlanes在首次创建时是垂直的,因此我们必须将平面旋转90度。创建平面后,将其添加到锚点附加的节点。
- 每个锚都有唯一的标识符。我们使用其唯一标识符作为关键字将平面节点添加到字典中。
ARKit不断分析场景,如果发现检测到的表面现在变大或变小,它将renderer:didUpdatenode:forAnchor:
使用更新的值调用其另一个委托方法。
实现以下委托方法,以便在更新飞机的范围或中心值时收到通知:
func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
// 1
guard let planeAnchor = anchor as? ARPlaneAnchor else { return }
// 2
guard let planeNode = detectedPlanes[planeAnchor.identifier.uuidString] else { return }
let planeGeometry = planeNode.geometry as! SCNPlane
planeGeometry.width = CGFloat(planeAnchor.extent.x)
planeGeometry.height = CGFloat(planeAnchor.extent.z)
planeNode.position = SCNVector3Make(planeAnchor.center.x,
planeAnchor.center.y,
planeAnchor.center.z)
}
- 与之前相同,我们首先检查以确保更新的锚是类型的
ARPlaneAnchor
。 - 由于我们之前已将检测到的平面保存在字典中,因此我们可以使用锚点的唯一标识符并检索我们的平面并更新其值。
运行应用程序:
image了解ARKit如何不断更新飞机?
我们不再需要看地面了。在renderer:didUpdatenode:forAnchor:
,设置planeNode.opacity =0.0
。
有关平面检测的更深入的文章可以在这里找到。
创建多米诺骨牌并使用命中测试将它们放在地板上
我们的飞机检测完成后,我们现在准备进行一些命中测试!
首先,让我们在场景中添加一个空数组,以便我们可以保留对我们添加的多米诺骨牌的引用。这将在以后派上用场。将以下变量添加到类的顶部:
var dominoes: [SCNNode] = []
最简单形式的命中测试是确定用户触摸的屏幕的2D位置是否与现实世界中的任何虚拟对象或在我们的情况下与平面相交。如果检测到对象,则将返回对象以及交叉点。我们使用这些数据将我们的多米诺骨牌添加到触摸屏幕的地板上的确切位置。
我们必须为我们的场景添加一个平移手势。在以下末尾添加以下代码viewDidLoad
:
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(screenPanned))
sceneView.addGestureRecognizer(panGesture)
将以下方法添加到您的代码中。每次在屏幕上检测到平移手势时都会调用它:
@objc func screenPanned(gesture: UIPanGestureRecognizer) {
// 1
let configuration = ARWorldTrackingConfiguration()
sceneView.session.run(configuration)
// 2
let location = gesture.location(in: sceneView)
guard let hitTestResult = sceneView.hitTest(location, types: .existingPlane).first else { return }
let currentPosition = SCNVector3Make(hitTestResult.worldTransform.columns.3.x,
hitTestResult.worldTransform.columns.3.y,
hitTestResult.worldTransform.columns.3.z)
// 3
let dominoGeometry = SCNBox(width: 0.007, height: 0.06, length: 0.03, chamferRadius: 0.0)
dominoGeometry.firstMaterial?.diffuse.contents = UIColor.green
let dominoNode = SCNNode(geometry: dominoGeometry)
dominoNode.position = SCNVector3Make(currentPosition.x,
currentPosition.y + 0.03,
currentPosition.z)
sceneView.scene.rootNode.addChildNode(dominoNode)
// 4
dominoes.append(dominoNode)
}
- 我们需要底层稳定,所以我们必须首先禁用平面检测。要禁用平面检测,我们会重新配置会话并再次运行。
- 我们在用户触摸的屏幕上获得2D点,并使用它来执行我们的命中测试。如果检测到任何物体,
ARHitTestResult
将返回一个然后我们用来获得确切位置的物体。 - 我们使用简单的SCNBox创建我们的多米诺骨牌。为其添加绿色,创建一个放置在其中的节点,并使用我们通过命中测试检测到的坐标来定位它。我们在节点的Y位置添加一个“0.03”的值来向上移动我们的多米诺骨牌,否则一半的多米诺骨牌会在地板内!
- 我们将多米诺骨牌节点添加到我们的多米诺骨牌阵列中供以后使用。
现在运行应用程序。将手机移动一下,以便ARKit可以检测到地板并用手指在屏幕上绘制:
3.png好吧,热门测试工作正常,但现在我们遇到了一个新问题:这么多的多米诺骨牌!(不要担心多米诺骨牌都面向同一个方向,我们稍后会解决)。
多米诺距离
当用户在屏幕上移动他的手指时调用平移手势。由于这是连续移动,因此该方法每秒被调用多次。
我们需要想办法在每个多米诺骨牌之间留出一些距离。要做到这一点,我们必须保存先前放置的多米诺骨牌的位置,然后计算它到我们的命中测试的当前位置的距离。如果距离大于或等于某个最小距离,我们将放置新的多米诺骨牌,否则,我们将退出该功能并重复该过程,直到达到最小距离。
创建一个新变量并将其添加到类的顶部。这将存储我们最近放置的多米诺骨牌的位置:
var previousDominoPosition: SCNVector3?
将以下方法添加到您的类:
func distanceBetween(point1: SCNVector3, andPoint2 point2: SCNVector3) -> Float {
return hypotf(Float(point1.x - point2.x), Float(point1.z - point2.z))
}
这是一个辅助方法,用于计算空间中两点之间的距离。
现在,对screenPanned:
方法进行以下更改,使其如下所示:
@objc func screenPanned(gesture: UIPanGestureRecognizer) {
let configuration = ARWorldTrackingConfiguration()
sceneView.session.run(configuration)
let location = gesture.location(in: sceneView)
guard let hitTestResult = sceneView.hitTest(location, types: .existingPlane).first else { return }
// 1
guard let previousPosition = previousDominoPosition else {
self.previousDominoPosition = SCNVector3Make(hitTestResult.worldTransform.columns.3.x,
hitTestResult.worldTransform.columns.3.y,
hitTestResult.worldTransform.columns.3.z)
return
}
// 2
let currentPosition = SCNVector3Make(hitTestResult.worldTransform.columns.3.x,
hitTestResult.worldTransform.columns.3.y,
hitTestResult.worldTransform.columns.3.z)
// 3
let minimumDistanceBetweenDominoes: Float = 0.03
let distance = distanceBetween(point1: previousPosition, andPoint2: currentPosition)
if distance >= minimumDistanceBetweenDominoes {
let dominoGeometry = SCNBox(width: 0.007, height: 0.06, length: 0.03, chamferRadius: 0.0)
dominoGeometry.firstMaterial?.diffuse.contents = UIColor.green
let dominoNode = SCNNode(geometry: dominoGeometry)
dominoNode.position = SCNVector3Make(currentPosition.x,
currentPosition.y + 0.03,
currentPosition.z)
sceneView.scene.rootNode.addChildNode(dominoNode)
dominoes.append(dominoNode)
// 4
self.previousDominoPosition = currentPosition
}
}
- 首先,我们检查是否已经放置了多米诺骨牌。如果没有,我们将
previousPosition
属性设置为hit-Test结果的位置并返回。 - 我们得到了命中测试结果的当前位置。
- 我们计算前一个位置和当前位置之间的距离。如果距离大于或等于我们设定的最小距离(在这种情况下为3厘米),则会放置一个新的多米诺骨牌。
- 记得设置
previousDominoPosition
为currentPosition
。
运行应用程序:
4.png现在,多米诺骨牌被放置在一个漂亮而均匀的距离。
多米诺取向
由于多米诺骨牌在首次创建时没有给出旋转值,因此它们都面向相同的方向。为了计算每个多米诺骨牌应该面对的方向,我们必须做一些数学运算。
5.jpg从上图可以看出,我们目前的情况类似于左边的图表,每个多米诺骨牌面向相同的方向。我们希望它看起来像右边的图表,以便我们放置的每个新多米诺骨牌都能正确旋转。要做到这一点,我们必须计算前一个多米诺骨牌位置和当前位置之间的角度,并相应地旋转新的多米诺骨牌。
我们可以使用arcTan公式获得两个多米诺骨牌之间的角度。此公式计算两个点相对于轴(在本例中为X轴)之间的角度。
将以下函数添加到您的类:
func pointPairToBearingDegrees(startingPoint: CGPoint, secondPoint endingPoint: CGPoint) -> Float{
let originPoint: CGPoint = CGPoint(x: startingPoint.x - endingPoint.x, y: startingPoint.y - endingPoint.y)
let bearingRadians = atan2f(Float(originPoint.y), Float(originPoint.x))
let bearingDegrees = bearingRadians * (180.0 / Float.pi)
return bearingDegrees
}
原始来源:https://stackoverflow.com/a/6065003/3975207
一旦我们有了角度,我们就可以用计算出的角度围绕Y轴旋转新的多米诺骨牌。
在该screenPanned
方法中,在sceneView.scene.rootNode.addChildNode(dominoNode)
之前添加以下三行代码:
// 1
var currentAngle: Float = pointPairToBearingDegrees(startingPoint: CGPoint(x: CGFloat(currentPosition.x), y: CGFloat(currentPosition.z)), secondPoint: CGPoint(x: CGFloat(previousPosition.x), y: CGFloat(previousPosition.z)))
// 2
currentAngle *= .pi / 180
// 3
dominoNode.rotation = SCNVector4Make(0, 1, 0, -currentAngle)
- 获取当前多米诺骨牌和之前的多米诺骨牌之间的角度。
- 从弧度转换为度数。
- 沿Y轴旋转节点。
在我们运行我们的应用程序之前,让我们快速为多米诺骨牌添加一些颜色。在类的顶部添加以下属性:
let dominoColors: [UIColor] = [.red, .blue, .green, .yellow, .orange, .cyan, .magenta, .purple]
它只是一个简单的数组,有几种颜色,我们将随机选择并分配给每个新的多米诺骨牌。现在,我们所要做的就是将screenPanned:
我们为多米诺骨牌设置绿色的行更改为以下行:
dominoGeometry.firstMaterial?.diffuse.contents = dominoColors.randomElement()
运行应用程序:
6.jpg随机颜色对多米诺骨牌的外观有很大的不同!
我们的多米诺骨牌现在旋转得很好。
现在我们已经很好地设置了我们的多米诺骨牌,现在是时候让它们互动了。
物理
SceneKit物理引擎实际上非常容易使用; 你只需要让SceneKit知道应用物理的对象,SceneKit将完成其余的工作。在我们的例子中,我们想告诉SceneKit我们的多米诺骨牌应该相互碰撞和地板。
为此,我们必须在节点中添加所谓的“ 物理体 ”。将物理主体添加到节点会告诉SceneKit将该节点包含在物理模拟中。
要在SceneKit中创建一个物理体,我们必须给它一个type
和一个shape
。
有三种不同类型的物理实体:
静态:不受力或碰撞影响且不能移动的物理体。
动态:可受力和碰撞影响的物理体。
运动学:一种物理体,不受力或碰撞的影响,但在移动时会导致碰撞影响其他物体。
在我们的例子中,我们需要为地板使用静态主体,为多米诺骨牌使用动态主体。
物理形状决定了SceneKit如何处理碰撞。在大多数情况下,用于创建形状的实际几何形状足够好; 但对于高级几何体,最好使用更简单的形状,以便它们使用更少的计算能力和内存。这将使模拟更加顺畅。
在viewDidLoad
,在行下添加以下行sceneView.scene = scene
:
sceneView.scene.physicsWorld.timeStep = 1/200
TimeStep
是物理模拟更新之间的时间间隔。这个数字越小,物理模拟就越准确。我们想要更精确的模拟,因此我们将其设置为1/200(默认值为1/60)。
renderer
方法中在行node.addChildNode(planeNode)
前面在添加:
// 1
let box = SCNBox(width: CGFloat(planeAnchor.extent.x), height: CGFloat(planeAnchor.extent.z), length: 0.001, chamferRadius: 0)
// 2
planeNode.physicsBody = SCNPhysicsBody(type: .static, shape: SCNPhysicsShape(geometry: box, options: nil))
- 我们
SCNBox
使用planeAnchor的范围值创建一个物理体。 - 我们创建一个
SCNPhysicsBody
类型设置为.static并使用SCNBox
其形状。
好!现在我们的地板上有一个物理体。但它还没有完成。由于ARKit平面检测不断更新地板的大小,因此值也physicsShape
应该更新。
在renderer
方法的末尾添加以下行:
let box = SCNBox(width: CGFloat(planeAnchor.extent.x), height: CGFloat(planeAnchor.extent.z), length: 0.001, chamferRadius: 0)
planeNode.physicsBody?.physicsShape = SCNPhysicsShape(geometry: box, options: nil)
接下来,让我们为我们的多米诺骨牌添加物理。在screenPanned
方法前面的行中添加以下行sceneView.scene.rootNode.addChildNode(dominoNode)
:
// 1
dominoNode.physicsBody = SCNPhysicsBody(type: .dynamic, shape: nil)
// 2
dominoNode.physicsBody?.mass = 2.0
dominoNode.physicsBody?.friction = 0.8
- 对于多米诺骨牌,我们将使用
dynamic
类型物理体并将形状设置为nil
。为什么我们没有为我们的物理身体赋予形状?当我们设置nil
为形状的值时,SceneKit会自动将节点的几何体用于物理形状。这意味着我们的工作量减少了! - 物理体具有许多不同的物理特性,你可以改变它们的质量,摩擦力,阻尼等。我们将多米诺骨牌设置
mass
为2,将friction
0.8 设置为0.8。这使物理看起来更逼真。我们如何知道使用什么值?这主要是试验和错误。只需继续尝试不同的价值观,看看什么效果最好。这就是Apple对物理体属性所说的话:
请注意,您无需尝试为物理量提供实际值 - 使用任何值来生成您正在寻找的行为或游戏玩法。
现在构建并运行。
...
好吧,似乎没有任何事情发生,这正是我们想要的!由于除了重力影响我们的多米诺骨牌之外没有其他力量,所以什么都不会发生。为了击倒多米诺骨牌,我们必须对第一张多米诺骨牌施加一股力量。
我们将在场景中添加两个按钮。一个按钮将删除我们场景中的所有多米诺骨牌,另一个按钮将推翻第一个多米诺骨牌。
在main.storyboard
,创建一个按钮,并将其命名为“ 删除所有多米诺骨牌 ”。创建一个动作插座并为其命名removeAllDominoesButtonPressed
。
创建另一个按钮并将其命名为“ 开始 ”。创建一个动作插座并为其命名startButtonPressed
。
在startButtonPressed
方法中添加以下代码行:
// 1
guard let firstDomino = dominoes.first else { return }
// 2
let power: Float = 0.7
firstDomino.physicsBody?.applyForce(SCNVector3Make(firstDomino.worldRight.x * power,
firstDomino.worldRight.y * power,
firstDomino.worldRight.z * power),
asImpulse: true)
- 这是我们的多米诺骨牌列表变得有用的地方。由于多米诺骨牌按照放置顺序添加到列表中,我们可以轻松获得第一张多米诺骨牌。如果不存在多米诺骨牌,则该方法将返回。
- 现在我们有了我们最初的多米诺骨牌,我们必须对它施加一种力量。我们使用SceneKits
applyForce
方法来完成此操作。第一个参数采用SCNVector3
它用于力的方向和大小的a。第二个参数采用aBoolean
,如果为真,则将力作为脉冲(瞬间)施加。由于我们想要轻弹效果,我们将冲动设置为真。
将以下代码添加到removeAllBominoesButtonPressed
方法中:
for domino in dominoes {
domino.removeFromParentNode()
self.previousDominoPosition = nil
}
dominoes = []
这将从场景中删除所有我们的多米诺骨牌,将previousDominoPosition
属性设置为nil
,并将dominoes
数组设置为空,以便我们可以重新开始。
现在运行应用程序:
8.jpg好吧,多米诺骨牌倒下了!我们差不多完成了。
光源
在计算机图形学中,使场景看起来真实的最重要方面之一是良好的照明。我们希望让多米诺骨牌看起来尽可能真实,因此必须使用光源和阴影。
目前在SceneKit中,只有两种类型的灯支持阴影:
点光源 - 照亮锥形区域的光源
方向光源 - 具有均匀方向和恒定强度的光源。这个位置被忽略了,只有它的方向很重要。
对于这个场景,我们将使用定向灯。
我们添加到场景中的地板是不透明的,因此应用到它上面的任何阴影都是不可见的。如何在保持隐形的同时为地板添加阴影?自iOS 11以来,SceneKit已经添加了一个新策略来实现这一目标。通过将colorBufferWriteMask
几何体设置为空,SceneKit不会渲染该几何体的任何颜色,但会允许它接收阴影。在行renderer:didAddNode:ForAnchor:
之前添加以下行letplaneNode = SCNNode(geometry: plane)
:
plane.firstMaterial?.colorBufferWriteMask = .init(rawValue: 0)
然后确保您已删除该行planeNode.opacity = 0.0
。否则,阴影将不会呈现。
将以下函数添加到您的类:
func addLights() {
// 1
let directionalLight = SCNLight()
directionalLight.type = .directional
directionalLight.intensity = 500
// 2
directionalLight.castsShadow = true
directionalLight.shadowMode = .deferred
// 3
directionalLight.shadowColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.5)
// 4
let directionalLightNode = SCNNode()
directionalLightNode.light = directionalLight
directionalLightNode.rotation = SCNVector4Make(1, 0, 0, -Float.pi / 3)
sceneView.scene.rootNode.addChildNode(directionalLightNode)
// 5
let ambientLight = SCNLight()
ambientLight.intensity = 50
let ambientLightNode = SCNNode()
ambientLightNode.light = ambientLight
sceneView.scene.rootNode.addChildNode(ambientLightNode)
}
- 我们创建一个灯,将其类型设置为500 ,并为其
.directional
提供intensity
500。 - 我们将其设置
castShadow
为true并设置shadowMode
为.deferred
在渲染对象时不应用阴影,但将其应用为最终后期处理(这是在不可见平面上投射阴影所必需的)。 - 我们创建一个50%不透明度的黑色,并将其设置为我们的
shadowColor
。这将使我们的阴影看起来更加灰色和逼真,而不是默认的深黑色。 - 为了将光添加到场景中,它必须附加到节点。当在SceneKit中首次创建光源时,它默认指向-Z方向(直线向前)。我们想要旋转光源,使其朝向地板向下倾斜。
- 定向灯本身使我们的场景非常暗。环境光从各个方向照亮场景中的所有对象。它将减轻整体场景。
现在,我们所要做的就是添加addLights()
到viewDidLoad
应用程序的末尾并运行应用程序:
我们完成了!
结论
恭喜你一路走来。这是一个非常长的教程,但我希望它能帮助您了解创建交互式ARKit应用程序所需的过程,更重要的是,我希望您能够创建它。
如果您有任何问题或建议,请在下面的评论中写下。
您可以在此处下载完整的Xcode项目。
dominoes.gif这是iOS开发人员Koushan Korouei的客座文章,专注于ARKit。该文章首次发表于Medium。
关于作者:Koushan Korouei是一位具有Swift和Objective-C专业经验的iOS开发人员。他对增强现实充满热情,他现在的主要焦点是ARKit。他相信AR眼镜将取代智能手机的未来。您可以在Twitter上关注他或在LinkedIn上与他联系。