simplestarの技術ブログ

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

AIに身体性を与えるためのマイクロワールドの構築6

まずは、どこまで移動しても4096ブロック進んだら、ループができる仕組みを作ります。

f:id:simplestar_tech:20171111185410j:plain

チャンク単位で3次元配列を保持させます。

private int[][][] _chunkData = null;

持たせました!
そのチャンクも2次元配列で保持させます。

Chunk[][] _chunkArray = null;

持たせました!

効率的にメモリを利用するため
デザインパターンの一つ Proxy パターンに開放機能を付けます。

具体的には、観測者の位置と有効視界範囲設定から、自分に近いチャンクごとのキューを作り
キューから取り出したチャンクごとに別スレッドで、チャンクファイルデータのメモリ読み込み
読み込み終えたら、六角柱メッシュの構築を行うようにします。

観測者の位置情報と、観測者の位置の更新が必要になりました。

f:id:simplestar_tech:20171105224430g:plain
移動する観測者を用意しました!

具体的な観測者の移動スクリプトはこちら

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

namespace SimpleStar
{
    [RequireComponent(typeof(Camera))]
    public class Observer : MonoBehaviour
    {
        // Use this for initialization
        void Start()
        {
            _camera = this.GetComponent<Camera>();
        }

        // Update is called once per frame
        void Update()
        {
            float hMove = Input.GetAxis("Horizontal");
            float vMove = Input.GetAxis("Vertical");
            float hTurn = Input.GetAxis("HorizontalTurn");
            float vTurn = Input.GetAxis("VerticalTurn");
            float hStep = Input.GetAxis("DPAD-Horizontal");
            float vStep = Input.GetAxis("DPAD-Vertical");

            transform.Translate(Vector3.right * hMove);
            transform.Translate(Vector3.forward * vMove);

            transform.Rotate(Vector3.up * hTurn, Space.World);
            transform.Rotate(Vector3.right * vTurn);

            transform.Translate(Vector3.right * hStep);
            transform.Translate(Vector3.up * vStep, Space.World);
        }

        Camera _camera = null;
    }
}

観測者のチャンクが切り替わったら、チャンクキューから有効視界範囲外に出たチャンクを除外し、開放チャンクキューに入れます。

まずは、観測者がどのチャンクに内包されているのかを特定する計算が必要です。
そもそもチャンクの内側の判定はどのように行われるべきでしょうか?

チャンク作成時に、チャンクに緯度と経度情報を埋め込みます。
まずは、観測者のワールド座標から経度と緯度を求めます。

ブロックとチャンク、そして、ワールド座標系の関係を調べました。

f:id:simplestar_tech:20171106082150j:plain

小さく映っているのは、直径 1 m の球体です。
六角形は一辺 1 m の正三角形 6つによって構成されているため、まず x 方向は ルート3 * 16 ずつチャンクが切り替わります。
単純に x 方向のチャンクインデックスは x / (ルート3 * 16) で出てきます。
同じく、単純に z 方向のチャンクインデックスは z / (1.5 * 16) で出てきます。(互いに嚙合わせるように重ねているので、ここは単純な 2 ではありません)
ところで x, z が負の値だった場合はどうしましょうか?
x, z が最大チャンク数を超えた場合もどうしましょうか?
世界は 256 チャンクで周回しますので、ここは単純に負だったら (ルート3 * 16) * 256, (1.5 * 16) * 256 を足したり
チャンクインデックスが 256 以上だったら、単純に 256 を引いたりしましょう。

具体的なコードは次の通りです。

        void Update()
        {
            float x = _observer.transform.position.x;
            float z = _observer.transform.position.z;

            float root3 = Mathf.Sqrt(3.0f);
            int chunkIndexX = 0;
            int chunkIndexZ = 0;

            chunkIndexX = Mathf.FloorToInt(x / (root3 * (1 << 4)));
            while (0 > chunkIndexX)
            {
                chunkIndexX += (1 << 8);
            }
            chunkIndexX %= (1 << 8);

            if (lastchunkIndexX != chunkIndexX)
            {
                lastchunkIndexX = chunkIndexX;
                Debug.Log("chunkIndexX = " + chunkIndexX);
            }

            chunkIndexZ = Mathf.FloorToInt(z / (1.5f * (1 << 4)));
            while (0 > chunkIndexZ)
            {
                chunkIndexZ += (1 << 8);
            }
            chunkIndexZ %= (1 << 8);

            if (lastchunkIndexZ != chunkIndexZ)
            {
                lastchunkIndexZ = chunkIndexZ;
                Debug.Log("chunkIndexZ = " + chunkIndexZ);
            }
        }

動作的に問題なさそうでした。

続いて、自分に近い順番でチャンク配列を作り、非同期で頂点リスト、インデックスリスト、UVリストを作成するところまで作りました。
しかし、やっぱりUnityの機能でメッシュを作る作業はメインスレッドで動かさなければならない様子。
ここは UniRx でイベントを定期的に発行して、少しずつ必要な処理をメインスレッドでこなしていくように作っていきます。

ということで UniRx 導入について、公式ページを参照
github.com

Unity の Asset Store でそのままインポートするのが早いかな
https://www.assetstore.unity3d.com/jp/#!/content/17276

Plugins>UniRx>Examples に13例ほど用意されているので、まずはここで書式と使い方を学ぶのが良いのではないでしょうか。

イベントを定期的に発行する最も単純なコードはこちら

using UniRx;

        public void Async(long a)
        {
            Observable.Start(() => {
                // asynchronous work
                return a;
            }) 
            .ObserveOnMainThread()
            .Subscribe(x => Debug.Log(x));
        }

        private void Awake()
        {
            Observable.Interval(System.TimeSpan.FromMilliseconds(100))
                .Subscribe(_ => {
                    long t = 0;
                    this.Async(t);
                });

永遠に 100 ms 間隔で this.Async(t); をメインスレッドから呼び出し
Async(long a) 関数内にて別スレッドで実行する{ return a; }の処理を開始して
その別スレッドでの作業完了と同時に、メインスレッドに戻って Debug.Log(x); を実行するというコード

これを使って、定期的にメッシュ更新対象のチャンクキューからデキューして得たチャンクについて
別スレッドでファイルロードとメッシュ配列の生成を行い
メインスレッドに戻って、メッシュの生成を行います。

メインスレッドに戻って、チャンクのメッシュの生成を行いたいけど、チャンクにも 16 個のメッシュがあるから
16フレームに分割して1フレームにメッシュを更新したい、という要望を UniRx で実現する方法を思いついた。

        public int UpdateMeshSync(int h = 0)
        {
            int works = 0;
            if (0 == h)
            {
                for (int i = 0; i < _meshObjects.Length; i++)
                {
                    if (_meshObjects[i]._needUpdateMesh)
                    {
                        ++works;
                    }
                }
            }

            while (!_meshObjects[h]._needUpdateMesh)
            {
                ++h;
                if (_meshObjects.Length == h)
                {
                    break;
                }
            }
            if (_meshSources.Length > h && 0 < _meshSources[h]._subMeshCount)
            {
                Mesh mesh = new Mesh();

                mesh.vertices = _meshSources[h]._vertices;
                mesh.uv = _meshSources[h]._uvs;
                mesh.subMeshCount = _meshSources[h]._subMeshCount;
                for (int i = 0; i < mesh.subMeshCount; i++)
                {
                    if (null != _meshSources[h]._indices[i])
                    {
                        mesh.SetIndices(_meshSources[h]._indices[i], MeshTopology.Triangles, i);
                    }
                }
                mesh.RecalculateNormals();
                MeshRenderer renderer = _meshObjects[h]._meshObject.GetComponent<MeshRenderer>();
                renderer.materials = _meshSources[h]._materials;
                _meshObjects[h]._meshObject.GetComponent<MeshFilter>().sharedMesh = mesh;
                _meshObjects[h]._needUpdateMesh = false;
            }
            ++h;
            if (_meshSources.Length > h)
            {
                Observable.NextFrame().Subscribe(_ => {
                    this.UpdateMeshSync(h);
                });
            }
            return works;
        }

これぞ Simplestar 流、for 文の要素処理を frame 分割する負荷分散化 UniRx コーディング…はずかしい、だれでも考えることでしたね。

続いて、すでにチャンクを作った場所には、チャンクオブジェクトを構築しないような仕組みを入れます。
そして、一定距離離れたらそのチャンクを削除するようにします。

有効視界内に新たに入るチャンクを、自分に近いチャンクごとのキューに加えて、未読み込みのチャンクについて、チャンクファイルデータの読み込みとメッシュの構築を走らせます。
開放チャンクキューに入っているチャンクを次のアクティブチャンクの切り替え時に取り出して、その時に有効視界範囲外のチャンクになっていたら、チャンクデータを開放します。
これにより、チャンクデータは必要な範囲までしかメモリに読み込まれなくなります。

ここまで作ってみましたが、スパイクが発生したときに、問題を特定しづらいという UniRx の落とし穴に気付きました。
コーディングは楽ですが、いったい何が原因でフレームレートが落ちるのか、隠れてしまうのです。

一度、UniRx を使わない単純なコードに戻して、重たい処理を見つけました。
次の関数のように、自分のチャンクを中心に周囲のチャンクについて読み込みをかけるという処理が重かったようです。

        // これが重い!
        private void UpdateUnityWorld(Chunk chunk, Transform ts)
        {
            int tsx = Mathf.FloorToInt(ts.position.x / (_root3 * Chunk.Width));
            int tsz = Mathf.FloorToInt(ts.position.z / (1.5f * Chunk.Depth));
            ShowChunk(chunk, tsx, tsz, 0, 0);
            for (int radius = 1; radius < _uniyWorldShowRadius; radius++)
            {
                int ofzl = -radius;
                for (int ofx = -radius; ofx < radius; ofx++)
                {
                    Chunk c = _world.ChunkArray[_RemapX(_myChunk.Longitude, ofx)][_RemapZ(_myChunk.Latitude, ofzl)];
                    ShowChunk(c, tsx, tsz, ofx, ofzl);
                }
                int ofxh = radius;
                for (int ofz = -radius; ofz < radius; ofz++)
                {
                    Chunk c = _world.ChunkArray[_RemapX(_myChunk.Longitude, ofxh)][_RemapZ(_myChunk.Latitude, ofz)];
                    ShowChunk(c, tsx, tsz, ofxh, ofz);
                }
                int ofzh = radius;
                for (int ofx = radius; ofx > -radius; ofx--)
                {
                    Chunk c = _world.ChunkArray[_RemapX(_myChunk.Longitude, ofx)][_RemapZ(_myChunk.Latitude, ofzh)];
                    ShowChunk(c, tsx, tsz, ofx, ofzh);
                }
                int ofxl = -radius;
                for (int ofz = radius; ofz > -radius; ofz--)
                {
                    Chunk c = _world.ChunkArray[_RemapX(_myChunk.Longitude, ofxl)][_RemapZ(_myChunk.Latitude, ofz)];
                    ShowChunk(c, tsx, tsz, ofxl, ofz);
                }
            }
        }

やっていることはチャンクごとのオブジェクトの作成と非同期読み込みの開始ですけど、確かに、これは重くなりますね。
そこで、順番にチャンクごとの処理をキューに積んで、これを処理するようにしていこうと思います。

ということで、更新すると次のコード

        private void _UpdateUnityWorld(Chunk chunk, Transform ts)
        {
            _createChunkMeshTask.Clear();
            int transformX = Mathf.FloorToInt(ts.position.x / (_root3 * Chunk.Width));
            int transformZ = Mathf.FloorToInt(ts.position.z / (1.5f * Chunk.Depth));
            _createChunkMeshTask.Enqueue(new CreateChunkMeshSourcesTask(this, chunk, transformX, transformZ, 0, 0));
            for (int radius = 1; radius < _uniyWorldShowRadius; radius++)
            {
                int offsetZlow = -radius;
                for (int offsetX = -radius; offsetX < radius; offsetX++)
                {
                    Chunk c = _world.ChunkArray[_RemapX(_myChunk.Longitude, offsetX)][_RemapZ(_myChunk.Latitude, offsetZlow)];
                    _createChunkMeshTask.Enqueue(new CreateChunkMeshSourcesTask(this, c, transformX, transformZ, offsetX, offsetZlow));
                }
                int offsetXHigh = radius;
                for (int offsetZ = -radius; offsetZ < radius; offsetZ++)
                {
                    Chunk c = _world.ChunkArray[_RemapX(_myChunk.Longitude, offsetXHigh)][_RemapZ(_myChunk.Latitude, offsetZ)];
                    _createChunkMeshTask.Enqueue(new CreateChunkMeshSourcesTask(this, c, transformX, transformZ, offsetXHigh, offsetZ));
                }
                int offsetZHigh = radius;
                for (int offsetX = radius; offsetX > -radius; offsetX--)
                {
                    Chunk c = _world.ChunkArray[_RemapX(_myChunk.Longitude, offsetX)][_RemapZ(_myChunk.Latitude, offsetZHigh)];
                    _createChunkMeshTask.Enqueue(new CreateChunkMeshSourcesTask(this, c, transformX, transformZ, offsetX, offsetZHigh));
                }
                int offsetXLow = -radius;
                for (int offsetZ = radius; offsetZ > -radius; offsetZ--)
                {
                    Chunk c = _world.ChunkArray[_RemapX(_myChunk.Longitude, offsetXLow)][_RemapZ(_myChunk.Latitude, offsetZ)];
                    _createChunkMeshTask.Enqueue(new CreateChunkMeshSourcesTask(this, c, transformX, transformZ, offsetXLow, offsetZ));
                }
            }
        }

            Observable.IntervalFrame(1)
                .Subscribe(_ =>
                {
                    Chunk lastChunk = _myChunk;
                    _myChunk = GetMyChunk(_observer.transform);
                    if (lastChunk != _myChunk)
                    {
                        _UpdateUnityWorld(_myChunk, _observer.transform);
                    }

                    while (0 < _createChunkMeshTask.Count)
                    {
                        CreateChunkMeshSourcesTask task = _createChunkMeshTask.Dequeue();
                        if (task.Run())
                        {
                            break;
                        }
                    }

                    while (0 < ChunksToUpdateMesh.Count)
                    {
                        UnityChunk chunk = ChunksToUpdateMesh.Dequeue();
                        if (null != chunk)
                        {
                            if (0 < chunk.SetRenderMesh())
                            {
                                break;
                            }
                        }
                    }
                });


これで、スパイクがなくなりました。
ということで、オープンワールドのように、チャンクデータをすべて記録しつつも、ほぼ無限に続く世界が完成しました。

www.youtube.com

そしていよいよ、ブロック同士の相互作用の話です。
ここは"妖精"にまかせることにします。

…あの、まじめに答えています。
ここで妖精とはルールに従って現象に働きかける見えない存在のことを指します。

私たちも電磁気力、重力、光が直進、反射する現象などを観測しますが
すべて見えない妖精によって作られた結果と考えることができます。
同様の仕組みをマイクロワールドにも取り入れるという方針です。

妖精は対象とするブロックの種類、圧力、温度、主成分の種類と割合と、その周囲のブロックのそれらの情報を得て、決められたルールの結果を書き込みます。
例えば、周囲よりも温度の高い空気ブロックは主成分の水を上のブロックへ渡し
湖は接している空気ブロックに主成分として水を与え続けることになり、そのうち水の割合が減って湖の水ブロックは霧→水を限界まで含んだ空気ブロック→乾いた空気ブロックに変化していきます。
時間をおいて眺めてみると、湖が枯渇したかのような現象が確認できることになります。

このように妖精がブロックに訪れると、ルールに従ってブロックが変化することで世界は形を変えていきます。

デザインパターンの Visitor パターンが、妖精がブロックを巡回していく構造に利用できるかもしれませんね。
このシステムの狙いは、ブロックには 32bit という非常に小さなデータ単位しか持たせないという点にあります。

この妖精っていうやつは、次の記事で実装していこうと思います。