ARKit + SceneKit でカメラから取得した映像にエフェクトをかける
ARKit Advent Calendar 2017 | 15日目
以下の昨日の記事の動画。
前半では ARKit で取得しているカメラ映像に対して Core Image のフィルター CIDotScreen を使用し、
新聞のモノクロ写真のようなモノクロドットや黒の塗りつぶしで構成された効果を適応しており、
こちらのご紹介。
本来は Metal を使用したり、SceneKit の SCNTechnique などを使うべきだが、 今回の処理でも許容範囲かつ実装が簡単なのでこちらを選択した。
(Metal 直で処理した方が速いと思われるのと、負荷がかからないはずなので、頑張れる方は頑張ってほしい)
流れ
昨日の記事で紹介しているように ARKit のシーンの背景は、 iOS 端末の背面カメラである iSight or iSight Duo カメラから取得している映像(画像)を渡している。
デバイスから画像を渡しているのだが、この画像は「SCNScene.background.contents」からは そのまま取れないようなので ARKit がフレーム毎に情報を渡している ARFrame から取得する。
そして、ARFrame から取得した画像にフィルターをかけて、シーンにその画像を戻している。
ロジックの実行場所
ARKit を使用しているので、ARKit のデリゲートで書くと思いきや、 ARKit がラップしている SceneKit の SCNSceneRendererDelegate で処理をする。
ARKit は AR 関連の処理を行い、その後 SceneKit で表示部分であるレンダリング処理をしており、 レンダリング処理の前にシーンの背景を変えると意図した表示にならない場合があるため。
SCNSceneRendererDelegate の renderer メソッド
renderer(_:updateAtTime:) - SCNSceneRendererDelegate | Apple Developer Documentation
SCNSceneRendererDelegate
SCNSceneRendererDelegate - SceneKit | Apple Developer Documentation
ひとまず試してみる
Xcode から AR のテンプレートを選択して、ViewController に以下のものを書き足す。
func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { // ARKit 設定時にカメラからの画像が空で渡されるのでその場合は処理しない guard let cuptureImage = sceneView.session.currentFrame?.capturedImage else { return } // PixelBuffer を CIImage に変換しフィルターをかける let ciImage = CIImage.init(cvPixelBuffer: cuptureImage) let filter:CIFilter = CIFilter(name: "CIDotScreen")! filter.setValue(ciImage, forKey: kCIInputImageKey) // CIImage を CGImage に変換して背景に適応 // カメラ画像はホーム右のランドスケープの状態で画像が渡されるため、CGImagePropertyOrientation(rawValue: 6) でポートレートで正しい向きに表示されるよう変換 let context = CIContext() let result = filter.outputImage!.oriented(CGImagePropertyOrientation(rawValue: 6)!) if let cgImage = context.createCGImage(result, from: result.extent) { sceneView.scene.background.contents = cgImage } }
ARSCNView の session.currentFrame.capturedImage で現在のカメラ画像が PixelBuffer 渡されるので、 CIImage で読み取り CGImage に変換して背景画像に渡している。
ちなみに UIImage に変換したら処理の問題か画像が探せない状態になり、背景が 白 or 黒 の状態になった。
端末回転時の処理
そのままだと、端末を傾けランドスケープにした際に表示がおかしくなるので、以下の処理を加える。
- 端末の向きの状態を保持する変数を用意する
- UIVIewController の viewWillAppear で端末の向きがかわった際に NotificationCenter で通知を送る
- どの向きの回転を許すか設定する(今回は全ての向き)
- NotificationCenter から呼ばれる関数で変数へ傾きを渡す
- CGImagePropertyOrientation(rawValue: 6) の 6 を傾きを保持する変数に変える。するとフレーム毎で傾きに合わせて表示が変更される
以下のコードに iPhone X 分岐を追加する必要あり
以下 ViewController.swift の全コード。
import UIKit import SceneKit import ARKit class ViewController: UIViewController, ARSCNViewDelegate { @IBOutlet var sceneView: ARSCNView! // 端末の傾きを保持する変数 var orientationNumber:UInt32 = 6 override func viewDidLoad() { super.viewDidLoad() // Set the view's delegate sceneView.delegate = self // Show statistics such as fps and timing information sceneView.showsStatistics = true // Create a new scene let scene = SCNScene(named: "art.scnassets/ship.scn")! // Set the scene to the view sceneView.scene = scene } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) // Create a session configuration let configuration = ARWorldTrackingConfiguration() // Run the view's session sceneView.session.run(configuration) // 傾きを検知したら通知を送る NotificationCenter.default.addObserver(self, selector: #selector(onOrientationChange(notification:)), name: .UIDeviceOrientationDidChange, object: nil) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // Pause the view's session sceneView.session.pause() } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Release any cached data, images, etc that aren't in use. } // MARK: - ARSCNViewDelegate /* // Override to create and configure nodes for anchors added to the view's session. func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? { let node = SCNNode() return node } */ func session(_ session: ARSession, didFailWithError error: Error) { // Present an error message to the user } func sessionWasInterrupted(_ session: ARSession) { // Inform the user that the session has been interrupted, for example, by presenting an overlay } func sessionInterruptionEnded(_ session: ARSession) { // Reset tracking and/or remove existing anchors if consistent tracking is required } func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { // ARKit 設定時にカメラからの画像が空で渡されるのでその場合は処理しない guard let cuptureImage = sceneView.session.currentFrame?.capturedImage else { return } // PixelBuffer を CIImage に変換しフィルターをかける let ciImage = CIImage.init(cvPixelBuffer: cuptureImage) let filter:CIFilter = CIFilter(name: "CIDotScreen")! filter.setValue(ciImage, forKey: kCIInputImageKey) // CIImage を CGImage に変換して背景に適応 let context = CIContext() let result = filter.outputImage!.oriented(CGImagePropertyOrientation(rawValue: orientationNumber)!) if let cgImage = context.createCGImage(result, from: result.extent) { sceneView.scene.background.contents = cgImage } } // 傾きを全て許可 override var supportedInterfaceOrientations: UIInterfaceOrientationMask { return UIInterfaceOrientationMask.all } // 通知の呼び出しから傾きを保持 @objc func onOrientationChange(notification: NSNotification){ switch UIDevice.current.orientation { case .landscapeLeft: orientationNumber = 0 case .landscapeRight: orientationNumber = 3 case .portrait: orientationNumber = 6 case .portraitUpsideDown: orientationNumber = 8 default: orientationNumber = 6 } } }
今回、全ての向きで回転を許可しており、この場合プロジェクトの TARGETS の General にある Device Orientation の Upside Down にチェックを入れる必要がある。
デフォルトではチェックが入っていないので注意。
なぜこの処理をしようと思ったか。そして発見したこと。
1番の理由は AR と VR の空間をスイッチさせるため。
SceneKit を使用した ARKit ではデバイスカメラ画像の取得 API はなく、ARSCNView のシーンの背景を一度変えると自分で取得する必要がありフィルターの適応はただのおまけ。
そして、背景にエフェクトをかけると部屋や空間が多少汚くてもある程度はごまかせることを発見した。
ひとまず、CIFilter の効果は CIDotScreen 以外にも沢山あるし、頑張ればカスタムのフィルターをつくることができるので、色々試してみるのもよいかと。
AR / VR をスイッチするサンプルファイル
ViewController.swift 98行目の
sceneView.scene.background.contents = UIImage(named: "art.scnassets/Background_sky.png")
直していないけど、毎フレームで背景画像を設定しているので、ここはスイッチ後はフラグ立てて1回だけ実行が良いかと。