🕌

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 の組み合わせで、以下を実現しました:

  1. 壁検出: 2段階Raycast + サイズ検証で安定した面検出
  2. 正確な配置: 法線ベースの姿勢計算で壁・床・天井に対応
  3. 永続化: Cloud Anchors (1-5cm) → Geospatial VPS → GPS の3段階フォールバック
  4. 同期: Supabaseで空間フィルタリング付きのクロスデバイス共有
  5. 環境判定: 複数センサー融合で屋内外を自動判定

ARアプリで「永続的な空間体験」を作るのはまだまだ課題が多いですが、Cloud Anchorsの進化とともに精度は向上し続けています。

SLAP@はこれからも「街をキャンバスに」する体験を追求していきます。


SLAP@の最新情報は X (@slap_at_app) で発信しています。

Discussion