こんにちは。

現在開発を行っているプロジェクトで、アプリを再度立ち上げても現実空間の位置や回転を維持できるWorldAnchorを使用したので、移動可能で、位置を永続的に保持できるオブジェクトを作るコードを紹介したいと思います。といいつつも、公式のサンプルで同じような実装を行なっているものがあります。ただ、このサンプルは他の要素も結構入っており、シンプルではない箇所もあるため、このサンプルを分解して簡素化したものだと思っていただければと思います。

また、永続的に位置を保持するアプリを作る上でその位置情報に紐づいたデータを永続保持できる機能を持つことはよくあることだと思うので、今回はSwiftDataを用いて位置情報に紐づいたデータ(登録された時間情報)を保持できるようにしています。SwiftDataについては詳しく解説しませんが、ギャップロにはSwiftDataに関する記事もあるので、興味のある方はご参照ください。

概略図

細かいですが、今回実装したWorldAnchor周りの処理の概略図になります。新規登録する場合はオブジェクトを生成し、その生成したオブジェクトの位置情報をWorldAnchorとして登録。すでに登録済みのWorldAnchorがある場合はWorldAnchorの位置情報からオブジェクトを生成します。また、オブジェクトの移動の際はドラッグが完了したタイミングで新しいWorldAnchorを生成して、古いものと差し替えることで移動先の位置情報を保持しています。

概略図からわかる通りWorldAnchorは一度生成した後はWorldAnchor自体の位置情報を書き換えることはできず、違う位置情報を保持したい場合は新しいWorldAnchorを生成する必要があります。

実装したデモの動画

実際のコード

実際のコードが以下になります。コードをまとめるのに通常Viewでは行う必要のないものもあるため長くなったり、Extensionでコードが隠れている部分もありますが基本的にはこのような実装で可能です。ざっくり重要な部分について解説していきたいと思います。

ImmersiveView.swift

//
//  ImmersiveView.swift
//  worldAnchorDemo
//
//  Created by yusuke.yamanaka on 2025/03/11.
//

import SwiftUI
import RealityKit
import ARKit
import SwiftData

struct ImmersiveView: View {
    // AppModelを環境変数として取得
    @Environment(AppModel.self) var appModel

    // Anchor作成からAnchor検知までの一時的なリスト
    @State var objectsBeingAnchored: [UUID: UUID] = [:]

    // すでにWorldAnchorが存在するEntityのリスト
    @State var anchoredEntityList: [UUID: Entity] = [:]

    // SwiftDataの設定
    @Query private var persistentData: [PersistentData]
    @Environment(\.modelContext) private var modelContext

    // WorldSensingのProvider
    @State var worldTracking = WorldTrackingProvider()

    var body: some View {

        RealityView{content,attachment in
            content.add(appModel.root)
            // ARSessionを開始する
            Task{
                do {
                    try await appModel.arSessionManager.arSession
                        .run([worldTracking])
                } catch {
                    print("Error: \(error)" )
                }
            }

        }update:{ content,attachment in
            // 登録されたattachmentを可視化
            appModel.attachmentsProvider
                .visualizeAttachment(
                    root: appModel.root,
                    realityViewAttachment:attachment)

        }attachments:{
            // Attachmentを登録
            ForEach(appModel.attachmentsProvider.sortedTagViewPairs, id: \.tag) { pair in
                Attachment(id: pair.tag) {
                    pair.attachmentView.view
                }
            }
        }
        .installGestures() // GestureComponentの起動
        .onChange(of: appModel.isSubmit){
            // 別のViewのSubmitボタンが押されたらEntityを作成する
            if(appModel.isSubmit){

                createEntity()
                appModel.isSubmit = false
            }
        }
        .onChange(of: appModel.immersiveSpaceState){
            // ImmesiveViewの状態を監視して、close処理を行う
            if appModel.immersiveSpaceState == .closed{
                dismissImmersiveView()
            }
        }
        .task {
            // WorldAnchorのアップデートの開始
            await processWorldAnchorUpdates( )
        }
    }

    // WorldAnchorのイベントシークエンス
    @MainActor
    func processWorldAnchorUpdates() async {
        for await anchorUpdate in worldTracking.anchorUpdates {
            let anchor = anchorUpdate.anchor
            switch anchorUpdate.event {
            case .added:
                if let objectBeingAnchored = objectsBeingAnchored[anchor.id] {
                    // アンカー追加のための一時リストからremove
                    objectsBeingAnchored.removeValue(forKey: anchor.id)
                    
                    if let persistentData = persistentData.first(
                        where: {$0.objectId == objectBeingAnchored}){
                        // WorldAnchorを登録する
                        await  replacePersistentAnchorWithID(persistentData: persistentData,anchor: anchor)
                    }

                } else if let persistentData = persistentData.first(
                    where: {$0.anchorId == anchor.id
                    }){
                    // 永続データから初回配置される場合
                    attachAnchorFromPersistentData(persistentData:persistentData, anchor: anchor)

                } else {
                     //オブジェクトに紐づいていないWorldAnchorを削除する
                    if persistentData
                        .first(
                            where: {$0.anchorId == anchor.id
                            }) == nil || objectsBeingAnchored
                        .contains(where: {$0.key != anchor.id}){
                        Task {
                            await removeAnchor(uuid:anchor.id)
                        }
                    }
                }
            case .updated:
                    // worldAnchorのアップデート
                    updateEntityPositionByAnchor(anchor: anchor)
            case .removed:
                break
            }
        }
    }
    // 移動、新規作成で永続データのAnchorIdを更新する
    func replacePersistentAnchorWithID(persistentData:PersistentData, anchor:WorldAnchor) async{
        let oldAnchorId = persistentData.anchorId
        persistentData.anchorId = anchor.id
        if let anchorId = oldAnchorId{
            // 変更されたWorldAnchorを削除
            await removeAnchor(uuid: anchorId)

            do {try modelContext.save()}catch{print("cant save SwiftData")}
            print("replace complete")
        }
    }

    // 永続データからのオブジェクトの生成
    @MainActor
    func attachAnchorFromPersistentData(
        persistentData:PersistentData,
        anchor: WorldAnchor
    ){
        // 永続データから取得したUUIDを元にEntityを作成
        let targetEntity = ModelEntity.createModelEntity(
            attachingUUID: persistentData.objectId,
            onEndDrag: onEndDragObject
        )
        // worldAnchorが存在するEntityを保存
        anchoredEntityList[anchor.id] = targetEntity

        // Entityのポジションとローテションを設定
        targetEntity.position = anchor.originFromAnchorTransform.translation
        targetEntity.orientation = anchor.originFromAnchorTransform.rotation

        // Attacmnentを作成
        appModel.attachmentsProvider
            .createAttachment(
                text: persistentData.timeStamp
                    .formatted(.dateTime.day().hour().minute().second()),
                entityUUID: targetEntity.uuid!,
                parentEntity: targetEntity
            )

        // appModelに保存しているRootEntityにaddChild
        appModel.root.addChild(targetEntity)
    }

    // Anchorの削除
    func removeAnchor(uuid: UUID) async{
        do {
            try await worldTracking.removeAnchor(forID: uuid)
        } catch {
            print("Failed to delete world anchor \(uuid) with error \(error).")
        }
    }

    // WorldAnchorの登録
    func registerWorldAnchor(anchor: WorldAnchor,entityId:  UUID) async{
        // 登録中のAnchorを一時保存
        objectsBeingAnchored[anchor.id] = entityId

        do {
            // provderに登録
            try await worldTracking.addAnchor(anchor)
            print("registered\(anchor.id)")

        } catch {
            if let worldTrackingError = error as? WorldTrackingProvider.Error, worldTrackingError.code == .worldAnchorLimitReached {
                print("worldAnchor is Limited")
            } else {
                print("Failed to add world anchor \(anchor.id) with error: \(error).")
            }

            objectsBeingAnchored.removeValue(forKey: anchor.id)
            return
        }
    }
    // ProviderからWorldAnchorのアップデートが走った場合
    func updateEntityPositionByAnchor(anchor: WorldAnchor){

        if let entity = anchoredEntityList.first(
            where: {$0.key == anchor.id})?.value{
            entity.position = anchor.originFromAnchorTransform.translation
            entity.orientation = anchor.originFromAnchorTransform.rotation
        }
    }

    // Drag終了時のイベント
    func onEndDragObject(dragValue: EntityTargetValue<DragGesture.Value>){
        let entity = dragValue.entity
        guard let entityId = entity.uuid else{return}

        // worldAnchorの作成
        let anchor = WorldAnchor(
            originFromAnchorTransform: entity
                .transformMatrix(relativeTo: nil)
        )

        Task{
            // WorldAnchorを差し替えるフローへ
            await registerWorldAnchor(anchor: anchor, entityId: entityId)
        }
    }
    // Entityの追加
    func createEntity()
    {
        // 新しいオブジェクトを作成
        let targetEntity = ModelEntity.createModelEntity(
            attachingUUID: nil,
            onEndDrag: onEndDragObject)
        //ワールドアンカーを作成
        let anchor = WorldAnchor(
            originFromAnchorTransform: targetEntity
                .transformMatrix(relativeTo: nil)
        )
        // providerにAnchorを登録
        Task{
            await registerWorldAnchor(
                anchor: anchor,
                entityId: targetEntity
                    .uuid!)
        }

        // anchorがついたEntityとして追加
        anchoredEntityList[anchor.id] = targetEntity

        // SwiftDataに現在の時間と共に永続データを入れる
        let persisentData = PersistentData(
            objectId: targetEntity.uuid,
            anchorId: anchor.id,
            timeStamp: Date.now
        )
        // SwiftDataへデータの追加
        modelContext.insert(persisentData)

        // attachmentの作成
        appModel.attachmentsProvider
            .createAttachment(
                text: persisentData.timeStamp
                    .formatted(.dateTime.day().hour().minute().second()),
                entityUUID: targetEntity.uuid!,
                parentEntity: targetEntity
            )
        // entityの表示
        appModel.root.addChild(targetEntity)

    }
    // Immersiveから出た場合に呼ばれる
    @MainActor func dismissImmersiveView()
    {
        // Entityを削除
        anchoredEntityList.forEach { (key,value) in
            value.removeFromParent()
        }
        // リストのクリア
        anchoredEntityList.removeAll()
        // アタッチメントのクリア
        appModel.attachmentsProvider.dismissImmersiveView()
        // ARSessionの停止
        appModel.didLeaveImmersiveSpace()
    }
}

PersistentData.swift

import SwiftUI
import SwiftData

// 永続保存するためのSwiftDataのモデル
@Model
final class PersistentData{
    var objectId : UUID?
    var anchorId : UUID?
    var timeStamp: Date
    init(
        objectId: UUID? = nil,
        anchorId: UUID? = nil,
        timeStamp: Date
    ) {
        self.objectId = objectId
        self.anchorId = anchorId
        self.timeStamp = timeStamp
    }
}

SwiftDataで登録するデータ

SwiftDataで登録するデータは三つです。

  • objectId: オブジェクトを判別するためのUUID
  • anchorId: worldAnchorのidを保存
  • timeStamp: 登録日時を保存 ※今回はわかりやすさのために保存している

永続データにオブジェクトのIDを入れているのは、WorldAnchorを差し替える際に永続データに登録するkeyとしてオブジェクトIDを利用しているためです。今回のようにEntityを利用しない場合はオブジェクトに紐づかないUUIDや、SwiftDataが持っているidentifierを使うこともできると思います。

worldTrackingProviderのイベントで行っていること。

WorldAnchorは、ARKit配下のWorldTrackingProviderを利用することで管理できます。
このWorldTrackingProviderは他のProviderと同様に、.added,.updated,.removedの三つのイベントを持っており、それぞれ名前の通り、下記のような場合のイベントになっています。

  • .added : シーンにAnchorが追加された場合
  • .updated:アンカーが更新された場合
  • .removed: アンカーが削除された場合

.addイベントでの処理

今回のWorldAnchorの利用にあたっては、.addedイベントがかなり重要な役割であり、複雑な処理を行います。まず、オブジェクトを新規で作成することを想定してみましょう。
オブジェクトの新規作成は、createEntity()で行っています。
ポイントとしては、WorldAnchorを生成したのちにWorldTrackingProviderへ登録をしている部分です。
下記のコードの時点では、WorldAnchorは意味を持っておらず、Providerに登録を行っただけです。

     //ワールドアンカーを作成
     let anchor = WorldAnchor(
         originFromAnchorTransform: targetEntity
                .transformMatrix(relativeTo: nil)
     )
     // ProviderにAnchorを登録
     Task{
         await registerWorldAnchor(
             anchor: anchor,
             entityId: targetEntity
                 .uuid!)
     }

    // Anchorの登録
    func registerWorldAnchor(anchor: WorldAnchor,entityId:  UUID) async{
        // 登録中のAnchorを一時保存
        objectsBeingAnchored[anchor.id] = entityId
        do {
            try await worldTracking.addAnchor(anchor)
            print("registered\(anchor.id)")

        } catch {
            if let worldTrackingError = error as? WorldTrackingProvider.Error, worldTrackingError.code == .worldAnchorLimitReached {
                print("worldAnchor is Limited")
            } else {
                print("Failed to add world anchor \(anchor.id) with error: \(error).")
            }

            objectsBeingAnchored.removeValue(forKey: anchor.id)
            return
        }
    }


では、どのタイミングで登録したWorldAnchorが意味を持ったものとして検知できるようになるかというと、worldTracking.anchorUpdatesの.addedイベントで同じIdのWorldAnchorを取得した場合です。
なので、WorldAnchorの作成には下記のようなフローを必ず経ることになります。

WorldAnchorの作成 → WorldTrackingProviderへの登録 → .addedイベントで登録時と同じWorldAnchorを取得

これを行うことで、WorldAnchorの登録が完了します。

この.addedイベントは、WorldAnchorがシーンに追加された時に呼ばれます。RealityViewを立ち上げた際、すでに登録済みのWorldAnchorがSceneに追加される場合も.addedイベントが発生するため、.addedで検知されるWorldAnchorの種類は今回のアプリでは2種類になります。

  • 既に前のセッション(WorldTrackingProviderの起動と思ってもらって良いです。)で、登録されていたWorldAnchorが起動時にシーンに追加される
  • WorldAnchorを新規作成して、Providerに登録されたもの

そのため、以前登録したWorldAnchorと今回新規登録したWorldAnchorを分ける必要があります。その峻別は、下記のobjectsBeingAnchoredで行っており、先ほどの流れのProviderに登録→.addedイベントで登録完了するまでの間のAnchorIdとObjectのIdを保持します。(今回オブジェトのIdはModelEntityのextensionでUUIDを付与できるコンポーネントを作ることでUUIDで管理しています。)

オブジェクト新規作成の.addedイベント

このリストに存在するWorldAnchorが.addedイベントで検知できた場合はobjectIdをkeyにしてanchorのidを差し替えを行い新規で登録されたWorldAnchorを永続データに紐づけます。replacePersistentAnchorWithID(persistentData: persistentData,anchor: anchor)はその操作を行なっています。

// Anchor作成からAnchor検知までの一時的なリスト
@State var objectsBeingAnchored: [UUID: UUID] = [:]

~~~~~~~~~ processWorldAnchorUpdates()内部


  } else if let persistentData = persistentData.first(
      where: {$0.anchorId == anchor.id
      }){

      // 永続データから初回配置される
      attachAnchorFromPersistentData(persistentData:persistentData, anchor: anchor)
  }

~~~~~~~~~

    // 永続データからのオブジェクトの生成
    @MainActor
    func attachAnchorFromPersistentData(
        persistentData:PersistentData,
        anchor: WorldAnchor
    ){
        // 永続データから取得したUUIDを元にEntityを作成
        let targetEntity = ModelEntity.createModelEntity(
            attachingUUID: persistentData.objectId,
            onEndDrag: onEndDragObject
        )
        // worldAnchorが存在するEntityを保存
        anchoredEntityList[anchor.id] = targetEntity

        // Entityのポジションとローテションを設定
        targetEntity.position = anchor.originFromAnchorTransform.translation
        targetEntity.orientation = anchor.originFromAnchorTransform.rotation

        // Attachmentを作成
        appModel.attachmentsProvider
            .createAttachment(
                text: persistentData.timeStamp
                    .formatted(.dateTime.day().hour().minute().second()),
                entityUUID: targetEntity.uuid!,
                parentEntity: targetEntity
            )

        // appModelに保存しているRootEntityにaddChild
        appModel.root.addChild(targetEntity)
    }

.updateイベントでの処理

.updateイベントは、WorldAnchorが更新された場合に走るイベントです。上で説明した通り、WorldAnchorに新しい値を書き込むことはできません。そのため、このイベントはProvider側でWorldAnchorが変更された場合に実行されます。

WorldAnchorが更新されるとはどういうことか?

WorldAnchorは、認識した環境からワールド座標を作成し、そのワールド座標をもとにアンカーを打つことになります。では、ワールド座標が更新される時に.updatedが走ることになるのですが、ここで話されるワールド座標はVisionProを中心としたワールド座標です。なので、例えばDigital Crownを長押しするなどの行動を行うとWorldAnchorの更新が起こることになります。

WorldAnchorは、必ず取り出されるときにデバイスのワールド座標に変換されます。そうしないと、毎回デバイス中心の座標に変換する必要があるので便利なのですが、逆にWorldAnchorを他のデバイスと共有することはできないことになります。今後、認識した環境を中心とした座標を取り出せるようになったら他デバイスと同じオブジェクトを固定できるようになるかもしれません。

.updatedの実際のコード

長い前置きでしたが、.updatedのコードになります。といっても、これは最初に挙げた公式サンプルと全く一緒です。Anchor.idをkeyにしたEntityを保存しておき、更新の際にそのポジションとローテンションを反映するだけです。

    // ProviderからWorldAnchorのアップデートが走った場合
    func updateEntityPositionByAnchor(anchor: WorldAnchor){

        if let entity = anchoredEntityList.first(
            where: {$0.key == anchor.id})?.value{
            entity.position = anchor.originFromAnchorTransform.translation
            entity.orientation = anchor.originFromAnchorTransform.rotation
        }
    }

.removedイベントでの処理

今回のアプリでは、.removedを利用することはないですが、他のProviderの場合はtrackingが外れるタイミング等で利用されます。今回は、removedが起こる場合はないので。利用していません。

オブジェクトの移動処理

オブジェクトの移動は、GestureComponentという公式サンプルで利用されているカスタムコンポーネントを少し改造してドラッグジェスチャーのonChange()とonEndコールバックできるようにしています。今回は、onEndのタイミングでワールドアンカーを差し替える処理が必要なので、下記のような関数をコールバックに仕込んで処理を行っています。この処理で、ドラッグが終わったタイミングでポジションのWorldAnchorを生成し、永続データのWorldAnchorとの差し替えを行うことで移動先のWorldAnchorを最新として登録し続けます。

    // Drag終了時のイベント
    func onEndDragObject(dragValue: EntityTargetValue<DragGesture.Value>){
        let entity = dragValue.entity
        guard let entityId = entity.uuid else{return}

        // worldAnchorの作成
        let anchor = WorldAnchor(
            originFromAnchorTransform: entity
                .transformMatrix(relativeTo: nil)
        )

        Task{
            // WorldAnchiorを差し替えるフローへ
            await registerWorldAnchor(anchor: anchor, entityId: entityId)
        }
    }

終わりに

WorldAnchorはアプリが閉じられた後も、場所を保存できるという強力な機能です。Immersalなどの保存された点群から場所を特定するサービスと同じようなことが公式でできるのでアクセスしやすく、手軽に利用できると思います。

しかし、先ほど指摘した通り他のデバイスにWorldAnchorのデータを送ったとしても共有先のデバイスでは意味のないデータになってしまうので、その部分だけは注意が必要です。今後、マルチデバイスに対応するようになれば、固定された同じ場所にあるオブジェクトに対してインタラクションできるなど、より面白い活用が期待できるため、今後のアップデートに注目したいところです。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる