Audio in ARKit/ViewController.swift
/* |
Copyright (C) 2018 Apple Inc. All Rights Reserved. |
See LICENSE.txt for this sample’s licensing information |
Abstract: |
Main view controller for the AR experience. |
*/ |
import UIKit |
import SceneKit |
import ARKit |
class ViewController: UIViewController, ARSCNViewDelegate { |
// MARK: - IBOutlets |
@IBOutlet weak var sessionInfoLabel: UILabel! |
@IBOutlet weak var sceneView: ARSCNView! |
lazy var screenCenter: CGPoint = { |
CGPoint(x: sceneView.bounds.midX, y: sceneView.bounds.midY) |
}() |
// Shows a preview of the object to be placed and hovers over estimated planes. |
var previewNode: PreviewNode? |
// Contains the model that is shared by the preview and final nodes. |
var contentNode = SCNNode() |
// Audio source for positional audio feedback. |
var audioSource: SCNAudioSource = SCNAudioSource(fileNamed: "Assets.scnassets/ping.aif")! |
// State variable used to flag relocalization completion |
var relocalizing = false |
// MARK: - View Life Cycle |
override func viewDidLoad() { |
super.viewDidLoad() |
// Show statistics such as FPS and timing information. |
sceneView.showsStatistics = true |
// Setup environment mapping. |
let environmentMap = UIImage(named: "Assets.scnassets/sharedImages/environment_blur.exr")! |
sceneView.scene.lightingEnvironment.contents = environmentMap |
// Complete rendering setup of ARSCNView. |
sceneView.antialiasingMode = .multisampling4X |
sceneView.automaticallyUpdatesLighting = false |
sceneView.contentScaleFactor = 1.3 |
// Setup the audio file. |
audioSource.loops = true |
audioSource.load() |
// Prevent the screen from being dimmed after a while as users will likely have |
// long periods of interaction without touching the screen or buttons. |
UIApplication.shared.isIdleTimerDisabled = true |
} |
/// - Tag: StartARSession |
override func viewWillAppear(_ animated: Bool) { |
super.viewWillAppear(animated) |
// Start the ARSession. |
let configuration = ARWorldTrackingConfiguration() |
configuration.planeDetection = .horizontal |
sceneView.session.run(configuration) |
} |
override func viewWillDisappear(_ animated: Bool) { |
super.viewWillDisappear(animated) |
contentNode.removeAllAudioPlayers() |
// Pause the view's session. |
sceneView.session.pause() |
} |
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { |
super.viewWillTransition(to: size, with: coordinator) |
// The screen's center point changes on orientation switch, so recalculate `screenCenter`. |
screenCenter = CGPoint(x: size.width / 2.0, y: size.height / 2.0) |
} |
// MARK: - Internal methods |
// Check the light estimate from the current ARFrame and update the scene. |
private func updateLightEstimate() { |
if let lightEstimate = sceneView.session.currentFrame?.lightEstimate { |
sceneView.scene.lightingEnvironment.intensity = lightEstimate.ambientIntensity / 40.0 |
} else { |
sceneView.scene.lightingEnvironment.intensity = 40.0 |
} |
} |
private func resetTracking() { |
let configuration = ARWorldTrackingConfiguration() |
configuration.planeDetection = .horizontal |
sceneView.session.run(configuration, options: [.resetTracking, .removeExistingAnchors]) |
// Reset preview state. |
contentNode.removeFromParentNode() |
contentNode = SCNNode() |
previewNode?.removeFromParentNode() |
previewNode = nil |
playSound() |
} |
private func setNewVirtualObjectToAnchor(_ object: SCNNode, to anchor: ARAnchor, cameraTransform: matrix_float4x4) { |
let cameraWorldPosition = cameraTransform.translation |
var cameraToPosition = anchor.transform.translation - cameraWorldPosition |
// Limit the distance of the object from the camera to a maximum of 10 meters. |
if simd_length(cameraToPosition) > 10 { |
cameraToPosition = simd_normalize(cameraToPosition) |
cameraToPosition *= 10 |
} |
object.simdPosition = cameraWorldPosition + cameraToPosition |
} |
// MARK: - ARSCNViewDelegate |
/// - Tag: UpdateAudioPlayback |
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { |
if contentNode.parent == nil && previewNode == nil { |
// If our model hasn't been placed and we lack a preview for placement then setup a preview. |
setupPreviewNode() |
updatePreviewNode() |
} else { |
updatePreviewNode() |
} |
updateLightEstimate() |
cutVolumeIfPlacedObjectIsInView() |
} |
/// - Tag: PlaceARContent |
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) { |
// Place content only for anchors found by plane detection. |
guard anchor is ARPlaneAnchor && previewNode != nil |
else { return } |
// Stop showing a preview version of the object to be placed. |
contentNode.removeFromParentNode() |
previewNode?.removeFromParentNode() |
previewNode = nil |
// Add the contenNode to the scene's root node using the anchor's position. |
guard let cameraTransform = sceneView.session.currentFrame?.camera.transform |
else { return } |
setNewVirtualObjectToAnchor(contentNode, to: anchor, cameraTransform: cameraTransform) |
sceneView.scene.rootNode.addChildNode(contentNode) |
// Disable plane detection after the model has been added. |
let configuration = ARWorldTrackingConfiguration() |
configuration.planeDetection = .horizontal |
sceneView.session.run(configuration, options: []) |
// Set up positional audio to play in case the object moves offscreen. |
playSound() |
} |
func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) { |
let message: String |
// Inform the user of their camera tracking state. |
switch camera.trackingState { |
case .notAvailable: |
message = "Tracking unavailable" |
case .normal: |
message = "Tracking normal" |
endRelocalization() |
case .limited(.excessiveMotion): |
message = "Tracking limited - Too much camera movement" |
case .limited(.insufficientFeatures): |
message = "Tracking limited - Not enough surface detail" |
case .limited(.initializing): |
message = "Initializing AR Session" |
case .limited(.relocalizing): |
beginRelocalization() |
message = "Recovering from interruption. Return to the location where you left off or try resetting the session." |
} |
sessionInfoLabel.text = message |
} |
// Called after the session state has changed to "relocalizing" |
func beginRelocalization() { |
relocalizing = true |
// Hide our content because it's position is very likely incorrect. |
contentNode.isHidden = true |
// Start the tracking reset timer. |
trackingResetTimer = Timer.scheduledTimer(withTimeInterval: relocalizationLimit, repeats: false, block: { [unowned self] _ in |
self.sessionInfoLabel.text = "Failed to relocalize the AR session. Resetting the AR session." |
self.resetTracking() |
}) |
// Mute audio since it can no longer be associated with our hidden content. |
guard let player = contentNode.audioPlayers.first, |
let avNode = player.audioNode as? AVAudioMixing else { |
return |
} |
avNode.volume = 0.0 |
} |
// Called after the session state has changed from "relocalizing" to "normal" |
func endRelocalization() { |
if relocalizing { |
relocalizing = false |
// Show our content and reenable audio. |
contentNode.isHidden = false |
// Stop the reset timer. |
trackingResetTimer.invalidate() |
// Resume the audio. |
guard let player = contentNode.audioPlayers.first else { |
playSound() |
return |
} |
// Restore volume on contenNode's existing audio player. |
if let avNode = player.audioNode as? AVAudioMixing { |
avNode.volume = 1.0 |
} |
} |
} |
func session(_ session: ARSession, didFailWithError error: Error) { |
// Present an error message to the user. |
sessionInfoLabel.text = "Session failed: \(error.localizedDescription). Resetting the AR session." |
resetTracking() |
} |
// Countdown timer used to reset our AR tracking and session if we are unable to relocalize within relocalizationLimit seconds. |
private var trackingResetTimer = Timer() |
private let relocalizationLimit = TimeInterval(30.0) |
func sessionWasInterrupted(_ session: ARSession) { |
// Inform the user that the session has been interrupted, for example, by presenting an overlay. |
sessionInfoLabel.text = "Session was interrupted" |
} |
func sessionInterruptionEnded(_ session: ARSession) { |
// Reset tracking and/or remove existing anchors if consistent tracking is required. |
sessionInfoLabel.text = "Session interruption ended" |
// If object has not been placed yet, reset tracking |
if previewNode != nil { |
resetTracking() |
} |
} |
/* |
Allow the session to attempt to resume after an interruption. |
This process may not succeed, so the app must be prepared |
to reset the session if the relocalizing status continues |
for a long time. |
*/ |
func sessionShouldAttemptRelocalization(_ session: ARSession) -> Bool { |
// Only relocalize if the object had been placed |
if previewNode != nil { return false } |
return true |
} |
// MARK: - Preview Node |
/* |
Loads the model (`contenNode`) that is used for the duration of the app. |
Initializes a `PreviewNode` that contains the `contenNode` and adds it to the node hierarchy. |
*/ |
func setupPreviewNode() { |
if (contentNode.childNode(withName: "candle", recursively: false) == nil) { |
// Load the scene from the bundle only once. |
let modelScene = SCNScene(named: "Assets.scnassets/candle/candle.scn")! |
// Get a handle to the model. |
let model = modelScene.rootNode.childNode(withName: "candle", recursively: true)! |
// Set the model onto `contenNode`. |
contentNode.addChildNode(model) |
} |
// Initialize `previewNode` to display the model. |
previewNode = PreviewNode(node: contentNode) |
// Add `previewNode` to the node hierarchy. |
sceneView.scene.rootNode.addChildNode(previewNode!) |
} |
/* |
`previewNode` exists when ARKit is finding a plane. During this time, get a world position for the areas closest to the scene's point of view that ARKit believes might be a plane, and use it to update the `previewNode` position. |
*/ |
func updatePreviewNode() { |
guard let node = previewNode else { return } |
let (worldPosition, planeAnchor, _) = worldPositionFromScreenPosition(screenCenter, |
in: sceneView, |
objectPos: node.simdPosition) |
if let position = worldPosition { |
node.update(for: position, planeAnchor: planeAnchor, camera: sceneView.session.currentFrame?.camera) |
} |
} |
// MARK: - Sound |
/* |
Determines whether the `contenNode` is visible. If the `contenNode` isn't visible, a sound is played using |
SceneKit's positional audio functionality to locate the `contenNode`. |
*/ |
func cutVolumeIfPlacedObjectIsInView() { |
guard previewNode == nil, let pointOfView = sceneView.pointOfView else { return } |
guard let player = contentNode.audioPlayers.first, |
let avNode = player.audioNode as? AVAudioMixing else { |
return |
} |
let placedObjectIsInView = sceneView.isNode(contentNode, insideFrustumOf: pointOfView) |
avNode.volume = placedObjectIsInView ? 0.0 : 1.0 |
} |
// Plays a sound on the contenNode using SceneKit's positional audio. |
/// - Tag: AddAudioPlayer |
func playSound() { |
// Ensure there is only one audio player. |
contentNode.removeAllAudioPlayers() |
guard contentNode.audioPlayers.count == 0 else { |
return |
} |
contentNode.addAudioPlayer(SCNAudioPlayer(source: audioSource)) |
} |
} |
Copyright © 2018 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2018-03-28