说明
在 AR 开发中,我们有时会遇到这样的问题:让一个物体根据距离来决定显示还是隐藏,比如当你走到 2 米处,显示一个详情介绍页等;或者是场景非常大,希望走远 100 米外,隐藏某些物体。
在 RealityKit 中,我们可以用接近触发器来实现,但 ARKit 中并没有直接提供相应功能,我们只能通过其它方式。
zFar 的使用
首先让我想到的,就是 camera 的属性:zFar
但是,这样做有两个明显的不方便之处:
- zFar 属性适用于整个场景,这样一来,所有物体都会受到距离的影响;
- zFar 会强制截断物体的显示,如果虚拟物体很大,或者细长型,就会只显示一半;
距离计算
这样一来,我们就只能手动计算距离,来实现这个功能了。苹果提供了现成的距离计算方法:simdDistance(),我们可以利用它,在 ARSCNView 的渲染循环代理方法中,或者 ARSession 的渲染代理方法中,计算物体虚拟物体坐标原点(或坐标系内任一点)到手机(即相机)的距离。
为了节省性能开销,我们可以每 10 帧或者 20 帧计算一次距离,来减小系统压力;甚至放在后台线程计算距离来减少卡顿。但是这样做,也有一定的不足之处:
- 如果物体过多,仍然会有计算压力;
- 如果物体很大,或者细长型,会导致相机距离物体表面很近甚至已经进入物体内部了,但中心点距离仍不满足条件;
boundingBox 距离计算
于是我们想到了用边界盒/边界球来计算,ARKit 提供了获取 boundingBox 最大值最小值的方法:
let (min,max) = tempNode.boundingBox
复制代码
但是需要注意的是,有时候物体太大,可能会出现:离 8 个顶点都很远,但实际已经离物体表面很近,甚至已经进入 boundingBox 内部了。如下图,物体大小有 10 米,但我们希望相机离物体 5 米时再显示,大于 5 米则不显示。这时候如果相机从物体中心穿过去,显示离 8 个顶点都是大于 5 米距离的:
此时也不是没有解决办法:
- 可以将 6 个面向外扩张 5 米,只需要再判断相机是否进入扩大后的边界盒内部就行了:当相机位置(x, y, z)同时满足 min.x < x < max.x , min.y < y < max.y,min.z < z < max.z 即说明进入了边界盒内部
- 使用物体几何体顶点,逐个判断,步骤复杂
物理引擎使用
实际上,当我们使用 boundingBox 或者几何体顶点来做判断的时候,我们就相当于实现了一个最简单版本的碰撞计算。
那我们干脆用 SceneKit 自带的物理引擎进行碰撞计算不就行了?物理引擎的算法经过了优化,非常高效,同时还可以对 boundingBox 形状进行指定,控制碰撞的精确程度。
我们可以在相机上套一个透明的球体,观察这个球体和虚拟飞机的碰撞效果。
这里我们将飞机的物理形体类型设置为.static,即静止物体,可碰撞但不受碰撞效果影响(不会被撞飞)。
球体的物理形体设置为.kinamatic,即可移动的物体,可碰撞但不受碰撞效果影响(不会被撞飞)。
设置完成后,它们的 physicsBody?.categoryBitMask
会自动被设置为SCNPhysicsCollisionCategory.static
和SCNPhysicsCollisionCategory.default
。
另外需要注意的是,默认情况下,所有物体的collisionBitMask
为 all,即能够与其它所有类型物体发生碰撞。还需要设置当前物体与哪些物体碰撞要调用代理,这里注意,两个都要设置才能在代理中收到调用(只设置一个不管用):
shipNode?.physicsBody?.contactTestBitMask = Int(SCNPhysicsCollisionCategory.default.rawValue)
distanceBall.physicsBody?.contactTestBitMask = Int(SCNPhysicsCollisionCategory.static.rawValue)
代码如下:
override func viewDidLoad() {
super.viewDidLoad()
// Set the view's delegate
sceneView.delegate = self
// Show statistics such as fps and timing information
sceneView.showsStatistics = true
// Create a new scene
let scene = SCNScene(named: "art.scnassets/ship.scn")!
sceneView.debugOptions.insert(.showFeaturePoints)
sceneView.debugOptions.insert(.showWorldOrigin)
// Set the scene to the view
sceneView.scene = scene
scene.physicsWorld.contactDelegate = self // 控制器添加SCNPhysicsContactDelegate协议
let shipNode = scene.rootNode.childNode(withName: "ship", recursively: true)
shipNode?.physicsBody = SCNPhysicsBody(type: .static, shape: nil)
// staticBody默认SCNPhysicsCollisionCategory.static
// shipNode?.physicsBody?.categoryBitMask = Int(SCNPhysicsCollisionCategory.static.rawValue)
// 和 default 类型碰撞时调用代理
shipNode?.physicsBody?.contactTestBitMask = Int(SCNPhysicsCollisionCategory.default.rawValue)
// 调整位置,放在镜头前方
shipNode?.simdPosition = simd_float3(0, 0, -2)
// 在相机上套一个半径 1.5 米的球,设置为半透明,并开启isDoubleSided方便观察
let distanceBall = SCNNode(geometry: SCNSphere(radius: 1.5))
distanceBall.geometry?.firstMaterial?.diffuse.contents = UIColor(white: 1, alpha: 0.5)
distanceBall.geometry?.firstMaterial?.isDoubleSided = true
distanceBall.physicsBody = SCNPhysicsBody(type: .kinematic, shape: nil)
// 和 static 类型碰撞时调用代理
distanceBall.physicsBody?.contactTestBitMask = Int(SCNPhysicsCollisionCategory.static.rawValue)
// 将球体添加到相机上
sceneView.pointOfView?.addChildNode(distanceBall)
}
// 碰撞代理方法
func physicsWorld(_ world: SCNPhysicsWorld, didBegin contact: SCNPhysicsContact) {
// 将半透明球体变色
if contact.nodeA.name == "ship" {
contact.nodeB.geometry?.firstMaterial?.multiply.contents = UIColor(red: 1, green: 0, blue: 0, alpha: 0.5)
} else {
contact.nodeA.geometry?.firstMaterial?.multiply.contents = UIColor(red: 1, green: 0, blue: 0, alpha: 0.5)
}
}
func physicsWorld(_ world: SCNPhysicsWorld, didUpdate contact: SCNPhysicsContact) {
// print("didUpdate\(contact.nodeA),\(contact.nodeB)")
// 飞机进入球体内部也会一直调用,直到碰撞真正结束,但该方法调用过于频繁,这里不采用
}
func physicsWorld(_ world: SCNPhysicsWorld, didEnd contact: SCNPhysicsContact) {
let distance = simd_distance(contact.nodeA.simdWorldPosition, contact.nodeB.simdWorldPosition)
if distance > 1.5 { //完全脱离了
// 恢复正常颜色
contact.nodeA.geometry?.firstMaterial?.multiply.contents = UIColor(white: 1, alpha: 1)
contact.nodeB.geometry?.firstMaterial?.multiply.contents = UIColor(white: 1, alpha: 1)
} else {//飞机进入了球体内部,再次发生碰撞并结束,可能会误判为碰撞结束
// 变成绿色
contact.nodeA.geometry?.firstMaterial?.multiply.contents = UIColor(red: 0, green: 1, blue: 0, alpha: 0.5)
contact.nodeB.geometry?.firstMaterial?.multiply.contents = UIColor(red: 0, green: 1, blue: 0, alpha: 0.5)
}
}
复制代码
最后需要注意的是:
func physicsWorld(_ world: SCNPhysicsWorld, didEnd contact: SCNPhysicsContact)
被调用时,不一定是两者的几何体碰撞已经真正结束,可能是因为飞机已经完全进入到球体内部并再次发生碰撞带来的误判,所以需要重新计算一下距离。- 此时我们计算的距离,实际是飞机自身坐标系原点,与相机坐标系原点的距离。如果飞机不在原点处,需要坐标转换。
就是说要注意,原点在不在几何体内部的情况需要考虑到。当然了,大部分模型坐标原点都在几何体中心处
最终效果
见下图,红色代表碰撞开始了,白色代表碰撞真正结束了,绿色代表进入了内部再次碰撞并结束(没有真正结束):