はじめに
タイトルにある通り、ReactWebアプリに描画されたmapboxの地図上で3Dモデルを動かすことをゴールに記事にしていきます。
長いので4つの記事に分けて書きます。
- (1) 3DモデルをThree.jsを使ってブラウザ表示する
- (2) mapboxの地図上に3Dモデルを表示する
- (3) mapboxの地図上で3Dモデルをアニメーションさせる
- (4) mapboxの地形データ付きの地図上で地形に沿って3Dモデルをアニメーションさせる
今回は (2)mapboxの地図上に3Dモデルを表示する です。
最終的な完成形
mapboxの地形付きの地図上で、3Dモデルが地形に沿って移動していきます。
3Dモデルの準備
1回目の記事にあるモデルを使います。
(1)3DモデルをThree.jsを使ってブラウザ表示する
環境構築
React + Typescript環境で構築していきたいと思います。
基本はmapbox公式ドキュメントベースで進めます。
https://docs.mapbox.com/jp/help/tutorials/use-mapbox-gl-js-with-react/
vite
yarn create vite
✔ Select a framework: › React
✔ Select a variant: › TypeScript
package install
yarn add three @mapbox/mapbox-gl-language mapbox-gl
yarn add -D @types/three @types/mapbox-gl
mapboxアクセストークンを取得する
Mapbox のアカウントページにアクセスしてトークンを控えておく。
https://account.mapbox.com/
css 調整
index.css
の中身を全部消します。
<!-- 全消し -->
App.css
を書き換えます。
- #root {
- max-width: 1280px;
- margin: 0 auto;
- padding: 2rem;
- text-align: center;
- }
- .logo {
- height: 6em;
- padding: 1.5em;
- will-change: filter;
- }
- .logo:hover {
- filter: drop-shadow(0 0 2em #646cffaa);
- }
- .logo.react:hover {
- filter: drop-shadow(0 0 2em #61dafbaa);
- }
- @keyframes logo-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
- }
- @media (prefers-reduced-motion: no-preference) {
- a:nth-of-type(2) .logo {
- animation: logo-spin infinite 20s linear;
- }
- }
- .card {
- padding: 2em;
- }
- .read-the-docs {
- color: #888;
- }
+ .map-container {
+ height: 800px;
+ }
+ .sidebar {
+ background-color: rgba(35, 55, 75, 0.9);
+ color: #fff;
+ padding: 6px 12px;
+ font-family: monospace;
+ z-index: 1;
+ position: absolute;
+ top: 0;
+ left: 0;
+ margin: 12px;
+ border-radius: 4px;
+ }
App.tsx 初期セットアップ
色々書いてあるのを全部消します。
const App = () => {
return <div></div>;
};
export default App;
DLした3Dモデル配置
/public/models
ディレクトリを作ってそこに放り込んでおきます。
試しに起動
yarn dev
画面は真っ白な状態。コンソールにエラーとか出てなければOK。
mapbox について
デジタル地図の開発プラットフォーム
APIも豊富でカスタマイズやデザインが色々できます。
無期限の無料版があるのでアカウント作ったらすぐAPIを利用して遊ぶことができます。
mapbox の地図を表示させる実装
Reactでmapboxの地図を扱っていくために、mapbox-gl
というライブラリを使います。
import { useRef, useState, useEffect } from "react";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import "./App.css";
mapboxgl.accessToken = "[Your mapbox Token.]";
const App = () => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<mapboxgl.Map | null>(null);
const [lng, setLng] = useState(148.9819);
const [lat, setLat] = useState(-35.3981);
const [zoom, setZoom] = useState(18);
useEffect(() => {
// マップの初期セットアップ
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: 60,
bearing: -18.6,
antialias: true,
});
}, []);
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)));
}
});
});
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;
地図が表示されて、ポインタでグリグリ動かせて、左上に座標やらズームレベルやらが表示されていればOKです。
mapbox × Three.jsについて
mapboxでの描画の流れ
mapboxでは、各要素はLayerと呼ばれる階層構造で表現されます。
Three.jsで描画した3Dモデルも一つのLayerとしてmapboxに差し込みます。
-
Layer・・・mapboxの中で描画する階層
その他のcameraやらsceneやらは1回目の記事で説明しています。
Three.jsの描画先がcanvasタグからmapboxに変わったというだけですね。
mapbox上で3Dモデルを表示させる実装
モデルを描画するLayerを作る
droneLayer.ts
というファイルを作ります。
import mapboxgl from "mapbox-gl";
import * as THREE from "three";
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader";
let camera: THREE.Camera;
let scene: THREE.Scene;
let layermap: mapboxgl.Map;
let renderer: THREE.WebGLRenderer;
let modelTransform: {
translateX: number;
translateY: number;
translateZ: number | undefined;
rotateX: any;
rotateY: any;
rotateZ: any;
scale: number;
};
// モデルが配置されるmap上の座標
let modelOrigin: mapboxgl.LngLatLike = [148.9819, -35.39847];
let modelAltitude = 0;
let modelRotate = [Math.PI / 2, 0, 0];
export const droneLayer: mapboxgl.AnyLayer = {
id: "drone-model",
renderingMode: "3d",
type: "custom",
// レイヤーがマップに追加されたときに呼び出されるオプションのメソッド
onAdd: (map, gl) => {
const modelAsMercatorCoordinate = mapboxgl.MercatorCoordinate.fromLngLat(
modelOrigin,
modelAltitude
);
modelTransform = {
translateX: modelAsMercatorCoordinate.x,
translateY: modelAsMercatorCoordinate.y,
translateZ: modelAsMercatorCoordinate.z,
rotateX: modelRotate[0],
rotateY: modelRotate[1],
rotateZ: modelRotate[2],
scale: modelAsMercatorCoordinate.meterInMercatorCoordinateUnits(),
};
// ピュアなThree.jsだと色々引数の設定をしていたが、
// 視点などはmapbox側での視点になるのでインスタンスだけ生成する
camera = new THREE.Camera();
scene = new THREE.Scene();
// ライトの設定
const ambientLight = new THREE.AmbientLight();
ambientLight.color.set(0xffffff);
ambientLight.intensity = 0.5;
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.4);
directionalLight.position.set(1.0, 0.55, 5);
scene.add(directionalLight);
// オブジェクトの設定をしていく
const fbxLoader = new FBXLoader();
fbxLoader.setResourcePath("/models/Drone_Costum/Teturizer/");
fbxLoader.load("/models/Drone_Costum/Material/drone_costum.fbx", (obj) => {
// モデルが大きすぎるので縮小
obj.scale.set(0.025, 0.025, 0.025);
// 地面からちょっと浮いているように見せるためにY軸から+方向に少し上げる
obj.position.y = 20;
scene.add(obj);
});
layermap = map;
// threeJsで描画したオブジェクトをmapboxにマッピングする
renderer = new THREE.WebGLRenderer({
// 描画対象のcanvasをmapboxと指定している
canvas: map.getCanvas(),
context: gl,
antialias: true,
});
renderer.autoClear = false;
},
// レンダー フレーム中に呼び出され、レイヤが GL コンテキストに描画できるようにします
render: (gl, matrix) => {
// マップにマッピングしたときの座標を求める
const rotateX = new THREE.Matrix4().makeRotationAxis(
new THREE.Vector3(1, 0, 0),
modelTransform.rotateX
);
const rotateY = new THREE.Matrix4().makeRotationAxis(
new THREE.Vector3(0, 1, 0),
modelTransform.rotateY
);
const rotateZ = new THREE.Matrix4().makeRotationAxis(
new THREE.Vector3(0, 0, 1),
modelTransform.rotateZ
);
const m = new THREE.Matrix4().fromArray(matrix);
const l = new THREE.Matrix4()
.makeTranslation(
modelTransform.translateX,
modelTransform.translateY,
modelTransform.translateZ || 0
)
.scale(
new THREE.Vector3(
modelTransform.scale,
-modelTransform.scale,
modelTransform.scale
)
)
.multiply(rotateX)
.multiply(rotateY)
.multiply(rotateZ);
// mapboxの座標ベースでレンダリングをする
camera.projectionMatrix.elements = matrix;
camera.projectionMatrix = m.multiply(l);
renderer.state.reset();
renderer.render(scene, camera);
layermap.triggerRepaint();
},
};
Layerをmapに追加する
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";
mapboxgl.accessToken = "[Your mapbox Token.]";
const App = () => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<mapboxgl.Map | null>(null);
const [lng, setLng] = useState(148.9819);
const [lat, setLat] = useState(-35.3981);
const [zoom, setZoom] = useState(18);
useEffect(() => {
// マップの初期セットアップ
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: 60,
bearing: -18.6,
antialias: true,
});
}, []);
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;
地図上にドローンが描画されました。
浮遊感を出す(アニメーションを追加)
ついでに1回目の時と同様に、少しモデルを上下させてドローンに浮遊感を出してみようと思います。
アニメーションはanimate
の中で表現していくことになります。
droneLayerに座標を更新できる関数を追加する
import mapboxgl from "mapbox-gl";
import * as THREE from "three";
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader";
let camera: THREE.Camera;
let scene: THREE.Scene;
let layermap: mapboxgl.Map;
let renderer: THREE.WebGLRenderer;
let modelTransform: {
translateX: number;
translateY: number;
translateZ: number | undefined;
rotateX: any;
rotateY: any;
rotateZ: any;
scale: number;
};
// モデルが配置されるmap上の座標
let modelOrigin: mapboxgl.LngLatLike = [148.9819, -35.39847];
let modelAltitude = 0;
let modelRotate = [Math.PI / 2, 0, 0];
+ interface CustomLayer {
+ updateLngLat: ({
+ latLng,
+ altitude,
+ }: {
+ latLng?: mapboxgl.LngLatLike;
+ altitude?: number;
+ }) => void;
+ }
+ export const droneLayer: mapboxgl.AnyLayer & CustomLayer = {
- export const droneLayer: mapboxgl.AnyLayer = {
id: "drone-model",
renderingMode: "3d",
type: "custom",
// レイヤーがマップに追加されたときに呼び出されるオプションのメソッド
onAdd: (map, gl) => {
const modelAsMercatorCoordinate = mapboxgl.MercatorCoordinate.fromLngLat(
modelOrigin,
modelAltitude
);
modelTransform = {
translateX: modelAsMercatorCoordinate.x,
translateY: modelAsMercatorCoordinate.y,
translateZ: modelAsMercatorCoordinate.z,
rotateX: modelRotate[0],
rotateY: modelRotate[1],
rotateZ: modelRotate[2],
scale: modelAsMercatorCoordinate.meterInMercatorCoordinateUnits(),
};
// ピュアなThree.jsだと色々引数の設定をしていたが、
// 視点などはmapbox側での視点になるのでインスタンスだけ生成する
camera = new THREE.Camera();
scene = new THREE.Scene();
// ライトの設定
const ambientLight = new THREE.AmbientLight();
ambientLight.color.set(0xffffff);
ambientLight.intensity = 0.5;
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.4);
directionalLight.position.set(1.0, 0.55, 5);
scene.add(directionalLight);
// オブジェクトの設定をしていく
const fbxLoader = new FBXLoader();
fbxLoader.setResourcePath("/models/Drone_Costum/Teturizer/");
fbxLoader.load("/models/Drone_Costum/Material/drone_costum.fbx", (obj) => {
// モデルが大きすぎるので縮小
obj.scale.set(0.025, 0.025, 0.025);
- // 地面からちょっと浮いているように見せるためにY軸から+方向に少し上げる
- obj.position.y = 20;
scene.add(obj);
});
layermap = map;
// threeJsで描画したオブジェクトをmapboxにマッピングする
renderer = new THREE.WebGLRenderer({
// 描画対象のcanvasをmapboxと指定している
canvas: map.getCanvas(),
context: gl,
antialias: true,
});
renderer.autoClear = false;
},
// レンダー フレーム中に呼び出され、レイヤが GL コンテキストに描画できるようにします
render: (gl, matrix) => {
// マップにマッピングしたときの座標を求める
const rotateX = new THREE.Matrix4().makeRotationAxis(
new THREE.Vector3(1, 0, 0),
modelTransform.rotateX
);
const rotateY = new THREE.Matrix4().makeRotationAxis(
new THREE.Vector3(0, 1, 0),
modelTransform.rotateY
);
const rotateZ = new THREE.Matrix4().makeRotationAxis(
new THREE.Vector3(0, 0, 1),
modelTransform.rotateZ
);
const m = new THREE.Matrix4().fromArray(matrix);
const l = new THREE.Matrix4()
.makeTranslation(
modelTransform.translateX,
modelTransform.translateY,
modelTransform.translateZ || 0
)
.scale(
new THREE.Vector3(
modelTransform.scale,
-modelTransform.scale,
modelTransform.scale
)
)
.multiply(rotateX)
.multiply(rotateY)
.multiply(rotateZ);
// mapboxの座標ベースでレンダリングをする
camera.projectionMatrix.elements = matrix;
camera.projectionMatrix = m.multiply(l);
renderer.state.reset();
renderer.render(scene, camera);
layermap.triggerRepaint();
},
+ // 座標,高さの更新
+ updateLngLat: ({
+ latLng,
+ altitude,
+ }: {
+ latLng?: mapboxgl.LngLatLike;
+ altitude?: number;
+ }) => {
+ if (latLng) {
+ modelOrigin = latLng;
+ }
+ if (altitude) {
+ modelAltitude = altitude;
+ }
+ const updateMercator = mapboxgl.MercatorCoordinate.fromLngLat(
+ modelOrigin,
+ modelAltitude
+ );
+ modelTransform.translateX = updateMercator.x;
+ modelTransform.translateY = updateMercator.y;
+ modelTransform.translateZ = updateMercator.z;
+ },
};
アニメーションを追加する
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";
mapboxgl.accessToken = "[Your mapbox Token.]";
const App = () => {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<mapboxgl.Map | null>(null);
const [lng, setLng] = useState(148.9819);
const [lat, setLat] = useState(-35.3981);
const [zoom, setZoom] = useState(18);
+ const elevation = 20;
+ // この中でアニメーションを描画していく
+ const animation = (frame: number) => {
+ if (!map.current) return;
+ if (map.current.getLayer("drone-model")) {
+ let droneHeight = elevation + (Math.sin(elevation + frame * 0.01) * 0.5);
+ droneLayer.updateLngLat({ altitude: droneHeight });
+ }
+ requestAnimationFrame(animation);
+ };
useEffect(() => {
// マップの初期セットアップ
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: 60,
bearing: -18.6,
antialias: true,
});
+ animation(0);
}, []);
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;
まとめ
React+Typescriptの環境上でmapbox-glを用いてmapboxの地図を表示し、
その上でThree.jsを使って3Dモデルを地図上に描画することができました。
次回は3Dモデルを実際に地図上でルートに沿って移動させてみます。
参考
mapbox
公式:React アプリで Mapbox GL JS を使う
公式:3D モデルを追加
連載
(1) 3D モデルを Three.js を使ってブラウザ表示する
(2) mapbox の地図上に 3D モデルを表示する
(3) mapbox の地図上で 3D モデルをアニメーションさせる
(4) mapbox の地形データ付きの地図上で地形に沿って 3D モデルをアニメーションさせる