說明
本文與註釋版程式碼地址中的README.md檔案搭配閱讀,效果更佳.
2017年的蘋果釋出會,蘋果演示過ARKit的一個Demo,名為InteractiveContentwithARKit
,對,就是那隻變色龍!!
主要演示了下面的問題: 本示例演示了以下概念:
- 如何放置一個有互動動畫的CG物體(一個變色龍),並與其產生互動.
- 如何根據使用者的移動和接近來觸發並控制物體的動畫.
- 如何使用著色器shader來調整虛擬物體的外觀.
由於整個專案非常簡單,只有幾個主要檔案:
其中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
約束,為了防止尤拉角引起死鎖,所以要開啟萬向節鎖
同時還新增了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中講到,還可以用另一種方法來產生實時的,真實的陰影.
- 在物體正方,放置一塊平面,用來顯示陰影
- 選中平面,在材質檢查器中,取消
write to color
中選項,這樣平面就不會寫入顏色緩衝中去,但陰影也會同時消失. - 重新顯示陰影,需要更改燈光的配置,選中燈光節點,進入光照檢查器
- 將模式改為
Deferred
,陰影就重新產生了.