simplestarの技術ブログ

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

AIに身体性を与えるためのマイクロワールドの構築16.5:水のロジック

Unity でマインクラフトライクなゲーム制作の記録です。

前回用意したプラグイン機構を使って、水ブロックのロジックを考えてみました。
ゆっくり移動するのですが、早送りするとそれらしい振る舞いに見えなくもないかな…

f:id:simplestar_tech:20180116235107g:plain

動画はこちら
www.youtube.com

今回は水圧というものを加えてみました。
隣接する空気ブロックへ移動した場合は水圧は0になり、水面の水ブロックも水圧は0になります。
水圧は、水ブロックが自らの逃げ場を失った時に上昇し、上に積みあがった水ブロックの圧力よりも1大きく、上に水が積みあがっていない場合は周囲のブロックの中で最も高い圧力と同じになります。

移動に関しては、直下のブロックが空気だった場合はそのブロックと交換し、直下が空気でない場合は側面を調べ、最も圧力の高い隣接ブロックの反対の空気ブロックと交換し
移動方向が決まらない場合はランダムな側面の空気ブロックへと交換します。

実装コードは次の通り

#if !USE_PLUGINS
using System;
using System.Collections.Generic;

namespace SimpleHexWorld
{
    public class WaterLogic : IBlockLogic
    {
        public int BlockId()
        {
            return (int)MaterialID.Water;
        }

        public void Work(int widthIndex, int depthIndex, int heightIndex, IHexWorldUtility worldUtility)
        {
            double randomValue = _random.NextDouble();

            if (0.1f < randomValue)
            {
                // return;
            }
            BlockData myBlockData = worldUtility?.GetBlockData(widthIndex, depthIndex, heightIndex) ?? BlockData.Identity;
            BlockData bottomBlockData = worldUtility?.GetBlockData(widthIndex, depthIndex, heightIndex - 1) ?? BlockData.Identity;
            if ((ushort)MaterialID.Air == bottomBlockData.blockId)
            {
                worldUtility?.SetBlockData(widthIndex, depthIndex, heightIndex, bottomBlockData);
                myBlockData.stress = 0;
                worldUtility?.SetBlockData(widthIndex, depthIndex, heightIndex - 1, myBlockData);
            }
            else
            {
                byte maxSideStress = 0;
                byte maxBackStress = 0;
                int sideAirIndex = -1;
                List<int> sideAirIndices = new List<int>();
                for (int sideIndex = 0; sideIndex < 6; sideIndex++)
                {
                    int sideWidthIndex = widthIndex;
                    int sideDepthIndex = depthIndex;
                    worldUtility?.GetSideBlockIndex(widthIndex, depthIndex, sideIndex, out sideWidthIndex, out sideDepthIndex);

                    BlockData sideBlockData = worldUtility?.GetBlockData(sideWidthIndex, sideDepthIndex, heightIndex) ?? BlockData.Identity;
                    if ((ushort)MaterialID.Air == sideBlockData.blockId)
                    {
                        sideAirIndices.Add(sideIndex);
                        int backIndex = sideIndex + 3;
                        if (6 <= backIndex)
                        {
                            backIndex -= 6;
                        }
                        worldUtility?.GetSideBlockIndex(widthIndex, depthIndex, backIndex, out sideWidthIndex, out sideDepthIndex);
                        BlockData backBlockData = worldUtility?.GetBlockData(sideWidthIndex, sideDepthIndex, heightIndex) ?? BlockData.Identity;
                        if (maxBackStress < backBlockData.stress)
                        {
                            maxBackStress = backBlockData.stress;
                            sideAirIndex = sideIndex;
                        }
                        
                    }
                    else if ((ushort)MaterialID.Water == sideBlockData.blockId)
                    {
                        if (maxSideStress < sideBlockData.stress)
                        {
                            maxSideStress = sideBlockData.stress;
                        }
                    }
                }
                if (-1 != sideAirIndex)
                {
                    _MoveSideBlock(widthIndex, depthIndex, worldUtility, sideAirIndex, heightIndex, ref myBlockData);
                }
                else if (0 < sideAirIndices.Count)
                {
                    _MoveSideBlock(widthIndex, depthIndex, worldUtility, sideAirIndices[_random.Next(0, sideAirIndices.Count)], heightIndex, ref myBlockData);
                }
                else
                {
                    byte lastStress = myBlockData.stress;
                    BlockData topBlockData = worldUtility?.GetBlockData(widthIndex, depthIndex, heightIndex + 1) ?? BlockData.Identity;
                    if ((ushort)MaterialID.Water == topBlockData.blockId)
                    {
                        myBlockData.stress = (byte)Math.Min((1 << 6 - 1), topBlockData.stress + 1);
                    }
                    else if ((ushort)MaterialID.Air == topBlockData.blockId)
                    {
                        myBlockData.stress = 0;
                    }
                    else
                    {
                        myBlockData.stress = maxSideStress;
                    }
                    if (lastStress != myBlockData.stress)
                    {
                        worldUtility?.SetBlockData(widthIndex, depthIndex, heightIndex, myBlockData);
                    }
                }
            }
        }

        private void _MoveSideBlock(int widthIndex, int depthIndex, IHexWorldUtility worldUtility, int sideAirIndex, int heightIndex, ref BlockData myBlockData)
        {
            int sideWidthIndex = widthIndex;
            int sideDepthIndex = depthIndex;
            worldUtility?.GetSideBlockIndex(widthIndex, depthIndex, sideAirIndex, out sideWidthIndex, out sideDepthIndex);
            BlockData sideBlockData = worldUtility?.GetBlockData(sideWidthIndex, sideDepthIndex, heightIndex) ?? BlockData.Identity;
            worldUtility?.SetBlockData(widthIndex, depthIndex, heightIndex, sideBlockData);
            myBlockData.stress = 0;
            worldUtility?.SetBlockData(sideWidthIndex, sideDepthIndex, heightIndex, myBlockData);
        }

        private Random _random = new Random();
    }
}
#endif

AIに身体性を与えるためのマイクロワールドの構築16:風・土そして水

世界を構成する四大元素は風・土・水・火でした。
現在は風と土だけで世界が構成されていますので、水を作り出す必要があります。

イメージとしてはこんな感じ
f:id:simplestar_tech:20180114171718j:plain

水は液体なので、固体である土と違ってより低い風ブロックの隙間へ移動していくものです。

以前より作ってきたバックグラウンド処理にて、チャンクごとにすべてのブロックを処理し、もし水ブロックだったら周囲のブロックを確認して、直下が風ブロックだったら交換、直下が土ブロックだったら側面の風ブロックを確認して、もし風ブロックを見つけたら、その風ブロックと交換する仕組みを入れてみます。

期待では水に囲まれた中に空気ブロックがあると、これがバブルのように水面へ上昇してくると思います。
さっそく水ブロックの表現を作っていきましょう。

水を半透明の青いマテリアルとし、透過先に土ブロックが見えないといけませんので、水ブロックのメッシュの向こう側にもう一つ土メッシュを用意しなければなりません。
処理負荷はこの際無視して、半透明なブロックを考慮した2つ目のメッシュ作成の構造を考えていきます。

。。。と同時に、今のメッシュが一つの仕組みで水面の表現ができないか考えて、次のようなアイディアが浮かび上がってきました。
隣接するブロックが水なら土ブロックのメッシュを作成する。

とりあえず水ブロックを定義して、従来のシステムにおいて半透明なマテリアルを与えるとどのように見えるのか確認してみました。

f:id:simplestar_tech:20180114172041j:plain

要するに、この問題をどうにかしようと考えて、次のコードを閃いたわけです。
風にも水にも触れていないブロックは描画しないが、それ以外は描画するというロジックです。

int blockID = Chunk.GetBlockId(_chunk.BlockData[x][z][y]);
if (AirMaterialId != blockID)
{
    int n2 = World.Instance.GetNextBlockData(_chunk, out nextChunk, x + 1, z, y);
    int n5 = World.Instance.GetNextBlockData(_chunk, out nextChunk, x - 1, z, y);

    int n1 = World.Instance.GetNextBlockData(_chunk, out nextChunk, !bZOffset ? x : x + 1, z + 1, y);
    int n3 = World.Instance.GetNextBlockData(_chunk, out nextChunk, !bZOffset ? x : x + 1, z - 1, y);
    int n4 = World.Instance.GetNextBlockData(_chunk, out nextChunk, !bZOffset ? x - 1 : x, z - 1, y);
    int n6 = World.Instance.GetNextBlockData(_chunk, out nextChunk, !bZOffset ? x - 1 : x, z + 1, y);
    int t1 = World.Instance.GetNextBlockData(_chunk, out nextChunk, x, z, y + 1);
    int s1 = World.Instance.GetNextBlockData(_chunk, out nextChunk, x, z, y - 1);

    int subMeshIndex = materialIndices[blockID];
    if (WaterMaterialId == blockID && AirMaterialId == t1)
    {
        subMeshIndex = materialIndices[blockID + 1];
    }

    if (AirMaterialId != n1 && AirMaterialId != n2 && AirMaterialId != n3 && AirMaterialId != n4 && AirMaterialId != n5 && AirMaterialId != n6 && AirMaterialId != t1 && AirMaterialId != s1)
    {
        // not touch wind
        if (WaterMaterialId != n1 && WaterMaterialId != n2 && WaterMaterialId != n3 && WaterMaterialId != n4 && WaterMaterialId != n5 && WaterMaterialId != n6 && WaterMaterialId != t1 && WaterMaterialId != s1)
        {
            // not touch water
            continue;
        }
    }

この結果、次のレンダリング結果を得ました。

f:id:simplestar_tech:20180114172841j:plain

たまたま発見したのですが、水面オブジェクトだけ上面をレンダリングするようにすると、深いところのブロックが暗く表示されることを確認しました。
水中から上を見るとうまくありませんが、水面より上にカメラがあるときは良い絵が作れます。

では見た目が完成したので、ブロックのプラグインを考えていきましょう。

インタフェースは仮で次のようなものを用意してみました。

public interface IHexWorldUtility
{
    BlockData GetBlockData(int widthIndex, int depthIndex, int heightIndex);
    void SetBlockData(int widthIndex, int depthIndex, int heightIndex, BlockData blockData);
}

public interface IBlockLogic
{
    int BlockId();
    void Execute(int widthIndex, int depthIndex, int heightIndex, IHexWorldUtility pluginUtility);
}

public struct BlockData
{
    public ushort blockId; // 12bit (0~4095)
    public byte impuritiesId; // 4bit (0~15)
    public byte impuritiesRatio; // 4bit (0~15)
    public byte stress; // 6bit (0~63)
    public byte heat; // 6bit (0~63)

単純に水を土に変えるブロックロジックを作ってみました。

結果イメージ
f:id:simplestar_tech:20180114214525j:plain

今はメッシュの更新のタイミングがオブザーバーが移動したときだけなので、モデル側が更新されてもビュー側は更新されません。

チャンクの処理が一通り終わり、データに更新があるかどうかを確認して、もし更新があれば、ビューにチャンクのメッシュ更新を行うよう要請する仕組みを作ってみました。

結果イメージ
f:id:simplestar_tech:20180114214605j:plain

あとは正しくブロックのロジックを作るだけですね。
まずは単純なロジックを書いてみます。
水が風に上面で触れていたなら、干上がるように風になるというロジックです。

    public class WaterLogic : IBlockLogic
    {
        public int BlockId()
        {
            return 2; // @debug しかるべきリストから参照するべき
        }

        public void Work(int widthIndex, int depthIndex, int heightIndex, IHexWorldUtility worldUtility)
        {
            BlockData blockData = worldUtility?.GetBlockData(widthIndex, depthIndex, heightIndex + 1) ?? BlockData.Identity;
            if (0 == blockData.blockId)
            {
                blockData.blockId = 0;
                worldUtility?.SetBlockData(widthIndex, depthIndex, heightIndex, blockData);
            }
        }
    }

実行結果は次の動画で確認できます。

www.youtube.com

では、冒頭で書きました、低いところを目指す水のロジック書きます。
以下のコードになりました。

#if !USE_PLUGINS
using System;
using System.Collections.Generic;

namespace SimpleHexWorld
{
    public class WaterLogic : IBlockLogic
    {
        public int BlockId()
        {
            return 2; // @debug しかるべきリストから参照するべき
        }

        public void Work(int widthIndex, int depthIndex, int heightIndex, IHexWorldUtility worldUtility)
        {
            double randomValue = _random.NextDouble();

            if (0.1f < randomValue)
            {
                // return;
            }

            BlockData blockData = worldUtility?.GetBlockData(widthIndex, depthIndex, heightIndex - 1) ?? BlockData.Identity;
            if (0 == blockData.blockId)
            {
                blockData.blockId = 0;
                worldUtility?.SetBlockData(widthIndex, depthIndex, heightIndex, blockData);

                blockData.blockId = 2;
                worldUtility?.SetBlockData(widthIndex, depthIndex, heightIndex - 1, blockData);
            }
            else
            {
                List<int> sideIndices = new List<int>();
                for (int sideIndex = 0; sideIndex < 6; sideIndex++)
                {
                    int sideWidthIndex = widthIndex;
                    int sideDepthIndex = depthIndex;
                    worldUtility?.GetSideBlockIndex(widthIndex, depthIndex, sideIndex, out sideWidthIndex, out sideDepthIndex);

                    BlockData sideBlockData = worldUtility?.GetBlockData(sideWidthIndex, sideDepthIndex, heightIndex) ?? BlockData.Identity;
                    if (0 == sideBlockData.blockId)
                    {
                        sideIndices.Add(sideIndex);
                    }
                }
                if (0 < sideIndices.Count)
                {
                    int randIndex = _random.Next(0, sideIndices.Count);
                    int sideWidthIndex = widthIndex;
                    int sideDepthIndex = depthIndex;
                    worldUtility?.GetSideBlockIndex(widthIndex, depthIndex, sideIndices[randIndex], out sideWidthIndex, out sideDepthIndex);
                    blockData.blockId = 0;
                    worldUtility?.SetBlockData(widthIndex, depthIndex, heightIndex, blockData);

                    blockData.blockId = 2;
                    worldUtility?.SetBlockData(sideWidthIndex, sideDepthIndex, heightIndex, blockData);
                }
            }
        }

        private Random _random = new Random();
    }
}
#endif

実行結果は次の通り

www.youtube.com

フレームレートは60と落ちませんが、チャンク処理のループが遅いので非常にゆっくりと水ブロックは更新されていきます。
カメラ周辺のチャンクは昔話の竜宮城の外のように、周囲よりもずっと時間の進みが早い方が遊んでいて反応があって面白いかもしれません。
半径5チャンクぐらいなら、半径ごとにチャンク処理スレッドを割り当ててもいいかも

動きに不満があるとしたら、まずバブルが上昇することはありませんでした。
理由として、height 0からheight max まで順番に入れ替え処理がチャンク単位で行われるため、一度に空気ブロックは水面まで上昇してしまうことが挙げられます。
あと、側面への移動は空いている箇所へのランダム選択ですが、ここは直前のブロックの進んだ向きに最も近い方向へ進むべきだと思います。
慣性システムのために移動方向に周囲6,上下2の8つのdirectionとして3bitどこかblokから割いても良いかもしれませんね。
そのほか stress の計算を先に済ませて、最も圧をうけているブロックから力を受けて遠ざかる方向へ移動するのも自然かもしれません。

f:id:simplestar_tech:20180115083850g:plain

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

UnityでMinecraftライクゲーム作ってます。前回作った天地創造プラグインにパーリンノイズで高さを決定する機能を入れてみました。
こんな感じでみなさんの自由なアイディアをゲームに取り入れられます。

f:id:simplestar_tech:20180114104815j:plain

www.youtube.com

プラグインの実装はこちら↓
ノイズの関数を呼ぶとはいえ、わずかプラグインの13 行のプログラムでこの世界が形作られているとは、自分でも驚きです。

#if !USE_PLUGINS
using System;
using System.Collections.Generic;

namespace SimpleHexWorld
{
    public class WorldCreation2 : IWorldCreation {
        public bool IsInstantiater()
        {
            return true;
        }

        public IWorldCreation Instantiate(int positionX, int positionZ)
        {
            return new WorldCreation2();
        }

        public void WorldStart(int seed)
        {
            
        }
        public void ChunkStart(int positionX, int positionZ, int width, int depth, int height)
        {
            _perlin = new Perlin(32);
            _positionX = positionX;
            _positionZ = positionZ;
            _width = width;
            _depth = depth;
            _height = height;
        }

        public int BlockId(int widthIndex, int depthIndex, int heightIndex)
        {
            if (_lastWidth != widthIndex || _lastDepth != depthIndex)
            {
                _lastWidth = widthIndex;
                _lastDepth = depthIndex;
                double x = (_width * _positionX + widthIndex) / (256.0 * _width) * 32;
                double z = (_depth * _positionZ + depthIndex) / (256.0 * _depth) * 32;
                _heightNoise = _perlin.OctavePerlin(x, 0, z, 6, 0.5);
            }
            if (_heightNoise * _height > heightIndex)
            {
                return 1;
            }
            return 0;
        }

        public void ChunkEnd()
        {
        }

        public void WorldEnd()
        {
        }

        private Perlin _perlin;
        private int _positionX;
        private int _positionZ;
        private int _lastWidth = -1;
        private int _lastDepth = -1;
        private int _lastHeight;
        private int _width;
        private int _depth;
        private int _height;
        private double _heightNoise;
    }
}
#endif

パーリンノイズの実装は以前にも紹介しました次の記事
postd.cc
のものを使わせていただきました。
本当に素晴らしい記事、ありがとうございます。

public class Perlin
{
    public Perlin(int repeat = -1)
    {
        this._repeat = repeat;
    }

    public double OctavePerlin(double x, double y, double z, int octaves, double persistence)
    {
        double total = 0;
        double frequency = 1;
        double amplitude = 1;
        double maxValue = 0;            // Used for normalizing result to 0.0 - 1.0

        for (int i = 0; i < octaves; i++)
        {
            total += perlin(x * frequency, y * frequency, z * frequency) * amplitude;
            maxValue += amplitude;
            amplitude *= persistence;
            frequency *= 2;
        }
        return total / maxValue;
    }

    public double perlin(double x, double y, double z)
    {
        if (_repeat > 0)
        {                                   // If we have any repeat on, change the coordinates to their "local" repetitions
            x = x % _repeat;
            y = y % _repeat;
            z = z % _repeat;
        }

        int xi = (int)x & 255;                              // Calculate the "unit cube" that the point asked will be located in
        int yi = (int)y & 255;                              // The left bound is ( |_x_|,|_y_|,|_z_| ) and the right bound is that
        int zi = (int)z & 255;                              // plus 1.  Next we calculate the location (from 0.0 to 1.0) in that cube.

        double xf = x - (int)x;                             // We also fade the location to smooth the result.
        double yf = y - (int)y;
        double zf = z - (int)z;
        double u = fade(xf);
        double v = fade(yf);
        double w = fade(zf);

        int aaa, aba, aab, abb, baa, bba, bab, bbb;

        aaa = _p[_p[_p[xi] + yi] + zi];
        aba = _p[_p[_p[xi] + inc(yi)] + zi];
        aab = _p[_p[_p[xi] + yi] + inc(zi)];
        abb = _p[_p[_p[xi] + inc(yi)] + inc(zi)];
        baa = _p[_p[_p[inc(xi)] + yi] + zi];
        bba = _p[_p[_p[inc(xi)] + inc(yi)] + zi];
        bab = _p[_p[_p[inc(xi)] + yi] + inc(zi)];
        bbb = _p[_p[_p[inc(xi)] + inc(yi)] + inc(zi)];

        double x1, x2, y1, y2;

        x1 = lerp(grad(aaa, xf, yf, zf),                // The gradient function calculates the dot product between a pseudorandom
                    grad(baa, xf - 1, yf, zf),              // gradient vector and the vector from the input coordinate to the 8
                    u);                                     // surrounding points in its unit cube.
        x2 = lerp(grad(aba, xf, yf - 1, zf),                // This is all then lerped together as a sort of weighted average based on the faded (u,v,w)
                    grad(bba, xf - 1, yf - 1, zf),              // values we made earlier.
                      u);
        y1 = lerp(x1, x2, v);

        x1 = lerp(grad(aab, xf, yf, zf - 1),
                    grad(bab, xf - 1, yf, zf - 1),
                    u);

        x2 = lerp(grad(abb, xf, yf - 1, zf - 1),
                      grad(bbb, xf - 1, yf - 1, zf - 1),
                      u);
        y2 = lerp(x1, x2, v);
        return (lerp(y1, y2, w) + 1) / 2;                       // For convenience we bound it to 0 - 1 (theoretical min/max before is -1 - 1)
    }

    public int inc(int num)
    {
        num++;
        if (_repeat > 0) num %= _repeat;
        return num;
    }

    public static double grad(int hash, double x, double y, double z)
    {
        int h = hash & 15;                                  // Take the hashed value and take the first 4 bits of it (15 == 0b1111)
        double u = h < 8 /* 0b1000 */ ? x : y;              // If the most significant bit (MSB) of the hash is 0 then set u = x.  Otherwise y.
        double v;                                           // In Ken Perlin's original implementation this was another conditional operator (?:).  I

        // expanded it for readability.

        if (h < 4 /* 0b0100 */)                             // If the first and second significant bits are 0 set v = y
            v = y;
        else if (h == 12 /* 0b1100 */ || h == 14 /* 0b1110*/)// If the first and second significant bits are 1 set v = x
            v = x;
        else                                                // If the first and second significant bits are not equal (0/1, 1/0) set v = z
            v = z;
        return ((h & 1) == 0 ? u : -u) + ((h & 2) == 0 ? v : -v); // Use the last 2 bits to decide if u and v are positive or negative.  Then return their addition.
    }

    public static double fade(double t)
    {
        // Fade function as defined by Ken Perlin.  This eases coordinate values
        // so that they will "ease" towards integral values.  This ends up smoothing
        // the final output.
        return t * t * t * (t * (t * 6 - 15) + 10);         // 6t^5 - 15t^4 + 10t^3
    }

    public static double lerp(double a, double b, double x)
    {
        return a + x * (b - a);
    }

    static Perlin()
    {
        _p = new int[512];
        for (int x = 0; x < 512; x++)
        {
            _p[x] = permutation[x % 256];
        }
    }

    private static readonly int[] permutation = { 151,160,137,91,90,15,					// Hash lookup table as defined by Ken Perlin.  This is a randomly
		131,13,201,95,96,53,194,233,7,225,140,36,103,30,69,142,8,99,37,240,21,10,23,	// arranged array of all numbers from 0-255 inclusive.
		190, 6,148,247,120,234,75,0,26,197,62,94,252,219,203,117,35,11,32,57,177,33,
        88,237,149,56,87,174,20,125,136,171,168, 68,175,74,165,71,134,139,48,27,166,
        77,146,158,231,83,111,229,122,60,211,133,230,220,105,92,41,55,46,245,40,244,
        102,143,54, 65,25,63,161, 1,216,80,73,209,76,132,187,208, 89,18,169,200,196,
        135,130,116,188,159,86,164,100,109,198,173,186, 3,64,52,217,226,250,124,123,
        5,202,38,147,118,126,255,82,85,212,207,206,59,227,47,16,58,17,182,189,28,42,
        223,183,170,213,119,248,152, 2,44,154,163, 70,221,153,101,155,167, 43,172,9,
        129,22,39,253, 19,98,108,110,79,113,224,232,178,185, 112,104,218,246,97,228,
        251,34,242,193,238,210,144,12,191,179,162,241, 81,51,145,235,249,14,239,107,
        49,192,214, 31,181,199,106,157,184, 84,204,176,115,121,50,45,127, 4,150,254,
        138,236,205,93,222,114,67,29,24,72,243,141,128,195,78,66,215,61,156,180
    };

    public int _repeat;
    private static readonly int[] _p;                                                    // Doubled permutation to avoid overflow
}

このPerlinクラスの使い方を簡単に示します。

コンストラクタの引数で指定するのは循環値で、今回は 32 を入力しましたが、その場合は OctavePerlin で指定する座標値は 0~32 の範囲の実数で与えること。
これによって、ちょうど世界が循環するチャンク0 番付近でノイズも循環するので不自然な切断面ができなくなります。

仮に、0~32 で実数を与えているのに、コンストラクタに 64 とか設定しちゃうとこんな感じで、とても不自然な切り立った面ができちゃいます。
f:id:simplestar_tech:20180114111502j:plain

3次元座標を指定すると、浮遊島や入り組んだ洞窟も作れます。
プラグインの実装を次のように変更してみました。

        public int BlockId(int widthIndex, int depthIndex, int heightIndex)
        {
            double x = (_width * _positionX + widthIndex) / (256.0 * _width) * 256;
            double z = (_depth * _positionZ + depthIndex) / (256.0 * _depth) * 256;
            double y = heightIndex / (double)(_height) * 32;
            _perlinNoise = _perlin.OctavePerlin(x, y, z, 6, 0.5);
            if (_perlinNoise < 0.4)
            {
                return 1;
            }
            return 0;
        }

結果は次の通りです。
f:id:simplestar_tech:20180114115143j:plain

いろんな角度から見た絵はこちら
www.youtube.com

最初の Height 指定と合わせて、土の中はこんな感じの 3D ノイズで穴を掘ったり
まばらに溶岩ブロックを置くとそれらしい初期状態が作れそうですね。

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

天地創造

ここのところメインスレッドが処理落ちしないよう、バックグラウンド処理でメッシュ構築を行う仕組みを作ってきました。
こちらが機能するようになったので、今回はいよいよブロックのロジックに入ります。

さっそく適当な天地創造プラグインを書いて結果を見てみました。

f:id:simplestar_tech:20180113215541g:plain

youtube 版はこちら
www.youtube.com


インタフェースは仮ですが、プラグインの実装はこの65行になります。

#if !USE_PLUGINS
using System;
using System.Collections.Generic;

namespace SimpleHexWorld
{
    public class WorldCreation : IWorldCreation
    {
        public bool IsInstantiater()
        {
            return true;
        }

        public IWorldCreation Instantiate(int positionX, int positionZ)
        {
            return new WorldCreation();
        }

        public void WorldStart(int seed)
        {
            _seed = seed;
        }

        public void ChunkStart(int positionX, int positionZ, int width, int depth, int height)
        {
            _random = new Random(DateTime.Now.Millisecond);
            for (int i = 0; i < 45; i++)
            {
                _points.Add(new float[] { _random.Next(0, width - 1), _random.Next(0, width - 1) });
            }
        }

        public int BlockId(int widthIndex, int depthIndex, int heightIndex)
        {
            _heightMax = _random.Next(20, 45);
            if (_heightMax > heightIndex)
            {
                for (int i = 0; i < _points.Count; i++)
                {
                    if (_points[i][0] == widthIndex && _points[i][1] == depthIndex)
                    {
                        return 1;
                    }
                }
            }
            return 0;
        }

        public void ChunkEnd()
        {
            
        }

        public void WorldEnd()
        {
            
        }

        private List<float[]> _points = new List<float[]>();
        private int _seed = 0;
        private Random _random;
        private int _heightMax = 0;
    }
}
#endif

まだ仮ですけど、プラグインのインタフェースを解説します。

    public interface IWorldCreation
    {
        bool IsInstantiater();
        IWorldCreation Instantiate(int positionX, int positionZ);
        void WorldStart(int seed);
        void ChunkStart(int positionX, int positionZ, int width, int depth, int height);
        int BlockId(int widthIndex, int depthIndex, int heightIndex);
        void ChunkEnd();
        void WorldEnd();
    }
  • IsInstantiater 創造主フラグ

プラグインの実装が一つの場合は必ず true を返します。
世界各地に応じてプラグイン実装をいくつも用意した時、それらの実装をインスタンス化して振り分けるプラグインなのかどうかを返事します。

単一処理のステートレスなプラグイン実装ならば this を返してください。
ただし、今回のように乱数生成オブジェクトを持つような(ステートフルな)プラグイン実装の場合は新しいインスタンスを作成して返さなければなりません。
なぜならば、複数のスレッドから同時にプラグインの機能が呼び出されることがあり、あるチャンク処理中に別のチャンクの処理が始まり、創造結果においては創造主の期待と異なる結果を生む場合があるからです。
positionX, positionZ はチャンクの位置インデックスで、それぞれ経度、緯度を意味します。
世界各地におけるプラグイン実装をいくつも用意した場合は、ここでそれらの実装を振り分けるようにインスタンス化して返してください。

  • WorldStart 世界構築前に一回だけ呼び出される

seed にはゲームメニューから設定された値が入ります。
乱数基準に作られる世界だったとしても、同じ種から生み出された世界は、同じ形となるべきです。
インスタンス化される側のプラグイン実装の場合は呼ばれないので注意

  • ChunkStart チャンク処理開始前に一回だけ呼び出される

positionX, positionZ はチャンクの位置インデックスで、それぞれ経度、緯度を意味します。
width, depth, height はチャンクのそれぞれのブロック数を示します。
今は 16 x 16 x 256 ブロックで構成されるチャンクになっています。

  • BlockId ブロックのマテリアルIDを決める

チャンク内のブロックのインデックスが引数で渡されますので、そのブロックがどのマテリアルになるべきかを返します。
今は 0 が風で、1が土、それ以外のマテリアルが存在しませんので、おいおいマテリアルID表を作成して公開します。
今回作ったプラグインは45本の柱について、高さ20まで積んだ後、高さ45までランダムに間引くように作られています。

  • ChunkEnd チャンク処理終了後に一回だけ呼び出される

チャンク処理が終わった後の終了処理をここで行います。

  • WorldEnd 世界を閉じる前に一回だけ呼び出される

終了処理を行うタイミングの一つとして認識してください。
インスタンス化されたプラグイン実装の場合は呼ばれないので注意

AIに身体性を与えるためのマイクロワールドの構築13:Unityのプラグイン機構

前置き

今日のゲームの面白さは、決められた内容をコントローラをピコピコしながら進めるところにはありません。
いつしかゲームは絵本や映画とは異なり、何度プレイしても内容が変化する、飽きが来ない仕組みへと変化してきました。

それはいつしか自らの筆で描いた絵をゲーム内に持ち込み、ゲームを構成しているロジックを考えて導入できるまでとなり
自分が考えたゲームを遊び、さらにその面白さを他人と共有することで、もっと面白さが広がるようになりました。

後半はもう少し未来のことです。

さて、前置きはここまでにして、いよいよ地形生成とブロックのロジックまわりを手掛けていきます。
未来予想図に従うならば、ここを外部から変更できるようにプラグイン機構を用意して、容易にプレイヤーがゲームのロジックを変えられるようにしなければなりません。

実装メモ

Unity でプラグイン機構を作るには C# で DLL を配置するのがベストだと思います。
メリットとして作成時に Visual Studio などの IDE のコード補間機能やコンパイルエラーメッセージなどが確認できて、安全かつ開発者にやさしい点が挙げられます。

スクリプトを配置して実行時にコンパイルして使う方法もあります、こちらのメリットは容易にプラグインの内容がユーザーにも開示される点でしょうか。

参考
プログラムでコンパイルを行う - .NET Tips (VB.NET,C#...)
文字列の計算式の計算結果を取得する - .NET Tips (VB.NET,C#...)

ただ、この件を同僚に相談したところ、プラグイン開発も容易に行えるツールも作れるわけで、最初は簡単な方から手掛けて
後から変更が利くように作っておけばなんでも良いという意見をいただきました。

まったくその通りであります。
ということで、経験もあり容易に実装できるマネージドDLLプラグイン形式で外部からブロックのロジックを提供するようにします。

ここで、地形作成さえもプラグイン化することで、ユーザーに独自の世界を楽しんでもらえるようにしようと思いつきました。

プラグインの作り方などはこちら
docs.unity3d.com

C:\Windows\Microsoft.NET\Framework\v4.0.30319 に csc.exe ファイルがあり、コマンドラインよりプラグインが作れます。
csc.exe を使用したコマンド ラインからのビルド | Microsoft Docs
コンパイラオプションは -help コマンドで確認できます。

さっそく地形生成処理に使ってみましょう。

テストに使ったプラグインコードはこちら

using System;
using UnityEngine;

namespace DLLTest
{

    public class MyUtilities
    {

        public int c;

        public void AddValues(int a, int b)
        {
            c = a + b;
        }

        public static int GenerateRandom(int min, int max)
        {
            System.Random rand = new System.Random();
            return rand.Next(min, max);
        }
    }
}

実際にビルドに使ったコマンドがこちら

C:\Users\simpl>C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe /r:"C:\Program Files\Unity2017.3\Editor\Data\Managed\UnityEngine.dll" /target:library /out:"F:\Work\GitHub\SimpleHexWorld\Assets\SimpleHexWorld\Scripts\UnityWorld\Model\External\MyUtilities.dll" F:\Work\GitHub\SimpleHexWorld\Assets\SimpleHexWorld\Scripts\UnityWorld\Model\External\MyUtilities.cs

作られた DLL を Assets 下に配置して、競合する型の警告が出る元の .cs ファイルを削除
次のテストコードを書いて、適当なオブジェクトにアタッチします。

using UnityEngine;
using System.Collections;
using DLLTest;

public class Test : MonoBehaviour
{

    void Start()
    {
        MyUtilities utils = new MyUtilities();
        utils.AddValues(2, 3);
        print("2 + 3 = " + utils.c);
    }

    void Update()
    {
        print(MyUtilities.GenerateRandom(0, 100));
    }
}

問題なく動きました。

が、競合するクラス定義がプロジェクト内に二つ以上あると、片方を削除しなければならないという問題があります。(一応警告が出るだけで実行はできる)
プラグイン側の定義のみ有効にする方法は無いのでしょうか?

。。。ひらめきました。

using System;

#if !USE_PLUGINS
namespace SimpleStar
{
    public class MyUtilities
    {

        public int c;

        public void AddValues(int a, int b)
        {
            c = a + b;
        }

        public static int GenerateRandom(int min, int max)
        {
            System.Random rand = new System.Random();
            return rand.Next(min, max);
        }
    }
}
#endif

USE_PLUGINS を Project Settings の Scripting Define Symbols にてCROSS_PLATFORM_INPUT;USE_PLUGINS のようにすることで
スクリプト側の実装を隠し、プラグイン側の実装のみを有効にすることができます。

USE_PLUGINS を外しておけば、プラグインのコードのデバッグも容易ですし、これは良いことを思いつきました。

続いて、動的に読み込み利用するようにテストコードを修正します。

参考にしたのは次のページ
zecl.hatenablog.com

LINQ という配列を入力に幾重にも条件フィルタリングしながら、時には変換して最終的に配列を返す書式をうまくつかってコードをすっきりまとめています。
素晴らしいですね。問題なく機能しました。

なお、ビルドに際してはプラグインもエディタも Interface を共通の dll から利用する必要があるので、少し工夫が必要です。
私が行ったのはインタフェースを定義するファイルを用意し、プリプロセッサ定義によってエディタでは無効になりつつも、プラグインでは有効になる仕組みを利用しました。

#if !USE_PLUGIN_INTERFACES
namespace SimpleHexWorld
{
    public interface IWorldCreation
    {
        int BlockId(int i);
    }
}
#endif

プラグインのインタフェースDLLが無い時は上記コードを有効にしてテストを進め、安定したある段階でプラグイン化して、プロジェクト側で USE_PLUGIN_INTERFACES を定義してプラグインの方を有効にします。
このファイルからインタフェースがまとまった DLL を作成するコマンドは次の通り

C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe /target:library /out:"F:\Work\GitHub\SimpleHexWorld\Assets\SimpleHexWorld\Scripts\UnityWorld\Model\Plugin\PluginInterfaces.dll" F:\Work\GitHub\SimpleHexWorld\Assets\SimpleHexWorld\Scripts\UnityWorld\Model\Plugin\PluginInterfaces.cs

そして、このインタフェースを継承するクラスを定義するプラグインを作成するコードが次の通りで

using System;

#if !USE_PLUGINS
namespace SimpleHexWorld
{
    public class TestPlugin : IWorldCreation
    {
        public int BlockId(int i)
        {
            return i + 2;
        }
    }
}
#endif

このファイルとインタフェースDLLからプラグインを作成するコマンドは次の通り

Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe /r:"F:\Work\GitHub\SimpleHexWorld\Assets\SimpleHexWorld\Scripts\UnityWorld\Model\Plugin\PluginInterfaces.dll" /target:library /out:"F:\Work\GitHub\SimpleHexWorld\Plugins\TestPlugin.dll" F:\Work\GitHub\SimpleHexWorld\Assets\SimpleHexWorld\Scripts\UnityWorld\Model\Plugin\TestPlugin.cs

あとは USE_PLUGINS のプリプロセッサをプロジェクト設定で定義して、プラグインを動的読み込みするコードを実行します。

        IWorldCreation _worldCreationPlugin; // @debug
        public void LoadPlugins(string dataPath)
        {
#if USE_PLUGINS
            foreach (IWorldCreation worldCreation in PluginUtil.LoadPlugins<IWorldCreation>(dataPath + @"\..\Plugins\"))
            {
                _worldCreationPlugin = worldCreation;
                break;
            }
#else
            _worldCreationPlugin = new TestPlugin();
#endif
        }

問題なくプラグインは読み込まれ、インタフェースを実行してプラグインの機能を呼び出すことに成功しました。
チャンクのセーブデータが無ければ WorldCreation のプラグインが指定するブロックマテリアルを設定するようにして、次のような結果を得ました。

インタフェースはもう少しチャンクの情報やブロックの情報を渡すように改良する必要がありますが、プラグイン機構がおおむね用意できましたので、次の記事へ進みます。

f:id:simplestar_tech:20180111002630j:plain

AIに身体性を与えるためのマイクロワールドの構築12:ワーカースレッドの仕組み

前置き

ゲームの描画ループを止めることなく、バックグラウンドで処理を行う作りにすることは、ゲーム体験を向上させる点で重要なことです。
マイクロワールドの表示において、ブロックのメッシュの更新がバックグラウンドで実行できないという Unity の厳しい制約がありました。

そのため、メイン描画スレッドの他にバックグラウンド処理用のスレッドを立て、可能な限り裏で処理をして、Unityがどうしてもメインスレッドで処理してほしいと宣言している処理のみをメインスレッドに渡し、さらにフレームレートが落ちないよう重い処理を分割して消化する仕組みを作る必要があります。

成果物

こちら、上から見た図です。
マウスで動かしているのがプレイヤーのカメラの位置、周囲のチャンクが近い順に作成され、遠いチャンクは削除されていることがわかります。

f:id:simplestar_tech:20180108192448g:plain

目に見えませんが、定期的に走査する処理が走り、部分的にチャンクを更新しながら、不要となったチャンクを開放する処理が走ります。

実装メモ

私の考えは次の通りです。
ゲームが始まると同時に一つのメインビュースレッドと二つのモデル側のスレッドが走ります。
具体的にはメインビュースレッドの描画準備が整い次第、メインスレッドが二つのモデルワーカースレッドを走らせます。

実は UniRx を使って次のコードで別スレッド処理を起動できるのですが…

Observable.Start(() => {
// ここに別スレッドで行う処理
})
.ObserveOnMainThread()
.Subscribe(_ => { });

開発中のコードにてクラッシュすると、どこで例外が発生したのか Unity からうまく追えなくなるという問題があります。
絶対にエラーが出ない単純な処理なら UniRx を用いるのもコーディング面で便利ですけど…今回はそのケースにあてはまりません。

やっぱりスレッドは以前書いた以下の記事を参考にして実行することにします。
simplestar-tech.hatenablog.com

Mixed Reality Tool Kit の導入を以前、書きましたが、この時点でビルド設定は Experimental だったので、簡単に sync / async キーワードが使えました。
まずはワーカースレッドをメインスレッドが生きている間中走らせるようにしてみました。
作られる二つのスレッドにはそれぞれ次の名前が付いています。
1. LatticeScanner
2. SpiralScanner
1. はその名の通り格子状に走査するスレッドで、具体的には連番でチャンクを 3 x 3 で復元し、チャンクの全てのブロックの処理を行った後、オブザーバーまでの距離と共にビューに通知します。
2. はその名の通り螺旋状に走査するスレッドで、具体的にはオブザーバーがいるチャンクを中心として螺旋状にループしながら、3 x 3 でチャンクを復元し、チャンクの全てのブロックの処理を行った後、ビューに通知します。

落ち着いてきたころの実装コードはこちら

        public void StartWorkerThreads()
        {
            _workThread = true;
            _mainThread = SynchronizationContext.Current;

            Task.Run(_ScannLattice);
            Task.Run(_ScanSpiral);
        }

       private Action _ScannLattice = () =>
        {
            var threadId = Thread.CurrentThread.ManagedThreadId;

        private Action _ScanSpiral = () =>
        {
            var threadId = Thread.CurrentThread.ManagedThreadId;

LatticeScanner の場合
Observer から遠く離れている可能性があります。
その場合、計算で不要となった3つのチャンクを開放しなければなりません。
いつチャンクの情報を開放するのか?→これはブロックの処理が終わったタイミングで開放することにしました。
どうやって3つのチャンクを特定するのか?→ループの書式上、最も若いオフセットでアクセスしたチャンクで特定できました。
開放したは良いですが、デバッグ表示として、ビューで結果を可視化したい。→可視化できるようにしました。
そこで、緯度経度が変更される瞬間も不要なチャンクが開放されることを可視化により確認。

確認した時の動画
www.youtube.com

ここでもう一方の SpiralScanner の場合
Latticeの方は世界一周して、オブザーバーの近くにくるまで時間がかかりますので、常にオブザーバーを中心として螺旋状にスキャンする仕組みも必要です。
こちらも可視化してみます。
www.youtube.com

これは、消してほしくないものまで消していますね。
オブザーバーの周囲にあるチャンクは消さないようにしたいのですが、どうすればその判定ができるでしょうか?

オフセット計算できる関数→ありませんので作ります。

循環の部分はどうすればよいのでしょうか?→考えます。。。

なるほど!遠すぎるということも近いということに気付きました。
これでインデックスが折り返すような状況でも残したいチャンクだけ残るように修正できそうです。

www.youtube.com

できました。

次に、オブジェクトが残り続けるという問題を解決します。
先ほどのオブジェクトが近い判定が循環を含めて行えるようになりましたので、こちらを利用して
ビュー側の Update 処理で、オブザーバーから遠すぎる場合はオブジェクトを削除するようにしてみました。

また、同時に同じチャンクに対して処理を行うとブロックデータを破壊してしまうので、チャンクごとにロックオブジェクトを作成し同じチャンクに同時にアクセスできないようにしました。

www.youtube.com

スレッドの仕組みについては、おおむね片付きました。
次は地形生成のプラグイン機構を作っていきます。

Unityでクラスをファイルとして保存(シリアライズ)

前置き

ゲーム内で利用しているクラスオブジェクトをそっくりファイルに保存して
次にゲームを起動したときに、そのファイルからクラスオブジェクトを完璧に復元できます。
できる方法があります。

ゲームの状態をセーブして、次にゲームを起動したときにロードする話です。
基本的なゲームの機能ですね。

ファイルの読み書きの書式を学んで、独自にファイルフォーマットを策定して、必要なゲーム情報だけを効率的に保存し、効率的に読み出しできるようにローダーを描く、残念ですがこんなプロ仕様な話は今回は取り扱いません。

今回はタイトルの通り、セーバーもローダーも用意せずに、クラスオブジェクトを渡すとファイルになり、ファイルを渡すとクラスオブジェクトが得られる方法をここでは扱います。

具体的な実装方法

バイナリファイル版

using System.IO;
using System.Runtime.Serialization.Formatters.Binary;

public class BinaryUtil {

    public static void Seialize<T>(string path, T obj)
    {
        using (FileStream fs = new FileStream(path, FileMode.Create, FileAccess.Write))
        {
            BinaryFormatter bf = new BinaryFormatter();
            bf.Serialize(fs, obj);
        }
    }

    public static T Deserialize<T>(string path)
    {
        T obj;
        using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read))
        {
            BinaryFormatter f = new BinaryFormatter();
            obj = (T)f.Deserialize(fs);
        }
        return obj;
    }
}

注意点として T に設定するクラスには属性

    [Serializable()]
    public class Chunk {

が必要

Xmlファイル版

using System.IO;
using System.Xml.Serialization;

public class XmlUtil
{
    public static T Seialize<T>(string filename, T data)
    {
        using (var stream = new FileStream(filename, FileMode.Create))
        {
            var serializer = new XmlSerializer(typeof(T));
            serializer.Serialize(stream, data);
        }

        return data;
    }

    public static T Deserialize<T>(string filename)
    {
        using (var stream = new FileStream(filename, FileMode.Open))
        {
            var serializer = new XmlSerializer(typeof(T));
            return (T)serializer.Deserialize(stream);
        }
    }
}
実装メモ

これでマイクロワールドのチャンクの初期状態をファイルから復元できるようになりました。

f:id:simplestar_tech:20180107133138j:plain