Unity - 複数のTerrainに対してプレイヤーに近い順に何か処理する

Unityで、MapMagicが自動生成したTerrainに対して、プレイヤーに近い順に特定の処理をしたいことがあったのでメモ。

なお、今回の記事ではサンプルの題材として「Terrainにオブジェクトを配置する」という内容にしているが、オブジェクトの配置が目的であればMapMagicの公式追加アセット「MapMagic 2 Objects」を使った方がもっと高機能だし楽なはず。

環境

  • Unity: 2020.3.33f1 Personal
  • MapMagic 2: 2.1.10

できあがりイメージ

画像のリンク先はGoogleDriveに置いたGIF。75MBくらい。

カメラに近いTerrainから順にSphereを生成している…のだが、静止画だと分からないな…

考え方

  • 生成されたTerrainから早い者勝ちで処理しようとすると割とばらつきが出る。
    遠くのTerrainが先に処理されて、近くのTerrainがしばらく待たされたり。
  • なので、プレイヤーの目に付きやすい近くのTerrainから先に処理したい。
    遠くのTerrainはあまり見えないので後からゆっくりやってくれればいい。

赤→オレンジ→緑の順番で処理したい

  • Terrainを1枚生成し終わったら、そのTerrainに対して処理したいタスクを生成してキューに入れる。
  • キューから取り出す際にプレイヤーと各Terrainの距離を測定し、最も近くにあるTerrainのタスクを取り出して実行する。

各Terrainのタスクをいったんキューに入れ、プレイヤーに近い順に取り出す

  • 優先順位は取り出し時に決める。
    キューに入れる時に決めると、プレイヤーが高速で移動した場合やセーブデータのロードなどで一気に遠くのTerrainへ移動した場合に、元いたTerrainのタスクが高優先扱いのまま残ってしまう。
  • 古いエントリを削除してしまうと、元のTerrain方面へ引き返した際にそのタスクを実行できないので、優先順位が低い状態で残ってほしい。

サンプルコード

各クラスの関連と流れ

だいたいこんな感じ

  1. MapMagicが発行するOnAllCompleteイベント(Terrain生成完了)を検知し、タスクを生成してタスクキューに登録する
  2. タスクキューは内部的に、距離測定用のクラスでタスクをラップしてからキューに入れる
  3. 取り出し時はキュー内の全エントリを走査して各Terrainとプレイヤーとの距離を測定し、最も近いエントリを取り出す
  4. 取り出したタスクを実行する

TerrainEventHandler

using UnityEngine;
using MapMagic.Core;
using MapMagic.Terrains;

public class TerrainEventHandler : MonoBehaviour
{
    private Transform _objects;

    private Transform tile => transform.parent;

    void OnEnable()
    {
        // 遠距離用の`/MapMagic/Tile *,*/Draft Terrain`や、コピー元の`/MapMagic`は対象外。
        // 近距離用の`/MapMagic/Tile *,*/Main Terrain`だけ処理したい。

        // e.g. `Main Terrain@Tile 0,0`
        var nameWithParent = $"{name}@{tile?.name}";
        if (!nameWithParent.StartsWith("Main Terrain@Tile "))
        {
            return;
        }

        _objects = tile.Find("Objects");

        TerrainTile.OnAllComplete -= CreateMapObjects;
        TerrainTile.OnAllComplete += CreateMapObjects;
    }

    void OnDisable()
    {
        TerrainTile.OnAllComplete -= CreateMapObjects;
    }

    public void CreateMapObjects(MapMagicObject terrain)
    {
        // オブジェクトを生成する時は、`Tile *.*/Objects`配下に生成する。
        // ここに既にオブジェクトが生成されていた場合は、処理済みのTerrainとみなしてスキップ。
        var alreadyGenerated = (_objects == null || _objects.transform.childCount > 0);
        if (alreadyGenerated)
        {
            return;
        }

        var task = new TerrainTask(tile);
        TerrainTaskManager.Enqueue(task);

        TerrainTile.OnAllComplete -= CreateMapObjects;
    }

}

TerrainTaskManager

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

public class TerrainTaskManager : MonoBehaviour
{
    [SerializeField]
    private float _pollingInterval = 0.1f;

    private static LinkedList<TerrainTaskQueueEntry> _queue =
        new LinkedList<TerrainTaskQueueEntry>();

    void Start()
    {
        StartCoroutine(PollQueue());
    }

    private IEnumerator PollQueue()
    {
        while (true)
        {
            var entry = Dequeue();
            if (entry != null)
            {
                yield return entry.Run();
            }

            yield return new WaitForSecondsRealtime(_pollingInterval);
        }
    }

    public static void Enqueue(TerrainTask task)
    {
        // Destroyと生成のギリギリの位置で行ったり来たりすると、そのTerrainに対するタスクが
        // 実行されない内に(距離が遠いので優先度が低い)どんどん新しくキューイングされてしまうので、
        // キューイング済みならスキップする。
        var alreadyQueued = _queue.Any(e => e.Name == task._name);
        if (alreadyQueued)
        {
            return;
        }

        var entry = new TerrainTaskQueueEntry(task);
        _queue.AddLast(entry);
    }

    private static TerrainTaskQueueEntry Dequeue()
    {
        if (_queue.Count < 1)
        {
            return null;
        }

        // 最短距離にあるTerrainに対するタスクを探す
        var closestTask = _queue.Aggregate((cur, nxt) => cur.Closer(nxt));
        _queue.Remove(closestTask);

        return closestTask;
    }

}

TerrainTaskQueueEntry

タスク側に含めてしまってもよかったが、散らかった感じになるかなと思って分けた。

using System.Collections;
using UnityEngine;

public class TerrainTaskQueueEntry
{
    public TerrainTask _task;

    public string Name => _task._name;

    public IEnumerator Run() => _task.Run();

    public TerrainTaskQueueEntry(TerrainTask task)
    {
        this._task = task;
    }

    public TerrainTaskQueueEntry Closer(TerrainTaskQueueEntry another)
    {
        var fromThis = this.DistanceToPlayer();
        var fromAnother = another.DistanceToPlayer();
        var closer = (fromThis < fromAnother) ? this : another;
        return closer;
    }

    private float DistanceToPlayer()
    {
        // プレイヤーの移動によってTerrainがDestroyされている場合がある
        if (!_task._tile)
        {
            return float.MaxValue;
        }

        var playerPos = GlobalRefs.Player.transform.position;
        var terrainPos = _task._tile.transform.position + GlobalRefs.TileCenterGap;
        return Vector3.Distance(terrainPos, playerPos);
    }
}

TerrainTask

今回はSphereをいくつか生成するだけ。

using System.Collections;
using System.Linq;
using UnityEngine;

public class TerrainTask
{
    public Transform _tile { get; private set; }

    public string _name { get; private set; }

    private Transform _objectsParent;

    public TerrainTask(Transform parentTile)
    {
        _tile = parentTile;
        _name = $"{_tile.name}/{_tile.GetComponentInChildren<Terrain>(false).name}";
        _objectsParent = _tile.Find("Objects");
    }

    public IEnumerator Run()
    {
        Debug.Log($"ここでTerrainに対する処理。アイテム置いたりとか。 @{_name}");
        var something = Enumerable.Range(0, 5).ToArray();
        foreach (var i in something)
        {
            // プレイヤーの移動によってTerrainがDestroyされている場合がある
            if (!_tile) break;

            CreateDummySphere();
            yield return null;
        }
    }

    private void CreateDummySphere()
    {
        var sphere = GameObject.CreatePrimitive(PrimitiveType.Sphere);
        sphere.transform.parent = _objectsParent;
 
        var rndX = Random.Range(250, 750);
        var rndZ = Random.Range(250, 750);
        // 簡単のためY座標は固定値
        sphere.transform.localPosition = new Vector3(rndX, 100, rndZ);
        sphere.transform.localScale = Vector3.one * 200;
    }

}

GlobalRefs

距離計算する際に毎回GameObject.Findするのはつらいので、取得結果を持っててもらう。
今回の記事の内容とはあまり関係ないので上の図には描いていないが、ソースコピペで動かすレベルでも負荷上がってしまうかなと思ったので一応。

using UnityEngine;
using MapMagic.Core;

public class GlobalRefs
{
    private static GameObject _player;
    public static GameObject Player
    {
        get
        {
            if (!_player)
            {
                _player = GameObject.FindWithTag("Player");
            }
            return _player;
        }
    }

    // MapMagicのTerrain1枚の大きさ。
    // Playerとの距離を測る際にTerrainの中心を求める用。
    private static Vector3 _tileCenterGap = Vector3.up; // Y is never used
    public static Vector3 TileCenterGap
    {
        get
        {
            if (_tileCenterGap == Vector3.up)
            {
                // デフォルトだと1000*1000のはず。
                // MapMagicの内部の値なのであまり深入りしない方がいいかも。
                var tileSize = GameObject.Find("MapMagic")
                        .GetComponent<MapMagicObject>().tileSize;

                _tileCenterGap = new Vector3(tileSize.x / 2, 0, tileSize.z / 2);
            }
            return _tileCenterGap;
        }
    }
}

スクリプトのアタッチ

TerrainEventHandler

MapMagicObjectコンポーネントと同じオブジェクトにアタッチする。
併せて、MapMagicObjectコンポーネントCopy Components to TerrainsをONにする。
これで自動生成されたTerrainにもこのスクリプトをコピーしてくれる。

スクリプトのアタッチとCopy Component to TerrainsのチェックON

TerrainTaskManager

ルート辺りにEmptyを作ってアタッチする。
キューを監視する部分が動き出してくれればいいだけなので、どこでもよい。
今回はルートにScriptsという名前のEmptyを作ってそこに付けた。

くっつける

使用したアセット

  • MapMagic 2 | Terrain | Unity Asset Store
    あらかじめパラメータを設定しておくことで動的にTerrainを生成してくれるアセット。
    実行中にどんどん生成してくれるのでどこまでも歩いて行ける。
    ノイズで地形をデコボコさせたり、草を生やしたり地表テクスチャをブレンドしたりしていい感じのTerrainを作ってくれる。
    動作も軽い。
    基本的な部分は無料で、追加機能としていくつかの有料アセットがある。