はじめに
PLATEAUで広いエリアに対応したいと思ったことはありますでしょうか?
Cesium for Unityで広範囲を動かすことも出来ますが、ハイエンドPCでないと満足には動かせず、モバイル端末やHMDでは厳しいと思います。そんな中で、MagicLeap2で広域に対応したいという話があったので、どのような考え方で実装したかを紹介したいと思います。PLATEAUを扱った記事は過去にもいくつか挙げています。
開発環境
- Unity 2022.3.4f1
- PLATEAU SDK for Unity v1.1.4
- Magic Leap2 (OS Version1.2.0)
- Magic Leap MLSDK v1.3.0-dev2
- Magic Leap SDK v1.8.0
分割読み込みのロジック
Unityで分割読み込みとなると、マルチシーンの採用を思い浮かべる方も多いと思いますが、今回はモデルである建物全体の拡大縮小と移動が必要であったため、採用を見送りました。他の手法として、オープンワールド的なゲームを作成する場合、オブジェクトの読み込みに4分木空間分割や、その3D版の8分木空間分割等のアルゴリズムを使用すると思います。以下のサイトの説明が詳しいです。
- https://kurokumasoft.com/2023/01/15/octree-openworld-optimization/
- http://marupeke296.com/COL_2D_No8_QuadTree.html
Google Map等のマップアプリに置いては、「ズームレベル」という概念を用いており、どれだけ拡大縮小されているかによって、読み込む領域を決めているみたいです。以下の国土地理院の説明が分かりやすいです。
これらを参考に、Unityでは当たり判定が取りやすいことを活かして、以下のようにしました。
あらかじめ分割されたエリアをタイル状に配置し、これに沿って分割したモデルも用意しておく。各エリアにはBoxColliderをアタッチしておきます。

分割された各エリアにはプレイヤーが入った時に読み込む隣接するエリアを設定しておきます。

読み込む最大エリア数は、プレイヤー自身のいるエリアとその隣接するエリアを含めて、3x3の9エリア分を読み込みます。プレイヤーがエリアの端に移動しても、隣接するエリアが表示されるようにそうしています。

プレイヤーが他のエリアに入ったら、必要なエリアを読み込み、要らないエリアを削除します。例えば、25番から24番に入ったら、16番、23番、30番のエリアを追加で読み込み、19番、26番、33番のエリアを削除します。

プレイヤーがそのエリアに入ったか?の判定にはColliderを利用しています。以下コードの一部
/// <summary>
/// 分割エリアに侵入した時
/// </summary>
/// <param name="other"></param>
private void OnTriggerEnter(Collider other)
{
var divideArea = other.gameObject.GetComponent<DioDivideArea>();
if(divideArea == null) return;
//入ったエリアに設定されている隣接エリアの取得
var refNumbers = divideArea.RefAreaNumbers;
//モデルの読み込み・破棄を行うタスクの作成
var taskLazy = UniTask.Lazy(async () => await changeBuildingModelsAsync(refNumbers));
//確実に順番に実行してもらうためにQueueにいれて実行。
_taskQueue.Enqueue(taskLazy);
}
さらにアプリ容量(メモリ)の節約のために、分割したモデルはアセットバンドル化して外部ディレクトリに配置しています。サーバーに置くのもありだと思います。
- 外部ディレクトリの参照箇所
- Unity
- PersistentDataPath
- MagicLeap2
- /storage/self/primary/Android/data/<Package Name>/files/
- Unity

今回使用したモデル
使用するモデルは何でも良いですが、今回はPLATEAU SDK for Unityを利用して、インポートしたモデルをそのまま使用しています。新宿駅を中心に9区域分をインポートしました。
※PLATEAU SDK for Unity の詳細な使い方は省略します。

LOD2テクスチャ付きをサーバーからインポートしたのですが、かなり時間がかかるため余裕がある時にやった方が良いと思います。

モデルをエクスポートすると、バラバラに作成されるので、一つのPrefabにまとめておきます。
ツールの紹介
あらかじめ分割しておいたモデルを作るためにUnityをEditor拡張してツールを作成しました。「エリア分割開始」のボタンを押したら、分割されたエリアがアセットバンドル化して、外部ディレクトリに保存されるようにしています。Mesh本体の分割は行わずに、分割したエリア内にモデルの座標があったら、そのエリアのモデルとして扱うみたいにやっています。

インスペクタから諸々設定した後、読み込んだ新宿全体に合わせて、オレンジ色の全体のエリア領域を調整すればエリア分割の準備は大丈夫です。この中でエリア分割を行います。

エリア分割のコードの一部です。やっていることは単純でエリア全体領域を指定分割数で割っているのみです。
/// <summary>
/// 分割エリアの作成
/// </summary>
/// <param name="verticalDivisions">垂直分割数</param>
/// <param name="horizontalDivisions">水平分割数</param>
/// <returns>分割したエリア群</returns>
public List<DioDivideArea> CreateAreaDivide(float verticalDivisions,float horizontalDivisions)
{
//オレンジ色のエリア全体の領域のCollider取得
var collider = _boundingAreaObject.GetComponent<BoxCollider>();
var bounds = collider.bounds;
//縦・横に分割したときの1エリア分のサイズの取得
float horizontalDivideSize = bounds.size.x / horizontalDivisions;
float verticalDivideSize = bounds.size.z / verticalDivisions;
//エリア全体の領域の大きさに合わせて、分割エリアの大きさの設定
var parentScale = _boundingAreaObject.transform.lossyScale;
float scaleX = horizontalDivideSize / parentScale.x;
float scaleY = verticalDivideSize / parentScale.z;
_divideAreaCube.transform.localScale = new Vector3(scaleX, parentScale.y * 5f, scaleY);
int areaNumber = 0;
List<DioDivideArea> divideAreas = new List<DioDivideArea>();
for (int x = 0; x < horizontalDivisions; x++)
{
//分割エリアのX座標設定
var divideX = bounds.min.x + (horizontalDivideSize / 2) + (horizontalDivideSize * x);
for (int y = 0; y < verticalDivisions; y++)
{
//分割エリアのY座標設定
var divideY = bounds.min.z + (verticalDivideSize / 2) + (verticalDivideSize * y);
//分割エリアの座標設定。高さは固定なのでエリア全体領域と同じ。
var pos = new Vector3(divideX , bounds.max.y , divideY);
//分割したエリアの生成
var go = Instantiate(_divideAreaCube, _boundingAreaObject.transform);
go.name = $"{areaNumber}_{x}_{y}_{_divideAreaCube.name}";
go.transform.position = pos;
var divideArea = go.GetComponent<DioDivideArea>();
//エリア番号の設定
divideArea.SetAreaNumber(areaNumber);
//隣接エリアの設定
divideArea.SetRefAreaNumbers(createRefAreaNumbers(areaNumber));
divideAreas.Add(divideArea);
areaNumber++;
}
}
return divideAreas;
}
こちらは分割したエリアの中に、建物のモデルがあるか判定して、そのエリア1つ分のゲームオブジェクトを作成するコードの一部です。この戻り値をアセットバンドル化して外部ディレクトリに保存しています。
/// <summary>
/// 分割した1区域分のモデル作成
/// </summary>
/// <param name="rootObjName">分割したエリアの名前</param>
/// <param name="collider">分割したエリアのコライダー</param>
/// <param name="modelChildren">建物のモデル群</param>
/// <returns>分割したエリア1つ分のゲームオブジェクト</returns>
private GameObject createDivideModelPrefab(string rootObjName ,
BoxCollider collider ,
Renderer[] modelChildren)
{
var bounds = collider.bounds;
var max = bounds.max;
var min = bounds.min;
//分割したエリアの親になるオブジェクト
var parentObject = new GameObject(rootObjName);
//建物のモデル数だけループ
foreach (var child in modelChildren)
{
var x = child.bounds.center.x;
var y = child.bounds.center.z;
Vector2 targetPos = new Vector2(x, y);
//建物がエリア内にあるかの判定。
bool xRange = min.x <= targetPos.x && targetPos.x <= max.x;
bool yRange = min.z <= targetPos.y && targetPos.y <= max.z;
if ( xRange && yRange )
{
var childObj = Instantiate(child.gameObject,parentObject.transform);
parentObject.transform.localScale = Vector3.one;
}
}
//Prefabの保存
var prefabName = $"{rootObjName}.prefab";
var savePath = $"{_assetSourceDirectory}/{PATH_ROOT_MODEL}/{prefabName}";
var prefab = PrefabUtility.SaveAsPrefabAssetAndConnect(parentObject,
savePath,
InteractionMode.AutomatedAction);
//Scene上のGameObjectを消す。
DestroyImmediate(parentObject);
return prefab;
}
アセットバンドルについての説明は今回は省略しますが、ビルド方法はBuildPipeline.BuildAssetBundlesを用いて自作しています。
ミニマップ
分割読み込みのロジックとは直接関係無いですが、広域を移動してると、どの辺りを見ているか分からなくなってしまうため、ミニマップを表示すると、体験の質が向上します。実装自体は、建物群の座標をミニマップの座標に変換しているのみであまり難しく無いです。

エリアの設定データ
エリアの広さやミニマップのスプライト、建物群のアセットバンドルの読み込み先等を設定しているScriptableObjectです。これ自体もアセットバンドル化して外部ディレクトリに保存することによって、エリアの切り替えにも対応が可能です。

MagicLeap2 実機デモ
実際に動かしてみた時の動画です。読み込みと破棄の処理は思ったよりスムーズに動いています。やはりモデルが重たいためか描画には負荷がかかりFPSは30前後ぐらいになっています。
まとめ
自分で実装してみた分には思ったよりスムーズに動いてくれて手ごたえを感じました。MagicLeap2に限らず、描画範囲が限られるような状況では、どんなハードでもかなり有効な手段ではないかと思いました。宜しければ皆さんも試してみてください。