ゲーム開発
Unity
UnrealEngine
C++
Blender
ゲーム数学
ゲームAI
グラフィックス
サウンド
アニメーション
GBDK
制作日記
IT関連
ツール開発
フロントエンド関連
サーバサイド関連
WordPress関連
ソフトウェア設計
おすすめ技術書
音楽
DTM
楽器・機材
ピアノ
ラーメン日記
四コマ漫画
その他
おすすめアイテム
おもしろコラム
  • ゲーム開発
    • Unity
    • UnrealEngine
    • C++
    • Blender
    • ゲーム数学
    • ゲームAI
    • グラフィックス
    • サウンド
    • アニメーション
    • GBDK
    • 制作日記
  • IT関連
    • ツール開発
    • フロントエンド関連
    • サーバサイド関連
    • WordPress関連
    • ソフトウェア設計
    • おすすめ技術書
  • 音楽
    • DTM
    • 楽器・機材
    • ピアノ
  • ラーメン日記
    • 四コマ漫画
      • その他
        • おすすめアイテム
        • おもしろコラム
      1. ホーム
      2. 20240528_01_unity_boids

      【Unity】Boidsアルゴリズムを用いて魚の群集シミュレーションを実装する

      Unityグラフィックス群集シミュレーションGPGPUGPUインスタンシング
      2024-05-28

      マイケル
      マイケル
      みなさんこんにちは! マイケルです!
      エレキベア
      エレキベア
      こんにちクマ〜〜〜
      マイケル
      マイケル
      今回は久しぶりのUnityです! 下記のような魚の群集シミュレーションを作ってみました!
      20240528_01_unity_boids_02
      ▲魚の群れができている

      20240528_01_unity_boids_06
      ▲シミュレーション空間

      エレキベア
      エレキベア
      おお〜〜〜 なんかすごいクマ〜〜〜〜
      マイケル
      マイケル
      これはBoidsアルゴリズムという有名な手法でシミュレーションしたものなんだ! 実装は下記のUnityGraphicsProgrammingを参考に実装しました!
      20240528_01_unity_boids_01

      UnityGraphicsProgramming vol.1

      エレキベア
      エレキベア
      UnityGraphicsProgrammingは無料公開されていて 情報も膨大だからほんとにありがたいクマね
      マイケル
      マイケル
      今回実装したプロジェクトは下記リポジトリにもあげています! こちらも合わせてご参照ください!

      GitHub - unity-boids-simulation
      ▲サンプルプロジェクトのリポジトリ

      • Unityバージョン
        • 2022.3.16f
      エレキベア
      エレキベア
      解説楽しみクマ〜〜〜

      参考書籍

      マイケル
      マイケル
      Boidsアルゴリズムはグラフィックス分野の他、ゲームAI分野でもよく取り上げられています。 UnityGraphicsProgrammingの他に、下記書籍も参考にさせていただきました!

      Unreal Engine 5で学ぶビジュアルエフェクト実装 基本機能からNia...

      実例で学ぶゲームAIプログラミング

      エレキベア
      エレキベア
      定番のアルゴリズムなのクマね
      マイケル
      マイケル
      また、今回ComputeShaderなどHLSLを使用したシェーダーも実装します。 HLSLについては下記書籍が体系的にまとまっていておすすめです!

      HLSL シェーダーの魔導書 シェーディングの基礎からレイトレーシングまで

      エレキベア
      エレキベア
      これもいい書籍だったクマね

      Boidsアルゴリズムとは

      マイケル
      マイケル
      まずBoidsアルゴリズムとは何かというと、 1987年にSIGGRAPHにて発表された群集シミュレーションアルゴリズムです。
      エレキベア
      エレキベア
      英語でよく分からんクマ...
      マイケル
      マイケル
      内容について簡単に説明すると、 分離(Separation)、整列(Alignment)、結合(Cohesion)という3つの規則により群れのような動きを再現するアルゴリズムになっています。 これらの規則により加える力を合わせて操舵力とも消されます。
      Boidsアルゴリズムとは
      • 1987年に発表された群集シミュレーションアルゴリズム
      • 分離(Separation)、整列(Alignment)、結合(Cohesion)という3つのルールにより群れのような動きを再現する
      • これらのルールにより加える力を合わせて操舵力とも呼ぶ
      20240528_01_unity_boids_08
      ▲Boidsアルゴリズムの規則(分離、整列、結合)

      エレキベア
      エレキベア
      ほうほう・・・ 聞いた感じだとシンプルクマね
      マイケル
      マイケル
      それではこの分離、整列、結合のそれぞれのルールについて内容を説明します。

      分離(Separation)

      マイケル
      マイケル
      1つ目の分離は、ある一定の距離内にある個体と密集することを避けるように動く規則です。
      エレキベア
      エレキベア
      近づきすぎたら離れるということクマね
      20240528_01_unity_boids_09
      ▲近づきすぎたら離れる

      マイケル
      マイケル
      これをコードで書くと下記のようなイメージになります。 各個体との距離を調べて、一定距離以下の個体との位置差分の平均を力として計算しています。
          for (uint blockIndex = 0; blockIndex < (uint) _MaxBoidNum; blockIndex += SIMULATION_BLOCK_SIZE)
          {
      
            ・・・略・・・
      
                  // 分離: 近づきすぎたら離れる
                  if (distance <= _SeparationDistance)
                  {
                      separationPositionSum += diffPosition;
                      separationCount++;
                  }
      
                  ・・・略・・・
      
          }
      
          ・・・略・・・
      
          if (separationCount > 0)
          {
              const float3 separationPosition = separationPositionSum / (float) separationCount;
              force += separationPosition * _SeparationCoefficient;
          }
      
      ▲分離の判定
      エレキベア
      エレキベア
      簡単な計算クマね

      整列(Alignment)

      マイケル
      マイケル
      2つ目の結合は、ある一定の距離内にある個体が向いている方向の平気に向かおうと動く規則です。
      エレキベア
      エレキベア
      みんなで同じ方向に向かうのクマね
      20240528_01_unity_boids_10
      ▲同じ方向に向かう

      マイケル
      マイケル
      コードのイメージは下記のようになります。 分離の時と似ていますが、平均を取るのは位置ではなく各個体の速度となっています。
          for (uint blockIndex = 0; blockIndex < (uint) _MaxBoidNum; blockIndex += SIMULATION_BLOCK_SIZE)
          {
      
            ・・・略・・・
      
                  // 整列: 近くの向きに合わせる
                  if (distance <= _AlignmentDistance)
                  {
                      alignmentVelocitySum += targetVelocity;
                      alignmentCount++;
                  }
      
                  ・・・略・・・
      
          }
      
          ・・・略・・・
      
          if (alignmentCount > 0)
          {
              const float3 alignmentVelocity = alignmentVelocitySum / (float) alignmentCount;
              force += alignmentVelocity * _AlignmentCoefficient;
          }
      
      ▲整列の判定
      エレキベア
      エレキベア
      どっちも同じような計算クマね

      結合(Cohesion)

      マイケル
      マイケル
      最後に整列は、ある一定の距離内にある個体の平均位置に動く規則です。
      エレキベア
      エレキベア
      分値と違うのは複数個体の平均位置に動くということクマか・・・
      20240528_01_unity_boids_11
      ▲複数個体の平均位置に動く

      マイケル
      マイケル
      コードで書くと下記のようなイメージになります。 平均位置を求めて、自身の位置との差分を力として加えています。
          for (uint blockIndex = 0; blockIndex < (uint) _MaxBoidNum; blockIndex += SIMULATION_BLOCK_SIZE)
          {
      
            ・・・略・・・
      
                  // 結合: 近くのboidsの重心に近づく
                  if (distance <= _CohesionDistance)
                  {
                      cohesionPositionSum += targetPosition;
                      cohesionCount++;
                  }
      
          }
      
          ・・・略・・・
      
          if (cohesionCount > 0)
          {
              const float3 cohesionPosition = cohesionPositionSum / (float) cohesionCount;
              force += (cohesionPosition - inPosition) * _CohesionCoefficient;
          }
      
      ▲結合の判定
      エレキベア
      エレキベア
      これで3つの力が計算できたクマ〜〜

      最終的な位置の計算

      マイケル
      マイケル
      計算した力を操舵力として位置の計算を行います。 こちらの処理は下記のようになっていて、合わせてシミュレーション範囲からはみ出さないよう境界処理も挟んでいます。
      [numthreads(SIMULATION_BLOCK_SIZE, 1, 1)]
      void IntegrateCS(uint3 dtId : SV_DispatchThreadID)
      {
          // 現在のデータを取得
          const unsigned int inIndex = dtId.x;
          BoidData inData = _BoidDataBufferWrite[inIndex];
          float3 force = _BoidForceBufferRead[inIndex];
      
          // 境界処理: 壁際に来たら反発力を加える
          float3 inPosition = inData.position;
          float3 wc = _SimulationAreaCenter.xyz;
          float3 ws = _SimulationAreaSize.xyz;
          float3 avoidance = float3(0, 0, 0);
          avoidance.x = (inPosition.x < wc.x - ws.x * 0.5) ? avoidance.x + 1.0 : avoidance.x;
          avoidance.x = (inPosition.x > wc.x + ws.x * 0.5) ? avoidance.x - 1.0 : avoidance.x;
          avoidance.y = (inPosition.y < wc.y - ws.y * 0.5) ? avoidance.y + 1.0 : avoidance.y;
          avoidance.y = (inPosition.y > wc.y + ws.y * 0.5) ? avoidance.y - 1.0 : avoidance.y;
          avoidance.z = (inPosition.z < wc.z - ws.z * 0.5) ? avoidance.z + 1.0 : avoidance.z;
          avoidance.z = (inPosition.z > wc.z + ws.z * 0.5) ? avoidance.z - 1.0 : avoidance.z;
          force += avoidance * _AvoidWallWeight;
      
          // 操舵力を速度に適用して位置を更新
          inData.velocity += force * _DeltaTime;
          inData.velocity = limit(inData.velocity, _MaxSpeed);
          inData.position += inData.velocity * _DeltaTime;
      
          // 計算結果を書き込む
          _BoidDataBufferWrite[inIndex] = inData;
      }
      
      ▲最終的な位置の計算
      エレキベア
      エレキベア
      これでシミュレーションの流れが分かったクマね

      Boids計算の注意点

      マイケル
      マイケル
      計算方法自体はシンプルですが、個体ごとに他の個体との判定処理を行う必要があるため、大量オブジェクトでシミュレーションする場合の計算不可が高いという注意点があります。
      エレキベア
      エレキベア
      単純に考えてN^2の計算量になるクマね・・・
      マイケル
      マイケル
      空間分割等で計算量を抑えることもできますがそれでも計算が多いことには代わりないため、GPUによる並列計算を行う場合が多いです。 今回は シミュレーション計算:ComputeShader オブジェクト描画:GPUインスタンシング の手法をそれぞれ使用しています。
      エレキベア
      エレキベア
      これからUnityでその実装を行なっていくクマね

      Boidsシミュレーションの実装

      マイケル
      マイケル
      それではBoidsアルゴリズムを実現するためにUnityで実装していきます。 先ほど記載した通り、 シミュレーション計算:ComputeShader オブジェクト描画:GPUインスタンシング の手法をそれぞれ使用します。

      GPUインスタンシングとComputeShader

      ComputeShader
      マイケル
      マイケル
      ComputeShaderは、描画処理以外の汎用的な計算をGPUに行わせるためのものです。 GPUに計算させることで並列計算が高速に行えるので、今回のような大量オブジェクトの計算に非常に向いています。
      ComputeShaderとは
      • 描画処理以外の汎用的な計算をGPUに行わせるためのもの(GPGPU)
      マイケル
      マイケル
      ComputeShaderの詳細やUnityでの使い方については下記記事でも紹介していますので、 こちらもよければご参照ください!
      【Unity】ComputeShaderの基本的な使い方についてまとめる
      2023-01-07
      【書籍紹介】「ゲーム制作者になるための3Dグラフィックス技術」に出てくる用語を簡潔にまとめる
      2022-12-15
      エレキベア
      エレキベア
      ComputeShaderは並列計算による効率化が測れるクマね
      GPUインスタンシング
      マイケル
      マイケル
      GPUインスタンシングとは不要なGameObjectの生成を行うことなく、同じメッシュのコピーを一度に描画できる手法のことです。 その結果、ドローコールの数が軽減され、大量オブジェクトを高速に描画できるようになります。

      参考:
      Unity Documentation - GPUインスタンシング

      GPUインスタンシングとは
      • 不要なGameObjectの生成を行うことなく、同じメッシュのコピーを一度に描画できる手法
      • 使用されるドローコールの数を減らすことができる
      エレキベア
      エレキベア
      GPUに直接描画命令を出せるクマね GameObjectの機能を使わないのであればその方がよさそうクマ
      マイケル
      マイケル
      GPUインスタンシングは下記手順で使用することができます。
      1. GPUインスタンシングに対応したシェーダーを書く
      2. マテリアル上で有効にする
      3. RenderMesh(DrawMesh)系のAPIを介して描画する
      マイケル
      マイケル
      RenderMesh(DrawMesh)系のAPIについては何種類もありすぎてどれを使えばいいのか分かりづらいのですが、 大体下記のような区分になっていると思います。

      参考:
      Unity Documentation - Graphics

      RenderMesh関連のAPIについて
       
      Unity2021未満
      Unity2022以降
      単一メッシュの描画
      DrawMesh
      RenderMesh
      複数メッシュの描画
      (一度に1023個まで)
      DrawMeshInstanced
      RenderMeshInstanced
      複数メッシュの描画
      (ComputeShaderサポート、引数バッファ渡し)
      DrawMeshInstancedIndirect
      RenderMeshIndirect
      複数メッシュの描画
      (ComputeShaderサポート、インスタンス数直接指定)
      DrawMeshInstancedProcedural
      RenderMeshPrimitives
      エレキベア
      エレキベア
      複数メッシュ描画の中でもComputeShaderを使用するものとそうでないものがあるクマね
      マイケル
      マイケル
      Unity公式の下記動画でも紹介されている通り、Unity2022以降は「DrawMeshXXX」から「RenderMeshXXX」という名称に変更されたらしく、基本的にあそちらを使用するようにした方がよさそうです。
      マイケル
      マイケル
      今回はComputeShaderを使用する前提のため、「RenderMeshIndirect」関数を使用する方向で進めました。
      エレキベア
      エレキベア
      これで実装方針は決まったクマね

      GPUインスタンシングによる描画

      マイケル
      マイケル
      それでは早速実装の紹介に入っていきます。 まずはGPUインスタンシングによる描画の部分からです。
      GPUインスタンシングに対応したシェーダーを書く
      マイケル
      マイケル
      まずはGPUインスタンシングの対応したシェーダーを書く必要があるのですが、今回はSurfaceShaderを使用しているため記述も最小限となっています。
      マイケル
      マイケル
      対応が必要な箇所としては ・instancing_optionsでprocedural関数の指定を行う ・「unity_InstanceID」(各個体ごとのID)を使用して頂点座標の計算を行う ・「UNITY_PROCEDURAL_INSTANCING_ENABLED」(RenderMeshIndirectで呼び出す時に有効になるdefine)で頂点座標の計算部分を囲む の3点です。

      Unity Documentation - Graphics.RenderMeshIndirect

      // GPUインスタンシングによる描画(3D)
      Shader "Custom/Boids3DRender"
      {
          Properties
          {
              _Color ("Color", Color) = (1,1,1,1)
              _MainTex ("Albedo (RGB)", 2D) = "white" {}
          }
          SubShader
          {
              Tags {
              	"RenderType" = "Opaque"
              }
              LOD 200
      
              // 裏面描画
              Cull Off
      
              CGPROGRAM
              #pragma surface surf Standard vertex:vert SimpleLambert
              #pragma instancing_options procedural:setup
              #pragma target 3.0
      
              struct Input
              {
                  float2 uv_MainTex;
              };
      
              sampler2D _MainTex;
              fixed4 _Color;
      
              // Boidオブジェクトの大きさ
              float3 _BoidScale;
      
              // Boidデータの構造体
              struct BoidData
              {
                  float3 velocity; // 速度
                  float3 position; // 位置
              };
      
              #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
              StructuredBuffer<BoidData> _BoidDataBuffer;
              #endif
      
      		// 角度を回転行列に変換(3D)
              // (クォータニオンによる計算の方がいいかも?)
      		float4x4 radianToRotationMatrix(float3 angles)
      		{
      			const float cy = cos(angles.y); float sy = sin(angles.y);
      			const float cx = cos(angles.x); float sx = sin(angles.x);
      			const float cz = cos(angles.z); float sz = sin(angles.z);
      			// Ry-Rx-Rz (Yaw Pitch Roll)
      			return float4x4(
      				cy * cz + sy * sx * sz, -cy * sz + sy * sx * cz, sy * cx, 0,
      				cx * sz, cx * cz, -sx, 0,
      				-sy * cz + cy * sx * sz, sy * sz + cy * sx * cz, cy * cx, 0,
      				0, 0, 0, 1
      			);
      		}
      
              void setup()
      		{
      		}
      
              void vert(inout appdata_full v)
              {
              	#ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
      
              	// Boidデータを取得
              	BoidData boidData = _BoidDataBuffer[unity_InstanceID];
              	float3 boidPosition = boidData.position.xyz;
              	float3 boidScale = _BoidScale;
      
              	// オブジェクト座標 -> ワールド座標に変換する行列を定義
              	float4x4 object2world = (float4x4) 0;
      
      			// scale
              	object2world._11_22_33_44 = float4(boidScale.xyz, 1.0);
      
              	// rotation
              	float rotX = -asin(boidData.velocity.y / (length(boidData.velocity.xyz) + 1e-8));
              	float rotY = atan2(boidData.velocity.x, boidData.velocity.z);
      	        float4x4 rotMatrix = radianToRotationMatrix(float3(rotX, rotY, 0));
              	object2world = mul(rotMatrix, object2world);
      
      			// position
              	object2world._14_24_34 += boidPosition.xyz;
      
              	// 行列から頂点、法線を設定
              	v.vertex = mul(object2world, v.vertex);
              	v.normal = normalize(mul(object2world, v.normal));
      
              	#endif
              }
      
              void surf (Input IN, inout SurfaceOutputStandard o)
              {
                  fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
      			clip(c.a - 0.1); // 透明部分は非表示
                  o.Albedo = c.rgb;
              }
              ENDCG
          }
          FallBack "Diffuse"
      }
      
      
      ▲受け取った情報から頂点座標を計算して描画する
      エレキベア
      エレキベア
      「_BoidDataBuffer」が受け取る計算データで、それを「unity_InstanceID」から該当のデータを取得して計算しているクマね
      マテリアル上で有効にする
      マイケル
      マイケル
      そしてこのシェーダーを設定したマテリアルの「Enable GPU Instancing」フラグをONにすることでGPUインスタンシングが有効になります。
      20240528_01_unity_boids_03
      ▲GPUインスタンシングを有効にする

      エレキベア
      エレキベア
      まさかそんなところにフラグがあったとは・・・
      RenderMeshIndirectで描画する
      マイケル
      マイケル
      あとはC#側でRenderMeshIndirect関数を使用して描画処理を実装すれば完了です! こちらはGraphicsBufferで生成したバッファと、RenderParamsにデータを設定して渡すことで描画することができます。
              /// <summary>
              /// GPUインスタンシングのための引数データ
              /// </summary>
              protected GraphicsBuffer.IndirectDrawIndexedArgs[] _gpuInstanceArgs;
      
              /// <summary>
              /// GPUインスタンシングのための引数バッファ
              /// </summary>
              protected GraphicsBuffer _gpuInstanceArgsBuffer;
      
      ・・・略・・・
      
              /// <summary>
              /// バッファ初期化
              /// </summary>
              private void InitBuffer()
              {
                  // GraphicsBuffer.Target: https://docs.unity3d.com/ScriptReference/GraphicsBuffer.Target.html
                  var commandCount = 1;
                  _gpuInstanceArgs = new GraphicsBuffer.IndirectDrawIndexedArgs[commandCount];
                  _gpuInstanceArgsBuffer = new GraphicsBuffer(GraphicsBuffer.Target.IndirectArguments, commandCount, GraphicsBuffer.IndirectDrawIndexedArgs.size);
              }
      
              /// <summary>
              /// メッシュ描画
              /// </summary>
              protected abstract void OnRenderMesh();
      
              /// <summary>
              /// バッファ解放
              /// </summary>
              private void ReleaseBuffer()
              {
                  if (_gpuInstanceArgsBuffer != null)
                  {
                      _gpuInstanceArgsBuffer.Release();
                      _gpuInstanceArgsBuffer = null;
                  }
              }
      
      ▲GPUインスタンシングのための引数バッファを定義
              /// <summary>
              /// メッシュ描画
              /// https://docs.unity3d.com/ScriptReference/Graphics.RenderMeshIndirect.html
              /// </summary>
              protected override void OnRenderMesh()
              {
                  if (_boidsRenderMesh == null
                      || _boidsRenderMaterial == null
                      || !SystemInfo.supportsInstancing)
                  {
                      return;
                  }
      
                  // シミュレーションデータの取得
                  uint maxBoidsCount = (uint) _boids3DSimulation.MaxBoidsNum;
                  GraphicsBuffer boidsDataBuffer = _boids3DSimulation.BoidsDataBuffer;
                  Vector3 simulationAreaCenter = _boids3DSimulation.SimulationAreaCenter;
                  Vector3 simulationAreaSize = _boids3DSimulation.SimulationAreaSize;
      
                  // 引数バッファにインデックス数、生成数を設定
                  var numIndices = _boidsRenderMesh.GetIndexCount(0);
                  _gpuInstanceArgs[0].indexCountPerInstance = numIndices;
                  _gpuInstanceArgs[0].instanceCount = maxBoidsCount;
                  _gpuInstanceArgsBuffer.SetData(_gpuInstanceArgs);
      
                  // マテリアルに描画用のデータを設定
                  var renderParams = new RenderParams(_boidsRenderMaterial);
                  renderParams.matProps = new MaterialPropertyBlock();
                  renderParams.matProps.SetBuffer("_BoidDataBuffer", boidsDataBuffer);
                  renderParams.matProps.SetVector("_BoidScale", _boidScale);
                  renderParams.worldBounds = new Bounds(simulationAreaCenter, simulationAreaSize);
      
                  // メッシュ描画
                  Graphics.RenderMeshIndirect(
                      renderParams,
                      _boidsRenderMesh,
                      _gpuInstanceArgsBuffer);
              }
      
      ▲RenderMeshIndirect関数で描画する(シミュレーションデータはこれから計算する)
      エレキベア
      エレキベア
      確かGraphicsBufferはComputeBufferの後継的な立ち位置だったクマね 必要なデータの設定方法はリファレンスやサンプルを見るしかなさそうクマ

      群集シミュレーションの実装

      マイケル
      マイケル
      あとはシミュレーション計算を行えば群集シミュレーションの実装は完了です。 こちらはComputeShaderで下記のように実装しています。 冒頭で紹介した分離・整列・結合の判定を行い、操舵力として計算しています。
      // Boidデータ格納用シェアードメモリ
      groupshared BoidData shared_boid_data[SIMULATION_BLOCK_SIZE];
      
      [numthreads(SIMULATION_BLOCK_SIZE, 1, 1)]
      void ForceCS(uint3 dtId : SV_DispatchThreadID, uint groupIndex: SV_GroupIndex)
      {
          // 現在のデータを取得
          const unsigned int inIndex = dtId.x;
          const float3 inPosition = _BoidDataBufferRead[inIndex].position;
      
          // 操舵力計算用の変数を初期化
          float3 separationPositionSum = float3(0.0, 0.0, 0.0);
          float3 alignmentVelocitySum = float3(0.0, 0.0, 0.0);
          float3 cohesionPositionSum = float3(0.0, 0.0, 0.0);
          int separationCount = 0;
          int alignmentCount = 0;
          int cohesionCount = 0;
      
          [loop]
          for (uint blockIndex = 0; blockIndex < (uint) _MaxBoidNum; blockIndex += SIMULATION_BLOCK_SIZE)
          {
              // ブロックサイズ分のBoidデータをシェアードメモリに格納
              shared_boid_data[groupIndex] = _BoidDataBufferRead[blockIndex + groupIndex];
              GroupMemoryBarrierWithGroupSync(); // グループ内のスレッドが全て到着するまで待機
      
              // シェアードメモリから全てのブロック内のオブジェクトをチェック
              for (int targetBoidIndex = 0; targetBoidIndex < SIMULATION_BLOCK_SIZE; targetBoidIndex++)
              {
                  const float3 targetPosition = shared_boid_data[targetBoidIndex].position;
                  const float3 targetVelocity = shared_boid_data[targetBoidIndex].velocity;
      
                  // 自身との距離を取得
                  const float3 diffPosition = inPosition - targetPosition;
                  const float distance = sqrt(dot(diffPosition, diffPosition));
                  if (distance <= 0.0)
                  {
                      continue;
                  }
      
                  // 分離: 近づきすぎたら離れる
                  if (distance <= _SeparationDistance)
                  {
                      separationPositionSum += diffPosition;
                      separationCount++;
                  }
      
                  // 整列: 近くの向きに合わせる
                  if (distance <= _AlignmentDistance)
                  {
                      alignmentVelocitySum += targetVelocity;
                      alignmentCount++;
                  }
      
                  // 結合: 近くのboidsの重心に近づく
                  if (distance <= _CohesionDistance)
                  {
                      cohesionPositionSum += targetPosition;
                      cohesionCount++;
                  }
              }
      
              // グループ内のスレッドが全て到着してから次のグループへ進む
              GroupMemoryBarrierWithGroupSync();
          }
      
          // 平均を取り加える力を計算する
          float3 force = float3(0, 0, 0);
          if (separationCount > 0)
          {
              const float3 separationPosition = separationPositionSum / (float) separationCount;
              force += separationPosition * _SeparationCoefficient;
          }
          if (alignmentCount > 0)
          {
              const float3 alignmentVelocity = alignmentVelocitySum / (float) alignmentCount;
              force += alignmentVelocity * _AlignmentCoefficient;
          }
          if (cohesionCount > 0)
          {
              const float3 cohesionPosition = cohesionPositionSum / (float) cohesionCount;
              force += (cohesionPosition - inPosition) * _CohesionCoefficient;
          }
      
          // 最終的な結果を書き込み
          force = limit(force, _MaxSteerForce);
          _BoidForceBufferWrite[inIndex] = force;
      }
      
      ▲操舵力の計算処理
      マイケル
      マイケル
      計算内容は冒頭で紹介したものと同じですが、計算を効率化するためにスレッドグループ共有メモリを使用しています。 こちらはスレッドグループ内で共有・高速アクセスできるメモリで、データを一括で格納した後に参照することで効率化することができます。

      参考:
      【Unity】実践!Compute Shaderを最適化してみよう - Zenn

      エレキベア
      エレキベア
      GPUの仕組みを活用した処理クマね
      マイケル
      マイケル
      計算した操舵力は下記のように別カーネルで速度に適用して位置を更新しています。
      [numthreads(SIMULATION_BLOCK_SIZE, 1, 1)]
      void IntegrateCS(uint3 dtId : SV_DispatchThreadID)
      {
          // 現在のデータを取得
          const unsigned int inIndex = dtId.x;
          BoidData inData = _BoidDataBufferWrite[inIndex];
          float3 force = _BoidForceBufferRead[inIndex];
      
          // 境界処理: 壁際に来たら反発力を加える
          float3 inPosition = inData.position;
          float3 wc = _SimulationAreaCenter.xyz;
          float3 ws = _SimulationAreaSize.xyz;
          float3 avoidance = float3(0, 0, 0);
          avoidance.x = (inPosition.x < wc.x - ws.x * 0.5) ? avoidance.x + 1.0 : avoidance.x;
          avoidance.x = (inPosition.x > wc.x + ws.x * 0.5) ? avoidance.x - 1.0 : avoidance.x;
          avoidance.y = (inPosition.y < wc.y - ws.y * 0.5) ? avoidance.y + 1.0 : avoidance.y;
          avoidance.y = (inPosition.y > wc.y + ws.y * 0.5) ? avoidance.y - 1.0 : avoidance.y;
          avoidance.z = (inPosition.z < wc.z - ws.z * 0.5) ? avoidance.z + 1.0 : avoidance.z;
          avoidance.z = (inPosition.z > wc.z + ws.z * 0.5) ? avoidance.z - 1.0 : avoidance.z;
          force += avoidance * _AvoidWallWeight;
      
          // 操舵力を速度に適用して位置を更新
          inData.velocity += force * _DeltaTime;
          inData.velocity = limit(inData.velocity, _MaxSpeed);
          inData.position += inData.velocity * _DeltaTime;
      
          // 計算結果を書き込む
          _BoidDataBufferWrite[inIndex] = inData;
      }
      
      
      ▲操舵力を位置に適用
      エレキベア
      エレキベア
      シミュレーション範囲に収まるように制御も加えているのだったクマね
      マイケル
      マイケル
      最後に、下記のように必要な情報を設定してカーネルを呼び出すようにすれば実装は完了です!
              /// <summary>
              /// シミュレーション実行
              /// </summary>
              protected override void OnSimulation()
              {
                  ComputeShader cs = _boidsCs;
                  var csId = -1;
      
                  // スレッドグループ数
                  var threadGroupSize = Mathf.CeilToInt(MaxBoidsNum / SimulationBlockSize);
      
                  // 操舵力の計算
                  csId = cs.FindKernel("ForceCS");
                  cs.SetInt("_MaxBoidNum", MaxBoidsNum);
                  cs.SetFloat("_MaxSpeed", _maxSpeed);
                  cs.SetFloat("_MaxSteerForce", _maxSteerForce);
                  cs.SetFloat("_SeparationDistance", _separationDistance);
                  cs.SetFloat("_AlignmentDistance", _alignmentDistance);
                  cs.SetFloat("_CohesionDistance", _cohesionDistance);
                  cs.SetFloat("_SeparationCoefficient", _separationCoefficient);
                  cs.SetFloat("_AlignmentCoefficient", _alignmentCoefficient);
                  cs.SetFloat("_CohesionCoefficient", _cohesionCoefficient);
                  cs.SetVector("_SimulationAreaCenter", SimulationAreaCenter);
                  cs.SetVector("_SimulationAreaSize", SimulationAreaSize);
                  cs.SetFloat("_AvoidWallWeight", _avoidWallWeight);
                  cs.SetBuffer(csId, "_BoidDataBufferRead", _boidsDataBuffer);
                  cs.SetBuffer(csId, "_BoidForceBufferWrite", _boidsForceBuffer);
                  cs.Dispatch(csId, threadGroupSize, 1, 1);
      
                  // 速度と位置を計算
                  csId = cs.FindKernel("IntegrateCS");
                  cs.SetFloat("_DeltaTime", Time.deltaTime);
                  cs.SetBuffer(csId, "_BoidDataBufferWrite", _boidsDataBuffer);
                  cs.SetBuffer(csId, "_BoidForceBufferRead", _boidsForceBuffer);
                  cs.Dispatch(csId, threadGroupSize, 1, 1);
              }
      
      ▲必要なデータを設定してカーネルを呼び出す
      // Boidデータの構造体
      struct BoidData
      {
          float3 velocity; // 速度
          float3 position; // 位置
      };
      
      // スレッドグループのスレッドサイズ
      #define SIMULATION_BLOCK_SIZE 256
      
      // 構造体データのバッファ
      StructuredBuffer<BoidData> _BoidDataBufferRead;
      RWStructuredBuffer<BoidData> _BoidDataBufferWrite;
      
      // 操舵力のバッファ
      StructuredBuffer<float3> _BoidForceBufferRead;
      RWStructuredBuffer<float3> _BoidForceBufferWrite;
      
      // スクリプトから受け取るパラメータ郡
      int _MaxBoidNum;  // Boid最大オブジェクト数
      float _DeltaTime; // 前フレームから経過した時間
      
      float _MaxSpeed;       // 速度の最大値
      float _MaxSteerForce;  // 操舵する力の最大値
      
      float _SeparationDistance; // 分離: 適用する他の個体との距離
      float _AlignmentDistance;  // 整列: 適用する他の個体との距離
      float _CohesionDistance;   // 結合: 適用する他の個体との距離
      
      float _SeparationCoefficient; // 分離: 適用時の重み係数
      float _AlignmentCoefficient;  // 整列: 適用時の重み係数
      float _CohesionCoefficient;   // 結合: 適用時の重み係数
      
      float4 _SimulationAreaCenter; // シミュレーション範囲の中心座標
      float4 _SimulationAreaSize;   // シミュレーション範囲のサイズ
      float _AvoidWallWeight;       // 壁を避ける強さの重み
      
      ▲シミュレーションに必要なデータの定義
      エレキベア
      エレキベア
      やったクマ〜〜〜〜

      自作の魚オブジェクトの設定

      マイケル
      マイケル
      仕上げとして、自分で作成した魚モデルを群集として泳がせてみます。 今回はBlenderにて、下記のようなローポリゴンなモデルを作成してみました。
      20240528_01_unity_boids_04
      ▲Blenderで制作した魚

      20240528_01_unity_boids_05
      ▲頂点数は50ほど

      エレキベア
      エレキベア
      落書き感がすごい・・・
      マイケル
      マイケル
      こちらのモデルを設定して実行すると・・・ 下記のように美しい群を作りながら泳ぐことを確認できました!
      20240528_01_unity_boids_07
      ▲いい泳ぎっぷりである

      エレキベア
      エレキベア
      おお〜〜〜 感動クマ〜〜〜〜

      おわりに

      マイケル
      マイケル
      というわけで今回はUnityでの群集シミュレーション実装でした! どうだったかな??
      エレキベア
      エレキベア
      簡単な規則なのにそれらしい群れが出来て感動したクマ〜〜
      マイケル
      マイケル
      ゲーム内で使用するにはもう少し効率化の面で工夫が必要そうだけど、演出として使用するには十分使えそうだね! Unityで実装した大量オブジェクトの描画手法も、今後活用できそうです。
      エレキベア
      エレキベア
      外部の環境にインタラクティブに反応する実装もやってみたいクマね
      マイケル
      マイケル
      それでは今回はこの辺で! アデューーー!!
      エレキベア
      エレキベア
      クマ〜〜〜〜

      【Unity】Boidsアルゴリズムを用いて魚の群集シミュレーションを実装する 〜完〜


      Unityグラフィックス群集シミュレーションGPGPUGPUインスタンシング
      2024-05-28

      関連記事
      【Unity】Timeline × Excelでスライドショーを効率よく制作する
      2024-10-31
      【書籍紹介】「コンピュータグラフィックス」に出てくる用語をまとめる【CGエンジニア検定】
      2024-07-13
      【UE5】Niagara SimulationStageによるシミュレーション環境構築
      2024-05-30
      【Unity】GoでのランキングAPI実装とVPSへのデプロイ方法についてまとめる【Go言語】
      2024-04-14
      【Unity】第二回 Wwiseを使用したサウンド制御 〜インタラクティブミュージック編〜
      2024-03-30
      【Unity】第一回 Wwiseを使用したサウンド制御 〜基本動作編〜
      2024-03-30
      【Unity】第二回 CRI ADXを使用したサウンド制御 〜インタラクティブミュージック編〜
      2024-03-28
      【Unity】第一回 CRI ADXを使用したサウンド制御 〜基本動作、周波数解析編〜
      2024-03-28