阅读 624

[ARKit]3-苹果官方AR变色龙Demo解读

说明

本文与注释版代码地址中的README.md文件搭配阅读,效果更佳.

ARKit系列文章目录

官方代码地址

2017年的苹果发布会,苹果演示过ARKit的一个Demo,名为InteractiveContentwithARKit,对,就是那只变色龙!!

主要演示了下面的问题: 本示例演示了以下概念:

  • 如何放置一个有交互动画的CG物体(一个变色龙),并与其产生交互.
  • 如何根据用户的移动和接近来触发并控制物体的动画.
  • 如何使用着色器shader来调整虚拟物体的外观.

由于整个项目非常简单,只有几个主要文件:

WX20180204-184402@2x.png

其中Extensions.swift只是一个简单的工具类. ViewController.swift中也只有几个点击事件及渲染循环. 具体各个函数的作用及调用时机在README.md文件中也有说明.

我们要关注的是Chameleon.swift中几个有趣的方法实现.

preloadAnimations()动画加载与播放

动画的播放非常简单,找到节点,添加动画就可以了:

// anim为SCNAnimation动画
contentRootNode.childNodes[0].addAnimation(anim, forKey: anim.keyPath)
复制代码

那么动画怎么来的?它是根据名称从.dae文件中加载的.而.dae文件是个场景文件,即SCNScene,对它的rootNode进行遍历,根据animationKey找到对应的animationPlayer就可以了.

static func fromFile(named name: String, inDirectory: String ) -> SCNAnimation? {
    let animScene = SCNScene(named: name, inDirectory: inDirectory)
    var animation: SCNAnimation?
    // 遍历子节点
    animScene?.rootNode.enumerateChildNodes({ (child, stop) in
        if !child.animationKeys.isEmpty {
            // 根据key找到对应的player
            let player = child.animationPlayer(forKey: child.animationKeys[0])
            animation = player?.animation
            stop.initialize(to: true)
        }
    })
    
    animation?.keyPath = name
    
    return animation

}

复制代码

这样就完了么??没有那么简单的,在转身的动画中,SCNAnimation只是让变色龙有了转身的动作,但节点并没有真正转过来,所以在playTurnAnimation(_ animation: SCNAnimation)中还使用了SCNTransaction来让这个节点的transform真正改变过来.

relativePositionToHead(pointOfViewPosition: simd_float3)求夹角

这个方法中求头部和摄像机之间夹角的方法挺有意思:

// 将摄像机视点的坐标,从世界坐标系转换到`head`的坐标系中
let cameraPosLocal = head.simdConvertPosition(pointOfViewPosition, from: nil)
// 摄像机视点坐标在`head`所在平面的投影(y值等于`head`的y值)
let cameraPosLocalComponentX = simd_float3(cameraPosLocal.x, head.position.y, cameraPosLocal.z)
let dist = simd_length(cameraPosLocal - head.simdPosition)

// 反三角函数求夹角,并转化为角度制
let xAngle = acos(simd_dot(simd_normalize(head!.simdPosition), simd_normalize(cameraPosLocalComponentX))) * 180 / Float.pi
let yAngle = asin(cameraPosLocal.y / dist) * 180 / Float.pi

let selfToUserDistance = simd_length(pointOfViewPosition - jaw.simdWorldPosition)

// 然后再根据夹角和距离,在其他函数中确定需要播放的动画	
复制代码

openCloseMouthAndShootTongue()动画过程中触发其它事件

原本是个很简单的CAKeyframe旋转动画,将嘴巴张开.

// 绕x轴旋转
let animation = CAKeyframeAnimation(keyPath: "eulerAngles.x")
animation.duration = 4.0
animation.keyTimes = [0.0, 0.05, 0.75, 1.0]
animation.values = [0, -0.4, -0.4, 0]
animation.timingFunctions = [
CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut),
CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear),
CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut)
	]
// 这是什么东西?是根据动画行进到不同阶段触发的闭包回调
animation.animationEvents = [startShootEvent, endShootEvent, mouthClosedEvent]

mouthAnimationState = .mouthMoving
// 添加后即播放动画
jaw.addAnimation(animation, forKey: "open close mouth")
复制代码

但是需要在张开后触发发射舌头的动画,所以添加了animationEvents以在keyTime不同阶段触发不同的回调

let startShootEvent = SCNAnimationEvent(keyTime: 0.07) { (_, _, _) in
	self.mouthAnimationState = .shootingTongue
}
let endShootEvent = SCNAnimationEvent(keyTime: 0.65) { (_, _, _) in
	self.mouthAnimationState = .pullingBackTongue
}
let mouthClosedEvent = SCNAnimationEvent(keyTime: 0.99) { (_, _, _) in
	self.mouthAnimationState = .mouthClosed
	self.readyToShootCounter = -100
}
复制代码

self.mouthAnimationState被改为.shootingTongue后, reactToDidApplyConstraints(in sceneView: ARSCNView)方法在每帧都会被调用,再调用了updateTongue(forTarget target: simd_float3)判断出状态后,则开始移动舌头节点tongueTip(缩回舌头.pullingBackTongue也是同样):

currentTonguePosition = startPos + intermediatePos
// 将舌尖需要到达的位置`currentTonguePosition`从世界坐标系转换到舌尖父节点的动画位置处,并将转换处的位置赋值给`tongueTip`
tongueTip.simdPosition = tongueTip.parent!.presentation.simdConvertPosition(currentTonguePosition, from: nil)
复制代码

setupConstraints()中的约束与万向节锁

眼睛添加了SCNLookAtConstraint约束,为了防止欧拉角引起死锁,所以要打开万向节锁

4039616-97978e4b06dd8ac8.gif

同时还添加了SCNTransformConstraint约束,将x轴欧拉角限制在-20~+20度内,左眼y轴欧拉角限制在5~150度,右眼-5~-150

// 设置眼睛运动的约束
let leftEyeLookAtConstraint = SCNLookAtConstraint(target: focusOfLeftEye)
leftEyeLookAtConstraint.isGimbalLockEnabled = true

let rightEyeLookAtConstraint = SCNLookAtConstraint(target: focusOfRightEye)
rightEyeLookAtConstraint.isGimbalLockEnabled = true

let eyeRotationConstraint = SCNTransformConstraint(inWorldSpace: false) { (node, transform) -> SCNMatrix4 in
    var eulerX = node.presentation.eulerAngles.x
    var eulerY = node.presentation.eulerAngles.y
    if eulerX < self.rad(-20) { eulerX = self.rad(-20) }
    if eulerX > self.rad(20) { eulerX = self.rad(20) }
    if node.name == "Eye_R" {
        if eulerY < self.rad(-150) { eulerY = self.rad(-150) }
        if eulerY > self.rad(-5) { eulerY = self.rad(-5) }
    } else {
        if eulerY > self.rad(150) { eulerY = self.rad(150) }
        if eulerY < self.rad(5) { eulerY = self.rad(5) }
    }
    let tempNode = SCNNode()
    tempNode.transform = node.presentation.transform
    tempNode.eulerAngles = SCNVector3(eulerX, eulerY, 0)
    return tempNode.transform
}

leftEye?.constraints = [leftEyeLookAtConstraint, eyeRotationConstraint]
rightEye?.constraints = [rightEyeLookAtConstraint, eyeRotationConstraint]
复制代码

setupShader()着色器的使用

读取着色器String,然后通过shaderModifiers加载进去,通过字典来指定类型,SCNShaderModifierEntryPoint的类型有geometry,surface,lightingModel,fragment.此处我们指定为surface类型.

如何给着色器传参呢??直接使用KVC,简单粗暴,但是挺好用的...

skin.shaderModifiers = [SCNShaderModifierEntryPoint.surface: shader]

skin.setValue(Double(0), forKey: "blendFactor")
skin.setValue(NSValue(scnVector3: SCNVector3Zero), forKey: "skinColorFromEnvironment")
		
let sparseTexture = SCNMaterialProperty(contents: UIImage(named: "art.scnassets/textures/chameleon_DIFFUSE_BASE.png")!)
skin.setValue(sparseTexture, forKey: "sparseTexture")
复制代码

updateCamouflage(sceneView: ARSCNView)activateCamouflage(_ activate: Bool)中则是通过KVC修改shader的参数值来激活/更新伪装色.

Metal的shader

最后,我们来简单看下Metal的shader.

#pragma arguments供外部传入的参数
float blendFactor;
texture2d sparseTexture;
float3 skinColorFromEnvironment;

#pragma body
// 纹理和采样器声明
// 采样器,归一化坐标: normalized,寻址模式:clamp_to_zero, 滤波模式:linear
constexpr sampler sparseSampler(coord::normalized, address::clamp_to_zero, filter::linear);
// 采样结果,得到外部纹理sparseTexture中采样出的颜色
float4 texelToMerge = sparseTexture.sample(sparseSampler, _surface.diffuseTexcoord);
// 混合后赋值回去(实际上刚启动时传入的blendFactor=0,即用自带纹理;后面启动伪装后,外部传入的blendFactor=1,即使用外部传入的纹理了)
_surface.diffuse = mix(_surface.diffuse, texelToMerge, blendFactor);

float alpha = _surface.diffuse.a;
// 根据外部传入的环境颜色,改变_suface的漫反射层的rgb值.
_surface.diffuse.rgb += skinColorFromEnvironment * (1.0 - alpha);
_surface.diffuse.a = 1.0;
复制代码

寻址模式中的 clamp_to_zero 跟OpenGL中的clamp-to-boarder类似, 当采样到边界之外的时候, 如果该纹理不包含alpha分量的,其颜色值永远为(0.0, 0.0, 0.0, 1.0), 否则, 该颜色值为(0.0, 0.0, 0.0, 0.0). Metal的shader是基于c++11(Metal2已经是c++ 14了),添加了一些自己的语法同时也做了一些限制.详细的语法可以参考Metal Shading Language Guide.

阴影小技巧

变色龙这个Demo使用的是环境光贴图,没有真正的光源也就没有真正的阴影产生,而是使用了一些小技巧来产生了"假阴影",做法是在四只脚下面放上一块浅灰纹理的平面,这样仿佛就有了阴影.这也就是所谓的将光照和阴影"烘焙"进纹理中.

// The chameleon uses an environment map, so disable built-in lighting
// 禁用内置光照
sceneView.automaticallyUpdatesLighting = false
复制代码
// Load the environment map
// 加载光照环境贴图
self.lightingEnvironment.contents = UIImage(named: "art.scnassets/environment_blur.exr")!
复制代码

在苹果官方WWDC17中讲到,还可以用另一种方法来产生实时的,真实的阴影.

  1. 在物体正方,放置一块平面,用来显示阴影
    WX20180301-093643@2x.png
  2. 选中平面,在材质检查器中,取消write to color中选项,这样平面就不会写入颜色缓冲中去,但阴影也会同时消失.
    WX20180301-093834@2x.png
  3. 重新显示阴影,需要更改灯光的配置,选中灯光节点,进入光照检查器
    WX20180301-093857@2x.png
  4. 将模式改为Deferred,阴影就重新产生了.
    WX20180301-093927@2x.png
关注下面的标签,发现更多相似文章
评论