Interactive Content/Chameleon.swift
/* |
Copyright (C) 2018 Apple Inc. All Rights Reserved. |
See LICENSE.txt for this sample’s licensing information |
Abstract: |
This file manages the movements and display of the Chameleon using SceneKit. |
*/ |
import Foundation |
import SceneKit |
import ARKit |
class Chameleon: SCNScene { |
// Special nodes used to control animations of the model |
private let contentRootNode = SCNNode() |
private var geometryRoot: SCNNode! |
private var head: SCNNode! |
private var leftEye: SCNNode! |
private var rightEye: SCNNode! |
private var jaw: SCNNode! |
private var tongueTip: SCNNode! |
private var focusOfTheHead = SCNNode() |
private var focusOfLeftEye = SCNNode() |
private var focusOfRightEye = SCNNode() |
private var tongueRestPositionNode = SCNNode() |
private var skin: SCNMaterial! |
// Animations |
private var idleAnimation: SCNAnimation? |
private var turnLeftAnimation: SCNAnimation? |
private var turnRightAnimation: SCNAnimation? |
// State variables |
private var modelLoaded: Bool = false |
private var headIsMoving: Bool = false |
private var chameleonIsTurning: Bool = false |
private let focusNodeBasePosition = simd_float3(0, 0.1, 0.25) |
private var leftEyeTargetOffset = simd_float3() |
private var rightEyeTargetOffset = simd_float3() |
private var currentTonguePosition = simd_float3() |
private var relativeTongueStickOutFactor: Float = 0 |
private var readyToShootCounter: Int = 0 |
private var triggerTurnLeftCounter: Int = 0 |
private var triggerTurnRightCounter: Int = 0 |
private var lastRelativePosition: RelativeCameraPositionToHead = .tooHighOrLow |
private var lastDistance: Float = Float.greatestFiniteMagnitude |
private var didEnterTargetLockDistance = false |
private var mouthAnimationState: MouthAnimationState = .mouthClosed |
private var changeColorTimer: Timer? |
private var lastColorFromEnvironment = SCNVector3(130.0 / 255.0, 196.0 / 255.0, 174.0 / 255.0) |
// Enums to describe the current state |
private enum RelativeCameraPositionToHead { |
case withinFieldOfView(Distance) |
case needsToTurnLeft |
case needsToTurnRight |
case tooHighOrLow |
var rawValue: Int { |
switch self { |
case .withinFieldOfView(_) : return 0 |
case .needsToTurnLeft : return 1 |
case .needsToTurnRight: return 2 |
case .tooHighOrLow : return 3 |
} |
} |
} |
private enum Distance { |
case outsideTargetLockDistance |
case withinTargetLockDistance |
case withinShootTongueDistance |
} |
private enum MouthAnimationState { |
case mouthClosed |
case mouthMoving |
case shootingTongue |
case pullingBackTongue |
} |
// MARK: - Initialization and Loading |
override init() { |
super.init() |
// Load the environment map |
self.lightingEnvironment.contents = UIImage(named: "art.scnassets/environment_blur.exr")! |
// Load the chameleon |
loadModel() |
} |
required init?(coder aDecoder: NSCoder) { |
fatalError("init(coder:) has not been implemented") |
} |
private func loadModel() { |
guard let virtualObjectScene = SCNScene(named: "chameleon", inDirectory: "art.scnassets") else { |
return |
} |
let wrapperNode = SCNNode() |
for child in virtualObjectScene.rootNode.childNodes { |
wrapperNode.addChildNode(child) |
} |
self.rootNode.addChildNode(contentRootNode) |
contentRootNode.addChildNode(wrapperNode) |
hide() |
setupSpecialNodes() |
setupConstraints() |
setupShader() |
preloadAnimations() |
resetState() |
modelLoaded = true |
} |
// MARK: - Public API |
func show() { |
contentRootNode.isHidden = false |
} |
func hide() { |
contentRootNode.isHidden = true |
resetState() |
} |
func isVisible() -> Bool { |
return !contentRootNode.isHidden |
} |
func setTransform(_ transform: simd_float4x4) { |
contentRootNode.simdTransform = transform |
} |
// MARK: - Turn left/right and idle animations |
private func preloadAnimations() { |
idleAnimation = SCNAnimation.fromFile(named: "anim_idle", inDirectory: "art.scnassets") |
idleAnimation?.repeatCount = -1 |
turnLeftAnimation = SCNAnimation.fromFile(named: "anim_turnleft", inDirectory: "art.scnassets") |
turnLeftAnimation?.repeatCount = 1 |
turnLeftAnimation?.blendInDuration = 0.3 |
turnLeftAnimation?.blendOutDuration = 0.3 |
turnRightAnimation = SCNAnimation.fromFile(named: "anim_turnright", inDirectory: "art.scnassets") |
turnRightAnimation?.repeatCount = 1 |
turnRightAnimation?.blendInDuration = 0.3 |
turnRightAnimation?.blendOutDuration = 0.3 |
// Start playing idle animation. |
if let anim = idleAnimation { |
contentRootNode.childNodes[0].addAnimation(anim, forKey: anim.keyPath) |
} |
tongueTip.removeAllAnimations() |
leftEye.removeAllAnimations() |
rightEye.removeAllAnimations() |
chameleonIsTurning = false |
headIsMoving = false |
} |
private func playTurnAnimation(_ animation: SCNAnimation) { |
var rotationAngle: Float = 0 |
if animation == turnLeftAnimation { |
rotationAngle = Float.pi / 4 |
} else if animation == turnRightAnimation { |
rotationAngle = -Float.pi / 4 |
} |
let modelBaseNode = contentRootNode.childNodes[0] |
modelBaseNode.addAnimation(animation, forKey: animation.keyPath) |
chameleonIsTurning = true |
SCNTransaction.begin() |
SCNTransaction.animationTimingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear) |
SCNTransaction.animationDuration = animation.duration |
modelBaseNode.transform = SCNMatrix4Mult(modelBaseNode.presentation.transform, SCNMatrix4MakeRotation(rotationAngle, 0, 1, 0)) |
SCNTransaction.completionBlock = { |
self.chameleonIsTurning = false |
} |
SCNTransaction.commit() |
} |
// MARK: - Head and tongue animations |
private func relativePositionToHead(pointOfViewPosition: simd_float3) -> RelativeCameraPositionToHead { |
// Compute angles between camera position and chameleon |
let cameraPosLocal = head.simdConvertPosition(pointOfViewPosition, from: nil) |
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) |
var relativePosition: RelativeCameraPositionToHead |
if yAngle > 60 { |
relativePosition = .tooHighOrLow |
} else if xAngle > 60 { |
relativePosition = cameraPosLocal.x < 0 ? .needsToTurnLeft : .needsToTurnRight |
} else { |
var distanceCategory: Distance |
switch selfToUserDistance { |
case 0..<0.3: |
distanceCategory = .withinShootTongueDistance |
case 0.3..<0.45: |
distanceCategory = .withinTargetLockDistance |
if lastDistance > 0.45 || lastRelativePosition.rawValue > 0 { |
didEnterTargetLockDistance = true |
} |
default: |
distanceCategory = .outsideTargetLockDistance |
} |
relativePosition = .withinFieldOfView(distanceCategory) |
} |
lastDistance = selfToUserDistance |
lastRelativePosition = relativePosition |
return relativePosition |
} |
private func openCloseMouthAndShootTongue() { |
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 |
} |
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") |
// Move the head a little bit up. |
let headUpAnimation = CAKeyframeAnimation(keyPath: "position.y") |
let startY = focusOfTheHead.position.y |
headUpAnimation.duration = 4.0 |
headUpAnimation.keyTimes = [0.0, 0.05, 0.75, 1.0] |
headUpAnimation.values = [startY, startY + 0.1, startY + 0.1, startY] |
headUpAnimation.timingFunctions = [ |
CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut), |
CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear), |
CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut) |
] |
focusOfTheHead.addAnimation(headUpAnimation, forKey: "move head up") |
} |
} |
// MARK: - React To Placement and Tap |
extension Chameleon { |
func reactToPositionChange(in view: ARSCNView) { |
self.reactToPlacement(in: view) |
} |
func reactToInitialPlacement(in view: ARSCNView) { |
self.reactToPlacement(in: view, isInitial: true) |
} |
private func reactToPlacement(in sceneView: ARSCNView, isInitial: Bool = false) { |
if isInitial { |
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: { |
self.getColorFromEnvironment(sceneView: sceneView) |
self.activateCamouflage(true) |
}) |
} else { |
DispatchQueue.main.async { |
self.updateCamouflage(sceneView: sceneView) |
} |
} |
} |
func reactToTap(in sceneView: ARSCNView) { |
self.activateCamouflage(false) |
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: { |
self.activateCamouflage(true) |
}) |
} |
private func activateCamouflage(_ activate: Bool) { |
skin.setValue(NSValue(scnVector3: lastColorFromEnvironment), forKey: "skinColorFromEnvironment") |
let blendFactor = activate ? 1.0 : 0.0 |
SCNTransaction.begin() |
SCNTransaction.animationDuration = 1.5 |
skin.setValue(blendFactor, forKey: "blendFactor") |
SCNTransaction.commit() |
} |
private func updateCamouflage(sceneView: ARSCNView) { |
getColorFromEnvironment(sceneView: sceneView) |
SCNTransaction.begin() |
SCNTransaction.animationDuration = 1.5 |
self.skin.setValue(NSValue(scnVector3: lastColorFromEnvironment), forKey: "skinColorFromEnvironment") |
SCNTransaction.commit() |
} |
private func getColorFromEnvironment(sceneView: ARSCNView) { |
let worldPos = sceneView.projectPoint(contentRootNode.worldPosition) |
let colorVector = sceneView.averageColorFromEnvironment(at: worldPos) |
lastColorFromEnvironment = colorVector |
} |
} |
// MARK: - React To Rendering |
extension Chameleon { |
func reactToRendering(in sceneView: ARSCNView) { |
// Update environment map to match ambient light level |
lightingEnvironment.intensity = (sceneView.session.currentFrame?.lightEstimate?.ambientIntensity ?? 1000) / 100 |
guard modelLoaded, !chameleonIsTurning, let pointOfView = sceneView.pointOfView else { |
return |
} |
let localTarget = focusOfTheHead.parent!.simdConvertPosition(pointOfView.simdWorldPosition, from: nil) |
followUserWithEyes(to: localTarget) |
// Obtain relative position of the head to the camera and act accordingly. |
let relativePos = self.relativePositionToHead(pointOfViewPosition: pointOfView.simdPosition) |
switch relativePos { |
case .withinFieldOfView(let distance): |
handleWithinFieldOfView(localTarget: localTarget, distance: distance) |
case .needsToTurnLeft: |
followUserWithHead(to: simd_float3(0.4, focusNodeBasePosition.y, focusNodeBasePosition.z)) |
triggerTurnLeftCounter += 1 |
if triggerTurnLeftCounter > 150 { |
triggerTurnLeftCounter = 0 |
if let anim = turnLeftAnimation { |
playTurnAnimation(anim) |
} |
} |
case .needsToTurnRight: |
followUserWithHead(to: simd_float3(-0.4, focusNodeBasePosition.y, focusNodeBasePosition.z)) |
triggerTurnRightCounter += 1 |
if triggerTurnRightCounter > 150 { |
triggerTurnRightCounter = 0 |
if let anim = turnRightAnimation { |
playTurnAnimation(anim) |
} |
} |
case .tooHighOrLow: |
followUserWithHead(to: focusNodeBasePosition) |
} |
} |
private func handleWithinFieldOfView(localTarget: simd_float3, distance: Distance) { |
triggerTurnLeftCounter = 0 |
triggerTurnRightCounter = 0 |
switch distance { |
case .outsideTargetLockDistance: |
followUserWithHead(to: localTarget) |
case .withinTargetLockDistance: |
followUserWithHead(to: localTarget, instantly: !didEnterTargetLockDistance) |
case .withinShootTongueDistance: |
followUserWithHead(to: localTarget, instantly: true) |
if mouthAnimationState == .mouthClosed { |
readyToShootCounter += 1 |
if readyToShootCounter > 30 { |
openCloseMouthAndShootTongue() |
} |
} else { |
readyToShootCounter = 0 |
} |
} |
} |
private func followUserWithHead(to target: simd_float3, instantly: Bool = false) { |
guard !headIsMoving else { return } |
if mouthAnimationState != .mouthClosed || instantly { |
focusOfTheHead.simdPosition = target |
} else { |
didEnterTargetLockDistance = false |
headIsMoving = true |
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: { |
let moveToTarget = SCNAction.move(to: SCNVector3(target.x, target.y, target.z), duration: 0.5) |
self.focusOfTheHead.runAction(moveToTarget, completionHandler: { |
self.headIsMoving = false |
}) |
}) |
} |
} |
private func followUserWithEyes(to target: simd_float3) { |
randomlyUpdate(&leftEyeTargetOffset) |
randomlyUpdate(&rightEyeTargetOffset) |
focusOfLeftEye.simdPosition = target + leftEyeTargetOffset |
focusOfRightEye.simdPosition = target + rightEyeTargetOffset |
} |
} |
// MARK: - React To DidApplyConstraints |
extension Chameleon { |
func reactToDidApplyConstraints(in sceneView: ARSCNView) { |
guard modelLoaded, let pointOfView = sceneView.pointOfView else { |
return |
} |
// Correct the user position such that it is a few centimeters in front of the camera. |
let translationLocal = SCNVector3(0, 0, -0.012) |
let translationWorld = pointOfView.convertVector(translationLocal, to: nil) |
let camTransform = SCNMatrix4Translate(pointOfView.transform, translationWorld.x, translationWorld.y, translationWorld.z) |
let userPosition = simd_float3(camTransform.m41, camTransform.m42, camTransform.m43) |
updateTongue(forTarget: userPosition) |
} |
private func updateTongue(forTarget target: simd_float3) { |
// When the tongue is in motion, update the relative amount how much it sticks out |
// between 0 (= in the mouth) and 1 (= at the target). |
if mouthAnimationState == .shootingTongue { |
if relativeTongueStickOutFactor < 1 { |
relativeTongueStickOutFactor += 0.08 |
} else { |
relativeTongueStickOutFactor = 1 |
} |
} else if mouthAnimationState == .pullingBackTongue { |
if relativeTongueStickOutFactor > 0 { |
relativeTongueStickOutFactor -= 0.02 |
} else { |
relativeTongueStickOutFactor = 0 |
} |
} |
// Set the position of the 'focus of the tongue' node, which is used by the tongue's transformation constraint. |
let startPos = tongueRestPositionNode.presentation.simdWorldPosition |
let endPos = target |
let intermediatePos = (endPos - startPos) * relativeTongueStickOutFactor |
currentTonguePosition = startPos + intermediatePos |
tongueTip.simdPosition = tongueTip.parent!.presentation.simdConvertPosition(currentTonguePosition, from: nil) |
} |
} |
// MARK: - Helper functions |
extension Chameleon { |
private func rad(_ deg: Float) -> Float { |
return deg * Float.pi / 180 |
} |
private func randomlyUpdate(_ vector: inout simd_float3) { |
switch arc4random() % 400 { |
case 0: vector.x = 0.1 |
case 1: vector.x = -0.1 |
case 2: vector.y = 0.1 |
case 3: vector.y = -0.1 |
case 4, 5, 6, 7: vector = simd_float3() |
default: break |
} |
} |
private func setupSpecialNodes() { |
// Retrieve nodes we need to reference for animations. |
geometryRoot = self.rootNode.childNode(withName: "Chameleon", recursively: true) |
head = self.rootNode.childNode(withName: "Neck02", recursively: true) |
jaw = self.rootNode.childNode(withName: "Jaw", recursively: true) |
tongueTip = self.rootNode.childNode(withName: "TongueTip_Target", recursively: true) |
leftEye = self.rootNode.childNode(withName: "Eye_L", recursively: true) |
rightEye = self.rootNode.childNode(withName: "Eye_R", recursively: true) |
skin = geometryRoot.geometry?.materials.first |
// Fix materials |
geometryRoot.geometry?.firstMaterial?.lightingModel = .physicallyBased |
geometryRoot.geometry?.firstMaterial?.roughness.contents = "art.scnassets/textures/chameleon_ROUGHNESS.png" |
let shadowPlane = self.rootNode.childNode(withName: "Shadow", recursively: true) |
shadowPlane?.castsShadow = false |
// Set up looking position nodes |
focusOfTheHead.simdPosition = focusNodeBasePosition |
focusOfLeftEye.simdPosition = focusNodeBasePosition |
focusOfRightEye.simdPosition = focusNodeBasePosition |
geometryRoot.addChildNode(focusOfTheHead) |
geometryRoot.addChildNode(focusOfLeftEye) |
geometryRoot.addChildNode(focusOfRightEye) |
} |
private func setupConstraints() { |
// Set up constraints for head movement |
let headConstraint = SCNLookAtConstraint(target: focusOfTheHead) |
headConstraint.isGimbalLockEnabled = true |
head?.constraints = [headConstraint] |
// Set up constraints for eye movement |
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] |
// The tongueRestPositionNode always remains at the tongue rest position, |
// even if the tongue is animated. It helps to calculate the intermediate position in the tongue animation. |
tongueTip.parent!.addChildNode(tongueRestPositionNode) |
tongueRestPositionNode.transform = tongueTip.transform |
currentTonguePosition = tongueTip.simdPosition |
} |
private func resetState() { |
relativeTongueStickOutFactor = 0 |
mouthAnimationState = .mouthClosed |
readyToShootCounter = 0 |
triggerTurnLeftCounter = 0 |
triggerTurnRightCounter = 0 |
if changeColorTimer != nil { |
changeColorTimer?.invalidate() |
changeColorTimer = nil |
} |
} |
private func setupShader() { |
guard let path = Bundle.main.path(forResource: "skin", ofType: "shaderModifier", inDirectory: "art.scnassets"), |
let shader = try? String(contentsOfFile: path, encoding: String.Encoding.utf8) else { |
return |
} |
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") |
} |
} |
Copyright © 2018 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2018-02-15