在WWDC2017上,苹果推出了ARKit,在介绍完基本概念和用法后,演示了一个很小的 demo,虽然比较简单,但是很有趣,只需要几分钟就可以写出来。但对 SceneKit 不熟悉的同学来说,可能还有几个迷惑的地方,我们正好可以通过这个小 demo,管中窥豹学习一些基本知识。
这个Demo在AR场景中显示了一个飞机模型,手指点击屏幕,会将当前看到的画面截屏,并固定在场景中,就像将拍摄的照片固定在真实世界中一样。
先来看一下核心代码:
guard let currentFrame = sceneView.session.currentFrame else {
return
}
// Create an image plane using a snapshot of the view
let imagePlane = SCNPlane(width: sceneView.bounds.width / 6000,
height: sceneView.bounds.height / 6000)
imagePlane.firstMaterial?.diffuse.contents = sceneView.snapshot()
imagePlane.firstMaterial?.lightingModel = .constant
// Create a plane node and add it to the scene
let planeNode = SCNNode(geometry: imagePlane)
sceneView.scene.rootNode.addChildNode(planeNode)
// Set transform of node to be 10cm in front of camera
var translation = matrix_identity_float4x4
translation.columns.3.z = -0.1
planeNode.simdTransform = matrix_multiply(currentFrame.camera.transform, translation)
我们来一句一句剖析这段代码:
let imagePlane = SCNPlane(width: sceneView.bounds.width / 6000, height: sceneView.bounds.height / 6000)
为什么平面的宽和高要用
sceneView
的尺寸计算出来?
注意到snapshot()
这个方法是SCNView
的一个方法,返回的UIImage
是按照实际SCNView
的大小输出的。虽然这个例子乃至于实际大多数的AR应用都是全屏的,但你还是能将SCNView
设置成想要的任何尺寸。因此这里不应假设就是屏幕大小,使用UIScreen.main.bounds
之类的方法来定义截图,而是要使用调用了snapshot()
方法的那个SCNView
对象的尺寸。为什么宽和高要保持等比例,都除以一个数(6000)?
因为图片纹理的显示规则与UIImageView
设置图片的机制是不一样的。在UIImageView
中,可以通过设置contentMode
属性在UIViewContentMode
枚举中选择缩放和比例模式。但在纹理的设置中 ,并没有这样自动的方法来设置纹理,如果都使用默认属性,SCNPlane
的纹理设置为图片后,该图片会拉伸铺满整个平面。所以,要保持SCNPlane
的长宽比与截图的长宽比相同,从而保证图片显示在平面上不会被拉伸。为什么这个比例数是6000?
以 iphone7plus 为例,屏幕点数为 414x736,由于sceneView
与屏幕大小相同,因此sceneView.bounds
返回CGRect(0, 0, 414, 736)
,于是宽和高经过计算分别为 414/6000=0.069, 736/6000=0.123。其实宽和高的具体的数字不重要,重要的是数量级,可以看到都是小于1的非常小的数。这是因为 ARKit 要保证虚拟物体与现实世界融合的时候,虚拟物体的尺寸与现实相符,因此ARSCNView
(ARKit 中的SCNView
的子类,sceneView
的类型)规定所用的单位为米,也就是真实物理世界中的米单位。因此这两个数字可以理解为真实物理世界中的69毫米和123毫米,大概是手机屏幕那么大。想象一下如果除以600,最后的结果尺寸就会大10倍,放置到真实世界中就会是69厘米和123厘米,目测那张桌子也放不下。而这个数字6000也应该为了这个demo选择的一个数字,正好使得放置平面后差不多正好覆盖整个屏幕,达到视觉上分离出一张图片的效果。
imagePlane.firstMaterial?.diffuse.contents = sceneView.snapshot()
imagePlane.firstMaterial?.lightingModel = .constant
-
sceneView.snapshot()
方法返回了什么?
通过查看文档得知返回的是UIImage
,那么这个UIImage
的尺寸是多少呢,文档并没有说明尺寸,那就只有通过调试来得知了。以 iPhone7+ 为例,返回的是1080x1920大小的UIImage
,再修改为放大模式后,仍然是1080x1920,这说明返回的是物理像素大小的图片,与 point 无关。 - 如何设置纹理?
- 首先,纹理是设置在
SCNGeometry
上的,与SCNNode
无关(SCNPlane
是SCNGeometry
的子类) - 内置的所有
SCNGeometry
的子类都定义了纹理和几何形状的对应关系,可以通过查看文档得知。因此它们至少都有一个纹理可以设置,imagePlane.firstMaterial
总是有值的。因此最简单的纹理使用方法就是设置firstMaterial
属性返回的SCNMaterial
对象。 -
diffuse
属性定义纹理本身的颜色,相当于简单地将图片贴到几何形状表面,diffuse.contents
则定义使用什么内容来指定纹理,这个例子用的是UIImage
,更简单的还可以使用UIColor
,有很多种类型的数据都可以设置给contents
,甚至可以设置为AVPlayer
或者SKScene
,可谓是很强大了。
- 首先,纹理是设置在
let planeNode = SCNNode(geometry: imagePlane)
sceneView.scene.rootNode.addChildNode(planeNode)
-
SCNNode
是 SceneKit 用来指定三维物体空间属性的,按照一种树形的层级结构来组织物体。 - 创建
SCNNode
有三种目的,一是做为几何对象,显示几何图形,需要设置一个geometry
属性;二是做为容器,将其他SCNNode
添加进来组合到一起;三是放置一些其他对象,比如SCNCamera
SCNLight
等 -
SCNNode
作为几何对象时,决定一个几何实体的位置、缩放、旋转等对外的属性。 -
SCNNode
作为容器时,其内部的所有对象组合起来做为一个整体,外面并不用关心SCNNode
内部到底是有一个SCNGeometry
还是由多个SCNNode
组合而成的。 -
SCNNode
放置其他对象,与几何对象一样,一些其他对象也需要位置、缩放、旋转,比如摄像机和点光源。 - 一个
SCNNode
可以同时具有以上三种用法。一种有趣的用法是在持有SCNCamera
的SCNNode
中添加子节点,就会使这个子节点相对于摄像头不动,像固定在屏幕中一样。 -
SCNNode
是按照树形结构存储的,必然有一个根节点,这个根节点是预定义好的,rootNode
属性返回这个场景的根节点。自定义的节点通过添加到根节点来添加到场景中。SCNNode
有parent
属性,指示上层节点,rootNode
的parent
属性值为nil
。
var translation = matrix_identity_float4x4
translation.columns.3.z = -0.1
planeNode.simdTransform = matrix_multiply(currentFrame.camera.transform, translation)
-
前面讲到
SCNNode
具有位置、缩放、旋转等属性,这几个属性可以通过单独的属性设置,可以通过一个矩阵来表示它们的组合。这些属性所表示的都是相对于父节点坐标系的。- 位置:
position : SCNVector3
,表示这个SCNNode
所在的位置。 - 缩放:
scale : SCNVector3
,表示这个SCNNode
自身三个维度的缩放比例。 - 旋转:旋转是最有趣的,有3个属性可以用来表示旋转。注意 SceneKit 所有 API 涉及到的角度都是弧度制。
-
eulerAngles : SCNVector3
,欧拉角,表示节点分别围绕三个坐标轴旋转的角度,是一种比较简单的表示方法,这种方法有一个缺点就是万向节死锁。简单讲就是使用欧拉角时在特定的组合下回丢失某个轴的旋转角度,因此只建议在固定角度经过试验的情况下或者只有一个轴旋转时使用。 -
rotation : SCNVector4
,向量的 x, y, z 表示一个三维的方向向量,w 表示围绕这个向量旋转的角度。这个属性是很好理解的,对于任意两个向量,总能找到一个向量和一个角度使其中一个变换为另一个。 -
orientation : SCNQuaternion
,其实SCNQuaternion
类型是SCNVector4
的别名,但表示的含义不一样,是比较难理解的一个数学概念……wiki传送门,适合在不需要直观理解旋转角度的情况下使用,比如一个节点要用动画的方式旋转到与另一个节点同样的朝向,不用关心具体的值,只需要使用动画改变这个属性即可。
-
- 矩阵:可以表达以上三种属性的组合,它是节点从默认状态变换为当前状态的一个变换矩阵,设置这个属性相当于先将节点变换回默认状态,再执行这个矩阵变换。那么矩阵与上面三种属性有什么关系呢?分别设置三种属性的先后顺序不会对结果产生影响,是因为三种属性的设置等价于更新变换矩阵,而这个变换矩阵的构造顺序是固定的,即先缩放和旋转(都围绕中心所以这两种不互相影响),再位移。
- 位置:
simd:指的是 intel 的一个向量指令集,定义了丰富的向量结构与运算,可以直接在 SceneKit 中使用,上面所说的属性都有另一个以 simd 为前缀的版本用来返回或者设置 simd 类型。SceneKit 也定义了带有 SCN 前缀的一些结构和方法来进行向量和矩阵的运算,但运算方法比较少,有时还需要借助 simd 的一些方法。
-
矩阵乘法:代码中使截图浮在摄像头前面并朝向摄像头,达到这样效果是因为将图片做了几次变换。
- 首先
SCNPlane
默认是一个垂直于z轴的平面,将它向z轴反方向位移0.1米,就可以在 (0, 0, 0) 的位置看到平面后移了一些,对应的代码是translation.columns.3.z = -0.1
,如果将矩阵translation
设置给平面节点,那么它只会固定在 (0, 0, -0.1) 的位置,多次截图的位置都相同,与摄像头当前的位置没有关系。 - 然后,假设只需要把截图平面变换到与摄像头相同的状态,只需要将摄像头的矩阵
currentFrame.camera.transform
设置给平面节点即可。但这样平面就与摄像头重合了,我们最终要的效果是在摄像头前面0.1米放置截图,这样就要找到将位移和摄像头状态组合起来的方法。 - 两个变换矩阵结合是有先后顺序的,根据我们的需求可得知应该先将平面移动一下,再将它变换为摄像头的状态。因此就需要将两个矩阵相乘
translation * currentFrame.camera.transform
,将结果作为平面节点的矩阵。 - 注意 simd 提供的
matrix_multiply
方法的两个参数的顺序与它的数学含义的顺序是相反的,即A * B
应写成matrix_multiply(B, A)
。再注意:SCNMatrix4
也提供了一个矩阵乘法运算SCNMatrix4Mult
,它的顺序是正常的,即A * B
应写成SCNMatrix4Mult(A, B)
。
- 首先
以上代码解析完毕,这个 demo 虽然小,但是涉及到了很多 SceneKit 的概念和用法。比较难以理解的是最后的矩阵变换,可能需要复习一下数学知识。矩阵使用熟练以后可以实现很多有趣的功能,后面的文章中会包含一些相关的例子。