simplestarの技術ブログ

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

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