Cesiumでジオラマアプリを作ってみよう! 第2回「座標編」(全4回)
はじめに
第2回「座標編」では、第1回「座標編」の土台を動かしたり、GlobeAnchorの活用方法を紹介したいと思います。その過程で座標系の基礎知識が必要になってくるので、予め理解しておいてください。
第2回「座標編」目次
座標系の基礎知識
Cesiumは以下の座標系を扱うことができます。
緯度,経度,高度 : 地球上の位置を示す地理座標
ECEF(Earth-Centered , Earth-Fixed) : 地球の中心を原点とした地球固定座標系
Cesiumを扱う上では、地理座標とUnity空間の座標(x,y,z)を意識する必要があります。
例えば、Unityの座標が(0,0,0)の時は、地理座標は(35.68958,139.6917,200)の様に相互変換をする必要があります。
Cesiumにはこれらの変換を容易にする機能が備わっています。
CesiumGeoreference
TransformUnityPositionToEarthCenteredEarthFixed() : 座標をUnity系からECEF系に変換
TransformUnityDirectionToEarthCenteredEarthFixed() : 向きをUnity系からECEF系に変換
TransformEarthCenteredEarthFixedPositionToUnity() : 座標をECEF系からUnity系に変換
TransformEarthCenteredEarthFixedDirectionToUnity() : 向きをECEF系からUnity系に変換
CesiumGlobeAnchor
Sync() : Untiy座標系と地理座標を即座に同期
WGS84座標系
Cesiumは、GPSなどでも利用されている”WGS84 (World Geodetic System 1984)”という座標系を採用しています。WGS84は、地球の形を最も正確に近似した楕円体モデルに基づいており、世界中で広く利用されているため、Cesiumで作成したジオラマアプリを他の地理サービスと連携する際にも便利です。
日本測地系には対応していないため、日本の地理サービスと連携する際には注意が必要です。
国土地理院のサイトで違いが分かりやすく説明されています。
前回の土台を動かしてみる
第1回「描画編」で作成した土台を地理座標、Unity座標ともに動かしてみたいと思います。
管理しやすいように、Google Photorealistic 3D Tilesの親となるTilesetParentという名前のGameObjectを作成しておきましょう。親子関係を以下の様にしておきます。
CesiumGeoreferenceに土台を動かすための適当なスクリプトを作成してアタッチしておきましょう。
クラス名はBaseTilesetControllerにしておきます。次からこのスクリプトを改修していきます。
まずは地理座標とUnity座標の移動で使用する移動予定先の座標を取得する関数です。
カメラの向きを考慮して移動するようになっています。
/// <summary>
/// 移動感度
/// </summary>
[SerializeField]
private float _moveSensitivity = 0.05f;
/// <summary>
/// 移動予定先の座標の取得
/// </summary>
/// <param name="vector">移動方向</param>
/// <returns></returns>
private Vector3 getDestinationPosition(Vector2 vector)
{
var cameraT = Camera.main.transform;
var sensitivity = _moveSensitivity * -1f;
var vertical = cameraT.forward * vector.y * sensitivity;
var horizontal = cameraT.right * vector.x * sensitivity;
var moveVector = vertical + horizontal;
//高さは無視
moveVector.y = 0f;
return moveVector;
}
土台を動かすと、描画の中心がずれるため、クリッピングがおかしくなります。
動かすたびに以下の様な関数で更新しましょう。
(※スクリプトから更新するためには、PivotのExposedをShaderGraphで外しておく必要があります)
/// <summary>
/// PivotプロパティのID
/// </summary>
private readonly int _pivotPropartyId = Shader.PropertyToID("_Pivot");
/// <summary>
/// 描画の中心の更新
/// </summary>
private void updateClippingPivot()
{
Vector3 tileSetPos = _tilesetParentT.transform.position;
Vector2 pivot = new Vector2(tileSetPos.x,tileSetPos.z);
Shader.SetGlobalVector(_pivotPropartyId,pivot);
}
以上を踏まえてUnity座標を動かす時は以下の様になります。
そのままtransform.positionを動かすのみで特別な処理等は必要ないです。
呼び出す時に、コントローラーやキーボードの入力の値を渡して上げれば良いです。
/// <summary>
/// Tilesetの親トランスフォーム
/// </summary>
private Transform _tilesetParentT;
/// <summary>
/// 土台の水平移動
/// </summary>
/// <param name="vector">移動方向</param>
private void moveTileset(Vector2 vector)
{
var moveVector = getDestinationPosition(vector);
_tilesetParentT.position += moveVector;
updateClippingPivot();
}
次は地理座標の移動です。
CesiumGeoreferenceが持っているlatitudeやlongitudeをそのまま更新でも緯度経度は動かせるのですが、カメラがUnity世界の正面以外を見たり、土台が回転したりすると、思った方向に移動しなくなると思います。
そこで土台が回転していた場合、その分のベクトルを移動させて、向きを地球座標系に変換してから渡しています。
呼び出す時は同様に、コントローラーやキーボードの入力の値を渡して上げれば良いです。
/// <summary>
/// 地理座標の移動
/// </summary>
/// <param name="vector">移動方向</param>
private void moveGeographicCoordinates(Vector2 vector)
{
var moveVector = getDestinationPosition(vector);
//土台の回転分、ベクトルを回転させる
var angle = _tilesetParentT.rotation.eulerAngles.y;
if (angle != 0) angle = 360f - angle;
var rotation = Quaternion.AngleAxis(angle, Vector3.up);
var rotateVector = rotation * moveVector;
double3 dMoveVector = new double3(rotateVector);
//Unity座標系から地球座標系の方向に変換
var unityDirection = _cesiumGeoreference.TransformUnityDirectionToEarthCenteredEarthFixed(dMoveVector);
_cesiumGeoreference.ecefX += unityDirection.x;
_cesiumGeoreference.ecefY += unityDirection.y;
_cesiumGeoreference.ecefZ += unityDirection.z;
}
土台の高さの移動や回転は以下の通りで特別な処理は必要ないです。
/// <summary>
/// 土台の高さの移動
/// </summary>
/// <param name="value">高さ</param>
private void moveHeightTileset(float value)
{
value *= _moveSensitivity;
var posBuff = _tilesetParentT.position;
posBuff.y += value;
_tilesetParentT.position = posBuff;
}
/// <summary>
/// 土台の回転
/// </summary>
/// <param name="angle">角度</param>
private void rotateTileset(float angle)
{
_tilesetParentT.Rotate(0f,angle,0f);
}
これで自由に土台を動かせるようになったと思います。
CesiumGlobeAnchor
3DモデルやuGUIのピン等のGameObjectにアタッチし、緯度経度を指定することで、ほぼ地球上の好きな場所に配置することができます。また、地理座標を動かした時にUnity座標も同時に動かしてくれます。反対に、AnchorのUnity座標を動かすことでも地理座標が同時に更新されます。
自動で追従してくれるのは便利ですが、円形にクリッピングをしていると、円をはみ出した時にそのまま残ってしまいます。以下の様な円の判定で防ぐことができます。
/// <summary>
/// 描画範囲のチェック
/// </summary>
private void renderingCheck()
{
var radius = _renderRange;
var parentPos = transform.parent.position;
var myPos = transform.position;
var dx = myPos.x - parentPos.x;
var dz = myPos.z - parentPos.z;
var distanceSquared = dx * dx + dz * dz;
var isPointInCircle = distanceSquared <= radius * radius;
var alpha = isPointInCircle ? 1f : 0f;
_canvasGroup.alpha = alpha;
}
緯度経度を取得できるため、このUnity座標では緯度経度は何か?というのを調べるのにも使えます。
Unity座標を代入したあとに、緯度経度に反映されるには1ライフサイクル待機する必要がありますが、
Sync()を呼ぶことで即座に反映することができます。
CesiumGlobeAnchor _globeAnchor;
//AnchorのUnity座標を更新
_globeAnchor.transform.position = targetPosition;
//座標を同期して緯度経度の反映
_globeAnchor.Sync();
//経度の取得
var longitude = _globeAnchor.longitudeLatitudeHeight.x;
//緯度の取得
var latitude = _globeAnchor.longitudeLatitudeHeight.y;
Raycastして地理座標の取得
前項のCesiumGlobeAnchorとRaycastを組み合わせることで、土台をマウスでクリックや、コントローラーのポインターを当てた時等に、その部分の地理座標を取得することができます。
//マウス右クリック時
if (Input.GetMouseButtonDown(1))
{
RaycastHit hit;
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
float maxDistance = 10000.0f;
if (Physics.Raycast(ray, out hit, maxDistance))
{
_globeAnchor.transform.position = hit.point;
_globeAnchor.Sync();
var longitude = _globeAnchor.longitudeLatitudeHeight.x;
var latitude = _globeAnchor.longitudeLatitudeHeight.y;
}
}
当たった場所からさらに、その場所の真上から真下にRaycastすることで、3DTiles上の建物高さも取得することができます。高度の代わりに利用することもできると思います。
/// <summary>
/// 自身の真上から垂直にRayCastする
/// </summary>
/// <param name="targetT">対象のTransform</param>
/// <param name="hitPoint">当たった場所</param>
/// <returns></returns>
public static bool VerticallyCast(Transform targetT, out Vector3 hitPoint)
{
const float RAY_HEIGHT = 100;
var origin = targetT.position;
origin.y += RAY_HEIGHT;
if(Physics.Raycast(origin,Vector3.down,out RaycastHit hit,Mathf.Infinity))
{
hitPoint = hit.point;
return true;
}
hitPoint = Vector3.zero;
return false;
}
当たり判定が取れない時は、Cesium3DTilesetのCreatePhysicsMeshesにチェックが入っているか確認してください。
XR Interaction Toolkit を使用する時
Rayの判定を取得するために、XRBaseInteractableを継承したクラスを作成しておく必要あります。
ここで選択された時に、カメラやコントローラー等からRaycastしています。
/// <summary>
/// Cesium 3DTilesのRayCastのInteractable
/// </summary>
public class CesiumRayInteractable : XRBaseInteractable
{
public UnityAction<Vector3> onSelected;
protected override void OnDestroy()
{
onSelected = null;
base.OnDestroy();
}
protected override void OnSelectExited(SelectExitEventArgs args)
{
RaycastHit hit;
Transform interactorTransform = args.interactorObject.transform;
if (Physics.Raycast(interactorTransform.position,
interactorTransform.TransformDirection(Vector3.forward),
out hit, Mathf.Infinity))
{
onSelected?.Invoke(hit.point);
}
}
}//CesiumRayInteractable End
このコンポーネントを3DTilesが生成された時にアタッチします。
/// <summary>
/// Googleの3DTiles
/// </summary>
[SerializeField] private Cesium3DTileset _googleTileset;
void Start()
{
//3DTilesが生成された時のコールバック登録
_googleTileset.OnTileGameObjectCreated += onTileGameObjectCreated;
}
/// <summary>
/// Rayが当たった時
/// </summary>
/// <param name="hitPosition"></param>
private void onRayCastTiles(Vector3 hitPosition)
{
_rayAnchor.transform.position = hitPosition;
_rayAnchor.Sync();
var latitude = _rayAnchor.longitudeLatitudeHeight.y;
var longitude = _rayAnchor.longitudeLatitudeHeight.x;
Debug.Log($"Hit: 座標:{hitPosition} : 緯度:{latitude},経度:{longitude}");
}
/// <summary>
/// 3DTilesオブジェクトが作成された時のコールバック
/// </summary>
/// <param name="tileObj"></param>
private void onTileGameObjectCreated(GameObject tileObj)
{
//コントローラーのRayがHit時のコールバック登録
var interactable = tileObj.AddComponent<CesiumRayInteractable>();
interactable.onSelected = onRayCastTiles;
}
Rayを飛ばすController側も環境ごとに準備が必要です。
以下はEditorでマウスクリックした時や、スマートフォンでタップ時に必要な設定の例です。
Quest3等のHMDならサンプルの設定がそのまま使えることが多いと思います。
新規にGameObjectを作成して、名前はScreenSpaceControllerとしておきます。
そのGameObjectに以下のコンポーネントをアタッチします。
XR Screen Space Controller
XR Ray Interactor
XR Interaction Manager
Input Action Manager
Input Action Manager にActionAssetを登録します。
XR Interaction ToolkitのSamplesのStarterAssetsに入っているXRI Default Input Actionsで良いかと思います。
XR Screen Space Controllerにも各種Actionの設定が必要です。XRI Default Input Actionsからの引用で良いと思います。
これで準備が整ったため、マウス左クリックした時にUnity座標と地理座標が分かるようになったと思います。
スクリプト全容
BaseTilesetController
土台の移動とRaycastのスクリプト全容です。
/// <summary>
/// 土台を操作する
/// </summary>
[RequireComponent(typeof(CesiumGeoreference))]
public class BaseTilesetController : MonoBehaviour
{
#region Variables
/// <summary>
/// 移動感度
/// </summary>
[SerializeField]
private float _moveSensitivity = 0.05f;
/// <summary>
/// Googleの3DTiles
/// </summary>
[SerializeField]
private Cesium3DTileset _googleTileset;
/// <summary>
/// Rayの座標変換用アンカー
/// </summary>
[SerializeField]
private CesiumGlobeAnchor _rayAnchor;
/// <summary>
/// ピンの親トランスフォーム
/// </summary>
[SerializeField]
private Transform _pinParentT;
/// <summary>
/// PinのPrefab
/// </summary>
[SerializeField]
private GameObject _pinPrefab;
/// <summary>
/// CesiumGeoreference
/// </summary>
private CesiumGeoreference _cesiumGeoreference;
/// <summary>
/// Tilesetの親トランスフォーム
/// </summary>
private Transform _tilesetParentT;
/// <summary>
/// PivotプロパティのID
/// </summary>
private readonly int _pivotPropartyId = Shader.PropertyToID("_Pivot");
#endregion
#region UnityLifeCycle
void Start()
{
//3DTilesが生成された時のコールバック登録
_googleTileset.OnTileGameObjectCreated += onTileGameObjectCreated;
_cesiumGeoreference = GetComponent<CesiumGeoreference>();
_tilesetParentT = transform.GetChild(0);
updateClippingPivot();
}
void Update()
{
//Space押下中は緯度経度の移動
bool isGeoMove = Input.GetKey(KeyCode.Space);
//--- WASDキー(水平移動、緯度経度移動) ---//
if (Input.GetKey(KeyCode.W))
{
//経度
if(isGeoMove) moveGeographicCoordinates(new Vector2(0f, 1f));
else moveTileset(new Vector2(0f, -1f));
}
if (Input.GetKey(KeyCode.S))
{
//経度
if(isGeoMove) moveGeographicCoordinates(new Vector2(0f, -1f));
else moveTileset(new Vector2(0f, 1f));
}
if (Input.GetKey(KeyCode.A))
{
//緯度
if(isGeoMove) moveGeographicCoordinates(new Vector2(-1f, 0f));
else moveTileset(new Vector2(1f, 0f));
}
if (Input.GetKey(KeyCode.D))
{
//緯度
if(isGeoMove) moveGeographicCoordinates(new Vector2(1f, 0f));
else moveTileset(new Vector2(-1f, 0f));
}
//--- 上下左右キー(高さ、回転) ---//
if (Input.GetKey(KeyCode.UpArrow))
{
moveHeightTileset(1f);
}
if (Input.GetKey(KeyCode.DownArrow))
{
moveHeightTileset(-1f);
}
if (Input.GetKey(KeyCode.LeftArrow))
{
rotateTileset(-0.1f);
}
if (Input.GetKey(KeyCode.RightArrow))
{
rotateTileset(0.1f);
}
//マウス右クリック時
if (Input.GetMouseButtonDown(1))
{
RaycastHit hit;
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
float maxDistance = 10000.0f;
if (Physics.Raycast(ray, out hit, maxDistance))
{
Debug.Log($"Hit Obj: Name:{hit.transform.name} , Layer:{hit.transform.gameObject.layer}" );
onRayCastTiles(hit.point);
}
}
}
private void OnDestroy()
{
//3DTilesが生成された時のコールバック解除
_googleTileset.OnTileGameObjectCreated -= onTileGameObjectCreated;
}
#endregion
#region PrivateMethod
/// <summary>
/// 土台の水平移動
/// </summary>
/// <param name="vector">移動方向</param>
private void moveTileset(Vector2 vector)
{
var moveVector = getDestinationPosition(vector);
_tilesetParentT.position += moveVector;
updateClippingPivot();
}
/// <summary>
/// 土台の高さの移動
/// </summary>
/// <param name="value">高さ</param>
private void moveHeightTileset(float value)
{
value *= _moveSensitivity;
var posBuff = _tilesetParentT.position;
posBuff.y += value;
_tilesetParentT.position = posBuff;
}
/// <summary>
/// 土台の回転
/// </summary>
/// <param name="angle">角度</param>
private void rotateTileset(float angle)
{
_tilesetParentT.Rotate(0f,angle,0f);
}
/// <summary>
/// 地理座標の移動
/// </summary>
/// <param name="vector">移動方向</param>
private void moveGeographicCoordinates(Vector2 vector)
{
var moveVector = getDestinationPosition(vector);
//土台の回転分、ベクトルを回転させる。
var angle = _tilesetParentT.rotation.eulerAngles.y;
if (angle != 0) angle = 360f - angle;
var rotation = Quaternion.AngleAxis(angle, Vector3.up);
var rotateVector = rotation * moveVector;
double3 dMoveVector = new double3(rotateVector);
//Unity座標系から地球座標系の方向に変換
var unityDirection = _cesiumGeoreference.TransformUnityDirectionToEarthCenteredEarthFixed(dMoveVector);
_cesiumGeoreference.ecefX += unityDirection.x;
_cesiumGeoreference.ecefY += unityDirection.y;
_cesiumGeoreference.ecefZ += unityDirection.z;
}
/// <summary>
/// 移動予定先の座標の取得
/// </summary>
/// <param name="vector">移動方向</param>
/// <returns></returns>
private Vector3 getDestinationPosition(Vector2 vector)
{
var cameraT = Camera.main.transform;
var sensitivity = _moveSensitivity * -1f;
var vertical = cameraT.forward * vector.y * sensitivity;
var horizontal = cameraT.right * vector.x * sensitivity;
var moveVector = vertical + horizontal;
//高さは無視
moveVector.y = 0f;
return moveVector;
}
/// <summary>
/// 描画の中心の更新
/// </summary>
private void updateClippingPivot()
{
Vector3 tileSetPos = _tilesetParentT.transform.position;
Vector2 pivot = new Vector2(tileSetPos.x,tileSetPos.z);
Shader.SetGlobalVector(_pivotPropartyId,pivot);
}
/// <summary>
/// Rayが当たった時
/// </summary>
/// <param name="hitPosition"></param>
private void onRayCastTiles(Vector3 hitPosition)
{
_rayAnchor.transform.position = hitPosition;
_rayAnchor.Sync();
var latitude = _rayAnchor.longitudeLatitudeHeight.y;
var longitude = _rayAnchor.longitudeLatitudeHeight.x;
Debug.Log($"Hit: 座標:{hitPosition} : 緯度:{latitude},経度:{longitude}");
//Rayの当たった場所にPinを生成
var pinObj = Instantiate(_pinPrefab,hitPosition,Quaternion.identity,_pinParentT);
var pinRenderer = pinObj.GetComponent<PinRenderer>();
pinRenderer.SyncGeographicPosition();
}
/// <summary>
/// 3DTilesオブジェクトが作成された時のコールバック
/// </summary>
/// <param name="tileObj"></param>
private void onTileGameObjectCreated(GameObject tileObj)
{
//コントローラーのRayがHit時のコールバック登録
var interactable = tileObj.AddComponent<CesiumRayInteractable>();
interactable.onSelected = onRayCastTiles;
}
#endregion
}//BaseTilesetController End
PinRenderer
ピンのスクリプト全容です。適当にuGUIを作成してアタッチしたら良いと思います。
/// <summary>
/// ピンの描画
/// </summary>
public class PinRenderer : MonoBehaviour
{
/// <summary>
/// 緯度のテキスト
/// </summary>
[SerializeField]
private Text _latitudeText;
/// <summary>
/// 経度のテキスト
/// </summary>
[SerializeField]
private Text _longitudeText;
/// <summary>
/// CanvasGroup
/// </summary>
[SerializeField]
private CanvasGroup _canvasGroup;
/// <summary>
/// 閉じるボタン
/// </summary>
[SerializeField]
private Button _closeButton;
/// <summary>
/// GlobeAnchor
/// </summary>
[SerializeField]
private CesiumGlobeAnchor _globeAnchor;
/// <summary>
/// 描画範囲
/// </summary>
private float _renderRange = 0.5f;
#region UnityLifeCycle
private void Start()
{
_closeButton.onClick.AddListener(() =>
{
Destroy(this.gameObject);
});
}
private void Update()
{
billBoard();
renderingCheck();
}
#endregion
#region PublicMethod
/// <summary>
/// 地理座標の同期
/// </summary>
public void SyncGeographicPosition()
{
_globeAnchor.Sync();
double latitude = _globeAnchor.longitudeLatitudeHeight.y;
double longitude = _globeAnchor.longitudeLatitudeHeight.x;
var latitudeStr = latitude.ToString(CultureInfo.InvariantCulture);
var longitudeStr = longitude.ToString(CultureInfo.InvariantCulture);
_latitudeText.text = $"緯度:{latitudeStr}";
_longitudeText.text = $"経度:{longitudeStr}";
}
#endregion
#region PrivateMethod
/// <summary>
/// ビルボード
/// </summary>
private void billBoard()
{
var target = Camera.main.transform;
Vector3 directionToTarget = target.position - transform.position;
directionToTarget.y = 0;
Quaternion rotation = Quaternion.LookRotation(-directionToTarget);
transform.rotation = rotation;
}
/// <summary>
/// 描画範囲のチェック
/// </summary>
private void renderingCheck()
{
var radius = _renderRange;
var parentPos = transform.parent.position;
var myPos = transform.position;
var dx = myPos.x - parentPos.x;
var dz = myPos.z - parentPos.z;
var distanceSquared = dx * dx + dz * dz;
var isPointInCircle = distanceSquared <= radius * radius;
var alpha = isPointInCircle ? 1f : 0f;
_canvasGroup.alpha = alpha;
}
#endregion
}//PinRenderer End
まとめ
前回の「描画編」と今回の「座標編」でジオラマアプリに必要な基礎的な部分はできたと思います。
後は、いかに応用してオリジナルのジオラマアプリを作成するかだと思います。
次回の「API連携編」ではGoogleAPIやOpenAIを活用してどんなことができるか紹介したいと思います。