CityCanvas(街全体をARキャンバスにするソーシャルプラットフォーム)を開発する過程で、Three.js・Supabase+PostGIS・Meshy AI・Capacitorを組み合わせた実装でいくつかの深刻なハマりポイントに遭遇しました。同じ構成で開発している方が検索で辿り着けるよう、エラーメッセージ・原因・解決コードをセットで残しておきます。
構成の概要
Three.js(3Dレンダリング)
Mapbox GL JS(地図レイヤー)
Supabase + PostGIS(空間クエリ)
Meshy AI(テキスト→3D生成)
Capacitor(ネイティブアプリ化)
PWA対応
デバイスのGPS座標を取得 → PostGISで半径N m以内のARコンテンツを検索 → Three.jsで3Dオブジェクトをオーバーレイ表示する、というパイプラインです。
ハマりポイント1: Three.jsのレンダラーとカメラ映像の合成でz-fightingが発生した
症状
カメラプレビューの上にThree.jsのcanvasを重ねたとき、3Dオブジェクトが地面に埋まったり、チラついたりする現象が発生。特にMapbox GL JSのタイルと3Dオブジェクトの境界でひどかった。
原因
WebGLRendererのデフォルト設定ではlogarithmicDepthBufferがfalseのため、遠景と近景のdepth精度が均一になってしまいます。地図タイルのような「ほぼ平面なジオメトリ」と3Dオブジェクトが同一深度にいると深度バッファの精度不足でz-fightingが起きます。
解決コード
const renderer = new THREE.WebGLRenderer({
canvas: document.getElementById('ar-canvas'),
alpha: true, // カメラ映像を透過させる
antialias: true,
logarithmicDepthBuffer: true, // ← これが重要
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setClearColor(0x000000, 0); // 背景を完全透明に
// カメラのnear/farも意図的に広げる
const camera = new THREE.PerspectiveCamera(
70,
window.innerWidth / window.innerHeight,
0.01, // nearを小さく
10000 // farを大きく(地図スケールに対応)
);
logarithmicDepthBuffer: trueにすると深度バッファが対数スケールになり、near〜farの範囲全体で精度が均等化されます。ただしシェーダー内でgl_FragDepthを直接書いているカスタムシェーダーがあると壊れるので注意。
ハマりポイント2: PostGISのST_DWithinで距離計算がおかしい
症状
「半径500m以内のコンテンツを取得」するクエリを書いたが、実際には数km離れたコンテンツまで返ってくる。
エラーの原因コード
-- ❌ これはダメ(geography型ではなくgeometry型のまま度数で比較している)
SELECT * FROM ar_contents
WHERE ST_DWithin(location, ST_MakePoint(139.7, 35.6), 500);
ST_MakePointで作った点はデフォルトでgeometry型(単位: 度)になります。ST_DWithinの第3引数もその単位(度)で解釈されるため、500は「500度」という意味になってしまいます。
解決コード
-- ✅ geography型にキャストして距離をメートル単位にする
SELECT
id,
title,
content_type,
ST_AsGeoJSON(location) AS geojson,
ST_Distance(
location::geography,
ST_MakePoint(139.7, 35.6)::geography
) AS distance_meters
FROM ar_contents
WHERE ST_DWithin(
location::geography,
ST_MakePoint(139.7, 35.6)::geography,
500 -- geography型なのでメートル単位
)
ORDER BY distance_meters ASC;
::geographyでキャストするだけで地球の曲率を考慮したメートル単位の計算になります。Supabaseのテーブル定義でもlocationカラムは最初からgeography(POINT, 4326)型にしておくのがベストです。
-- テーブル定義例
CREATE TABLE ar_contents (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id),
title TEXT NOT NULL,
content_type TEXT CHECK (content_type IN ('3d_object', 'text', 'photo')),
location GEOGRAPHY(POINT, 4326), -- ← geography型で定義
model_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- インデックスも忘れずに
CREATE INDEX ar_contents_location_idx
ON ar_contents USING GIST(location);
ハマりポイント3: Meshy AIで生成した3DモデルのGLTFがThree.jsで正しく読み込めない
症状
Meshy AIが返すGLTFファイルをそのままThree.jsのGLTFLoaderで読み込むと、マテリアルが真っ黒になるか、テクスチャが表示されない。
原因
Meshy AIが生成するGLTFのテクスチャパスが相対パスで、かつ独自のCDN URL形式で返ってくるケースがあります。また、KHR_materials_unlit拡張を使っているモデルがあり、Three.jsの標準ライティングと干渉します。
解決コード
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
const setupLoader = () => {
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/'); // Dracoデコーダーのパス
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);
return loader;
};
const loadMeshyModel = async (modelUrl, scene) => {
const loader = setupLoader();
return new Promise((resolve, reject) => {
loader.load(
modelUrl,
(gltf) => {
const model = gltf.scene;
// マテリアルを走査してテクスチャ問題を修正
model.traverse((child) => {
if (child.isMesh) {
const mat = child.material;
// MeshBasicMaterialへの強制変換(ライティング不要モデル用)
if (mat.type === 'MeshBasicMaterial') {
mat.side = THREE.DoubleSide;
} else {
// 通常モデルはMeshStandardMaterialに統一
const newMat = new THREE.MeshStandardMaterial({
map: mat.map || null,
color: mat.color || new THREE.Color(0xffffff),
metalness: mat.metalness ?? 0.1,
roughness: mat.roughness ?? 0.8,
});
child.material = newMat;
}
// テクスチャのエンコーディング修正(Three.js r152以降はcolorSpaceで指定)
if (child.material.map) {
child.material.map.colorSpace = THREE.SRGBColorSpace;
child.material.needsUpdate = true;
}
}
});
// モデルサイズを正規化(Meshy AIは生成ごとにスケールが変わる)
const box = new THREE.Box3().setFromObject(model);
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z);
const scale = 1.5 / maxDim; // 最大辺が1.5mになるよう正規化
model.scale.setScalar(scale);
scene.add(model);
resolve(model);
},
undefined,
(error) => {
console.error('GLTFLoader error:', error);
reject(error);
}
);
});
};
バウンディングボックスでスケール正規化しているのは、Meshy AIが生成するモデルのスケールが毎回まちまちだからです。AR空間に配置するとき「現実の建物より大きい3Dコップ」みたいな事態を防げます。
ハマりポイント4: CapacitorでGeolocation APIがiOSのWKWebView内で動かない
症状
ブラウザ(Safari)ではnavigator.geolocation.watchPositionが動くのに、Capacitorでビルドしたアプリ内では常にエラーコード1(PERMISSION_DENIED)が返る。
原因
WKWebViewはHTTPSでないとnavigator.geolocationを許可しません。ローカル開発サーバーがHTTPの場合、capacitor://スキームと混在してブロックされます。さらにiOSではInfo.plistへの記述も必要です。
解決策
まず@capacitor/geolocationプラグインをネイティブAPIとして使います:
import { Geolocation } from '@capacitor/geolocation';
import { Capacitor } from '@capacitor/core';
const getLocation = async () => {
// Capacitor環境かどうかで分岐
if (Capacitor.isNativePlatform()) {
// ネイティブプラグインを使う
const permission = await Geolocation.requestPermissions();
if (permission.location !== 'granted') {
throw new Error('Location permission denied');
}
return new Promise((resolve, reject) => {
Geolocation.watchPosition(
{ enableHighAccuracy: true, timeout: 10000 },
(position, err) => {
if (err) { reject(err); return; }
resolve({
lat: position.coords.latitude,
lng: position.coords.longitude,
accuracy: position.coords.accuracy,
});
}
);
});
} else {
// ブラウザ(PWA)環境
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
(pos) => resolve({
lat: pos.coords.latitude,
lng: pos.coords.longitude,
accuracy: pos.coords.accuracy,
}),
reject,
{ enableHighAccuracy: true, timeout: 10000 }
);
});
}
};
ios/App/App/Info.plistには以下を追加:
<key>NSLocationWhenInUseUsageDescription</key>
<string>ARコンテンツを表示するために現在地が必要です</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>バックグラウンドでもARコンテンツの通知を受け取るために必要です</string>
ハマりポイント5: GPS座標からThree.jsのワールド座標への変換がズレる
症状
PostGISから取得したARコンテンツのGPS座標を3D空間に配置すると、実際の場所と数十mズレる。特に高緯度(北海道など)で顕著。
原因
GPS座標(経度・緯度)をそのままx・zに使うと、緯度1度≒111kmに対して経度1度は緯度によって変わる(赤道付近≒111km、北緯35度≒91km)ので、EW方向が歪みます。
解決コード
// ユーザーの現在地を原点としてメートル単位のローカル座標に変換
const gpsToWorldPosition = (targetLat, targetLng, originLat, originLng) => {
const EARTH_RADIUS = 6371000; // 地球半径 (m)
const DEG_TO_RAD = Math.PI / 180;
// 緯度差をメートルに変換
const deltaLat = (targetLat - originLat) * DEG_TO_RAD;
const deltaLng = (targetLng - originLng) * DEG_TO_RAD;
// 緯度によって経度1度あたりのメートル数が変わる
const avgLat = ((targetLat + originLat) / 2) * DEG_TO_RAD;
const x = EARTH_RADIUS * deltaLng * Math.cos(avgLat); // 東西方向
const z = EARTH_RADIUS * deltaLat; // 南北方向(Three.jsはz軸が手前)
return new THREE.Vector3(x, 0, -z); // Three.jsの座標系に合わせてzを反転
};
// 使用例
const userPos = { lat: 35.6812, lng: 139.7671 }; // ユーザーの現在地(原点)
const arContents = await fetchNearbyContents(userPos.lat, userPos.lng, 500);
arContents.forEach(content => {
const worldPos = gpsToWorldPosition(
content.lat,
content.lng,
userPos.lat,
userPos.lng
);
const mesh = createARObject(content); // オブジェクト生成
mesh.position.copy(worldPos);
mesh.position.y = content.altitude ?? 1.5; // 地上高さ(デフォルト1.5m)
scene.add(mesh);
});
中間緯度(avgLat)でcos補正を入れることで、どの地域でも正確にメートル換算できます。
まとめ
| ポイント | 根本原因 | キーワード |
|---|---|---|
| z-fighting | 深度バッファ精度不足 |