simplestarの技術ブログ

目的を書いて、思想と試行、結果と考察、そして具体的な手段を記録します。

Unity:ECSとJobSystemsでFractal Brownian Motion(fBm)ノイズを生成

■実行結果
f:id:simplestar_tech:20181021202606g:plain

10000ブロックの高さの計算にバックグラウンド処理で 2.0 ms, メインスレッドは Job をキックするだけなので 0.05 ms でした。
なお ECS による高速化前は10000 ブロックの高さの更新に 68 ms を要していました。
およそ 1360 倍の高速化に成功しました。

■前提知識

ECS は 2018年8月に自分が書いた記事を読み直して、再度頭に叩き込みます。(うむ、全然わからないな、自分の記事なのに)
Unity:CEDEC2018のECSの発表から学ぶ - simplestarの技術ブログ

2018年9月に詳しい解説記事が出来てました!(こっちの方が分かりやすそう、全部読みます!)
qiita.com

ComponentSystem は必ず world に属するって?知らなかったぞ

MonoBehaviour からの情報の引き渡しの書式を教わりましたよ。
ComponentSystem のコンストラクタで readonly のメンバに格納して利用するみたいです。

登場人物(クラスや構造体)
・マネージャー(MonoBehavior から world を作成して、ComponentSystem たちを管理します)
それが world に System を渡すときに CreateManager というものを行うようなのです。(ようなのですって…)
つまり、ComponentSystem ごとにマネージャーがいる、、素直に world に System を作成するというメンタルモデルを持つことにしました。

・ComponentSystem
例のインジェクションは?と思ったけど、OnCreateManager で ComponentGroup を作成しているみたい

g = GetComponentGroup(ComponentType.ReadOnly<Count>())

・ComponentData
上のコードでいうところの Count がそう、とにかくこの構造体がないと Entity の型を定義できない

最初に知っておきたかったメンタルモデル
ComponentSystem は Manager によって、自身の ComponentGroup を持つ Entity が world に存在しているときに反応する
反応の仕方は今のところ OnUpdate でフレームに一度だけ呼ばれる、Entity の数は関係なく、一つでも ComponentGroup があれば System が反応する

…ふふふ、全然理解できない。
ということで、先にはじめての ECS の記事を書きます。

約一か月勉強して記事を書きました!
qiita.com

これをベースにノイズ生成のロジックを書いていきましょうか。

Job Systems でのメッシュデータの生成のための予備知識のためにこちらを一読…しましたが
Unity:job-system-cookbookからC#JobSystemの実例を確認 - simplestarの技術ブログ
ちょっとこの記事では、メッシュ生成まで考えるとコンテンツを詰め込み過ぎになってしまうのでやめます。

パーリンノイズの作り方はこちらを参照
nn-hokuson.hatenablog.com

■構想
最初はパーリンノイズ作成を簡単に理解できるように参考資料に沿って、最適化を考えずに実装していきます。
そのあとに ECS を導入して記事は完成です。

■実践

まずは基礎関数だけで作った疑似ランダムノイズ
f:id:simplestar_tech:20180925084903j:plain

作成コードはこちら

public class RandomInstanticate : MonoBehaviour
{
    [SerializeField]
    private float scale = 1;

    [SerializeField]
    private GameObject cubePrefab;

    const int SIDE = 20;


    // Start is called before the first frame update
    void Start()
    {
        
    }

    // Update is called once per frame
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.A))
        {
            for (int x = 0; x < SIDE; x++)
            {
                for (int z = 0; z < SIDE; z++)
                {
                    float rand = Random(new Vector2(x / (float)SIDE, z / (float)SIDE));
                    Instantiate(cubePrefab, new Vector3(x, rand, z), Quaternion.identity, transform);
                }
            }
            
        }
    }

    static private float Random(Vector2 p)
    {
        float f = Mathf.Sin(Vector2.Dot(p, new Vector2(12.9898f, 78.233f)) * 43758.5453f);
        return f - Mathf.Floor(f);
    }
}

ブロックノイズはこんな感じ
f:id:simplestar_tech:20180925085701j:plain

実装変更箇所は次の行

float rand = Random(new Vector2(Mathf.Floor(4 * x / (float)SIDE), Mathf.Floor(4 * z / (float)SIDE)));

バリューノイズはこの通り
f:id:simplestar_tech:20180926093037j:plain

編集箇所はこちら

            for (int x = 0; x < SIDE; x++)
            {
                for (int z = 0; z < SIDE; z++)
                {
                    float vx = x / (float)SIDE * 8;
                    float vz = z / (float)SIDE * 8;

                    float fl_x = Mathf.Floor(vx);
                    float fl_z = Mathf.Floor(vz);
                    float fr_x = vx - fl_x;
                    float fr_z = vz - fl_z;

                    float v00 = Random(new Vector2(fl_x + 0, fl_z + 0));
                    float v10 = Random(new Vector2(fl_x + 1, fl_z + 0));
                    float v01 = Random(new Vector2(fl_x + 0, fl_z + 1));
                    float v11 = Random(new Vector2(fl_x + 1, fl_z + 1));

                    float ux = fr_x * fr_x * (3.0f - 2.0f * fr_x);
                    float uz = fr_z * fr_z * (3.0f - 2.0f * fr_z);

                    float v0010 = (1.0f - ux) * v00 + ux * v10;
                    float v0111 = (1.0f - ux) * v01 + ux * v11;

                    float rand = (1.0f - uz) * v0010 + uz * v0111;

                    Instantiate(cubePrefab, new Vector3(x, rand, z), Quaternion.identity, transform);
                }
            }

パーリンノイズの作成

f:id:simplestar_tech:20180926225000j:plain

編集箇所はこちら
乱数生成が 2次元になり、補間処理にも内積計算を利用しています

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.A))
        {
            for (int x = 0; x < SIDE; x++)
            {
                for (int z = 0; z < SIDE; z++)
                {
                    float vx = x / (float)SIDE * 4;
                    float vz = z / (float)SIDE * 4;

                    float fl_x = Mathf.Floor(vx);
                    float fl_z = Mathf.Floor(vz);

                    float fr_x = vx - fl_x;
                    float fr_z = vz - fl_z;

                    float ux = fr_x * fr_x * (3.0f - 2.0f * fr_x);
                    float uz = fr_z * fr_z * (3.0f - 2.0f * fr_z);

                    Vector2 v00 = Random2(new Vector2(fl_x + 0, fl_z + 0));
                    Vector2 v10 = Random2(new Vector2(fl_x + 1, fl_z + 0));
                    Vector2 v01 = Random2(new Vector2(fl_x + 0, fl_z + 1));
                    Vector2 v11 = Random2(new Vector2(fl_x + 1, fl_z + 1));

                    float v0010 = (1.0f - ux) * Vector2.Dot(v00, new Vector2(fr_x - 0, fr_z - 0)) + ux * Vector2.Dot(v10, new Vector2(fr_x - 1, fr_z - 0));
                    float v0111 = (1.0f - ux) * Vector2.Dot(v01, new Vector2(fr_x - 0, fr_z - 1)) + ux * Vector2.Dot(v11, new Vector2(fr_x - 1, fr_z - 1));

                    float rand = (1.0f - uz) * v0010 + uz * v0111;

                    Instantiate(cubePrefab, new Vector3(x, rand * 4, z), Quaternion.identity, transform);
                }
            }
            
        }
    }
    static private Vector2 Random2(Vector2 p)
    {
        p = new Vector2(Vector2.Dot(p, new Vector2(127.1f, 311.7f)), Vector2.Dot(p, new Vector2(269.5f, 183.3f)));
        Vector2 f = new Vector2(Mathf.Sin(p.x) * 43758.5453123f, Mathf.Sin(p.y) * 43758.5453123f);
        return new Vector2(-1.0f + 2.0f * (f.x - Mathf.Floor(f.x)), -1.0f + 2.0f * (f.y - Mathf.Floor(f.y)));
    }

Fractal Brownian Motion(fBm)ノイズ

f:id:simplestar_tech:20180927072218j:plain

実装はこんな感じに修正しました。

    private float parlin(int x, int z)
    {
        float vx = x / (float)SIDE * 4;
        float vz = z / (float)SIDE * 4;

        float fl_x = Mathf.Floor(vx);
        float fl_z = Mathf.Floor(vz);

        float fr_x = vx - fl_x;
        float fr_z = vz - fl_z;

        float ux = fr_x * fr_x * (3.0f - 2.0f * fr_x);
        float uz = fr_z * fr_z * (3.0f - 2.0f * fr_z);

        Vector2 v00 = Random2(new Vector2(fl_x + 0, fl_z + 0));
        Vector2 v10 = Random2(new Vector2(fl_x + 1, fl_z + 0));
        Vector2 v01 = Random2(new Vector2(fl_x + 0, fl_z + 1));
        Vector2 v11 = Random2(new Vector2(fl_x + 1, fl_z + 1));

        float v0010 = (1.0f - ux) * Vector2.Dot(v00, new Vector2(fr_x - 0, fr_z - 0)) + ux * Vector2.Dot(v10, new Vector2(fr_x - 1, fr_z - 0));
        float v0111 = (1.0f - ux) * Vector2.Dot(v01, new Vector2(fr_x - 0, fr_z - 1)) + ux * Vector2.Dot(v11, new Vector2(fr_x - 1, fr_z - 1));

        float rand = (1.0f - uz) * v0010 + uz * v0111;
        return rand;
    }

    // Update is called once per frame
    void Update()
    {
        if (Input.GetKeyDown(KeyCode.A))
        {
            for (int x = 0; x < SIDE; x++)
            {
                for (int z = 0; z < SIDE; z++)
                {
                    float rand = 0.5f * parlin(x, z);
                    rand += 0.25f * parlin(x << 1, z << 1);
                    rand += 0.125f * parlin(x << 2, z << 2);
                    rand += 0.0625f * parlin(x << 3, z << 3);
                    Instantiate(cubePrefab, new Vector3(x, rand * 8, z), Quaternion.identity, transform);
                }
            }
            
        }
    }
    static private Vector2 Random2(Vector2 p)
    {
        p = new Vector2(Vector2.Dot(p, new Vector2(127.1f, 311.7f)), Vector2.Dot(p, new Vector2(269.5f, 183.3f)));
        Vector2 f = new Vector2(Mathf.Sin(p.x), Mathf.Sin(p.y)) * 43758.5453123f;
        return 2.0f * new Vector2(f.x - Mathf.Floor(f.x), f.y - Mathf.Floor(f.y)) - Vector2.one;
    }

■考察
さて、ここから ECSとJobSystems による高速化について考えていきます。
そもそも ECS はどうやって計算をキックするんでしたっけ?

約一か月 ECS について勉強していたことになりますが、答えは「 ECS が用意している World に Component System を作ると
その Component System が対象とする Entity がシーン内に存在していると OnUpdate が毎フレーム呼ばれる」が正解でした。

詳しくはこちら
qiita.com

ECS + Job + Burst で Fractal Brownian Motion ノイズ地形生成を実装します。

■改善

実装しました。

プロジェクトの設定:
File メニュー > Build Settings > Player Settings の Scripting Define Symbols に
UNITY_DISABLE_AUTOMATIC_SYSTEM_BOOTSTRAP
を追記して(複数設定する時のデリミタは ; でした) Enter を押し、ビルド完了を待ち

ポインタを C# で扱うことになるので、事前に File メニュー > Build Settings > Player Settings を開いて Allow 'unsafe' Code にチェックを入れます。

下記スクリプトをカメラオブジェクトにアタッチしてシーンを再生します。
HelloECS.cs

using Unity.Burst;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Entities;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Rendering;
using Unity.Transforms;
using UnityEngine;

[RequireComponent(typeof(Camera))]
public class HelloECS : MonoBehaviour
{
    public Material cubeMaterial;

    void Start()
    {
        InitializeWorld();

        CreateCubeForECS();
    }

    void OnDisable()
    {
        ScriptBehaviourUpdateOrder.UpdatePlayerLoop(null);
        _world?.Dispose();
    }

    void InitializeWorld()
    {
        _world = World.Active = new World("MyWorld");
        _world.CreateManager(typeof(EntityManager));
        _world.CreateManager(typeof(EndFrameTransformSystem));
        _world.CreateManager(typeof(EndFrameBarrier));
        _world.CreateManager<MeshInstanceRendererSystem>().ActiveCamera = GetComponent<Camera>();
        _world.CreateManager(typeof(RenderingSystemBootstrap));
        
        _world.CreateManager(typeof(MyCubeSystem));
        ScriptBehaviourUpdateOrder.UpdatePlayerLoop(_world);
    }

    void CreateCubeForECS()
    {
        var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
        cube.transform.position = Vector3.zero;
        cube.transform.rotation = Quaternion.identity;
        cube.transform.localScale = Vector3.one;

        var manager = _world?.GetExistingManager<EntityManager>();
        if (null != manager)
        {
            var archetype = manager.CreateArchetype(ComponentType.Create<Prefab>(), ComponentType.Create<Position>(),
            ComponentType.Create<MeshInstanceRenderer>());

            var prefabEntity = manager.CreateEntity(archetype);

            manager.SetComponentData(prefabEntity, new Position() { Value = float3.zero });

            manager.SetSharedComponentData(prefabEntity, new MeshInstanceRenderer()
            {
                mesh = cube.GetComponent<MeshFilter>().sharedMesh,
                material = cubeMaterial,
                subMesh = 0,
                castShadows = UnityEngine.Rendering.ShadowCastingMode.Off,
                receiveShadows = false
            });

            const int SIDE = 100;
            NativeArray<Entity> entities = new NativeArray<Entity>(SIDE * SIDE, Allocator.Temp, NativeArrayOptions.UninitializedMemory);
            try
            {
                manager.Instantiate(prefabEntity, entities);
                float time = Time.realtimeSinceStartup;
                for (int x = 0; x < SIDE; x++)
                {
                    for (int z = 0; z < SIDE; z++)
                    {
                        int index = x + z * SIDE;
                        float rand = 0.5f * Perlin(x, z);
                        rand += 0.25f * Perlin(x << 1, z << 1);
                        rand += 0.125f * Perlin(x << 2, z << 2);
                        rand += 0.0625f * Perlin(x << 3, z << 3);
                        manager.SetComponentData(entities[index], new Position
                        {
                            Value = new float3(x, rand * 8, z)
                        });
                    }
                }
                float span = Time.realtimeSinceStartup - time;
                Debug.Log("span = " + span.ToString("0.00000"));
            }
            finally { entities.Dispose(); }
        }

        Destroy(cube);
    }

    private float Perlin(int x, int z)
    {
        float vx = x / (float)100 * 1;
        float vz = z / (float)100 * 1;

        float fl_x = Mathf.Floor(vx);
        float fl_z = Mathf.Floor(vz);

        float fr_x = vx - fl_x;
        float fr_z = vz - fl_z;

        float ux = fr_x * fr_x * (3.0f - 2.0f * fr_x);
        float uz = fr_z * fr_z * (3.0f - 2.0f * fr_z);

        Vector2 v00 = Random2(new Vector2(fl_x + 0, fl_z + 0));
        Vector2 v10 = Random2(new Vector2(fl_x + 1, fl_z + 0));
        Vector2 v01 = Random2(new Vector2(fl_x + 0, fl_z + 1));
        Vector2 v11 = Random2(new Vector2(fl_x + 1, fl_z + 1));

        float v0010 = (1.0f - ux) * Vector2.Dot(v00, new Vector2(fr_x - 0, fr_z - 0)) + ux * Vector2.Dot(v10, new Vector2(fr_x - 1, fr_z - 0));
        float v0111 = (1.0f - ux) * Vector2.Dot(v01, new Vector2(fr_x - 0, fr_z - 1)) + ux * Vector2.Dot(v11, new Vector2(fr_x - 1, fr_z - 1));

        float rand = (1.0f - uz) * v0010 + uz * v0111;
        return rand;
    }

    static private Vector2 Random2(Vector2 p)
    {
        p = new Vector2(Vector2.Dot(p, new Vector2(127.1f, 311.7f)), Vector2.Dot(p, new Vector2(269.5f, 183.3f)));
        Vector2 f = new Vector2(Mathf.Sin(p.x), Mathf.Sin(p.y)) * 43758.5453123f;
        return 2.0f * new Vector2(f.x - Mathf.Floor(f.x), f.y - Mathf.Floor(f.y)) - Vector2.one;
    }

    private World _world;
}

public sealed class MyCubeSystem : JobComponentSystem
{
    protected override JobHandle OnUpdate(JobHandle inputDeps)
    {
        var myCubeJob = new MyCubeJob
        {
            time = Time.realtimeSinceStartup * 3
        };
        return myCubeJob.Schedule(this, inputDeps);
    }

    [BurstCompile(Accuracy.Med, Support.Relaxed)]
    struct MyCubeJob : IJobProcessComponentData<Position>
    {
        public float time;

        public unsafe void Execute(ref Position data)
        {
            float3 pos = data.Value;
            float x = data.Value.x + time;
            float z = data.Value.z + time;
            float rand = 0.5f * Perlin(x, z);
            rand += 0.25f * Perlin(x * 2, z * 2);
            rand += 0.125f * Perlin(x * 4, z * 4);
            rand += 0.0625f * Perlin(x * 8, z * 8);
            pos.y = rand * 150;
            data.Value = pos;
        }
    }

    static private float Perlin(float x, float z)
    {
        float vx = x / 100 * 1;
        float vz = z / 100 * 1;

        float fl_x = math.floor(vx);
        float fl_z = math.floor(vz);

        float fr_x = vx - fl_x;
        float fr_z = vz - fl_z;

        float ux = fr_x * fr_x * (3.0f - 2.0f * fr_x);
        float uz = fr_z * fr_z * (3.0f - 2.0f * fr_z);

        float2 v00 = Random2(new float2(fl_x + 0, fl_z + 0));
        float2 v10 = Random2(new float2(fl_x + 1, fl_z + 0));
        float2 v01 = Random2(new float2(fl_x + 0, fl_z + 1));
        float2 v11 = Random2(new float2(fl_x + 1, fl_z + 1));

        float v0010 = (1.0f - ux) * math.dot(v00, new float2(fr_x - 0, fr_z - 0)) + ux * math.dot(v10, new float2(fr_x - 1, fr_z - 0));
        float v0111 = (1.0f - ux) * math.dot(v01, new float2(fr_x - 0, fr_z - 1)) + ux * math.dot(v11, new float2(fr_x - 1, fr_z - 1));

        float rand = (1.0f - uz) * v0010 + uz * v0111;
        return rand;
    }

    static private float2 Random2(float2 p)
    {
        p = new float2(math.dot(p, new float2(127.1f, 311.7f)), math.dot(p, new float2(269.5f, 183.3f)));
        float2 f = new float2(Mathf.Sin(p.x), Mathf.Sin(p.y)) * 43758.5453123f;
        return 2.0f * new float2(f.x - Mathf.Floor(f.x), f.y - Mathf.Floor(f.y)) - new float2(1, 1);
    }
}

実行結果:
f:id:simplestar_tech:20181021202606g:plain

10000ブロックの高さの計算にバックグラウンド処理で 2.0 ms, メインスレッドは Job をキックするだけなので 0.05 ms でした。
なお ECS による高速化前は10000 ブロックの高さの更新に 68 ms を要していました。
およそ 1360 倍の高速化に成功しました。

■まとめ

ノイズ作成コードの資料を参考に C# で五つのノイズを生成して動作確認しました。
Unity Entity Component System について勉強し、技術者向けの入門資料をまとめました。
ECS を使った fBm ノイズ生成を確認、およそ 1360 倍の高速化することを確認しました。

■気になったこと1
Unity の surf シェーダのライティングの種類をざっと確認したい
説明はこちらにあり
>ライティングモデルには、ディフューズライティングには Lambert、スペキュラーライティングには BlinnPhong という 2 つのビルトインモデルがあります。
サーフェスシェーダーでのカスタムライティングモデル - Unity マニュアル
…もっとあるんじゃない?

はい、あります。
UnityPBSLighting.cginc というファイルの中に

inline half4 LightingStandard (・・・) {・・・}
inline half4 LightingStandard_Deferred (・・・) {・・・}

と用意されています。
詳説してくれている記事を見つけました。感謝です!
tsumikiseisaku.com
この方も独自に調査して発見していますね。
なんで公式ドキュメントは説明を怠ったのでしょうか?

■気になったこと2
Unity ShaderLab の基礎関数、特に frac が何を意味するのか知りたい
ということで、次の記事を参考にしました。
Unity ShaderLab ノート
frac frac(x) 小数値の小数部分を返す
なるほど、もっと複雑な処理を想像していましたが、安心しました。