Rork Max に「床に家具を置ける AR アプリ」と伝えると、驚くほど素直に動くものが出てきます。ARKit の初期化も、平面検出も、タップした位置へのモデル配置も、こちらが Swift を一行も書かないうちに揃います。
問題は、その次に起きました。
配置を終えてアプリを閉じ、翌朝もう一度開くと、置いたはずのものが一つ残らず消えていたのです。バグではありません。ARKit のセッションは、既定では何も記憶しないまま終わります。私自身、個人開発で壁紙アプリを長く運用してきて「保存し忘れ」の類にはそれなりに敏感なつもりでしたが、AR では保存すべき対象そのものが直感と違っていました。座標を保存するだけでは足りないのです。
ここでは、その「足りない部分」を ARWorldMap で埋める設計を、順を追って組み立てます。
座標を保存しても配置が戻らない理由
最初に手が伸びるのは、タップ位置の simd_float4x4 を JSON にして保存する方法です。これは動きません。理由は、AR の座標系そのものが毎回作り直されるからです。
ARKit のワールド座標の原点は、セッション開始時のデバイス位置に置かれます。昨日は部屋の入口で起動し、今日はソファの前で起動したなら、同じ数値が指す実世界の場所は数メートル単位でずれます。座標は、それを解釈する地図とセットでなければ意味を持ちません。
その地図にあたるのが ARWorldMap です。中身は大きく 3 つに分かれます。
| 要素 | 内容 | 保存されるか |
| 特徴点群 | カメラが認識した空間の点の集合(rawFeaturePoints) | される |
| アンカー | ARAnchor の一覧(変換行列と識別子) | される |
| 3D モデル | 表示している家具のメッシュ・マテリアル | されない |
3 行目が肝心です。ARWorldMap は「どこに」を記憶しますが、「何を」は記憶しません。復元後にアンカーだけが蘇り、画面には何も出ないという状態に、私は最初まさに一晩溶かしました。
アンカーに意味を持たせる
そこで、アンカーを継承して自前の情報を載せます。ARWorldMap のアーカイブはアンカーも一緒に符号化するため、NSSecureCoding を正しく実装すれば、モデルの識別子が地図と一緒に運ばれます。
final class FurnitureAnchor: ARAnchor {
// 復元時にどのモデルを再生成するかを決める鍵
let catalogID: String
let placedAt: Date
init(catalogID: String, transform: simd_float4x4) {
self.catalogID = catalogID
self.placedAt = Date()
super.init(name: "furniture", transform: transform)
}
// ARKit がセッション内でアンカーを複製する際に必ず呼ばれる。
// ここを実装し忘れると、復元前に情報が消える。
required init(anchor: ARAnchor) {
let other = anchor as! FurnitureAnchor
self.catalogID = other.catalogID
self.placedAt = other.placedAt
super.init(anchor: other)
}
override class var supportsSecureCoding: Bool { true }
required init?(coder: NSCoder) {
guard let id = coder.decodeObject(of: NSString.self, forKey: "catalogID") as String? else {
return nil // 旧バージョンの地図は復元せず捨てる
}
self.catalogID = id
self.placedAt = coder.decodeObject(of: NSDate.self, forKey: "placedAt") as Date? ?? Date()
super.init(coder: coder)
}
override func encode(with coder: NSCoder) {
super.encode(with: coder)
coder.encode(catalogID as NSString, forKey: "catalogID")
coder.encode(placedAt as NSDate, forKey: "placedAt")
}
}
Rork Max が生成したコードにこの層を足すとき、私は init(anchor:) を必ず先に確認します。ここが未実装だと、セッション中は正常に見えるのに、保存された地図の中身だけが空になります。実機で 1 度アプリを閉じるまで気づけない種類の落とし穴で、生成コードのレビューで最も見落としやすい箇所だと感じています。回避策は単純で、保存の直後に一度だけ読み戻し、catalogID が残っているかを確かめることです。
保存は mappingStatus が .mapped になってから
getCurrentWorldMap はいつでも呼べますが、いつ呼んでも良いわけではありません。ARKit は地図の熟成度を mappingStatus として公開しています。
4 つの状態と、待つべき理由
.notAvailable — 地図がまだ存在しません。呼んでも失敗します。
.limited — 特徴点が乏しく、復元はほぼ成功しません。
.extending — 使えますが、周辺の記録が途中です。
.mapped — 現在地の周辺が十分に記録されています。
私の手元の 1 LDK で試したところ、部屋の中央で 3〜4 歩ゆっくり動くと 12〜20 秒ほどで .mapped に届きました。逆に、カメラを一点に向けたまま動かさないと、90 秒経っても .extending から上がりませんでした。特徴点は視差から作られるため、時間ではなく移動が必要になります。
保存側は、状態を見て黙って待つ実装にします。
func saveWorldMap(session: ARSession, to url: URL) async throws {
// .mapped 以外での保存は、復元成功率が体感で半分以下に落ちる
guard let frame = session.currentFrame,
frame.worldMappingStatus == .mapped else {
throw MapError.notReadyYet
}
let map = try await withCheckedThrowingContinuation { cont in
session.getCurrentWorldMap { map, error in
if let map { cont.resume(returning: map) }
else { cont.resume(throwing: error ?? MapError.unknown) }
}
}
let data = try NSKeyedArchiver.archivedData(
withRootObject: map, requiringSecureCoding: true)
// 特徴点群が本体。1LDK 全体で 4〜11 MB になりました
try data.write(to: url, options: [.atomic, .completeFileProtection])
}
.completeFileProtection を付けている点には理由があります。地図は室内の空間形状そのもので、写真ほど分かりやすくないだけで、生活空間の情報に他なりません。端末ロック中に読み出せない場所へ置くのが素直だと考えています。
復元とリローカライズ — 待たせ方が体験を決める
復元は initialWorldMap に渡すだけで始まります。ただし、渡した瞬間に配置が戻るわけではありません。ARKit は今見えている景色と地図を照合し、一致点が見つかって初めて座標系を接続します。この照合中、セッションは .limited(.relocalizing) に入ります。
func restore(from url: URL, into session: ARSession) throws {
let data = try Data(contentsOf: url)
guard let map = try NSKeyedUnarchiver.unarchivedObject(
ofClass: ARWorldMap.self, from: data) else {
throw MapError.decodeFailed
}
let config = ARWorldTrackingConfiguration()
config.planeDetection = [.horizontal]
config.initialWorldMap = map
// 既存アンカーが二重に増えるため resetTracking と併用しない
session.run(config, options: [.removeExistingAnchors])
}
// リローカライズの進行はデリゲートで受け取る
func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) {
switch camera.trackingState {
case .limited(.relocalizing):
statusText = "前回の場所を探しています。置いた辺りにカメラを向けてください"
case .normal:
statusText = nil // ここで初めて配置が画面に戻る
relocalizeDeadline?.cancel()
default:
break
}
}
.removeExistingAnchors を外すと、復元済みのアンカーに前回分が重なり、家具が二重に並びます。生成されたコードでは resetTracking が既定で入っていることが多く、そのままだと地図を渡した意味がなくなります。ここは私が毎回手で確認している箇所です。
そして、モデルの再生成は復元後のアンカー追加通知で行います。
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
guard let furniture = anchor as? FurnitureAnchor else { return }
// 地図はモデルを持たない。catalogID から自分で組み直す
node.addChildNode(FurnitureLibrary.makeNode(id: furniture.catalogID))
}
成立しないときの逃げ道を先に用意する
ここが、公式の解説に最も書かれていない部分です。リローカライズは、必ず成功する処理ではありません。
私が実機で確かめた範囲では、次の 3 条件で失敗が目立ちました。
- 照明が変わったとき(昼に保存した地図を、夜のシーリングライトで復元)
- 保存時と違う方向から入室したとき(特徴点が裏側から見えていない)
- 家具の配置を大きく動かした後(部屋の見た目が別物になっている)
いずれも、本番環境ではユーザーから「アプリが固まった」ようにしか見えません。無言で待たせる実装は、そのまま離脱に直結します。私は締め切りを設ける方式を採っています。
// 30 秒で見切る。成功する場面は 5〜15 秒で戻ることがほとんどでした
relocalizeDeadline = Task {
try? await Task.sleep(for: .seconds(30))
guard !Task.isCancelled else { return }
await MainActor.run { self.offerManualRecovery() }
}
@MainActor
func offerManualRecovery() {
// 選択肢を出す。黙って待たせ続けない
// 1. もう一度探す(もとの位置に戻ってもらう案内つき)
// 2. 今いる場所を基準に、前回の相対配置ごと置き直す
// 3. 最初からやり直す
presentRecoverySheet()
}
2 番目の選択肢が実用上いちばん効きます。アンカー同士の相対位置は地図が無くても保持できるため、テーブルと椅子の位置関係だけを再現し、全体を今の床に置き直せます。完全な復元ではありませんが、ゼロからの再配置よりはるかに短い操作で済みます。
容量と、保存する前に決めておくこと
地図は小さくありません。私が測った範囲では、机まわりだけで 1.8 MB、1LDK 全体を歩き回ると 11 MB を超えました。部屋ごとに保存する設計にすると、数十 MB が端末に積み上がります。
そこで、保存前に 3 つを決めておくことをお勧めします。
- 保存単位 — 部屋ごとに 1 ファイル。全体を 1 枚の巨大な地図にすると、更新のたびに全書き換えになります。
- 上限と破棄 — 私は直近 5 部屋までとし、それ以前は最終利用日で捨てています。
- App Store の申請文言 — 地図は空間の形状情報です。カメラ利用目的の説明に、記録が端末内に残ることと、その用途を明記します。ここを曖昧にしたまま出すと、審査で説明を求められます。
iCloud へ同期したくなる場面もあるはずですが、私は既定では端末内に留める判断をしています。地図が端末を渡ると、部屋の形が端末を渡ることになるためです。共有が価値になるアプリだけ、明示的な同意とセットで解禁するのが順序だと考えています。
最初に作る最小構成
全部を一度に組むと、どこで失敗しているか分からなくなります。私が勧める順序は次の通りです。
- アンカーを 1 個だけ置き、
mappingStatus を画面に出す。移動と状態変化の関係を体感する。
.mapped になったら保存し、ファイルサイズをログに出す。数 MB 出れば成功しています。
- アプリを完全に終了し、復元する。
.limited(.relocalizing) から .normal に変わるまでの秒数を測る。
- カスタムアンカーとモデル再生成を足す。ここで初めて「何を」が戻ります。
- 締め切りと逃げ道を足す。
この順で 1 段ずつ確かめると、失敗が起きた層がすぐ分かります。とくに 3 番を先に測っておくと、後で締め切りを何秒にするかを勘で決めずに済みます。
ネイティブ生成でどこまで届き、どこから手が要るかの全体像は Rork Max で届かない領域はどこか — ネイティブ生成の現実的な線引き に整理しています。空間を測る側の設計は Rork Max の RoomPlan で部屋を測るアプリを作る — スキャン精度のばらつきを設計で吸収する が近い話です。生成コードそのものの磨き方は Rork Max が生成した SwiftUI コードを本番品質まで磨き上げる10のリファクタリング・パターン を参照してください。
まずは手順 1 だけを、今日の Rork Max のセッションで足してみてください。mappingStatus が画面に出た瞬間、AR が何を見て何を覚えていないのかが、言葉の説明よりずっと速く腑に落ちます。私自身、この一行を出すまで数日を回り道しました。同じ回り道を省いていただけたら嬉しいです。