自分自身をアニメーション付きの3Dモデルデータにし、ARKitでARコンテンツ化してみた記録です(ブログにて2回に分けて書いていた記事をまとめて再構成してみました)。
まず完成したものがこちら。
そしてソースコードはこちらです。
https://github.com/miyabi/dancing-ar
全身3Dスキャナー「3D GATEWAY」
去る9月25・26日に開催されたUnite Tokyo 2019のブース展示で、Digital Artisanさんが全身3Dスキャナー「3D GATEWAY」を展示、3Dスキャン体験をおこなっていたので、スキャンしてもらってきました(3Dスキャン体験は他のイベントなどでも実施されているようです)。
3D GATEWAYで3Dスキャナー体験ができます!スキャンデータは後日sketchfabに公開し、自由に3Dデータをダウンロードできるとのこと。 #UniteTokyo pic.twitter.com/Ag8odd1sTY
— ユニティ・テクノロジーズ・ジャパン (@unity_japan) September 25, 2019
スキャン自体は一瞬(1/200秒)で終わり、約10分で3Dモデルデータを作成できるそうです。ただし当日はイベントで多数の方がスキャンされていたため、夕方スキャンして翌朝モデルデータが完成しました。モデルデータはSketchfab上で公開され、FBXかglTFでダウンロードが可能です。
https://sketchfab.com/3d-models/0926042-0f1953978d5a412bb5dace2e2d84feb5
モデルデータをFBXでダウンロードして手持ちの3Dツール、Cheetah 3Dで確認したところ、ポリゴン数は20万でした。
mixamoでリギング
せっかくなので動かしてみたいと思い、Adobeのmixamoでリギングしてみました。リギングとは、3Dモデルをアニメーションで動かすための設定を施すことです。
元のFBXをそのままmixamoにアップロードしたらエラーになってしまいました。どうもポリゴン数が多すぎるようなので、減らしてみます。
Cheetah 3DのTools→Polygon→Simplifyで試してみましたが微妙な感じだったので、一旦OBJに変換してMeshmixerを使うことにしました。
Selectから全部選択してEdit→Reduce、デフォルトで50%になっているのでそのままAccept、再びOBJでエクスポートします。
これをマテリアル、テクスチャーと一式zipにしてmixamoにアップロードしてみました。今度はエラーにはならないものの、モデルが真っ黒になってしまいました。
Cheetah 3DでOBJファイルを開いてみると余計なマテリアルがあったので、これを削除したあと再びzipにしてアップロードすると、今度はうまくいきました。
mixamoでは手首、肘など8箇所をマークするだけで、自動的にリギングしてくれます。とっても簡単!
リギングしたモデルには、2,000以上あるアニメーションを割り当ててその場でプレビューできます。これで色々試しているだけでもだいぶ楽しいです。
ARKitでARコンテンツ化
さて、ここからが本編。アニメーション付きの3Dモデルデータが得られたので、ARKit 3を使ってARコンテンツ化してみます。
やりたいこと
ARKitで3Dオブジェクトを描画するには、SceneKit、SpriteKit、iOS 13から追加されたRealityKitを使うか、あるいはMetalを使って独自に描画します。今回は手軽にARコンテンツを作成するために、SceneKitを使用します。
また、画面のタップした位置に3Dモデルを配置する仕様にします。リアルな3DモデルなのでARで表示するときもリアルに表示したいと思い、以下の実装もおこないます。
- 3Dモデルに当たる照明と実際の照明をなじませる(Light estimation)
- 影を描画する
- 3Dモデルで手前の人物が隠れてしまうのを防ぐ(People occlusion)
準備
mixamoでアニメーションを付けた3DモデルはFBX(.fbx)かCollada(.dae)形式でダウンロードできます。.dae形式は特別なライブラリが不要で、Xcodeがビルド時に自動的にSceneKitで扱える形式に変換してくれるので、この形式でダウンロードしておきます。
XcodeのNewメニュー→Projectから「Augmented Reality App」を選択し、Content Technologyを「SceneKit」にします。
以下、コードはすべてViewController.swiftに実装しています。
モデルの配置
.dae形式の3Dモデル(とテクスチャー)をリソースとしてプロジェクトに追加します。viewDidLoadで3Dモデルを読み込みますが、あとで画面のタップした位置に配置したいのでシーンには追加せずにSceneKitのノードとして保持しておきます。
var baseNode: SCNNode!
override func viewDidLoad() {
// ...
baseNode = SCNScene(named: "Samba Dancing.dae")!.rootNode.childNode(withName: "Base", recursively: true)
}
ARWorldTrackingConfiguration.planeDetectionに.horizontalを指定して、平面を検出できるようにします。
override func viewWillAppear(_ animated: Bool) {
// ...
configuration.planeDetection = [.horizontal]
// ...
}
Main.storyboardにTap Gesture Recognizerを追加し、画面のタップを受け取るアクションを追加します。
タップした位置に対応する現実空間の座標を取得するにはARFrame.hitTestを使用しますが、今回はiOS 13から追加されたARSCNView.raycastQueryとARSession.raycastを使って実装します。
@IBAction func screenDidTap(_ sender: UITapGestureRecognizer) {
guard let view = sender.view else { return }
if sender.state == .ended {
let location = sender.location(in: view)
guard let raycastQuery = sceneView.raycastQuery(from: location, allowing: .estimatedPlane, alignment: .horizontal) else { return }
guard let raycastResult = sceneView.session.raycast(raycastQuery).first else { return }
let position = SCNVector3Make(
raycastResult.worldTransform.columns.3.x,
raycastResult.worldTransform.columns.3.y,
raycastResult.worldTransform.columns.3.z
)
let newBaseNode = baseNode.clone()
newBaseNode.position = position
sceneView.scene.rootNode.addChildNode(newBaseNode)
}
}
まずraycastQueryにSCNView上での座標、raycastをヒットさせる対象とそのalignment(今回は検出された水平面にするのでそれぞれ.estimatedPlaneと.horizontal)を指定してクエリを作ります。そのクエリをARSession.raycastに渡すことによって、現実空間の座標を取得できます。ただしsimd_float4x4なので、これをSCNVector3に変換します。
保持しておいた3Dモデルのノードをcloneして、得られた座標を設定し、シーンに追加します。
Light estimation
Light estimationはキャプチャしたシーンの画像から照明を推定する機能で、初期のARKitから利用可能です。
まず、Light estimationを反映させるための環境光を追加します。
var ambientLightNode: SCNNode!
override func viewDidLoad() {
// ...
let ambientLight = SCNLight()
ambientLight.type = .ambient
ambientLight.shadowMode = .deferred
ambientLightNode = SCNNode()
ambientLightNode.light = ambientLight
scene.rootNode.addChildNode(ambientLightNode)
}
ARWorldTrackingConfigurationのisLightEstimationEnabledとenvironmentTexturingを有効にします。
override func viewWillAppear(_ animated: Bool) {
// ...
configuration.isLightEstimationEnabled = true
configuration.environmentTexturing = .automatic
// ...
}
ARSCNViewを使用している場合、これだけで現実空間の照明の変化は自動的にシーンの照明に反映されます。
影の描画
単に3Dモデルを表示するだけでは"浮いて"見えてしまいます。そこで影を描画します。
まず、光源となるDirectional lightを追加します。この照明で影を描画するのでcastsShadowをtrueにします。また、レンダリングパスの最後に描画するのでshadowModeを.defferedに、shadowColorは半透明の黒にします。
少し影をぼかしてよりリアルにするため、shadowSampleCountとshadowRadiusの値を8に設定します。
最後に、Light estimationでは光源の方向を推定できないため、真上から光が当たるようにして、シーンに追加します。
var directionalLightNode: SCNNode!
override func viewDidLoad() {
// ...
let directionalLight = SCNLight()
directionalLight.type = .directional
directionalLight.intensity = 1000
directionalLight.castsShadow = true
directionalLight.shadowMode = .deferred
directionalLight.shadowColor = UIColor.black.withAlphaComponent(0.5)
directionalLight.shadowSampleCount = 8
directionalLight.shadowRadius = 8
directionalLightNode = SCNNode()
directionalLightNode.light = directionalLight
directionalLightNode.rotation = SCNVector4Make(1.0, 0.0, 0.0, -Float.pi / 2.0)
scene.rootNode.addChildNode(directionalLightNode)
}
このままでは影が投影される平面がシーン上にないので、検出した現実空間の平面のジオメトリから作ります。このジオメトリのマテリアルのcolorBufferWriteMaskを空にしておくことで、透明で影だけ投影できる平面が作れます。
func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
guard let planeAnchor = anchor as? ARPlaneAnchor else { return }
let device = MTLCreateSystemDefaultDevice()!
let planeGeometry = ARSCNPlaneGeometry(device: device)!
planeGeometry.update(from: planeAnchor.geometry)
planeGeometry.firstMaterial?.colorBufferWriteMask = []
node.geometry = planeGeometry
}
People occlusion
通常ARコンテンツにおける3Dモデルは、現実空間をキャプチャした画像の上に描画されるため、手前にある現実の物体の奥に3Dモデルを表示することができず、違和感の元となっていました。
iOS 13から使用可能になったPeople occlusionによって、手前の人物をマスクすることができるようになり、この違和感を低減させることができます。
Occluding Virtual Content with People | Apple Developer Documentation
ただし、この機能にはA12 Bionic以降に搭載されている8コアのニューラルエンジンが必要なため、すべてのデバイスで使用することはできません。対応しているデバイスは、XR以降のiPhoneと、第3世代以降の12.9インチまたは11インチのiPad Pro、第3世代以降のiPad Air、第5世代以降のiPad miniとなっています。
People occlusionを有効にするには、ARWorldTrackingConfiguration.supportsFrameSemanticsで対応しているデバイスか確認したあと、ARWorldTrackingConfiguration.frameSemanticsに.personSegmentationWithDepthを設定します。
override func viewWillAppear(_ animated: Bool) {
// ...
if ARWorldTrackingConfiguration.supportsFrameSemantics(.personSegmentationWithDepth) {
configuration.frameSemantics.insert(.personSegmentationWithDepth)
}
// ...
}
ARSCNViewを使用している場合は、これで3Dモデルより手前の人物がマスクされます。
ARCoachingOverlayView
最後に本筋ではありませんがiOS 13で追加されたARCoachingOverlayViewを実装しておきます。
ARCoachingOverlayView - ARKit | Apple Developer Documentation
デバイスに現実空間を認識させるためには、その仕組み上、デバイスを少し動かすなど一定の手順があります。これまでは開発者自身がそれをユーザーに伝える工夫をする必要がありましたが、それがOSの標準機能で可能になりました。
実装はこんな感じです。sessionに使用するARSession、goalに.horizontalPlaneを設定して(今回は水平面の検出なので)、ARSCNViewに追加します。
var coachingOverlayView: ARCoachingOverlayView!
override func viewDidLoad() {
// ...
coachingOverlayView = ARCoachingOverlayView()
coachingOverlayView.session = sceneView.session
coachingOverlayView.delegate = self
coachingOverlayView.activatesAutomatically = true
coachingOverlayView.goal = .horizontalPlane
sceneView.addSubview(coachingOverlayView)
coachingOverlayView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
coachingOverlayView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
coachingOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
coachingOverlayView.widthAnchor.constraint(equalTo: view.widthAnchor),
coachingOverlayView.heightAnchor.constraint(equalTo: view.heightAnchor),
])
}
最後に
実際のプロジェクトでは、スケールを設定するスライダーとPeople occlusionの有効/無効を切り替えるスイッチを実装しています。
スケールを小さくしてたくさん3Dモデルを配置するとわらわらして楽しい感じになります。
スイッチの方は、People occlusionを有効にすると(ARWorldTrackingConfiguration.frameSemanticsに.personSegmentationWithDepthを設定すると)、なぜか平面に影が描画されないという現象があったので実装しました。
3Dモデル上には影が投影されているので、colorBufferWriteMaskあたりの問題なのかなあとは思っています。
こちらからは以上です。