ARKit + Cloud Anchorsで「現実世界に残るステッカー」を作った話
はじめに
「街の壁にステッカーを貼りたい」——そんなシンプルな欲求から、ARステッカーアプリ SLAP@ の開発が始まりました。
SLAP@は、iPhoneのカメラを通して現実世界の壁や床にデジタルステッカーを貼れるARアプリです。ただ貼るだけでなく、他のユーザーが同じ場所に来たときにそのステッカーが見えるという永続性を実現しています。
この記事では、ARKit・Google Cloud Anchors・Supabaseを組み合わせて、どのようにクロスデバイスで永続するARステッカー体験を作ったかを解説します。
技術スタック
| 技術 | 用途 |
|---|---|
| Swift / SwiftUI | アプリ本体 |
| ARKit | 壁検出・空間認識 |
| RealityKit | 3Dステッカー描画 |
| Google ARCore Cloud Anchors | クロスデバイス位置共有 |
| ARGeoAnchor / Geospatial API | 屋外の高精度位置合わせ |
| Supabase | バックエンド(Auth・DB・Storage) |
1. 壁検出:2段階Raycast戦略
ステッカーを貼るには、まず「壁がどこにあるか」を知る必要があります。ARKitのRaycastを使いますが、単純な実装だと検出精度にムラが出ます。
SLAP@では2段階のフォールバック戦略を採用しています。
// 1st: 確定済みの平面ジオメトリ(最も高精度)
let results1 = arView.raycast(
from: center,
allowing: .existingPlaneGeometry,
alignment: .any
)
// 2nd: 推定平面(平面が確定する前でも動作)
let results2 = arView.raycast(
from: center,
allowing: .estimatedPlane,
alignment: .any
)
さらに、検出した平面のサイズを検証して、小さすぎる平面(幻覚)を除外しています。
// 屋外: 0.08m以上、屋内: 0.03m以上
let minExtent: Float = isLikelyIndoor ? 0.03 : 0.08
guard planeAnchor.extent.x >= minExtent
&& planeAnchor.extent.z >= minExtent else {
return false
}
2. ステッカー配置:法線ベースの姿勢計算
壁が検出できたら、ステッカーをその面に正しく貼り付ける必要があります。壁・床・天井のどれに貼るかで、ステッカーの向きを変える必要があるため、法線ベクトルから正規直交基底を構築しています。
let normal = SIMD3<Float>(
result.worldTransform.columns.2.x,
result.worldTransform.columns.2.y,
result.worldTransform.columns.2.z
)
// 壁なら world-up、床ならカメラの前方向を参照
let upReference: SIMD3<Float>
if abs(normal.y) > 0.5 {
// 水平面(床/天井)→ カメラ前方をXZ平面に射影
let camForward = /* カメラの前方ベクトル */
upReference = normalize(SIMD3(camForward.x, 0, camForward.z))
} else {
// 垂直面(壁)→ ワールドのY軸
upReference = SIMD3(0, 1, 0)
}
// 正規直交基底を構築
let zAxis = normalize(normal)
let xAxis = normalize(cross(upReference, zAxis))
let yAxis = cross(zAxis, xAxis)
3. Cloud Anchorsで永続化
SLAP@の核心は「貼ったステッカーが他のユーザーにも見える」ことです。これを実現するのがGoogle Cloud Anchorsです。
ホスティング(ステッカーを貼る側)
garSession.hostCloudAnchor(arAnchor, ttlDays: ttlDays) { cloudId, state in
if state == .success, let cloudId = cloudId {
// cloudIdをSupabaseに保存
PlacementSync.shared.updateCloudAnchorId(
placementId: id,
cloudAnchorId: cloudId
)
}
}
リゾルブ(ステッカーを見る側)
garSession.resolveCloudAnchor(cloudAnchorId) { anchor, state in
if state == .success, let anchor = anchor {
// anchor.transformでステッカーを配置
self.placeRestoredSticker(at: anchor.transform)
}
}
Cloud Anchorsの精度は1〜5cm。同じ場所の視覚的特徴をマッチングするため、テクスチャの豊富な壁ほど精度が高くなります。
4. 3段階フォールバック戦略
Cloud Anchorsは高精度ですが、常に使えるわけではありません。SLAP@では3段階のフォールバックで位置合わせを行います。
Cloud Anchors (1-5cm精度)
↓ 失敗時
Geospatial VPS (Google Street View位置合わせ)
↓ 失敗時
GPS + コンパス方位 (数m精度)
各段階の判定ロジック:
// 1. Cloud Anchorを試行
if let cloudAnchorId = placement.cloudAnchorId {
cloudAnchorManager.resolve(cloudAnchorId) { ... }
}
// 2. Geospatial VPSで位置合わせ
if cloudAnchorManager.isLocalized,
let transform = cloudAnchorManager.latestGeospatialTransform {
// VPS精度 < 10m なら使用
}
// 3. GPS座標 + heading で概算配置
if let lat = placement.latitude,
let lng = placement.longitude {
let heading = placement.heading ?? 0
// GPS座標からローカル座標に変換
}
5. Supabaseとの同期
配置データはSupabaseのPostgreSQLに保存します。
struct RemotePlacement: Codable {
let userId: String
let stickerName: String
let transformMatrix: [Double] // 4x4行列(列優先)
let cloudAnchorId: String?
let latitude: Double?
let longitude: Double?
let heading: Double? // コンパス方位
let surfaceType: String? // "wall" / "floor"
let referenceFrame: String // "local" / "geo"
let expiresAt: Date?
}
周辺のステッカーを取得する際は、GPS座標で空間フィルタリングしています。
// 半径radiusMeters以内のステッカーを取得
let latDelta = radiusMeters / 111_000.0
let lngDelta = radiusMeters / (111_000.0 * cos(lat * .pi / 180))
let query = supabase
.from("sticker_placements")
.select()
.eq("reference_frame", "geo")
.neq("user_id", currentUserId)
.gte("latitude", lat - latDelta)
.lte("latitude", lat + latDelta)
.gte("longitude", lng - lngDelta)
.lte("longitude", lng + lngDelta)
6. 屋内/屋外の自動判定
ステッカーの共有可否は環境に依存します。屋内ではCloud Anchorsの精度が下がるため、SLAP@では複数センサーの組み合わせで屋内外を判定しています。
- Vision Framework: シーン分類スコア
- ARCore Geospatial: VPSのローカライゼーション状態
- CoreMotion: 歩行検出・高速移動判定
- 気圧計: 相対高度(2F以上なら屋内)
- GPS精度: 20m未満 + 歩行中 → 屋外
var isLikelyIndoor: Bool {
if visionSceneScore > 0.4 { return false } // 屋外
if visionSceneScore < -0.3 { return true } // 屋内
if cloudAnchorManager.isLocalized { return false } // VPS成功→屋外
if ceilingDetected { return true } // 天井あり→屋内
// ...
}
ステッカーの寿命
永続するとはいえ、無限に残り続けるわけではありません。ステッカーには**寿命(Durability)**があります。
| 環境 | デフォルト寿命 |
|---|---|
| 屋内 | セッション限り |
| 屋外 | 3日間 |
ポイントやダイヤで延長可能(10日、1ヶ月、1年)。Cloud Anchorsの TTL とも連動させています。
まとめ
ARKit + Cloud Anchors + Supabase の組み合わせで、以下を実現しました:
- 壁検出: 2段階Raycast + サイズ検証で安定した面検出
- 正確な配置: 法線ベースの姿勢計算で壁・床・天井に対応
- 永続化: Cloud Anchors (1-5cm) → Geospatial VPS → GPS の3段階フォールバック
- 同期: Supabaseで空間フィルタリング付きのクロスデバイス共有
- 環境判定: 複数センサー融合で屋内外を自動判定
ARアプリで「永続的な空間体験」を作るのはまだまだ課題が多いですが、Cloud Anchorsの進化とともに精度は向上し続けています。
SLAP@はこれからも「街をキャンバスに」する体験を追求していきます。
SLAP@の最新情報は X (@slap_at_app) で発信しています。
Discussion