Help us understand the problem. What is going on with this article?

[Unity] ComputeShaderでモブを動かす【その3:車に道路上を走らせる+パーティクル版】

経緯

コンピュートシェーダー(ComputeShader)を学ぶため、自動車を動かす交通シミュレーターもどきを作ってみようと思いました。個々の自動車がそれぞれ衝突を回避しつつ適切な経路で目的地に移動できるようになるのが目標です。
前回作成した車用ポリゴン前々回作った道路 の上で走らせてみました。
caronroad03.gif

また前回、ジオメトリシェーダーとコンピュートシェーダーがMacでは同時に使えないことが判明したので、車をパーティクルシステムで代用する方法も試してみました。
caronroad04.gif

◀【その2:車を生成する
その4:衝突予測・失敗談】▶

ジオメトリシェーダー版

一旦Macのことは置いておいて、ジオメトリシェーダーで描画する車との組み合わせをやってみました。

C#側実装

そろそろ実装も複雑になってきたし、張り付けても見づらくなってきたので GitHub にリポジトリを作りました。実際のソースはこちらをご覧ください。
(初回からの分も入ってます)
https://github.com/ShinodaNaoki/learnComputeShader

CarRepository

ComputeBuffer を直接コントローラーから弄るのも煩雑なので、 CarRepository なるクラスを作って管理させることにしました。同時にこれは ComputeBuffer で使う struct をラップした Car オブジェクトとしてコントローラー側から扱えるようにしたものです。

紆余曲折ありましたが、車のstructについて、色やサイズなどの(一度車を生成したら)不変の静的情報と、速度や向きなどの動的情報に分割しました。
こうすることで、 ComputeBuffer.GetData() の負荷が幾分抑えられると考えています。

ICarStaticInfo.cs
public interface ICarStaticInfo
{
    /// <summary>
    /// サイズ
    /// </summary>
    Vector3 size { get; }

    /// <summary>
    /// 色
    /// </summary>
    Color color { get; }
}
ICarDynamicInfo.cs
public interface ICarDynamicInfo
{

    /// <summary>
    /// 座標
    /// </summary>
    Vector2 pos { get; set; }

    /// <summary>
    /// 向き(進行方向)
    /// </summary>
    Vector2 direction { get; set; }

    /// <summary>
    /// 速度
    /// </summary>
    float velocity { get; set; }
}

CarTemplate

車種ごとの色と巡航速度の雛形情報を保持するクラスです。CarRepositoryでは車種を指定するだけで、この雛形情報を元に ICarStaticInfo, ICarDynamicInfo を生成します。

RoadPlane

道路上を走らせるため、車の初期配置情報を持つ EntryPoint なるものを提供するようにしました。

RoadPlane02.cs
public class EntryPoint
{
    /// <summary>
    /// 座標
    /// </summary>
    public readonly Vector2 pos;
    public readonly Vector2 dir;

    public EntryPoint(Vector2 pos, Vector2 dir)
    {
        this.pos = pos;
        this.dir = dir;
    }
}

public class RoadPlane02 : MonoBehaviour
{
  // ..中略..

    private Vector2 ToWorldPos(Vector2 local)
    {
        // Planeのmeshサイズは10なので、なんで20なのかよくわからないけど、ぴったり合う
        var scale = 20f * transform.localScale.x / MAP_SIZE;
        // Plane が(0,0)に配置されてる前提だと、座標の起点は -MAP_SIZE/2 にある
        var half = MAP_SIZE / 2;
        return new Vector2((local.x - half) * scale, (local.y - half) * scale);
    }

    private void InitializeEntryPoints(Road02[] roads)
    {
        entryPoints = new List<EntryPoint>();
        foreach(Road02 road in roads)
        {
            var dir = (road.pos2 - road.pos1).normalized;
            var cross = new Vector2(-dir.y, dir.x); // dirと直交するベクトル
            var offset = cross * LANE_WIDTH / 2;
            var step = cross * LANE_WIDTH;
            // 上りレーン
            for(int i = 0; i< road.lanes.x; i++)
            {
                entryPoints.Add(new EntryPoint(ToWorldPos(road.pos1 + offset), dir));
                offset += step;
            }
            cross *= -1;
            dir *= -1;
            offset = cross * LANE_WIDTH / 2;
            step = cross * LANE_WIDTH;
            // 下りレーン
            for (int i = 0; i < road.lanes.y; i++)
            {
                entryPoints.Add(new EntryPoint(ToWorldPos(road.pos2 + offset), dir));
                offset += step;
            }
        }
    }

今回も、道路は直線オンリーなので、一度 EntryPoint に車を置いたら後は道をはみ出る心配もなく直進させるだけです。

CarsController

ややこしいところは CarRepository に委譲したので、比較的すっきりしてます。

CarsContorller02
public class CarsController02 : MonoBehaviour
{
  // ..中略..
    CarRepository<Car02s,Car02> factory;

    void OnDisable()
    {
        // コンピュートバッファは明示的に破棄しないと怒られます
        factory.ReleaseBuffers();
    }

    void Start()
    {
        material = new Material(carShader);
        InitializeComputeBuffer();        
    }

    void Update()
    {
        carComputeShader.SetBuffer(0, "CarsStatic", factory.StaticInfoBuffer);
        carComputeShader.SetBuffer(0, "CarsDynamic", factory.DynamicInfoBuffer);
        carComputeShader.SetFloat("DeltaTime", Time.deltaTime);
        carComputeShader.Dispatch(0, factory.Length / 8 + 1, 1, 1);
    }

    void InitializeComputeBuffer()
    {
        factory = new CarRepository<Car02s,Car02>(MAX_CARS, CarTemplate02.dictionary);
        factory.AssignBuffers();

        RoadPlane02 roadPlane = GetComponent<RoadPlane02>();
        var entries = roadPlane.EntryPoints;

        // 配列に初期値を代入する
        for (int i = 0; i < MAX_CARS; i++)
        {
            var entry = entries[ Random.Range(0,entries.Count) ];
            factory.CreateRandomType(entry.pos, entry.dir);
        }

        factory.ApplyData();
    }

    void OnRenderObject()
    {
        // 車データバッファをマテリアルに設定
        material.SetBuffer("CarsStatic", factory.StaticInfoBuffer);
        material.SetBuffer("CarsDynamic", factory.DynamicInfoBuffer);
        // レンダリングを開始
        material.SetPass(0);    
        // オブジェクトをレンダリング
        Graphics.DrawProcedural(MeshTopology.Points, factory.ActiveCars);
    }
}

シェーダー実装

シェーダーは道路用は前々回のまま、車用もおおむねそのままです。
ただ、structを静的情報と動的情報に分割したので、その対応が入ってます。
詳しくは GitHub 上でご確認ください。

DrivingComputeShader02.compute

Cars02.shader

結果1 (road03.scene)

冒頭にも貼りましたが、こんな感じです。
車はランダムに車種を変えてますが、車種ごとに色と速度が決まっているので整列してるように見えますね。(実際は同じ車種同士、同じ位置に数千台重なっています!)

caronroad03.gif

パーティクル版

次に、車をパーティクルシステムで代用する方法を試してみました。
と言っても凝ったのは面倒なの矢印のテクスチャをQuadで表示するだけにします。
ps_renderer.jpgps_config.jpg

Emissionはコードで制御するので、設定値は0だけどチェックは入れておかないとダメみたい。

C#側実装

CarController

最初はスクリプト(C#)側でパーティクル設定するようにしてみました。

CarsController03.cs
    private void cpu_UpdateParticles()
    {
        var cars = factory.GetCars();
        var emitter = particlesSystem.emission;
        emitter.rateOverTime = cars.Length;
        int numParticlesAlive = particlesSystem.GetParticles(particles, cars.Length);
        int i = 0;
        foreach (var car in cars)
        {
            var par = particles[i];
            var pos = car.Dynamic.pos;
            par.position = new Vector3(pos.x, 0.1f, pos.y) * 0.5f;
            par.startSize3D = car.Static.size;
            par.startColor = car.Static.color;
            var dir = car.Dynamic.direction;
            par.rotation3D = new Vector3(1, 1 - dir.y, dir.x) * 90;
            particles[i] = par;
            i++;
        }
        while(i < numParticlesAlive)
        {
            particles[i++].startLifetime = 0;
        }
        particlesSystem.SetParticles(particles, cars.Length);
    }

結果2 (road04.scene)

caronroad04.gif
これでも動きますが、パフォーマンスが気になったので調べてみました。

パフォーマンス改良

パフォーマンス比較1

一万台の車を動かした状態です。
【ジオメトリシェーダー版】
prof03.jpg
【パーティクルCPU設定版】
prof04.jpg

あからさまですね。毎フレーム10000回ループしてるんだから、当然ですか。
GPUの負荷増加はパーティクルとジオメトリシェーダーの違いだと思います。面倒くさがって透明ありテクスチャを使ってますが、矢印程度ならmeshにしたほうが幾分マシになったかもしれませんね。

なお、ComputeBuffer.GetData() で GPU側で得た結果を取得するだけなら、比較的高速(下記の図で 1.03ms と 0.60ms)なので毎フレームやっても問題はなさそうでした。
prof04-2.jpg
これは静的情報と動的情報に分ける前の状態なので、最終的にはさらにこれの約半分になってます。

CarController改良

ParticleSystem.Particle も struct なので、そのまま ComputeBuffer に使えるんじゃないか?
そうしたらGPU側でパーティクルを設定できて、パフォーマンス大幅向上できるんじゃないか?
と考えました。

実際やってる人がいました!
https://github.com/sugi-cho/Unity-ParticleSystem-GPUUpdate

ただ、Unityバージョンの違いのせいか、この人のcgincでは上手く動かなかったので、即席で実際の構造を出力するコードを作って調べてみました。

structParticle.jpg
こんな感じで出力されました。どうやら最後のm_Flagsが足りなかった模様。
早速これを使って試してみました。

CarsController03.cs
    private void gpu_UpdateParticles()
    {
        var cars = factory.GetCars();
        var emitter = particlesSystem.emission;
        emitter.rateOverTime = cars.Length;
        int numParticlesAlive = cars.Length;

        particlesSystem.GetParticles(particles, cars.Length);
        particleBuffer.GetData(particles, 0, 0, numParticlesAlive);

        particlesSystem.SetParticles(particles, numParticlesAlive);
    }

パーティクル対応シェーダー

DrivingComputeShader03.compute
// 出力先のパーティクルバッファ
RWStructuredBuffer<Particle> Particles;

// Particle構造体へコピー
inline void PrepareParticle(in CarS carS, in CarD carD, inout Particle p)
{
    p.m_Position = half3(carD.pos.x, 0.1, carD.pos.y) * 0.5;
    p.m_StartSize = carS.size;
    p.m_StartColor = Particle_ColorToUint(carS.col);    
    const float radian90 = 3.14159 / 2;
    p.m_Rotation = half3(1, 1 - carD.dir.y, carD.dir.x) * radian90; // 角度単位がC#と違う
    p.m_StartLifetime = 1;
    p.m_Lifetime = 1;
}

[numthreads(8,1,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
    CarD carD = CarsDynamic[id.x];
    // それぞれの位置情報に移動ベクトルを加算 (0.28はkm/hをm/sに変換する係数)
    carD.pos += carD.dir * carD.velocity * DeltaTime * 0.28;
    CarsDynamic[id.x] = carD;
    PrepareParticle(CarsStatic[id.x], carD, Particles[id.x]);
}

ところで、シェーダーの関数はCみたいに利用より先に定義記述しないとダメみたい?

パフォーマンス比較2

これも一万台の車を動かした状態です。

【パーティクルGPU設定版】
prof04-b.jpg

見ての通り、かなり効果がありました!
縦軸のスケールが約半分くらいになってるので、一見した以上に高速化されてます。

【パーティクルCPU設定版】
prof04.jpg

流石にジオメトリシェーダー版ほどではないですが、近いレベルになったかと思います。

【ジオメトリシェーダー版】
prof03.jpg

これ実装した後で、パーティクルにもGPUインスタンシング機能があるらしいことを知ったのですが、既に本来の目標からだいぶ横道に逸れているので、これ以上の深入りはしないでおきます。🙄

気づいたこと・まとめ

(※まだ大目標は完了じゃないですが)

複数のComputeBufferを使ったり、GPUで計算した結果をCPUで使ったり、別のシェーダーに渡したりする方法がわかった。当たり前だが、SetDataした後の配列を保持しておいてもGetDataしない限り、GPU側の変更は反映されない。

Macでは一部機能に制限があることを知った。

ComputeBuffer でやりとりする構造体はC#:HLSL間で矛盾があってもチェックされない。例えば float3 が float4 になってたりすると、一見訳のわからないことが起きたりする。Particleの構造体のメンバー数が間違ってた時もおかしな挙動になりました。

ComputeBuffer は確保した数だけ埋める必要がある。あとでセット数を減らすと、以前のデータがGPU上に残ってる(少なくともUnityの再生・停止ぐらいでは消えない)模様。ちゃんとReleaseするのはもちろん、Bufferサイズを適切に管理する必要がある。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away