0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Supabase+PostGISで半径検索APIを構築し、Three.jsのARビューにリアルタイム反映する実装手順

0
Posted at

PWAやネイティブアプリでAR機能を作るとき、「現在地から一定半径内のコンテンツだけを取得する」という空間検索は避けて通れない課題です。RDBでやろうとすると緯度経度の計算が複雑になりますが、PostGISを使えばシンプルなSQL一発で解決できます。

この記事では、CityCanvasという位置情報ARプラットフォームの実装を題材に、以下の流れを手を動かしながら解説します。

  1. Supabase上でPostGIS拡張を有効にし、空間インデックス付きテーブルを設計する
  2. 半径検索のRPC関数を作成する
  3. Three.jsのレンダリングループに接続し、カメラの向きに応じてARオブジェクトを描画する

環境・前提条件

  • Node.js 20以上
  • Supabaseプロジェクト(無料枠でOK)
  • Three.js r160以降
  • ブラウザのGeolocation APIが使える環境(httpsまたはlocalhost)

1. SupabaseでPostGIS拡張を有効にする

SupabaseはデフォルトでPostGISが使えるようになっています。プロジェクトのSQL Editorを開いて次を実行します。

-- PostGIS拡張を有効化(既に有効な場合はエラーにならない)
CREATE EXTENSION IF NOT EXISTS postgis;

-- ARコンテンツを保存するテーブル
CREATE TABLE ar_contents (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  title       TEXT NOT NULL,
  content_type TEXT NOT NULL CHECK (content_type IN ('3d_object', 'text', 'photo')),
  content_url TEXT,                    -- Supabase StorageのURL
  model_data  JSONB,                   -- Three.jsに渡すメタデータ
  location    GEOGRAPHY(POINT, 4326),  -- PostGIS地理型(WGS84)
  altitude    FLOAT DEFAULT 0,         -- 地面からの高さ(メートル)
  created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- 空間インデックスを作成(検索速度が数十倍変わる)
CREATE INDEX ar_contents_location_idx
  ON ar_contents USING GIST (location);

GEOGRAPHY(POINT, 4326) を使う理由は、GEOMETRY 型だと距離計算が度数単位になってしまうためです。GEOGRAPHY 型はメートル単位で計算してくれるので、「半径500m以内」のような検索が直感的に書けます。


2. 半径検索のRPC関数を作成する

SQL Editorで続けて次の関数を定義します。

-- 現在地から指定半径内のARコンテンツを取得するRPC関数
CREATE OR REPLACE FUNCTION get_nearby_contents(
  lat        FLOAT,
  lng        FLOAT,
  radius_m   FLOAT DEFAULT 500  -- デフォルト500m
)
RETURNS TABLE (
  id           UUID,
  title        TEXT,
  content_type TEXT,
  content_url  TEXT,
  model_data   JSONB,
  latitude     FLOAT,
  longitude    FLOAT,
  altitude     FLOAT,
  distance_m   FLOAT
)
LANGUAGE sql
STABLE
AS $$
  SELECT
    id,
    title,
    content_type,
    content_url,
    model_data,
    ST_Y(location::geometry) AS latitude,
    ST_X(location::geometry) AS longitude,
    altitude,
    ST_Distance(
      location,
      ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography
    ) AS distance_m
  FROM ar_contents
  WHERE ST_DWithin(
    location,
    ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography,
    radius_m
  )
  ORDER BY distance_m ASC;
$$;

ST_DWithin が空間インデックスを活用してくれる核心部分です。緯度経度のBOX検索のような前処理なしで、インデックスが効いた状態で真の球面距離計算が走ります。

RLSポリシーも忘れずに設定します。

-- RLSを有効化
ALTER TABLE ar_contents ENABLE ROW LEVEL SECURITY;

-- 全員が読める(ARはパブリックコンテンツとして設計)
CREATE POLICY "Anyone can read ar_contents"
  ON ar_contents FOR SELECT
  USING (true);

-- 本人だけが投稿・更新・削除できる
CREATE POLICY "Users can manage own contents"
  ON ar_contents FOR ALL
  USING (auth.uid() = user_id);

3. JavaScriptからRPC関数を呼び出す

フロントエンド側でSupabaseクライアントを設定し、先ほどの関数を呼び出します。

// src/lib/supabase.js
import { createClient } from '@supabase/supabase-js'

export const supabase = createClient(
  import.meta.env.VITE_SUPABASE_URL,
  import.meta.env.VITE_SUPABASE_ANON_KEY
)

/**
 * 現在地から指定半径内のARコンテンツを取得する
 * @param {GeolocationCoordinates} coords - ブラウザのGeolocation API座標オブジェクト
 * @param {number} radiusM - 検索半径(メートル)
 * @returns {Promise<Array>} - コンテンツの配列
 */
export async function fetchNearbyContents(coords, radiusM = 500) {
  const { data, error } = await supabase.rpc('get_nearby_contents', {
    lat: coords.latitude,
    lng: coords.longitude,
    radius_m: radiusM,
  })

  if (error) {
    console.error('fetchNearbyContents error:', error.message)
    throw error
  }

  return data ?? []
}

/**
 * Geolocation APIをPromiseでラップする
 * @returns {Promise<GeolocationCoordinates>}
 */
export function getCurrentPosition() {
  return new Promise((resolve, reject) => {
    if (!navigator.geolocation) {
      reject(new Error('Geolocation is not supported'))
      return
    }
    navigator.geolocation.getCurrentPosition(
      (pos) => resolve(pos.coords),
      (err) => reject(err),
      { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
    )
  })
}

enableHighAccuracy: true を指定する理由は、ARアプリでは数メートル単位の精度が必要なためです。電力消費は増えますが、精度なしのARは使い物になりません。


4. Three.jsでARビューを構築する

ここからが本題です。カメラ映像を背景にし、取得したコンテンツをワールド座標に配置します。

// src/ar/ARScene.js
import * as THREE from 'three'
import { fetchNearbyContents, getCurrentPosition } from '../lib/supabase.js'

/**
 * GPS座標をThree.jsのローカル座標(メートル)に変換する
 * 基準点(originCoords)からの相対距離をXZ平面にマッピングする
 */
function gpsToLocal(targetLat, targetLng, originLat, originLng) {
  const R = 6371000 // 地球の半径(メートル)
  const dLat = ((targetLat - originLat) * Math.PI) / 180
  const dLng = ((targetLng - originLng) * Math.PI) / 180
  const avgLat = ((originLat + targetLat) / 2) * (Math.PI / 180)

  // X: 東西方向、Z: 南北方向(Three.jsはZ軸が手前なので符号に注意)
  const x = R * dLng * Math.cos(avgLat)
  const z = -R * dLat

  return { x, z }
}

export class ARScene {
  constructor(containerEl) {
    this.container = containerEl
    this.scene = new THREE.Scene()
    this.camera = new THREE.PerspectiveCamera(
      70,
      window.innerWidth / window.innerHeight,
      0.1,
      2000
    )
    this.renderer = new THREE.WebGLRenderer({
      antialias: true,
      alpha: true, // 背景を透過してカメラ映像と合成する
    })
    this.renderer.setSize(window.innerWidth, window.innerHeight)
    this.renderer.setPixelRatio(window.devicePixelRatio)
    this.container.appendChild(this.renderer.domElement)

    // カメラ映像用のVideoテクスチャを背景に設定
    this._setupCameraBackground()

    // 環境光 + 平行光源
    this.scene.add(new THREE.AmbientLight(0xffffff, 0.6))
    const dirLight = new THREE.DirectionalLight(0x00ffff, 1.2) // サイバーパンク風シアン
    dirLight.position.set(5, 10, 5)
    this.scene.add(dirLight)

    this.contentObjects = new Map() // id -> THREE.Object3D
    this.originCoords = null

    window.addEventListener('resize', this._onResize.bind(this))
  }

  async _setupCameraBackground() {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({
        video: { facingMode: 'environment', width: { ideal: 1280 } },
      })
      const video = document.createElement('video')
      video.srcObject = stream
      video.setAttribute('playsinline', '') // iOSで必須
      await video.play()

      const texture = new THREE.VideoTexture(video)
      this.scene.background = texture
    } catch (err) {
      console.warn('カメラアクセス失敗、背景なしで続行:', err.message)
    }
  }

  /** 近隣コンテンツをロードしてシーンに追加する */
  async loadNearbyContents() {
    const coords = await getCurrentPosition()
    this.originCoords = coords

    const contents = await fetchNearbyContents(coords, 300)
    console.log(`${contents.length}件のARコンテンツを取得`)

    for (const item of contents) {
      this._addContentToScene(item)
    }
  }

  _addContentToScene(item) {
    if (this.contentObjects.has(item.id)) return // 重複追加防止

    const { x, z } = gpsToLocal(
      item.latitude,
      item.longitude,
      this.originCoords.latitude,
      this.originCoords.longitude
    )

    let mesh

    if (item.content_type === 'text') {
      // テキストコンテンツはスプライトで表示
      const canvas = document.createElement('canvas')
      canvas.width = 512
      canvas.height = 128
      const ctx = canvas.getContext('2d')
      ctx.fillStyle = 'rgba(0, 255, 255, 0.15)'
      ctx.fillRect(0, 0, 512, 128)
      ctx.font = 'bold 36px monospace'
      ctx.fillStyle = '#00ffff'
      ctx.textAlign = 'center'
      ctx.fillText(item.title, 256, 72)

      const texture = new THREE.CanvasTexture(canvas)
      const spriteMaterial = new THREE.SpriteMaterial({ map: texture })
      mesh = new THREE.Sprite(spriteMaterial)
      mesh.scale.set(4, 1, 1)
    } else {
      // 3DオブジェクトはボックスでPH(実際はGLTFLoaderで読み込む)
      const geometry = new THREE.BoxGeometry(1, 1, 1)
      const material = new THREE.MeshStandardMaterial({
        color: 0x00ffff,
        transparent: true,
        opacity: 0.8,
        wireframe: item.content_type === '3d_object',
      })
      mesh = new THREE.Mesh(geometry, material)
    }

    mesh.position.set(x, item.altitude || 1.5, z)
    mesh.userData = { id: item.id, title: item.title }

    this.scene.add(mesh)
    this.contentObjects.set(item.id, mesh)
  }

  /** デバイスオリエンテーションAPIでカメラ姿勢を更新する */
  updateCameraOrientation(alpha, beta, gamma) {
    // alpha: Z軸回転(コンパス方位)、beta: X軸回転(傾き前後)
    const euler = new THREE.Euler(
      THREE.MathUtils.degToRad(beta),
      THREE.MathUtils.degToRad(alpha),
      THREE.MathUtils.degToRad(-gamma),
      'YXZ'
    )
    this.camera.quaternion.setFromEuler(euler)
  }

  animate() {
    requestAnimationFrame(this.animate.bind(this))

    // スプライトを常にカメラに向ける処理は不要(Spriteクラスが自動でやる)
    // 3Dオブジェクトをゆっくり回転させてAR感を出す
    this.contentObjects.forEach((obj) => {
      if (obj.isMesh) obj.rotation.y += 0.005
    })

    this.renderer.render(this.scene, this.camera)
  }

  _onResize() {
    this.camera.aspect = window.innerWidth / window.innerHeight
    this.camera.updateProjectionMatrix()
    this.renderer.setSize(window.innerWidth, window.innerHeight)
  }
}

5. エントリーポイントで組み合わせる

// src/main.js
import { ARScene } from './ar/ARScene.js'

const container = document.getElementById('ar-container')
const arScene = new ARScene(container)

// デバイスオリエンテーションのパーミッション要求(iOS 13+で必要)
document.getElementById('start-btn').addEventListener('click', async () => {
  // iOSではユーザージェスチャー起点でパーミッションを要求する必要がある
  if (typeof DeviceOrientationEvent.requestPermission === 'function') {
    const permission = await DeviceOrientationEvent.requestPermission()
    if (permission !== 
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?