Unity - スクリプトでTerrainに木を植える

Terrainに自動で木を植えたいなと思って調べたが、あまり情報がない感じだったので書く。

環境

  • Unity: 2020.3.26f1 Personal
  • Universal RP: 10.8.1

できあがりイメージ

f:id:bifutek:20220221000339p:plain
Terrain上のランダムな座標に生成された木々。
向きや大きさなどを固定値で生成したので工業製品っぽくなってしまった。。

考え方

全体的には、UnityEditorでTerrainを指した時のInspectorに表示される情報のイメージに近い感じ。

f:id:bifutek:20220221135731p:plain
だいたいこんな感じ

  1. 生成する木のPrefabをTreePrototypeにセットする
  2. TreePrototypeを基にTreeInstanceを量産する
  3. 生成したTreeInstanceをTerrainDataにセットすると木が生成される
  4. Colliderの位置を木の表示位置に同期させるためにTerrainColliderをDisable/Enableする

サンプルスクリプト

  • 以下のスクリプトをTerrainオブジェクトにくっつけ、Inspectorで木のPrefabを設定すればPlayした時に自動的に木を生成する。
  • 分かりやすいようにTerrainの縦横を走査しているが、これだとかなり重い。
    実際に使う場合は、乱数の適合要素を先に抽出してその座標だけ走査するなど、別のアプローチにした方がよい。

TerrainTreeGenerator.cs

using System.Linq;
using UnityEngine;

public class TerrainTreeGenerator : MonoBehaviour
{
    public GameObject _treePrefab;

    [Range(0, 5)]
    public int _density = 1;

    void Start()
    {
        Terrain terrain = GetComponent<Terrain>();
        GenerateTerrainTrees(terrain);
    }

    private void GenerateTerrainTrees(Terrain terrain)
    {
        var terrainData = terrain.terrainData;

        // すべての木の元ネタ
        terrainData.treePrototypes = new TreePrototype[] {
            new TreePrototype() {
                prefab = _treePrefab
            }
        };

        // デフォルトは513のはず
        var terrainSideLength = terrainData.heightmapResolution;

        var noise = new CreationNoiseGener(terrainSideLength, _density);

        // TerrainのHeightMap解像度を単位として各座標に木を生成するか判定し、
        // 適合した座標にTreeInstanceで木を生成する
        var points = Enumerable.Range(0, terrainSideLength);
        var instances = points.AsParallel().SelectMany(x =>
        {
            var row = points.Where(z => noise.ShouldCreate(x, z)).Select(z =>
            {
                var normalizedX = (float)x / terrainSideLength;
                var normalizedZ = (float)z / terrainSideLength;
                var position = new Vector3(normalizedX, 0, normalizedZ);
                return CreateTreeInstance(position);
            });
            return row;
        }).ToArray();
        terrainData.SetTreeInstances(instances, true);

        // Colliderを更新しないと木とColliderの位置がずれたままになる
        var collider = terrain.GetComponent<TerrainCollider>();
        collider.enabled = false;
        collider.enabled = true;
    }

    private TreeInstance CreateTreeInstance(Vector3 position)
    {
        var instance = new TreeInstance()
        {
            prototypeIndex = 0,
            position = position,
            // 簡単のために向きや高さなどを固定値にしているが、
            // 揺らぎを付けるともっと自然な感じになる
            rotation = 0,
            widthScale = 1,
            heightScale = 1,
            color = Color.white,
            lightmapColor = Color.white,
        };
        return instance;
    }

    /// <summary>
    /// 各座標にオブジェクトを生成するかランダム判定する用ユーティリティ
    /// </summary>
    private class CreationNoiseGener
    {
        private int[] _randAtPoint;
        private int _threshold;
        private int _resolution;

        public CreationNoiseGener(int resolution, int density)
        {
            _threshold = 100 - density;
            _resolution = resolution;

            var ints = GenerateRandomInts(resolution * resolution);
            _randAtPoint = ints.Select(e => e * 100 / byte.MaxValue).ToArray();
        }

        public bool ShouldCreate(int x, int z)
        {
            return _threshold < _randAtPoint[x * _resolution + z];
        }

        private int[] GenerateRandomInts(int size)
        {
            var bytes = new byte[size];
            new System.Random().NextBytes(bytes);

            var ints = new int[size];
            bytes.CopyTo(ints, 0);

            return ints;
        }
    }

}

補足

  1. TreeInstanceで生成した木のColliderは、TerrainのTerrainColliderが適用されるようになる(木のPrefabに付いていたものでなく)。
  2. 木の生成座標は(0,0,0)~(1,1,1)の範囲で指定する。
    f:id:bifutek:20220221130549p:plain
    分かりやすい座標に木を生成したもの

参考リンク

サンプルで使用したアセット