はじめに
CityCanvas(シティキャンバス)は「街全体をキャンバスにする」ソーシャルARプラットフォームです。GPS座標にARコンテンツを紐付け、スマートフォンのカメラ越しに他ユーザーの3Dオブジェクトやテキストを発見できるUGCアプリです。
このプロジェクトを実装していく中で、技術選定の局面が何度もありました。特に「ARをWebで動かすか、ネイティブにするか」「地理検索をどのレイヤーで実装するか」「3Dオブジェクト生成をどこに置くか」という3つの判断は、アーキテクチャ全体に影響しました。
本記事では、それらの設計判断とその背景にあるトレードオフを、実際のコードを交えながら解説します。
全体アーキテクチャの概観
[PWA / Capacitor Shell]
│
├── Three.js (ARビュー・3Dレンダリング)
├── Mapbox GL JS (マップビュー)
├── Meshy AI API (テキスト→3D生成)
│
└── Supabase (BaaS)
├── PostgreSQL + PostGIS (空間検索)
├── Auth
└── Storage (3Dアセット)
シンプルに見えますが、各レイヤーの選択に至るまでにいくつかの選択肢を潰しています。
判断1: PWA + Capacitorの二刀流を選んだ理由
最初に検討した選択肢
ARアプリをどのプラットフォームで提供するかは最初の大きな判断でした。
- React Native + ViroReact: ARに特化したネイティブARフレームワーク。ARCoreとARKitに直接アクセスできる。ただしWeb版を別途作る必要がある
- Unity + AR Foundation: AR品質は最高。ただしWeb展開が現実的でなく、ストアのみの配布になる
- PWA単体: リーチが最大で、インストール不要。ただしカメラアクセスやネイティブAPIに制約がある
- PWA + Capacitor: Webコードを1セットで維持しながら、ネイティブラッパーで不足するAPIを補う
CityCanvasが最終的にPWA + Capacitorを選んだのは、ユーザー獲得コストとコンテンツ発見体験のバランスが理由です。
ARコンテンツを投稿する側(クリエイター)はアプリをインストールしても動機がありますが、街中でコンテンツを偶然発見する側(ディスカバラー)に「まずストアでインストールして」というフリクションを置くのは致命的だと判断しました。PWAなら「このQRコードから見れるよ」という口コミが成立します。
一方でネイティブ機能(デバイスモーション、カメラのフルコントロール)が必要な部分はCapacitorのプラグインで補います。
// capacitor.config.ts
import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.citycanvas.app',
appName: 'CityCanvas',
webDir: 'dist',
plugins: {
// デバイスモーションへのアクセスをネイティブで許可
Motion: {
enabled: true,
},
Geolocation: {
// 高精度GPS(バッテリー消費大だが、AR配置精度のため許容)
enableHighAccuracy: true,
},
},
ios: {
// ARKit利用のためWKWebViewでカメラアクセスを許可
allowsLinkPreview: false,
scrollEnabled: false,
},
};
export default config;
CapacitorはWebViewラッパーなので、Three.jsのWebGLコンテキストはそのまま動きます。「ネイティブARエンジンを使えない」というトレードオフは受け入れつつ、WebXR Device APIとデバイスセンサーを組み合わせた擬似ARで十分な体験を作れると判断しました。
判断2: 地理検索をPostGISに寄せた理由
アプリケーション層でやれないのか
「現在地から半径300m以内のARコンテンツを取得する」という要件は、一見アプリ側でフィルタリングしても実装できます。全件取得してJavaScriptで距離計算する方法です。
実際に最初のプロトタイプはそうしていました。コンテンツ数が少ないうちは動いていましたが、以下の問題が出てきます。
- 全件fetchの転送量が増加し続ける
- 境界ボックスすら絞れないため、地球の裏側のコンテンツも取得してしまう
- ページネーションと空間フィルタリングを組み合わせると実装が複雑化する
PostGISを使うとこれが1クエリで済みます。SupabaseはPostgreSQLをベースにしているため、PostGIS拡張を有効にするだけで使えます。
-- PostGIS拡張の有効化(Supabaseダッシュボードで実行)
CREATE EXTENSION IF NOT EXISTS postgis;
-- コンテンツテーブルの作成
CREATE TABLE ar_contents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id),
title TEXT NOT NULL,
content_type TEXT CHECK (content_type IN ('3d_object', 'text', 'photo')),
model_url TEXT,
-- 位置情報をgeography型で保持(度単位ではなくメートル単位で距離計算できる)
location GEOGRAPHY(POINT, 4326) NOT NULL,
altitude FLOAT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 空間インデックスを必ず貼る(これがないと全件スキャンになる)
CREATE INDEX ar_contents_location_idx
ON ar_contents USING GIST(location);
クライアント側からの検索クエリはRPCとして定義します。
-- 半径検索のRPC関数
CREATE OR REPLACE FUNCTION get_nearby_contents(
lat FLOAT,
lng FLOAT,
radius_meters FLOAT DEFAULT 300
)
RETURNS TABLE (
id UUID,
title TEXT,
content_type TEXT,
model_url TEXT,
latitude FLOAT,
longitude FLOAT,
altitude FLOAT,
distance_meters FLOAT,
user_id UUID
)
LANGUAGE plpgsql
AS $$
BEGIN
RETURN QUERY
SELECT
ac.id,
ac.title,
ac.content_type,
ac.model_url,
ST_Y(ac.location::geometry) AS latitude,
ST_X(ac.location::geometry) AS longitude,
ac.altitude,
ST_Distance(
ac.location,
ST_MakePoint(lng, lat)::geography
) AS distance_meters,
ac.user_id
FROM ar_contents ac
WHERE ST_DWithin(
ac.location,
ST_MakePoint(lng, lat)::geography,
radius_meters
)
ORDER BY distance_meters ASC
LIMIT 50;
END;
$$;
JavaScript側からはSupabaseクライアントで呼び出すだけです。
// src/services/contentService.js
import { supabase } from './supabaseClient';
export async function fetchNearbyContents(lat, lng, radiusMeters = 300) {
const { data, error } = await supabase.rpc('get_nearby_contents', {
lat,
lng,
radius_meters: radiusMeters,
});
if (error) {
console.error('Nearby contents fetch failed:', error);
throw error;
}
// Three.jsのシーンで使いやすい形に変換
return data.map((item) => ({
...item,
// GPS座標をARシーン内のローカル座標に変換するため距離を保持
distanceMeters: item.distance_meters,
}));
}
ST_DWithinはgeography型の場合メートル単位で動作するため、「半径300m」という仕様を直感的に表現できます。また空間インデックス(GIST)があるため、コンテンツが増えても境界ボックスで絞り込んでからの詳細検索になり、パフォーマンスが安定します。
「アプリ側でフィルタリング」との比較でPostGISを選んだ最大の理由は「インデックスが効く」という一点です。 アプリ層でいくら最適化しても、全件転送というボトルネックは消えません。
判断3: Meshy AIをバックエンドを介さずフロントから叩く設計
テキスト→3D生成の配置問題
CityCanvasの特徴機能のひとつが「テキストから3Dオブジェクトを生成してARで配置する」というフローです。Meshy AIはこのテキスト→3D生成を提供するAPIです。
設計の選択肢は2つありました。
- フロントエンド → Supabase Edge Functions → Meshy AI: APIキーをサーバー側に隠せる。ユーザーごとの利用量制限を実装しやすい
- フロントエンド → Meshy AI(直接): 実装がシンプル。Edge Functionsのコールドスタートを経由しない
最終的にEdge Functionsを経由する構成を選びました。理由はAPIキーの保護と生成リクエストのレート制限です。
// supabase/functions/generate-3d-model/index.ts
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const MESHY_API_KEY = Deno.env.get('MESHY_API_KEY')!;
const DAILY_LIMIT_PER_USER = 3; // 無料枠の管理
serve(async (req) => {
if (req.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
// JWTからユーザーを認証
const authHeader = req.headers.get('Authorization');
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{ global: { headers: { Authorization: authHeader! } } }
);
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return new Response('Unauthorized', { status: 401 });
}
// 当日の生成回数チェック(Supabaseのテーブルで管理)
const today = new Date().toISOString().split('T')[0];
const { count } = await supabase
.from('generation_logs')
.select('*', { count: 'exact' })
.eq('user_id', user.id)
.gte('created_at', `${today}T00:00:00`);
if ((count ?? 0) >= DAILY_LIMIT_PER_USER) {
return new Response(
JSON.stringify({ error: 'Daily generation limit reached' }),
{ status: 429, headers: { 'Content-Type': 'application/json' } }
);
}
const { prompt } = await req.json();
// Meshy AI APIへリクエスト
const meshyResponse = await fetch('https://api.meshy.ai/v2/text-to-3d', {
method: 'POST',
headers: {
'Authorization': `Bearer ${MESHY_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
mode: 'preview',
prompt,
art_style: 'realistic',
negative_prompt: 'low quality, low resolution',
}),
});
const meshyData = await meshyResponse.json();
// 生成ログを記録
await supabase.from('generation_logs').insert({
user_id: user.id,
task_id: meshyData.result,
prompt,
});
return new Response(JSON.stringify({ taskId: meshyData.result }), {
headers: { 'Content-Type': 'application/json' },
});
});
Meshy AIの生成は非同期(タスクIDを受け取り、後でポーリングして結果を取得するフロー)です。そのためフロントエンド側ではポーリングロジックが必要になります。
// src/services/meshyService.js
export async function pollModelStatus(taskId, onProgress) {
const POLL_INTERVAL_MS = 3000;
const MAX_ATTEMPTS = 40; // 最大2分待機
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
const { data } = await supabase.functions.invoke('check-model-status', {
body: { taskId },
});
onProgress(data.progress ?? 0);
if (data.status === 'SUCCEEDED') {
return {
modelUrl: data.model_urls.glb,
thumbnailUrl: data.thumbnail_url,
};
}
if (data.status === 'FAILED') {
throw new Error(`Model generation failed: ${data.task_error?.message}`);
}
}
throw new Error('Model generation timed out');
}
このポーリング処理をフロントに置いたのは意図的な選択です。WebSocketやServer-Sent Eventsでサーバーからプッシュする方法も検討しましたが、Supabase Edge Functionsの実行時間制限(