simplestarの技術ブログ

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

CubeWalkGameなだらかな地形生成と永続化

このシリーズの続きです。
simplestar-tech.hatenablog.com

1.ECS による動的頂点生成と面生成
2.チャンクをまたぐデータ参照
3.キューブの回転とテクスチャの設定

と階段を登ってきました。

今回は過去記事でいうところのこのあたり
simplestar-tech.hatenablog.com
simplestar-tech.hatenablog.com
を整理しながら新しい知見を交えて、地形生成と永続化の手段を記録します。

過去実装からの移植で考えなければならないのは、
1.近景と遠景の階層化
単純に言えば、近場のチャンクにはコライダーが適用され、様々な相互作用の計算が走ります。
遠景のチャンクはチャンク同士でさえ結合され、コライダーは無く、描画にのみ特化した結合メッシュとなります。

加えて、
2.プレイヤーチャンクを中心に、もしプレイヤーがチャンクをまたいで移動した時は、動的に近景チャンクを追加し世界を広げる必要があり、古い近景チャンクは削除

しなければなりません。
そして、遠景と近景の間の穴が見つかることなくうめなければなりません。

もう一つ、
3.複数のマテリアルを利用して世界を表現

できなければなりません。これは描画レートの制約です。
現在の構想では、マテリアル数を 4 つほどに抑えて、オブジェクトを複数に分けるというものでしたが、はたして…

この三点について具体的な実装を考えていきます。

# 近景と遠景の階層化

3次元空間を扱う List の作成となりますが、5ヶ月前はとても難易度の高い立体 List を作ってこれの変化を管理するということを行いました。

難しいテンソル変形操作で、とても休日の片手間でメンテナンスするものではなくなりました。
(これ、休日に管理したくない)

そこで考えたのがソートとキューです。
計算リソースを使うことで、コードを簡略化することにします。

要するに、プレイヤーの座標 + チャンク幅の真ん中オフセットからチャンク位置までの距離を計算し
あたかもチャンクの中心とプレイヤー座標との距離を計算しているかのようにし
その距離を昇順にソートしてキューに詰めます。

キューの先頭から要素を取り出せば、順にプレイヤーに近いチャンクから処理されていくという動きが想像できます。

ぱっと思いつくのは
キューそのものにソート機能は無い
List には Sort 機能ある
何度もソートするなら、先に距離を計算する
チャンク内のキューブ数を増やして、チャンクの数を減らすとソート時間が短縮される

こう考えていくと、最初にリストに乗せるチャンクはどうやって決めるのか定義しなければなりません。

プレイヤーの座標から、チャンクの位置 int, int, int を決定
そこから立方体を切り出すように int, int, int の配列をつくり

これまでの以下のロジックを使って一意のチャンクキーを決定するのはどうか?

        #region 位置からチャンクキーに変換
        var byteMax = (byte.MaxValue + 1);
        var chunkKeyX = chunkPosition.x % byteMax;
        if (0 > chunkKeyX) chunkKeyX += byteMax;
        var chunkKeyY = chunkPosition.y % byteMax;
        if (0 > chunkKeyY) chunkKeyY += byteMax;
        var chunkKeyZ = chunkPosition.z % byteMax;
        if (0 > chunkKeyZ) chunkKeyZ += byteMax;
        #endregion

近景と遠景で処理を分けるとして
遠景はどのように表現すべきか
以前は 2 x 2 x 2 のチャンクのメッシュを合成していた
ただ、難しいのが近景と遠景の接続部分

贅沢なメモリ利用を考えるなら、メッシュ情報をすべてチャンク単位で持ち
近景と重なる遠景の部分は、場合によりチャンク単位で描画され
まったく重ならない領域では、メッシュを結合して 1/8 チャンクのメッシュとなり
さらに遠方では 3 x 3 x 3 で結合して、さらに遠方では 4 x 4 x 4 で結合する
階層は 1, 2, 3, 4, 5 階層くらいに分けて、結合されたチャンクはいつでも柔軟に
チャンク単位でメッシュを結合できる仕組みを考えます。

計算量も有限なので、階層ごとにコンビネーションを取ることは難しいと考えました。

プレイヤーのカメラを内包するチャンクを中心チャンクとして、これが余白をもって変化したタイミングで
最大チャンク半径について、すべてのチャンクの中心チャンクからの距離を計算し、近い順にソートします。

近い順に処理しながら、チャンク単位のメッシュを作成します。
距離が離れていくにつれて、2x2x2, 3x3x3, 4x4x4, 5x5x5 とメッシュを結合していきます。

外周のチャンクを作っている最中はだんだんパフォーマンスが悪くなりそうな予感がしてますが、どうなんでしょう?
ロジックとしては単純で、チャンクの結合が柔軟に行えて良い気がしています。

結合した後、中心チャンクが移動したときはどうなるでしょうか?
もう一度、すべてのチャンクの中心チャンクからの距離を計算し、近い順にソートします。

近い順にメッシュ情報を再利用しながら、一定距離近づいたにも関わらず 2 x 2 x 2 の結合メッシュに所属しているチャンクは一度、その結合メッシュをチャンク単位で描画するように戻し、結合メッシュを破棄

ふとここまでイメージしていて気づいたことに、視錐台カリングのために密集したチャンク同士でメッシュを結合する必要を考えてきたけど
順に取り出した位置はバラバラのチャンクで結合してもパフォーマンスはいくらか上がるという考え方
これなら距離に応じて結合チャンク数を逐次増やしていけるし、順番に結合済みのチャンクを作っていける
外周チャンクを作っているときに大量のチャンク単位のオブジェクトが作られる問題も解決します。

いや、やはり階層化しつつ、密集したチャンクで一つのオブジェクトとしたいところ
プレイヤーカメラからの距離でチャンクのソートが行えたなら、その順番を数で区切って 1階層2階層、3階層とグループ分けできると考えた
2階層のチャンクについて、中心チャンクから一定の間隔で 2 x 2 x 2 の結合を行っていくで良さそう
結合対象から漏れていたらそのチャンクを結合しなければいい
分解ができるなら、周囲のチャンクのみ分解し、その中心チャックに沿って分解と再結合を行っていく
第6階層の走査で、領域に含まれるチャンクについては削除を行うようにして、メモリの肥大を防ぐというもの

プレイヤーカメラと中心チャンクとの距離だけを毎フレーム見ながら、一定距離離れたら、全チャンクとの距離を計算してソートするようにして
いったんどのような動きとなるか考えていきたいと思います。

実装前の最後に、チャンクの情報をどうやってディスクやインターネットから読み込むかを考えてみます。
階層1の処理が終わったら、階層2のチャンクのデータの作成、保存、次回から読み込みを行うようにする。

イメージどおりに動くか、そろそろ実装します。

…一晩寝かせて気づいたのは、階層構造を立体格子状にループさせれば
チャンクまでの距離ソートがいらないのではないか、ということ

ここまで長く書いてきたことは…無かったことにしてください!

例えばこちらが愚直に書き下した、既存のチャンク処理ループ

            #region 作成対象のエンティティを Enqueue
            if (Input.GetKeyDown(KeyCode.Space))
            {
                for (var x = -15; x <= 15; x++)
                {
                    for (var z = -15; z <= 15; z++)
                    {
                        for (var y = -15; y <= 15; y++)
                        {
                            this.EnqueueCreateEntity(x, y, z);
                        }
                    }
                }
            }
            #endregion

f:id:simplestar_tech:20190619080304p:plain
31 x 31 x 31 チャンクの生成状況 32.52 ms ループ

f:id:simplestar_tech:20190619080434p:plain
タイムライン

遅い…なんとか高速化できないか

高速化について、次のように、中央から順に周辺チャンクを結合していくループを回すのはどうかと考えた

            #region 作成対象のチャンク情報を Enqueue
            if (Input.GetKeyDown(KeyCode.Space))
            {
                var perChunkRadius = 1;
                for (var x = -perChunkRadius; x <= perChunkRadius; x++)
                {
                    for (var z = -perChunkRadius; z <= perChunkRadius; z++)
                    {
                        for (var y = -perChunkRadius; y <= perChunkRadius; y++)
                        {
                            this.EnqueueCreateEntity(x, y, z, 0);
                        }
                    }
                }
                for (var level = perChunkRadius + 1; level <= 2; level++)
                {
                    if (2 > level)
                    {
                        break;
                    }
                    var offset = 3;
                    var geta = level * offset - 3;
                    for (var x = -geta; x <= geta; x += offset)
                    {
                        for (var z = -geta; z <= geta; z += offset)
                        {
                            for (var y = -geta; y <= geta; y += offset)
                            {
                                if (0 != geta - Mathf.Abs(x) && 0 != geta - Mathf.Abs(y) && 0 != geta - Mathf.Abs(z))
                                {
                                    continue;
                                }
                                for (var radiusX = -perChunkRadius; radiusX <= perChunkRadius; radiusX++)
                                {
                                    for (var radiusZ = -perChunkRadius; radiusZ <= perChunkRadius; radiusZ++)
                                    {
                                        for (var radiusY = -perChunkRadius; radiusY <= perChunkRadius; radiusY++)
                                        {
                                            if (0 == radiusX && 0 == radiusY && 0 == radiusZ)
                                            {
                                                continue;
                                            }
                                            this.EnqueueCreateEntity(x + radiusX, y + radiusY, z + radiusZ, 0);
                                        }
                                    }
                                }
                                this.EnqueueCreateEntity(x, y, z, 1);
                            }
                        }
                    }
                }
            }
            #endregion

f:id:simplestar_tech:20190619230359p:plain
geta 飛び配置で描画負荷を低減

f:id:simplestar_tech:20190621225117p:plain
コアで結合した結果

f:id:simplestar_tech:20190621225225p:plain
描画負荷は十分に解決

プレイヤーのいるチャンクを中心に、処理をループさせて、近景は 1 チャンクずつ
遠景は 3x3x3 を一つのチャンクに結合するという処理は、構想から始めて、だいたいいい形で固まりました。

# 適当な地形生成

プレイヤーカメラ移動に際し、今の巨大なキューブでは移動によって結果変化がわかりません。
このままでは先に進めなくなったので、適当な地形生成を行います。

以前調べた snoise を高さ情報として、なだらかな地形を生成してみます。

イメージを形に…

今はチャンク情報を1チャンク分だけ確保して、すべて同じチャンク情報としています。
これをやめて、すべてのチャンクごとにチャンク情報を保持させるようにします。

見た目は同じでも、中身は別

f:id:simplestar_tech:20190621233319p:plain
見た目が同じでも中身が別

参考に今のコード

    void EnqueueCreateEntity(int x, int y, int z, int margeRadius)
    {
        #region チャンク情報の定義
        var chunkData = new int[ChunkManager.ChunkSizeX * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY];
        var nativeChunkData = new NativeArray<int>(chunkData, Allocator.Persistent);
        CreateWorld(nativeChunkData);
        #endregion

        #region ポインターのポインターにチャンクをセット
        int chunkKeyX, chunkKeyY, chunkKeyZ;
        ChunkInt3ToChunkKey(new Vector3Int(x, y, z), out chunkKeyX, out chunkKeyY, out chunkKeyZ);
        var byteMax = (byte.MaxValue + 1);
        int chunkIndex = chunkKeyX * byteMax * byteMax + chunkKeyZ * byteMax + chunkKeyY;
        this.ppChunkData[chunkIndex] = (int*)nativeChunkData.GetUnsafePtr(); // 本来なら Amazon S3 からダウンロード完了後にこれしてから Enqueue とか?
        this.nativeChunkDataArray[chunkIndex] = nativeChunkData;
        #endregion

        #region キーとなる位置情報を Enqueue
        var chunkPosition = new Vector3Int(x, y, z);
        var createChunkInfo = new CreateChunkInfo { chunkPosition = chunkPosition, mergeRadius = margeRadius };
        this.createEntityQueue.Enqueue(createChunkInfo);
        #endregion
    }

    static void CreateWorld(NativeArray<int> nativeChunkData)
    {
        var pChunkData = (int*)nativeChunkData.GetUnsafePtr();
        for (var x = 0; x < ChunkManager.ChunkSizeX; x++)
        {
            for (var z = 0; z < ChunkManager.ChunkSizeZ; z++)
            {
                for (var y = 0; y < ChunkManager.ChunkSizeY; y++)
                {
                    var dataIndex = (x * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY + z * ChunkManager.ChunkSizeY + y);
                    var pData = (byte*)(pChunkData + dataIndex);
                    pData[0] = 1;
                    pData[1] = (byte)Random.Range((int)CubeRotationType.Top000, (int)CubeRotationType.Max);
                    pData[2] = 1;
                    pData[3] = 1;
                }
            }
        }
    }

上記コードの CreateWorld を snoise で作ります。

と、その前に遊ばせている CPU を見つけたので、並列性を高めておきました。

f:id:simplestar_tech:20190622002329p:plain
最低でも論理コア数のオブジェクトを作成するようにして並列計算

f:id:simplestar_tech:20190622111748p:plain
psrnoise 処理 x 1

f:id:simplestar_tech:20190622132048p:plain
pnoise 試験画像

f:id:simplestar_tech:20190622133541p:plain
期待するなだらかな地形

f:id:simplestar_tech:20190622134038j:plain
なだらかな地形

現在の地形生成の実装は次の通り
pnoise の p は periodic (ループする)だそうです。

    static void LoadChunkData(Vector3Int chunkKeyXYZ, Vector3Int chunkPosition, int* pChunkData)
    {
        float amplitude = 64.0f; // 振幅倍率
        float period = 1; // 周期
        float chunkCount = byte.MaxValue + 1;

        for (int x = 0; x < ChunkManager.ChunkSizeX; x++)
        {
            for (int z = 0; z < ChunkManager.ChunkSizeZ; z++)
            {
                float height = 0;
                for (int frequency = 8; frequency < 10; frequency++)
                {
                    var power = Mathf.Pow(2f, frequency);
                    var bAmp = Mathf.Max(amplitude / power, 2);
                    height += bAmp * noise.pnoise(new float2(
                        (chunkKeyXYZ.x * ChunkManager.ChunkSizeX + x) / (float)(ChunkManager.ChunkSizeX * chunkCount),
                        (chunkKeyXYZ.z * ChunkManager.ChunkSizeZ + z) / (float)(ChunkManager.ChunkSizeZ * chunkCount)
                        ) * (period * power), new float2(period * power, period * power));
                }
                for (int y = 0; y < ChunkManager.ChunkSizeY; y++)
                {
                    float cubeHeight = chunkPosition.y * ChunkManager.ChunkSizeY + y;
                    byte sideType = (cubeHeight < height) ? (byte)1 : (byte)0;

                    var dataIndex = (x * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY + z * ChunkManager.ChunkSizeY + y);
                    var pData = (byte*)(pChunkData + dataIndex);
                    pData[0] = 1;
                    pData[1] = (byte)Random.Range((int)CubeRotationType.Top000, (int)CubeRotationType.Max);
                    pData[2] = sideType;
                    pData[3] = sideType;
                }
            }
        }
    }

# 地形データの永続化

移動を実装する前に、今の地形をファイルにセーブして
次回起動時に、同じキーのチャンク情報が保存されているなら、計算で作らずにファイル読み込みから地形を生成するようにします。

先にポインターでファイルデータをやり取りするクラスを作りました。
参考にした記事はこちら
qiita.com

ちょっとだけ使いたい方向に修正した、ほぼ同じコードがこちら

using System.IO;
using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.IO.LowLevel.Unsafe;

internal unsafe class UnsafeFileUtility
{
    /// <summary>
    /// 呼び出し元は fileData.Buffer に対し ReleaseReadData で開放する責任あり
    /// </summary>
    public static void ReadData(string filePath, out ReadCommand fileData)
    {
        var fileInfo = new System.IO.FileInfo(filePath);
        long fileSize = fileInfo.Length;

        var readCommand = new NativeArray<ReadCommand>(1, Allocator.Persistent);
        readCommand[0] = new ReadCommand
        {
            Offset = 0,
            Size = fileSize,
            Buffer = UnsafeUtility.Malloc(fileSize, UnsafeUtility.AlignOf<byte>(), Allocator.Persistent),
        };

        var readHandle = AsyncReadManager.Read(filePath, (ReadCommand*)readCommand.GetUnsafePtr(), 1);
        readHandle.JobHandle.Complete();
        fileData = readCommand[0];
        
        readHandle.Dispose();
        readCommand.Dispose();
    }

    /// <summary>
    /// データの書き込み
    /// </summary>
    public static void WriteData(string filePath, ReadCommand fileData)
    {
        byte* pFiledata = (byte*)fileData.Buffer;
        using (FileStream fs = new FileStream(filePath, FileMode.Create, FileAccess.Write))
        {
            for (int filePosition = 0; filePosition < fileData.Size; filePosition++)
            {
                fs.WriteByte(pFiledata[filePosition]);
            }
        }
    }

    /// <summary>
    /// データの開放
    /// </summary>
    /// <param name="fileData"></param>
    public static void ReleaseReadData(ref ReadCommand fileData)
    {
        if (0 < fileData.Size && null != fileData.Buffer)
        {
            UnsafeUtility.Free(fileData.Buffer, Allocator.Persistent);
            fileData.Buffer = null;
            fileData.Size = 0;
        }
    }
}

これを利用して先程のなだらかな地形生成アルゴリズムとつなげます。
ファイルがあったら読むだけというコードと共に

    /// <summary>
    /// チャンクデータの読み込み(out int* は呼び出し元で開放責任あり)
    /// </summary>
    static void LoadChunkData(Vector3Int chunkKeyXYZ, Vector3Int chunkPosition, out int* pChunkData)
    {
        var fileName = $"{chunkKeyXYZ.x.ToString("000")}-{chunkKeyXYZ.y.ToString("000")}-{chunkKeyXYZ.z.ToString("000")}.bytes";
        var filePath = Path.Combine(Application.persistentDataPath, fileName);
        if (File.Exists(filePath))
        {
            UnsafeFileUtility.ReadData(filePath, out var fileData);
            pChunkData = (int*)fileData.Buffer;
        }
        else
        {
            var fileSize = sizeof(int) * ChunkManager.ChunkSizeX* ChunkManager.ChunkSizeY * ChunkManager.ChunkSizeZ;
            pChunkData = (int*)(UnsafeUtility.Malloc(fileSize, sizeof(int), Allocator.Persistent));

            float amplitude = 64.0f; // 振幅倍率
            float period = 1; // 周期
            float chunkCount = byte.MaxValue + 1;

            for (int x = 0; x < ChunkManager.ChunkSizeX; x++)
            {
                for (int z = 0; z < ChunkManager.ChunkSizeZ; z++)
                {
                    float height = 0;
                    for (int frequency = 8; frequency < 10; frequency++)
                    {
                        var power = Mathf.Pow(2f, frequency);
                        var ampSize = Mathf.Max(amplitude / power, 2);
                        var repSize = period * power;
                        height += ampSize * noise.pnoise(new float2(
                            (chunkKeyXYZ.x * ChunkManager.ChunkSizeX + x) / (float)(ChunkManager.ChunkSizeX * chunkCount),
                            (chunkKeyXYZ.z * ChunkManager.ChunkSizeZ + z) / (float)(ChunkManager.ChunkSizeZ * chunkCount)
                            ) * repSize, new float2(repSize, repSize));
                    }
                    for (int y = 0; y < ChunkManager.ChunkSizeY; y++)
                    {
                        float cubeHeight = chunkPosition.y * ChunkManager.ChunkSizeY + y;
                        byte sideType = (cubeHeight < height) ? (byte)1 : (byte)0;

                        var dataIndex = (x * ChunkManager.ChunkSizeZ * ChunkManager.ChunkSizeY + z * ChunkManager.ChunkSizeY + y);
                        var pData = (byte*)(pChunkData + dataIndex);
                        pData[0] = 1;
                        pData[1] = (byte)Random.Range((int)CubeRotationType.Top000, (int)CubeRotationType.Max);
                        pData[2] = sideType;
                        pData[3] = sideType;
                    }
                }
            }
            UnsafeFileUtility.WriteData(filePath, new Unity.IO.LowLevel.Unsafe.ReadCommand { Buffer = pChunkData, Size = fileSize });
        }
    }

問題なく動きました。

f:id:simplestar_tech:20190622165740p:plain
なだらかな地形を永続化

CubeWalkGameなだらかな地形生成と永続化ができました。
次のプレイヤーの移動については、長くなったので記事を分けます。

# プレイヤーチャンクの特定

# プレイヤーチャンク移動タイミングの定義

# 既存のチャンクメッシュを利用する再結合処理

続きはこちら
simplestar-tech.hatenablog.com

Unity:動的NavMeshの確認

ゲームの3大AI(人工知能)の一つはナビゲーションAI

ゲームAIを大きく分類すると次の三つがあることに気づけます

  • キャラクターAI
    • キャラの振る舞いを決定
  • ナビゲーションAI
    • 目的地への経路を決定
  • メタAI
    • ゲームの進行を決定

今回強く関係しているのはナビゲーションAIです。

キャラクターが行動できる範囲を Walkable (歩行可能) エリアと呼びますが、次の動画の水色の半透明な領域が計算によって求めた Walkable エリアです。

f:id:simplestar_tech:20190105172931g:plain
動的NavMeshの動作の様子
f:id:simplestar_tech:20190105173305g:plain
動的NavMeshの動作の様子2

ゲームプレイ中に動的に Walkable エリアが更新されていることが確認できました!
これ本当にすごいことを目の当たりにしている瞬間でして、長いこと Unity では静的にしか Walkable エリアを計算できませんでした。
要するに、ゲーム実行前のビルドの段階で Walkable エリアを作り込むことしか選択肢がなかったのです。

計算が非常に複雑な経路計算がゲーム実行中にできる
それは単にナビゲーションが動的に作成した地形に適用できるという話では収まりません
あらゆる知的計算は経路探索に置き換えることができます。
例えば、今日この記事を書いている私の頭の中は、様々な行動プランを選択できる状態の中、この記事を書き始めるべきだと経路を作って、その経路に従って行動しています。
つまり、キャラクターAIのプランニング機能を動的 NavMesh 生成処理で実現できます!
(そんな気づきを得て興奮できる人がいるかは別として…)

Unity での具体的な実装方法

まずは次の Unity マニュアルを最後まで読む
docs.unity3d.com

実装手順だけ簡単に示します。
ドキュメントに書いてある通りこの動的 NavMesh 機能は標準の Unity エディターインストーラーには 含まれていません

次の GitHub のページを Git Clone して、Assets 以下の NavMeshComponents フォルダをあなたの Unity プロジェクトの Assets 以下へ配置します。
github.com

NavMeshSurface コンポーネントが利用可能になるので、シーン内の空オブジェクトに次のように追加します。

f:id:simplestar_tech:20190105191205j:plain
NavMeshSurface コンポーネントを追加した GameObject のインスペクタ

もう一つ、ユーザースクリプトがゲームオブジェクトに割当たっていますが、こちらの実装は定期的に Bake ボタンを押すというものです。
以下の実装になっています。

NavMeshSurfaceBaker.cs

using System.Collections;
using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshSurface))]
public class NavMeshSurfaceBaker : MonoBehaviour
{
    void Start()
    {
        _surface = GetComponent<NavMeshSurface>();
        StartCoroutine(TimeUpdate());
    }

    IEnumerator TimeUpdate()
    {
        while (true)
        {
            _surface.BuildNavMesh();

            yield return new WaitForSeconds(5.0f);
        }
    }

    NavMeshSurface _surface;
}

実装の意味は、単に 5秒ごとに NavMesh を焼き直すというもの

計算に利用するCollier メッシュオブジェクトの集め方はいくつか用意されていて
All ですべてのオブジェクト、Volume で指定した範囲内のオブジェクトだけ、Child で Transform の子オブジェクトだけ、を使って焼き直しが走ります。
レイヤーマスクも指定できるので、なるべく簡素なメッシュを用意してそれに割り当てたレイヤーを利用すると 5秒に一回の計算量のスパイクを低くできます。

エージェントの操作

動的 NavMesh が作れても、経路計算の方法がわからなければ、宝の持ち腐れですね。
NavMeshAgent クラスを活用して経路計算を行ないますが、そちらの知識を確認したい場合はこちらの記事が分かりやすいのでどうぞ
nopitech.com

エージェントが宙に浮いちゃった!どうすれば良い?

f:id:simplestar_tech:20190105192908j:plain
NavMeshSurface を利用すると中空を歩いてしまう…

私の対処としては AgentBaseHeightCorrector.cs を次の実装で作り、キャラクターオブジェクトに適用しました。

using UnityEngine;
using UnityEngine.AI;

[RequireComponent(typeof(NavMeshAgent))]
public class AgentBaseHeightCorrector : MonoBehaviour
{
    void Start ()
    {
        _nav = GetComponent<NavMeshAgent>();
    }

    void Update()
    {
        CorrectBaseHeight();
    }

    private void CorrectBaseHeight()
    {
        NavMeshHit navhit;
        if (NavMesh.SamplePosition(transform.position, out navhit, 10f, NavMesh.AllAreas))
        {
            Ray r = new Ray(navhit.position, Vector3.down);
            RaycastHit hit;
            if (Physics.Raycast(r, out hit, 10f, LayerMask.GetMask("Level")))
            {
                _nav.baseOffset = -hit.distance;
            }
        }
    }

    NavMeshAgent _nav;
}

※実装ヒントは次の討論からもらいました。
https://forum.unity.com/threads/navmesh-surface-bakes-slightly-above-the-surface.508532/


毎フレーム処理が CPU リソース的に勿体ないなら、上の TimeUpdate の妙技を使ってみるのもありだと思います。

おわりに

うわぁ、ここまで書いてから類似するテラシュールブログさんの記事の存在に気付く…
ていうか自分スター押してる…忘れていたが正解でした!

tsubakit1.hateblo.jp

ただ、読み直した感じ、いくつか現在の実装とは異なる部分が見られたので
本記事は新しい NavMeshComponents の動作確認を行ったという意味があったかも

AI:「作って動かすALife」本を読んでの感想

読んだ本の情報

作って動かすALife
――実装を通した人工生命モデル理論入門
www.oreilly.co.jp

著者の一人に、次の対談に出てくる池上高志さんがいたので気になってました。
boundbaw.com

ALife 人工生命の入門書として書かれています。

個人的なとらえ方によるざっくりとした内容

1. ALife とは?
これまでの抽象的な生命論はやめて、実験的な数学を通して具体的に生命について理解を深める研究のこと
2. 生命のパターンを作る
蝶の羽模様や魚や動物の体表の模様、貝殻のパターン模様などが単純な数式で再現できる例を示す(Gray-Scottモデル)
3. 個と自己複製
個の定義も難しいがそれは置いておいて、ビット配列の遺伝子パターンを読み取って自分と同じ形を複製する2D世界のマシンを作り、これが無限に増殖していくものは考え出されていて、実際コンピュータで計算できることも示されている。(しかし、とても計算量が多くて時間がかかる)突然変異が遺伝子の一部を壊し、自己の複製ではなく変異種を複製し始め、様々な個体が生まれ、時に元の個体を複製するように戻ったりすることが観測できた。
4. 生命としての群れ
Bird-oid と書いて鳥っぽいもの群れとして Boids と呼び、よく ECS などで群のシミュレーションを行うアレの話。巨大で複雑にしないと進化が起きないという話もあったかも
5. 身体性を獲得する
認知・判断・行動と直列で構築したAIはアホすぎて、簡単な目標もクリアできなかったが、「障害物を見つけたら避ける」「何もなければ適当に動き回る」「目的地を見つけたら近づく」という反射的行動、迷った時の行動、目的意識を持った行動を階層化し、より高い意識層が下位の反射層を制御することで、複雑な目標をクリアでいることを示した。
6. 個体の動きが進化する
遺伝的アルゴリズムの・選択・突然変異・交叉の例を示して、生命ってのは自然淘汰の中での単なる多様性を生み出しているだけなのかもねって話
7. ダンスとしての相互作用
相互に影響し合うことがなければ飽きが来る、同じことを繰り返していても飽きが来る、この飽きが安定状態から不安定状態へと遷移させ、似たりよったりの中から新しいものが生まれる進化が巻き起こる
8. 意識の未来
やはり知能は内部でイメージを持ち未来予測や期待をして、実際に観測した情報との統合を行っていて、この時間の流れの中に意識があると考える。
一見無駄とも思える冗長な未来予測が創造につながり、こうした予測と行動選択は現実を生きるために不安定でなければならず、これが曖昧で定義できない意識なのかもしれない。

個人的な感想

私は特に文献を漁らずに AI について思ったことを書いてきました。(隙あらば自分語り…)
たとえば、ちょうど 2年前の記事
AI的な調べもの - simplestarの技術ブログ
>人工知能はマイクロワールドから出られなかった
>AI に身体性を持たせる必要がある
>その身体性をマイクロワールドの中で与える試みを行おうと思う

それからマイクロワールドの開発を進め
計算に時間がかかりすぎるので Unity ECS を学んでいたのが最近までのこと

このマイクロワールドのAI作りで役に立つ知見が得られるかもしれないとこの本を手にしました。

普段考えていたことの言語化ができるようになった気がします。
例えば、飽きがこないゲームとは、相互作用があり、それが安定しないことである
とか、冗長な未来予測の中で創造や発見が生まれ、知的に振る舞うエージェントを表現できる、など

そのほか、認知・判断・行動の直列をやめて、並列化して本能と理性の階層化により、理性で本能を抑えると知的に振る舞うという新たな知見を得ました。

これから AI をプログラムに落とし込むときに役立つ知見が一点増えた気がしました。

Unity2017~2018の追加機能の習熟作業を振り返る

前回の目標を達成しました。

以下が前回の目標に掲げたこと

Unity に頂点カラーのマテリアルは用意されていません。
そこで、シェーダを多少はいじれるようにしておきたいと考えました。
そのほかUnityのシェーダーを編集できる UI の確認
タイムラインもそろそろ触れるようになっておきたいし
Cinemachine ってのも勉強したいです。

頂点カラーマテリアルの作り方はこちらで紹介
simplestar-tech.hatenablog.com

シェーダーを多少はいじれるようになりました。
Unityのシェーダーを編集できる UI とは Shader Graph のことで、この Graph Shader のコードも出力できますし、編集できます。
お勉強は大成功ですね!

simplestar-tech.hatenablog.com

タイムラインもその辺の Animation Clip 触っただけの素人なんか小指で吹き飛ばせるほど、スクリプトでカスタマイズできる実力も付きました。
simplestar-tech.hatenablog.com

Cinemachine も謎のままでしたが、やっと正体がつかめた気がします。
simplestar-tech.hatenablog.com

次はこれらの知識をベースに、しばらく止めていたAIに身体性を与えるためのマイクロワールドの構築を進めていきます。

なんてったって Unity がマルチスレッド処理 + コンパイラレベルでの高速化を導入してきたんですよ!

その辺の技術も興味深く勉強してきました

ECS
simplestar-tech.hatenablog.com

Job Systems
simplestar-tech.hatenablog.com

Burst Compiler
simplestar-tech.hatenablog.com

さらに Scriptable Render Pipeline についても理解を深めました。
simplestar-tech.hatenablog.com

マテリアルの切り替え回数を減らせているかを確認するデバッグ術もマスターしています。
simplestar-tech.hatenablog.com

Unity の今後の Prefab とアセットバンドル管理の仕組みも予習し、リリースに向けての土台作りも意識できるようになっています。
simplestar-tech.hatenablog.com

新しい Network 機能を Google と連携して Unity が用意しているってニュースが流れていますし、今は個人ゲーム開発のわくわくが止まらないですね。
さぁて、1年前に構想した心を持つAIの作り方
simplestar-tech.hatenablog.com

こちらを目指して実装していきましょう!

勾配降下法とユーティリティーベースAIのパラメータ決定

ユーティリティーベースAIの作り方について、とても分かりやすい記事を見つけました。
moon-bear.com

筆者のくろくまさんは、ユーティリティベースAIの評価関数の作り方について、明確に次の問題点を指摘しています。

しかし困ったことに「こうすればいい」というような決まりはない

ひいてはゲームAI全般に言えることですが、パラメータの決定を AI ディレクターなる立場の方が試行錯誤や勘に頼って行っているのが現在のゲーム業界です。
大手メーカー各社、ほか医療関係の業界では人の命にかかわる製品が多いため、必ず事故が起こらないようにパラメータを解析的に決定するようにしています。(経験的に決めた値が使われることもあるかも、必ずは言い過ぎ)

解析的に決めるという手法ですが
具体的には、ゲーム中にキャラクターを人間が動かして行動ログを取り、その人間の行動ログと適当なパラメータで決定したAIの行動ログの差を誤差として
誤差最小化の枠組みでパラメータを変動させながら決めるというのが、一般的なアプローチです。

5年前にこのような発言がゲーム業界に投げかけられたことがあったのですが
だれもが「そんな難しいことできません。開発工数にそういった高度な試みをする余裕がない」という回答を持って、聞き入れませんでした。
そして、現在も行われていません。

しかし、近年 GDC などのゲーム国際会議の議題レベルでは、Deep Learning をはじめとする機械学習のツールを活用したゲーム開発効率や作品の質向上について語られるようになってきました。
経営権を持つ上層の人たちも、こうした情報元を通じて、大量の行動ログとAIの行動ログの差を最小化する手法(一般的な機械学習のスタイル)について、見識を持ち始めています。

5年前と違って、今こそAI ディレクターが試行錯誤や勘に頼って頑張っている作業を機械化し、より高品質なユーティリティーベース、またはゲームAIが作れるようになる時代だと考えています。

すべては飽きがなかなか来ない愛せるほどの面白いゲームを作るため
ゲーム業界で大量のログを持て余している方の目にとまったならば、その人間のログとAIのログの差を最小化する手法を試そうと調べてみるのはどうでしょうか?

関連する、約3年前の私の記事です。

誤差は順調に減少していくと思いますから、そうなったとき好ましい結果を返すAIになってくれると良いですね。

simplestar-tech.hatenablog.com

ユーティリティベースの8つの欲求

The SiMs のキャラクターはまるで欲求を持って行動しているように見えるそうですが
実際、8つの欲求が設計されていて、時間と状況によって一番高い欲求に従うように行動しているそうな

その8つとはどんな欲求だったのか見てみます。

身体的欲求
1.Hunger :空腹
2.Comfort :快適さ
3.Hygiene :衛生
4.Bladder:膀胱(尿意、便意)
精神的欲求
5.Energy : 気力
6.Fun : 楽しさ、面白さ
7.Social :社会所属
8.Room : 一人になりたい

基本的にどれも減少していく値と結び付けられていて
例えば選択肢にトイレと風呂があったとき

トイレに行くと
Bladder が大回復、 Room が回復

風呂に行くと
Hygiene が大回復、Comfort が回復、Room が回復

する。

キャラクターの現在最も値の低い欲求値について、それを最も回復させてくれるものをプランニングして行動する
たとえば、「かなり尿意がある、から、トイレに行きたい」とキャラが発言できるようになるし
その発言の後に実際にトイレに行くことになる

これまでのゲームAI:7つの分類

ゲームの国際会議GDC2018のAIの集会で、日本人の三宅陽一郎(43)さんが発表してますが、ゲームAIについて良く調べている方と言えばこの人になります。
そんな三宅さんがゲームに用いられるAIを7つに分類してくれました。

前回の私の記事で、遺伝、習性、意思決定などのレイヤー構造のAIにすると良さそうだ、と酔った頭で考えていましたが
今回のこの 7つの分類は、そのレイヤーの一つとしてとらえていけそうな気がします。

解説いただいたその 7つの分類は次の通り

1.ルールベース(すべてを平たんに if ~ else ~ で記述する反射行動)
2.ステートベース(状態ごとに反射行動を記述、状態遷移によって処理を分岐)
3.ビヘイビアベース(開発者にやさしい設計で描く反射行動)
4.ゴール思考(手段目的構造でプランニング)
5.タスクベース(目的をタスクに分解してプランニング)
6.ユーティリティベース(欲求の効用曲線、動物的な習性)
7.シミュレーションベース(短期的な未来を計算して、その場における最善手を選ぶ)

また補足説明として 1.2.3.は反射的な行動、ゆえにあまり賢いAIに見えない
4.5.は有名FPSとかに導入されているけど、作品数は多くない
6.は日常生活系のシミュレータで大活躍
7.こそ研究段階、囲碁や将棋AIはこれ

ということでした。

かれこれ 2016年の9月ころから私が考えてきたAIは、7.シミュレーションベースのその先の研究領域だったのかもしれません。
周囲の環境をシミュレーション可能な枠組みで作るという逆転の発想を用いて、その結果から行動を決めるAI
また、何度も同じような行動が繰り返されたときに、Deep Learning 技術で特徴学習を行って、短時間で過去の経験から賢い選択をできるようになるAI
そんなAIを目指しています。

すべては飽きがなかなか来ない愛せるほどの面白いゲームを作るためですが、少しずつ世界がその方向に向かって動き出していることを感じられてうれしく思いました。