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

# はじめに

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

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

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

## 最終的な完成形

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

![完成形.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/e895bd7f-bf59-f384-bd62-646ae3ed2a92.gif)


# 3Dモデルの準備

1回目の記事にあるモデルを使います。
[(1)3DモデルをThree.jsを使ってブラウザ表示する](https://qiita.com/itouoti/items/6c3f489e3e59f6a75635)

# 環境構築

React + Typescript環境で構築していきたいと思います。
基本はmapbox公式ドキュメントベースで進めます。
[https://docs.mapbox.com/jp/help/tutorials/use-mapbox-gl-js-with-react/](https://docs.mapbox.com/jp/help/tutorials/use-mapbox-gl-js-with-react/)

## vite

```bash
yarn create vite

✔ Select a framework: › React
✔ Select a variant: › TypeScript
```

## package install

```bash
yarn add three @mapbox/mapbox-gl-language mapbox-gl
yarn add -D @types/three @types/mapbox-gl
```

## mapboxアクセストークンを取得する

Mapbox のアカウントページにアクセスしてトークンを控えておく。
[https://account.mapbox.com/](https://account.mapbox.com/)

## css 調整

`index.css`の中身を全部消します。

```diff_css:index.css
<!-- 全消し -->
```

`App.css`を書き換えます。

```diff_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 初期セットアップ

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

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

export default App;
```

## DLした3Dモデル配置

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

![modelパス.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/de1edc56-6a53-f2f6-4e4c-f0aaff0197d8.png)


## 試しに起動

```
yarn dev
```

[http://localhost:5173](http://localhost:5173)

画面は真っ白な状態。コンソールにエラーとか出てなければOK。
![初期確認.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/6be3d3fc-5ceb-1904-5ef4-0d3bd21bc5d1.png)


# mapbox について

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

[https://www.mapbox.jp/](https://www.mapbox.jp/)

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

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

```tsx: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/61711c21-4a76-626b-b0fb-df5a19a03160.gif)


# mapbox × Three.jsについて

## mapboxでの描画の流れ

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

ものすごく大雑把な自分の中でのイメージです。
![threejsのイメージ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/1539c9bb-5ae9-3c64-9b56-615a5f98bc20.png)


- **Layer**・・・mapboxの中で描画する階層
  その他の**camera**やら**scene**やらは[1回目の記事](https://qiita.com/itouoti/items/6c3f489e3e59f6a75635)で説明しています。

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

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

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

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

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

```diff_typescript: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/ddcd44ca-f72d-ee14-91d3-51d978ad838b.gif)


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

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

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

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

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

```diff_typescript: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2641313/7cca5522-07f6-8ba2-9a7a-8f95e0a4329b.gif)


# まとめ

React+Typescriptの環境上でmapbox-glを用いてmapboxの地図を表示し、
その上でThree.jsを使って3Dモデルを地図上に描画することができました。

次回は3Dモデルを実際に地図上でルートに沿って移動させてみます。

# 参考

[mapbox](https://www.mapbox.jp/)
[公式：React アプリで Mapbox GL JS を使う](https://docs.mapbox.com/jp/help/tutorials/use-mapbox-gl-js-with-react/)
[公式：3D モデルを追加](https://docs.mapbox.com/jp/mapbox-gl-js/example/add-3d-model/)

# 連載
[(1) 3D モデルを Three.js を使ってブラウザ表示する](https://qiita.com/itouoti/items/6c3f489e3e59f6a75635)
(2) mapbox の地図上に 3D モデルを表示する
[(3) mapbox の地図上で 3D モデルをアニメーションさせる](https://qiita.com/itouoti/items/5a0e077ae492bb378017)
[(4) mapbox の地形データ付きの地図上で地形に沿って 3D モデルをアニメーションさせる](https://qiita.com/itouoti/items/40541ea11a66e385cdd8)