LoginSignup
3
3

More than 1 year has passed since last update.

【React/mapboxの地図上で3Dモデルを動かす】(4) mapboxの地形データ付きの地図上で地形に沿って3Dモデルをアニメーションさせる

Posted at

はじめに

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

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

今回は (4)mapboxの地形データ付きの地図上で地形に沿って3Dモデルをアニメーションさせる です。

最終的な完成形

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

完成形.gif

環境構築

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倍高く地形を表示します。

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";
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モデルが表示されなくなりました。

地形データ導入.gif

地形情報の読み込みでなぜ3Dモデルが表示されなくなったのか

簡単に言うと、モデルを表示する高さが足りないからです。
イメージはこの画像のような感じです。
なぜモデルが表示されないか.png

モデルを正しく表示するためには、その座標における地形の標高をモデルのy軸方向の位置に加算してあげる必要があります。

3Dモデルを地形の高さに合わせて表示させる

ついでに地形情報の読み込み後から、3Dモデルのアニメーションが始まるように調整します。

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";
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 モデルを表示することができました。
完成形.gif

まとめ

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

3
3
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
3