[ARKit]3-蘋果官方AR變色龍Demo解讀

蘋果API搬運工發表於2018-02-04

說明

本文與註釋版程式碼地址中的README.md檔案搭配閱讀,效果更佳.

ARKit系列文章目錄

學習ARKit前,需要先學習SceneKit,參考SceneKit系列文章目錄

更多iOS相關知識檢視github上WeekWeekUpProject

官方程式碼地址

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

相關文章