見出し画像

自分が一万円の顔に!?Vision Proで新一万円札AR作ってみた(後編)

この記事は、自分が一万円の顔に!?Vision Proで新一万円札AR作ってみた(前編)の続きになるため、もし前回の記事を見ていない方は前編の方も見ていただければ幸いです。

今回の記事の内容

前回の記事では、Personaの画像を取得・加工し、ObjectTracking用のリファレンスファイルの作成を行なったところで終わりました。今回の記事ではObjectTrackingProviderからAnchorを取り出し、位置合わせを行う工程について話したいと思います。下記の画像でいうところの4,5の部分になります。

4. Tracking Providerから検出したAnchorを取得

今回はObjectTrackingを利用しましたが、ImageTrackingを利用した場合でもこの工程はほとんど同じになります。違いとしては、取り出されるAnchorがImageAnchorなのかObjectAnchorなのかぐらいです。位置合わせに必要なpositionやrotationについてはどちらのAnchorも持っているため、利用用途に合わせて選択すれば良いかと思います。

実際のコードについて

実際のコードは、大部分をAppleのサンプルプロジェクトを参考にしています。特に、ARSessionやObjectTrackingProvderの管理部分についてはほとんど何も手を加えていません。

パーミッションについて

ObjectTrackingを行うには、info.plistでWorldSensingを許可する必要があります。サンプルコードを利用する場合は、元々この記述が入っているので気にする必要はありませんが、もし動かしてみてパーミッション系のエラーが出た場合は見直してみると解決するかもしれません。

リファレンスファイルの紐付け方法

以前の記事のObjectTrackingの公式サンプルを試したまとめ記事でもお話ししましたが、Appleのサンプルコードでは、FileManagerを利用してプロジェクト内のすべての.referencefileを検索してObjectTrackingProviderを生成しています。サンプルのReferenceObjectLoader内部の下記の部分がそれにあたります。そのため、生成したリファレンスファイルはプロジェクト内部に配置すれば、勝手にリファレンスファイルを参照した ObjectTrackingProviderを生成してくれます。

final class ReferenceObjectLoader {

       ~~~

       func loadBuiltInReferenceObjects() async {

        ~~~
        
        // Get a list of all reference object files in the app's main bundle and attempt to load each.
        var referenceObjectFiles: [String] = []
        if let resourcesPath = Bundle.main.resourcePath {
            try? referenceObjectFiles = FileManager.default.contentsOfDirectory(atPath: resourcesPath).filter { $0.hasSuffix(".referenceobject") }
        }
        
        ~~~
    }
}


Anchorの取得、管理方法

公式サンプルでは下のようなコードを利用してAnchorを取り出しています。コード的にはfor await anchorUpdate in objectTracking.anchorUpdates {} の部分でAnchorの更新を受け取り、Anchorの持つ固有のUUIDで作成されたディクショナリーからAnchorを特定して処理を行います。
switch文以下のanchorUpdate.event ではそれぞれアンカーが追加された時、更新された時、トラッキングが外れた時のイベントになっていて、イベント毎に処理を行えるようになっています。

// anchorのidと対応したVisualizationクラスのディクショナリー
@State private var objectVisualizations: [UUID: ObjectAnchorVisualization] = [:]

~~~

Task {
    let objectTracking = await appState.startTracking()
    guard let objectTracking else {
        return
    }
    
  // アンカーのupdateを受け取る
    for await anchorUpdate in objectTracking.anchorUpdates {
     // Anchorの取得
        let anchor = anchorUpdate.anchor
     // Anchor固有のid
        let id = anchor.id
        
        switch anchorUpdate.event {
        case .added:
       // Anchorから認識したモデルを特定する
            let model = appState.referenceObjectLoader.usdzsPerReferenceObjectID[anchor.referenceObject.id]
       // Visualizetionの生成
            let visualization = ObjectAnchorVisualization(for: anchor, withModel: model)
       // anchorから対応するvisualizetionを特定するためにディクショナリーに追加
            self.objectVisualizations[id] = visualization
            root.addChild(visualization.entity)
     // トラッキングしているAnchorの更新イベント
        case .updated:
            objectVisualizations[id]?.update(with: anchor)
     // トラッキングしていたAnchorのトラッキングが外れた際のイベント
        case .removed:
            objectVisualizations[id]?.entity.removeFromParent()
            objectVisualizations.removeValue(forKey: id)
        }
    }
}

注意点として、現在確認した環境では.removedのイベントがうまく行われていない不具合があり、トラッキング外れた後もremovedのイベントが起きない現象があります。(該当フォーラム)

この対応として、Anchor自体にはisTrackedというトラッキングしているかどうかのプロパティが存在するため、このプロパティをupdatedイベントで確認することでトラッキングが外れた際の処理をremovedイベント無しで書くことができます。おそらく、リリース版では修正されると思いますが同じような現象に当たった場合はご参考いただければと思います。

case .updated:

    if anchor.isTracked {
        //トラッキングが行われているアンカーのupdateイベント
    }else{
        //トラッキングが外れたアンカーのupdateイベント
        objectVisualizations[id]?.entity.removeFromParent()
        objectVisualizations.removeValue(forKey: id)
    }


5. アンカーの座標に画像や3Dモデルを位置合わせする

空間上のアンカーが取れてしまえばあとはそのアンカーを利用してPersonaの画像を位置合わせするだけなのですが、実はここが工夫のしどころがある部分でした。自分がUnityのエンジニアなので、ここまでいけば結構簡単かなーと思っていたのですが。。。

位置合わせを行う方法として二つの方法をトライしました。一つは、SwiftのViewを配置する方法。もう一つは、3Dモデルを重ね合わせる方法です。

SwiftUIのViewをAnchorの位置に配置する。

SwiftUIのViewをアンカーの位置に配置するには、AttachmentというRealityViewの機能を利用します。簡単な例として下のようなコードでAttachmentを作成できます。(コードの参考こちら)
1.,2.でidがGlassCubeLabelとして定義したアタッチメントをattachmentsクロージャーの中で定義し、3.でidから定義したattachmentを探索しています。RealityView { content, attachments in}内部のmakeクロージャーで与えられているattachmentsには、attachmentsクロージャーの中に定義されたAttachmentが渡されます。そのため、先にattachments クロージャー内でAttachmentを事前に定義している必要があります。

RealityView { content, attachments in

    if let glassCube = try? await Entity(named: "GlassCube") {

        content.add(glassCube)

        //3. attachmentsクロージャーで定義されたアタッチメントをidからEntityとして取り出す
        if let glassCubeAttachment = attachments.entity(for: "GlassCubeLabel") {
	        //4. ポジションを変更する
            glassCubeAttachment.position = [0, -0.1, 0]
       //5. RealityViewのContentの子供に入れる
            glassCube.addChild(glassCubeAttachment)
        }

    }
} placeholder: {
    ProgressView()
} attachments: {
    //1. アタッチメントをID付きで定義する
    Attachment(id: "GlassCubeLabel") {
     // 2. SwiftUIのViewを定義
        Text("Glass Cube")
            .font(.extraLargeTitle)
            .padding()
            .glassBackgroundEffect()
    }
}

しかし、今回は動的にAnchorごとに違うViewを定義します。この場合、事前にattachmentsクロージャーにAttachmentを定義することができません。

例えば、下のようなコードのようにanchorが生成されたタイミングでanchor.idを保存して、保存したanchor.idを参照してすぐに3.の工程を行うことも考えると思うのですが、3.のタイミングでは与えられているattachmentsの中に保存したidのattachmentが定義されてない判定となり、anchor.idで探索したとしても見つからない結果になります。

// anchorを保存する
var anchorId: UUID
RealityView { content, attachments in
  for await anchorUpdate in objectTracking.anchorUpdates {
        let anchor = anchorUpdate.anchor
        anchorId = anchor.id

        switch anchorUpdate.event {
          case .added:
              if let glassCube = try? await Entity(named: "GlassCube") {

                  content.add(glassCube)
                  //3. アタッチメントのEntityをidから取り出そうとするが登録されてない
                  if let glassCubeAttachment = attachments.entity(for: anchorId) {
                     //4. ポジションを変更する。
                     glassCubeAttachment.position = [0, -0.1, 0]                      
             //5. RealityViewのContentの子供に入れる
                     glassCube.addChild(glassCubeAttachment)
                  }

            }
       }
   }
} placeholder: {
    ProgressView()
} attachments: {
    //1. 保存したアンカーでAttachmentを定義する
    Attachment(id: anchorId) {
     // 2. SwiftUIのViewを定義
        Text("Glass Cube")
            .font(.extraLargeTitle)
            .padding()
            .glassBackgroundEffect()
    }
}


では、どのように実現すれば良いかというと。RealityViewのupdateクロージャーを使います。この方法はDioramaのDioramaView.swiftを参考にしました。updateクロージャーは、Viewの状態(View`s state)が更新されると呼ばれます。updateのタイミングでは、attachmentsクロージャー内でAttachmentが定義されているため、このタイミングでAttachmentのEntityをルートEntityにaddChildすることでAttachmentが表示されるようになります。
下記コード上でいうと、1-3でアンカー情報をDicitionaryに登録し、4.でDictionaryの更新からAttachmentの定義、5.でAttachmentを検索して、6-8でAttachmentをシーン上に表示しています。
あとは、生成したViewにPerosonaの画像を流し込む処理を入れることにより、Viewで紙幣に重ね合わせをする事ができます。

struct ObjectTrackingRealityView: View {
    var root = Entity()
    var attachmentDictionary: [UUID: AttachmentInfo] = [:]

    var body: some View {
        
        RealityView { content, attachments in
     // rootをRealityViewのContentに紐付ける。以後、Root以下は表示される
     content.add(root)

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

                switch anchorUpdate.event {
                case .added:
                    if let glassCube = try? await Entity(named: "GlassCube") {
                        content.add(glassCube)
                        // 1. Viewを定義する
                        let view = AttachmentView()
                        // 2. Viewと一緒にAtatchmentの情報を作成
                        let info = AttachmentInfo(view)
                        // 3.アタッチメントの情報をAnhcor.idと一緒に保存する
                        attachmentDictionary[anchor.id] = info


                    }
                }
            }
        } update: { content, attachments in
            
            attachmentDictionary.forEach{ (tag: UUID, attachmentInfo: AttachmentInfo) in

                // 5. attachmentDictionaryに登録したanchor.idと同じattachmentsを見つける
                guard let attachmentEntity = attachments.entity(for: tag) else{
                    print("attachment Entity is not create")
                    return}
                    // 6.isSceneAdd=falseの場合attachmentがまだScene内に存在しないのでSceneに入れる
                    if(!attachmentInfo.isSceneAdd){
                        attachmentInfo.isSceneAdd = true
              //7. attachmentのポジションを設定
                        attachmentEntity.position = [0, -0.1, 0]
                        //8. RealityViewのContentの子供に入れる
                        root.addChild(attachmentEntity)
                    }
            }
        }attachments: {
            //4. attachmentDictionaryに追加されたViewがAttachmetとして定義される
            ForEach(attachmentDictionary, id: \.tag) { pair in
                Attachment(id: pair.tag) {
                    pair.attachmentEntity.view
                }
            }
        }
    }
}
struct AttachmentView: View {

    var body: some View {
        Text("Glass Cube")
            .font(.extraLargeTitle)
            .padding()
            .glassBackgroundEffect()
    }
}


class AttachmentInfo
{
    var isSceneAdd:Bool = false
    var view: AnyView
    init(isSceneAdd: Bool, view: AnyView) {
        self.isSceneAdd = isSceneAdd
        self.view = view
    }
}

実際の動画

実際の動画がこちらになります。

★クリックして動画を再生する★

動画の通り、紙幣の上に画像が重ね合わされていると思います。ただ、実際の紙幣上の渋沢栄一がチラチラと見えていたり、Perosonaの動画の色味が紙幣と合わない状態になっています。AR的には、渋沢栄一を消した状態で色が馴染んだ形で表示したいところです。

SwiftUIのView重ね合わせの使い所


今回の事例ではイマイチでしたが、SwiftUIのViewで重ね合わせを行うことの利点も多くあります。例えば、オブジェクトの詳細情報をオブジェクトの近くに出す場合、今回のようにSwiftUIでViewを出す方法が適していると思います。SwiftUIのViewとして扱えるので、@Observableを利用した値の共有にも何の問題もなく対応できますし、UIの整合性を合わせることも容易です。

紙幣の3DモデルにPlaneを配置して、PlaneにPersona画像を流し込む

渋沢栄一を隠して、Persona画像を配置するとなると現実の紙幣に重ね合わせを行うのは限界があり、最終的には紙幣の上に紙幣3Dモデルを重ね、その上にPersonaを重ね合わせて表示することにしました。この方法で撮影したのが一番初めにお見せした動画になります。3Dモデルの作成はBlenderとRealityComposerProを利用しました。

この方法だと3Dモデルと同じライトの光を画像に貼り付けるPlaneも受けることができるので、色味が馴染みやすい利点があります。現状、3Dモデルを扱う RealityComposerProで配置したディレクショナルライトをSwiftUIのViewで受けることはできないため、色を馴染ませる場合はこの方法を取る必要があります。
また、RealityComposerProのShaderをPlaneに適用することで、いろいろな加工をShaderGraphで簡単に行えるようになります。今回は行っていませんが、今後トライしてみたいことの一つです。

RealityComposerProでモデルを作成する

3Dモデルは、Blenderで主なものを作成し、RealityComposerProで組み合わせる形で作成しました。最終的には下のような3Dモデルを作成し、シーン上にロードしています。わかりやすさのために、古代ローマ人のアイコンが重ね合わさっていますが、このアイコン部分にパート1のPersonaの画像が流し込まれると思っていただければ良いです。

RealityComposerProでは、CubeやSphereはPrimitive Shapeとして追加可能なのですがPlaneの生成の仕方がわからず、Blenderで板ポリを生成し、それを RealityComposerProに入れる形でPlaneを導入しています。(多分いい方法があるのだと思うのですが、見つけられず。。。コード上からはGenerateMeshで実現できると思います。)

なぜか、Planeが存在しない。どうやって作るのだろう。。

Texture情報をPlaneに流し込むためにShaderGraphを少しだけ利用しています。下記の画像がそのShaderGraphになります。
PersonaImageがShaderに流し込むInput画像で、その画像情報をRGBとAlphaに分離してOutPutに渡しています。画像情報をShaderに流し込むためには、Node上で右クリックしてPromote to Uniform Input を選択すると画像のPersonaImageのように青色のNodeになり、Inputとして利用できるようになります。

コード上からMaterialにTexture情報を流し込む

上記で作成したShaderGraphに画像をインプットするには、 ShaderGraphMaterialというMaterialを取得し、パラメータをセットする必要があります。公式のサンプルとしてはDioramaでShaderGraph内のパラメータを変更する処理を行っており、参考にしました。

実際のコードとしては、下記のようになります。

    
    // 動的に画像を流し込むマテリアル
    var dynamicMaterial: ShaderGraphMaterial?
    // ロードしたEntity
    var overlapEntity: Entity?
    // Meshを持つModelEntity
    var overlapModel: ModelEntity?
    // Personaの画像を管理しているクラス
    var personaImage: PersonaImage

   func setDynamicPlane(entity: Entity) async
    {
        // モデルをロードする
        if let scene = try? await Entity(named: "OverlapOsatsu", in: realityKitContentBundle)
        {
            overlapEntity = scene
            entity.addChild(scene)
        }

        do{
            // Materialを入れるModelEntityを検索する
            overlapModel = self.overlapEntity!.findEntity(named: "PersonaMesh") as? ModelEntity
            // ShaderMaterialをアセットとパスから取得
            dynamicMaterial = try await ShaderGraphMaterial(named: "/Root/PersonaMaterial", from: "OverlapOsatsu.usda", in: realityKitContentBundle)
            if let model = overlapModel{
                if let mat = dynamicMaterial {
                    model.model?.materials = [mat]
                    entity.addChild(model)

                }
            }
        }catch{
            print( "ShaderError\(error.localizedDescription)")
        }
        // CIImageの反映
        tracking()
    }

    // @Observationで管理しているPersonaのCIImageの更新を監視する
    func tracking() {
        withObservationTracking {
            imageUpdate(ciImage: personaImage.effectedImage!)
        } onChange: {
            print("on Change")
            Task { @MainActor [weak self] in
                guard let self else { return }
                tracking()
            }
        }
    }
    // 画像のアップデート
    func imageUpdate(ciImage: CIImage)
    {

        print("imageUpdate")
        guard let cgImage = ImageConverter.ciImageToCgImage(ciImage) else{
            print("cant get cgImage")
            return
        }

        guard let texture = try? TextureResource(
            image: cgImage,
            options: TextureResource.CreateOptions.init(
                semantic: nil))else {
            print("cant get textureResources")
            return
        }
        do{
             // マテリアルにパラメータを入れる
            try dynamicMaterial?
                .setParameter(
                    name: "PersonaImage",
                    value: .textureResource(texture))
         // マテリアルをモデルに設定する
            if let model = overlapModel{
                if let mat = dynamicMaterial {
                    model.model?.materials = [mat]
                }
            }
            //print("成功しました。")
        }catch{
            print("imageUpdate失敗しました: \(error.localizedDescription)")
        }

    }

Personaの加工画像(CIImage)は、@Observableのクラスで管理しているので、tracking()メソッドのようにwithObservationTrackingを利用してその変更を監視しています。変更があった場合はShaderGraphMaterialに対してSetParameterを行い、マテリアルを更新します。

これにて完成です!!もう一度完成動画を載せておきます。

ARアプリ開発の観点で言えば、精度やfpsに課題があり、スマホとの違いに注意する必要があると思います。ImageTrackingのfpsや精度が低いことや今回のObjectTrackingについてもEnterpriseAPIを利用しなければ、fpsが低くトラッキング対象を移動させると、重ね合わせがズレることが多々あります。今後の機能追加で改善があるかもしれませんが、この制限の中でどのように表現するのが最適なのか?ということも考えていく必要があるのかもしれません。

以上になります。長い文章を読んでいただき、ありがとうございました!

追記:2024年9/16にvisionOS 2.0がリリースされることになりました。リリースはもう少し先かな?と思と思っていたのでこのタイミングでリリースとされてとても楽しみです!記事内容が、beta版のものである影響で内容に齟齬があるかもしれませんが、正式版と見比べつつご参考いただければ幸いです。

いいなと思ったら応援しよう!