なおしのこれまで、これから

学んだこと・感じたこと・やりたいこと

ML-Agentsでシューティングゲームを学習させる

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

始めに

ML-Agentsの本を大体読んだので自分で簡単なシューティングゲームを作って、それを学習させてみようと思います。

注意としてML-Agentsの解説は少しありますが、この記事を読んでML-Agentsを動かせるようにはならないのでわからない箇所がある場合は以下の記事を参考にすると良いと思います。

note.com

note.com



今回のプロジェクトはgithubに上げています。

github.com



実行環境

Unity 2019.4.1f1

ML-Agents Release3

Python 3.7.6


シューティングゲームを作る

シューティングゲームの仕様は以下の通りです。

  • プレイヤーは左右に移動できる
  • プレイヤーは弾を発射できる、発射した後にインターバルがある
  • 敵は上から下に一直線に下りてくる
  • 敵は弾に当たると消えて、弾も消える
  • 敵はプレイヤーと同じ位置に来たら消える
  • 敵は数秒おきに、ランダムの位置に生成される


f:id:vxd-naoshi-19961205-maro:20201004222839g:plain



学習の流れ

1. 状態の取得

Raycastを使用して敵の情報を取得します。詳しくは後程説明します。

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



2. 行動決定

ポリシーをもとにエージェントが行う各行動の状態を決定します。ポリシーとは状況に応じて行動を決める戦略を意味します。

今回のゲームでプレイヤーが行う行動とその行動の状態は以下の通りです。

  • 移動(状態:移動しない、右に移動、左に移動)
  • 弾を発射(状態:発射しない、発射する)

なので行動決定では右か左に移動するもしくは移動しないか、弾を発射するかしないかを決めます。



3. 行動実行

2.で決定された行動を実行してシーンに反映させます。

ここでは実際にプレイヤーを移動させたり弾を発射したりします。



4. 報酬取得

3.で行った行動の結果で得られる報酬を取得します。

弾が敵に当たったら加点、敵がプレイヤーの位置まで来たら減点をします。 また、報酬の合計値はエピソード単位で求めます。エピソードとは学習の訓練1回分を意味します



5. ポリシー更新

エピソードが完了したら、4.で得られた報酬からポリシーの更新をします。ポリシーの更新はPython側で行われるため、Unity側では特に意識しなくても大丈夫です。



この1~5を繰り返して高得点を取れるポリシーを求めていきます。



学習の準備

Agentの定義

Agentとは行動の主体となるものです。今回はプレイヤーとなります。

プレイヤーとなるコンポーネントはAgentクラスを継承したものとなります。

PlayerAgent.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.MLAgents;
using Unity.MLAgents.Sensors;

public class PlayerAgent : Agent
{

    [SerializeField]
    private GameObject m_bullet;

    [SerializeField]
    private float m_shotIntervalTime;

    [SerializeField]
    private float m_moveSpeed;

    [Header("動ける幅の絶対値")]
    [SerializeField]
    private float m_movableWidth;

    [SerializeField]
    private DestroyCounter m_destroyCounter;

    private float m_time = 0;

    [SerializeField]
    private Transform m_ShootingGameTransform;

    public override void Initialize()
    {
        m_time = 0;
    }

    public override void OnEpisodeBegin() {
        m_destroyCounter.Reset();
    }

    public override void OnActionReceived(float[] vectorAction) {

        int moveOperation = (int)vectorAction[0];
        int shotOperation = (int)vectorAction[1];

        Move(moveOperation);
        Shot(shotOperation);
    }

    private void Move(int operation)
    {

        Vector3 position = transform.localPosition;

        if (operation == 1)
            position.x = Mathf.Min(position.x + m_moveSpeed * Time.deltaTime, m_movableWidth);
        else if (operation == 2)
            position.x = Mathf.Max(position.x - m_moveSpeed * Time.deltaTime, -m_movableWidth);

        transform.localPosition = position;

    }

    private void Shot(int operation)
    {

        m_time += Time.deltaTime;

        // インターバル中もしくは弾を発射しない場合は処理を行わない
        if (m_time < m_shotIntervalTime || operation == 0)
            return;

        var newBullet = Instantiate(m_bullet, transform.position, Quaternion.identity, m_ShootingGameTransform);
        Destroy(newBullet, 2f);
        m_time = 0;

    }

    public override void Heuristic(float[] actionsOut) {
        
        actionsOut[0] = 0;
        actionsOut[1] = 0;

        if(Input.GetKey(KeyCode.RightArrow) && !Input.GetKey(KeyCode.LeftArrow))
            actionsOut[0] = 1;
        else if(Input.GetKey(KeyCode.LeftArrow) && !Input.GetKey(KeyCode.RightArrow))
            actionsOut[0] = 2;
        
        if(Input.GetKey(KeyCode.Space))
            actionsOut[1] = 1;

    }

}



OnEpisodeBegin()はエピソードを開始する前に呼ばれる関数です。ここでは倒した敵の数をリセットしています。DestroyCounterについては後程載せます。

    public override void OnEpisodeBegin() {
        m_destroyCounter.Reset();
    }



OnActionReceived()では行動決定で得られた値から実際に行動を行います。 vectorAction[0]は0 ~ 2、vectorAction[1]は0 ~ 1の値が入ります。

これらの値の範囲はBehavior Parameterコンポーネントで設定します。(後述)

    public override void OnActionReceived(float[] vectorAction) {

        int moveOperation = (int)vectorAction[0];
        int shotOperation = (int)vectorAction[1];

        Move(moveOperation);
        Shot(shotOperation);
    }

    private void Move(int operation)
    {

        Vector3 position = transform.localPosition;

        if (operation == 1)
            position.x = Mathf.Min(position.x + m_moveSpeed * Time.deltaTime, m_movableWidth);
        else if (operation == 2)
            position.x = Mathf.Max(position.x - m_moveSpeed * Time.deltaTime, -m_movableWidth);

        transform.localPosition = position;

    }

    private void Shot(int operation)
    {

        m_time += Time.deltaTime;

        // インターバル中もしくは弾を発射しない場合は処理を行わない
        if (m_time < m_shotIntervalTime || operation == 0)
            return;

        var newBullet = Instantiate(m_bullet, transform.position, Quaternion.identity, m_ShootingGameTransform);
        Destroy(newBullet, 2f);
        m_time = 0;

    }



Heuristic()は学習を行うに当たっては意味を持ちませんが、手動で操作する場合にはこの関数をオーバーライドする必要があります。

この関数をオーバーライドすることによってデバックをしたり、模倣学習のデータを生成することが出来ます。(今回模倣学習については行いません)



その他のプログラムの作成

かなり雑に作ったのでクラス関係が複雑になってるかもしれません。

Enemyクラスでは動きと当たり判定について書いています。

Enemy.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Enemy : MonoBehaviour
{

    [SerializeField]
    private float m_velocity;

    [SerializeField]
    private float m_rotateSpeed;

    private Quaternion m_rotation;

    [SerializeField]
    private Rigidbody m_rigidbody;

    private DestroyCounter m_destroyCounter;

    // Start is called before the first frame update
    void Start()
    {
        // ランダムに回転するようにする
        Vector3 randomAxis = new Vector3(Random.Range(0, 360f), Random.Range(0, 360f), Random.Range(0, 360f));
        m_rotation = Quaternion.AngleAxis(m_rotateSpeed * Mathf.Deg2Rad, randomAxis);

        // 前に進める、回転させる
        m_rigidbody.velocity = new Vector3(0, 0, -m_velocity);
        m_rigidbody.angularVelocity = randomAxis.normalized * m_rotateSpeed * Mathf.Deg2Rad;
    }

    public void SetDestroyCounter(DestroyCounter destroyCounter) {
        m_destroyCounter = destroyCounter;
    }

    private void OnTriggerEnter(Collider other) {

        if(other.CompareTag("Bullet")) {
            Destroy(this.gameObject);
            m_destroyCounter.AddDestroyCount(true);     // 加点
        }
        else if(other.CompareTag("DestroyArea")) {
            Destroy(this.gameObject);
            m_destroyCounter.AddDestroyCount(false);    // 減点
        }

    }

}


EnemyMakerクラスは敵の生成を担当しています。

EnemyMaker.cs

    using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class EnemyMaker : MonoBehaviour
{
    [SerializeField]
    private float m_width;

    [SerializeField]
    private float m_respawnInterval;

    [SerializeField]
    private float m_respawnPositionZ;

    [SerializeField]
    private Enemy m_enemyPrefab;

    private float m_time = 0;

    [SerializeField]
    private DestroyCounter m_destroyCounter;

    [SerializeField]
    private Transform m_ShootintGameTransform;

    void Update()
    {

        m_time += Time.deltaTime;

        if(m_time >= m_respawnInterval) {
            m_time = 0;

            InstantiateEnemy();

        }

    }

    private void InstantiateEnemy() {

        Vector3 position = transform.position + new Vector3(Random.Range(-m_width, m_width), 0, m_respawnPositionZ);
        var enemy = Instantiate(
            m_enemyPrefab, 
            position, 
            Quaternion.Euler(Random.Range(0, 360f), Random.Range(0, 360f), Random.Range(0, 360f)),
            m_ShootintGameTransform);
        enemy.SetDestroyCounter(m_destroyCounter);

    }

}


DestroyCounterクラスではエピソード単位の敵の個数に応じた加点や減点を担当します。

DestroyCounter.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DestroyCounter : MonoBehaviour
{

    [SerializeField]
    private int m_destroyCountPerEpisode;

    [SerializeField]
    private PlayerAgent m_playerAgent;

    private int m_destoryCount = 0;

    public void Reset() {
        m_destoryCount = 0;
    }

    public void AddDestroyCount(bool ToAddReward) {

        m_destoryCount++;

        // 報酬の計算を行う
        if(ToAddReward)
            m_playerAgent.AddReward(1.0f/m_destroyCountPerEpisode);
        else
            m_playerAgent.AddReward(-1.0f/m_destroyCountPerEpisode);

        // 指定された個数の敵を倒したら、1回分の訓練を終了
        if(m_destoryCount == m_destroyCountPerEpisode)
            m_playerAgent.EndEpisode();

    }

}


BulletクラスではEnemyに当たったら消える処理と前に進む処理が書かれています。

Bullet.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Bullet : MonoBehaviour
{

    [SerializeField]
    private float m_velocity;

    [SerializeField]
    private Rigidbody m_rigidbody;

    // Start is called before the first frame update
    void Start()
    {
        // 前に進むようにする
        m_rigidbody.velocity = new Vector3(0, 0, m_velocity);
    }

    private void OnTriggerEnter(Collider other) {

        if(other.CompareTag("Enemy"))
            Destroy(gameObject);

    }

}



報酬取得処理について解説します。

EnemyクラスのOnTriggerEnterで弾に当たった場合は加点を、ステージの下に設置された当たり判定にあたった場合は減点をします。

DestroyCounterクラスのAddDestroyCountからAgent.AddReward関数を呼び出して報酬を計算します。

報酬の合計値はエピソード内の敵を倒しきれたら1、すべて倒せなかったら-1になるように1回の報酬を求めています。

また、指定された個数の敵が倒された場合はAgent.EndEpisode関数でエピソードを終了します。

// Enemy.cs 37行目
    private void OnTriggerEnter(Collider other) {

        if(other.CompareTag("Bullet")) {
            Destroy(this.gameObject);
            m_destroyCounter.AddDestroyCount(true);     // 加点
        }
        else if(other.CompareTag("DestroyArea")) {
            Destroy(this.gameObject);
            m_destroyCounter.AddDestroyCount(false);    // 減点
        }

    }
// DestroyCounter.cs 20行目
    public void AddDestroyCount(bool ToAddReward) {

        m_destoryCount++;

        // 報酬の計算を行う
        if(ToAddReward)
            m_playerAgent.AddReward(1.0f/m_destroyCountPerEpisode);
        else
            m_playerAgent.AddReward(-1.0f/m_destroyCountPerEpisode);

        // 指定された個数の敵を倒したら、1回分の訓練を終了
        if(m_destoryCount == m_destroyCountPerEpisode)
            m_playerAgent.EndEpisode();

    }



Unityでの設定

詳しいシーン設定の解説は省きますが、ML-Agentsの機能を使用している個所について解説していきます。


Behavior Parametersコンポーネント

PlayerオブジェクトにBehavior Parametersコンポーネントをアタッチします。

このコンポーネントで観測するデータの個数を指定したり、行動実行に必要なデータの型や個数を指定することができます。

VectorObservationは観測に使用するデータの個数を指定したり、保存する量を設定します。今回はデータを観測に使用しないのでSpace Size = 0、Stacked Vector = 1に設定します。

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


Vector Actionでは行動決定で使用するデータを設定します。Space TypeではContinuous(連続値)とDiscrete(離散値)があります。0 ~ 1の値が欲しい場合などではContinuous、0か1や0, 1, 2のどれかが欲しい場合はDiscreteを指定します。今回はDiscreteを設定します。

次にBranches SizeとBranch X Sizeについて軽く説明します。Branches Sizeは使用する値の個数を指定します。 プレイヤーが行う行動は移動と弾の発射の2つなのでBranches Size = 2と設定します。

Branch 0 Sizeは各行動がいくつの状態を持っているかを設定します。移動では移動しない、右に移動、左に移動の3つなのでBranch 0 Size = 3、弾の発射は発射しない、発射するの2つなのでBranch 1 Size = 2と設定します。 ここの内容がOnActionReceied関数と関係しているので注意していください。

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


Behavior Typeでは手動で操作するか、学習データを使用するか等を設定できます。ここは基本的にDefaultに設定していいと思います。(画像では実験で動かすためにHeuristic Onlyにしています。)



Decision Requesterの設定

Decision Requesterは行動の決定を要求するコンポーネントです。これがないと行動決定が行われず、全く動かないものになってしまいます。

Decision Periodでは何フレームごとに行動決定を要求するかを設定します。この値が大きいと動きが少なくなるので今回はDecision Period = 1に設定します。

Take Actions Between Decisionsは行動決定が要求されないフレームでもOnActionReceived()を実行するかを設定します。今回はあまり意味はないですがチェックしています。

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



PlayerAgentの設定

Agentクラスを継承したコンポーネントにはMax Stepという項目があります。

これは何フレーム実行したらエピソード(1回の訓練)を終えるかを指定します。今回はフレーム数ではなく、敵を倒した数でエピソードを指定するのでMax Step = 0(無制限)を指定します。 

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



Ray Perception Sensor 3Dの設定

敵の検知にRay Perception Sensor 3Dコンポーネントを使用します。 これを使うことで、レイが当たったかや当たったオブジェクトの距離などを観測して学習に使用します。

Detectable Tagsでは判定に使用するタグを指定します。今回は敵のみなのでSize = 1、Element0 = Enemyとします。また、忘れずに敵オブジェクトのタグもEnemyに設定します。

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


Rays Per Directionでは左右にいくつレイを飛ばすか、Max Ray Degreesでは横に飛ばすレイの最大角度、Sphere Cast Radiusではレイの半径、Ray Lengthではレイの長さを指定します。

f:id:vxd-naoshi-19961205-maro:20201006223800g:plain


これらの値は以下のように設定しました。

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


最後にStacked Raycastsですが、これは学習に使用する過去のRay castのデータの個数を指定します。何度か試してみてStacked Raycasts = 1でも問題なかったですが、数値を変更しても面白いかもしれません。



訓練設定ファイル(.yaml)の作成

訓練設定ファイルについてはあまり解説しませんのでこちらを参照してください。

Unity ML-Agents 0.14.0 の訓練パラメータ|npaka|note

以下私が使用した訓練設定ファイルになります。

behaviors:
  ShootingGame:
    trainer_type: ppo
    hyperparameters:
      batch_size: 128
      buffer_size: 2048
      learning_rate: 0.0003
      beta: 0.005
      epsilon: 0.2
      lambd: 0.95
      num_epoch: 3
      learning_rate_schedule: linear
    network_settings:
      normalize: true
      hidden_units: 128
      num_layers: 2
      vis_encode_type: simple
    reward_signals:
      extrinsic:
        gamma: 0.99
        strength: 1.0
    keep_checkpoints: 5
    checkpoint_interval: 500000
    max_steps: 500000
    time_horizon: 64
    summary_freq: 10000
    threaded: true


一ヶ所注意しないといけない点は、2行目の"ShootingGame"とUnityのBehavior ParametersコンポーネントのBehavior Nameを一致させないといけません。

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



学習させる

準備が出来たら学習を開始していきます。始めはかなり無茶苦茶な動きになります。

f:id:vxd-naoshi-19961205-maro:20201006230538g:plain



しかし、学習が進むにつれ敵がいるところに移動するようになります。

f:id:vxd-naoshi-19961205-maro:20201006231833g:plain


参考程度に学習過程のグラフが以下のようになります。 縦軸が報酬の合計値で横軸が学習を行ったフレーム数になります。報酬の合計値が9ぐらいまでは安定して上昇しますが、それ以上はなかなか安定しませんでした。

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



学習結果

学習結果が以下のようになりました。ちゃんと敵を倒していくようになりました。

f:id:vxd-naoshi-19961205-maro:20201006232844g:plain



ただし、シチュエーションによってはどれを倒すか迷うような挙動をするときもあります。

f:id:vxd-naoshi-19961205-maro:20201006233501g:plain



感想

簡単なゲームの学習だったらすんなりと思ったものを作ることができました。学習のパラメータ等を切り替えるとまた違った結果が得られると思います。

また何かしらML-Agentsで出来そうだと思ったらサンプルを作って記事にしようと思います。



参考

ML-Agentsを勉強するうえでこちらの本が大変参考になりました。



過去記事

shitakami.hatenablog.com

アニメーションするオブジェクトを大量に表示する(VAT + GPUInstancing)

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

始めに

前回はVAT(Vertex Animation Texture)についてまとめました。

今回はVATとGraphic.DrawMeshInstancedIndirectを組み合わせるサンプルを作成してみました。


過去記事



使用したプロジェクト・アセット

VATの記事でも使用させて頂いたAnimation Texture Bakerを今回も使っています。

github.com


モデルとアニメーションはこちらのアセットから使用しました。

assetstore.unity.com



VAT・マテリアルの作成

始めにアニメーションをさせるマテリアルの作成を行います。

こちらの内容は前回の内容を元に作成しているので、興味があれば前回の内容を拝見してください。

f:id:vxd-naoshi-19961205-maro:20200921135036g:plain



実装

C#プログラム

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

public class GPUInstancingZombies : MonoBehaviour
{
    [SerializeField]
    private ComputeShader m_computeShader;

    [Header("DrawMeshInstancedDirectのパラメータ")]
    [Space(20)]
    [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;

    [Header("生成する数")]
    [Space(20)]
    [SerializeField]
    private int m_instanceCount;

    [SerializeField]
    private float m_offsetPositionY;

    [Header("モデルを歩かせる速さ")]
    [Space(20)]
    [SerializeField]
    private float m_speed;



    private ComputeBuffer m_argsBuffer;
    private ComputeBuffer m_zombieDataBuffer;

    private int m_moveKernel;
    private Vector3Int m_groupSize;

    private int m_deltaTimeId = Shader.PropertyToID("_deltaTime");

    private float m_minBoundX;
    private float m_maxBoundX;
    private float m_minBoundZ;
    private float m_maxBoundZ;

    struct ZombieData {
        public Vector3 position;
        public float animationOffset;
    }


    // Start is called before the first frame update
    void Start()
    {
        CalculateBounds();
        InitializeArgsBuffer();
        InitializeComputeShader();
    }

    void Update() {

        m_computeShader.SetFloat(m_deltaTimeId, Time.deltaTime);
        m_computeShader.Dispatch(m_moveKernel, m_groupSize.x, m_groupSize.y, m_groupSize.z);
    }

    // Update is called once per frame
    void LateUpdate()
    {
        
        Graphics.DrawMeshInstancedIndirect(
            m_mesh,
            0,
            m_material,
            m_bounds,
            m_argsBuffer,
            0,
            null,
            m_shadowCastingMode,
            m_receiveShadows
        );

    }

    private void InitializeArgsBuffer() {

        uint[] args = new uint[5] { 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 InitializeComputeShader() {

        m_instanceCount = Mathf.ClosestPowerOfTwo(m_instanceCount);

        InitializeZombieDataBuffer();

        m_moveKernel = m_computeShader.FindKernel("Move");
        
        m_computeShader.GetKernelThreadGroupSizes(m_moveKernel, out uint x, out uint y, out uint z);
        m_groupSize = new Vector3Int(m_instanceCount / (int)x, (int)y, (int)z);

        m_computeShader.SetFloat("_Speed", m_speed);
        m_computeShader.SetFloat("_MinBoundZ", m_minBoundZ);
        m_computeShader.SetFloat("_MaxBoundZ", m_maxBoundZ);

        m_computeShader.SetBuffer(m_moveKernel, "_ZombieDataBuffer", m_zombieDataBuffer);

    }

    private void InitializeZombieDataBuffer() {

        ZombieData[] zombieData = new ZombieData[m_instanceCount];

        for(int i = 0; i < m_instanceCount; ++i) {

            zombieData[i].position = new Vector3(
                Random.Range(m_minBoundX, m_maxBoundX),
                m_offsetPositionY,
                Random.Range(m_minBoundZ, m_maxBoundZ)
            );
            zombieData[i].animationOffset = Random.Range(0, 10.0f);

        }

        m_zombieDataBuffer = new ComputeBuffer(m_instanceCount, Marshal.SizeOf(typeof(ZombieData)));
        m_zombieDataBuffer.SetData(zombieData);
        m_material.SetBuffer("_ZombieDataBuffer", m_zombieDataBuffer);

    }

    private void CalculateBounds() {

        m_minBoundX = m_bounds.center.x - m_bounds.size.x / 2.0f;
        m_maxBoundX = m_bounds.center.x + m_bounds.size.x / 2.0f;

        m_minBoundZ = m_bounds.center.z - m_bounds.size.z / 2.0f;
        m_maxBoundZ = m_bounds.center.z + m_bounds.size.z / 2.0f;

    }

    private void OnDestroy() {

        m_argsBuffer?.Release();
        m_zombieDataBuffer?.Release();

    }

}


ComputeShader

#pragma kernel Move

struct ZombieData {
    float3 position;
    float animationOffset;
};

RWStructuredBuffer<ZombieData> _ZombieDataBuffer;
float _Speed;
float _deltaTime;

float _MinBoundZ;
float _MaxBoundZ;

[numthreads(8,1,1)]
void Move(uint3 id : SV_DispatchThreadID) {

    float z = _ZombieDataBuffer[id.x].position.z;
    z += _deltaTime * _Speed;
    
    if(z > _MaxBoundZ)
        z = _MinBoundZ + (z - _MaxBoundZ);

    _ZombieDataBuffer[id.x].position.z = z;
}


Shader
Shaderの内容はAnimation Texture BakerのShaderに座標やアニメーションのオフセットを追加した程度となります。

Shader "Unlit/TextureAnimPlayer"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _PosTex("position texture", 2D) = "black"{}
        _NmlTex("normal texture", 2D) = "white"{}
        _DT ("delta time", float) = 0
        _Length ("animation length", Float) = 1
        [Toggle(ANIM_LOOP)] _Loop("loop", Float) = 0
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100 Cull Off

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma multi_compile ___ ANIM_LOOP

            #include "UnityCG.cginc"

            #define ts _PosTex_TexelSize

            struct appdata
            {
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float3 normal : TEXCOORD1;
                float4 vertex : SV_POSITION;
            };

            struct ZombieData {
                float3 position;
                float animationOffset;
            };

            sampler2D _MainTex, _PosTex, _NmlTex;
            float4 _PosTex_TexelSize;
            float _Length, _DT;
            
            StructuredBuffer<ZombieData> _ZombieDataBuffer;

            v2f vert (appdata v, uint vid : SV_VertexID, uint instanceID : SV_INSTANCEID)
            {
                float dt = _ZombieDataBuffer[instanceID].animationOffset;
                float3 worldPos = _ZombieDataBuffer[instanceID].position;
                // 時間 - オフセット
                float t = (_Time.y - dt) / _Length;
#if ANIM_LOOP
                t = fmod(t, 1.0);
#else
                t = saturate(t);
#endif
                float x = (vid + 0.5) * ts.x;
                float y = t;
                float4 pos = tex2Dlod(_PosTex, float4(x, y, 0, 0));
                float3 normal = tex2Dlod(_NmlTex, float4(x, y, 0, 0));

                v2f o;
                // アニメーションしている頂点の座標位置 + ワールド座標
                o.vertex = UnityObjectToClipPos(pos + worldPos);
                o.normal = UnityObjectToWorldNormal(normal);
                o.uv = v.uv;
                return o;
            }
            
            half4 frag (v2f i) : SV_Target
            {
                half diff = dot(i.normal, float3(0,1,0))*0.5 + 0.5;
                half4 col = tex2D(_MainTex, i.uv);
                return diff * col;
            }
            ENDCG
        }
    }
}

 

解説

ComputeShaderやDrawMeshInstancedIndirectの内容については過去記事のまま使用しています。


モデルを歩かせる範囲の指定

DrawMeshInstancedIndirectで使用するBoudsをもとにしてモデルを歩かせる範囲を計算します。

    private void CalculateBounds() {

        m_minBoundX = m_bounds.center.x - m_bounds.size.x / 2.0f;
        m_maxBoundX = m_bounds.center.x + m_bounds.size.x / 2.0f;

        m_minBoundZ = m_bounds.center.z - m_bounds.size.z / 2.0f;
        m_maxBoundZ = m_bounds.center.z + m_bounds.size.z / 2.0f;

    }



個体の初期座標、アニメーションのオフセットの初期化

C#プログラム内で個体の初期座標やアニメーションのオフセットを初期化します。

初期座標は先ほど計算で求めた範囲をもとに乱数を求めます。

    private void InitializeZombieDataBuffer() {

        ZombieData[] zombieData = new ZombieData[m_instanceCount];

        for(int i = 0; i < m_instanceCount; ++i) {

            zombieData[i].position = new Vector3(
                Random.Range(m_minBoundX, m_maxBoundX),
                m_offsetPositionY,
                Random.Range(m_minBoundZ, m_maxBoundZ)
            );
            zombieData[i].animationOffset = Random.Range(0, 10.0f);

        }

        m_zombieDataBuffer = new ComputeBuffer(m_instanceCount, Marshal.SizeOf(typeof(ZombieData)));
        m_zombieDataBuffer.SetData(zombieData);
        m_material.SetBuffer("_ZombieDataBuffer", m_zombieDataBuffer);

    }



モデルの座標計算

ComputeShaderを使用して個体の移動を実装しています。

個体を前方向に移動させて、境界を越えたら一番後ろの境界に戻しています。

[numthreads(8,1,1)]
void Move(uint3 id : SV_DispatchThreadID) {

    float z = _ZombieDataBuffer[id.x].position.z;
    z += _deltaTime * _Speed;
    
    if(z > _MaxBoundZ)
        z = _MinBoundZ + (z - _MaxBoundZ);

    _ZombieDataBuffer[id.x].position.z = z;
}



Shaderでモデルを移動させる

今回は今までとは異なり、FragmentShaderでの実装となります。

FragmentShaderではSetup関数は無いので、vert関数でセマンティクスでインスタンスIDを取得して個体情報を受け取ります。

アニメーションのオフセットは時間計算を行っている個所に、座標は頂点座標計算を行っている個所に追加しています。

v2f vert (appdata v, uint vid : SV_VertexID, uint instanceID : SV_INSTANCEID)
            {
                float dt = _ZombieDataBuffer[instanceID].animationOffset;
                float3 worldPos = _ZombieDataBuffer[instanceID].position;
                // 時間 - オフセット
                float t = (_Time.y - dt) / _Length;
#if ANIM_LOOP
                t = fmod(t, 1.0);
#else
                t = saturate(t);
#endif
                float x = (vid + 0.5) * ts.x;
                float y = t;
                float4 pos = tex2Dlod(_PosTex, float4(x, y, 0, 0));
                float3 normal = tex2Dlod(_NmlTex, float4(x, y, 0, 0));

                v2f o;
                // アニメーションしている頂点の座標位置 + ワールド座標
                o.vertex = UnityObjectToClipPos(pos + worldPos);
                o.normal = UnityObjectToWorldNormal(normal);
                o.uv = v.uv;
                return o;
            }



結果

ゾンビの大行進を作ることが出来ました。

f:id:vxd-naoshi-19961205-maro:20200921143740g:plain


約6万体を表示してだいたい160fpsでした。

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



まとめ

VAT、ComputeShader、DrawMeshInstancedIndirectを組み合わせることが可能とわかりました。

今回は簡単なサンプルとして作成しましたが、次はもうちょっとちゃんとしたものを作りたいなと思います。



参考

今回もUnity Graphics Programming vol.3を参考にさせて頂きました。

github.com

VAT(Vertex Animation Texture)を使用してシェーダーでアニメーションをさせる

f:id:vxd-naoshi-19961205-maro:20200917214605g:plain

始めに

野球ゲームの観客だったり、最近ではVtuberのライブの観客だったりで多くのNPCを表示させてかつアニメーションもさせています。

どうやれば実装できるかと思っているとき、VAT(Vertex Animation Texture)という技術を知りました。

このVATを使うことでUnityのAnimatorよりも軽量にアニメーションをさせることが出来ます。

ということで、今回はVATについてまとめていこうと思います。



VAT(Vertex Animation Texture)

Vertex Animation Textureとはモデルの頂点座標や回転の情報が書き込まれたテクスチャを指します。このテクスチャを使用することでモデルにアニメーションをさせることが出来ます。

Houdiniの記事にはなりますが、こちらが参考になりました。

houdinifx.jp



Animation Texture Baker

今回は0から作るのではなくgithubにあるプロジェクト「Animation Texture Baker」を使ってVATをしていこうと思います。

github.com



シェーダーでアニメーションをさせるまでの手順

先ほどのgithubプロジェクトを使っていきます。


モデルの準備

モデルは Assets/Horse/Model/Horse を使用します。 HorseをHierarchyに追加します。

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



コンポーネントの設定

次にHorse用の AnimatorController を作成して、Horseに元からあるAnimatorコンポーネントのControllerに設定します。作成したAnimatorControllerは何も設定しなくて大丈夫です。

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



次に Animation Clip Texture Bakerコンポーネントを追加して Assets/AnimationBaker/Shaders に入っている Info Tex GenにMeshInfoTextureGen.computeを、Play Shaderに2つのshaderファイルのどちらかを設定します。

Clipsには実行したいアニメーションを追加していきます。今回は Assets/Horse/Animation にあるHorse_Idle、Horse_Run、Horse_Walkを追加します。

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

VATの作成

Animation Clip Texture Bakerを右クリックするとメニューの一番下にbake textureがあります。 こちらを選択することでVATとVATを使用したPrefabが生成されます。

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

f:id:vxd-naoshi-19961205-maro:20200917214605g:plain

また、作成されたPrefabファイルは Assets/BakedAnimationTex に保存されています。



AssetStoreにあるモデルで試す

次にこちらのゾンビのモデルで試してみます。 手順は先ほどと同じです。

assetstore.unity.com


結果はモデルがx軸に-90度回転したものが出来てしまいました。

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



続いて次のロボットのモデルで試してみます。 RobotのRigをHumanoidに設定して同様の手順を行います。

assetstore.unity.com

assetstore.unity.com


先ほどとは異なり、回転があっていました。

f:id:vxd-naoshi-19961205-maro:20200917220901g:plain



回転オフセットを追加する

いくつか試している際にモデルの回転があっていない現象がたびたびありました。

なのでVATを作成する際に回転のオフセットを設定できるよう改良してみました。


やっていることは単純で頂点座標テクスチャの値に回転行列をかける処理を追加しただけです。

// AnimationClipTextureBaker.csの91行目
 infoTexGen.SetInt("VertCount", vCount);

infoTexGen.SetFloat("AngleOffsetX", angleOffset.x);
infoTexGen.SetFloat("AngleOffsetY", angleOffset.y);
infoTexGen.SetFloat("AngleOffsetZ", angleOffset.z);
            
infoTexGen.SetBuffer(kernel, "Info", buffer);
infoTexGen.SetTexture(kernel, "OutPosition", pRt);
infoTexGen.SetTexture(kernel, "OutNormal", nRt);
infoTexGen.SetTexture(kernel, "OutTangent", tRt);


// MeshInfoTextureGen.computeの17行目
float AngleOffsetX;
float AngleOffsetY;
float AngleOffsetZ;

#define Deg2Rad 0.0174532924
float4x4 eulerAnglesToRottationMatrix(float3 angles) {

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

    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);

}

[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
    int index = id.y * VertCount + id.x;
    MeshInfo info = Info[index];

    // OutPosition[id.xy] = float4(info.position, 1.0);
    float3 angle = float3(AngleOffsetX, AngleOffsetY, AngleOffsetZ);
    float4 position = mul(eulerAnglesToRottationMatrix(angle), float4(info.position, 1.0));
    OutPosition[id.xy] = position;
    OutNormal[id.xy] = float4(info.normal, 1.0);
    OutTangent[id.xy] = float4(info.tangent, 1.0);
}



結果、インスペクターから回転を指定することで作成されるPrefabの回転を合わせることが出来ました。

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

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



有効なモデルについて

いくつか試してみてSkinnedMeshRendererを使用しているモデルのみ有効のようです。

例えば、次のRobot SphereではSkinnedMeshRendererを使用していないためVATを作成するのは出来ませんでした。

assetstore.unity.com


また、VATの作成では1つのSkinnedMeshRendererを使用して1つのテクスチャを作成しているため複数のSkinnedMeshRendererが付いたモデルではその数分のVATを作成しなくてはいけません。

以下のアセットでは2つのSkinnedMeshRendererを使用しているので2つVATを作成して同じ座標に置くことで上手くいきました。

assetstore.unity.com

f:id:vxd-naoshi-19961205-maro:20200917233516g:plain


1つのSkinnedMeshRendererを使用しているモデルについては問題なくVATを作成することが出来ました。

Mini Legion Rock Golem PBR HP Polyart | Characters | Unity Asset Store

f:id:vxd-naoshi-19961205-maro:20200917225727g:plain



感想

VATによってCPU負荷を減らしてアニメーションを行うことが出来ました。

VATだけでの検証でしたが、これからGPUインスタンシングと組み合わせてみようと思います。

参考

こちらの記事とUnity Graphics Programming vol.3 を参考にしています。 また、Unity Graphics Programming vol.3では詳しい解説が載っているので知りたい方は拝見してください。

tsubakit1.hateblo.jp

github.com

ML-Agentsのサンプルを動かしてみた

始めに

本屋でテキトーに本を見てた時にML-Agentsの新しいバージョンの本があり、秒で買ってしまいました。


実は前のバージョンの本も購入していましたが、出版から1年くらい経っていたのでML-Agentsのバージョンがかなり異なり本を読み進めることが困難となっていました。

今回は同じような失敗をしないよう早速、本の始めに出てくるサンプルを試してみました。

また、今回の内容はネット上でも拝見することが出来ます。

note.com



実行環境

基本的にすべて元から自分のPCに入っていたのでそれを元に実行してみました。 

OS : windows10

Unity : 2018.4.14f

Python : 3.7.9

Anaconda : 4.8.3

ML-Agents : Release 3



Python側での準備

ここの内容は普段使っていない機能が多いのでまとめます。


Anacondaを使ってPythonの仮想環境を作成

Anacondaのコマンドで仮想環境を作成します。conda create -n "仮想環境の名前" python=x.xで仮想環境が作成できます。

$ conda create -n ml-agents python=3.7


また、作成した仮想環境に切り替える際はconda activate "仮想環境の名前"で行います。

$ conda activate ml-agetns

成功した場合はコマンドプロンプト(ターミナル)の先頭に仮想環境の名前が付きます。

これらのコマンドについてはこちらのサイトが参考になりました。

[Python]Anacondaで仮想環境を作る - Qiita



Pythonパッケージのインストール

次にML-Agentsに必要となるパッケージをインストールします。

先ほど作成した仮想環境内で以下のコマンドを使用して"ml-agents-env"と"ml-agents"をインストールします。

pip install -e ./ml-agents-envs
pip install -e ./ml-agents



Unityシーンの作成

f:id:vxd-naoshi-19961205-maro:20200906212909g:plain

今回作るシーンは玉を転がして目標地点まで行くものとなります。

こちらは先ほどのリンク通り行うとこのようなシーンが作成されます。

note.com



学習の流れ

訓練設定ファイルの作成

訓練設定ファイルの作成します。拡張子は.yamlです。

まだ訓練設定ファイルについての解説を読んでいないのでどんなことが書かれているか把握していません。

以下、サイトからの引用です。 (Unity ML-Agents Release 3 のチュートリアル (2)|npaka|note

behaviors:
  RollerBall:
    trainer_type: ppo
    hyperparameters:
      batch_size: 10
      buffer_size: 100
      learning_rate: 0.0003
      beta: 0.005
      epsilon: 0.2
      lambd: 0.95
      num_epoch: 3
      learning_rate_schedule: linear
    network_settings:
      normalize: true
      hidden_units: 128
      num_layers: 2
      vis_encode_type: simple
    reward_signals:
      extrinsic:
        gamma: 0.99
        strength: 1.0
    keep_checkpoints: 5
    checkpoint_interval: 500000
    max_steps: 500000
    time_horizon: 64
    summary_freq: 1000
    threaded: true

作成した.yamlファイルはML-Agents Release3のフォルダ内の./config/sample/に保存します。ここでは"RollerBall.yaml"と保存します。



mlagents-learnの実行

ではここから学習を開始していきます。

学習を開始するコマンドはmlagents-learn "ファイルパス" --run-id="ID名"です。

今回は次のようにコマンドを入力します。

mlagents-learn ./config/sample/Rollerball.yaml --run-id=firstRun

成功した場合はテキストの最後の部分にStart training by pressing the Play button in the Unity Editorと出力されるはずです。



mlagents-learnの実行が上手くいかなかった場合

ここで私は上手く実行できなかったのでその時の対処法を記します。

そもそもStart training by pressing the Play button in the Unity Editorが出力されなかった場合はファイルパスが間違っているか、既存のID名があるか、訓練ファイルのパラメータ名が間違っていないか、インデントがずれていないか確認してください。


次に準備は成功するが学習を始めきれない場合についてです。本では以下のサイトのvc_redist.s64.exeをダウンロード、インストールしてくださいと記載されていました。

support.microsoft.com

私の場合はその通りにしても上手く動作しなかったので、もう一つの方法としてコマンドpip install tensorflow==2.0を行いました。こちらの内容は以下のサイトに記載されています。

インストールをした後は上手く学習を進めることが出来ました。

github.com


どちらが学習を進める要因だったかはわかりませんが、どちらかをやってみてそれでも上手くいかなかった場合は両方やるといいかもしれません。



Unityで学習を始める

mlagents-learnが成功したら、あとはUnityの再生ボタンを押すと学習が始まります。

学習が進んでいないときはボールがランダムに動き、すぐにステージから落ちてしまいます。

f:id:vxd-naoshi-19961205-maro:20200906224705g:plain



これが少しずつ目的地に向かうようになり、最終的には一直線に向かうようなります。

f:id:vxd-naoshi-19961205-maro:20200906224920g:plain



また、学習の最中のコマンドプロンプトは途中結果を出力します。Mean Rewardが平均報酬、Std of Reward標準偏差を表します。

Mean Rewardが1、Std of Rewardが0になったら学習を終了します。

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



推論の実行

先ほどの学習の結果を使って、推論を行います。

学習の結果ファイルはml-agetnsファイルの./results/firstRun/RollerBall.nnに保存されており、このRollerBall.nnをUnityのAssetsにコピーします。

あとはBehavior ParametersコンポーネントのModelパラメータにRollerBall.nnをつけて実行すれば推論を実行できます。

f:id:vxd-naoshi-19961205-maro:20200906225946g:plain



感想

サンプルを触った感じとても面白かったです。また、無事実行できたことで勉強するモチベーションを作ることが出来ました。

また本を読み始めたばかりなので全くわかりませんが、これから少しずつ読み進めていこうと思います。また、余力があれば何かしらのサンプルを作ってブログにまとめたいです。



参考

note.com

note.com

ComputeShaderでGPUパーティクルを実装する

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

始めに

ComputeShaderについて調べてみますと「GPUパーティクル」という単語を何度か目にしました。

今回はこのGPUパーティクルについてまとめようと思います。


こちらのサイトを参考に実装を行っています。

qiita.com


また、今回の内容は以下のリポジトリにあります。

github.com



GPUパーティクルの処理の流れ

パーティクルを見えないようにしてスタックに入れる

始めにパーティクルをすべて用意して、大きさを 0 にしたり透明にして見えないようにします。(今回は大きさを 0 にしています)

次に各パーティクルのインデックスを1つのスタックに保存します。

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



スタックから要素を取り出してパーティクルを生成する

パーティクルを生成する際はスタックからパーティクルのインデックスを取得します。

取得されたインデックスのパーティクルは大きさを戻す、不透明にする(今回は大きさを戻す)ことでパーティクルが生成されたように見せます。

また、生成されたパーティクルには「使用している」フラグを立てます。

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



生成されたパーティクルの更新を行う

パーティクルの更新を行う際は「使用している」フラグが立っているもののみを更新します。

パーティクルの更新では速度計算、座標計算、大きさの調整、寿命の減算などを行います。

寿命が尽きたパーティクルをスタックに戻す

パーティクルの更新で寿命が尽きたパーティクルは再び見えないようにして、「使用しているフラグ」を降ろします。

また、そのパーティクルのインデックスをスタックに追加して再び使えるようにします。

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



Append/ConsumeStructuredBufferについて

上の解説ではスタックと解説しましたが、ComputeShaderにスタックはありません。

スタックの代わりにAppendStructuredBuffer、ConsumeStructuredBufferを使用します。

  • AppendStructuredBuffer : 要素の追加のみが出来る
  • ConsumeStructuredBuffer : 要素の取り出しのみが出来る

このAppendStructuredBufferとConsumeStructuredBufferが1つのComputeBufferを参照することで疑似的にスタックの機能を実現することが出来ます。

ComputeBufferに値を追加する際はAppendStructuredBuffer.Append()を使い、値を取り出す際はConsumeStructuredBuffer.Consume()を使います。

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



実装

C#プログラム

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

public class TestGPGPUParticle : MonoBehaviour {

    #region DrawMeshInstancedIndirect_Parameters
    [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;
    #endregion

    #region GPUParticle_Parameters
    [Header ("GPUパーティクルのパラメータ")]
    [Space (20)]
    [SerializeField]
    private int m_instanceCount;

    [SerializeField]
    private int m_emitCount;

    [SerializeField]
    private float m_lifeTime;
    [SerializeField]
    private float m_force;
    [SerializeField]
    private float m_forceAngle;
    [SerializeField]
    private float m_gravity;
    [SerializeField]
    private float m_scale;
    #endregion

    #region ComputeShader_Parameters
    private ComputeBuffer m_argsBuffer;

    [SerializeField]
    private ComputeShader m_gpuParticleCalculator;

    private ComputeBuffer m_particlesBuffer;

    private ComputeBuffer m_particlePoolBuffer;

    private ComputeBuffer m_particlePoolCountBuffer;
    private int[] m_poolCount = { 0, 0, 0, 0 };

    private int m_updateKernel, m_emitKernel;

    private Vector3Int m_updateGroupSize, m_emitGroupSize;
    #endregion

    #region Struct_Particle
    private struct Particle {
        public Vector3 position;
        public Vector3 velocity;
        public Vector3 angle;
        public float duration;
        public float scale;
        public bool isActive;
    }
    #endregion

    // Start is called before the first frame update
    void Start () {

        InitializeArgsBuffer ();
        InitializeGPUParticle ();

    }

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

        m_gpuParticleCalculator.SetFloat ("Time", Time.time);
        m_gpuParticleCalculator.SetFloat ("deltaTime", Time.deltaTime);

        if (Input.GetMouseButton (0)) {
            EmitParticles ();
        }

        UpdateParticles ();

    }

    void LateUpdate () {

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

    }

    private void EmitParticles () {

        int poolCount = GetParticlePoolCount ();

        // 未使用のパーティクルが一度に生成する個数より低い場合は
        // パーティクルを生成しない
        if (poolCount < m_emitCount)
            return;

        m_gpuParticleCalculator.Dispatch (m_emitKernel, m_emitGroupSize.x, m_emitGroupSize.y, m_emitGroupSize.z);

    }

    private int GetParticlePoolCount () {

        // ComputeShader内のDeadListに入っているデータの個数を取得する
        ComputeBuffer.CopyCount (m_particlePoolBuffer, m_particlePoolCountBuffer, 0);
        m_particlePoolCountBuffer.GetData (m_poolCount);
        int restPool = m_poolCount[0];

        Debug.Log ("restPool = " + restPool);

        return restPool;

    }

    private void UpdateParticles () {

        m_gpuParticleCalculator.Dispatch (m_updateKernel, m_updateGroupSize.x, m_updateGroupSize.y, m_updateGroupSize.z);

    }

    private void InitializeArgsBuffer () {

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

        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 InitializeGPUParticle () {

        Assert.IsFalse (m_instanceCount < m_emitCount, "一度に出すパーティクルの個数がパーティクルの総数以上になっています");

        // インスタンスの個数、一度に出すパーティクルの個数は2の累乗に設定(計算しやすくするため)
        m_instanceCount = Mathf.ClosestPowerOfTwo (m_instanceCount);
        m_emitCount = Mathf.ClosestPowerOfTwo (m_emitCount);

        InitializeComputeBuffers ();

        InitializeParticlePool ();

        m_gpuParticleCalculator.SetFloat ("lifeTime", m_lifeTime);
        m_gpuParticleCalculator.SetFloat ("force", m_force);
        m_gpuParticleCalculator.SetFloat ("forceAngle", m_forceAngle);
        m_gpuParticleCalculator.SetFloat ("scale", m_scale);
        m_gpuParticleCalculator.SetFloat ("gravity", m_gravity);

        m_updateKernel = m_gpuParticleCalculator.FindKernel ("UpdateParticles");

        uint x, y, z;
        m_gpuParticleCalculator.GetKernelThreadGroupSizes (m_updateKernel, out x, out y, out z);
        m_updateGroupSize = new Vector3Int (m_instanceCount / (int) x, (int) y, (int) z);
        m_gpuParticleCalculator.SetBuffer (m_updateKernel, "particles", m_particlesBuffer);
        m_gpuParticleCalculator.SetBuffer (m_updateKernel, "deadList", m_particlePoolBuffer);

        m_emitKernel = m_gpuParticleCalculator.FindKernel ("EmitParticles");

        m_gpuParticleCalculator.GetKernelThreadGroupSizes (m_emitKernel, out x, out y, out z);
        m_emitGroupSize = new Vector3Int (m_emitCount / (int) x, (int) y, (int) z);
        m_gpuParticleCalculator.SetBuffer (m_emitKernel, "particles", m_particlesBuffer);
        m_gpuParticleCalculator.SetBuffer (m_emitKernel, "particlePool", m_particlePoolBuffer);

    }

    private void InitializeComputeBuffers () {

        m_particlesBuffer = new ComputeBuffer (m_instanceCount, Marshal.SizeOf (typeof (Particle)));
        m_material.SetBuffer ("_ParticleBuffer", m_particlesBuffer);

        m_particlePoolBuffer = new ComputeBuffer (m_instanceCount, Marshal.SizeOf (typeof (int)), ComputeBufferType.Append);
        // Append/Consumeの追加削除位置を0に設定する
        m_particlePoolBuffer.SetCounterValue (0);

        // パーティクルの個数を求める際に使用する
        m_particlePoolCountBuffer = new ComputeBuffer (4, Marshal.SizeOf (typeof (int)), ComputeBufferType.IndirectArguments);
        m_particlePoolCountBuffer.SetData (m_poolCount);

    }

    private void InitializeParticlePool () {

        int initializeParticlesKernel = m_gpuParticleCalculator.FindKernel ("InitializeParticles");
        m_gpuParticleCalculator.GetKernelThreadGroupSizes (initializeParticlesKernel, out uint x, out uint y, out uint z);
        m_gpuParticleCalculator.SetBuffer (initializeParticlesKernel, "deadList", m_particlePoolBuffer);
        m_gpuParticleCalculator.SetBuffer (initializeParticlesKernel, "particles", m_particlesBuffer);
        m_gpuParticleCalculator.Dispatch (initializeParticlesKernel, m_instanceCount / (int) x, 1, 1);

    }

    private void OnDisable () {

        m_particlesBuffer?.Release ();
        m_particlePoolBuffer = null;

        m_particlePoolBuffer?.Release ();
        m_particlePoolBuffer = null;

        m_particlePoolCountBuffer?.Release ();
        m_particlePoolCountBuffer = null;

        m_argsBuffer?.Release ();
        m_argsBuffer = null;

    }

}


ComputeShader

#pragma kernel InitializeParticles
#pragma kernel EmitParticles
#pragma kernel UpdateParticles

#define Deg2Rad 0.0174532924
#define PI 3.14159274

struct Particle {
    float3 position;
    float3 velocity;
    float3 angle;
    float duration;
    float scale;
    bool isActive;
};


RWStructuredBuffer<Particle> particles;
AppendStructuredBuffer<uint> deadList;
ConsumeStructuredBuffer<uint> particlePool;

float lifeTime;
float force;
float forceAngle;
float gravity;
float scale;

float deltaTime;
float Time;

// 乱数生成
inline float rnd(float2 p){
    return frac(sin(dot(p ,float2(12.9898, 78.233))) * 43758.5453);
}

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);

}

float3 CalcAngle(float id) {

    float x = rnd(float2(deltaTime + id*3.951, deltaTime-2*id));
    float y = rnd(float2(Time+id*1.23, Time+id*3.14));
    float z = rnd(float2(deltaTime+Time+id*0.987, Time/deltaTime+id*3.23));

    return float3(x*180.0, y*180.0, z*180.0);

}

// 上方向からangle以下傾けた速度を返す
float3 CalcVelocity(float angle, float id) {

    float4 vel = float4(0, force, 0, 1);
    float angleY = rnd(float2(Time + id*2.978, Time - deltaTime + id*1.098))*2*PI;
    float angleX = rnd(float2(Time + deltaTime - id*2.131, Time + id*4.521))*angle*Deg2Rad;

    vel = mul(eulerAnglesToRotationMatrix(float3(angleX, angleY, 0)), vel);

    return float3(vel.x, vel.y, vel.z);

}


[numthreads(8, 1, 1)]
void InitializeParticles(uint id : SV_DISPATCHTHREADID) {

    particles[id.x].isActive = false;

    deadList.Append(id.x);

}


[numthreads(8, 1, 1)]
void EmitParticles() {

    uint id = particlePool.Consume();
    particles[id].isActive = true;

    particles[id].position = float3(0, 0, 0);
    particles[id].velocity = CalcVelocity(forceAngle, id);
    particles[id].angle = CalcAngle(id);
    particles[id].duration = lifeTime;
    particles[id].scale = scale;

}


[numthreads(8, 1, 1)]
void UpdateParticles(uint id : SV_DISPATCHTHREADID) {

    if(particles[id.x].isActive) {

        particles[id.x].velocity -= float3(0, gravity * deltaTime, 0);
        particles[id.x].position += particles[id.x].velocity * deltaTime;
        particles[id.x].duration = max(0, particles[id.x].duration - deltaTime);
        particles[id.x].scale = lerp(scale, 0, 1 - particles[id.x].duration/lifeTime);

        if(particles[id.x].duration <= 0) {
            particles[id.x].isActive = false;
            deadList.Append(id.x);
        }

    }

}


Shader

Shader "Custom/GPUParticleShader"
{
    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
    }
    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
        #pragma multi_compile_instancing
        #pragma instancing_options procedural:setup

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

        sampler2D _MainTex;

        struct Input
        {
            float2 uv_MainTex;
        };

        struct Particle {
            float3 position;
            float3 velocity;
            float3 angle;
            float duration;
            float scale;
            bool isActive;
        };

#ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
    StructuredBuffer<Particle> _ParticleBuffer;
#endif

        #define Deg2Rad 0.0174532924

        float4x4 eulerAnglesToRottationMatrix(float3 angles) {

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

            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);

        }

        void setup() {

        #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
            float3 position = _ParticleBuffer[unity_InstanceID].position;
            float3 angle = _ParticleBuffer[unity_InstanceID].angle;
            float scale = _ParticleBuffer[unity_InstanceID].scale;

            // LifeTime == 1の場合、生成されて消えるまで180度回転する
            angle += (1 - _ParticleBuffer[unity_InstanceID].duration) * 180;

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

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

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

            // モデル行列を求める(間違っているかも. . .)
            // 参考:https://qiita.com/yuji_yasuhara/items/8d63455d1d277af4c270
            unity_WorldToObject = unity_ObjectToWorld;
            unity_WorldToObject._14_24_34 *= -1;
            unity_WorldToObject._11_12_13 = unity_ObjectToWorld._11_21_31;
            unity_WorldToObject._21_22_23 = unity_ObjectToWorld._12_22_32;
            unity_WorldToObject._31_32_33 = unity_ObjectToWorld._13_23_33;
            unity_WorldToObject._11_12_13 /= scale * scale;
            unity_WorldToObject._21_22_23 /= scale * scale;
            unity_WorldToObject._31_32_33 /= scale * scale;
        #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"
}


シェーダーは過去のブログの内容をほぼそのまま持ってきました。

shitakami.hatenablog.com



解説

基本的に最初にお話しした"GPUパーティクルの処理の流れ"に沿っていると思います。


パーティクルの初期化

パーティクルのインデックスを保存するComputeBuffer "m_particlePoolBuffer" を生成します。

Append/Consumeを行うComputeBufferはコンストラクタで ComputeBufferType.Append を設定しなくてはいけません。

またSetCounterValue(0)を指定することで要素の追加を0から始めるよう設定します。

        m_particlePoolBuffer = new ComputeBuffer (m_instanceCount, Marshal.SizeOf (typeof (int)), ComputeBufferType.Append);
        // Append/Consumeの追加削除位置を0に設定する
        m_particlePoolBuffer.SetCounterValue (0);



ComputeBufferの初期化の次にパーティクルのデータが入ったComputeBufferと先ほどのm_particlePoolBufferをComputeShaderに渡して初期化処理を実行します。

ここで、m_particlePoolBufferはComputeShader内のAppendStructuredBuffer "deadList" から参照されます。

    private void InitializeParticlePool () {

        int initializeParticlesKernel = m_gpuParticleCalculator.FindKernel ("InitializeParticles");
        m_gpuParticleCalculator.GetKernelThreadGroupSizes (initializeParticlesKernel, out uint x, out uint y, out uint z);
        m_gpuParticleCalculator.SetBuffer (initializeParticlesKernel, "deadList", m_particlePoolBuffer);
        m_gpuParticleCalculator.SetBuffer (initializeParticlesKernel, "particles", m_particlesBuffer);
        m_gpuParticleCalculator.Dispatch (initializeParticlesKernel, m_instanceCount / (int) x, 1, 1);

    }



初期化処理では、「使用している」フラグを表す isActive をfalseに設定し未使用のパーティクルのインデックスをdeadList.Appendを利用してm_particlePoolBufferに追加します。

[numthreads(8, 1, 1)]
void InitializeParticles(uint id : SV_DISPATCHTHREADID) {

    particles[id.x].isActive = false;

    deadList.Append(id.x);

}



パーティクルの生成

パーティクルを生成する前に未使用のパーティクルがいくつあるか確認します。もし未使用の個数が一度に生成する個数より少ない場合は、パーティクルの生成を行いません。

個数が十分であればパーティクルの生成を行います。

また、一度に生成するパーティクルの個数はComputeShaderのグループサイズ x スレッドサイズとなります。

例えば、グループサイズ(16, 1, 1)、スレッドサイズ(8, 1, 1)とすると (16 x 1 x 1) x (8 x 1 x 1) = 128となり一度に128個のパーティクルを生成します。

    private void EmitParticles () {

        int poolCount = GetParticlePoolCount ();

        // 未使用のパーティクルが一度に生成する個数より低い場合は
        // パーティクルを生成しない
        if (poolCount < m_emitCount)
            return;

        m_gpuParticleCalculator.Dispatch (m_emitKernel, m_emitGroupSize.x, m_emitGroupSize.y, m_emitGroupSize.z);

    }



未使用のパーティクルの個数を求める関数は次のようになります。

ここで使用されている "m_particlePoolCountBuffer" は ComputeBufferType.IndirectArguments として生成されたComputeBufferとなります。 ComputeBuffer.CopyCountを使用してm_particlePoolBufferに入っているデータの個数を取得してm_particlPoolCountBufferの始めに保存します。

後は取得した個数を先ほどの呼び出し元に返します。

        // パーティクルの個数を求める際に使用する
        m_particlePoolCountBuffer = new ComputeBuffer (4, Marshal.SizeOf (typeof (int)), ComputeBufferType.IndirectArguments);
        m_particlePoolCountBuffer.SetData (m_poolCount);

  . . . . . . . . . . .
  . . . . . . . . . . .

    private int GetParticlePoolCount () {

        // ComputeShader内のDeadListに入っているデータの個数を取得する
        ComputeBuffer.CopyCount (m_particlePoolBuffer, m_particlePoolCountBuffer, 0);
        m_particlePoolCountBuffer.GetData (m_poolCount);
        int restPool = m_poolCount[0];

        return restPool;

    }



ComputeShader内でのパーティクル生成処理は次のようになります。

particlePoolはm_particlePoolBufferを参照しており、particlePool.Consumeで保存されているパーティクルのインデックスを取得します。

次に、パーティクルが使用されいていることを示すisActiveをtrueにして各パラメータの初期化を行います。

[numthreads(8, 1, 1)]
void EmitParticles() {

    uint id = particlePool.Consume();
    particles[id].isActive = true;

    particles[id].position = float3(0, 0, 0);
    particles[id].velocity = CalcVelocity(forceAngle, id);
    particles[id].angle = CalcAngle(id);
    particles[id].duration = lifeTime;
    particles[id].scale = scale;

}



パーティクルの更新

パーティクルの更新処理は毎フレーム行います。

パーティクルの更新をする際、最初にisActiveを確認してそのパーティクルが生きているかを確認します。 そうでないものには更新処理を行いません。

更新処理では速度、座標、大きさ、そして寿命の更新を行います。

この時寿命が尽きたら、初期化時と同様にdeadList.Appendでm_particlePoolBufferにインデックスを保存し、isActiveをfalseに設定します。

また、寿命が尽きたパーティクルは大きさの計算で自動的に大きさ 0 となり、見えなくなります。

[numthreads(8, 1, 1)]
void UpdateParticles(uint id : SV_DISPATCHTHREADID) {

    if(particles[id.x].isActive) {

        particles[id.x].velocity -= float3(0, gravity * deltaTime, 0);
        particles[id.x].position += particles[id.x].velocity * deltaTime;
        particles[id.x].duration = max(0, particles[id.x].duration - deltaTime);
        particles[id.x].scale = lerp(scale, 0, 1 - particles[id.x].duration/lifeTime);

        if(particles[id.x].duration <= 0) {
            particles[id.x].isActive = false;
            deadList.Append(id.x);
        }

    }

}



生成時の初速について

パーティクルの初速はUnityのパーティクルのShape Coneを模して作成しました。

パーティクルの初速は上方向から 0 ~ angle 傾けた速度ベクトルとなります。

これはx軸に対して0 ~ angle度傾けて、次に0 ~ 360度y軸に対して回転させることで求めることが出来ます。

また、ベクトルの回転はshaderでも使用している回転行列を使用しています。

// 上方向からangle以下傾けた速度を返す
float3 CalcVelocity(float angle, float id) {

    float4 vel = float4(0, force, 0, 1);
    float angleY = rnd(float2(Time + id*2.978, Time - deltaTime + id*1.098))*2*PI;
    float angleX = rnd(float2(Time + deltaTime - id*2.131, Time + id*4.521))*angle*Deg2Rad;

    vel = mul(eulerAnglesToRotationMatrix(float3(angleX, angleY, 0)), vel);

    return float3(vel.x, vel.y, vel.z);

}



結果

見栄え的にはあまり良くないかもしれませんが、たくさんのcubeを出すパーティクルが出来ました。

f:id:vxd-naoshi-19961205-maro:20200904163147g:plain

f:id:vxd-naoshi-19961205-maro:20200904163513g:plain



参考

今回の内容はこちらのサイトを参考にして勉強させていただきました。

qiita.com

乱数についてはこちらのサイトも参考にしています。

qiita.com

以下Unity公式マニュアルです。

docs.unity3d.com

docs.unity3d.com

docs.unity3d.com

群れの表現(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

DrawMeshInstancedIndirectとComputeShaderを組み合わせる

始めに

前回はDrawMeshInstancedIndirectについてまとめました。

shitakami.hatenablog.com

今回は前回の内容とComputeShaderを組み合わせた簡単なサンプルを作成したいと思います。


ComputeShaderについては以下の記事にまとめています。

shitakami.hatenablog.com

shitakami.hatenablog.com



プログラム

今回のプログラムは前回のプログラムにComputeShaderの機能を追加したものとなります。

前回と同様にDrawMeshInstancedIndirectでcubeを描画しますが、ComputeShaderを使用してcubeを回転させながら上下に動かしたいと思います。


C#プログラム

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;
using System.Runtime.InteropServices;   // Marshal.Sizeofの呼び出しに必要

public class DrawCubesWithMoving : 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;

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

    [SerializeField]
    private float m_moveSpeed;
    [SerializeField]
    private float m_rotateSpeed;

    [SerializeField]
    private float m_moveHeight;

    private int m_kernelID;

    private Vector3Int m_groupSize;

    private ComputeBuffer m_argsBuffer;

    private ComputeBuffer m_cubeParamBuffer;

    struct CubeParameter {
        public Vector3 position;
        public Vector3 angle;
        public float scale;
        public float randTime;
        public float baseHeight;
    }

    // Start is called before the first frame update
    void Start()
    {
        // 描画するメッシュの個数を最も近い2の累乗の値する
        m_instanceCount = Mathf.ClosestPowerOfTwo(m_instanceCount);

        InitializeArgsBuffer();
        InitializeCubeParamBuffer();
        InitializeComputeShader();
    }

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

    void LateUpdate() {

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

    }

    private void InitializeArgsBuffer() {

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

        args[0] = (m_mesh != null) ? m_mesh.GetIndexCount(0) : 0;
        args[1] = (uint)m_instanceCount;

        m_argsBuffer = new ComputeBuffer(1, sizeof(uint) * args.Length, ComputeBufferType.IndirectArguments);
        m_argsBuffer.SetData(args);

    }

    private void InitializeComputeShader() {

        // カーネルIDの取得
        m_kernelID = m_computeShader.FindKernel("ChangeCubeParameter");

        // グループサイズを求める
        m_computeShader.GetKernelThreadGroupSizes(m_kernelID, out uint x, out uint y, out uint z);
        m_groupSize = new Vector3Int((int)x, (int)y, (int)z);
        m_groupSize.x = m_instanceCount / m_groupSize.x;

        // パラメータをComputeShaderに設定
        m_computeShader.SetBuffer(m_kernelID, "cubeParamBuffer", m_cubeParamBuffer);
        m_computeShader.SetFloat("moveSpeed", m_moveSpeed);
        m_computeShader.SetFloat("rotateSpeed", m_rotateSpeed);
        m_computeShader.SetFloat("moveHeight", m_moveHeight);

    }

    private void InitializeCubeParamBuffer() {

        CubeParameter[] cubeParameters = new CubeParameter[m_instanceCount];

        for(int i = 0; i < m_instanceCount; ++i) {

            cubeParameters[i].position = new Vector3(Random.Range(-100f, 100f), Random.Range(-100f, 100f), Random.Range(-100f, 100f));
            cubeParameters[i].angle = new Vector3(Random.Range(-180f, 180f), Random.Range(-180f, 180f), Random.Range(-180f, 180f));
            cubeParameters[i].scale = Random.Range(0.1f, 1f);
            cubeParameters[i].randTime = Random.Range(-Mathf.PI * 2, Mathf.PI * 2);
            cubeParameters[i].baseHeight = cubeParameters[i].position.y;

        }

        // Marshal.SizeOfで構造体CubeParameterのサイズを取得する
        m_cubeParamBuffer = new ComputeBuffer(m_instanceCount, Marshal.SizeOf(typeof(CubeParameter)));

        m_cubeParamBuffer.SetData(cubeParameters);

        m_material.SetBuffer("cubeParamBuffer", m_cubeParamBuffer);
    }

    readonly private int m_TimeId = Shader.PropertyToID("_Time");

    private void UpdatePositionAndAngle() {

        m_computeShader.SetFloat(m_TimeId, Time.deltaTime);
        m_computeShader.Dispatch(m_kernelID, m_groupSize.x, m_groupSize.y, m_groupSize.z);

    }


    private void OnDisable() {

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

        m_argsBuffer = null;

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

        m_cubeParamBuffer = null;

    }

}


Shader

Shader "Custom/InstancedWithMove"
{
    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
    }
    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関数を呼び出す

        sampler2D _MainTex;

        struct Input
        {
            float2 uv_MainTex;
        };

        struct CubeParameter {
            float3 position;
            float3 angle;
            float scale;
            float randTime;
            float baseHeight;
        };

#ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
    StructuredBuffer<CubeParameter> cubeParamBuffer;
#endif

        #define Deg2Rad 0.0174532924

        float4x4 eulerAnglesToRottationMatrix(float3 angles) {

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

            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);

        }

        void setup() {

        #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
            float3 position = cubeParamBuffer[unity_InstanceID].position;
            float3 angle = cubeParamBuffer[unity_InstanceID].angle;
            float scale = cubeParamBuffer[unity_InstanceID].scale;

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

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

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

            // モデル行列を求める(間違っているかも. . .)
            // 参考:https://qiita.com/yuji_yasuhara/items/8d63455d1d277af4c270
            unity_WorldToObject = unity_ObjectToWorld;
            unity_WorldToObject._14_24_34 *= -1;
            unity_WorldToObject._11_12_13 = unity_ObjectToWorld._11_21_31;
            unity_WorldToObject._21_22_23 = unity_ObjectToWorld._12_22_32;
            unity_WorldToObject._31_32_33 = unity_ObjectToWorld._13_23_33;
            unity_WorldToObject._11_12_13 /= scale * scale;
            unity_WorldToObject._21_22_23 /= scale * scale;
            unity_WorldToObject._31_32_33 /= scale * scale;
        #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 cy = tex2D (_MainTex, IN.uv_MainTex) * _Color;
            o.Albedo = cy.rgb;
            // Metallic and smoothness come from slider variables
            o.Metallic = _Metallic;
            o.Smoothness = _Glossiness;
            o.Alpha = cy.a;
        }
        ENDCG
    }
    FallBack "Diffuse"
}


ComputeShader

#pragma kernel ChangeCubeParameter

struct CubeParameter {
    float3 position;
    float3 angle;
    float scale;
    float randTime;
    float baseHeight;
};

RWStructuredBuffer<CubeParameter> cubeParamBuffer;

float _Time;
float rotateSpeed;
float moveSpeed;
float moveHeight;


[numthreads(64, 1, 1)]
void ChangeCubeParameter(uint id : SV_DISPATCHTHREADID) {

    cubeParamBuffer[id.x].angle.x += rotateSpeed * _Time;

    float time = cubeParamBuffer[id.x].randTime + _Time * moveSpeed;
    cubeParamBuffer[id.x].randTime = time;

    float baseHeight = cubeParamBuffer[id.x].baseHeight;

    cubeParamBuffer[id.x].position.y = baseHeight + sin(time) * moveHeight;

}



結果

解説が少し長くなるので、先に結果をお見せします

f:id:vxd-naoshi-19961205-maro:20200826224530g:plain



解説

今回はシェーダーについては解説しません。興味がある方は前回の記事を参照してください。


構造体の定義、使用

今回のプログラムでは複数の値を渡すため、構造体を使用します。

C#プログラムでは次のように構造体CubeParameterが定義されます。

    struct CubeParameter {
        public Vector3 position;
        public Vector3 angle;
        public float scale;
        public float randTime;
        public float baseHeight;
    }


次にこの構造体と同じ構造体をシェーダー側とComputeShader側にも定義します。

ここでC#の構造体とメンバ変数の型や個数が異なれば、エラーになりませんが正しく計算が行われずバグになります。

        // シェーダー側
        struct CubeParameter {
            float3 position;
            float3 angle;
            float scale;
            float randTime;
            float baseHeight;
        };

#ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
    StructuredBuffer<CubeParameter> cubeParamBuffer;
#endif
// ComputeShader側
struct CubeParameter {
    float3 position;
    float3 angle;
    float scale;
    float randTime;
    float baseHeight;
};

RWStructuredBuffer<CubeParameter> cubeParamBuffer;



構造体の初期化

今回も前回と同様にランダムにcubeを生成したいので座標をランダムにします。

また、上下に動かす動きをcubeごとに別々にしたかったのでアニメーション時に使用する時間パラメータもランダムにします。

構造体のComputeBufferを作る際はsizeof関数ではなく、Marshal.SizeOf(typeof(構造体名))で行うと構造体のサイズを調べることが出来ます。

    private void InitializeCubeParamBuffer() {

        CubeParameter[] cubeParameters = new CubeParameter[m_instanceCount];

        for(int i = 0; i < m_instanceCount; ++i) {

            cubeParameters[i].position = new Vector3(Random.Range(-100f, 100f), Random.Range(-100f, 100f), Random.Range(-100f, 100f));
            cubeParameters[i].angle = new Vector3(Random.Range(-180f, 180f), Random.Range(-180f, 180f), Random.Range(-180f, 180f));
            cubeParameters[i].scale = Random.Range(0.1f, 1f);
            cubeParameters[i].randTime = Random.Range(-Mathf.PI * 2, Mathf.PI * 2);
            cubeParameters[i].baseHeight = cubeParameters[i].position.y;

        }

        // Marshal.SizeOfで構造体CubeParameterのサイズを取得する
        m_cubeParamBuffer = new ComputeBuffer(m_instanceCount, Marshal.SizeOf(typeof(CubeParameter)));

        m_cubeParamBuffer.SetData(cubeParameters);

        m_material.SetBuffer("cubeParamBuffer", m_cubeParamBuffer);
    }



ComputeShaderの初期化

ComputeShaderの初期化は次の順序で行われています。

  1. カーネルIDを取得する
  2. 実行する際のグループサイズを調べる
  3. パラメータを入れる(毎フレーム入れる必要がないもの)
    private void InitializeComputeShader() {

        // カーネルIDの取得
        m_kernelID = m_computeShader.FindKernel("ChangeCubeParameter");

        // グループサイズを求める
        m_computeShader.GetKernelThreadGroupSizes(m_kernelID, out uint x, out uint y, out uint z);
        m_groupSize = new Vector3Int((int)x, (int)y, (int)z);
        m_groupSize.x = m_instanceCount / m_groupSize.x;

        // パラメータをComputeShaderに設定
        m_computeShader.SetBuffer(m_kernelID, "cubeParamBuffer", m_cubeParamBuffer);
        m_computeShader.SetFloat("moveSpeed", m_moveSpeed);
        m_computeShader.SetFloat("rotateSpeed", m_rotateSpeed);
        m_computeShader.SetFloat("moveHeight", m_moveHeight);

    }



ComputeShaderの実行、描画

今回はUpdate関数でComputeShaderを実行して、LateUpdate関数で描画を行いました。

サイトによってはUpdate関数に両方実行しているのもあったので、好きなほうでいいと思います。(間違いがあれば訂正します)

    void Update()
    {
        UpdatePositionAndAngle();
    }

    void LateUpdate() {

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

    }


ComputeShaderを実行する前に、Time.deltaTimeの値を渡すことに注意してください。

    private void UpdatePositionAndAngle() {

        m_computeShader.SetFloat(m_TimeId, Time.deltaTime);
        m_computeShader.Dispatch(m_kernelID, m_groupSize.x, m_groupSize.y, m_groupSize.z);

    }



cubeの回転、上下に動かす

cubeを回転させるときはx軸に対して回転させています。

上下に動かす処理は、基準になる高さにsin値 * 上下させる高さを足して高さを求めています。

void ChangeCubeParameter(uint id : SV_DISPATCHTHREADID) {

    cubeParamBuffer[id.x].angle.x += rotateSpeed * _Time;

    float time = cubeParamBuffer[id.x].randTime + _Time * moveSpeed;
    cubeParamBuffer[id.x].randTime = time;

    float baseHeight = cubeParamBuffer[id.x].baseHeight;

    cubeParamBuffer[id.x].position.y = baseHeight + sin(time) * moveHeight;

}



まとめ

ずっと勉強したかった内容をやっとまとめることが出来ました。

今回は特に変わったことはしていないので、Boidsアルゴリズムだったり興味があるものをDrawMeshInstancedIndirect+ComputeShaderで実装したいと思います。

恐らくもうちょっと続くと思うのでよろしくお願いします。



参考

gottaniprogramming.seesaa.net

docs.microsoft.com