tpc22-1

TOPIC 22|3D都市モデルと位置情報をUnityで扱う[1/2]|3D都市モデルを読み込んでさまざまな地理情報を重ねる

このトピックでは、TOPIC 14「VR・ARでの活用」の発展として、スマートフォンのGPSによる位置情報など、座標系の異なる位置情報をPLATEAUと重ね合わせて表示するARアプリの開発を通して、PLATEAUを他の位置情報と合わせて使う方法について説明します。

Share

PLATEAUは、他のさまざまな位置情報と組み合わせて使うことで、さらに多くの活用が考えられます。このトピックでは、TOPIC 14 「VR・ARでの活用」の発展として、スマートフォン(以下、スマホ)のGPSなど、座標系やデータ形式の異なる位置情報をPLATEAUと重ね合わせて表示するARアプリの開発を通して、PLATEAUを他の位置情報と合わせて使う方法について説明します。

【目次】

22.1  このトピックの見どころ

22.2  3D都市モデルを読み込む

 22.2.1  PLATEAU SDK for Unityを使ってデータを読み込む

 22.2.2  新規プロジェクトの作成とSDKの導入

 22.2.3  3D都市モデルのインポート

22.3  さまざまな地理情報を重ねる

 22.3.1  座標計算の一般的な注意

 22.3.2  平面直角座標系への変換

 22.3.3  位置情報の読み込み

 22.3.4  スマホのGPS情報を重ねる

 22.3.5  GPS受信機のデータを重ねる

 22.3.6  オープンデータを重ねる

3D都市モデルと地理情報を重ねた図

22.1 _ このトピックの見どころ

このトピックでは、VRやARでPLATEAUを扱うときに必要となる、位置合わせの方法を解説します。主な見どころは、以下のとおりです。

・3D都市モデルのインポート

PLATEAU SDK for Unityを用いて、任意のエリアの3D都市モデルをインポートする方法を学びます。

・座標変換の方法

PLATEAUの3D都市モデルの座標は、経緯度の情報です。Unityで扱うには、平面直角座標と呼ばれる座標系(メートル単位)に変換しなければなりません。その変換方法や、その際の演算精度の注意点について説明します。

・指定された位置にPrefabを置く

例として、指定された経緯度の位置に、Prefabを配置する方法を説明します。これはVRやARにおいて、PLATEAUの地図と位置情報を合わせる基本となります。

・スマホのGPS情報と重ねる

スマホのGPS情報を読み取り、その位置に、同じくPrefabを配置する方法を説明します。これはVRやARにおいて、PLATEAUの地図と自分の位置とを合わせる基本となります。

・GPS受信機データの扱い方

応用として、市販のGPS受信機のデータを扱う方法を紹介します。GPS受信機によっては、スマホより高い精度で、自分の位置がわかります。

・オープンデータの扱い

さまざまな地方公共団体は、オープンデータとして地理空間情報を提供しています。例として、バス停の位置が記述されたCSVファイルを読み込み、それをUnity上でPLATEAUの地図と重ねて表示する方法を解説します。

オープンデータをUnity上で扱えるようにするには、いくつかのデータ変換をしなければならない場面があります。このトピックでは、その変換にオープンソースのGISであるQGISを使う方法を紹介します。

・ARアプリの開発

最後に応用として、これまでの説明をもとに、AR Foundation・ARCore Geospatial APIを使って、簡単なARアプリを作る方法を解説します。

22.2 _ 3D都市モデルを読み込む

最初に、PLATEAU SDK for Unityを使って、PLATEAUの3D都市モデルをUnityに読み込みます。このとき、ファイルの扱い方、ツールの利用方法、どのような座標系で読み込まれるかなど、重ね合わせるためのポイントを確認します。

本チュートリアルでは、なるべく実際の処理の内容を詳細に記載するため、数式に基づくコードを記述したり、データをひとつずつ取り出して処理したりするなど、細かい処理を解説しています。しかし、これらを簡素化する便利なライブラリなどもあるので、自分が読み込みたいデータや行いたい処理に対応したライブラリなどを探してみてください。

22.2.1 _ PLATEAU SDK for Unityを使ってデータを読み込む

PLATEAU SDK for Unityを使って、PLATEAUの3D都市モデルをUnityに読み込みます。SDKについての詳細は、GitHubのリポジトリのReadmeやマニュアルを参考にしてください。PLATEAU公式サイトのTOPIC 17 「PLATEAU SDKでの活用[1/2]|PLATEAU SDK for Unityを活用する」にも記載があります。

22.2.2 _ 新規プロジェクトの作成とSDKの導入

本トピックでは、Unity 2021.3.16f1を使います。「.16f1」などのマイナーバージョンは、あまり気にしなくてよいですが、PLATEAU SDK for Unityは、2023年10月時点において、Unityバージョン2021.3を想定しているので、それに合わせてください。

プロジェクトテンプレートは、「3D(URP)」を使い、Universal Render Pipelineのプロジェクトとして進めます。Unityを起動したら、「3D(URP)」テンプレートを選んで、新規プロジェクトを作成してください。

プロジェクトを作成したら、PLATEAU SDK for Unityを導入します。導入方法などについては、ここでは省略します。GitHubのリポジトリのReadmeやマニュアルなどを参照してください。

2023年10月時点において、PLATEAU SDK for Unityは、バージョン1系と、バージョン2系のα版があります。このチュートリアルでは、安定版であるPLATEAU SDK for Unity v1.16を使います。

図 22-1 3D(UDP)テンプレートでプロジェクトを新規作成する

22.2.3 _ 3D都市モデルのインポート

SDKを導入したら、3D都市モデルをインポートします。

今回は、筆者に馴染みのある東京都豊島区の大塚駅周辺を対象としたアプリを作っていきます。

【手順】3D都市モデルのインポート

[1]東京23区の3D都市モデルのダウンロード

あらかじめ、G空間情報センターから、東京23区の3D都市モデルデータをダウンロードしておきます。東京23区の3D都市モデルには、いくつかの年度がありますが、このトピックでは、下記の2022年度のCityGML v2形式のものを使います。

ダウンロードしたら、適当なフォルダに展開してください。

【メモ】

 CityGML v2とは、3D都市モデル標準製品仕様書の2.0版に準拠したCityGML形式のことです。CityGMLとだけ書かれている版(古い年度の東京23区のページには、この版があります)は、1.0版に準拠したCityGML形式です。

【メモ】

本トピックの手順では、あらかじめダウンロードしておいたCityGMLファイルを使いますが、PLATEAU SDK for Unityには、その場で必要な範囲の3D都市モデルデータをダウンロードする機能もあります。「17.2.2  PLATEAU SDK for Unityを使用する」で解説する方法で、3D都市モデルをインポートしてもかまいません。

【3D都市モデル(Project PLATEAU)東京23区(2022年度)】

https://www.geospatial.jp/ckan/dataset/plateau-tokyo23ku-2022

図 22-2 3D都市モデル(Project PLATEAU)をダウンロードする

[2]フォルダを指定してインポートする

Unityの[PLATEAU]メニューから[PLATEAU SDK]を選択して、「PLATEAU SDK for Unity」を起動します。次のように操作して、インポートします。

(1)都市の追加

[ローカル]を選択し、手順[1]でダウンロードして展開しておいたフォルダを選択します。

(2)基準座標系の選択

[09:東京(本州),福島,栃木,茨木,埼玉,千葉,群馬,神奈川]を選びます。これらは対象とする3D都市モデルに合わせて読み替えてください。

【メモ】

PLATEAU SDK for Unityでは、インポート時に平面直角座標系に変換します。平面直角座標系とは日本固有の投影座標系で、日本全国を19のゾーンに分け、ガウスの等角投影法を適用した座標系です(TOPIC 3「3D都市モデルデータの基本[4/4]|CityGMLの座標・高さとデータ変換」を参照)。本トピックで作成していくアプリでは、さまざまな地理空間情報を、最終的に平面直角座標系に変換することで、PLATEAUの3D都市モデルと合わせていきます。

(3)マップ範囲選択

[範囲選択]をクリックすると、マウスで領域を選択できる画面が表示されるので、マウスでドラッグして、インポートしたい範囲を選択します。

(4)地物別設定

範囲選択すると、地物別設定のオプションが表示されます。ここでは、[建築物]のみチェックを付けます。

このトピックの3D都市モデルの用途は、アタリを付けるためとオクルージョンのためです。こうした用途では、あまり詳細なモデルは必要ないので、ここでは、「LOD1」のみを使います。

デフォルトでは、すべてのLODを読み込むので、[LOD描画設定]で「1」を選択し、LOD1のみを読み込むように設定してください(どのLODを使うのが適切なのかは、用途によって異なります)。

「テクスチャを含める」「Mesh Colliderをセットする」「モデル結合」には、次の設定をしてください。

テクスチャを含めるチェックしない
Mesh Colliderをセットするチェックしない
モデル結合地域単位

(5)基準座標系からのオフセット値(メートル)

読み込んだときの中心点の座標です。デフォルトでは、自動的に範囲の中心が設定されるので、そのまま進めます。この値は、インポートされた3D都市モデルのデータのインスペクタで確認できます(図22-9を参照)。

以降の座標変換で必要となる値なので、控えておいてください。

図 22-3 3D都市モデルをインポートする

[3]インポートの完了

上記の設定をしたら、[モデルをインポート]ボタンを押して、3D都市モデルのインポートを実行します。

図 22-4 インポートが完了した

22.3 _ さまざまな地理情報を重ねる

次に、座標変換の方法を説明します。

まずは、実際に座標変換の式をプログラムとして書くことで、変換方法を確認します。この変換方法をもとに、GPSのデータやオープンデータを、PLATEAUの3D都市モデルと重ねられるように変換する方法を説明します。

22.3.1 _ 座標計算の一般的な注意

経緯度の座標から、平面直角座標系の座標に変換するプログラムを作成します。変換式は国土地理院が公開しているので、それをプログラムに落とし込みます。

プログラムを作るに当たって、ひとつ、注意点があります。それは、数値計算の誤差です。

地球規模の座標計算をする場合、地球の周囲長は約40,000km――メートルで記述すると約40,000,000mです。一方で、コンピューターが扱う浮動小数点規格の標準であるIEEE754において、一般的にfloatとして知られる32ビットの単精度浮動小数点の形式では、実際の数字を現す仮数部が23ビットです。この精度で表現できるのは、10進数でおよそ7桁の数字までです。地球の周囲長と比べてみると、上から数えて10mの位までしか表現できません。つまり、地球規模の座標を演算する際にfloatを使うと、10m以下は桁落ちし、大きく精度が落ちます。

それならば倍精度浮動小数であるdoubleで計算すればよいかというと、そう単純な話ではありません。Unityなどの3Dコンピュータグラフィックスを描画するプログラムは、一般的に高速化のためfloatで座標を表すためです。

こうした違いのため、座標変換のプログラム内では倍精度浮動小数であるdoubleで計算し、扱いやすい局所的な座標に計算してからfloatにキャストしてUnityなどに渡す、という方法をとるのがよいでしょう。

22.3.2 _ 平面直角座標系への変換

PLATEAU SDK for Unityで変換された3D都市モデルは、平面直角座標系になります。このため、経緯度のデータを重ねるためには変換をする必要があります。経緯度から平面直角座標系に変換するには、ガウス=クリューゲル変換として知られる計算をします。国土地理院のWebサイトには、測量計算サイトというページがあり、よく使う座標変換をWeb上で計算できます。それぞれの計算式やアルゴリズムも解説されています。ここでは、JGD2011測地系の経緯度として扱います。

図 22-5 測量計算サイト

◾️平面直角座標に換算する計算式

掲載されている計算式を使って、経緯度から平面直角座標系に変換するプログラムを作成してみましょう。平面直角座標への換算のページの上段には、計算式というリンクがあり、そこから式を確認できます。

図 22-6 平面直角座標への換算ページ
図 22-7 平面直角座標に換算する計算式

◾️平面直角座標に換算するC#のプログラム

今回は、Unityなので、この計算式をC#のプログラムとして落とし込みます。

一般にUnityのクラスは、MonoBehaviorクラスから継承することが多いですが、座標変換のクラスは、その必要がないので、素のクラスとして記述します。また、状態を持つ必要もないので、変換メソッドはstaticメソッドとして実装します(リスト 22-1)。

リスト 22-1では、座標の2つの値を返すのにタプルを使っていますが、ここも必要に応じて設計を変えるとよいでしょう(どのように使うかを想定して各自で設計を考えてみてください)。

using System;


public class CoordinateUtil
{
  // GRS80 Ellipsoid
  private const double a = 6378137d;
  private const double F = 298.257222101d;


  // 平面直角座標系のX軸上における縮尺係数
  private const double m0 = 0.9999d;


  private const double n = 1d / (2d * F - 1d);


  // Geographic -> Plane Rectangular
  private const double a1 = 1d * n / 2d - 2d * n * n / 3d + 5d * n * n * n / 16d + 41d * n * n * n * n / 180d - 127d * n * n * n * n * n / 288d;
  private const double a2 = 13d * n * n / 48d - 3d * n * n * n / 5d + 557d * n * n * n * n / 1440d + 281d * n * n * n * n * n / 630d;
  private const double a3 = 61d * n * n * n / 240d - 103d * n * n * n * n / 140d + 15061d * n * n * n * n * n / 26880d;
  private const double a4 = 49561d * n * n * n * n / 161280d - 179d * n * n * n * n * n / 168d;
  private const double a5 = 34729d * n * n * n * n * n / 80640d;


  private const double A0 = 1d + n * n / 4d + n * n * n * n / 64d;
  private const double A1 = -3d / 2d * (n - n * n * n / 8d - n * n * n * n * n / 64d);
  private const double A2 = 15d / 16d * (n * n - n * n * n * n / 4d);
  private const double A3 = -35d / 48d * (n * n * n - 5d * n * n * n * n * n / 16d);
  private const double A4 = 315d * n * n * n * n / 512d;
  private const double A5 = -693d * n * n * n * n * n / 1280d;


  // Plane Rectangular -> Geographic
  private const double b1 = n / 2d - 2d * n * n / 3d + 37d * n * n * n / 96d - n * n * n * n / 360d - 81d * n * n * n * n * n / 512d;
  private const double b2 = n * n / 48d + n * n * n / 15d - 437d * n * n * n * n / 1440d + 46d * n * n * n * n * n / 105d;
  private const double b3 = 17d * n * n * n / 480d - 37d * n * n * n * n / 840d - 209d * n * n * n * n * n / 4480d;
  private const double b4 = 4397d * n * n * n * n / 161280d - 11d * n * n * n * n * n / 504d;
  private const double b5 = 4583d * n * n * n * n * n / 161280d;


  private const double d1 = 2d * n - 2d * n * n / 3d - 2d * n * n * n + 116d * n * n * n * n / 45d + 26d * n * n * n * n * n / 45d - 2854d * n * n * n * n * n * n / 675d;
  private const double d2 = 7d * n * n / 3d - 8d * n * n * n / 5d - 227d * n * n * n * n / 45d + 2704d * n * n * n * n * n / 315d + 2323d * n * n * n * n * n * n / 945d;
  private const double d3 = 56d * n * n * n / 15d - 136d * n * n * n * n / 35d - 1262d * n * n * n * n * n / 105d + 73814d * n * n * n * n * n * n / 2835d;
  private const double d4 = 4279d * n * n * n * n / 640d - 332d * n * n * n * n * n / 35d - 399572d * n * n * n * n * n * n / 14175d;
  private const double d5 = 4174d * n * n * n * n * n / 315d - 144838d * n * n * n * n * n * n / 6237d;
  private const double d6 = 601676d * n * n * n * n * n * n / 22275d;


  public static (double x, double y) JGD2011ToPlaneRectCoord(double lat, double lon, double o_lat, double o_lon)
  {
      double latr = lat * Math.PI / 180d; // TO Radian
      double lonr = lon * Math.PI / 180d;
      double o_latr = o_lat * Math.PI / 180d;
      double o_lonr = o_lon * Math.PI / 180d;


      double t = Math.Sinh(Math.Atanh(Math.Sin(latr))
          - 2d * Math.Sqrt(n) / (1d + n) * Math.Atanh(2d * Math.Sqrt(n) / (1d + n) * Math.Sin(latr)));
      double _t = Math.Sqrt(1d + t * t);


      double Lc = Math.Cos(lonr - o_lonr);
      double Ls = Math.Sin(lonr - o_lonr);


      double Xi_ = Math.Atan(t / Lc);
      double Eta_ = Math.Atanh(Ls / _t);


      double _S = m0 * a / (1d + n) * (A0 * o_latr +
          A1 * Math.Sin(2d * o_latr) +
          A2 * Math.Sin(2d * 2d * o_latr) +
          A3 * Math.Sin(2d * 3d * o_latr) +
          A4 * Math.Sin(2d * 4d * o_latr) +
          A5 * Math.Sin(2d * 5d * o_latr));


      double _A = m0 * a / (1d + n) * A0;


      double x = _A * (Xi_ +
          a1 * Math.Sin(2d * 1d * Xi_) * Math.Cosh(2d * 1d * Eta_) +
          a2 * Math.Sin(2d * 2d * Xi_) * Math.Cosh(2d * 2d * Eta_) +
          a3 * Math.Sin(2d * 3d * Xi_) * Math.Cosh(2d * 3d * Eta_) +
          a4 * Math.Sin(2d * 4d * Xi_) * Math.Cosh(2d * 4d * Eta_) +
          a5 * Math.Sin(2d * 5d * Xi_) * Math.Cosh(2d * 5d * Eta_)) - _S;
      double y = _A * (Eta_ +
          a1 * Math.Cos(2d * 1d * Xi_) * Math.Sinh(2d * 1d * Eta_) +
          a2 * Math.Cos(2d * 2d * Xi_) * Math.Sinh(2d * 2d * Eta_) +
          a3 * Math.Cos(2d * 3d * Xi_) * Math.Sinh(2d * 3d * Eta_) +
          a4 * Math.Cos(2d * 4d * Xi_) * Math.Sinh(2d * 4d * Eta_) +
          a5 * Math.Cos(2d * 5d * Xi_) * Math.Sinh(2d * 5d * Eta_));


      return (x, y);
  }


  public static (double lat, double lon) PlaneRectCoordToJGD2011(double x, double y, double o_lat, double o_lon)
  {
      double o_latr = o_lat * Math.PI / 180d;
      double o_lonr = o_lon * Math.PI / 180d;


      double _S = m0 * a / (1d + n) * (A0 * o_latr +
          A1 * Math.Sin(2d * o_latr) +
          A2 * Math.Sin(2d * 2d * o_latr) +
          A3 * Math.Sin(2d * 3d * o_latr) +
          A4 * Math.Sin(2d * 4d * o_latr) +
          A5 * Math.Sin(2d * 5d * o_latr));


      double _A = m0 * a / (1d + n) * A0;


      double Xi = (x + _S) / _A;
      double Eta = y / _A;


      double Xi_ = Xi - (
          b1 * Math.Sin(2d * Xi) * Math.Cosh(2d * Eta) +
          b2 * Math.Sin(2d * 2d * Xi) * Math.Cosh(2d * 2d * Eta) +
          b3 * Math.Sin(2d * 3d * Xi) * Math.Cosh(2d * 3d * Eta) +
          b4 * Math.Sin(2d * 4d * Xi) * Math.Cosh(2d * 4d * Eta) +
          b5 * Math.Sin(2d * 5d * Xi) * Math.Cosh(2d * 5d * Eta));
      double Eta_ = Eta - (
          b1 * Math.Cos(2d * Xi) * Math.Sinh(2d * Eta) +
          b2 * Math.Cos(2d * 2d * Xi) * Math.Sinh(2d * 2d * Eta) +
          b3 * Math.Cos(2d * 3d * Xi) * Math.Sinh(2d * 3d * Eta) +
          b4 * Math.Cos(2d * 4d * Xi) * Math.Sinh(2d * 4d * Eta) +
          b5 * Math.Cos(2d * 5d * Xi) * Math.Sinh(2d * 5d * Eta));


      double Kai = Math.Asin(Math.Sin(Xi_) / Math.Cosh(Eta_));


      double lat = 180d / Math.PI * (Kai + (
          d1 * Math.Sin(2d * Kai) +
          d2 * Math.Sin(2d * 2d * Kai) +
          d3 * Math.Sin(2d * 3d * Kai) +
          d4 * Math.Sin(2d * 4d * Kai) +
          d5 * Math.Sin(2d * 5d * Kai) +
          d6 * Math.Sin(2d * 6d * Kai)
          ));
      double lon = o_lon + 180d / Math.PI * Math.Atan2(
          Math.Sinh(Eta_), Math.Cos(Xi_));


      return (lat, lon);
  }
}

リスト22-1 平面直角座標に換算するC#のプログラム(CoordinateUtil.cs)

◾️変換メソッド

リスト 22-1の変換メソッドは、次の2つがあります。

① JGD2011ToPlaneRectCoord(double lat, double lon, double o_lat, double o_lon)
JGD2011測地系の経緯度を平面直角座標に変換します。

② PlaneRectCoordToJGD2011(double x, double y, double o_lat, double o_lon)

平面直角座標をJGD2011測地系の経緯度に変換します。

これらの変換メソッドを呼ぶときには、変換したい座標と平面直角座標系の原点座標(o_latおよびo_lon)を一緒に渡します。平面直角座標系の原点座標は、国土地理院のページで確認してください。

【メモ】

国土地理院のページでは、経緯度は、度分秒の60進法で示されています。リスト 22-1では、10進法で指定します。例えば、9系の経度原点「139度50分0秒0000」は、10進数だと「139.83333333333」です。

例えば、緯度35.7334、経度139.7280(大塚駅のあたり)を9系の平面直角座標に変換するには、次のようにします。

double lat = 35.7334d;
double lon = 139.7280d;
(var x,var y) = CoordinateUtil.JGD2011ToPlaneRectCoord(lat, lon, 36d, 139.83333333333d);

以下、この変換プログラムを使って経緯度を持った点をPLATEAUの3D都市モデルと重ねて表示してみましょう。

22.3.3 _ 位置情報の読み込み

まずは、座標を羅列したCSVファイルを読み込み、その場所にオブジェクトを置いてみます。

◾️CSVファイルの準備

CSVファイルは、カンマでデータを区切ったテキスト形式のファイルです。テキストエディタで作成でき、扱いが容易なので、データの保存形式としてよく使われます。

ここでは、「緯度,経度,高さ」のように3つの数値を羅列したファイルを以下のように作ります。

35.729550,139.730014,20.8
35.730756,139.725266,31.8
35.730020,139.727659,24.2
35.730020,139.723019,30.8

リスト22-2 CSVファイルの例

手作業で場所のデータを作成するときは、国土地理院の公開している地理院地図が便利です。地図中心の地理座標や標高などを調べることができます。

図 22-8 地理院地図を使うと、地理座標や標高などを調べられる

◾️Prefabを配置する

次に、このCSVファイルを読み込み、その位置にPrefabを配置するC#スクリプトを書きます。

このサンプルでは、CSVファイルをTextAssetとして読み込んでいますが、FileStreamなどで読み込んでもよいでしょう。

CSVファイルのパースは便利なライブラリなどもありますが、今回は簡単な読み込みロジックを書きました。座標変換には、先ほどリスト 22-1で作成したCoordinateUtilクラスを使っています。平面直角座標系の原点は9系の値(36d, 139.83333333333d)を指定しています。

(var x,var y) = CoordinateUtil.JGD2011ToPlaneRectCoord(lat, lon, 36d, 139.83333333333d);

また、中心座標を合わせるため、Unity SDK for PLATEAUで読み込む際に設定したオフセットの値を引き算しています。

x = x + 29787.4390;
y = y + 9810.3435;

オフセットの座標は読み込んだ3D都市モデルのインスペクタで確認できます。平面直角座標では、Xが南北方向、Yが東西方向を表すので、その点も注意しましょう(図 22-9)。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ReadCSV : MonoBehaviour
{
  public TextAsset csv;
  public GameObject go;


  // Start is called before the first frame update
  void Start()
  {
      var lines = csv.text.Split("\n");
      foreach (var line in lines)
      {
          if (line.Contains(","))
          {
              var tokens = line.Split(",");
              double lat = double.Parse(tokens[0]);
              double lon = double.Parse(tokens[1]);
              double height = double.Parse(tokens[2]);


              (var x,var y) = CoordinateUtil.JGD2011ToPlaneRectCoord(lat, lon, 36d, 139.83333333333d);


              x = x + 29787.4390;
              y = y + 9810.3435;


              Instantiate(go, new Vector3((float)y, (float)height, (float)x), Quaternion.identity);


          }
      }
  }


}

リスト22-3 CSVファイルを読み込み、その位置にPrefabを配置するスクリプト(ReadCSV.cs)

※上記リスト内、「¥」表記の箇所はフォントの設定によっては半角バックスラッシュとなります。なお、文字コードとしては同じですので、リストからコピーのうえ使っていただいて構いません。

図 22-9 中心点(この値を、X座標、Y座標から引き算する)

◾️実行例

実際に実行するには、次のようにします(図 22-10)。

【手順】リスト 22-3を実行する

[1]GameObjectを用意する

プロジェクト上に、リスト 22-3をアタッチしたGameObjectを作ります。

[2]CSVファイルを取り込む

リスト 22-2のCSVファイルをドラッグ&ドロップして、Unityプロジェクトに取り込みます。

[3]実行する

[1]のインスペクタから、[2]のCSVファイルを設定し、実行します。

図 22-10 CSVファイルを読み込んで実行する

実行すると図22-11のように、PLATEAUの3D都市モデルと合わせて指定した位置に赤い柱状のオブジェクトが配置されます。

図 22-11 実行結果

22.3.4 _ スマホのGPS情報を重ねる

次に、スマートフォンのGPS情報を取得して、その位置にオブジェクトを配置してみましょう。

端末のGPSの座標をUnityで取得するには、Input.Locationを使うことができますが、これまでの歴史的経緯のため、経緯度の座標値がfloatの単精度浮動小数で返ってきます。これでは精度として足りません。

そのため、iOSとAndroidの各プラットフォームの機能を直接利用するネイティブライブラリを使います。今回は筆者が作成したものを組み込んで使います。

【メモ】

GPSは衛星測位システムの一般名称として広く使われていますが、正式には、アメリカ合衆国が運用する測位衛星の名称です。衛星測位システム一般は、GNSS(Global Navigation Satellite System)という名称を使います。しかしながら、本項ではわかりやすさのため、GPSを一般名称のように使います。

◾️高精度位置情報ライブラリを導入する

まず、ライブラリのunitypackageをGitHubのリポジトリから入手します。releaseページからunitypackageをダウンロードできるので、これを保存します。

Unityのメニューの[Assets]―[Import Package]―[Custom Package]を選択し、ダウンロードしたHighResLocation.unitypackageを選択して[インポート]をクリックします。

さらにAndroid向けにビルドの設定をします。[Edit]―[Project Settings]を開き、[Player]のAndroidタブで、[Publishing Settings]の[Custom Main Manifest]にチェックを付けます。これにより、Unityのプロジェクトの「Plugins/Android」以下にAndroidManifest.xmlが作成されます。これをテキストエディタで開き、既存の内容に、パーミッション設定の2行を追加します。

<?xml version="1.0" encoding="utf-8"?>
<manifest
    xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.unity3d.player"
    xmlns:tools="http://schemas.android.com/tools">
  <!-- パーミッション設定以下の2行を<application>タグと同じ階層に追加 -->
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
    <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
    <!-- 追加ここまで -->
    <application>
        <activity android:name="com.unity3d.player.UnityPlayerActivity"
                  android:theme="@style/UnityThemeSelector">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
            <meta-data android:name="unityplayer.UnityActivity" android:value="true" />
        </activity>
    </application>
</manifest>

リスト22-4 GPS座標取得のパーミッション設定

◾️現在位置にPrefabを配置する例

端末のGPS位置情報取得の例を、次のリスト 22-5に示します。UnityでUIボタンを設けておき、それがクリックされたときに、リスト 22-5のplaceGameObjectメソッドを呼び出すように構成しておくと、そのときの現在地にPrefabが配置されます。

リスト 22-5では、Startメソッドの処理で、Locationのサービスを初期化しています。

緯度・経度・高さを取得して、オブジェクトを配置しているのはplaceGameObjectメソッドです。

取得した経緯度は、そのまま平面直角座標系へと変換し、中心点のオフセットを引いて、それを配置すべきGameObjectの座標としています。

配置先のPrefabは、goというパラメータで指定するようにしてあります。インスペクタから、適当なPrefabを選択してください。

using System.Collections;
using System.Collections.Generic;
using tech.orthoverse.location;
using UnityEngine;

public class PhoneGPS : MonoBehaviour
{
    public GameObject go;

    private HighResLocation location = new HighResLocation();

    // Start is called before the first frame update
    void Start()
    {
        location.StartLocation();
    }

    public void placeGameObject()
    {
        var loc = location.GetLocation();
        double lat = loc.latitude;
        double lon = loc.longitude;
        double height = loc.ellipsoidalHeight;

        (var x, var y) = CoordinateUtil.JGD2011ToPlaneRectCoord(lat, lon, 36d, 139.83333333333d);

        x = x + 29787.4390;
        y = y + 9810.3435;

        Instantiate(go, new Vector3((float)y, (float)height, (float)x), Quaternion.identity);

    }
}

リスト22-5 現在の場所にオブジェクトを配置する例

22.3.5 _ GPS受信機のデータを重ねる

GPS機能を内蔵しているのは、スマホだけではありません。現在、さまざまなGPS受信機を入手できます。例えば、GARMIN社のGPS受信機(例:GPSMAP 66i)のように、ディスプレイ付きで単体でナビゲーションできるものから、GPS-RTK-SMAモジュールのような、モジュールとして組み込むための基板も市販されています。

スマホに搭載されているような一般的なGPS受信機は、精度が10m以上の1周波の単体GPSが多いのですが、2周波以上の多波長に対応する受信機や、サーバーと補正情報を通信することで最高精度が数cmにもなるRTKという仕組みを搭載した高精度な受信機もあります。

こうした一般的なGPS受信機では、NMEA 0183という形式で、座標や受信している衛星などのさまざまな情報が出力されます。これを保存したファイルを読み込み、座標変換してPLATEAUの3D都市モデル上に重ねて表示してみましょう。

◾️NMEAデータの例

ここでは、ビズステーション株式会社が販売しているDroggerのRWPパッケージを使用し、ソフトバンク株式会社が提供するichimillサービスを補正情報として使用して、RTKによる高精度測位でNMEAデータを作成しました。

NMEA 0183フォーマットでは、行単位のカンマ区切りのテキストデータでGPSに関する情報を表します。例として、今回使用したDroggerのRWPのNMEA出力を、以下に示します。

$GNGGA,070239.87,3543.9279547,N,13943.5410115,E,5,12,0.64,31.530,M,39.331,M,0.9,0150*50
$GNGGA,070240.00,3543.9278367,N,13943.5410033,E,5,12,0.64,31.578,M,39.331,M,1.0,0150*55
$GNGGA,070240.12,3543.9277397,N,13943.5409928,E,5,12,0.64,31.683,M,39.331,M,1.1,0150*5B
$GNGGA,070240.25,3543.9276340,N,13943.5409879,E,5,12,0.65,31.691,M,39.331,M,1.3,0150*51
$GNGGA,070240.37,3543.9275475,N,13943.5409796,E,5,12,0.64,31.720,M,39.331,M,1.4,0150*53
$GNGGA,070240.50,3543.9274611,N,13943.5409773,E,5,12,0.64,31.792,M,39.331,M,1.5,0150*50
$GNGGA,070240.62,3543.9273552,N,13943.5409905,E,5,12,0.65,31.867,M,39.331,M,0.6,0150*5B
$GNGGA,070240.75,3543.9272637,N,13943.5410161,E,5,12,0.64,31.859,M,39.331,M,0.8,0150*5C
$GNGGA,070240.87,3543.9271658,N,13943.5409850,E,5,12,0.60,31.785,M,39.331,M,0.9,0150*53
$GNGGA,070241.00,3543.9270604,N,13943.5409561,E,5,12,0.60,31.772,M,39.331,M,1.0,0150*5A

◾️NMEAデータをパースしてGPS受信機の軌跡を描く例

CSVを読み込んで、その位置にPrefabを配置したプログラムと同じように、NMEAのファイルをパースして、PLATEAUの3D都市モデルと重ね合わせて表示するプログラムを、リスト 22-6に示します。実行すると、図 22-12のように軌跡が重ねて表示されます。

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;


public class ReadNMEA : MonoBehaviour
{
  public TextAsset nmea;


  // Start is called before the first frame update
  void Start()
  {
      LineRenderer lr = GetComponent<LineRenderer>();
      List<Vector3> positions = new List<Vector3>();


      var lines = nmea.text.Split("\n");
      foreach (var line in lines)
      {
          try
          {
              if (line.Contains(","))
              {
                  var tokens = line.Split(",");


                  if (tokens[0] == "$GNGGA")
                  {
                      double lat = double.Parse(tokens[2].Substring(0, 2)) + double.Parse(tokens[2].Substring(2)) / 60d;
                      double lon = double.Parse(tokens[4].Substring(0, 3)) + double.Parse(tokens[4].Substring(3)) / 60d;
                      float height = float.Parse(tokens[9]);


                      (var x, var y) = CoordinateUtil.JGD2011ToPlaneRectCoord(lat, lon, 36d, 139.83333333333d);


                      x = x + 29787.4390;
                      y = y + 9810.3435;


                      positions.Add(new Vector3((float)y, (float)height, (float)x));
                  }
              }
          } catch(Exception e) {
              Debug.LogException(e);
          }
      }
      lr.positionCount = positions.Count;
      lr.SetPositions(positions.ToArray());
  }
}

リスト 22-6 NMEAデータをパースし、軌跡を描く例

※上記リスト内、「¥」表記の箇所はフォントの設定によっては半角バックスラッシュとなります。なお、文字コードとしては同じですので、リストからコピーのうえ使っていただいて構いません。

図 22-12 リスト 22-6の実行結果

リスト 22-6ではLineRendererを使って、GPS受信機の軌跡を線として表示するようにしました。

LineRendererは、線を描画するための仕組みです。LineRendererコンポーネントをリスト 22-6をアタッチしたGameObjectにアタッチして使います。

図 22-13 LineRendererをアタッチする

NMEA 0183フォーマットでは、経緯度の書式が、DDMM.MMMMやDDDMM.MMMMです。パース時には、これらをDD.DDDDDDの形式に変換する処理を入れています。また、NMEA形式では、座標以外にも衛星の位置や電波の受信状況などの情報が記載されているため、座標の行だけを判定して処理しています。

なおGPSが利用する座標系はWGS84ですが、JGD2011と実用上ほとんど同じため、ここではWGS84からJGD2011への変換はしていません。GPS受信機からのWGS84の座標をJGD2011とみなして平面直角座標に変換しています。

22.3.6 _ オープンデータを重ねる

近年、地方公共団体やさまざまな企業などが、地理情報データを公開しています。オープンデータと言われるこれらのデータは、個人でもダウンロードして活用でき、PLATEAUの3D都市モデルを合わせて、さまざまなユースケースを作り出すことができるでしょう。

ここでは、まずいくつかのオープンデータを紹介します。そして、オープンソースのGISであるQGISを使ってデータ変換し、それをUnityで読み込んで表示する方法を解説します。

◾️さまざまなオープンデータ

オープンデータには、さまざまなものがあります。地理情報のオープンデータの集まっているサイトとして、まず、G空間情報センターが挙げられます。

【G空間情報センター】

https://front.geospatial.jp/

図 22-14 G空間情報センター

また、国土交通省の国土数値情報にも、多くの地理情報オープンデータが掲載されています。

【国土数値情報】

https://nlftp.mlit.go.jp/

図 22-15 国土数値情報

今回は、国土交通省の国土数値情報からバス停留所のデータを入手し、それを変換してUnity上でPLATEAUの3D都市モデルと重ねて表示してみます。

◾️バス停留所データの入手

まず、国土数値情報のページから、バス停留所のリンクを選択します。[4. 交通]―[交通]―[バス停留所(ポイント)]のところにあります。

図 22-16 バス停留所データ

各データのページ(例としてバス停留所)には、そのデータの諸元が記載されています。座標系やデータ形状、データ構造などが確認できます。また、ライセンスも確認してください。データによっては商用利用が制限されていることもあります。

ここでは、「東京(シェープ、geojson形式)」の令和4年版のデータをダウンロードして使います。Zipファイルなので、ダウンロードしたら、適宜展開してください。

図 22-17 東京(シェープ、geojson形式)を利用する

◾️QGISを使った変換

ダウンロードしたデータには、シェープファイルというGISのファイル形式のものと、geojson形式のものの、2種類が含まれています。

JSONパーサーを使ってgeojson形式のファイルを読み込むこともできますが、ここではシェープファイルを使うことにします。その理由は、現在でも多くの地理情報がシェープファイルで提供されているため、さまざまな応用が利くと考えられるからです。

シェープファイルをUnityで直接扱うと複雑になるので、いったんQGISで読み込んでCSVに変換し、それをUnityで読み込んで表示する流れとします。

【メモ】

シェープファイルは、拡張子.shpのファイルだけではありません。.shx、.dbfなど、複数の拡張子のファイルの組み合わせからなるファイル群です。どれかが欠けると、正しく処理できないので、ファイルを移動やコピーする際には、注意してください。

QGISを使ってシェープファイルをCSVに変換する操作は、次のとおりです。

【手順】シェープファイルをCSVに変換する

[1]新規プロジェクトを作成する

QGISを開き、新規プロジェクトを作成します。QGISの基本的な操作は、TOPIC 5 「GISで活用する[1/3]|QGIS を使った PLATEAU の活用」を参考にしてください。

図 22-18 新規プロジェクトを作成したところ

[2]ベースマップを読み込む

最初にベースマップとなる地図を読み込みます。OpenStreetMapというオープンな地図データを使います。OpenStreetMapは、マッパーと呼ばれる有志のコミュニティによって構築されているオープンデータです(https://www.openstreetmap.org/about)。

OpenStreetMapを読み込むには、[レイヤ]―[レイヤを追加]―[XYZレイヤを追加]を選択します。

図 22-19 XYZレイヤを追加する

データソースマネージャのダイアログが開いたら、プルダウンメニューから、[OpenStreetMap]を選択し、「追加」ボタンをクリックします。すると、OpenStreetMapの地図が表示されるようになります。操作が終わったら、[閉じる]をクリックして、ダイアログを閉じてください。

図 22-20 OpenStreetMapを追加する

[3]バス停留所データを読み込む

次に、ダウンロードしておいたバス停留所のデータ(シェープファイル)を読み込みます。

まずは、[レイヤ]―[レイヤを追加]―[ベクタレイヤを追加]を選択します。

図 22-21 ベクタレイヤを追加する

[ソース]のファイル選択ダイアログが表示されたら、あらかじめダウンロードして展開しておいたバス停留所データのうち、拡張子がshpのファイルを選んでください。選んだら、[追加]ボタンをクリックすると、データが読み込まれます。

図 22-22 拡張子がshpのファイルを選ぶ

東京都のあたりをズームインしていくと、図 22-23のように、OpenStreetMapの地図上に、バス停の点が表示されていることがわかります。

図 22-23 バス停の位置が地図上に表示された

[4]CSV形式としてエクスポートする

次に、Unityから利用したい部分を切り取り、CSVとして保存します。地物の選択ツールをクリックして選択します。

図 22-24 地物の選択ツールをクリックする

マウスで範囲をドラッグして、範囲内の点を選択します。

図 22-25 範囲内の点(地物)を選択する

点を選択したら、読み込んだレイヤの上で右クリックして、[エクスポート]―[新規ファイルに地物を保存]を選択します。

図 22-26 地物をエクスポートする

エクスポートのダイアログでは、[カンマで区切られた値[CSV]]を選択し、出力先のファイル名を指定します。[選択地物のみ保存]をチェックし、その他の設定は、図22-27のとおりとします。

図 22-27 エクスポートの設定

エクスポートすると、次の内容のCSVファイルが作成されます。

X,Y,P11_001,P11_002,P11_003_01,P11_003_02,P11_003_03,P11_003_04,P11_003_05,P11_003_06,P11_003_07,P11_003_08,P11_003_09,P11_003_10,P11_003_11,P11_003_12,P11_003_13,P11_003_14,P11_003_15,P11_003_16,P11_003_17,P11_003_18,P11_003_19,P11_003_20,P11_003_21,P11_003_22,P11_003_23,P11_003_24,P11_003_25,P11_003_26,P11_003_27,P11_003_28,P11_003_29,P11_003_30,P11_003_31,P11_003_32,P11_003_33,P11_003_34,P11_003_35,P11_004_01,P11_004_02,P11_004_03,P11_004_04,P11_004_05,P11_004_06,P11_004_07,P11_004_08,P11_004_09,P11_004_10,P11_004_11,P11_004_12,P11_004_13,P11_004_14,P11_004_15,P11_004_16,P11_004_17,P11_004_18,P11_004_19,P11_004_20,P11_004_21,P11_004_22,P11_004_23,P11_004_24,P11_004_25,P11_004_26,P11_004_27,P11_004_28,P11_004_29,P11_004_30,P11_004_31,P11_004_32,P11_004_33,P11_004_34,P11_004_35,P11_005
139.72143740043,35.7377671299951,上池袋三丁目,東京都,"深夜02,王40甲,王40出入,王55,草63,草64",,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,"2,2,2,2,2,2",,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
139.710143356298,35.7377110240603,池袋小学校,国際興業(株),池07,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,"1",,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
139.712844798114,35.7376363769469,豊島清掃事務所,国際興業(株),"池07,池55,赤51,赤97,光02",,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,"1,1,1,1,1",,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
139.71967678249,35.7361567137618,上池袋一丁目,東京都,"深夜02,王40甲,王40出入,王55,草63,草64",,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,"2,2,2,2,2,2",,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
139.715334410769,35.7352150452483,健康プラザとしま,国際興業(株),池07,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,"1",,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,
139.736766495305,35.7350547517637,とげぬき地蔵前,東京都,"草63,草64",,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,"2,2",,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,

◾️変換したCSVデータを読み込みUnityで表示する

ここまできたら、先ほど提示したCSVファイルの読み込みのプログラム(前掲のリスト22-3)を多少変更して、このデータをUnityに読み込めるようにします(リスト22-7)。

エクスポートしたCSVファイルには、高さの情報が含まれていないので、高さは0としました。また、最初の行がヘッダーになっているので、数値としてパースできなかったときの例外処理で対応しました。

配置先のPrefabのGameObjectには、テキストを追加して、バス停の名前を表示するようにしました(図22-28)。実行結果の例を図22-29に示します。

using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;


public class OpenData : MonoBehaviour
{
  public TextAsset csv;
  public GameObject go;


  // Start is called before the first frame update
  void Start()
  {
      var lines = csv.text.Split("\n");
      foreach (var line in lines)
      {
          var tokens = line.Split(",");
          try
          {
              double lon = double.Parse(tokens[0]);
              double lat = double.Parse(tokens[1]);
              double height = 0d;
              string name = tokens[2];


              (var x, var y) = CoordinateUtil.JGD2011ToPlaneRectCoord(lat, lon, 36d, 139.83333333333d);


              x = x + 29787.4390;
              y = y + 9810.3435;


              var obj = Instantiate(go, new Vector3((float)y, (float)height, (float)x), Quaternion.identity);
              obj.transform.GetComponentInChildren<TextMesh>().text = name;
          } catch (Exception e)
          {
              Debug.LogException(e);
          }
      }
  }
}

リスト 22-7 CSV ファイルを読み込んで、その場所に Prefab を置く

※上記リスト内、「¥」表記の箇所はフォントの設定によっては半角バックスラッシュとなります。なお、文字コードとしては同じですので、リストからコピーのうえ使っていただいて構いません。

図 22-28 バス停の名前をテキストで表示できるようにした
図 22-29 リスト 22-7の実行結果
コラム:点以外の幾何形状(ジオメトリ)の読み込み

シェープファイルには、点以外にも、線、面などの幾何形状が格納されていることがあります。また、シェープファイル以外のさまざまな形式のデータでも、これらの線や面の幾何形状を読み込みたいときがあります。

線に関しては、「22.3.4 スマホのGPS情報を重ねる」で説明したLineRendererで描画する方法もありますが、UnityのMeshには、各頂点をスクリプトから設定する仕組みがあり、それを利用して線や面を描画することもできます。

面の場合、Unityでは三角形以外の多角形を直接描画できないので、多角形を三角形に分割するアルゴリズムを利用します。Earcut(参考:MapboxのEarcutアルゴリズムのJavaScript実装)などが有名なので、参考にしてください。

また、CSV以外でやりとりする方法として、WKT(Well Known Text)という、幾何形状情報をテキスト化してアプリ間を取り回すためのフォーマットも使えます。QGISでは、エクスポート時にこれを選択できるので、Unity側でパーサーを作成すれば、読み込めます。

【文】

於保俊(株式会社ホロラボ)

【協力】

大澤文孝