LoginSignup
3
4

More than 1 year has passed since last update.

【React/mapboxの地図上で3D モデルを動かす】(2) mapboxの地図上に3Dモデルを表示する

Last updated at Posted at 2022-11-08

はじめに

タイトルにある通り、ReactWebアプリに描画されたmapboxの地図上で3Dモデルを動かすことをゴールに記事にしていきます。
長いので4つの記事に分けて書きます。

  • (1) 3DモデルをThree.jsを使ってブラウザ表示する
  • (2) mapboxの地図上に3Dモデルを表示する
  • (3) mapboxの地図上で3Dモデルをアニメーションさせる
  • (4) mapboxの地形データ付きの地図上で地形に沿って3Dモデルをアニメーションさせる

今回は (2)mapboxの地図上に3Dモデルを表示する です。

最終的な完成形

mapboxの地形付きの地図上で、3Dモデルが地形に沿って移動していきます。

完成形.gif

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の中身を全部消します。

index.css
<!-- 全消し -->

App.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 初期セットアップ

色々書いてあるのを全部消します。

App.tsx
const App = () => {
  return <div></div>;
};

export default App;

DLした3Dモデル配置

/public/modelsディレクトリを作ってそこに放り込んでおきます。

modelパス.png

試しに起動

yarn dev

http://localhost:5173

画面は真っ白な状態。コンソールにエラーとか出てなければOK。
初期確認.png

mapbox について

デジタル地図の開発プラットフォーム
APIも豊富でカスタマイズやデザインが色々できます。
無期限の無料版があるのでアカウント作ったらすぐAPIを利用して遊ぶことができます。

https://www.mapbox.jp/

mapbox の地図を表示させる実装

Reactでmapboxの地図を扱っていくために、mapbox-glというライブラリを使います。

App.tsx
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です。

map表示.gif

mapbox × Three.jsについて

mapboxでの描画の流れ

mapboxでは、各要素はLayerと呼ばれる階層構造で表現されます。
Three.jsで描画した3Dモデルも一つのLayerとしてmapboxに差し込みます。

ものすごく大雑把な自分の中でのイメージです。
threejsのイメージ.png

  • Layer・・・mapboxの中で描画する階層
    その他のcameraやらsceneやらは1回目の記事で説明しています。

Three.jsの描画先がcanvasタグからmapboxに変わったというだけですね。

mapbox上で3Dモデルを表示させる実装

モデルを描画するLayerを作る

droneLayer.tsというファイルを作ります。

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に追加する

App.tsx
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;

地図上にドローンが描画されました。

モデル表示.gif

浮遊感を出す(アニメーションを追加)

ついでに1回目の時と同様に、少しモデルを上下させてドローンに浮遊感を出してみようと思います。
アニメーションはanimateの中で表現していくことになります。

droneLayerに座標を更新できる関数を追加する

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];

+ 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;
+   },
};

アニメーションを追加する

App.tsx
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;

地図上のドローンモデルに浮遊感を持たせることができました。
step2完成形.gif

まとめ

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 モデルをアニメーションさせる

3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4