はじめに
タイトルにある通り、ReactWebアプリに描画されたmapboxの地図上で3Dモデルを動かすことをゴールに記事にしていきます。
長いので 4 つの記事に分けて書きます。
- (1) 3DモデルをThree.jsを使ってブラウザ表示する
- (2) mapboxの地図上に3Dモデルを表示する
- (3) mapboxの地図上で3Dモデルをアニメーションさせる
- (4) mapboxの地形データ付きの地図上で地形に沿って3Dモデルをアニメーションさせる
今回は (4)mapboxの地形データ付きの地図上で地形に沿って3Dモデルをアニメーションさせる です。
最終的な完成形
mapboxの地形付きの地図上で、3Dモデルが地形に沿って移動していきます。
環境構築
2-3回目の記事で設定したプロジェクトを使います。
(2) mapboxの地図上に3Dモデルを表示する
(3) mapboxの地図上で3Dモデルをアニメーションさせる
mapboxの地形情報
mapboxでは地形情報を提供してくれていて、それを地図に設定することによって地図を3Dマップとして表示することができます。
それを利用して地図を立体的にしてみます。
https://www.mapbox.jp/news/gl-js-v2-launch
3D 地図の実装
mapbox://mapbox.terrain-rgb
という URL から地形(terrain)情報を取得することができます。
更に、実際の標高より高く描画することもできます。
今回は現実よりも1.5倍高く地形を表示します。
import { useRef, useState, useEffect } from "react";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import "./App.css";
import { droneLayer } from "./droneLayer";
import { getLineInfo, makeLineSeting } from "./functions";
import * as turf from "@turf/turf";
mapboxgl.accessToken = "[Your mapbox Token.]";
const App = () => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<mapboxgl.Map | null>(null);
const [lng, setLng] = useState(6.5873);
const [lat, setLat] = useState(45.3967);
const [zoom, setZoom] = useState(13.85);
const animationDuration = 20000; // 20秒
+ const exaggeration = 1.5;
const elevation = 100;
let startTime = 0;
let path: any = undefined;
let pathDistance = 0;
let dronePosition: mapboxgl.LngLatLike = [6.56674, 45.39881];
// この中でアニメーションを描画していく
const animation = (frame: number) => {
if (!map.current) return;
// アニメーション開始時間を設定
if (!startTime) {
startTime = frame;
}
if (map.current.getLayer("drone-model")) {
let droneHeight = elevation + Math.sin(elevation + frame * 0.01) * 0.5;
const animationPhase = (frame - startTime) / animationDuration;
if (animationPhase > 1) {
// まだドローンが移動する時間になっていなければ上下の揺れだけ表現
droneLayer.updateLngLat({ altitude: droneHeight });
// geoJsonの読み込み完了してからアニメーション開始
} else if (path && pathDistance) {
// ルートを受け取り、線上にある指定された距離の座標を返す。
const alongPath = turf.along(path, pathDistance * animationPhase)
.geometry.coordinates;
const nextDroneLngLat = {
lng: alongPath[0],
lat: alongPath[1],
};
// ドローンを動かす
droneLayer.updateLngLat({
latLng: nextDroneLngLat,
altitude: droneHeight,
});
dronePosition = nextDroneLngLat;
}
// 移動したラインの位置まで赤くする
map.current.setPaintProperty("line", "line-gradient", [
"step",
["line-progress"],
"red",
animationPhase,
"rgba(255, 0, 0, 0)",
]);
}
requestAnimationFrame(animation);
};
useEffect(() => {
async function init() {
// マップの初期セットアップ
if (map.current) return;
map.current = new mapboxgl.Map({
container: mapContainer.current as HTMLElement,
style: "mapbox://styles/mapbox/streets-v11",
center: [lng, lat],
zoom: zoom,
pitch: 76,
bearing: 150,
antialias: true,
});
// 移動する座標データを取得する
const [pinRouteGeojson] = await Promise.all([
fetch(
"https://docs.mapbox.com/mapbox-gl-js/assets/route-pin.geojson"
).then((response) => response.json()),
// データ取得後、mapのloadアクションを実行する
map.current.once("load"),
]);
// ルート用のライン追加
map.current.addSource("route-line", {
type: "geojson",
lineMetrics: true,
data: pinRouteGeojson,
});
// ラインを描画
map.current.addLayer(makeLineSeting("baseLine", "rgba(0,255,0,1)"));
map.current.addLayer(makeLineSeting("line", "rgba(0,0,0,0)"));
// ルートの距離を計算する
const routes = pinRouteGeojson.features[0].geometry.coordinates;
const lineInfo = getLineInfo(routes);
path = lineInfo.path;
pathDistance = lineInfo.pathDistance;
+ // 標高データの追加
+ map.current.addSource("mapbox-dem", {
+ type: "raster-dem",
+ url: "mapbox://mapbox.terrain-rgb",
+ tileSize: 512,
+ maxzoom: 14,
+ });
+ map.current.setTerrain({ source: "mapbox-dem", exaggeration });
// ドローンのレイヤーを最上位に移動する
map.current.moveLayer("drone-model");
animation(0);
}
init();
}, []);
useEffect(() => {
// マップ読み込み後
if (!map.current) return;
map.current.on("move", () => {
if (map.current) {
setLng(Number(map.current.getCenter().lng.toFixed(4)));
setLat(Number(map.current.getCenter().lat.toFixed(4)));
setZoom(Number(map.current.getZoom().toFixed(2)));
}
});
map.current.on("load", () => {
// モデルを描画する
if (map.current) {
if (map.current.getLayer("drone-model")) {
return;
}
map.current.addLayer(droneLayer);
}
});
});
return (
<div>
<div style={{ position: "relative" }}>
<div className="sidebar">
Longitude: {lng} | Latitude: {lat} | Zoom: {zoom}
</div>
<div ref={mapContainer} className="map-container" />
</div>
</div>
);
};
export default App;
地図が立体的な表示になりました。
しかし、地形情報を読み込むと3Dモデルが表示されなくなりました。
地形情報の読み込みでなぜ3Dモデルが表示されなくなったのか
簡単に言うと、モデルを表示する高さが足りないからです。
イメージはこの画像のような感じです。
モデルを正しく表示するためには、その座標における地形の標高をモデルのy軸方向の位置に加算してあげる必要があります。
3Dモデルを地形の高さに合わせて表示させる
ついでに地形情報の読み込み後から、3Dモデルのアニメーションが始まるように調整します。
import { useRef, useState, useEffect } from "react";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import "./App.css";
import { droneLayer } from "./droneLayer";
import { getLineInfo, makeLineSeting } from "./functions";
import * as turf from "@turf/turf";
mapboxgl.accessToken = "[Your mapbox Token.]";
const App = () => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<mapboxgl.Map | null>(null);
const [lng, setLng] = useState(6.5873);
const [lat, setLat] = useState(45.3967);
const [zoom, setZoom] = useState(13.85);
const animationDuration = 20000; // 20秒
const exaggeration = 1.5;
const elevation = 100;
let startTime = 0;
let path: any = undefined;
let pathDistance = 0;
let dronePosition: mapboxgl.LngLatLike = [6.56674, 45.39881];
// この中でアニメーションを描画していく
const animation = (frame: number) => {
if (!map.current) return;
+ // ドローンの現在位置の標高を取得
+ const terrainElevation =
+ Math.floor(
+ map.current.queryTerrainElevation(dronePosition, {
+ exaggerated: false,
+ }) || 0
+ ) * exaggeration;
- // アニメーション開始時間を設定
- if (!startTime) {
+ // 標高データが読み込み完了してからアニメーション開始
+ if (!startTime && terrainElevation) {
startTime = frame;
}
if (map.current.getLayer("drone-model")) {
- let droneHeight = elevation + Math.sin(elevation + frame * 0.01) * 0.5;
+ let droneHeight =
+ terrainElevation + elevation + Math.sin(elevation + frame * 0.01) * 0.5;
const animationPhase = (frame - startTime) / animationDuration;
if (animationPhase > 1) {
// まだドローンが移動する時間になっていなければ上下の揺れだけ表現
droneLayer.updateLngLat({ altitude: droneHeight });
- // geoJsonの読み込み完了してからアニメーション開始
- } else if (path && pathDistance) {
+ // 標高データが読み込み完了してからアニメーション開始
+ } else if (path && pathDistance && terrainElevation) {
// ルートを受け取り、線上にある指定された距離の座標を返す。
const alongPath = turf.along(path, pathDistance * animationPhase)
.geometry.coordinates;
const nextDroneLngLat = {
lng: alongPath[0],
lat: alongPath[1],
};
// ドローンを動かす
droneLayer.updateLngLat({
latLng: nextDroneLngLat,
altitude: droneHeight,
});
dronePosition = nextDroneLngLat;
}
// 移動したラインの位置まで赤くする
map.current.setPaintProperty("line", "line-gradient", [
"step",
["line-progress"],
"red",
animationPhase,
"rgba(255, 0, 0, 0)",
]);
}
requestAnimationFrame(animation);
};
useEffect(() => {
async function init() {
// マップの初期セットアップ
if (map.current) return;
map.current = new mapboxgl.Map({
container: mapContainer.current as HTMLElement,
style: "mapbox://styles/mapbox/streets-v11",
center: [lng, lat],
zoom: zoom,
pitch: 76,
bearing: 150,
antialias: true,
});
// 移動する座標データを取得する
const [pinRouteGeojson] = await Promise.all([
fetch(
"https://docs.mapbox.com/mapbox-gl-js/assets/route-pin.geojson"
).then((response) => response.json()),
// データ取得後、mapのloadアクションを実行する
map.current.once("load"),
]);
// ルート用のライン追加
map.current.addSource("route-line", {
type: "geojson",
lineMetrics: true,
data: pinRouteGeojson,
});
// ラインを描画
map.current.addLayer(makeLineSeting("baseLine", "rgba(0,255,0,1)"));
map.current.addLayer(makeLineSeting("line", "rgba(0,0,0,0)"));
// ルートの距離を計算する
const routes = pinRouteGeojson.features[0].geometry.coordinates;
const lineInfo = getLineInfo(routes);
path = lineInfo.path;
pathDistance = lineInfo.pathDistance;
// 標高データの追加
map.current.addSource("mapbox-dem", {
type: "raster-dem",
url: "mapbox://mapbox.terrain-rgb",
tileSize: 512,
maxzoom: 14,
});
map.current.setTerrain({ source: "mapbox-dem", exaggeration });
// ドローンのレイヤーを最上位に移動する
map.current.moveLayer("drone-model");
animation(0);
}
init();
}, []);
useEffect(() => {
// マップ読み込み後
if (!map.current) return;
map.current.on("move", () => {
if (map.current) {
setLng(Number(map.current.getCenter().lng.toFixed(4)));
setLat(Number(map.current.getCenter().lat.toFixed(4)));
setZoom(Number(map.current.getZoom().toFixed(2)));
}
});
map.current.on("load", () => {
// モデルを描画する
if (map.current) {
if (map.current.getLayer("drone-model")) {
return;
}
map.current.addLayer(droneLayer);
}
});
});
return (
<div>
<div style={{ position: "relative" }}>
<div className="sidebar">
Longitude: {lng} | Latitude: {lat} | Zoom: {zoom}
</div>
<div ref={mapContainer} className="map-container" />
</div>
</div>
);
};
export default App;
地形データに合わせて 3D モデルを表示することができました。
まとめ
React+Typescriptの環境上でmapbox-glを用いてmapboxの3D地図を表示し、
3DモデルをgeoJsonの座標を元に地図上で動かすことができました。
参考
公式:Query terrain elevation
公式:3D 地図について
連載
(1) 3DモデルをThree.jsを使ってブラウザ表示する
(2) mapboxの地図上に3Dモデルを表示する
(3) mapboxの地図上で3Dモデルをアニメーションさせる
(4) mapboxの地形データ付きの地図上で地形に沿って3Dモデルをアニメーションさせる