PWAやネイティブアプリでAR機能を作るとき、「現在地から一定半径内のコンテンツだけを取得する」という空間検索は避けて通れない課題です。RDBでやろうとすると緯度経度の計算が複雑になりますが、PostGISを使えばシンプルなSQL一発で解決できます。
この記事では、CityCanvasという位置情報ARプラットフォームの実装を題材に、以下の流れを手を動かしながら解説します。
- Supabase上でPostGIS拡張を有効にし、空間インデックス付きテーブルを設計する
- 半径検索のRPC関数を作成する
- 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 !==