10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

KDDIテクノロジーAdvent Calendar 2024

Day 17

3Dプリンターで印刷したクリスマスツリーにApple Vision ProでAR飾り付けをする話

Last updated at Posted at 2024-12-16

はじめに

クリスマス!なのでクリスマスツリーを飾りたい。
せっかくなので最近勉強中のVisionPro + ObjectTrackingを使って、
ARでツリーを表示するアプリを作ってみました。

ObjectTrackingとは、現実空間の物体をAR用マーカーとし
VisionProでその物体を検出し、その物体に演出を重ね合わせる
ARコンテンツを開発できる機能です。
「スマートフォンでQRマーカーを読みAR表示を行う」という物と同じで、
マーカーが画像やQRではなく立体物にできるというのが特徴です。
現実の立体物をマーカーにできるため、
より没入感のある体験・ストーリーテリングを作れる可能性があります。

(ちなみに今回使ってるツリーは前後左右を識別できない
回転対称であることに加え、印刷カラーが黒のため
識別精度が低く、題材としてはあまりよろしくありません。
白い背景だと正常動作するのでご了承ください・・・)

TL;DR

サンプルプロジェクトはこちら↓

3Dプリントデータはこちら↓
PLAの黒、スケール80%で印刷するとそのまま使えるかもしれません。

参考元プロジェクト

今回のプロジェクトは下記リンクから派生する形で、
ロード用モジュールなどをお借りして実装しています。
元リンクの方が出来ることが多い(その代わり難しい)ので、
この記事を理解した後、下記のプロジェクトも試してみてください。

制作環境・バージョン

バージョン
MacOS Sequoia 15.2 Beta (24C5089c)
Xcode 16.1 (16B40)
RealityComposerPro 2.0 (448.0.16.0.3)

物理ツリー側制作

0. ツリーを3Dプリント

まず初めにトラッキング用マーカーとなる立体物を作っていきます。
どのご家庭でもお試しいただけるように、
Bambu Lab A1miniで印刷できるサイズ・材質・データで制作します。
(真面目な話、3Dプリントさえあれば
データ的に検証環境を統一できるため大変オススメ)
以降の手順でマーカーの機械学習も行う場合は、
お好みの立体物で問題ありません。

今回はこちらのクリスマスツリーを使わせていただきます。

今回のプロジェクトではフィラメント節約もかねて
サイズを80%で印刷しています。
サンプルプロジェクトをそのまま利用する場合は80%設定にして
印刷するようにしてください。

出来上がったものがこちらです。

1. ツリーを3Dスキャン

次に用意した立体物を3Dスキャンし、
トラッキングマーカー用のデータを準備します。
次ステップにてUSDZ形式の3Dモデルデータが必要になりますので、
USDZ出力ができる3Dスキャンアプリを用意しましょう。

最近はスマホ向け3Dスキャンアプリが結構多く出てきていますが、
手軽さやUIのシンプルさを考えScaniverseがオススメです。

スキャン時はなるべく明るい環境下で実施し、
撮影者の影が対象物に被らないように立ち回ることを意識しましょう。
Scaniverseの結像は相当優秀なので、多少雑でも成功します。

結像に成功したら画面下部の編集ボタンからトリミングを行います。
スキャン時に映り込んだ背景などを切り取ります。
コツとしては横からの視点にし、床面を若干削り取ることです。
トリミングが終わったら保存して元の画面に戻ります。

背景をキレイに刈り取った後の様子がこちらです。
人間が見て「だいたいそれっぽい」という形状にできたら、
VisionProもだいたい検出してくれます。

編集が終わったら画面下部の共有ボタンから
モデルをエクスポートします。
この際USDZ形式のデータとしてエクスポートしてください。
出力後は何らかの方法(AirDropなど)でMac環境に転送しましょう。

CADデータをそのまま使えば?

それはそう。
次ステップで行う、立体物をマーカーとして利用できるようにする工程が
USDZ形式のデータしかロードできません。
そのため、一度印刷してそれをスキャンし直しUSDZで出力するという
回りくどいフローを採用しています。

CAD用のSTLデータなどをUSDZに変換してもよいのですが、
スケール統一や「カメラから見たオブジェクトの状態」として扱うなど、
いくつかの観点で意図的に3Dスキャンをし直しています。

2. スキャンデータを機械学習

Scaniverseから出力されたUSDZデータを機械学習にかけて
トラッキングマーカーとして利用できるように準備します。

機械学習を行うにはXcodeに同梱されている
CreateMLというアプリを使います。
上部メニュー/Xcode/Open Developer Tool/Create MLからアクセスします。

CreateMLを立ち上げたらNew Documentから新規プロジェクトを作成し、
左メニューのSpatialからObject Trackingテンプレートを選びます。

プロジェクト名などを指定し保存先を選ぶと以下のような画面に遷移し、
機械学習のターゲットを選んだりオプションをいじる画面になります。

画面中央エリア左上からSelect an objectを選ぶと、
トレーニング対象のファイルを指定するダイアログが現れるので、
先ほどScaniverseから転送してきたスキャンデータを選択します。

(上記画像はトレーニング終了後なので厳密にはちょっと違いますが)
データを選択すると上のように中央に3Dモデルと、実寸スケールが表示されます。
ここで現実の立体物と概ね同じサイズになっていることを確認しましょう。
Scaniverseでの編集時に底面を若干削っているので、
高さ方向が少しズレているかもしれません。

View Anglesについて

画面下部エリアのView Anglesから
「そのオブジェクトをどのアングルから見る可能性があるか」を定義します。

All Anglesは文字通り立体物の全方位から見る可能性があるものです。
地球儀などの球体を利用する際はこちらのオプションを選びます。

Upright右上 上から見下ろすアングルで利用する場合に選びます。
今回はクリスマスツリーを見下ろす(見上げるアングルは無いという意味)形になるので
このオプションで進めます。

Frontは更に範囲を絞り、上からに加え見る角度も立体物の手前側のみという設定です。
机に固定されている物体をトラッキングマーカーとするケースや、
自動車のハンドルやメーターをマーカーとするケースに向いている・・・っぽいですが、
使ったことは今のところありません。


View Anglesを設定したら画面上部エリア左上にある
Trainボタンから機械学習を開始します。
トレーニング開始後はTrainingタブから進行状況を確認することができます。

この工程がまァ〜〜〜〜〜〜〜〜それはそれは時間がかかります。
電力もアホほど食うので、コンセントに繋いで待つしかありません。
「寝る前に仕掛けて、朝起きて終わってたらラッキー」というくらい時間がかかります。
12時間待つのだぞ。

ちなみに学習中はCPUもメモリも食いつぶしてブン回るので、
基本的に他タスクは終了して、Mac自体もなるべく触らないようにしましょう。
なんなら低温やけどするレベルで熱くなります。触らないというか、触れない。

3. ReferenceObjectの出力

じっと我慢の子であった・・・。(ホンマに)
トレーニングが完了すると画面上部エリアのOutputタブから学習結果の出力ができます。
Getボタンで確実に学習結果データを保存しましょう。

ここで出力されるデータはReferenceObjectという独自形式のデータです。
内部には学習元のUSDZデータやトラッキング用データが入ってるらしい・・・。
このReferenceObjectデータは後ほどXcodeで利用するので、
わかりやすい場所に保存しておきます。

ちなみに今回利用する黒いクリスマスツリーのトレーニングは
約11時間かかっています。勘弁してヨ・・・。
(過去16時間かかった物体があったので、なんなら短い方)

アプリケーション側制作

0. Xcodeプロジェクトの立ち上げ

トラッキング用マーカーを用意したら、アプリ実装に進みます。
Xcodeから新規にプロジェクトを立ち上げます。
テンプレートはvisionOS/Appを選びます。

プロジェクトのオプションは以下のとおりです

Initial Scene Volume
Immersive Space Renderer RealityKit
Immersive Space Mixed

その他は特に指定はありません。

1. AR演出の制作

プロジェクトを立ち上げたらまず初めにAR表示用の演出シーンを制作します。
プロジェクトディレクトリ内にPackagesというフォルダがあるはずです。
プロジェクト名/Packages/RealityKitContent/Package.realitycomposerpro
というファイルを選択すると以下のような画面になるので、
右上のOpen in Reality Composer ProのボタンからRealityComposerProを起動します。

RealityComposerProが起動したら画面下部のプロジェクトビューで
command + nキーで今回表示する用の新規シーンを作ります。
任意のファイル名で大丈夫です。
(サンプルコード上ではChristmasTreeSceneとしています。)

次に.referenceobjectの学習元となったスキャンデータのUSDZファイルを
RealityComposerProに導入します。
USDZファイルをプロジェクトビューにドラッグ・アンド・ドロップするだけで
インポートできますので、適当に持ってきます。

USDZファイルを持ってきたら先程作成したシーン内にドラッグ・アンド・ドロップして
オブジェクトを設置します。
画面右のインスペクタビューからTransformコンポーネントを確認し、
Position,Rotationともに0,0,0になっていればOKです。
こんな感じ↓

飾り付けをする

ツリー本体が配置できたら、ツリー周辺に飾りを配置していきます。
今回はシンプルにRealityComposerProに同梱されているプリミティブデータのみで
飾り付けを行っていきます。

画面右上のプラスボタンからプロジェクト内に追加するデータを選択します。

例えばプリミティブデータの中からSphereをダブルクリックすると、
シーン内原点にSphereが配置されます。

追加されたオブジェクトを選択すると画面右側のインスペクタビューから
座標やスケールなどを操作することができます。
UnityやUnrealEngineなど、他のゲーム制作ツールを触ったことがある人なら
比較的スムーズに操作できると思います。
シーン内によしなに配置していきましょう。

オブジェクトの色を変更したい場合は、
画面左側ヒエラルキービューの左下、プラスボタンから
Physically Based Materialを作成します。
マテリアル作成後は画面右側インスペクタビューから
Diffuse Colorを始め各種パラメータを変更していきます。

マテリアルを作成したら先ほど配置したオブジェクトに適用します。
色を変えたいオブジェクトを選択し、インスペクタビューの
Material Bindingsからマテリアルを変更します。
今回はRoot/Materialを選択します。
以下のような形で色が変わったら成功です。

パーティクルを飛ばす

次にツリーの周辺に雪が降るようなパーティクルを飛ばします。
画面左側ヒエラルキービューの左下、プラスボタンから
Particle Emmiterを作成します。

Particle Emmiter作成後インスペクタビューを見ると、上部に再生ボタンが表示されます。
これをクリックするとシーン内にパーティクルが再生され始めるので、
その様子を見ながらパラメータを操作していくことになります。
生成直後の初期状態だと上に向かってパーティクルが飛んでいく状態です。

今回は各コンポーネントを以下の通りに変更してみました。
Transform/Position0,25,0
ParticleEmmiter/Emitter/Speed-0.1
ParticleEmmiter/Particle/Birth Rate20
ParticleEmmiter/Particle/Life Span3
ParticleEmmiter/Particle/Size1
ParticleEmmiter/Particle/Start Color,ParticleEmmiter/Particle/End Color
それぞれいい感じに

サンプルプロジェクトでは以下のような挙動にしています。

パーティクルのパラメータはかなり色々自由にいじれるため、
時間があるときにじっくり向き合ってみるとよいでしょう。

カリング用マテリアル

最後にShader Graphを使って、オクルージョンカリング用のマテリアルを作ります。
現状はUSDZデータをそのまま配置しているため、
このシーンをロードするとツリーの3Dモデルがそのまま表示されてしまいます。

せっかくのARコンテンツなので
「現実の立体物の奥に存在する3Dモデルを隠す」オクルージョンを実装してみます。
画面左側ヒエラルキービューの左下、プラスボタンから
Shader Graph Materialを作成します。

次に画面下部のプロジェクトビューがあるエリアから、
Shader Graphタブを選択し、Graphエディタに移動します。
デフォルトでは以下のような配置になっているはずです。

image.png

Graphエディタの右上からNew Nodeボタンをクリックし、
Occlusion Surface(RealityKit)ノードを検索します。
ノードが追加できると以下のように新規ノードが表示されます。

image.png

最後にOcclusionSurfaceノードのOutポートから、
OutputsノードのCustom Surfaceポートにドラッグ・アンド・ドロップで接続します。
以下のような接続ルートになったら完成です。

image.png

カリング用マテリアルが完成したら、
クリスマスツリーのモデルに対して適用します。
サンプルプロジェクトでは
Root/ChristmasBlack/Geom/meshオブジェクトにマテリアルのバインド設定があるので、
今作ったカリング用マテリアルをバインドします。

するとクリスマスツリーのモデルが透明になり、
加えてツリーの向こう側に存在するオブジェクトが見えなくなりました。
(黄色の輪郭は選択中のオブジェクトを示すアウトライン)
これで 「現実の立体物の奥に存在する3Dモデルを隠す」オクルージョン
実現できました。

これにてAR演出側は完成です。
次ステップからコードの実装に進んでいきます。

2. ReferenceObjectのロード

Qiitaに乗せるには長すぎるので、GitHubへのリンクを貼っておきます。
コード全体
https://github.com/GONBEEEproject/ChristmARsTree/blob/main/ChristmARsTree/Main/ReferenceObjectLoader.swift

ReferenceObjectLoader.swift
    func loadReferenceObjects() async {
        var referenceObjectFiles: [String] = []

        if let resourcesPath: String = Bundle.main.resourcePath {
            do {
                try referenceObjectFiles = FileManager.default.contentsOfDirectory(atPath: resourcesPath).filter { $0.hasSuffix(".referenceobject") }
            } catch {
                fatalError("Failed to load reference object files with error: \(error)")
            }
        }

        await withTaskGroup(of: Void.self) { [weak self] group in
            guard let self else { return }

            for file in referenceObjectFiles {
                let objectURL: URL = Bundle.main.bundleURL.appending(path: file)

                group.addTask {
                    await self.loadReferenceObject(objectURL)
                }
            }
        }
    }

重要なところとしては上記メソッドです。
プロジェクトデータから.referenceobjectの拡張子をリストアップし、
中身をそれぞれロードします。
.referenceobjectのファイルパスをURLとして保持しておき、
別メソッドloadReferenceObject(objectURL)にてReferenceObject型でロードしています。

ReferenceObject型の中身には元モデルのUSDZファイルや、
個体識別のためのIDなどが含まれています。
それらをIDをKey、EntityをValueとするDictionaryに保持しておきます。

3. AR演出のロード・表示

コード全体
https://github.com/GONBEEEproject/ChristmARsTree/blob/main/ChristmARsTree/Main/ObjectAnchorVisualization.swift

マーカーの分岐

ここでは検出したマーカーに合わせて何を表示するかを定義していきます。

ObjectAnchorVisualization.swift
@MainActor
class ObjectAnchorVisualization {
    enum ObjectType: String {
        case VisionProCase
        case ChristmasBlack
    }
    
//~~~~~~~~~~中略~~~~~~~~~~~~~~~

        switch model.name {
        case ObjectType.VisionProCase.rawValue:
            self.type = .VisionProCase
        case ObjectType.ChristmasBlack.rawValue:
            self.type = .ChristmasBlack
        default:
            fatalError(
                "Attempted to create ObjectAnchorVisualization for unknown ObjectType"
            )
        }

ObjectAnchorVisualizationクラスの中で検出したマーカーが何かを管理するための
enumを定義しています。
今回はデバッグ用のVisionProCase(ファイル名:VisionProCase.referenceobject)と
実際にツリーに使うChristmasBlack(ファイル名:ChristmasBlack.referenceobject)を
それぞれリストアップしています。

もしここに自分で用意した立体物を追加したい場合は、
enum ObjectTypeに書き加えていきます。
ファイル名をそのままデータとして扱っており、enum登録時もファイル名一致で
追加するようにしてください。

表示するデータの定義

ObjectAnchorVisualization.swift

        switch type {
        case .VisionProCase:
            entity.addChild(model)
        case .ChristmasBlack:

            guard
                let scene = try? await Entity(
                    named: "ChristmasTreeScene",
                    in: realityKitContentBundle)
            else {
                print("Unable to load CaseWithCarScene")
                self.entity = entity
                return
            }
            entity.addChild(scene)

        }

        entity.transform = Transform(matrix: anchor.originFromAnchorTransform)
        entity.isEnabled = anchor.isTracked

        self.entity = entity
    }

次に検出したマーカーに合わせて表示する3Dモデル・シーンデータを定義します。
.referenceobjectに同梱されている元のUSDZデータをただ表示するだけなら
case .VisionProCase:のブロックにある通り、
entity.addChild(model)のみでとりあえず表示することができます。

RealityComposerProで制作した演出入りシーンを表示する場合は
case .ChristmasBlack:のブロックに書いてあるように

  1. realityKitContentBundleからシーン名を指定し、非同期でロードする
  2. 一応ロード失敗時用にエラーハンドリング
  3. entity.addChild(scene)でロードしたシーンをentityに追加する

という手順でロード・表示します。

ロード完了後はentityの座標情報を制御したり、
トラッキング状態に合わせて有効無効を切り替えたりする行が入りますが、
下記に説明するupdateメソッドがあるため、ここではそこまで重要ではありません。
一応検出直後にその座標に表示する用ですが、
おまじないという事にして暗記したほうが早いと思います。

座標更新メソッド

ObjectAnchorVisualization.swift
    func update(with anchor: ObjectAnchor) {
        entity.isEnabled = anchor.isTracked

        guard anchor.isTracked else { return }

        entity.transform = Transform(matrix: anchor.originFromAnchorTransform)

    }

こちらはマーカーのトラッキング状態が更新された場合に発火されるメソッドです。
4. にて説明するAppDataから発火されます。

Transform(matrix: anchor.originFromAnchorTransform)を使って
更新されたマーカー座標を3Dモデル・シーンデータに改めて適用します。
このメソッドによりマーカーが動いたり、体験者が移動しても
現実の立体物にオーバーレイされ続けるという挙動になります。

4. AppModel実装

コード全体
https://github.com/GONBEEEproject/ChristmARsTree/blob/main/ChristmARsTree/Main/AppModel.swift

AppModel.swift

    private func processObjectUpdates(with rootEntity: Entity) async {
        guard let objectTrackingProvider else {
            print(
                "Error obtaining handTrackingProvider upon processHandUpdates")

            return
        }

        for await anchorUpdate in objectTrackingProvider.anchorUpdates {
            let anchor = anchorUpdate.anchor
            let id = anchor.id

            switch anchorUpdate.event {
            case .added:

                anchorReferences[id] = anchor
                
                let model: Entity? =
                    referenceObjectLoader.usdzsPerReferenceObjectID[anchor.referenceObject.id]

                let visualization = await ObjectAnchorVisualization(
                    for: anchor,
                    withModel: model,
                    appModel: self)

                objectVisualizations[id] = visualization
                rootEntity.addChild(visualization.entity)

            case .updated:

                anchorReferences[id] = anchor
                objectVisualizations[id]?.update(with: anchor)

            case .removed:

                anchorReferences.removeValue(forKey: id)
                objectVisualizations[id]?.entity.removeFromParent()
                objectVisualizations.removeValue(forKey: id)

            }
        }
    }

AppModel内で特に重要なのはマーカー検出時の更新処理です。
マーカー個別の更新時にanchorUpdate.eventにてそれぞれ
case .addedは新規に検出時(ロスト後、再検出を含む)に実行、
case .updatedは検出済みのものが更新される時(1秒に1度更新)に実行、
case .removedはマーカーが隠れる、見失うなどロストした際に実行されます。

前項で説明したupdateメソッドはcase .updated内で実行されています。
他プロジェクトでも使い回せる書き方なので、
こちらもおまじないとして覚えてしまったほうが早いかと思います。

5. 各種View実装

初期ウィンドウView
https://github.com/GONBEEEproject/ChristmARsTree/blob/main/ChristmARsTree/Views/ContentView.swift

モード切り替え用ボタン
https://github.com/GONBEEEproject/ChristmARsTree/blob/main/ChristmARsTree/Views/ToggleImmersiveSpaceButton.swift

トラッキングモード用View
https://github.com/GONBEEEproject/ChristmARsTree/blob/main/ChristmARsTree/Views/ObjectTrackingView.swift

各種UIウィンドウを制御するViewに関しては、
これといって特殊な事は行っていません。

ObjectTrackingView.swift
    var body: some View {
        RealityView { content in
            content.add(rootEntity)
        }
        .task {
            await appModel.startTracking(with: rootEntity)
        }
        .onDisappear() {
            for (_, visualization) in appModel.objectVisualizations {
                rootEntity.removeChild(visualization.entity)
            }

            appModel.objectVisualizations.removeAll()
        }
    }

強いて言うならObjectTrackingView.swiftにて、
Viewの起動時にAppModelに対してトラッキング開始の処理を送っていること、
Viewの終了時にトラッキングしていたものを全て破棄して終了していることくらいです。

Xcodeのプレビュー機能について

ContentView.swift
#Preview(windowStyle: .automatic) {
    let appModel = AppModel()

    ContentView()
        .environment(appModel)
}

Xcodeでは上記のように#Previewブロックを実装すると
そのViewのプレビューが表示できますが(visionOSの場合はエミュレーターが起動する)、
今回のアプリケーションではエミュレーターでは実行できないARKitの機能を使っており
プレビューを行うことができません。
面倒ですが、デバッグ時は実機に転送して本番環境でテストしましょう。

(これ本当にテスト方法ないのかな・・・デバッグすごい大変だけど・・・)

6. ChristmARsTreeApp実装

コード全体
https://github.com/GONBEEEproject/ChristmARsTree/blob/main/ChristmARsTree/Main/ChristmARsTreeApp.swift

ChristmARsTreeApp.swift
@main
struct ChristmARsTreeApp: App {
    @State private var appModel = AppModel()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(appModel)
                .frame(width: 400,
                       height: 200)
                .task {
                    await appModel.referenceObjectLoader.loadReferenceObjects()
                }

        }
        .windowResizability(.contentSize)

        ImmersiveSpace(id: appModel.immersiveSpaceID) {
            ObjectTrackingView()
                .environment(appModel)
                .onAppear {
                    appModel.immersiveSpaceState = .open
                }
                .onDisappear {
                    appModel.immersiveSpaceState = .closed
                }
        }
        .immersionStyle(selection: .constant(.mixed),
                        in: .mixed)
     }
}

最後にエントリーポイントとなる@mainを書きます。
初期ウィンドウとなるContentView呼び出し時には追加タスクで、
appModel.referenceObjectLoader.loadReferenceObjects()を発火し、
2. で説明したReferenceObjectのデータのロードを開始しています。

ObjectTrackingを行うImmersiveSpaceの宣言では
ObjectTrackingViewの呼び出しと、
ImmersiveSpace開始時・終了時にそれぞれ
appModelに対し.open .closeのステートを送っています。

7. 実機にデプロイ

VisionProとXcodeの連携と初期セットアップはこちらの記事をご参照ください。

一通りの実装が終わったらいよいよVisionPro実機にビルドを転送し、実行してみます。

アプリケーション起動後、現れるウィンドウから
Start Trackingのボタンを押すとマーカーの検出を始めます。
ウィンドウの表示がSearchingになったら目の前にクリスマスツリーをかざしましょう。
以下の動画のように3Dモデルやパーティクルが表示されたら成功です!

完成!&おわりに

かなり内容がミッチリ高密度な記事になってしまいましたが、いかがでしたでしょうか。
この記事をなぞったり、独自にカスタマイズを加えたりすれば
VisionProアプリの基礎的な実装方法や、
3Dスキャンを組み合わせた「現実空間に基づいたコンテンツ」制作、
RealityComposerProを使った3Dモデル配置~パーティクル演出などなど
かなり多方面の知識を身につけることが出来る(はず)です。

今回の内容から発展する形で
・独自の立体物をマーカーとして利用する
・複数の立体物に別々のAR演出を組み込み、同時表示する
・パーティクルや他の3Dモデルを追加し、より派手な演出を作る
などなど、様々な方向に掘り下げることができます。
この記事を足掛かりとして、Swiftを利用したVisionPro開発の道に
進んでいただければ幸いです。

最後まで読んでいただきありがとうございました。
「ここもっと詳細説明してほしい」や「ここバグってます」などご指摘ありましたら、
コメント欄かごんびぃーまでご連絡いただければと思います。

10
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?