simplestarの技術ブログ

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

オンラインゲーム化の準備

昨年9月からAIに身体性を与えるためのマイクロワールドの構築を続けてきました。
趣味プログラムなので、やりたいことをやりたい順番で進めてきたわけですが、見積もりをしてこなかったが故にしっぺ返しが…
さすがにブロック同士の相互作用を行う大気シミュレーションはマシンパワーに見合わないので、途方に暮れていたのです。
(計算を別スレッドに任せることでゲームは固まりませんが、人間が面白いと感じる速度で世界は変化していかないわけです…)

大気の循環は3次元で行わずに2次元マップで行うようにして、計算をさらに簡略化しようと思いますし

もう一つ、ここまでずっとクライアントサイドの実装を続けていましたが、時代の変化に合わせる形で
オンラインゲームのサーバーサイド技術も休日を使って勉強していかないといけなくなってしまいまして

勉強のモチベーションを高めるために、AIに身体性を与えるためのマイクロワールドをオンラインゲーム化するという名目で趣味プログラムを再開したいと思います。

と、書いておけば、そのうち記事を更新しだすと信じて、自分の身体に鞭打ってみます。

追記1:
あれ、実装途中のコードを見直してみると、物質の3態のベースを作るのに頭を悩ませていますね…3次元空間の相互作用を全然あきらめていないじゃん
計算負荷をクライアントやサーバーに分散する方向で世界を加速させる手段という方向で、オンラインゲームの勉強をしてみます。

AIに身体性を与えるためのマイクロワールドの構築18:大気の循環

この世界で水が山頂の方から流れてくるのはなぜか考えたことはありますでしょうか?
大気は循環しており、その大気に水が含まれているため、その大気が雨を降らせ、山から水が流れてきます。

f:id:simplestar_tech:20180204232847j:plain

はい、ということで今回は大気の循環をブロックのロジックを使って再現したいと思います。

大気の流れというものを、太陽からの輻射熱、地表の温度上昇、熱の伝播、暖気は冷気より軽い、重い空気は落下し…大気は流れる、というロジックで動かしてみたいと思います。

細かく見ていくと気体分子の運動量の伝播なのですが、そこまで細かくは作れませんので、具体的には
・太陽光と地表面のなす角より輻射熱の吸収、放出量が変化
・地表ブロックの判定
・地表ブロックの熱量増加
・地表ブロックに触れている空気へ熱が移動
・温まった空気ブロックが水分を水ブロックから吸収
・暖気の上に冷気がある場合は上下ブロックの内容を交換
というロジックで動かそうと思います。
これで空気が移動して風が吹き、さらに空気が水分を含むように作ることで大気と水の循環が行われる世界が出来上がる…はずです。

実装して確かめてみましょう。

・太陽光と地表面のなす角より輻射熱の吸収、放出量が変化

いまだ存在していなかった時間という概念をWorldに与えます。
続いて、その時間をもとに太陽の角度を決定します。
一日を 0~2π の値で表すと次の通り

worldTimeDayRadian = (2.0f * (float)Math.PI) * (Instance.worldTime.Hour / 24.0f + Instance.worldTime.Minute / (24.0f * 60.0f));

この値は太陽の角度決定にそのまま使い、さらに次のように調整することで
_radiantHeat = (float)Math.PI - Math.Abs(Instance.worldTimeDayRadian - (float)Math.PI)
輻射熱の吸収、放出量の係数として利用します。

そのほか、北と南、東と西の概念を世界に与えます。
チャンクごとに緯度と経度がありますので、世界標準時の経度と日付変更線、そして北と南の緯度における太陽の角度、日照時間の短さです。
年間を通して日照時間は変化するようにし、あたかも世界が球体で自転の軸が23.4度ほど傾いているように見せかけます。

・地表ブロックの判定

太陽の角度については特に考えず、上空から探索して最初に不透明ブロックを見つけた場合に、これを地表ブロックとします。(半透明の雲や水面は透過率をもって通過します)
ブロックのロジックに地表としての処理用インタフェースを追加して、これを今までのブロックのWorkを行っているタイミングと一緒に呼び出すこととします。

コードのイメージはこんな感じです。

BlockDataSt blockData = BlockDataSt.AirBlock;
for (int widthIndex = 0; widthIndex < Width; widthIndex++)
{
    offsetWidth = Depth * Height * widthIndex;
    for (int depthIndex = 0; depthIndex < Depth; depthIndex++)
    {
        offsetDepth = Height * depthIndex;
        bool isSurface = false;
        for (int heightIndex = Height - 1; 0 <= heightIndex; heightIndex--)
        {
            blockData.data = localBlockData[offsetWidth + offsetDepth + heightIndex];

            if (!isSurface && MaterialID.Material_Translucent < blockData.materialId)
            {
                if (MaterialID.Material_Opacity < blockData.materialId)
                    isSurface = true;

                blockData = blockLogics[(uint)blockData.materialId]?.SurfaceWork(blockData, worldUtility) ?? blockData;
            }

            BlockDataSt resultBlockData = blockLogics[(uint)blockData.materialId]?.Work(blockData, widthIndex, depthIndex, heightIndex, localBlockData, worldUtility) ?? blockData;
            localBlockData[offsetWidth + offsetDepth + heightIndex] = resultBlockData.data;
・地表ブロックの熱量増加

先ほど追加した SurfaceWork 関数を実装して、輻射熱係数をたよりに heat を上昇させたり、下降させたりしてみます。
テストとして、最高温度に達したら空気ブロックにマテリアル変換を起こすロジックを作ってみました。すると…

f:id:simplestar_tech:20180204231816g:plain

こんな感じで昼の間に表面の土ブロックだけが消失している絵ができました。
動画はこちら

www.youtube.com

今のままだと太陽光を受け、熱が上昇し続け、あっというまに最高温度に到達するわけですが、これは物理現象としてあり得ません。(地上で生きていけない…)
本来物質は固体の状態で熱が上がると、自身もその熱に応じて輻射熱を周囲へ放射します。
熱が上がれば輻射熱量もどんどん高くなるため、ある程度熱くなってくると、なかなか熱くならなくなるわけです。

しかし、残念ながら現在の計算スピードを考えると、周囲ブロックへ放射熱が伝播するところまで計算していられないので、ここは乱数に頼ってみようと思います。
低温状態の地表ブロックに太陽からの輻射熱を与えると高い確率で地表ブロックの熱が上昇しますが、熱量が高まるにつれて上昇の確率が下がっていくという仕組みを導入します。

熱量が上昇すると、徐々に熱の上昇確率が減っていき、例えば以下のようなグラフの曲線はどうでしょうか?
f:id:simplestar_tech:20180210193928j:plain
こちらは 0.98 を熱量の値で累乗した結果です。

最初は 0 乗なので 100%, 255の値付近では 0.5% です。
チャンクのブロック処理の頻度はそこまで高くないため、だいたい100の値を越えたあたりで、ほぼ熱量の上昇はしないという形になります。

次に太陽光を受け取らなくなった時に、徐々に放熱しなければなりませんので、これについては次のように、高い温度だった場合に周囲に高い熱量を放射する…

f:id:simplestar_tech:20180210195245j:plain

いや、これだけで高い温度に到達しずらい仕組みが作れそうですね。
ある角度で太陽光が当たったなら、その角度に応じた熱がブロックに加算され、比熱の値がブロックのマテリアルごとに決まっていて、その値で割った値が上昇する温度
しかし、温度に応じで周囲へ放射する量が決まっていて、太陽光の放射量がそれを越えない限りそこからの温度上昇は起きないという形です。
これなら太陽の力が強い時はしっかりとそれに応じた地表温度というものに収束します。(漸近)

テストシーンにこの仕組みを適用すれば、ブロックは地上から干上がることはなくなりました。

最終的に落ち着いたアイディアとして、太陽の日照密度というものを 0~1 の値で表現するようにして、単純に日の出の瞬間に0から始まり sin 波として太陽が直上に来た時に 1.0、日が沈むころに 0 となり、夜間はすべて 0 となる値を地表ブロックは受け取れるようにしました。
そこに比熱となる要素をかけることにします。
この比熱の値は light の値の範囲に気を付けないと、永遠に地表は温まらないことになるので、後程調整が必要です。(byte で温度表現のつらいところですね)

public virtual BlockDataSt SurfaceWork(BlockDataSt blockData, ref float light, IHexWorldUtility worldUtility)
{
    if (1.0f > lightTransmittance)
    {
        float addHeat = ((1.0f - lightTransmittance) * light) / specificHeat;
        float nextHeat = blockData.heat + addHeat;
        float radiantHeat = (float)Math.Pow(radiantHeatBase, nextHeat);
        blockData.heat = (byte)Math.Min(Math.Max(nextHeat - radiantHeat, 0), byte.MaxValue);
        light = lightTransmittance * light;
    }
    return blockData;
}

今後、シミュレーション結果を通じて、各ブロックの放射熱量、比熱の全体スケールというものを調整していきたいと思います。

・地表ブロックに触れている空気へ熱が移動

まず、私は気象学について無知でしたので次の入門記述を読みました。
気象学超入門

要はここに書いてある、上昇気流が低温化して雲を発生させるメカニズムというものを SimpleHexWorld (マイクロワールド)で実現しようとしています。

これまでの構想を経て、現在空気ブロックには熱と圧力の値が byte で詰められています。
この二つの値のうち、今は熱を動かそうとしていますが、まずは空気ブロックに標高に応じた圧力の値を詰めるべきと考えました。

単純に最も上空の空気は熱 0 / 255, 圧力 0 / 255 という宇宙(space)ブロックで固定し、宇宙に熱(分子の運動量)を逃がし続ける存在とします。
初期値としては地上に向かって降りるにしたがって圧力を 1 ずつ増やしていき、チャンクの最深部に到達する頃には圧力が 255 / 255 になる形で初期値を打ちます。

圧力の伝播も熱の伝播と同様にベースとなるロジックを書く必要があると思いました。

では、次に熱の伝導の部分を詰めていきましょう。
熱の伝導において、与える熱量と温まりやすさという度合いが重要になります。(熱伝導量と比熱ですね)
空気ブロックに、乾燥断熱減率の概念を導入しようと思います。
圧力が低くなるにつれて、heat が一定の割合で低くなっていくという現象です。(開放系の気体の振る舞いですが、圧力が異なる気体の中の分子の動きを脳内でイメージするの、難しいんですよね)

上空は低密度ですので、単純に単位ブロックあたりの質量は小さくなります。
大きい質量と小さい質量の気体を重力場に置けば、流動結果として大きい質量の方が重力方向の先に移動します。
地球に住む人間はこれを上昇ととらえているみたいですね。

上昇下降については単位ブロックあたりの質量を見て、大きい方を常に重力方向に配置するように動かせばよいのです。

太陽の光によって、だいたい地表の温度は 0~255 の値を行き来します。
自身を含めた周囲の温度の平均を自身の温度にするようにしてみます。
まずはこれで地表付近の空気ブロックから温まり始め、徐々に熱は上空に移動し始めます。
空気は放射する計算を行わせていないので、夜になると地表より空気の方が暖かくなり、地面によって空気は冷やされることになります。

夜は上空の大気の方が暖かくなる可能性があります。
もちろん地上も空気の熱伝導によって温まりますが、空気と同じように温度を混ぜ合わせることはできません。
例えば、土ブロックの周囲に空気ブロックがある場合、周囲の空気ブロックが 100 ℃だったとして、土ブロックが0℃、このとき周囲8ブロック+自身1ブロックの平均88℃まで土ブロックは上昇しません。
温まりやすさというものがあったとして、…つまり比熱ですね。

このブロックの heat の値を絶対にするか相対にするかで悩みましたが、絶対にすると熱伝導の高低の判別計算が楽ですので、絶対にします。(世界の熱分解能は低いですね…)

分解能の低さは丸め誤差による情報の欠損です。あまりにもエネルギーが保存されない世界はそれはそれで作る意味がありません。
そこで!
熱の移動について、ここに確率を取り入れてみるのはどうでしょうか?
比熱比で確率計算する感じで、具体的には、ブロックの周囲のheat値を確認し、もし自分が高い場合は、相手に熱を +1 する、しかしそこに確率が生じていて
土から空気の場合は比熱比が1以上になるので確実に +1 、つまり 100% 伝導し、移動した熱量だけ自分も -1 するべきところ、しかし、そこに確率が生じていて、比熱比が 0.01 となっているので1%で自身の heat を-1します。
次に、もし自分が低い場合は、空気ブロックの heat を -1 して、土のブロックの heat を +1 しますが、これも先ほどの確率計算で 100% と 1% の値を使って、周囲の空気は冷えやすく、土ブロックは温まりづらいという現象が実現でき、さらに丸め誤差による情報欠損が多少なり改善されます。(この低分解能な世界に必要な表現です、なかなか良いアイディアを思いついたと思っています。)
先ほどの空気と土が互いに88℃になる現象は、100%と1% の確率を使えば、期待値として7.4℃になるわけです。(土ブロックは空気ブロック100個分の熱エネルギーを蓄えられると考える)

こういう話、密度と比重と体積について、みなさん勉強してきたと思います。
ですが、このマイクロワールドにおいて、体積は固定されたブロックの単位でのみ存在しますので、考えるべきは密度と比重をかけた値、つまりブロックの質量のみを考えるだけでうまくいきます。

また、熱伝導率という概念を導入して、先ほど +1 ずつ値を変化させましたが、高い熱伝導率を持つような金属の場合はもっと高速に heat のやり取りを行うべきです。(温度差が 1 なのに 10も移動してはおかしいので、そういう場合は比熱比を確認して 確率を上昇させてから +1 にするなどですね、これもいいアイディアを思いついたと思いました。)

整理するとマテリアルのロジックは次の定数を持つべきということです。

public float specificHeat { get; protected set; }
public float lightTransmittance { get; protected set; }
public float radiantHeatBase { get; protected set; }
public float thermalConductivity { get; protected set; }

また実装で詰まってきた…チャンクを越えられない…
チャンク内はスレッド内のローカル変数を書き換えるだけなので問題ありませんが、チャンクをまたいだ編集を行おうとすると他のスレッドからアクセスされないようにロックする必要が…
これではまたパフォーマンスが落ちてしまうので、悩みます。

一つアイディアが沸いたのですが、スレッド内のローカル変数にチャンクに接している周辺8つのチャンクのためのローカル変数を用意しておき
仕事中はスレッド内の変数を更新するのみとして、処理後にロックが必要ならロックして書き換えるというのはどうでしょうか?

これもパフォーマンスは悪くなりそうですが、仕方ないですよね。
作ってみます。(処理が重くなるのこえぇ)

デバッグ風景
f:id:simplestar_tech:20180212104833j:plain

先ほどのアイディアを具体的にすると次の通り(このポッと具体化するのが大変なんですよね…頭使う、まぁ問題を解決するために手段を具体化して簡単な作業の組み合わせに変えるのが仕事なんですけどね)

lock (LockObject)
{
    if (null != BlockData)
    {
        for (int copyWidthIndex = 0; copyWidthIndex < Width; copyWidthIndex++)
        {
            Array.Copy(BlockData, Depth * Height * copyWidthIndex, localBlockData, (Depth + 2) * Height * (copyWidthIndex + 1) + Height, Depth * Height);
        }
    }
}

{
    Chunk chunk_mm = World.Instance.ChunkData[World.Instance.RemapLongitude(PositionX, -1)][World.Instance.RemapLatitude(PositionZ, -1)];
    lock (chunk_mm.LockObject)
    {
        if (null != chunk_mm.BlockData)
        {
            Array.Copy(chunk_mm.BlockData, Width * Depth * Height - Height, localBlockData, 0, Height);
        }
    }
}
{
    Chunk chunk_pm = World.Instance.ChunkData[World.Instance.RemapLongitude(PositionX, +1)][World.Instance.RemapLatitude(PositionZ, -1)];
    lock (chunk_pm.LockObject)
    {
        if (null != chunk_pm.BlockData)
        {
            Array.Copy(chunk_pm.BlockData, (Depth - 1) * Height, localBlockData, (Width + 1) * (Depth + 2) * Height, Height);
        }
    }
}
{
    Chunk chunk_pp = World.Instance.ChunkData[World.Instance.RemapLongitude(PositionX, +1)][World.Instance.RemapLatitude(PositionZ, +1)];
    lock (chunk_pp.LockObject)
    {
        if (null != chunk_pp.BlockData)
        {
            Array.Copy(chunk_pp.BlockData, 0, localBlockData, (Width + 2) * (Depth + 2) * Height - Height, Height);
        }
    }
}
{
    Chunk chunk_mp = World.Instance.ChunkData[World.Instance.RemapLongitude(PositionX, -1)][World.Instance.RemapLatitude(PositionZ, +1)];
    lock (chunk_mp.LockObject)
    {
        if (null != chunk_mp.BlockData)
        {
            Array.Copy(chunk_mp.BlockData, (Width - 1) * Depth * Height, localBlockData, (Depth + 1) * Height, Height);
        }
    }
}

{
    Chunk chunk_zm = World.Instance.ChunkData[World.Instance.RemapLongitude(PositionX, 0)][World.Instance.RemapLatitude(PositionZ, -1)];
    lock (chunk_zm.LockObject)
    {
        if (null != chunk_zm.BlockData)
        {
            for (int copyWidthIndex = 1; copyWidthIndex <= Width; copyWidthIndex++)
            {
                Array.Copy(chunk_zm.BlockData, Depth * Height * copyWidthIndex - Height, localBlockData, (Depth + 2) * Height + ((Depth + 2) * Height * (copyWidthIndex - 1)), Height);
            }
        }
    }
}

{
    Chunk chunk_zp = World.Instance.ChunkData[World.Instance.RemapLongitude(PositionX, 0)][World.Instance.RemapLatitude(PositionZ, 1)];
    lock (chunk_zp.LockObject)
    {
        if (null != chunk_zp.BlockData)
        {
            for (int copyWidthIndex = 1; copyWidthIndex <= Width; copyWidthIndex++)
            {
                Array.Copy(chunk_zp.BlockData, (Depth * Height) * (copyWidthIndex - 1), localBlockData, (Depth + 2) * Height * (copyWidthIndex + 1) - Height, Height);
            }
        }
    }
}

{
    Chunk chunk_mz = World.Instance.ChunkData[World.Instance.RemapLongitude(PositionX, -1)][World.Instance.RemapLatitude(PositionZ, 0)];
    lock (chunk_mz.LockObject)
    {
        if (null != chunk_mz.BlockData)
        {
            Array.Copy(chunk_mz.BlockData, (Depth * Height) * (Width - 1), localBlockData, Height, Depth * Height);
        }
    }
}

{
    Chunk chunk_pz = World.Instance.ChunkData[World.Instance.RemapLongitude(PositionX, +1)][World.Instance.RemapLatitude(PositionZ, 0)];
    lock (chunk_pz.LockObject)
    {
        if (null != chunk_pz.BlockData)
        {
            Array.Copy(chunk_pz.BlockData, 0, localBlockData, (Depth + 2) * Height * (Width + 1) + Height, Depth * Height);
        }
    }
}
lock (LockObject)
{
    if (null != BlockData)
    {
        for (int copyWidthIndex = 0; copyWidthIndex < Width; copyWidthIndex++)
        {
            Array.Copy(localBlockData, (Depth + 2) * Height * (copyWidthIndex + 1) + Height, BlockData, Depth * Height * copyWidthIndex, Depth * Height);
        }
    }
}

では、テストとしてあるブロックを用意します。
見た目で分かるようにオレンジ色のブロックにしました。

ロジックは次の通り、周囲ブロックを自分と置き換える増殖ロジックとしました。

System.Random rand = HexWorldUtility.RandomProvider.GetThreadRandom();
if (1 == rand.Next(0, 30))
{
    BlockDataSt localBlock = BlockDataSt.AirBlock;
    for (int sideIndex = 0; sideIndex < 6; sideIndex++)
    {
        int sideWidthIndex = widthIndex;
        int sideDepthIndex = depthIndex;
        worldUtility.GetSideBlockIndex(widthIndex, depthIndex, sideIndex, out sideWidthIndex, out sideDepthIndex);

        for (int nextHeight = 0; nextHeight < Chunk.Height; nextHeight++)
        {
            localBlock.data = localBlockData[sideWidthIndex * (Chunk.Depth + 2) * Chunk.Height + sideDepthIndex * Chunk.Height + nextHeight];
            if (MaterialID.Material_Opacity > localBlock.materialId)
            {
                localBlockData[sideWidthIndex * (Chunk.Depth + 2) * Chunk.Height + sideDepthIndex * Chunk.Height + nextHeight - 1] = blockData.data;
                break;
            }
        }
    }
    blockData.materialId = MaterialID.Earth;
}
return blockData;

結果がこちら
f:id:simplestar_tech:20180212130543j:plain
うわ、増えていってます。
確認したかったのはチャンクの境で情報が遮断されずに、しっかりと情報が届き、処理されていくかという点です。

最後にテストとして、ブロックがある方角へ進み続けるというロジックを組んでみます。
走り出しました。(消えることがあったので、やはりチャンクの境で他の処理と重なってブロックが消されることはありそうですね)

さて、メッシュの更新フラグの立て方について調整が必要なことがわかりました。
自身のマテリアルIDが変わったときを、これまでトリガーとしていましたが、周囲のマテリアルIDが変わったときにもそれを通知する必要があるためです。

これについてはロジック内の現象なので、外部からいちいちサポートできませんが、ユーティリティ関数を用意して、ローカルブロック情報のどこを変更したかを記録することとします。
しかし、これもスレッドごとにまとまってしょりしなければならない値なので、周囲8ブロック + 自身 1ブロック分のレイヤー更新フラグを所持します。

デバッグを続けて、ひとまず形になりました。

www.youtube.com

話は戻って熱伝導ですが、ブロックのロジックから他のブロックのロジックを手に入れることができないので、できる仕組みを考えて作ります。

素案としては、こんな感じ

protected void HeatConduction(int widthIndex, int depthIndex, int heightIndex, ulong[] chunkBlockData, BlockDataSt blockData, IHexWorldUtility worldUtility)
{
    float removeHeat = 0;
    BlockDataSt localData = BlockDataSt.AirBlock;
    for (int heightOffset = 1; heightOffset >= -1; heightOffset--)
    {
        if (0 == heightOffset)
            continue;
        int topBottomBlockIndex = GetDataArrayIndex(widthIndex, depthIndex, heightIndex + heightOffset);
        localData.data = chunkBlockData[topBottomBlockIndex];
        _MoveHeat(ref blockData, ref localData, ref removeHeat, ref worldUtility);
        chunkBlockData[topBottomBlockIndex] = localData.data;
    }

    for (int sideIndex = 0; sideIndex < 6; sideIndex++)
    {
        int sideWidthIndex = widthIndex;
        int sideDepthIndex = depthIndex;
        GetSideBlockIndex(widthIndex, depthIndex, sideIndex, out sideWidthIndex, out sideDepthIndex);
        int sideBlockIndex = GetDataArrayIndex(sideWidthIndex, sideDepthIndex, heightIndex);
        localData.data = chunkBlockData[sideBlockIndex];
        _MoveHeat(ref blockData, ref localData, ref removeHeat, ref worldUtility);
        chunkBlockData[sideBlockIndex] = localData.data;
    }

    float subHeat = Math.Max(removeHeat, 1);
    Random random = worldUtility.GetThreadRandom();
    if (removeHeat > random.NextDouble())
        blockData.heat = (byte)Math.Max(blockData.heat - subHeat, 0);
}

private void _MoveHeat(ref BlockDataSt blockData, ref BlockDataSt targetBlockData, ref float removeHeat, ref IHexWorldUtility worldUtility)
{
    if (blockData.heat > targetBlockData.heat)
    {
        BaseBlockLogic targetLogic = worldUtility.GetBaseLogic(targetBlockData.materialId);
        if (null != targetLogic)
        {
            float addHeat = Math.Min(targetLogic.thermalConductivity, blockData.heat - targetBlockData.heat);
            targetBlockData.heat = (byte)Math.Min(targetBlockData.heat + addHeat, byte.MaxValue);
            removeHeat += addHeat * (targetLogic.heatUpEnergy / heatUpEnergy);
        }
    }
}
private void _SetLayerUpdatedFlag(bool[][] layerUpdatedFlags, int sideIndex, int heightIndex)
{
    for (int heightOffset = -1; heightOffset < 2; heightOffset++)
    {
        layerUpdatedFlags[sideIndex][Math.Min(Math.Max((heightIndex + heightOffset) / Chunk.LayerHeight, 0), Chunk.LayerCount - 1)] = true;
    }
}

それから色々と調整して、次の形にしました。

protected BlockDataSt HeatConduction(int widthIndex, int depthIndex, int heightIndex, ulong[] chunkBlockData, BlockDataSt blockData, IHexWorldUtility worldUtility)
{
    Random random = worldUtility.GetThreadRandom();
    float removeHeat = 0;
    BlockDataSt localData = BlockDataSt.AirBlock;
    for (int heightOffset = -1; heightOffset <= 1; heightOffset++)
    {
        if (0 == heightOffset/* || 1 == random.Next(0, 2)*/)
            continue;
        int topBottomBlockIndex = GetDataArrayIndex(widthIndex, depthIndex, heightIndex + heightOffset);
        localData.data = chunkBlockData[topBottomBlockIndex];
        _MoveHeat(ref blockData, ref localData, ref removeHeat, ref worldUtility);
        chunkBlockData[topBottomBlockIndex] = localData.data;
    }

    for (int sideIndex = 0; sideIndex < 6; sideIndex++)
    {
        if (1 == random.Next(0, 2))
            continue;
        int sideWidthIndex = widthIndex;
        int sideDepthIndex = depthIndex;
        GetSideBlockIndex(widthIndex, depthIndex, sideIndex, out sideWidthIndex, out sideDepthIndex);
        int sideBlockIndex = GetDataArrayIndex(sideWidthIndex, sideDepthIndex, heightIndex);
        localData.data = chunkBlockData[sideBlockIndex];
        _MoveHeat(ref blockData, ref localData, ref removeHeat, ref worldUtility);
        chunkBlockData[sideBlockIndex] = localData.data;
    }

    if (1.0f > removeHeat)
    {
        if (removeHeat > random.NextDouble())
            blockData.heat = (byte)Math.Max(blockData.heat - 1, 0);
    }
    else
    {
        blockData.heat = (byte)Math.Max(blockData.heat - removeHeat, 0);
    }
    return blockData;
}

private void _MoveHeat(ref BlockDataSt blockData, ref BlockDataSt targetBlockData, ref float removeHeat, ref IHexWorldUtility worldUtility)
{
    if (blockData.heat > targetBlockData.heat)
    {
        BaseBlockLogic targetLogic = worldUtility.GetBaseLogic(targetBlockData.materialId);
        if (null != targetLogic)
        {
            float addHeat = Math.Max(targetLogic.thermalConductivity, 1);
            Random random = worldUtility.GetThreadRandom();
            if (targetLogic.thermalConductivity > random.NextDouble())
            {
                targetBlockData.heat = (byte)Math.Min(targetBlockData.heat + addHeat, byte.MaxValue);
                float ratio = specificHeat / targetLogic.specificHeat;
                removeHeat += addHeat / ratio;
            }
        }
    }
}

リアルタイムの熱伝導の様子をビジュアライズする機能を追加してデバッグすると次の通り
www.youtube.com

デバッグ目的で大気の温度を最大にしてチェックしていますが、地上と宇宙に接している部分から熱が逃げていき、徐々に大気が冷えている様子がうかがえます。

・温まった空気ブロックが水分を水ブロックから吸収

まずは水ブロックも世界に登場させないとですね。
登場させました。(色々仕様変更していたから、導入に手間取った…)

f:id:simplestar_tech:20180219001525j:plain

大気は温度によって水を含める量が変わることにします。
例えば 0 ~ 255 の温度の中で、温度0の空気は水を含めないとし
温度が上昇するにつれて、徐々に水を含めるようになっていき
温度255で最大の255まで水を含めることとします。
(本当は比例しないのですが、処理を簡略化するために比例することにします。)

基本的に水に接している空気は、水ブロックから限界まで水を吸収します。
水ブロックは最大の255まで到達した空気ブロック10個と等価で、合計2550を 255 で割った 10
つまり、10 ずつ空気に分配し、その時に水ブロックから 1引きます。(または、ここにも確率の要素をいれて、空気に+1しても 1/10 の確率で水ブロックで -1 とか)
255から始まって、0になった水ブロックは最後、空気ブロックになります。
これが蒸発の再現です。

温度ごとに含めることができる水の量は決まっていますので
高温多湿の空気が冷えることで、余った水が雲粒として空気中に現れます。(ブロックとは異なるエフェクトで可視化)
余った水の量に応じて雲の密度を段階的に上げます。(エフェクトを濃くするとか)

余った水の量は基本的に下の空気ブロックに渡し、渡しきれないときは繰り越されます。
これが行われる時、雨が降る状態となります。
空気中に255以上の水が含まれることが地上付近で起きると、この空気ブロックは分量1の水ブロックになります。
これが降水の再現です。

ロジックを書いてみます。

・暖気の上に冷気がある場合は上下ブロックの内容を交換

ボイルシャルルの法則によれば、等圧下における高温は低密度だそうで
単位体積あたりの質量は暖かい方が小さく、軽いということになります。

ブロックのマテリアルに新たに比重と密度を与えます。
ブロックの体積はすべて一定です。

土の比重に比べて、水の比重はさらに大きく、金の比重はさらに大きいとします。
密度は土よりも水、水よりも金の方が大きいなど

基本的に水と空気などでは、密度 x 比重によるブロックの質量を算出して
高い質量のブロックが重力方向に向かって交換が行われます。(落下を表現)

同じ比重、密度のブロック同士の場合は、温度と圧力の関係で圧力を温度で割った値を気体のエネルギーとします。

同じ比重と密度の気体同士が重力方向で重なっている場合、比較して温度が高い気体はエネルギーが小さいことになり
上の気体ブロックと交換します。(上昇を表現)

ただし、上のブロックの圧力が低く、同じエネルギーまたは、低温でも上の気体のエネルギーが小さい場合は、交換は発生しません。
ただし、気体は周囲の気体の圧力と同じ、または一つ上の気体より1大きい圧力になります。
すると、もしその状態で下のブロックのエネルギーが小さい場合は、上昇します。

これにより、一般的に上空は低温で低圧、地表は高温で高圧、しかし、太陽の輻射熱で地表があたたまり、熱伝導により空気が温まり
その空気のエネルギーが小さくなった場合は、暖かい空気が上昇します。

おまけ

立体的な数値の時間変化をデバッグするため、見た目で結果がわかるようにしなければなりません。(ログから想像する?別に頑張ってもらってもいいよ)
そこで、仕事帰りの時間でしばらく調査を続けていたのですが、Unity の Particle System を使って固定パーティクルでブロックの状態を表示する方法を思いつきました。

参考記事はこちら
qiita.com

パーティクルごとにサイズや色を設定できるため、なかなかに便利です。(パフォーマンス面は不安ですが)
f:id:simplestar_tech:20180208234241j:plain

Task.Run:Unityマルチスレッド処理に引数を渡す方法

AIに身体性を持たせるためのマイクロワールド:ブロックロジックの可視化システムの実装メモです。
まずは結果動画をご覧いただきたい。

www.youtube.com
f:id:simplestar_tech:20180128172844g:plain

マルチスレッド処理にて空気に触れている土ブロックだけが確率的に消失(正しくは空気ブロックにマテリアル変換される)

今回、技術的に記録しておかなければならないと思った点は二つ

1. Task.Runによるマルチスレッド処理に引数を渡す書式
2. スレッドごとの乱数生成器

1. Task.Runによるマルチスレッド処理に引数を渡す書式

参考にした記事はこちら↓
kazenetu.exblog.jp

Task に引数を渡す方法、それは関数内のローカル変数をラムダ式内で参照するという書式で実現できます。
私の場合は for 文を回している時の、増えていくインデックスをスレッドに渡したかったので、次のように記述して解決しました。

_workerThreads = new Task[SpiralScanRadius + 1/*Lattice*/];
for (int radius = 0; radius < SpiralScanRadius; radius++)
{
    _CreateSpiralTask(radius);
}
_workerThreads[SpiralScanRadius] = Task.Run(_ScannLattice);

private void _CreateSpiralTask(int radius)
{
    if (0 == radius)
    {
        _workerThreads[radius] = Task.Run(() =>
        {
            ulong[] localBlockData = new ulong[Chunk.Width * Chunk.Depth * Chunk.Height];
            while (Instance._workThread)
            {
                if (!Instance._SpiralWork(0, 0, localBlockData))
                {
                    break;
                }
            }
        });
    }
    else
    {
        _workerThreads[radius] = Task.Run(() =>
        {
            ulong[] localBlockData = new ulong[Chunk.Width * Chunk.Depth * Chunk.Height];
            while (Instance._workThread)
            {
                int offsetZLow = -radius;
                for (int offsetX = -radius; offsetX < radius; offsetX++)
                {
                    if (!Instance._SpiralWork(offsetX, offsetZLow, localBlockData))
                        goto ExitLoop;
                }
                int offsetXHigh = radius;
                for (int offsetZ = -radius; offsetZ < radius; offsetZ++)
                {
                    if (!Instance._SpiralWork(offsetXHigh, offsetZ, localBlockData))
                        goto ExitLoop;
                }
                int offsetZHigh = radius;
                for (int offsetX = radius; offsetX > -radius; offsetX--)
                {
                    if (!Instance._SpiralWork(offsetX, offsetZHigh, localBlockData))
                        goto ExitLoop;
                }
                int offsetXLow = -radius;
                for (int offsetZ = radius; offsetZ > -radius; offsetZ--)
                {
                    if (!Instance._SpiralWork(offsetXLow, offsetZ, localBlockData))
                        goto ExitLoop;
                }
            }
            ExitLoop:;
        });
    }
}

参考記事にもありますが for 文の中で Task.Run を記述しても、実際に Task が Run するのは for 文を抜けた後なので、for文後のインデックス値がすべての Task に渡ってしまいます。
そういう Task の仕組みを先に理解しておけば、みなさんも自力で解決方法を思いついたと思います。

2. スレッドごとの乱数生成器

Random は生成したスレッドと異なるスレッドで使用すると 0 が返る仕様です。(正しいが、これは困った)
スレッド内で乱数生成したい場合は、スレッド用の生成処理として ThreadLocal を使用します。

以下の記事で知りました。(UniRx でお世話になってます。)
neue cc - C#とランダム

実装メモ

public static class RandomProvider
{
    private static ThreadLocal<System.Random> randomWrapper = new ThreadLocal<System.Random>(() =>
    {
        using (var rng = new RNGCryptoServiceProvider())
        {
            var buffer = new byte[sizeof(int)];
            rng.GetBytes(buffer);
            var seed = BitConverter.ToInt32(buffer, 0);
            return new System.Random(seed);
        }
    });

    public static System.Random GetThreadRandom()
    {
        return randomWrapper.Value;
    }
}

使い方としては次の通り

public class EarthLogic : IBlockLogic
{
    static Random _random = new Random();

    public int MaterialId()
    {
        return (int)MaterialID.Earth;
    }

    public BlockDataSt Work(BlockDataSt blockData, int widthIndex, int depthIndex, int heightIndex, ulong[] localBlockData, IHexWorldUtility pluginUtility)
    {
        if (99 == HexWorldUtility.RandomProvider.GetThreadRandom().Next(0, 100))
        {
            BlockDataSt upBlock = new BlockDataSt(localBlockData[widthIndex * Chunk.Depth * Chunk.Height + depthIndex * Chunk.Height + Math.Min(heightIndex + 1, Chunk.Height - 1)]);
            if (MaterialID.Air == upBlock.materialId)
            {
                blockData.materialId = MaterialID.Air;
            }
        }
        return blockData;
    }
}

この Work 関数が複数のスレッドから呼び出されるのですが、スレッド内にて最初に乱数生成器が作られて、それ以降はスレッドごとの Random が使われるようになります。
上のコードはちょうど土ブロックのロジックになります。

まとめ

以下の状況を突破する知識が得られました。
1. Task.Runによるマルチスレッド処理に引数を渡したいが、Runする Action に引数を追加すると今度は Task.Run にActionを渡せない、この問題を解決するための書式の調べ方が不明
2. 複数スレッドから呼び出される関数内で乱数生成したいが、メインスレッドで作った Random は常に0を返してくる、どうにか複数スレッドから呼び出しても乱数を利用できるようにしたい

Unity:MessagePackをオブジェクトのバイナリ圧縮として使う

MessagePackは、効率の良いバイナリ形式のオブジェクト・シリアライズ フォーマットです。

JSONの置き換えとして使うことができ、様々なプログラミング言語をまたいでデータを交換することが可能です。
しかも、JSONよりも速くてコンパクトです。
例えば、小さな整数値はたった1バイト、短い文字列は文字列自体の長さ+1バイトでシリアライズできます。

出典:https://msgpack.org/

ゲーム作っていれば、任意の型オブジェクトを高速に圧縮してファイル保存し、高速に解凍して元の状態に戻したい場面に遭遇します。
今回はそんな場面で役立つ MessagePack を使ってみました。

使用方法は以下の通り

まず、次のリリースページより .zip ファイルを入手して解凍し、unity フォルダに入っている MsgPack.dll を Unity プロジェクトに配置します。
github.com

あとは、前回作った BinaryFormatter のシリアライズ機構
simplestar-tech.hatenablog.com

を次のように書き換えるだけです。

using MsgPack.Serialization;
using System.IO;

namespace Utility
{
    public class BinaryUtil
    {
        public static void Seialize<T>(string path, T obj)
        {
            using (FileStream fileStream = new FileStream(path, FileMode.Create, FileAccess.Write))
            {
                var serializer = SerializationContext.Default.GetSerializer<T>();
                serializer.Pack(fileStream, obj);
            }
        }

        public static T Deserialize<T>(string path)
        {
            T obj;
            using (FileStream fileStream = new FileStream(path, FileMode.Open, FileAccess.Read))
            {
                var serializer = SerializationContext.Default.GetSerializer<T>();
                obj = serializer.Unpack(fileStream);
            }
            return obj;
        }
    }
}

使い方は超・簡・単!

チャンク情報は主に ulong(8byte) x 65536 の情報を詰めているので、無圧縮なら 524 KB ということになりますが、この MessagePack の機能を使うと、およそ 130 KB まで圧縮されました。

MessagePack は高速性がウリなので、他の高効率圧縮のアルゴリズムよりゲームのセーブロードに向いていそうです。
(ちゃんと比較ベンチとれって同僚に怒られそうだな…ではこの辺で)

Unity:C#で共用体ちっくなビットフィールドの利用:SimpleHexWorld17.8:適切にマテリアルを切り替えたい

前回、パフォーマンスを落とさないために、テクスチャ座標で異なるブロックの種類を表現できる仕組みを作ったわけです

f:id:simplestar_tech:20180121185357j:plain
テクスチャ座標でブロックの種類を表現

が、それでもマテリアルを切り替えたいときだってあります。

そもそも四大元素や陰陽五行はマテリアルが違うからこそ、人類は歴史的に別の要素として認識してこれたわけです。(ですよね?)

1.透明な風
2.半透明な水
3.不透明な木、土
4.光を反射する金
5.光を発する火

実装としては、512種類ごとにマテリアルを切り替えるIDのルールを決めてほしいという欲求があります。
また、現在の Block の情報は int 値の 32 bit で、シリアライズするときは int としてシステムに認識させておいて
アプリ側での利用でコストの小さい変換により、コードとしては堅牢かつ可読性の高い情報アクセスというものを望んでいます。
解決方法として、以下のように共用体チックな ビットフィールド指定を行い、プロパティとしてビットフィールドの値を取り出せるようにしてみました。
int でファイルから読み込んだ値を変換する時のこと考えたら _raw を外から設定できるようにしておくと良いかもしれません。

public struct BlockDataSt
{
    public static BlockDataSt Identity { get { return new BlockDataSt() { _raw = 0 }; } }

    public MaterialID materialId
    {
        get { return (MaterialID)(_raw & mask0); }
        set { _raw = (uint)(_raw & ~mask0 | (uint)value & mask0); }
    }

    public byte stress
    {
        get { return (byte)((_raw & mask1) >> loc1); }
        set { _raw = (uint)(_raw & ~mask1 | ((uint)value << loc1) & mask1); }
    }

    public byte heat
    {
        get { return (byte)((_raw & mask2) >> loc2); }
        set { _raw = (uint)(_raw & ~mask2 | ((uint)value << loc2) & mask2); }
    }

    public byte impuritiesId
    {
        get { return (byte)((_raw & mask3) >> loc3); }
        set { _raw = (uint)(_raw & ~mask3 | ((uint)value << loc3) & mask3); }
    }

    public byte impuritiesRatio
    {
        get { return (byte)((_raw & mask4) >> loc4); }
        set { _raw = (uint)(_raw & ~mask4 | ((uint)value << loc4) & mask4); }
    }

    private uint _raw;
    private const int sz0 = 10, loc0 = 0, mask0 = ((1 << sz0) - 1) << loc0;
    private const int sz1 = 7, loc1 = loc0 + sz0, mask1 = ((1 << sz1) - 1) << loc1;
    private const int sz2 = 7, loc2 = loc1 + sz1, mask2 = ((1 << sz2) - 1) << loc2;
    private const int sz3 = 4, loc3 = loc2 + sz2, mask3 = ((1 << sz3) - 1) << loc3;
    private const int sz4 = 4, loc4 = loc2 + sz2, mask4 = ((1 << sz3) - 1) << loc4;
}

public enum MaterialID
{
    Air = 0,
    Earth = 1,
    Gold = 2,
    Water = 3,
    Wood = 4,
    Fire = 5,
}

上記実装は
stackoverflow.com
の掲示板の kvc さんの返信内容を参考に作っています。

ほかにも

1.ビット フラグとしての列挙型
列挙型 (C# プログラミング ガイド) | Microsoft Docs

2.[StructLayout(LayoutKind.Explicit)] 複数のフィールドの位置を重ねる
ufcpp.net

3.BitVector32 構造体
BitVector32 構造体 (System.Collections.Specialized)

を参考にしましたが、1.はコードを書く時の認識負荷が高いので却下で、2.はそもそもビットフィールドではなく byte 単位のオフセットだったのと、3.は良かったかもしれませんが、32bit の制約を付けたくなかったという理由でやめています。

さて、アプリ側の話ですが、マテリアルごとに分類し、512種類ごとに振り分けるとこんな感じでしょうか

public enum MaterialID
{
    Air = 0,
    Earth = 1,
    Wood,
    Water = 512,
    Glass,
    Crystal,
    Gold = 1024,
    Silver,
    Copper,
    Iron,
    Fire = 1536,
}

これで、数字を見るだけでどのマテリアルを振ればよいか判定できますし、そのマテリアル切り替えのオフセット量を ID から引けば、マテリアル内でのインデックスが取り出せそうです。
さっそく、マテリアルを切り替える処理を書いてみましょう。

以下は実装メモなので、後程記載

金ブロックだけでできたマイクロワールドの様子

AIに身体性を与えるためのマイクロワールドの構築の、ちょっとした描画遊びです。

これぞ金の山…ゴージャスです!
f:id:simplestar_tech:20180121205456j:plain

動画はこちら
www.youtube.com

マテリアルを一つにして、ブロックの種類をテクスチャ座標で切り替える方法を作りましたが、
しかし、今後金属ブロックなどを導入する予定なので、ブロックの表現を研究することも大事かもしれませんね。
マットな材質、光沢のある金属、透き通った液体、氷など、様々な材質をイメージできるマテリアルにも切り替わる仕組みを考えてみたいと思いました。

AIに身体性を与えるためのマイクロワールドの構築17.7:Unityテクスチャ座標活用で描画負荷を削減

前置き

Unity では一つのメッシュに対し、複数のマテリアルを面ごとに割り当てることができます。
利用するマテリアル数を増やしていくと、増やした分だけレンダリングパスが増えていき、描画時間が線形的に増加し、フレームレートが大きく落ちてしまいます。
つまり、今は登場するブロックの種類が少ないため問題になっていませんが、今後より多くの種類のブロックを描画するシーンを作ったとき、描画負荷が問題となる未来が確定しています。
せっかくマルチスレッド処理で CPU 側の処理が削減できたのに、描画負荷でゲームがフリーズしてしまっては、ユーザーに不満を与えてしまいます。

そこで、一つのマテリアルで非常に多くの種類のブロックを表現する仕組みを考えて、実装します。
具体的には、テクスチャをブロックの種類ごとに区分けして、ブロックの種類ごとにテクスチャ座標にオフセットを与える方法を実装します。

イメージはこんな感じです。

f:id:simplestar_tech:20180121183630j:plain

以下、実装メモを残します。

実装メモ

メッシュ作成コードについて、詳しくは前の記事の通りです。

simplestar-tech.hatenablog.com
simplestar-tech.hatenablog.com
simplestar-tech.hatenablog.com

現在 UV マップはすべての面で展開し、ブロックの種類ごとにマテリアルを切り替えて表示しています。
しかし、描画のパフォーマンス面でマテリアル数は極力減らさないといけないということに気づきました。
一つのマテリアルで複数のブロックを表現することは、テクスチャ座標を切り替えることで可能です。
これをシステムに導入するため、ブロックのIDによってマテリアルとUVオフセットが決まるようにし、そのUVオフセットに合わせたテクスチャを用意したいと思います。
ここ数ヶ月このシステムを導入したいと考えていましたので、これを機に一気に手を入れてみようと思いました。

まずはブロックの種類ごとに区分けされたテクスチャを用意します。
こんな感じでIDを振ってみました。

f:id:simplestar_tech:20180121184301p:plain

作成コードはこちら

using System.Drawing;

namespace BitmapImage
{
    class Program
    {
        static void Main(string[] args)
        {
            Bitmap img = new Bitmap(2048, 2048);
            Graphics g = Graphics.FromImage(img);
            g.FillRectangle(Brushes.Pink, g.VisibleClipBounds);

            int size = 64;
            int fontOffset = size / 4 + 4;
            int fontOffsetX = size / 16 - 3;
            for (int offsetY = 0; offsetY < img.Size.Height / (size * 2); offsetY++)
            {
                for (int offsetX = 0; offsetX < img.Size.Width / size; offsetX++)
                {
                    g.FillRectangle(Brushes.White, new Rectangle(new Point(offsetX * size, offsetY * size * 2), new Size(size, size * 2)));

                    g.DrawRectangle(Pens.Black, new Rectangle(new Point(offsetX * size, offsetY * size * 2), new Size(size + 1, size * 2 + 1)));

                    Font fnt = new Font("Arial", size / 4);

                    string st = GetMaterialString(offsetX, offsetY);

                    g.DrawString(st, fnt, Brushes.Black, offsetX * size + fontOffsetX, offsetY * size * 2 + fontOffset);
                    g.DrawString(st, fnt, Brushes.Red, offsetX * size + fontOffsetX, offsetY * size * 2 + (size) + fontOffset);

                    fnt.Dispose();
                }
            }
            g.Dispose();
            img.Save(@"TextureOffset.png", System.Drawing.Imaging.ImageFormat.Png);
        }

        private static string GetMaterialString(int offsetX, int offsetY)
        {
            string blockString = offsetX.ToString("00") + "," + offsetY.ToString("00");

            return blockString;
        }
    }
}

横に 32, 縦に 16 で区分けしましたので、仮でマテリアルIDから次のようにテクスチャ座標にオフセットを与えます。

for (int i = 0; i < workTexels.Length; i++)
{
    workTexels[i].x = _uvSources[i].x / 32.0f + (blockID % 32) / 32.0f;
    workTexels[i].y = _uvSources[i].y / 32.0f + Mathf.FloorToInt(blockID / 32) / 16.0f;
}
private const float _texelMargin = 0.02f;
private static Vector2[] _uvSources = new Vector2[]
{
    new Vector2 (0.50f, 0.50f),
    new Vector2 (0.50f, 1.00f - _texelMargin),
    new Vector2 (1.00f - _texelMargin, 0.75f - _texelMargin / 2),
    new Vector2 (1.00f - _texelMargin, 0.25f + _texelMargin / 2),
    new Vector2 (0.50f, 0.00f + _texelMargin),
    new Vector2 (0.00f + _texelMargin, 0.25f + _texelMargin / 2),
    new Vector2 (0.00f + _texelMargin, 0.75f - _texelMargin),

    new Vector2 (0.50f, 0.50f),
    new Vector2 (0.50f, 0.00f + _texelMargin),
    new Vector2 (1.00f - _texelMargin, 0.25f + _texelMargin / 2),
    new Vector2 (1.00f - _texelMargin, 0.75f - _texelMargin / 2),
    new Vector2 (0.50f, 1.00f - _texelMargin),
    new Vector2 (0.00f + _texelMargin, 0.75f - _texelMargin / 2),
    new Vector2 (0.00f + _texelMargin, 0.25f + _texelMargin),

    new Vector2 (0.00f + _texelMargin, 2.00f - _texelMargin),
    new Vector2 (1.00f - _texelMargin, 2.00f - _texelMargin),
    new Vector2 (1.00f - _texelMargin, 1.00f + _texelMargin),
    new Vector2 (0.00f + _texelMargin, 1.00f + _texelMargin),
};

結果はこんな感じです。

f:id:simplestar_tech:20180121185357j:plain