simplestarの技術ブログ

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

AIに身体性を与えるためのマイクロワールドの構築17.6:Unityでのマルチスレッド処理の話

前置き

AIに身体性をもたせるためのマイクロワールドの開発を進めていますが、マルチスレッドで処理しているため、期待と異なる結果になったり、パフォーマンスが極端に落ちたりすると、うまく原因を特定できません。
頼みのプロファイラーも抽象度の高い結果を返すため、結果的に開発者が処理を想像してデバッグすることになり、疲れるうえ、なかなか正確な原因にたどり着けません。
さらに、時間がたつと開発者自身、実装がどうなっているのか把握できなくなってくるので、このタイミングで簡単に実装内容をまとめてみようと思いました。

ゲームがフリーズしないようにするために

Unity はメインスレッドがロックされるとゲームがフリーズします。
最初、マイクロワールドで起こるすべてのブロックの処理やブロックのメッシュ作成処理などをメインスレッドが担当したのですが、もはやゲームではなくただのイラストになってしまいました。
そこで、マルチスレッド化です。

マイクロワールドで起こるすべての処理をメインスレッドとは異なる別スレッドに担当してもらうようにしました。
Unity でのマルチスレッドについての記事はこちら
simplestar-tech.hatenablog.com

ここで、Unity はメッシュの生成処理など、描画に関わる多くの処理は別スレッドから呼べない仕組みになっていることに気づかされます。そこで

WorldとUnityWorld
ChunkとUnityChunk

という、モデルとビューでそれぞれに世界とチャンク(世界の構成要素)の概念を与えました。
モデル側の World と Chunk にて UnityEngine 機能を呼ぶことを禁止することで、全てのモデル処理が別スレッドで処理できることが保証されるようになり、メインスレッドでしか処理できないような Unity 依存のコードを UnityWorld, UnityChunk に記述することで、いつかシミュレーションをUnity非依存のサーバで高速に行えるようになります。(あ、もう一つの設計方針であるモデルとビューの分離の話が混ざっちゃった、ちょっと理解するの難しいかも?)

さて、World で起こる出来事はすべてワーカースレッドが行うため、世界の更新がどんなに時間のかかる処理になったとしても、ゲームがフリーズすることは無いように思われますが、World をビジュアライズするためには、実行時に World から UnityWorld へ描画のためのメッシュ情報を渡す必要があります。さらにメッシュ生成処理もメインスレッドで行う必要があります。ここにフリーズの原因があると覚えてください。
スレッド間で同じものを参照する時に他のスレッドから更新されてはいけないオブジェクトもいくつかあります、たとえばメインスレッドで配列からメッシュを作成している最中に、参照している頂点配列とテクスチャ座標の配列のサイズが合わなくなるとクラッシュしますので、排他制御を行う必要があります。
この排他制御時のロックを非常に短くすることで、人間には観測できないロックになり、ストレスのないゲーム体験が実現できるわけですが、ここがエンジニアの腕の見せ所であり、このゲームの最も技術的に難しいテーマの一つに数えられます。
私がどうやってこの難題を解決したかを示します。

基本的な考え方は次の通り

メインスレッドにスレッドセーフキュー以外の排他制御コードを書くことを禁止する

普通はバックグラウンド処理中のロック時間を小さくすることを考えると思いますが、そうしない理由として、バックグラウンド処理が少しの間フリーズしても、ユーザーにとっては全然気にならないということが挙げられます。(ちょっとよくわからない文章になりましたが、要は裏で行われている処理はユーザーには認識できませんので、認識できないものがどんなに遅延しても、やはり遅延を認識できないということです。)
メインスレッドでロックを記述するということは、どんなに別スレッドでのロック時間を小さくしても、根本的にゲームのフリーズの可能性を取り払うことはできません。
将来にこのロックの仕組みを失念した私が、メインスレッドをフリーズさせる不具合を仕込む可能性が高いのです。
ロックを仕込まないようにする具体的な方法は、計算結果をインスタンス化して、キューに詰め、メインスレッドでキューから取り出したインスタンスを処理すれば、別スレッドで作成した計算結果が書き換わらないことが保証されますので、すべてうまくいきます。

では UnityWorld が保持しているスレッドセーフキューには、どのようなものがあるか見ていきましょう。

private ConcurrentQueue<Chunk> _encounteredChunkQueue = new ConcurrentQueue<Chunk>();
private ConcurrentQueue<Chunk> _updatedChunkQueue = new ConcurrentQueue<Chunk>();
private ConcurrentQueue<UnityChunk> _createMeshDataUnityChunkQueue = new ConcurrentQueue<UnityChunk>();
private ConcurrentQueue<UnityChunkLayer> _updateLayerMeshUnityChunkQueue = new ConcurrentQueue<UnityChunkLayer>();

まずカメラが配置されたチャンクを中心に一定距離内の視界に入る周囲チャンクをworldから _encounteredChunkQueue に詰めてもらいます。
World 側ではチャンクデータをディスクから読み出したり、WorldCreationプラグインから作ったりするため、処理に時間を要することがあり、そのまま全チャンクの準備が完了するまでロックするとやはりゲームがフリーズします。
そのため、メインスレッドを待たせないように、このスレッドセーフキューが用意されています。
チャンクデータの読み込みが完了し次第、_encounteredChunkQueue に Chunk が Enqueue されます。
その後メインスレッドで Dequeue して、チャンクオブジェクトが作成されます。
チャンクデータにアクセスする場合はロックが必要ですが、結果的にメインスレッドがロックされない仕組みになります。
その点については、チャンクデータからメッシュを作成する処理で詳しく述べます。

冒頭でも書きましたが GameObject に触る処理はメインスレッド外には書くことができません。
なのでこうして、ゲームオブジェクトを作成する処理だけをメインスレッドで処理します。
注意点として、worldはどのチャンクが表示されているかどうかのビューの情報は知ることができませんので、同じチャンクをキューに詰めてくる場合があります。
チャンクオブジェクトを重複して作成することを避けるため、作成後は辞書に登録して、辞書に載っていない場合にだけチャンクオブジェクトを作成する処理になっています。

while (_maxUpdateTime > updateProcessTime && _encounteredChunkQueue.TryDequeue(out chunk))
{
    if (!ChunkObjects.ContainsKey(chunk.ChunkKey))
    {
        GameObject chunkObject = new GameObject();
        chunkObject.name = "Chunk" + chunk.PositionX + "_" + chunk.PositionZ;
        UnityChunk unityChunk = chunkObject.AddComponent<UnityChunk>();
        unityChunk.SetChunk(chunk);
        unityChunk.SetNeedCollider(false);

        int offsetX = 0;
        int offsetZ = 0;
        Vector3 chunkObjectPosition = GetChunkObjectPosition(chunk, out offsetX, out offsetZ);
        chunkObject.transform.localPosition = chunkObjectPosition;
        chunkObject.transform.SetParent(transform, true);

        ChunkObjects.Add(chunk.ChunkKey, unityChunk);
        _createMeshDataUnityChunkQueue.Enqueue(unityChunk);
    }
    updateProcessTime = Time.realtimeSinceStartup - updateStartTime;
}

GetChunkObjectPosition で返ってくる位置は世界の循環を見越して計算されるもので、現在のカメラのチャンクから数えてどれくらいのオフセットが付くのか計算して返します。
オフセット計算は基本的には単なるチャンクインデックスの差の移動量を返しますが、あまりにも差が大きすぎる場合は、一周分のオフセットを足したり、引いたりした折り返しの移動量が返ってきます。
なので、世界の半分など、あまりにも遠くまで見渡せるようにしてしまうとこの循環配置システムは破綻します。つまりあまりにも狭すぎる世界は定義できないということです。
それなりに大きな世界ならば、現在のカメラのチャンクの位置を中心に計算するので、何度もループすることが可能であり、一見して果てのない世界を表現できます。

同じところに何度もループして戻ってくることを確認する動画
www.youtube.com

チャンクオブジェクトを作成し、そのチャンクオブジェクトに UnityChunk スクリプトコンポーネントを追加したら、今度はその UnityChunk コンポーネントをスレッドセーフキューの一つ _createMeshDataUnityChunkQueue へ詰めます。
このキューの中身の消化についてはメッシュ作成の段階で詳しく述べます。

次に _updatedChunkQueue について説明します。
ここにはワーカースレッドにて更新処理を終えたチャンクが詰め込まれます。
キューの中身の処理方法は基本的に変わらず、時間に余裕があればメッシュ作成のためのスレッドセーフキューに UnityChunk スクリプトコンポーネントを詰めますが、周囲の視界に入った場合の処理と違って、すでにチャンクオブジェクトが生成されている場合のみメッシュ更新処理をするようになっています。
ロジックによってブロック情報が書き換えられたのなら、ビューに反映されるべきですが、見えないところの更新については無視するというロジックです。
単にメッシュの更新が目的なので、チャンクオブジェクトが無い場合はスキップします。

while (_maxUpdateTime > updateProcessTime && _updatedChunkQueue.TryDequeue(out chunk))
{
    if (ChunkObjects.ContainsKey(chunk.ChunkKey))
    {
        UnityChunk unityChunk = ChunkObjects[chunk.ChunkKey];
        _createMeshDataUnityChunkQueue.Enqueue(unityChunk);
    }
    updateProcessTime = Time.realtimeSinceStartup - updateStartTime;
}

ここまで見てきた通り、_createMeshDataUnityChunkQueue にはチャンクオブジェクトに配置するメッシュ作成のためのソースとして UnitytChunk スクリプトコンポーネントが詰め込まれています。
それを次のようにバックグラウンド処理にて Dequeue して、メッシュデータの作成をメインスレッドに影響を与えないようにして作成します。
重要なことなので念を押しますが、ゲームオブジェクトを作成する処理をメインスレッドで行った後、再度メッシュデータの作成処理はメインスレッド以外で行うということです。
つまり、UnityChunk 側の実装でありながらも、この処理はどんなに時間を要してもゲームがフリーズしないということです。

private Action _CreateLayerMeshTask = () =>
{
    UnityChunk unityChunk = null;
    while (_meshCreaeteLoop)
    {
        if (Instance._createMeshDataUnityChunkQueue.TryDequeue(out unityChunk))
            unityChunk?.CreateLayerMeshData();
    }
    Debug.Log("_CreateLayerMeshTask finished");
};

メインスレッドとは別のワーカースレッドにて実行されますので、このCreateLayerMeshData関数を処理中にメインスレッドで UnityChunk がリムーブされたりアタッチ先のチャンクオブジェクトが削除された場合は、結果の反映先のオブジェクトが無いという困った状態になります。
幸い Unity はこの状況でクラッシュするにはならず、スクリプトコンポーネント自身が null となるだけですので、処理中に this == null が成立したらメッシュデータ作成処理をスキップする、という処理で対処します。
メッシュデータの作成が完了したら、成果物としての各種配列が詰まったインスタンスをスレッドセーフキュー _updateLayerMeshUnityChunkQueue に詰めます。

最後にメインスレッド側で、このキューから取り出したメッシュデータから描画用メッシュを作成して完了です。
計算結果のインスタンスはバックグラウンド処理から切り離されていますので、メインスレッドで安心してロックフリー参照してメッシュ生成を行えます。

private float _UpdateChunkLayerMesh(float updateProcessTime, float updateStartTime)
{
    UnityChunkLayer unityChunkLayer = null;
    while (_maxUpdateTime > updateProcessTime && _updateLayerMeshUnityChunkQueue.TryDequeue(out unityChunkLayer))
    {
        unityChunkLayer?.unityChunk.UpdateLayerMesh(unityChunkLayer.layerIndex, unityChunkLayer.meshSource);
        updateProcessTime = Time.realtimeSinceStartup - updateStartTime;
    }
    return updateProcessTime;
}
まとめ

スレッドセーフキューからDequeする処理以外で、メインスレッドがロックされる場面は存在しませんので、どんなに計算量の多い場面でもゲームのフレームレートが落ちないことが保証されます。
スレッドセーフキューに何も詰まっていなくても処理にそれなりの時間を要しますが、プロファイラーでの数値は約 0.63 ms ほどでした。
描画のために十分処理時間が余りますので、ひとまずはこの仕組みで作業していきたいと思います。

f:id:simplestar_tech:20180121134639j:plain

Unity:シーンに一つだけしかアタッチできないSingletonMonoBehaviour+実装例

グローバルスコープに唯一のインスタンスを生成して、プロジェクトのどこからでも容易にアクセスできる仕組みがシングルトンの良いところです。
絶対に一つしかインスタンスを生成していないことをコードから保証できる点で、優れたデザインパターンだと思います。

さて、Unity の MonoBehaviour というゲームオブジェクトにアタッチして使うクラスは、そんなC#のシングルトン書式が通用しません。
なので、次の記事で示されるような仕組みを用意する必要があります。

qiita.com

具体的には以下のコードです。

SingletonMonoBehaviour.cs

using System;
using UnityEngine;

namespace SimpleHexWorld
{
    public abstract class SingletonMonoBehaviour<T> : SimpleMonoBehaviour where T : SimpleMonoBehaviour
    {
        private static T _instance = null;
        public static T Instance
        {
            get
            {
                if (null == _instance)
                {
                    Type t = typeof(T);

                    _instance = (T)FindObjectOfType(t);
                    if (_instance == null)
                    {
                        Debug.LogError(t + " is not attached in this scene");
                    }
                }

                return _instance;
            }
        }

        virtual protected void Awake()
        {
            if (this != Instance)
            {
                Destroy(this);
                Debug.LogError(
                    typeof(T) +
                    " is already attached, so this script component was removed." +
                    " Object.name = " + Instance.gameObject.name + " causes this trouble.");
                return;
            }

            //DontDestroyOnLoad(this.gameObject);
        }
    }
}

簡単に説明すると、ゲーム開始後にどこかで Instance にアクセスすると、シーン内を探索した後、シーン内で唯一のスクリプトコンポーネントを返します。
もしシーン内に存在しない場合はエラーログが流れて null が返ります。
またシーン内の複数のオブジェクトに誤ってアタッチしてしまった場合、エラーログが Awake のタイミングで流れ、2つめ以降のスクリプトコンポーネントはゲームオブジェクトから削除されます。

以下、使い方についての実装メモです。

クラスは継承を使って、Awake を上書きしつつ、しっかり継承元の Awake を呼ぶようにします。

namespace SimpleHexWorld
{
    public class UnityWorld : SingletonMonoBehaviour<UnityWorld>, IViewWorld
    {
        protected override void Awake()
        {
            base.Awake();

使いどころでは Instance の null チェックを忘れずに!(?を一つ付けると null だったら null を返し、null じゃなければアクセスして値を返します。うれしい省略書式ですね!)

_meshData[layerIndex]._materials[subMeshIndex2] = UnityWorld.Instance?.BlockMaterials[keyValue.Key];

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