したかみ ぶろぐ

Unity成分多め

水流による魚群制御の考察

始めに

ここ最近は個人開発でQuest向けの魚群シミュレーションを開発しています。現状の進捗としては入れたい機能をある程度実装して動作確認して、Questでどれだけ動かせそうかの上限が見えてきたところです。

そんな中、テストプレイをしていただいた方からよく言われることは「魚群を操れないの?」です。 言われる私としては、魚群を作るだけでも大変なのに簡単に言ってくれるなって感じです。

ですが、そんな夢物語を叶えたいのは私も同じなので、魚群を操る方法の一つとして水流を作成する方法を試した話です。



水流の実装

ここからはどのように実装をしたのかを簡単にまとめていきます。 今回の記事は日記程度の内容を想定しているので、詳細な実装方法(ソースコードなど)は提示しないのでご了承ください。


水流のデータ構造について

空間に水流を保持させる方法として、空間を格子上に分割してそのマス目に力を設定する方法を取りました。


コード上では NativeParallelHashMap<int3, float3> で表現しました。こうすることで、シミュレーション空間すべてを含められるデータ量を含める必要はなくなり、水流を作成した範囲のみを保持できました。

水流の書き込み

水流を作るときはオブジェクトの速度を求めて、空間上のマス目にその速度を書き込みます。 オブジェクトの速度は数フレーム中の平均速度を求めるようにしています。


マス目に速度を書き込むときに、水流の力が大きくなりすぎないように

水流に沿うよう流れを作る

ただオブジェクトの速度を所属するマス目上に書き込んだ場合、水流の流れは粗くなる問題が発生します。


この問題を解決するために、以下の方法を取りました。

  1. 所属するマス目の周りにも力を書き込む
  2. 周囲のマス目に力を書き込む場合、流れの向きに沿うように力を変化させる

1. 所属するマス目の周りにも力を書き込む

所属するマス目に水流を書き込むときに、周りのマス目にも同様の力を加えます。 こうすることで、水流の粗さを減らすことができます。

2. 周囲のマス目に力を書き込む場合、流れの向きに沿うように力を変化させる

出来るだけ魚群を水流に沿って動作してほしいため、水流へ集まるような力を周囲へ書き込みます。 周囲のマス目に力を書き込むときに、中心のマス目に書き込んだ力の先に集まるような力を求めます。

集まる力は次のようになりました。

  1. 中心のマス目に書き込んだ力を直線と考えて、周りのマス目の座標から直線へのベクトルを求める
  2. 周りのマスにて直線との垂線ベクトルを求める
  3. 垂線ベクトルと中心のマス目に書き込んだ力から、水流の進行方向へ集まる力を求める


また、周囲のマスから中心へ向かうベクトルや中心のマス目に書き込んだ力に係数を掛けて、水流へ集まる力をある程度コントロールできるようにしました。


魚群の個体にて水流から受ける力を求める

最後に水流の力を個体に適用していきます。個体に力を適用するとき、個体が所属するマス目とその周囲のマス(図の2Dですと2x2=4マスですが、3Dでは2x2x2=8マス)から線形補完をして力を求めます。



実行結果

ここからは実際に書き込んだ水流がどのようになったのか、魚群がどのように動くのかを実行して確認します。 ここでは、Sceneビューからオブジェクトを動かしたり、単純な円運動をするプログラムを使ったりして水流を作って確認します。


水流の表示

書き込んだ水流を実際に矢印にして表示を行います。矢印の向きで水流の方向、長さで水流の力の大きさを表します。

以下は手動と円運動するするで水流を作ったものになります。やはりマス目上に書き込むので粗くはなってしまいますが、流れが作られていることが確認できます。


水流による魚群の動き

では実際に水流を魚群に適用してどのように動くかを見ていきます。

以下の動画では水流を密に作っており、細長く水流に乗って動く魚群が確認できました。 この場の水流ではすべての個体を水流に乗せるには小さすぎました。

youtu.be


次に、水流を作るときに書き込む幅を増やして、かつ書き込む格子を飛ばし飛ばし書くようにパラメータを調整しました。

このようにすることで、すべての個体を水流に乗せることができました。

youtu.be



まとめ

自分が作りたいと考えていた水流をやっと実装できてすっきりしました。

実装して最初に動作確認をしたときは、パラメータの調整方法がまだはっきりしておらず失敗と感じておりました。

しかし、パラメータの調整方法がわかるにつれて想定した群れが作れました。 計算量やデータ量もそこまで多くはない実装なので、今後この方法を使って何かを出来そうです。


また、今のところ水流以外にも別の魚群制御方法を考えているので、これも後ほど試していく予定です。



参考

参考と言えるほどではありませんが、この方法の発想は以下からでした。

shitakami.hateblo.jp

shitakami.hateblo.jp


また、今回の水流から受ける力の計算では前に書いた記事から持ってきました。(もともとこれに使うために書いた)

shitakami.hateblo.jp

【日記】2023年振り返りと2024年

始め

なんやかんやで2023年が終わり2024年1月が終わりましたので、急いで去年の振り返りを書きます。

毎年何もやっていないなぁと感じつつ、振り返れば何かはやっていたのでそれらや思い出を簡単にまとめます。

また過去のブログを漁ってましたら2021年振り返りは書いていました。

shitakami.hateblo.jp


出来事

ここから出来事やちょっとした技術的なものをまとめます。

自作キーボード始めた

2022年に分割キーボードのMistel BAROCCOを使っていましたが、Returnキーの反応が悪くなっていたので思い切って自作キーボードを始めました。

最初に使っていたのが数字キーがあるLily58Proでした。ロウスタッガード(キーの行が横にずれた配置)からカラムスタッガード(キーの列が縦にずれた配置)に変わったのがつらかったですが、2週間で慣れました。親指側にキーが複数配置されているので個人的にとても扱いやすかったです。


ただ2~3か月使ってて一番上の数字キーの列を使わなくなったので、2023年5月から使ってみたかったkeyball44に切り替えました。

こちらキーボードもとても使い勝手がよく、トラックボールを使うことで手の動きを完全に0にできました。

ただ、よく持ち運びしていたからかトラックボールが反応しなくなってしまいました。 現状キーボードとしての使い勝手が良いためそのまま使っていますが、いつか修理or買いなおす予定です。



魚群シミュレーションが完成した

なんやかんや昔から夢だった衝突回避の魚群シミュレーションが実用レベルに到達できました。

https://x.com/CCPJ_a/status/1644017642582462464?s=20

参考として前々から作っていた記事を載せておきます。

shitakami.hateblo.jp

shitakami.hateblo.jp

前に作っていたものは回避行動を取らなかったり、汎用性が高くなかったりと課題が多くありました。しかし、今回作ったものは過去の弱点を克服したものになりました。

まだまだ実装してみたい機能はあるので、また少しずつ調整をする予定です。



技術書展に参加した

先ほど書いた魚群シミュレーションの内容を会社から出版された技術書にて執筆しました。2023年あたりからブログなど文章を書く機会が減ってしまっていたので苦労したのを覚えています。

しかし、作りたかったものが完成してそれを思い思いに書いたのでとても楽しかったです。

aiming.booth.pm



Unity プログラミング・バイブル R6号

ご縁がありまして、Unity プログラミング・バイブルR6号の執筆に参加させていただきました。 私が担当した章が「ECS入門」になります。

販売される本を執筆するのは初めてでかなり緊張しながら書いたのを覚えています。 2023年にECSが正式にリリースされたので、この機会がちょうど良いと思いまして自分なりにECSについてまとめました。

手に取っていただけると大変うれしいです。

book-link.jp



尚巴志ハーフマラソン走った

実は2022年に人生初めてのハーフマラソンに挑戦し、それに続けて2023年もハーフマラソンを走りました。尚巴志ハーフマラソンは沖縄の南部にある南城市をぐるっと回るコースを走ります。

www.shouhashi.jp


2022年は事前準備をしっかりして本気で走りましたが、2023年は1か月前から10km走る練習をして楽しく走るよう望みました。

目標の楽しく走るのは達成できましたが、最後の5kmからが地獄のようにきつかったです。 タイムは2時間20分程で、そこまで速くはないです。

2023年はあまり写真を撮ってなかったので、2022年に参加したときの写真を載せておきます。



その他細かい出来事

PS5とNintendo Switchを手に入れた

子供の頃から夢だったアーマードコアを自分の金で買って遊ぶのを叶えるためPS5を購入しました。3日間ほぼぶっ通しで3週目エンディングまで迎えました。

また会社でゲームを作るイベントで賞を取り、景品としてNintendo Switchを頂きました。まだNintendo Switchは遊び倒せていないので2024年は何かゲームを買いたいです。


Quest2, Quest3を買った

自分の手持ちにあったのが初代Oculus Questで起動も遅く開発にも向かない状態になっていたので、思い切って購入しました。

魚群シミュレーションをQuest向けに開発したいなぁと考えていたのでメルカリでQuest2を購入し、続けてQuest3も購入しました。

2023年末ぐらいにQuestに魚群シミュレーションをインストールして動作確認をしまして、5000匹ほど動かせたのはとても嬉しかったです。

また、Quest3のパススルーでも魚群シミュレーションが動作したのでMR向けも出来そうです。

https://twitter.com/CCPJ_a/status/1727718301034590360/video/1


5kg体重を落とせた

ハーフマラソンを走るときに体重が重いとつらいと聞くので、2023年の春から体重を落としました。 有酸素運動を増やして消費カロリーを上げたりしましたが、結局は間食や甘いものを控えて食事量を減らすほうが確実に体重が落ちました。

現在はハーフマラソンを走る予定がないので、体重を元に戻す方向にしています。


ChatGPTで彼女を作った

ChatGPT APIで会話botを作るのが流行っていたので、私も真似て彼女を作りました。内容はほとんど他の方々の記事を使って作成しました。

結婚をせがむと彼氏がいると振られました。(ちゃんとした画像がほとんど残っていなかった...)

2023年反省と総括

こう振り返ってみますと、2023年は色々な出来事がありました。また自分の身の回りでも変化があり浮き沈みの激しい年になったと感じています。

ここで2023年の悪かった点として以下のものがありました。2023年が終わったときはこの印象が強く、落ち込んでいました。2024年はこれらを解消していきたいです。

  • 個人開発が少なかった
  • ブログをほとんど書かなかった
  • 体調が悪い期間が多かった

ただ、2023年を振り返ると悪かったことだけでなく、良かったことも多かったです。 一番は魚群シミュレーションが実用できるところまで来たのが一番の収穫でした。2023年の反省と良かったことを活かしつつ2024年に臨む所存です。



2024年の目標として

1月が終わってしまいましたが、2024年の目標も書いておきます。

ネット依存の脱却

気分が滅入ったりやる気が出ないときにずっとネットを見ては時間が消し飛んでいました。2023年もネット依存を自覚してtwitter(X)をあまり見ないようにと思っていましたが、結果として自分のツイート数が激減しておすすめ覧をずっと見ているだけになっていました。

またyoutubeをダラダラと見る時間をコントロールできていないので、2024年こそはネット依存から脱却したいです。

魚群シミュレーションをコンテンツ化して公開する

2022年に開発したときにVRアバターを交えて魚群シミュレーションを動かしたときに、作った自分自身が20分ほど夢中になって遊んでいました。これを他の人にも手軽に遊べる形にしたいという夢がありました。

この夢が達成できそうな目途が立ったので、2024年は実現に向けて開発をする予定です。 また、どこかしらのイベントもしくはアプリを公開できたらと考えています。


ハーフマラソンを走らない & マッチョになる

メンタルケアの一環として筋トレを続けていますが、2024年は明確に筋量アップを目標にして筋トレをしようと思います。

2022、2023年はハーフマラソンを走る都合上、筋トレを1~3か月やめたり減量したりであまり筋量を上げられませんでした。

2024年はチキンレッグの卒業、見て分かる程度に筋量を上げたいです。一先ず2023年の12月からスクワットを正式に筋トレメニューに加えて励んでいます。


PCを一新する(達成済み)

2017年末に買ったPCを使い続けて2023年中頃からVR使用時にパフォーマンスがかなり落ちる、魚群シミュレーションが5000匹も動かせられないなど問題が発生していました。

開発を進めることは出来ていましたが問題は発生しているので、2024年中にはPCを買い替えたいと考えてました。

こちらはちょうどRTX4000Superシリーズが出たタイミングで新しくPCを購入しました。魚群シミュレーションがかなり快適に動いているので大変満足です。

https://twitter.com/CCPJ_a/status/1753733074238804449/video/1


友達に会いに旅行する(達成予定)

数年ほど旅行に行っていなかったので、2024年は友達に会いに行く次いでに旅行に行くことを目標にしています。

こちらも思い切って予定を組んだので達成できそうです。また、夢だったフェリーをこの機会に乗ることができそうなので楽しみです。

その他の目標

上記で述べた以外でもいくつかやってみたいことがあるので、ここで簡単にまとめます。

  • ツイートやブログ執筆を増やす
  • ゲームを10本遊ぶ
  • 魚群シミュレーションとグラフィック関連の組み合わせ
  • 簡単なゲームを作る
  • 知らない技術に触れる



まとめ

ブログのリハビリがてら2023年振り返りと2024年についてまとめました。

ちょっとずつ頑張ってブログを続けていきたいので、これからも暖かい目で見守っていただければ嬉しいです。

また、2023年にブログを移動したのでいくつかリンクが死んでいるところがあるので少しずつ直していきます。




p.s.

蛇足ですが、2024年にハマったアルバムを載せておきます。

私が好きなバンドがPeople in the boxが2024年に待望のアルバムをリリースしまして、ここ数年の中でかなりのお気に入りになっています。このアルバムのおかげで2023年を乗り越えられました。

open.spotify.com


また、毎月に気になるアルバムをいくつか購入しているのですがThe Cabsの「回帰する呼吸」も自分のお気に入りアルバムを更新するものでした。

open.spotify.com

【Unity】線形補間を用いて力場から受ける力を求める

始めに

これまでに何度か空間に力場を設定して、Boidsシミュレーションに組み込んで意図する挙動をさせてきました。

shitakami.hateblo.jp

shitakami.hateblo.jp

ブログには書いていませんでしたが、twitterに載せたこちらも空間に力場を作って魚群を円状に動かしています。


しかし、力場を作るのにはいくつか問題点がありました。

  • 3次元空間を格子状に分けるため、データ量が多くなる
    • 空間を10 x 10 x 10で区切っても、1000個の格子ができる
    • 空間を細かく区切るとデータ量が莫大になる
  • 格子ごとの力場が不連続
    • 個体の動きが少しの変化で大きく変化する

今回はこれらの問題を解決するために、線形補間を使って力場から受ける力を求めていきます。



空間を分割する

空間を分割する計算について簡単に説明します。

分割する格子の一辺の長さをgridScaleとします。格子の原点は最小の頂点とし、0番目の格子の頂点が空間の原点と重なるように配置します。

このように格子を配置することで、ある地点positionで属する格子のindexは次のように求められます。

public static int3 CalculateGridIndex(float3 position, float gridScale)
{
    return (int3) math.floor(position / gridScale);
}


力場を設定する

分割した格子に力場を設定します。今回はデバッグ用に各格子の中央から原点へ向かう力を設定します。

先ほど作った格子のindexを求めるメソッドを使って次のように書いています。

// 任意の格子のみに力を設定できるようNativeHashMapを使用
var vectorField = new NativeHashMap<int3, float3>(gridCount * gridCount * gridCount, Allocator.Persistent);

var gridIndex = CalculateGridIndex(gridPosition, gridScale);
            
var gridCenter = gridPosition + new float3(gridScaleHalf);
var gridCenterToCenter = (float3)center - gridCenter;
var vector = math.normalize(gridCenterToCenter);
            
vectorField.TryAdd(gridIndex, vector);



最も近くにある原点を持つ格子を求める

ある地点の周りにある力場から線形補間を行います。

まずはある地点から最も近くにある原点を持つ格子を求めます。求める方法は次の通りです。

public static int3 CalculateOriginVectorFieldIndex(
    float3 position,
    float vectorFieldGridScale
)
{
    var halfGridScale = vectorFieldGridScale / 2f;
    return (int3)math.floor((position + halfGridScale) / vectorFieldGridScale);
}

ある地点が属する格子の半分より小さい場合は属する格子の原点が近く、反対に格子の半分以上の場合は次の格子の原点が近くなります。これらを判別するために、ある地点positionに格子の一辺の大きさの半分halfGridScaleを加えてから、格子のIndexを求める計算をします。

線形補間を行うときはこの格子を基準にしますので、ここでは基準格子と名づけます。



割合を求める

次に線形補間を行うための割合を求めます。求める処理は次の通りです。

public static float3 CalculateVectorFieldRate(
    float3 position,
    float vectorFieldGridScale
)
{
    var originIndex = CalculateOriginIndex(position, vectorFieldGridScale);
    var originPosition = originIndex * vectorFieldGridScale;
        
    var rate = (position - originPosition) / vectorFieldGridScale;
    return rate + new float3(0.5f);
}

割合を計算は、先ほど求めた基準格子の原点座標を求めます。次にある地点の座標と基準格子の原点座標との差を計算し、格子の一辺の長さで割ります。こうすることで -0.5 から 0.5 までの範囲の値が求まります。あとは 0.5 を加えて正規化することで、割合が求まります。



力場から受ける力を求める

最後に求めた割合を使って線形補間を行います。線形補間を行う力場は基準格子から一つ前の格子で行います。

今回は3次元の線形補間を行いますので、基準となる格子の数は8個です。プログラムでは基準格子の一つ前の格子を 0 とし、基準格子を 1 として表しています。格子 0 の値を使用する場合は 1 - rate, 格子 1 の値を使用する場合は rate を掛けて合計を求めます。

var fieldVector = (1 - rate.x) * (1 - rate.y) * (1 - rate.z) * vectorX0Y0Z0
    + rate.x * (1 - rate.y) * (1 - rate.z) * vectorX1Y0Z0
    + (1 - rate.x) * rate.y * (1 - rate.z) * vectorX0Y1Z0
    + rate.x * rate.y * (1 - rate.z) * vectorX1Y1Z0
    + (1 - rate.x) * (1 - rate.y) * rate.z * vectorX0Y0Z1
    + rate.x * (1 - rate.y) * rate.z * vectorX1Y0Z1
    + (1 - rate.x) * rate.y * rate.z * vectorX0Y1Z1
    + rate.x * rate.y * rate.z * vectorX1Y1Z1;

動作確認

実際に力場を作って、線形補間で正しく力が求められているかを確認します。

始めに任意の地点から原点 (0, 0, 0) へ向かう力を力場に設定し、その力場をもとに線形補完で求められた力を表示してみます。

結果は以下の画像の通りです。赤い矢印が実際に力場が持つ力で、白の矢印が力場の力を線形補間して求めた力です。線形補間によって、白の矢印も原点に向かう力となっています。


次に、力場に設定されている力の数を減らしてみます。こちらでも先程と同様の結果が求められていることが分かります。


最後に力場にランダムな力を設定して、各地点での力を線形補間で求めてみます。ある程度、流れが分かるぐらいには力場から受ける力が求められていることが分かります。


※後ほどコードを整理してここに載せます。



まとめ

線形補間を用いることで、少ない力場の値からおおよその値が求められることが確認できました。最初に述べた力場の問題に関してもこの手法を使うことで、解決できそうです。

この手法を使うことで様々な応用ができそうですが、今回はここまでにします。 今後はBoidsシミュレーションに組み込んで魚群を操作することができるのかを試す予定です。

蛇足

補間の手法についてはあまり知識がなく、ChatGPTに質問してみるとスプライン補間や多項式補間など聞いたことあるような無いようなものが出てきました。

もしかしたら今回の内容も本当は別の名前があったのかもしれません。

今回は単純な線形補間で目的が達成できたのでここで終わりですが、余裕ややる気があれば他の補間も試してみたいです。

【Unity】Edit Mode 時に設定したテクスチャをマテリアルに反映するコンポーネントを作る

始めに

uGUIを使わないUIを作るため、自作でテクスチャをマテリアルに設定するコンポーネントを作ります。

今回は備忘録的な内容なので結構あっさり目にまとめます。


やりたいこと

一番の希望はuGUIの Image コンポーネントのようなものになります。挙動としては次の通り。

  • 1つのマテリアルを複数のオブジェクトで共有できる
  • マテリアルの削除漏れが発生しない
  • Edit Mode でもInspectorのテクスチャの変更が反映される



マテリアルの注意点

上記のやりたいことに関しての簡単な注意点をまとめます。

マテリアルを共有した場合、変更は別オブジェクトでも適用される

マテリアルを共有している場合、一つのオブジェクトでマテリアルに画像を設定しても別オブジェクトにも反映されます。(同じマテリアルなので当たり前っちゃ当たり前)


また、スクリプトRenderer.sharedMaterialからマテリアルを変更した場合も同様になります。(sharedMaterial は共有しているマテリアルを参照するため


では、同じマテリアルを使って異なるテクスチャを設定する方法で Renderer.material を使う方法があります。 こちらからは設定されたマテリアルをインスタンス化したものを変更します。



Renderer.material は破棄をする

先程出てきた Renderer.material は自動的に破棄されるものではないので、Destory しない場合そのまま残り続けます。

なので、OnDestory などで生成した material を破棄する必要があります。こちらの内容については以下の記事がとても参考になります。

light11.hatenadiary.com



Edit Mode でもInspectorの変更を反映する

基本的にMonoBehaviourはPlay Mode時にしか動きませんが、いくつかの方法で Edit Mode からでも動作させられます。

今回調べて見つけた記事でこちらが網羅的に解説されていて参考になりました。

qiita.com


上記の記事から、今回選択した方法は2つ。

OnValidate() を使う

Unity公式では次のように説明されています。

この関数はスクリプトがロードされた時やインスペクターの値が変更されたときに呼び出されます(この呼出はエディター上のみ)

なので、Inspectorでテクスチャを変更するたびに呼ばれるので、ここで更新処理を書けばマテリアルに変更が適用されそうです。

[ExecuteAlways] を使う

OnValidate() だけですと、Material の生成と破棄が行えません。なので、[ExecuteAlways] を使って Awake()OnDestory() も呼ばれるようにします。



Inspectorの変更を反映するコンポーネントを作る

以上のことを注意して、Inspectorの変更を反映するコンポーネントを作りました。

このコンポーネントを作るに当たって、こちらの記事のコードを参考にしました。 (こちらの記事ではOnValidate() を使わずに Update() を使用、new Material(_renderer.sharedMaterial)でマテリアルを作れることを知った)

qiita.com

主に EditorApplication.IsPlaying を使って Edit Mode なのかを判別をしてマテリアル生成方法を分岐しています。

using UnityEngine;

#if UNITY_EDITOR
using UnityEditor;
#endif

[ExecuteAlways]
public class TextureSetting : MonoBehaviour
{
    [SerializeField] private Texture2D _texture;
    [SerializeField] private Renderer _renderer;

    private Material _material;
    private static readonly int MainTex = Shader.PropertyToID("_MainTex");

    private void Awake()
    {
#if UNITY_EDITOR
        _material = EditorApplication.isPlaying
            ? _renderer.material
            : new Material(_renderer.sharedMaterial);
#else
            _material = _renderer.material;
#endif

        _material.SetTexture(MainTex, _texture);
        _renderer.material = _material;
    }

#if UNITY_EDITOR
    private void OnValidate()
    {
        if (EditorApplication.isPlaying || _material == null) // Material生成前に呼ばれることがある
        {
            return;
        }

        _material.SetTexture(MainTex, _texture);
        _renderer.material = _material;
    }
#endif

    private void OnDestroy()
    {
        if (_material != null)
        {
#if UNITY_EDITOR
            if (EditorApplication.isPlaying)
            {
                Destroy(_material);
            }
            else
            {
                DestroyImmediate(_material);
            }
#else
            Destroy(_material);
#endif
        }
    }
}


こちらの結果は次のようになります。(コンポーネントの名前が異なるのは気にしないでください) Inspectorを変更したら反映されることが確認できました。



まとめ

このコンポーネントによって、オブジェクトごとにマテリアルを作る必要がなくなりました。

また、変更が Edit Mode でも反映されるので、作ろうとしているオブジェクト群やPrefabの構成をすぐに確認できます。

注意として、まだ完全に動作確認ができたわけではないので、もし不具合などがあればコードを修正する予定です。


使用した画像

今回しようした画像はいらすとやのものになります。

www.irasutoya.com

【Unity】NativeMultiHashMapを使った近傍探索Boidsシミュレーション

始めに

趣味でJobSystemを使ったBoidsシミュレーションを作成しています。そのときにNativeMultiHashMapがとても有用だったので備忘録として記事を書きます。


前提

今回の記事ではBoidsとJobSystemについては一切解説しません。

Boidsについては、Unity Graphics Programming vol.1 の第3章を参照してください。こちらの内容をもとに実装をしています。

github.com


JobSystemはUnity公式の解説動画やネット上にたくさん記事が挙げられているのでそれらを参照してください。


www.youtube.com



環境

  • Unity 2021.3.10f1
  • Unity.Burst 1.6.6
  • Unity.Collections 1.2.4
  • Unity.Mathematics
  • CPU core i7 8700k

プロジェクトはこちらです。

github.com



Boids実装概要

この記事では全探索と近傍探索それぞれのBoidsを実装します。

その両方で共通となる実装について簡単に触れていきます。


Boidsの設定データ

Boidsでは3つの力(結合、整列、分離)があり、それぞれ重みと影響範囲を持ちます。 また、加えて個体の最大速度やシミュレーション範囲などを設定する必要があります。

これらをInspectorで管理するのは大変なので、ScriptableObjectとして作成しております。

以下のコードは後で解説する全探索用の設定データになります。

AllSearchBoidsSettings

using Unity.Mathematics;
using UnityEngine;

namespace Boids.Settings
{
    [CreateAssetMenu(fileName = "AllSearchBoidsSetting", menuName = "Boids/AllSearchSetting")]
    public class AllSearchBoidsSetting : ScriptableObject
    {
        [Header("結合")]
        [SerializeField] private float _cohesionWeight;
        [SerializeField] private float _cohesionAffectedRadius;

        [Header("分離")]
        [SerializeField] private float _separationWeight;
        [SerializeField] private float _separationAffectedRadius;

        [Header("整列")]
        [SerializeField] private float _alignmentWeight;
        [SerializeField] private float _alignmentAffectedRadius;

        [Header("シミュレーション空間")]
        [SerializeField] private Vector3 _simulationAreaCenter;
        [SerializeField] private Vector3 _simulationAreaScale;
        [SerializeField] private float _avoidSimulationAreaWeight;

        [Space(20)]
        [SerializeField] private float _maxSpeed;
        [SerializeField] private float _maxSteerForce;

        [Header("個体のスケール")]
        [SerializeField] private float3 _instanceScale;

        public float CohesionWeight => _cohesionWeight;
        public float CohesionAffectedRadiusSqr => _cohesionAffectedRadius * _cohesionAffectedRadius;

        public float SeparateWeight => _separationWeight;
        public float SeparateAffectedRadiusSqr => _separationAffectedRadius * _separationAffectedRadius;

        public float AlignmentWeight => _alignmentWeight;
        public float AlignmentAffectedRadiusSqr => _alignmentAffectedRadius * _alignmentAffectedRadius;

        public Vector3 SimulationAreaCenter => _simulationAreaCenter;
        public Vector3 SimulationAreaScale => _simulationAreaScale / 2; // MEMO: 計算では 1/2 の方が書きやすいため
        public float AvoidSimulationAreaWeight => _avoidSimulationAreaWeight;

        public float MaxSpeed => _maxSpeed;
        public float MaxSteerForce => _maxSteerForce;

        public float3 InstanceScale => _instanceScale;
    }
}


Boids実行クラス

Boidsのシミュレーションを呼び出して、結果を描画するクラスを作ります。

今回描画する個体数は1000以上なので、描画がボトルネックにならないよう RenderMeshInstanced を使用します。

今回はInspectorで設定されたenum値に応じて、全探索と近傍探索のシミュレーションを分岐させます。

BoidsSimulator

using System;
using Boids;
using Boids.Settings;
using Unity.Collections;
using UnityEngine;
using UnityEngine.Rendering;

namespace BoidsSimulator
{
    public class BoidsSimulator : MonoBehaviour
    {
        private enum BoidsSimulationType
        {
            AllSearch,
            NeighborSearch,
        }

        [Header("シミュレーション法")]
        [SerializeField] private BoidsSimulationType _boidsSimulationType;

        [SerializeField] private int _instanceCount;

        [Header("描画関係")]
        [SerializeField] private Mesh _mesh;
        [SerializeField] private Material _material;
        
        private BoidsData[] _boidsDatas;
        private RenderParams _renderParams;

        [Header("AllSearchSetting")]
        [SerializeField] private AllSearchBoidsSetting _allSearchBoidsSetting;

        [Header("NeighborSearchSetting")]
        [SerializeField] private NeighborSearchBoidsSetting _neighborSearchBoidsSetting;

        private void Start()
        {
            InitializeBoidsInstance();
            _renderParams = new RenderParams(_material) { receiveShadows = true, shadowCastingMode = ShadowCastingMode.On };
        }

        private void Update()
        {
            switch (_boidsSimulationType)
            {
                case BoidsSimulationType.AllSearch:
                    UpdateBoidsByAllSearch();
                    break;
                case BoidsSimulationType.NeighborSearch:
                    UpdateBoidsByNeighborSearch();
                    break;
                default:
                    throw new ArgumentOutOfRangeException();
            }
        }

        private void UpdateBoidsByAllSearch()
        {
            var allSearchBoidsSimulator = new AllSearchBoidsSimulator(_allSearchBoidsSetting);
            var matricesArray = new NativeArray<Matrix4x4>(_instanceCount, Allocator.TempJob);
            
            allSearchBoidsSimulator.Calculate(matricesArray, _boidsDatas);

            RendererUtility.InstanceRenderUtility.DrawAll(_mesh, _renderParams, matricesArray);
            
            matricesArray.Dispose();
        }

        private void UpdateBoidsByNeighborSearch()
        {
            var matricesArray = new NativeArray<Matrix4x4>(_instanceCount, Allocator.TempJob);

            var neighborSearchBoidsSimulator = new NeighborSearchBoidsSimulator(_neighborSearchBoidsSetting);
            neighborSearchBoidsSimulator.Calculate(matricesArray, _boidsDatas);
            
            RendererUtility.InstanceRenderUtility.DrawAll(_mesh, _renderParams, matricesArray);

            matricesArray.Dispose();
        }

        private void InitializeBoidsInstance()
        {
            _boidsDatas = new BoidsData[_instanceCount];

            BoidsInitializer.In(_boidsDatas, _allSearchBoidsSetting.SimulationAreaCenter, _allSearchBoidsSetting.SimulationAreaScale, 0.1f);
        }
    }
}

RendererUtility

using Unity.Collections;
using UnityEngine;

namespace RendererUtility
{
    public static class InstanceRenderUtility
    {
        public static void DrawAll(Mesh mesh, RenderParams renderParams, NativeArray<Matrix4x4> matricesArray)
        {
            const int instanceCountPerDraw = 1023; // RenderMeshInstancedが一度に描画できる最大数
            var instanceCount = matricesArray.Length;

            for (int i = 0; i < instanceCount; i += instanceCountPerDraw)
            {
                var length = Mathf.Min(instanceCountPerDraw, instanceCount - i);
                Graphics.RenderMeshInstanced(renderParams, mesh, 0, matricesArray, length, i);
            }
        }
    }
}


Boids計算クラス

設定データをもとにBoidsの計算をして、描画用Matrixを求めるクラスを定義します。

このクラス内で計算に必要となるNativeArrayを生成してBoidsの計算を行うJobSystemを実行します。

計算が完了したら、NativeArrayの破棄と計算結果の反映を行います。(コードは全探索から)

AllSearchBoidsSimulator

using Boids.Job;
using Boids.Settings;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;

namespace Boids
{
    public class AllSearchBoidsSimulator
    {
        private readonly AllSearchBoidsSetting _allSearchBoidsSetting;

        public AllSearchBoidsSimulator(AllSearchBoidsSetting boidsSetting)
        {
            _allSearchBoidsSetting = boidsSetting;
        }

        public void Calculate(NativeArray<Matrix4x4> instanceMatricesArray, BoidsData[] boidsDatas)
        {
            var instanceCount = boidsDatas.Length;
            var boidsDataArray = new NativeArray<BoidsData>(instanceCount, Allocator.TempJob);
            var boidsSteerArray = new NativeArray<float3>(instanceCount, Allocator.TempJob);

            boidsDataArray.CopyFrom(boidsDatas);
            
            var boidsJob = new AllSearchBoidsSimulatorJob
            (
                _allSearchBoidsSetting.CohesionWeight,
                _allSearchBoidsSetting.CohesionAffectedRadiusSqr,
                _allSearchBoidsSetting.SeparateWeight,
                _allSearchBoidsSetting.SeparateAffectedRadiusSqr,
                _allSearchBoidsSetting.AlignmentWeight,
                _allSearchBoidsSetting.AlignmentAffectedRadiusSqr,
                _allSearchBoidsSetting.MaxSpeed,
                _allSearchBoidsSetting.MaxSteerForce,
                boidsDataArray,
                boidsSteerArray
            );

            var boidsJobHandler = boidsJob.Schedule(instanceCount, 0);
            boidsJobHandler.Complete();

            var applySteerForce = new ApplySteerForceJob
            (
                boidsDataArray,
                boidsSteerArray,
                instanceMatricesArray,
                _allSearchBoidsSetting.SimulationAreaCenter,
                _allSearchBoidsSetting.SimulationAreaScale,
                _allSearchBoidsSetting.AvoidSimulationAreaWeight,
                Time.deltaTime,
                _allSearchBoidsSetting.MaxSpeed,
                _allSearchBoidsSetting.InstanceScale
            );

            var applySteerForceHandler = applySteerForce.Schedule(instanceCount, 0);
            applySteerForceHandler.Complete();
            
            boidsDataArray.CopyTo(boidsDatas);

            boidsDataArray.Dispose();
            boidsSteerArray.Dispose();
        }
    }
}


JobSystem

Boidsの計算を2段階に分けて計算します。

  1. 各個体の操舵力を求める(全探索と近傍探索の2種類)
  2. 操舵力を各個体に反映、描画用Matrixの計算

1.の各個体の操舵力を求める計算は次のようになります

BoidsSimulatorJob.Execute

        public void Execute(int ownIndex)
        {
            var ownPosition = _boidsDatasRead[ownIndex].Position;
            var ownVelocity = _boidsDatasRead[ownIndex].Velocity;

            var cohesionPositionSum = new float3();
            var cohesionTargetCount = 0;

            var separateRepluseSum = new float3();
            var separateTargetCount = 0;

            var alignmentVelocitySum = new float3();
            var alignmentTargetCount = 0;

            // 全探索、近傍探索で各個体のインデックス targetIndex を取得
            {
                if (ownIndex == targetIndex)
                {
                    continue;
                }

                var targetPosition = _boidsDatasRead[targetIndex].Position;
                var targetVelocity = _boidsDatasRead[targetIndex].Velocity;

                var diff = ownPosition - targetPosition;
                var distanceSqr = math.dot(diff, diff);

                if (distanceSqr <= _cohesionAffectedRadiusSqr)
                {
                    cohesionPositionSum += targetPosition;
                    cohesionTargetCount++;
                }

                if (distanceSqr <= _separateAffectedRadiusSqr)
                {
                    separateRepluseSum += math.normalize(diff) / math.sqrt(distanceSqr); // 距離に反比例する相手から自分への力
                    separateTargetCount++;
                }

                if (distanceSqr <= _alignmentAffectedRadiusSqr)
                {
                    alignmentVelocitySum += targetVelocity;
                    alignmentTargetCount++;
                }
            }

            var cohesionSteer = new float3();
            if (cohesionTargetCount > 0)
            {
                var cohesionPositionAverage = cohesionPositionSum / cohesionTargetCount;
                var cohesionDirection = cohesionPositionAverage - ownPosition;
                var cohesionVelocity = math.normalize(cohesionDirection) * _maxSpeed;
                cohesionSteer = MathematicsUtility.Limit(cohesionVelocity - ownVelocity, _maxForceSteer);
            }

            var separateSteer = new float3();
            if (separateTargetCount > 0)
            {
                var separateRepulseAverage = separateRepluseSum / separateTargetCount;
                var separateVelocity = math.normalize(separateRepulseAverage) * _maxSpeed;
                separateSteer = MathematicsUtility.Limit(separateVelocity - ownVelocity, _maxForceSteer);
            }

            var alignmentSteer = new float3();
            if (alignmentTargetCount > 0)
            {
                var alignmentVelocityAverage = alignmentVelocitySum / alignmentTargetCount;
                var alignmentVelocity = math.normalize(alignmentVelocityAverage) * _maxSpeed;
                alignmentSteer = MathematicsUtility.Limit(alignmentVelocity - ownVelocity, _maxForceSteer);
            }

            _boidsSteerWrite[ownIndex] =
                cohesionSteer * _cohesionWeight +
                separateSteer * _separateWeight +
                alignmentSteer * _alignmentWeight;
        }


2.では1.で求めた操舵力を各個体に反映します。こちらは全探索と近傍探索の両方で共通です。

また、ここで個体の描画用Matrixを求めます。(本当は別で分けた方が良さそう)

ApplySteerForceJob

using Boids.Mathematics;
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;

namespace Boids.Job
{
    [BurstCompile]
    internal struct ApplySteerForceJob : IJobParallelFor
    {
        private NativeArray<BoidsData> _boidsDatasWrite;
        [ReadOnly] private readonly NativeArray<float3> _boidsForceRead;
        [WriteOnly] private NativeArray<Matrix4x4> _instanceMatrices;

        [ReadOnly] private readonly float3 _simulationAreaCenter;
        [ReadOnly] private readonly float3 _simulationAreaScale;
        [ReadOnly] private readonly float _avoidWallWeight;

        [ReadOnly] private readonly float _deltaTime;
        [ReadOnly] private readonly float _maxSpeed;
        [ReadOnly] private readonly float3 _instanceScale;

        public ApplySteerForceJob(
            NativeArray<BoidsData> boidsDatasWrite,
            NativeArray<float3> boidsForceRead,
            NativeArray<Matrix4x4> instanceMatrices,
            float3 simulationAreaCenter,
            float3 simulationAreaScale,
            float avoidWallWeight,
            float deltaTime,
            float maxSpeed,
            float3 instanceScale
        )
        {
            _boidsDatasWrite = boidsDatasWrite;
            _boidsForceRead = boidsForceRead;
            _instanceMatrices = instanceMatrices;
            _simulationAreaCenter = simulationAreaCenter;
            _simulationAreaScale = simulationAreaScale;
            _avoidWallWeight = avoidWallWeight;
            _deltaTime = deltaTime;
            _maxSpeed = maxSpeed;
            _instanceScale = instanceScale;
        }

        public void Execute(int ownIndex)
        {
            var boidsData = _boidsDatasWrite[ownIndex];
            var force = _boidsForceRead[ownIndex];

            force += AvoidAreaEdge(boidsData.Position, _simulationAreaCenter, _simulationAreaScale) * _avoidWallWeight;

            var velocity = boidsData.Velocity + (force * _deltaTime);
            boidsData.Velocity = MathematicsUtility.Limit(velocity, _maxSpeed);
            boidsData.Position += velocity * _deltaTime;

            _boidsDatasWrite[ownIndex] = boidsData;

            var rotationY = math.atan2(boidsData.Velocity.x, boidsData.Velocity.z);
            var rotationX = (float) -math.asin(boidsData.Velocity.y / (math.length(boidsData.Velocity.xyz) + 1e-8));
            var rotation = quaternion.Euler(rotationX, rotationY, 0);
            _instanceMatrices[ownIndex] = float4x4.TRS(boidsData.Position, rotation, _instanceScale);
        }

        private static float3 AvoidAreaEdge(float3 position, float3 simulationAreaCenter, float3 simulationAreaScale)
        {
            var acc = new float3();

            acc.x = position.x < simulationAreaCenter.x - simulationAreaScale.x
                ? acc.x + 1.0f
                : acc.x;

            acc.x = position.x > simulationAreaCenter.x + simulationAreaScale.x
                ? acc.x - 1.0f
                : acc.x;

            acc.y = position.y < simulationAreaCenter.y - simulationAreaScale.y
                ? acc.y + 1.0f
                : acc.y;

            acc.y = position.y > simulationAreaCenter.y + simulationAreaScale.y
                ? acc.y - 1.0f
                : acc.y;

            acc.z = position.z < simulationAreaCenter.z - simulationAreaScale.z
                ? acc.z + 1.0f
                : acc.z;

            acc.z = position.z > simulationAreaCenter.z + simulationAreaScale.z
                ? acc.z - 1.0f
                : acc.z;

            return acc;
        }
    }
}

全探索Boids

実装

全探索なので、各個体は全ての個体を見て自身に与える力を求めます。計算量は O(n2) になります。

AllSearchBoidsSimulatorJob

using Boids.Mathematics;
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEditor.U2D;

namespace Boids.Job
{
    [BurstCompile]
    internal struct AllSearchBoidsSimulatorJob : IJobParallelFor
    {
        [ReadOnly] private readonly float _cohesionWeight;
        [ReadOnly] private readonly float _cohesionAffectedRadiusSqr;
        [ReadOnly] private readonly float _separateWeight;
        [ReadOnly] private readonly float _separateAffectedRadiusSqr;
        [ReadOnly] private readonly float _alignmentWeight;
        [ReadOnly] private readonly float _alignmentAffectedRadiusSqr;

        [ReadOnly] private readonly float _maxSpeed;
        [ReadOnly] private readonly float _maxForceSteer;

        [ReadOnly] private readonly NativeArray<BoidsData> _boidsDatasRead;
        [WriteOnly] private NativeArray<float3> _boidsSteerWrite;

        public AllSearchBoidsSimulatorJob(
            float cohesionWeight,
            float cohesionAffectedRadiusSqr,
            float separateWeight,
            float separateAffectedRadiusSqr,
            float alignmentWeight,
            float alignmentAffectedRadiusSqr,
            float maxSpeed,
            float maxForceSteer,
            NativeArray<BoidsData> boidsDatasRead,
            NativeArray<float3> boidsSteerWrite
        )
        {
            _cohesionWeight = cohesionWeight;
            _cohesionAffectedRadiusSqr = cohesionAffectedRadiusSqr;
            _separateWeight = separateWeight;
            _separateAffectedRadiusSqr = separateAffectedRadiusSqr;
            _alignmentWeight = alignmentWeight;
            _alignmentAffectedRadiusSqr = alignmentAffectedRadiusSqr;
            _maxSpeed = maxSpeed;
            _maxForceSteer = maxForceSteer;
            _boidsDatasRead = boidsDatasRead;
            _boidsSteerWrite = boidsSteerWrite;
        }

        public void Execute(int ownIndex)
        {
            var ownPosition = _boidsDatasRead[ownIndex].Position;
            var ownVelocity = _boidsDatasRead[ownIndex].Velocity;

            var cohesionPositionSum = new float3();
            var cohesionTargetCount = 0;

            var separateRepluseSum = new float3();
            var separateTargetCount = 0;

            var alignmentVelocitySum = new float3();
            var alignmentTargetCount = 0;

            for (int targetIndex = 0; targetIndex < _boidsDatasRead.Length; ++targetIndex)
            {
                if (ownIndex == targetIndex)
                {
                    continue;
                }

                var targetPosition = _boidsDatasRead[targetIndex].Position;
                var targetVelocity = _boidsDatasRead[targetIndex].Velocity;

                var diff = ownPosition - targetPosition;
                var distanceSqr = math.dot(diff, diff);

                if (distanceSqr <= _cohesionAffectedRadiusSqr)
                {
                    cohesionPositionSum += targetPosition;
                    cohesionTargetCount++;
                }

                if (distanceSqr <= _separateAffectedRadiusSqr)
                {
                    separateRepluseSum += math.normalize(diff) / math.sqrt(distanceSqr);
                    separateTargetCount++;
                }

                if (distanceSqr <= _alignmentAffectedRadiusSqr)
                {
                    alignmentVelocitySum += targetVelocity;
                    alignmentTargetCount++;
                }
            }

            var cohesionSteer = new float3();
            if (cohesionTargetCount > 0)
            {
                var cohesionPositionAverage = cohesionPositionSum / cohesionTargetCount;
                var cohesionDirection = cohesionPositionAverage - ownPosition;
                var cohesionVelocity = math.normalize(cohesionDirection) * _maxSpeed;
                cohesionSteer = MathematicsUtility.Limit(cohesionVelocity - ownVelocity, _maxForceSteer);
            }

            var separateSteer = new float3();
            if (separateTargetCount > 0)
            {
                var separateRepulseAverage = separateRepluseSum / separateTargetCount;
                var separateVelocity = math.normalize(separateRepulseAverage) * _maxSpeed;
                separateSteer = MathematicsUtility.Limit(separateVelocity - ownVelocity, _maxForceSteer);
            }

            var alignmentSteer = new float3();
            if (alignmentTargetCount > 0)
            {
                var alignmentVelocityAverage = alignmentVelocitySum / alignmentTargetCount;
                var alignmentVelocity = math.normalize(alignmentVelocityAverage) * _maxSpeed;
                alignmentSteer = MathematicsUtility.Limit(alignmentVelocity - ownVelocity, _maxForceSteer);
            }

            _boidsSteerWrite[ownIndex] =
                cohesionSteer * _cohesionWeight +
                separateSteer * _separateWeight +
                alignmentSteer * _alignmentWeight;
        }
    }
}

結果

5000匹と10000匹を動かした結果になります。

5000匹はそこまで重くはないですが、10000匹まで行くと25fpsまで落ちてしまいます。

Profilerを見ると AllSearchBoidsSimulatorJobボトルネックになっています。

個体数 5000匹 10000匹
動作
Profiler
fps 約80fps 約25fps
Updateの時間 9.13ms 36.82ms



近傍探索Boids

NativeMultiHashMapについて

Struct NativeMultiHashMap<TKey, TValue> | Package Manager UI website

NativeContainerの一つで、キーに対して複数の値を持つことができます。

NativeMultiHashMapのコンストラクタで格納するデータのサイズと AllocatorManager.AllocatorHandle を指定します。(例のコードでは Allocator.TemJob

new NativeMultiHashMap<TKey, TValue>(instanceCount, Allocator.TempJob);


要素の追加は Add が使えますが、Job内で要素を追加する場合は NativeMultiHashMap<TKey, TValue>.ParallelWriter で行う必要があります。

var job = new HogeJob { nativeMultiHashMap.AsParallelWriter() };
.....
nativeMultiHasmMapParallelWriter.Add(key, value);


要素へのアクセス方法ですが、NativeMultiHashMapIterator<TKey> を使います。

TryGetFirstValueイテレータと最初の要素を取得して、TryGetNextValue で次の要素を取得していきます。要素の取得に失敗した場合は false が返されます。

for (var success = nativeMultiHashMap.TryGetFirstValue(key, out var value, out var iterator);
     success;
     success = nativeMultiHashMap.TryGetNextValue(out value, ref iterator))
{
......
}


実装

始めにシミュレーション空間を格子上に分割して、各個体が所属する格子に個体のIndexを NativeMultiHashMap 保存します。

格子の座標計算ではシミュレーション空間内に限定し、範囲外も空間内に納めています。

NeighborSearchBoidsSetting.Calculate

        public void Calculate(NativeArray<Matrix4x4> instanceMatricesArray, BoidsData[] boidsDatas)
        {
            var instanceCount = boidsDatas.Length;
            var boidsDataArray = new NativeArray<BoidsData>(instanceCount, Allocator.TempJob);
            var gridMultiHashMap = new NativeMultiHashMap<int3, int>(instanceCount, Allocator.TempJob);

            boidsDataArray.CopyFrom(boidsDatas);

            var registerInstanceToGridJob = new RegisterNeighborSearchGridJob
            (
                gridMultiHashMap.AsParallelWriter(),
                boidsDataArray,
                _neighborSearchBoidsSetting.MinNeighborSearchGridPoint,
                _neighborSearchBoidsSetting.NeighborSearchGridScale,
                _neighborSearchBoidsSetting.NeighborSearchGridCount
            );

            var registerInstanceToGridHandler = registerInstanceToGridJob.Schedule(instanceCount, 0);
            registerInstanceToGridHandler.Complete();
            .........
            .........

RegisterNeighborSearchGridJob

using Boids.Mathematics;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

namespace Boids.Job
{
    internal struct RegisterNeighborSearchGridJob : IJobParallelFor
    {
        [WriteOnly] private NativeMultiHashMap<int3, int>.ParallelWriter _gridWriter;
        [ReadOnly] private readonly NativeArray<BoidsData> _boidsDatasRead;
        [ReadOnly] private readonly float3 _minGridPoint;
        [ReadOnly] private readonly float _gridScale;
        [ReadOnly] private readonly int3 _gridCount;
        
        public RegisterNeighborSearchGridJob(
            NativeMultiHashMap<int3, int>.ParallelWriter gridWriter,
            NativeArray<BoidsData> boidsDatasRead,
            float3 minGridPoint,
            float gridScale,
            int3 gridCount)
        {
            _gridWriter = gridWriter;
            _boidsDatasRead = boidsDatasRead;
            _minGridPoint = minGridPoint;
            _gridScale = gridScale;
            _gridCount = gridCount;
        }

        public void Execute(int index)
        {
            var boidsDataPosition = _boidsDatasRead[index].Position;

            var gridIndex = MathematicsUtility.CalculateGridIndex(boidsDataPosition, _minGridPoint, _gridScale, _gridCount);

            _gridWriter.Add(gridIndex, index);
        }
    }
}

MatematicsUtility

        internal static int3 CalculateGridIndex(float3 position, float3 minGridPoint, float gridScale, int3 gridCount)
        {
            // MEMO: 範囲外のものは範囲内のGridに収める
            return new int3(
                (int) math.clamp((position.x - minGridPoint.x) / gridScale, 0, gridCount.x),
                (int) math.clamp((position.y - minGridPoint.y) / gridScale, 0, gridCount.y),
                (int) math.clamp((position.z - minGridPoint.z) / gridScale, 0, gridCount.z)
            );
        }


次にBoidsの操舵力を求める処理についてです。 

個体が所属する格子を求めて、周囲の格子から個体のIndexを取得します。これにより、周囲の個体のみで操舵力を求められます。

それ以外の処理は基本全探索と同じです。(共通部分は省略)

NeighborSearchBoidsSimulatorJob

using Boids.Mathematics;
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

namespace Boids.Job
{
    [BurstCompile]
    internal struct NeighborSearchBoidsSimulatorJob : IJobParallelFor
    {
        [ReadOnly] private readonly float _cohesionWeight;
        [ReadOnly] private readonly float _cohesionAffectedRadiusSqr;
        [ReadOnly] private readonly float _separateWeight;
        [ReadOnly] private readonly float _separateAffectedRadiusSqr;
        [ReadOnly] private readonly float _alignmentWeight;
        [ReadOnly] private readonly float _alignmentAffectedRadiusSqr;

        [ReadOnly] private readonly float _maxSpeed;
        [ReadOnly] private readonly float _maxForceSteer;

        [ReadOnly] private NativeMultiHashMap<int3, int> _grid;
        [ReadOnly] private readonly float _gridScale;
        [ReadOnly] private readonly int3 _gridCount;
        [ReadOnly] private readonly float3 _minGridPoint;

        [ReadOnly] private readonly NativeArray<BoidsData> _boidsDatasRead;
        [WriteOnly] private NativeArray<float3> _boidsSteerWrite;

        public NeighborSearchBoidsSimulatorJob(
            float cohesionWeight,
            float cohesionAffectedRadiusSqr,
            float separateWeight,
            float separateAffectedRadiusSqr,
            float alignmentWeight,
            float alignmentAffectedRadiusSqr,
            float maxSpeed,
            float maxForceSteer,
            NativeMultiHashMap<int3, int> grid,
            float gridScale,
            int3 gridCount,
            float3 minGridPoint,
            NativeArray<BoidsData> boidsDatasRead,
            NativeArray<float3> boidsSteerWrite
        )
        {
            _cohesionWeight = cohesionWeight;
            _cohesionAffectedRadiusSqr = cohesionAffectedRadiusSqr;
            _separateWeight = separateWeight;
            _separateAffectedRadiusSqr = separateAffectedRadiusSqr;
            _alignmentWeight = alignmentWeight;
            _alignmentAffectedRadiusSqr = alignmentAffectedRadiusSqr;
            _maxSpeed = maxSpeed;
            _maxForceSteer = maxForceSteer;
            _grid = grid;
            _gridScale = gridScale;
            _gridCount = gridCount;
            _minGridPoint = minGridPoint;
            _boidsDatasRead = boidsDatasRead;
            _boidsSteerWrite = boidsSteerWrite;
        }

        public void Execute(int ownIndex)
        {
            // 全探索と同じ

            var gridIndex = MathematicsUtility.CalculateGridIndex(ownPosition, _minGridPoint, _gridScale, _gridCount);

            int minX = gridIndex.x - 1 < 0 ? 0 : gridIndex.x - 1;
            int minY = gridIndex.y - 1 < 0 ? 0 : gridIndex.y - 1;
            int minZ = gridIndex.z - 1 < 0 ? 0 : gridIndex.z - 1;

            int maxX = gridIndex.x + 1 >= _gridCount.x ? gridIndex.x : gridIndex.x + 1;
            int maxY = gridIndex.y + 1 >= _gridCount.y ? gridIndex.y : gridIndex.y + 1;
            int maxZ = gridIndex.z + 1 >= _gridCount.z ? gridIndex.z : gridIndex.z + 1;

            for (int x = minX; x <= maxX; ++x)
            for (int y = minY; y <= maxY; ++y)
            for (int z = minZ; z <= maxZ; ++z)
            {
                var key = new int3(x, y, z);

                for (var success = _grid.TryGetFirstValue(key, out var targetIndex, out var iterator);
                     success;
                     success = _grid.TryGetNextValue(out targetIndex, ref iterator))
                {
                    if (ownIndex == targetIndex)
                    {
                        continue;
                    }

                    var targetPosition = _boidsDatasRead[targetIndex].Position;
                    var targetVelocity = _boidsDatasRead[targetIndex].Velocity;

                    // 全探索と同じ

                }
            }

            // 全探索と同じ
    }
}


結果

同じく5000匹と10000匹の実行結果になります。

全探索に比べてかなり高速になったことがわかります。また、5000匹の場合は操舵力の計算を求めるのに1ms以下に抑えられております。

個体数 5000匹 10000匹
動作
Profiler
fps 約240fps 約130fps
Updateの時間 1.55ms 4.64ms



比較

全探索と近傍探索で操舵力を求めるのにかかった時間を比較してみます。

(時間は添付した画像から、近傍探索は 格子に登録する時間+操舵力を計算する時間 )

5000匹 10000匹
全探索 8.6ms 36.1ms
近傍探索 0.4ms+0.7ms=1.1ms 0.7ms+3.2ms=3.9ms
倍率 7.8倍 9倍

表からわかる通り、10倍近く高速になりました。

全探索では計算量 O(n2) だったのに対して、近傍探索では計算量がおおよそ O(n) になるので大幅に高速化できました。



まとめ

NativeMultiHashMapを使うことでここまで最適化できたことに驚きでした。

これまでBoidsシミュレーションをコンテンツで扱うのは少し難しいと感じていましたが、この結果からそこまで躊躇しなくても良いのではないかと考えております。

今後Boidsで何かしらコンテンツを作っていくので、また何か知見を得られたらまとめます。

【Unity】JobSystemのサンプル作成と計測

始めに

並列処理が出来るJobSystemのサンプルを作成して、どれほどの性能なのかを計ってみようと思います。

何も考えずにサンプルを作って動作確認しているので、JobSystemについての詳細については一切ふれません。

もし、JobSystemについて詳しく知りたい方はUnity公式の動画がおすすめです。


www.youtube.com


www.youtube.com


作成するサンプル

指定された立方体内を反射し続けるCubeを大量に作って、以下の環境でどれだけの性能が出るかを計測しました。

  • 愚直な実装 + MeshRenderer
  • JobSystem + Burst + MeshRenderer
  • JobSystem + Burst + RenderMeshInstanced

また、負荷を高めるために O(n2) の計算を加えています。

ここで載せるコードは雑に書いたものになるので、参考程度にして下さい。

動作環境

  • Core i7 8700K
  • Unity 2021.3.8f1 URP


愚直な実装 + MeshRenderer

MonobehaviorのUpdateにそのままCubeの移動処理を書きます。

CubeMove.cs

using System.Collections.Generic;
using UnityEngine;

public class CubeMove : MonoBehaviour
{
    [SerializeField] private int _instanceCount;
    [SerializeField] private GameObject _prefab;
    [SerializeField] private float _range;

    [SerializeField] private float _velocity;
    
    private readonly List<ObjData> _objDatas = new();
    private readonly List<GameObject> _gameObjects = new();
    
    private class ObjData
    {
        public Vector3 Position;
        public Vector3 Velocity;
    }

    private void Start()
    {
        for (int i = 0; i < _instanceCount; ++i)
        {
            var position = RandomPositionInRange();
            var instance = Instantiate(_prefab, position, Quaternion.identity);
            _objDatas.Add(new ObjData { Position = position, Velocity = RandomDirectionVelocity() } );
            _gameObjects.Add(instance);
        }
    }

    private void Update()
    {
        UpdateObjects();
    }

    private void UpdateObjects()
    {
        for (int i = 0; i < _instanceCount; ++i)
        {
            SumVelocity(); // MEMO: わざと負荷を上げる
            
            var obj = _objDatas[i];

            var deltaTime = Time.deltaTime;
            var diff = obj.Velocity * deltaTime;
            
            if (Mathf.Abs(obj.Position.x + diff.x) >= _range) obj.Velocity.x *= -1;
            if (Mathf.Abs(obj.Position.y + diff.y) >= _range) obj.Velocity.y *= -1;
            if (Mathf.Abs(obj.Position.z + diff.z) >= _range) obj.Velocity.z *= -1;

            obj.Position += obj.Velocity * deltaTime;

            _gameObjects[i].transform.position = obj.Position;
        }
    }

    private void SumVelocity()
    {
        var sumVelocity = Vector3.zero;
        var sumPosition = Vector3.zero;

        for (int i = 0; i < _objDatas.Count; ++i)
        {
            sumVelocity += _objDatas[i].Velocity;
            sumPosition += _objDatas[i].Position;
        }
    }
    
    private Vector3 RandomPositionInRange()
    {
        return new Vector3(
            Random.Range(-_range, _range),
            Random.Range(-_range, _range),
            Random.Range(-_range, _range)
        );
    }

    private Vector3 RandomDirectionVelocity()
    {
        return Random.onUnitSphere * _velocity;
    }
}


こちらの実行結果が次のようになります。

cube数が100個の場合は約100fps、1000個の場合は約13fpsになります。

n = 100 n = 1000


JobSystem + Burst + MeshRenderer

次にJobSystemとBurstを使ってサンプルを作ってみます。

CubeMoveByJobSystem.cs

using System.Collections.Generic;
using System.Linq;
using Unity.Burst;
using Unity.Collections;
using UnityEngine;
using UnityEngine.Jobs;
using Random = UnityEngine.Random;

public class CubeMoveByJobSystem : MonoBehaviour
{
    [SerializeField] private int _instanceCount;
    [SerializeField] private GameObject _prefab;
    [SerializeField] private float _range;

    [SerializeField] private float _velocity;
    
    private ObjData[] _objDatas;
    private readonly List<GameObject> _gameObjects = new();
    private TransformAccessArray _transformAccessArray;

    private struct ObjData
    {
        public Vector3 Position;
        public Vector3 Velocity;
    }

    private void Start()
    {
        _objDatas = new ObjData[_instanceCount];

        var objDataList = new List<ObjData>();
        for (int i = 0; i < _instanceCount; ++i)
        {
            var position = RandomPositionInRange();
            var instance = Instantiate(_prefab, position, Quaternion.identity);
            objDataList.Add(new ObjData { Position = position, Velocity = RandomDirectionVelocity() } );
            _gameObjects.Add(instance);
        }

        _objDatas = objDataList.ToArray();
        _transformAccessArray = new TransformAccessArray(_gameObjects.Select(obj => obj.transform).ToArray());
    }

    private void Update()
    {
        UpdateByJobSystem();
    }

    private void UpdateByJobSystem()
    {
        var objDataArray = new NativeArray<ObjData>(_instanceCount, Allocator.TempJob);
        var objDataReadArray = new NativeArray<ObjData>(_instanceCount, Allocator.TempJob);
        
        objDataArray.CopyFrom(_objDatas);
        objDataReadArray.CopyFrom(_objDatas);
        
        var job = new MoveObjectJob
        {
            objDatas = objDataArray,
            objDatasRead = objDataReadArray,
            range = _range,
            deltaTime = Time.deltaTime
        };

        var handler = job.Schedule(_transformAccessArray);
        handler.Complete();
        
        objDataArray.CopyTo(_objDatas);

        objDataArray.Dispose();
        objDataReadArray.Dispose();
    }

    private Vector3 RandomPositionInRange()
    {
        return new Vector3(
            Random.Range(-_range, _range),
            Random.Range(-_range, _range),
            Random.Range(-_range, _range)
        );
    }

    private Vector3 RandomDirectionVelocity()
    {
        return Random.onUnitSphere * _velocity;
    }

    private void OnDestroy()
    {
        _transformAccessArray.Dispose();
    }

    [BurstCompile]
    private struct MoveObjectJob : IJobParallelForTransform
    {
        public NativeArray<ObjData> objDatas;
        [ReadOnly] public NativeArray<ObjData> objDatasRead;
        [ReadOnly] public float range;
        [ReadOnly] public float deltaTime;

        public void Execute(int index, TransformAccess transform)
        {
            SumVelocity(); // MEMO: わざと負荷を上げる
            
            var obj = objDatas[index];

            var diff = obj.Velocity * deltaTime;
            
            if (Mathf.Abs(obj.Position.x + diff.x) >= range) obj.Velocity.x *= -1;
            if (Mathf.Abs(obj.Position.y + diff.y) >= range) obj.Velocity.y *= -1;
            if (Mathf.Abs(obj.Position.z + diff.z) >= range) obj.Velocity.z *= -1;

            obj.Position += obj.Velocity * deltaTime;
            objDatas[index] = obj;

            transform.localPosition = obj.Position;
        }
        
        private void SumVelocity()
        {
            var sumVelocity = Vector3.zero;
            var sumPosition = Vector3.zero;

            for (int i = 0; i < objDatasRead.Length; ++i)
            {
                sumVelocity += objDatasRead[i].Velocity;
                sumPosition += objDatasRead[i].Position;
            }
        }
    }
}


こちらの実行結果は次のようになります。

n = 1000の場合は安定して80fps近く出せてますが、n = 5000で40fps行かないぐらい、n = 10000で約15fpsまで落ちました。

n = 1000 n = 5000 n = 10000


JobSystem + Burst + RenderMeshInstanced

先程のサンプルの処理速度を見るに、計算処理ではなく描画処理がボトルネックになっているとわかりました。(計算負荷を上げている個所をコメントアウトしてもフレームレートが変わらない)

なので、MeshRendererではなく、Graphics.RenderMeshInstancedを使って描画をしてみます。( Unity - Scripting API: Graphics.RenderMeshInstanced

CubeMoveByJobySystemAndGpuInstancing.cs

using System.Collections.Generic;
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;
using UnityEngine.Rendering;
using Random = UnityEngine.Random;

public class CubeMoveByJobySystemAndGpuInstancing : MonoBehaviour
{
    [SerializeField] private int _instanceCount;
    [SerializeField] private float _range;
    [SerializeField] private Mesh _mesh;
    [SerializeField] private Material _material;

    [SerializeField] private float _velocity;
    
    private ObjData[] _objDatas;
    private RenderParams _renderParams;

    private const int InstanceCountPerDraw = 1023;

    private struct ObjData
    {
        public Vector3 Position;
        public Vector3 Velocity;
    }

    private void Start()
    {
        _objDatas = new ObjData[_instanceCount];

        var objDataList = new List<ObjData>();
        for (int i = 0; i < _instanceCount; ++i)
        {
            var position = RandomPositionInRange();
            objDataList.Add(new ObjData { Position = position, Velocity = RandomDirectionVelocity() } );
        }

        _objDatas = objDataList.ToArray();
        _renderParams = new RenderParams(_material) { receiveShadows = true, shadowCastingMode = ShadowCastingMode.On };
    }

    private void Update()
    {
        UpdateByJobSystem();
    }

    private void UpdateByJobSystem()
    {
        var objDataArray = new NativeArray<ObjData>(_instanceCount, Allocator.TempJob);
        var objDataReadArray = new NativeArray<ObjData>(_instanceCount, Allocator.TempJob);
        var objMatrix = new NativeArray<Matrix4x4>(_instanceCount, Allocator.TempJob);

        objDataArray.CopyFrom(_objDatas);
        objDataReadArray.CopyFrom(_objDatas);

        var job = new MoveObjectJob
        {
            objDatas = objDataArray,
            objDatasRead = objDataReadArray,
            objMatrix = objMatrix,
            range = _range,
            deltaTime = Time.deltaTime
        };

        var handler = job.Schedule(_instanceCount, 0);
        handler.Complete();
        
        objDataArray.CopyTo(_objDatas);

        DrawAll(objMatrix);
        
        objDataArray.Dispose();
        objDataReadArray.Dispose();
        objMatrix.Dispose();
    }

    private Vector3 RandomPositionInRange()
    {
        return new Vector3(
            Random.Range(-_range, _range),
            Random.Range(-_range, _range),
            Random.Range(-_range, _range)
        );
    }

    private Vector3 RandomDirectionVelocity()
    {
        return Random.onUnitSphere * _velocity;
    }

    private void DrawAll(NativeArray<Matrix4x4> matricesArray)
    {
        for (int i = 0; i < _instanceCount; i += InstanceCountPerDraw)
        {
            var length = Mathf.Min(InstanceCountPerDraw, _instanceCount - i);
            Graphics.RenderMeshInstanced(_renderParams, _mesh, 0, matricesArray, length, i);
        }
    }

    [BurstCompile]
    private struct MoveObjectJob : IJobParallelFor
    {
        public NativeArray<ObjData> objDatas;
        [ReadOnly] public NativeArray<ObjData> objDatasRead;
        [ReadOnly] public float range;
        [ReadOnly] public float deltaTime;
        [WriteOnly] public NativeArray<Matrix4x4> objMatrix;

        public void Execute(int index)
        {
            SumVelocity(); // MEMO: わざと負荷を上げる
            
            var obj = objDatas[index];

            var diff = obj.Velocity * deltaTime;
            
            if (Mathf.Abs(obj.Position.x + diff.x) >= range) obj.Velocity.x *= -1;
            if (Mathf.Abs(obj.Position.y + diff.y) >= range) obj.Velocity.y *= -1;
            if (Mathf.Abs(obj.Position.z + diff.z) >= range) obj.Velocity.z *= -1;

            obj.Position += obj.Velocity * deltaTime;
            objDatas[index] = obj;

            objMatrix[index] = Matrix4x4.TRS(obj.Position, Quaternion.identity, Vector3.one);
        }
        
        private void SumVelocity()
        {
            var sumVelocity = Vector3.zero;
            var sumPosition = Vector3.zero;

            for (int i = 0; i < objDatasRead.Length; ++i)
            {
                sumVelocity += objDatasRead[i].Velocity;
                sumPosition += objDatasRead[i].Position;
            }
        }
    }
}


実行結果は次のとおりです。(マテリアルを設定する必要があるので、Cubeの色が変わっています)

n = 5000 n = 10000 n = 20000

n = 5000では安定して約90fps、n = 10000でも安定して50fps以上でました。

ただし、n = 20000まで来ると約20fpsになります。


計測結果 まとめ

n = 1000 n = 5000 n = 10000
愚直な実装 + MeshRenderer 13fps - -
JobSystem + Burst + MeshRenderer 80fps 40fps 15fps
JobSystem + Burst + RenderMeshInstanced 140fps 90fps 50fps



まとめ・感想

JobSystemで簡単なサンプルを作ってみましたが、とても取っつきやすい印象でした。実行速度もなかなか良い結果が出たので実用レベルと感じてみます。

また、Graphics.RenderMeshInstancedはNativeArrayをそのまま渡せるので、JobSystemと相性が良いんじゃないかなと思います。

 Graphics.RenderMeshInstanced(_renderParams, _mesh, 0, matricesArray);


計測結果から、極限のパフォーマンスを目指すとき以外はJobSystemでも事足りると感じています。(JobSystemを使うときは極限のパフォーマンスを目指すときかもしれませんが....)

ComputeShaderと比べて、学習コストの低さやC#でコードが書けること、Profilerで実行速度を観測できる点でJobSystemの方が圧倒的に扱いやすいです。


参考

shibuya24.info

shibuya24.info


www.youtube.com

【Boidsアルゴリズム】球体の障害物を回避させるアルゴリズムを考える

目次

始めに

過去のBoidsに関する記事です。Boidsに関してはここではあまり触れないのでご了承ください。

過去記事

 

shitakami.hateblo.jp

shitakami.hateblo.jp

shitakami.hateblo.jp

 



障害物から"逃げる"

始めに障害物から逃げる実装について簡単にお話します。

  1. 障害物の中心座標から個体へのベクトル  \vec{p} を求めて、ベクトルの長さが障害物から逃げる範囲の半径  r_e 以下であるかをチェック
  2. 個体が範囲内である場合はベクトル  \vec{p} を使って斥力を求めて、個体の速度ベクトルに加算する

 

結果

こちらを実装した結果が次のgifになります。見て分かる通り、この実装だけでは"避ける"ではなく"逃げる"となり、障害物へ向かってくる個体はそのままぶつかって跳ね返る挙動をします。


この方法でパラメータを調整したり、斥力を距離に反比例するようにしても避けるような挙動を作るのは難しかったです。



障害物を"回避する"

"逃げる"とは別に"回避する"をBoidsに実装します。

障害物を回避する方法は次のようになります。

  1. 障害物を回避する範囲内にいる、かつ逃げる範囲外にいるかチェック
  2. 前方向に障害物があるかをチェック
  3. 個体の速度ベクトル  \vec{v} を延長して、障害物の中心点との距離  d を求めて障害物から逃げる範囲の半径  r_e 以下であるかをチェック
  4. 1~3の条件を満たした個体の速度ベクトル  \vec{v} と、個体から障害物の中心点へのベクトル  \vec{o} との外積を計算して回転軸を求める。
  5. 回転軸と各速度を組み合わせて、速度ベクトルを回転させる。

 

1. 障害物を回避する範囲内にいる、かつ逃げる範囲外にいるか

こちらは個体から障害物への距離  d を求めて、それが  r_e <  d <=  r_a であるかをチェックします。( r_e : 逃げる範囲、 r_a : 回避範囲 )範囲外の個体は回避処理は適用させません。

 

2. 前方向に障害物があるかをチェック

個体の速度ベクトル  \vec{v} と個体から障害物へのベクトル  \vec{o} との内積  dot(\vec{v}, \vec{o}) を求めます。(内積を求める前に2つのベクトルを正規化する。) 内積が0以下の場合は  \vec{v} \vec{o} がなす角が鈍角になり、前方に障害物がない状態になります。なので、この場合は回避処理は行いません。

 

3. 個体の速度ベクトル  \vec{v} を延長して、障害物の中心点との距離  d を求めて障害物から逃げる範囲の半径  r_e 以下であるかをチェック

個体の速度ベクトルをまっすぐ延ばして直線を作ります。次に、障害物の中心点Oから直線に向けて垂直な線分OPを作って、その線分の距離  d を求めます。

この距離  d が逃げる範囲  r_e 以下であるかをチェックします。

 

こちらの計算は次のように行います。

  1. 個体の速度ベクトル  \vec{v} と 個体から障害物の中心点へのベクトル  \vec{o} との外積  cross(\vec{v}, \vec{o}) を求める
  2. 求めた外積のベクトルの長さ  length を計算して、速度ベクトルの長さ  |\vec{v}| で割って距離  dを求める
  3. 距離  d r_e 以下であるかチェックする

上記を1つの式にまとめますと次にようになります。


d =  \displaystyle\frac{ |cross(\vec{v}, \vec{o})| }{|\vec{v}|}

 

外積で求められるベクトルの大きさは2つのベクトルが生成する平行四辺形の面積になる性質があります。 (参考 : 「外積の長さ = 平行四辺形の面積」 証明  - 理数アラカルト -

次に底辺をなすベクトル  \vec{v} の大きさで割ることで平行四辺形の高さ = 中心点からの直線への距離を求められます。

 

4, 5. 回転軸を求めて速度ベクトルを回転させる

これまでの条件を満たした個体に対して障害物を避けるような回転を加えます。

回転軸は3.で求めた外積の単位ベクトル  \vec{A} になります。この回転軸と各速度を組み合わせて回転を生成します。

回転の生成ではロドリゲスの回転公式を使っています。(参考 : 3D数学の復習と実践(ロドリゲスの回転公式) - なおしのこれまで、これから

 

後は個体の速度ベクトルに回転を適用することで、障害物を避けるような挙動ができます。

 

コード

以上の実装はComputeShaderで行っております。 参考程度にして頂けると助かります。

inline float3x3 CalcAvoidObstacleTorque(const float3 position, const float3 velocity)
{
    const float3 diff = _ObstaclePosition.xyz - position;
    const float distance = sqrt(dot(diff, diff));
    
    if(distance > _AvoidObstacleRadius || _EscapeObstacleRadius > distance) // 障害物を避ける範囲内にいるか?
    {
        return float3x3(float3(1, 0, 0), float3(0, 1, 0), float3(0, 0, 1));
    }

    const float3 target2ObstacleDirection = normalize(diff);
    const float3 forward = normalize(velocity);
    const float directionDot = dot(target2ObstacleDirection, forward);

    if(directionDot < 0) // 前方向に障害物がないか
    {
        return float3x3(float3(1, 0, 0), float3(0, 1, 0), float3(0, 0, 1));
    }

    const float3 forward2DiffCross = cross(forward, diff);
    const float forward2ObstacleDistance = length(forward2DiffCross);

    if(forward2ObstacleDistance > _EscapeObstacleRadius) // 障害物から逃げる範囲と速度ベクトルの直線が重なっていないか
    {
        return float3x3(float3(1, 0, 0), float3(0, 1, 0), float3(0, 0, 1));
    }

    const float crossElementSum = forward2DiffCross.x + forward2DiffCross.y + forward2DiffCross.z;
    const float3 axis = crossElementSum != 0 ? normalize(forward2DiffCross) : float3(0, 1, 0);
    
    return CalcRotateMatrixByAxis(axis, _AvoidObstacleAngularVelocity);
}


....
....

const float3x3 avoidTorque = CalcAvoidObstacleTorque(boidData.position, velocity);
const float3 avoidVelocity = mul(velocity, avoidTorque);

 

結果

見やすいようにBoidsの行動範囲を2次元にしています。 分かれずに球体に向かっていく個体は障害物を上下方向に避けようとして詰まっています。

 

応用

障害物を複数追加して動作するようにした結果は次にようになりました。 (はっきりした方法ではないですが、複数の障害物を回避する場合は回転軸を合成しています。)

 

障害物で輪っかを作ったら真ん中の穴を通ってくれました。



考察

問題点

この方法で障害物を組み合わせて壁や窪みを作った場合は上手く避けてくれません。(回避先については考慮されていない) この方法で避けられるのは線状のものに限られるでしょう。

壁や窪みが静的なオブジェクトであれば前回のような力場を一度だけ計算して、衝突判定させるのが良いかもしれません。

shitakami.hatenablog.com

 

複数の障害物に対しての厳密な回避を考える

複数の障害物が接触している場合は、1つの球体に合成して回避する方法もありかもしれません。(しかし、障害物の穴などを塞いでしまうかも)

一番良いと考えているのが、速度ベクトルをずらして複数のRayを飛ばして障害物に当たらないor障害物との交点が最も遠い方向に逃げるようにするのが良いかもしれません。

この方法についてはこちらの動画がとても参考になると思います。

www.youtube.com



最後に

今回は前回できなかったBoidsの回避について実装と考察を行いました。

成果物としては自分の求める要件を満たすものになったので、考察で述べた内容についてはまた遠い将来になると思われます。

これからはこの機能を作りたいコンテンツで使っていく予定です。



参考

今回のBoidsの実装は「Unity Gracphics Programming vol.1」を元にしています。

UnityGraphicsProgramming-vol1.pdf - Google ドライブ

 

回避のアルゴリズムを考えるうえでこちらの本の計算幾何学の内容を参考にしています。