したかみ ぶろぐ

Unity成分多め

群れの表現(Boidsモデル)をUnityで実装する

f:id:vxd-naoshi-19961205-maro:20200830170814p:plain

始めに

前回はDrawMeshInstancedIndirectとComputeShaderを使って動くcubeをたくさん表示しました。

shitakami.hatenablog.com


今回はBoidsモデルを実装しようと思います。

もし、DrawMeshInstancedIndirectやComputeShaderについてわからないことがあれば過去のブログを参考にしてください。

ComputeShader

DrawMeshInstancedIndirect


また、今回の内容はこちらのリポジトリに入っています。(前回の分もあります)

github.com



Boidsモデルについて

Boidsモデルとは3つのルール(結合・分離・整列)によって群れの表現ができるアルゴリズムです。

このような現象を創発現象と言い、個体同士が決まったルールで動いた結果、自己組織的にパターンや構造が現れます。


以下3つのルールについて解説します。



結合

f:id:vxd-naoshi-19961205-maro:20200830162630p:plain

結合では範囲内にいる個体の中心方向へ向かうようにそろえるルールです。

個体の中心方向は周りの個体の座標の平均値 - 自身の座標で求めます。



分離

f:id:vxd-naoshi-19961205-maro:20200830163139p:plain

分離では範囲内の個体とぶつからないように離れるルールです。

こちらは個体から自身への方向 / 距離の合計値で求めます。



整列

f:id:vxd-naoshi-19961205-maro:20200830164800p:plain

整列では周りの個体と同じ方向を向くようにするルールです。

こちらは周りの個体の速度の平均値 - 自身の速度で求めます。



実装

3つのプログラムからなります。

C#プログラム

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Assertions;
using System.Runtime.InteropServices;

public class Boids : MonoBehaviour
{

    [Header("DrawMeshInstancedIndirectのパラメータ")]
    [SerializeField]
    private Mesh m_mesh;

    [SerializeField]
    private Material m_material;

    [SerializeField]
    private Bounds m_bounds;

    [SerializeField]
    private ShadowCastingMode m_shadowCastingMode;

    [SerializeField]
    private bool m_receiveShadows;

    [Space(20)]
    [SerializeField]
    private int m_instanceCount;

    [Header("力の強さ")]
    [Header("Boidsモデルのデータ")]
    [SerializeField]
    private float m_cohesionForce = 5f;
    [SerializeField]
    private float m_separationForce = 5f;
    [SerializeField]
    private float m_alignmentForce = 2f;

    [Space(5)]
    [Header("力の働く距離")]
    [SerializeField]
    private float m_cohesionDistance = 10f;
    [SerializeField]
    private float m_separationDistance = 6f;
    [SerializeField]
    private float m_alignmentDistance = 8f;

    [Space(5)]
    [Header("力の働く角度")]
    [SerializeField]
    private float m_cohesionAngle = 90f;
    [SerializeField]
    private float m_separationAngle = 90f;
    [SerializeField]
    private float m_alignmentAngle = 60f;

    [Space(5)]
    [SerializeField]
    private float m_boundaryForce = 1;
    [SerializeField]
    private float m_boundaryRange = 35f;

    [Space(5)]
    [SerializeField]
    private float m_maxVelocity = 0.1f;

    [SerializeField]
    private float m_minVelocity = 0.05f;

    [SerializeField]    
    private float m_maxForce = 1f;

    [Space(20)]
    [SerializeField]
    private ComputeShader m_boidsSimulator;

    private struct BoidsData {
        public Vector3 position;
        public Vector3 velocity;
    }

    private ComputeBuffer m_argsBuffer;

    private ComputeBuffer m_boidsDataBuffer;

    private int m_boidsCalcKernel;

    private int m_deltaTimeID = Shader.PropertyToID("deltaTime");

    private Vector3Int m_groupSize;

    // Start is called before the first frame update
    void Start()
    {
        m_instanceCount = Mathf.ClosestPowerOfTwo(m_instanceCount);

        Assert.IsNotNull(m_mesh, "メッシュデータが設定されていません");
        Assert.IsNotNull(m_material, "マテリアルが設定されていません");

        InitializeArgsBuffer();
        InitializeBoidsDataBuffer();
        InitializeBoidsSimulator();

    }

    // Update is called once per frame
    void Update()
    {
        BoidsCalc();

    }

    void LateUpdate() {

        Graphics.DrawMeshInstancedIndirect(
            m_mesh,
            0,
            m_material,
            m_bounds,
            m_argsBuffer,
            0,
            null,
            m_shadowCastingMode,
            m_receiveShadows
        );

    }

    private void InitializeArgsBuffer() {

        var args = new uint[] { 0, 0, 0, 0, 0 };

        args[0] = m_mesh.GetIndexCount(0);
        args[1] = (uint)m_instanceCount;

        m_argsBuffer = new ComputeBuffer(1, 4 * args.Length, ComputeBufferType.IndirectArguments);

        m_argsBuffer.SetData(args);

    }

    private void InitializeBoidsDataBuffer() {

        var boidsData = new BoidsData[m_instanceCount];

        for(int i = 0; i < m_instanceCount; ++i) {
            boidsData[i].position = Random.insideUnitSphere * m_boundaryRange;
            var velocity = new Vector3(Random.Range(-1f, 1f), Random.Range(-1f, 1f), Random.Range(-1f, 1f));
            boidsData[i].velocity = velocity.normalized * m_minVelocity; 
        }

        m_boidsDataBuffer = new ComputeBuffer(m_instanceCount, Marshal.SizeOf(typeof(BoidsData)));
        m_boidsDataBuffer.SetData(boidsData);

        m_material.SetBuffer("boidsDataBuffer", m_boidsDataBuffer);

    }

    private void InitializeBoidsSimulator() {

        m_boidsCalcKernel = m_boidsSimulator.FindKernel("BoidsCalculation");

        m_boidsSimulator.GetKernelThreadGroupSizes(m_boidsCalcKernel, out uint x, out uint y, out uint z);
        m_groupSize = new Vector3Int(m_instanceCount / (int)x, (int)y, (int)z);

        m_boidsSimulator.SetFloat("cohesionForce", m_cohesionForce);
        m_boidsSimulator.SetFloat("separationForce", m_separationForce);
        m_boidsSimulator.SetFloat("alignmentForce", m_alignmentForce);

        // ComputeShader内の距離判定で2乗の値を使用しているので合わせる
        m_boidsSimulator.SetFloat("cohesionDistance", m_cohesionDistance);
        m_boidsSimulator.SetFloat("separationDistance", m_separationDistance);
        m_boidsSimulator.SetFloat("alignmentDistance", m_alignmentDistance);

        // ComputeShader内ではラジアンで判定するので度数法からラジアンに変更する
        m_boidsSimulator.SetFloat("cohesionAngle", m_cohesionAngle * Mathf.Deg2Rad);
        m_boidsSimulator.SetFloat("separationAngle", m_separationAngle * Mathf.Deg2Rad);
        m_boidsSimulator.SetFloat("alignmentAngle", m_alignmentAngle * Mathf.Deg2Rad);

        m_boidsSimulator.SetFloat("boundaryForce", m_boundaryForce);
        m_boidsSimulator.SetFloat("boundaryRange", m_boundaryRange * m_boundaryRange);

        m_boidsSimulator.SetFloat("minVelocity", m_minVelocity);
        m_boidsSimulator.SetFloat("maxVelocity", m_maxVelocity);

        m_boidsSimulator.SetInt("instanceCount", m_instanceCount);

        m_boidsSimulator.SetFloat("maxForce", m_maxForce);

        m_boidsSimulator.SetBuffer(m_boidsCalcKernel, "boidsData", m_boidsDataBuffer);

    }

    private void BoidsCalc() {

        m_boidsSimulator.SetFloat(m_deltaTimeID, Time.deltaTime);
        m_boidsSimulator.Dispatch(m_boidsCalcKernel, m_groupSize.x, m_groupSize.y, m_groupSize.z);

    }

    private void OnDisable() {

        if(m_argsBuffer != null)
            m_argsBuffer.Release();

        m_argsBuffer = null;

        if(m_boidsDataBuffer != null)
            m_boidsDataBuffer.Release();

        m_boidsDataBuffer = null;

    }

}


ComputeShader

#pragma kernel BoidsCalculation

struct BoidsData {
    float3 position;
    float3 velocity;
};

RWStructuredBuffer<BoidsData> boidsData;

// Boidsパラメータ
float cohesionForce;
float separationForce;
float alignmentForce;

float cohesionDistance;
float separationDistance;
float alignmentDistance;

float cohesionAngle;
float separationAngle;
float alignmentAngle;

float boundaryForce;
float boundaryRange;

float maxVelocity;
float minVelocity;

int instanceCount;

float maxForce;

float deltaTime;

// 速度と座標から角度を求める
float CalcAngle(float3 velocity, float3 posX, float3 posY) {

    float3 vec = posY - posX;

    return acos(dot(normalize(velocity), normalize(vec)));

}

// 距離の2乗を求める
float CalcSqrDistance(float3 posX, float3 posY) {

    float3 vec = posY - posX;

    return dot(vec, vec);

}

// ベクトルの大きさを制限する
float3 limit(float3 vec, float max)
{
    float length = sqrt(dot(vec, vec)); // 大きさ
    return (length > max && length > 0) ? vec.xyz * (max / length) : vec.xyz;
}



[numthreads(256, 1, 1)]
void BoidsCalculation(uint id : SV_DISPATCHTHREADID) {

    float3 posX = boidsData[id.x].position;
    float3 velX = boidsData[id.x].velocity;

    float3 cohesionPositionSum = float3(0, 0, 0);
    float3 separationPositionSum = float3(0, 0, 0);
    float3 alignmentVelocitySum = float3(0, 0, 0);

    int cohesionCount = 0;
    int alignmentCount = 0;

    for(uint i = 0; i < instanceCount; ++i) {

        // 自身の計算は行わない
        if(i == id.x)
            continue;

        float3 posY = boidsData[i].position;
        float3 velY = boidsData[i].velocity;

        float sqrDistance = CalcSqrDistance(posX, posY);
        float angle = CalcAngle(velX, posX, posY);

        // 結合
        if(sqrDistance < cohesionDistance && angle < cohesionAngle) {
            cohesionPositionSum += posY;
            cohesionCount++;
        }

        // 分離
        if(sqrDistance < separationDistance && angle < separationAngle) {
            separationPositionSum += normalize(posX - posY) / sqrt(sqrDistance);        
        }

        // 整列
        if(sqrDistance < alignmentDistance && angle < alignmentAngle) {
            alignmentVelocitySum += velY;
            alignmentCount++;
        }

    }

    float3 cohesion = float3(0, 0, 0);
    float3 separation = separationPositionSum;
    float3 alignment = float3(0, 0, 0);
    float3 boundary = float3(0, 0, 0);

    if(cohesionCount != 0)
        cohesion = (cohesionPositionSum / (float)cohesionCount - posX) * cohesionForce;
    
    if(alignmentCount != 0) 
        alignment = (alignmentVelocitySum / (float)alignmentCount - velX) * alignmentForce;
    
    separation *= separationForce;

    // 範囲外から出た個体は範囲内に戻る力を加える
    float sqrDistFromCenter = dot(posX, posX);
    if(sqrDistFromCenter > boundaryRange)
        boundary = -boundaryForce * posX * (sqrDistFromCenter - boundaryRange) / sqrDistFromCenter;

    // 結合、分離、整列の力を制限
    cohesion = limit(cohesion, maxForce);
    separation = limit(separation, maxForce);
    alignment = limit(alignment, maxForce);

    velX += (cohesion + separation + alignment + boundary) * deltaTime;

    float velXScale = length(velX);

    // 速度を制限
    if(velXScale < minVelocity) {
        velX = minVelocity * normalize(velX);
    }
    else if (velXScale > maxVelocity) {
        velX = maxVelocity * normalize(velX);
    }

    boidsData[id.x].velocity = velX;
    boidsData[id.x].position += velX;

}


Shader

Shader "Custom/BoidsShader"
{
    Properties
    {
        _Color ("Color", Color) = (1,1,1,1)
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
        _Glossiness ("Smoothness", Range(0,1)) = 0.5
        _Metallic ("Metallic", Range(0,1)) = 0.0

        _ScaleX("ScaleX", float) = 1
        _ScaleY("ScaleY", float) = 1
        _ScaleZ("ScaleZ", float) = 1
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 200

        CGPROGRAM
        // Physically based Standard lighting model, and enable shadows on all light types
        #pragma surface surf Standard addshadow fullforwardshadows // 影を描画するためにはaddshadowが必要
        #pragma multi_compile_instancing    // GPU Instancingを可能にする
        #pragma instancing_options procedural:setup // setup関数を呼び出す


        // Use shader model 3.0 target, to get nicer looking lighting
        #pragma target 3.0

        sampler2D _MainTex;

        struct Input
        {
            float2 uv_MainTex;
        };

        struct BoidsData {
            float3 position;
            float3 velocity;
        };

        #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
        StructuredBuffer<BoidsData> boidsDataBuffer;
        #endif

        float4x4 eulerAnglesToRotationMatrix(float3 angles) {

            float cx = cos(angles.x); float sx = sin(angles.x);
            float cy = cos(angles.z); float sy = sin(angles.z);
            float cz = cos(angles.y); float sz = sin(angles.y);

            return float4x4(
                cz*cy + sz*sx*sy, -cz*sy + sz*sx*cy, sz*cx, 0,
                sy*cx, cy*cx, -sx, 0,
                -sz*cy + cz*sx*sy, sy*sz + cz*sx*cy, cz*cx, 0,
                0, 0, 0, 1);

        }

        float4x4 CalcInverseMatrix(float3 position, float3 angle, float3 scale) {

            float4x4 inversScaleeMatrix = float4x4(
                1/scale.x, 0, 0, -position.x,
                0, 1/scale.y, 0, -position.y,
                0, 0, 1/scale.z, -position.z,
                0, 0, 0, 1);

            float4x4 mat = float4x4(
                1, 0, 0, 0,
                0, 1, 0, 0,
                0, 0, 1, 0,
                0, 0, 0, 1);

            float4x4 rotMatrix = mul(eulerAnglesToRotationMatrix(angle), mat);

            float4x4 inverseRotMatrix = float4x4(
                rotMatrix._11, rotMatrix._21, rotMatrix._31, 0,
                rotMatrix._12, rotMatrix._22, rotMatrix._32, 0,
                rotMatrix._13, rotMatrix._23, rotMatrix._33, 0,
                0, 0, 0, 1);

            return mul(inversScaleeMatrix, inverseRotMatrix);

        }

        fixed _ScaleX;
        fixed _ScaleY;
        fixed _ScaleZ;

        void setup() {

        #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
            float3 position = boidsDataBuffer[unity_InstanceID].position;
            float3 velocity = boidsDataBuffer[unity_InstanceID].velocity;

            // スケーリング
            unity_ObjectToWorld._11_21_31_41 = float4(_ScaleX, 0, 0, 0);
            unity_ObjectToWorld._12_22_32_42 = float4(0, _ScaleY, 0, 0);
            unity_ObjectToWorld._13_23_33_43 = float4(0, 0, _ScaleZ, 0);

            // 速度から回転を求める
            float3 angle = float3(
                -asin(velocity.y/(length(velocity.xyz) + 1e-8)), // 0除算防止
                atan2(velocity.x, velocity.z),
                0);

            // 回転
            unity_ObjectToWorld = mul(eulerAnglesToRotationMatrix(angle), unity_ObjectToWorld);

            // 座標
            unity_ObjectToWorld._14_24_34_44 = float4(position, 1);

            // モデル行列を求める(間違っているかも. . .)
            // 参考:https://qiita.com/yuji_yasuhara/items/8d63455d1d277af4c270
            // 参考:http://gamemakerlearning.blog.fc2.com/blog-entry-196.html
            unity_WorldToObject = CalcInverseMatrix(position, angle, float3(_ScaleX, _ScaleY, _ScaleZ));


        #endif

        }


        half _Glossiness;
        half _Metallic;
        fixed4 _Color;

        // Add instancing support for this shader. You need to check 'Enable Instancing' on materials that use the shader.
        // See https://docs.unity3d.com/Manual/GPUInstancing.html for more information about instancing.
        // #pragma instancing_options assumeuniformscaling
        UNITY_INSTANCING_BUFFER_START(Props)
            // put more per-instance properties here
        UNITY_INSTANCING_BUFFER_END(Props)

        void surf (Input IN, inout SurfaceOutputStandard o)
        {
            // Albedo comes from a texture tinted by color
            fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
            o.Albedo = c.rgb;
            // Metallic and smoothness come from slider variables
            o.Metallic = _Metallic;
            o.Smoothness = _Glossiness;
            o.Alpha = c.a;
        }
        ENDCG
    }
    FallBack "Diffuse"
}



解説

周りの個体との結合、分離、整列を調べる

Boidsモデルの計算では始めに回りの個体との結合、分離、整列を調べます。

周りの個体を判別する際は距離と角度を使用します。

結合、分離、整列の計算方法は次のように行います。

  • 結合:周りの座標の平均値 - 自身の座標
  • 分離:周りの個体から自身へのベクトル / 距離の合計値
  • 整列:周りの速度の平均値 - 自身の速度
for(uint i = 0; i < instanceCount; ++i) {

        // 自身の計算は行わない
        if(i == id.x)
            continue;

        float3 posY = boidsData[i].position;
        float3 velY = boidsData[i].velocity;

        float sqrDistance = CalcSqrDistance(posX, posY);
        float angle = CalcAngle(velX, posX, posY);

        // 結合
        if(sqrDistance < cohesionDistance && angle < cohesionAngle) {
            cohesionPositionSum += posY;
            cohesionCount++;
        }

        // 分離
        if(sqrDistance < separationDistance && angle < separationAngle) {
            separationPositionSum += normalize(posX - posY) / sqrt(sqrDistance);        
        }

        // 整列
        if(sqrDistance < alignmentDistance && angle < alignmentAngle) {
            alignmentVelocitySum += velY;
            alignmentCount++;
        }

    }

    . . . . . . . . .
    . . . . . . . . . 
    float3 cohesion = float3(0, 0, 0);
    float3 separation = separationPositionSum;
    float3 alignment = float3(0, 0, 0);
    float3 boundary = float3(0, 0, 0);

    if(cohesionCount != 0)
        cohesion = (cohesionPositionSum / (float)cohesionCount - posX) * cohesionForce;
    
    if(alignmentCount != 0) 
        alignment = (alignmentVelocitySum / (float)alignmentCount - velX) * alignmentForce;
    
    separation *= separationForce;


範囲外の個体を範囲内に戻す

範囲外に出た個体は範囲内に戻す力を求めます。 範囲内であれば戻す力は 0 になります。

    // 範囲外から出た個体は範囲内に戻る力を加える
    float sqrDistFromCenter = dot(posX, posX);
    if(sqrDistFromCenter > boundaryRange)
        boundary = -boundaryForce * posX * (sqrDistFromCenter - boundaryRange) / sqrDistFromCenter;


速度、座標の更新

求めた3つの力が一定の大きさを超えた場合は、力の大きさをmaxForceの値に制限します。 その後、現在の速度に3つの力と範囲内に戻す力を加算します。

また、速度も同様にminVelocity以上maxVelocity以下に制限します。

最後に速度の更新と現在座標に速度を加えて更新を行います。

    // 結合、分離、整列の力を制限
    cohesion = limit(cohesion, maxForce);
    separation = limit(separation, maxForce);
    alignment = limit(alignment, maxForce);

    velX += (cohesion + separation + alignment + boundary) * deltaTime;

    float velXScale = length(velX);

    // 速度を制限
    if(velXScale < minVelocity) {
        velX = minVelocity * normalize(velX);
    }
    else if (velXScale > maxVelocity) {
        velX = maxVelocity * normalize(velX);
    }

    boidsData[id.x].velocity = velX;
    boidsData[id.x].position += velX;


速度から回転を求める

Boidsモデルの速度ベクトルが指す方向が前方向になります。

x軸の回転は前方向の上下、y軸の回転は前方向の左右に当たります。

x軸の回転は -arcsin(速度のy成分 / 速度の大きさ)

y軸の回転はarctan(速度のx成分 / 速度のz成分)となります。

こちらの内容は後述する「Unity Graphics Programming vol.1」を参考にしています。

            float3 position = boidsDataBuffer[unity_InstanceID].position;
            float3 velocity = boidsDataBuffer[unity_InstanceID].velocity;

            // スケーリング
            unity_ObjectToWorld._11_21_31_41 = float4(_ScaleX, 0, 0, 0);
            unity_ObjectToWorld._12_22_32_42 = float4(0, _ScaleY, 0, 0);
            unity_ObjectToWorld._13_23_33_43 = float4(0, 0, _ScaleZ, 0);

            // 速度から回転を求める
            float3 angle = float3(
                -asin(velocity.y/length(velocity.xyz)),
                atan2(velocity.x, velocity.z),
                0);

            // 回転
            unity_ObjectToWorld = mul(eulerAnglesToRottationMatrix(angle), unity_ObjectToWorld);

            // 座標
            unity_ObjectToWorld._14_24_34_44 = float4(position, 1);

結果

このプログラムの結果は次のようになります。 パラメータを変更することで様々な群れを表現できます。

(パラメータの設定忘れてしまいました!ごめんなさい!)

f:id:vxd-naoshi-19961205-maro:20200830194151p:plainf:id:vxd-naoshi-19961205-maro:20200830194205p:plainf:id:vxd-naoshi-19961205-maro:20200830195821p:plain


BoidsモデルをUnityで実装(パターン1)


BoidsモデルをUnityで実装(パターン2)



参考

こちらの本のプログラムを元に今回の内容を作成しています。


また、「Unity Graphics Programming vol.1」でもComputeShader+DrawMeshInstancedIndircetでBoidsモデルの実装をされています。所々の処理は異なりますが、多くの場面で参考にさせて頂きました。

github.com


一部こちらの内容も参考にしました。

gamemakerlearning.blog.fc2.com