これは MIERUNE Advent Calendar 2025 の22日目の記事です。
昨日は @bordorayさんによる 猿でもわかる座標系 でした。
DEMOはこちら
はじめに
ストリートビューのような機能を実現したい、という話が出たとき、真っ先に思い浮かぶのはGoogle Maps APIや Mapillaryといった既存のサービスだと思います。
一方で、特定のサービスに強く依存しない形で実装できるのが理想だと、個人的には感じていました。データの作成や管理を含めて自分自身でコントロールできること、ライセンスや利用条件を気にせず拡張できることは、長期的に見て大きな価値があります。
本記事では、個人開発として、既存の API を前提にするのではなく、データ作成から表示までをすべて自前で行い、オープンソースのみを使ってストリートビュー相当の仕組みを構築した取り組みについて、簡単にまとめます。
データ作成
今回の試作ストリートビュー実装では、岐阜県立森林文化アカデミーの演習林(33ha)を舞台としています。この演習林内の歩道や林道を対象に、周辺の様子を可視化しました。
画像出典:森林文化アカデミーWebサイトより(https://www.forest.ac.jp/courses/forestry/acforest/)
撮影機材
全天球カメラ「RICOH THETA」
「RICOH THETA SC」というかなり古いモデルです。単体で撮影した場合は位置情報も角度情報も記録されない仕様です。
2周波RTK-GNSS測量機器(Drogger DG-PRO1RWS)
通常のGPS機器よりも高精度な位置情報を取得できる測量機器です。特に林内のように立木などの障害物が多く、マルチパスが発生しやすい環境では、一般的なGPSでは測位精度が大きく低下しがちのためこちらを使用しました。
2周波RTK-GNSS測量機器を携行しながら林内を歩き、GPSログを記録しつつ、約10メートル間隔で全天球カメラを用いて林内の歩道から撮影を行いました。その後、GPSログの記録時刻と全天球カメラの撮影時刻のデータを、カシミール3D という無料の GIS ソフトを用いて紐づけることで、位置情報付きの 360 度画像を作成します。
そして、この位置情報付き写真をQGISのImportPhotosプラグインで読み、撮影地点のノードデータを作成しました。
背景地図出典:国土地理院
このノードデータには、各ノードを一意に識別するnode_idと、そのノードに紐づく写真のファイル名を示す photo_idを格納しています。
ビューアでは、このphoto_idをもとに、対応する地点の写真を読み込む仕組みになっています。

QGISによる属性テーブル
実装
実装のすべてを一から詳細に解説すると分量が多くなってしまうため、要点となる部分を中心に説明します。細かな実装や処理の流れについては、実際のソースコードをご覧いただければと思います。
技術スタック
- Three.js: 360度写真の描画
- MapLibre GL JS: 地図連携
- SvelteKit: フロントエンドフレームワーク
- Vite: ビルドツール
反転ボックスによるスカイボックス手法
本実装ではBoxGeometryをBackSideでレンダリングする手法を採用しています。
ボックスの内側から外を見る形でレンダリングし、シェーダー内で正距円筒図法への変換を行うことで、球体と同等の見た目を実現しています。
// シーンの初期化
const skyBoxGeometry = new THREE.BoxGeometry(1000, 1000, 1000);
const skyBox = new THREE.Mesh(skyBoxGeometry, fadeShaderMaterial);
scene.add(skyBox);
import fs from './shaders/fragment.glsl?raw';
import vs from './shaders/vertex.glsl?raw';
// シェーダーマテリアルの設定
const fadeShaderMaterial = new THREE.ShaderMaterial({
side: THREE.BackSide, // 内側からレンダリング
uniforms: uniforms,
fragmentShader: fs,
vertexShader: vs,
transparent: false,
});
正距円筒図法(Equirectangular)のUVマッピング
正距円筒図法(Equirectangular Projection)で保存されている2D画像を3D空間で正しく表示するため、シェーダー内で3D方向ベクトルからUV座標を計算します。
const float PI = 3.14159265359;
const float INV_PI = 1.0 / PI;
const float INV_TWO_PI = 1.0 / (2.0 * PI);
// 3D方向ベクトルをエクイレクタングラーUV座標に変換する関数
vec2 directionToEquirectangularUV(vec3 dir) {
dir = normalize(dir);
// 水平角度(経度): atan2(x, z) → -π〜π → 0〜1
float u = atan(dir.x, dir.z) * INV_TWO_PI + 0.5;
// 垂直角度(緯度): asin(y) → -π/2〜π/2 → 0〜1
float v = asin(dir.y) * INV_PI + 0.5;
return vec2(u, v);
}
カメラ姿勢補正(回転行列による角度補正)
今回撮影に使用した「RICOH THETA SC」は角度補正の機能を持たないため、実際の撮影時にはカメラが完全に水平な状態で撮影されていません。そのため、傾きを補正する目的で、事前に計算した角度データを使用しています。角度データは、写真のファイル名をキーとした JSON 形式で管理しています。
回転角度データ(JSON)
{
"R0011134": {
"angle_x": 177.84,
"angle_y": 183.96,
"angle_z": 165.96
},
"R0010564": {
"angle_x": 353.71,
"angle_y": 11.92,
"angle_z": 6.97
},
"R0010202": {
"angle_x": 12.24,
"angle_y": 218.52,
"angle_z": 12.24
},
// ... (他にも多数のエントリが続く)
}
ちなみに、この事前計算した角度情報は、デバッグ用のツールを自作し、各写真を一枚ずつ目視で確認しながら補正しました。
Y 軸回転にあたる方位については、撮影地点を地図と照らし合わせながら、道に対して山側か谷側かといった周辺地形の関係や、現地での記憶を手がかりに補正しています。一方、X・Z 軸回転の補正については、林内にまっすぐ生えている針葉樹(スギやヒノキ)を基準として行いました。
シェーダーでの回転処理
// X軸回りの3Dベクトル回転関数
vec3 rotateX(vec3 p, float angle) {
float s = sin(angle);
float c = cos(angle);
mat3 rotationMatrix = mat3(
1.0, 0.0, 0.0,
0.0, c, -s,
0.0, s, c
);
return rotationMatrix * p;
}
// Y軸回りの3Dベクトル回転関数
vec3 rotateY(vec3 p, float angle) {
float s = sin(angle);
float c = cos(angle);
mat3 rotationMatrix = mat3(
c, 0.0, s,
0.0, 1.0, 0.0,
-s, 0.0, c
);
return rotationMatrix * p;
}
// Z軸回りの3Dベクトル回転関数
vec3 rotateZ(vec3 p, float angle) {
float s = sin(angle);
float c = cos(angle);
mat3 rotationMatrix = mat3(
c, -s, 0.0,
s, c, 0.0,
0.0, 0.0, 1.0
);
return rotationMatrix * p;
}
// テクスチャサンプリング時に角度補正を適用
vec4 sampleTexture(sampler2D tex, vec3 rotationAngles) {
vec3 samplingDirection = normalize(v_modelPosition);
// 角度補正(Y→X→Zの順で適用)
vec3 rotatedDirection = samplingDirection;
rotatedDirection = rotateY(rotatedDirection, rotationAngles.y);
rotatedDirection = rotateX(rotatedDirection, rotationAngles.z);
rotatedDirection = rotateZ(rotatedDirection, rotationAngles.x);
vec2 uv = directionToEquirectangularUV(rotatedDirection);
uv.x = 1.0 - uv.x; // 水平反転(カメラ座標系の違いを補正)
return texture2D(tex, uv);
}
テクスチャ循環によるクロスフェード
パノラマ間をスムーズに切り替えるため、3つのテクスチャスロット(A/B/C) を循環させる手法を採用しています。
A → B → C → A → B → C → ...
3 スロット構成にすることで、「現在表示中のスロット」「フェード先として使用するスロット」「次に使用するテクスチャの読み込み用スロット」を同時に保持できます。これにより、表示中の描画を止めることなく次の画像を準備でき、スムーズなフェード遷移を実現しています。
Uniform定義
const uniforms = {
textureA: { value: null },
textureB: { value: null },
textureC: { value: null },
rotationAnglesA: { value: new THREE.Vector3() },
rotationAnglesB: { value: new THREE.Vector3() },
rotationAnglesC: { value: new THREE.Vector3() },
fromTarget: { value: 0 }, // フェード元 0=A, 1=B, 2=C
toTarget: { value: 0 }, // フェード先 0=A, 1=B, 2=C
fadeStartTime: { value: 0.0 },
fadeSpeed: { value: 3.0 },
time: { value: 0.0 },
// ... その他
};
フェード処理
const loadTextureWithFade = async (pointsData: CurrentPointData) => {
const { angle, texture } = pointsData;
const newTexture = await textureCache.loadTexture(texture);
// 次のテクスチャスロットを決定(0→1→2→0...)
const nextIndex = (currentTextureIndex + 1) % 3;
// フェード情報を設定
uniforms.fromTarget.value = currentTextureIndex;
uniforms.toTarget.value = nextIndex;
// 次のスロットにテクスチャと回転角度を設定
if (nextIndex === 0) {
uniforms.textureA.value = newTexture;
uniforms.rotationAnglesA.value = new THREE.Vector3(
degreesToRadians(angle.angle_x),
degreesToRadians(angle.angle_y),
degreesToRadians(angle.angle_z)
);
} else if (nextIndex === 1) {
uniforms.textureB.value = newTexture;
uniforms.rotationAnglesB.value = new THREE.Vector3(/*...*/);
} else {
uniforms.textureC.value = newTexture;
uniforms.rotationAnglesC.value = new THREE.Vector3(/*...*/);
}
// フェード開始時刻を記録
uniforms.fadeStartTime.value = performance.now() * 0.001;
currentTextureIndex = nextIndex;
};
シェーダーでのフェード処理
void main() {
// フェード進行度を計算
float fadeProgress = (time - fadeStartTime) * fadeSpeed;
fadeProgress = clamp(fadeProgress, 0.0, 1.0);
// smoothstepで滑らかなイージング
float smoothFade = smoothstep(0.0, 1.0, fadeProgress);
// フェード元テクスチャを決定
vec4 fromColor;
if (fromTarget < 0.5) {
fromColor = sampleTexture(textureA, rotationAnglesA);
} else if (fromTarget < 1.5) {
fromColor = sampleTexture(textureB, rotationAnglesB);
} else {
fromColor = sampleTexture(textureC, rotationAnglesC);
}
// フェード先テクスチャを決定
vec4 toColor;
if (toTarget < 0.5) {
toColor = sampleTexture(textureA, rotationAnglesA);
} else if (toTarget < 1.5) {
toColor = sampleTexture(textureB, rotationAnglesB);
} else {
toColor = sampleTexture(textureC, rotationAnglesC);
}
// mix関数でブレンド(smoothFade: 0.0〜1.0)
vec4 blendedColor = mix(fromColor, toColor, smoothFade);
gl_FragColor = blendedColor;
}
テクスチャキャッシュとプリロード
大量のパノラマ画像を扱うため、本実装ではテクスチャキャッシュを利用しつつ、隣接するノードの画像を事前に読み込むプリロードを行っています。ユーザーが移動操作を行った際にも、読み込み待ちによる表示の停止を最小限に抑えています。
class TextureCache {
private cache = new Map<string, THREE.Texture>();
private loadingPromises = new Map<string, Promise<THREE.Texture>>();
private maxCacheSize = 15; // 周辺ポイント + バッファ
async loadTexture(url: string): Promise<THREE.Texture> {
// キャッシュヒット
if (this.cache.has(url)) {
return this.cache.get(url)!;
}
// 重複リクエスト防止
if (this.loadingPromises.has(url)) {
return this.loadingPromises.get(url)!;
}
// 新規読み込み
const loadPromise = this.createLoadPromise(url);
this.loadingPromises.set(url, loadPromise);
try {
const texture = await loadPromise;
this.addToCache(url, texture);
return texture;
} finally {
this.loadingPromises.delete(url);
}
}
private addToCache(url: string, texture: THREE.Texture) {
// LRUキャッシュ: 古いものから削除
if (this.cache.size >= this.maxCacheSize) {
const firstKey = this.cache.keys().next().value;
const oldTexture = this.cache.get(firstKey);
oldTexture?.dispose(); // GPUメモリ解放
this.cache.delete(firstKey);
}
this.cache.set(url, texture);
}
// 複数テクスチャの並列プリロード
async preloadTextures(urls: string[]): Promise<void> {
const promises = urls.map((url) => this.preloadTexture(url));
await Promise.allSettled(promises); // 失敗しても続行
}
}
プリロード戦略
$effect(() => {
if (nextPointData) {
const pointsData = placePointData(nextPointData);
// 1. 現在地を優先読み込み
loadTextureWithFade(pointsData[0]).then(() => {
// 2. 周辺ポイントをバックグラウンドで読み込み
loadTextures(pointsData.slice(1));
});
}
});
ユーザーインタラクション
OrbitControlsの設定
操作感を Google ストリートビューに近づけるため、ドラッグ時の回転方向を反転させています。デフォルトの OrbitControls の挙動では、直感的に感じにくい場合があるため、PCとモバイルで回転速度を調整しています。
let orbitControls = new OrbitControls(camera, canvas);
// 視点操作のイージング
orbitControls.dampingFactor = 0.1;
orbitControls.enableDamping = true;
// ドラッグ方向を反転(自然な操作感)
orbitControls.rotateSpeed *= checkPc() ? -0.3 : -0.6;
// パン・ズームは無効化(カスタム実装を使用)
orbitControls.enablePan = false;
orbitControls.enableZoom = false;
FOVズーム(マウスホイール)
canvas.addEventListener('wheel', (event) => {
const zoomSpeed = 0.51;
const newFOV = camera.fov + event.deltaY * 0.05 * zoomSpeed;
// FOVを20°〜100°の範囲に制限
fov.set(Math.max(MIN_CAMERA_FOV, Math.min(MAX_CAMERA_FOV, newFOV)));
});
ピンチズーム(タッチデバイス)
let lastTouchDistance = 0;
canvas.addEventListener('touchstart', (event) => {
if (event.touches.length === 2) {
const touch1 = event.touches[0];
const touch2 = event.touches[1];
lastTouchDistance = Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2)
);
}
});
canvas.addEventListener('touchmove', (event) => {
if (event.touches.length === 2) {
event.preventDefault(); // デフォルトのピンチズームを無効化
const touch1 = event.touches[0];
const touch2 = event.touches[1];
const currentDistance = Math.sqrt(
Math.pow(touch2.clientX - touch1.clientX, 2) +
Math.pow(touch2.clientY - touch1.clientY, 2)
);
const deltaDistance = currentDistance - lastTouchDistance;
const newFOV = camera.fov - deltaDistance * 0.1 * zoomSpeed;
fov.set(Math.max(MIN_CAMERA_FOV, Math.min(MAX_CAMERA_FOV, newFOV)));
lastTouchDistance = currentDistance;
}
}, { passive: false });
MapLibre GL JSとの連携
ストリートビューは単体で動作するのではなく、MapLibre GL JSで表示される地図と密接に連携しています。
地図上のストリートビューレイヤー
ストリートビューのポイントとリンクは、MapLibre GL JSのレイヤーとして表示されます。
// ポイントレイヤー(個別のノード)
const streetViewCircleLayer: CircleLayerSpecification = {
id: '@street_view_circle_layer',
type: 'circle',
source: 'street_view_node_sources',
filter: ['==', ['get', 'has_link'], false], // リンクがないポイントのみ
minzoom: 10,
paint: {
'circle-color': '#08fa00',
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
10, 1, // ズーム10で半径1px
15, 12 // ズーム15で半径12px
],
'circle-opacity': 0.6,
}
};
// リンクレイヤー(ノード間の接続線)
const streetViewLineLayer: LineLayerSpecification = {
id: '@street_view_line_layer',
type: 'line',
source: 'street_view_link_sources',
paint: {
'line-color': '#08fa00',
'line-width': [
'interpolate', ['linear'], ['zoom'],
12, 1, 15, 12, 18, 20, 22, 40
],
'line-opacity': 0.6,
}
};
ノード接続データの生成
ストリートビューのナビゲーションには、「どのノードがどのノードに接続しているか」という隣接情報が必要になります。本実装では、この隣接情報を Python スクリプトによって事前に生成しています。
なお、元となるリンクデータは QGIS 上で手動で作成したラインデータで、撮影地点同士を実際の歩行経路に沿って接続したものを使用しています。
入力データ
- panorama_nodes.geojson: 各撮影地点(ノード)のPoint座標
- panorama_links.geojson: ノード間を結ぶLineString
Pythonスクリプト
import json
from pathlib import Path
import geopandas as gpd
from shapely.geometry import LineString
# 1. ノード座標 → ID の辞書を作成
def round_coordinates(coord, precision=6):
"""座標を指定された小数点の精度に丸める"""
return tuple(round(c, precision) for c in coord)
node_dict = {}
for feature in nodes_geojson["features"]:
if feature["geometry"]["type"] == "Point":
node_id = feature["properties"]["node_id"]
coordinates = round_coordinates(feature["geometry"]["coordinates"])
node_dict[coordinates] = node_id # 座標 → node_id
# 2. リンクから隣接情報を抽出
node_connections = {} # 各ノードの隣接ノードリスト
connected_nodes = set()
for feature in links_geojson["features"]:
if feature["geometry"]["type"] == "LineString":
coordinates = feature["geometry"]["coordinates"]
# リンクの始点・終点座標を取得
start_coord = round_coordinates(coordinates[0])
end_coord = round_coordinates(coordinates[-1])
# 座標からnode_idを逆引き
source_id = node_dict.get(start_coord)
target_id = node_dict.get(end_coord)
if source_id and target_id and source_id != target_id:
# 双方向の接続を登録
if source_id not in node_connections:
node_connections[source_id] = []
if target_id not in node_connections:
node_connections[target_id] = []
if target_id not in node_connections[source_id]:
node_connections[source_id].append(target_id)
if source_id not in node_connections[target_id]:
node_connections[target_id].append(source_id)
connected_nodes.add(source_id)
connected_nodes.add(target_id)
# 3. ノードに接続情報を追加
nodes_gdf = gpd.GeoDataFrame.from_features(nodes_geojson)
nodes_gdf["has_link"] = nodes_gdf["node_id"].isin(connected_nodes)
nodes_gdf["connection_count"] = nodes_gdf["node_id"].apply(
lambda x: len(node_connections.get(x, []))
)
# 4. 各種ファイルを出力
nodes_gdf.to_file("nodes.fgb", driver="FlatGeobuf")
links_gdf.to_file("links.fgb", driver="FlatGeobuf")
with open("node_connections.json", "w") as f:
json.dump(node_connections, f, indent=2)
出力データ
node_connections.json
{
"1276": [1256, 1068],
"157": [73, 295, 110],
"73": [157, 1074, 16]
}
- キー: node_id(文字列)
- 値: 隣接するnode_idの配列
フロントエンドでの使用
// +page.svelte
const nodeConnectionsJson = await fetch('/street_view/node_connections.json')
.then(res => res.json());
const setPoint = (nodeId: number) => {
// 隣接ノードを取得
const linkPoints = nodeConnectionsJson[nodeId] || [];
// 現在地 + 隣接ノードのデータを作成
const nextPoints = [nodeId, ...linkPoints]
.map(id => streetViewPointData.features.find(
p => p.properties.node_id === id
))
.map(nextPoint => ({
featureData: nextPoint,
bearing: turfBearing(currentPoint, nextPoint)
}));
nextPointData = nextPoints;
};
ナビゲーション矢印の方向計算
隣接ノードへの矢印は、Turf.jsのbearing関数で方位角を計算しています。
import turfBearing from '@turf/bearing';
// 現在地から各隣接ノードへの方位角を計算
const nextPoints = [pointId, ...linkPoints]
.map((id) => streetViewPointData.features.find(
(point) => point.properties.node_id === id
))
.filter((nextPoint): nextPoint is StreetViewPoint => nextPoint !== undefined)
.map((nextPoint) => ({
featureData: nextPoint,
// 現在地から隣接ノードへの方位角(度)
bearing: turfBearing(currentPoint, nextPoint)
}));
計算したbearingを使って、パノラマビュー内に矢印を円周上に配置します。パノラマ間の移動ボタンは、CSSで3D配置しています。

<div class="css-3d">
<div class="rotate-x-60">
<div bind:this={controlDiv}>
{#each nextPointData as point}
<button
class="css-arrow"
style="--angle: {point.bearing}deg; --distance: 175px;"
onclick={() => setStreetViewParams(point.node_id)}
>
<Icon icon="ep:arrow-up-bold"
style="transform: rotate({point.bearing}deg);" />
</button>
{/each}
</div>
</div>
</div>
.css-3d {
transform-style: preserve-3d;
perspective: 1000px;
}
.css-arrow {
position: absolute;
top: 50%;
left: 50%;
/* 角度に基づいて円周上に配置 */
--x: calc(cos(calc(var(--angle) - 90deg)) * var(--distance));
--y: calc(sin(calc(var(--angle) - 90deg)) * var(--distance));
translate: calc(var(--x) - 50%) calc(var(--y) - 50%);
}
カメラ向きとマーカーの同期
Three.jsのカメラ回転と、地図上に表示される方向マーカーをリアルタイムで同期させています。
Three.js → cameraBearing
// ThreeCanvas.svelte - アニメーションループ内
const animate = () => {
// カメラのY軸回転をラジアンから度に変換
let degrees = THREE.MathUtils.radToDeg(camera.rotation.y);
degrees = (degrees + 360) % 360; // 0〜360に正規化
// 180度オフセットしてcameraBearingに変換
if (!isExternalCameraUpdate) {
cameraBearing = (degrees + 180) % 360;
}
};
cameraBearing → マーカー回転
// AngleMarker.svelte
$effect(() => {
if (rotation && marker) {
// MapLibreのマーカー回転は逆方向
const r = -rotation + 180;
marker.setRotation(r);
}
});
まとめ
今回の実装で特に大変だったのは、角度情報を持たない 360 度写真に対して、すべて手動で角度補正を行った点です。データ作成の段階で適切なメタデータを付与しておくことの重要性を強く実感しました。データの品質が十分でない場合、最悪の場合は撮影し直しが必要になることもあります。実際、今回のデータの中にも手ブレのある写真がいくつか含まれており、後処理では対応しきれないケースがありました。
今回の取り組みは、データ作成から実装・表示まで、川上から川下までを一通り経験するものになりました。林業に例えるなら、山に入り木を育てるところから、木材として最終的に使える形へと加工するところまでを、自分の手で行ったような感覚です。
明日は@geo_jagaimoさんによる記事です!お楽しみにー








