#経緯
コンピュートシェーダー(ComputeShader)を学ぶため、自動車を動かす交通シミュレーターもどきを作ってみようと思いました。個々の自動車がそれぞれ衝突を回避しつつ適切な経路で目的地に移動できるようになるのが目標です。
前回作成した車用ポリゴン を 前々回作った道路 の上で走らせてみました。
また前回、ジオメトリシェーダーとコンピュートシェーダーがMacでは同時に使えないことが判明したので、車をパーティクルシステムで代用する方法も試してみました。
◀【その2:車を生成する】
【その4:衝突予測・失敗談】▶
ジオメトリシェーダー版
一旦Macのことは置いておいて、ジオメトリシェーダーで描画する車との組み合わせをやってみました。
##C#側実装
そろそろ実装も複雑になってきたし、張り付けても見づらくなってきたので GitHub にリポジトリを作りました。実際のソースはこちらをご覧ください。
(初回からの分も入ってます)
https://github.com/ShinodaNaoki/learnComputeShader
CarRepository
ComputeBuffer を直接コントローラーから弄るのも煩雑なので、 CarRepository なるクラスを作って管理させることにしました。同時にこれは ComputeBuffer で使う struct をラップした Car オブジェクトとしてコントローラー側から扱えるようにしたものです。
紆余曲折ありましたが、車のstructについて、色やサイズなどの(一度車を生成したら)不変の静的情報と、速度や向きなどの動的情報に分割しました。
こうすることで、 ComputeBuffer.GetData() の負荷が幾分抑えられると考えています。
public interface ICarStaticInfo
{
/// <summary>
/// サイズ
/// </summary>
Vector3 size { get; }
/// <summary>
/// 色
/// </summary>
Color color { get; }
}
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 なるものを提供するようにしました。
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 に委譲したので、比較的すっきりしてます。
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
結果1 (road03.scene)
冒頭にも貼りましたが、こんな感じです。
車はランダムに車種を変えてますが、車種ごとに色と速度が決まっているので整列してるように見えますね。(実際は同じ車種同士、同じ位置に数千台重なっています!)
パーティクル版
次に、車をパーティクルシステムで代用する方法を試してみました。
と言っても凝ったのは面倒なの矢印のテクスチャをQuadで表示するだけにします。
Emissionはコードで制御するので、設定値は0だけどチェックは入れておかないとダメみたい。
##C#側実装
CarController
最初はスクリプト(C#)側でパーティクル設定するようにしてみました。
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)
これでも動きますが、パフォーマンスが気になったので調べてみました。
パフォーマンス改良
パフォーマンス比較1
一万台の車を動かした状態です。
【ジオメトリシェーダー版】
【パーティクルCPU設定版】
あからさまですね。毎フレーム10000回ループしてるんだから、当然ですか。
GPUの負荷増加はパーティクルとジオメトリシェーダーの違いだと思います。面倒くさがって透明ありテクスチャを使ってますが、矢印程度ならmeshにしたほうが幾分マシになったかもしれませんね。
なお、ComputeBuffer.GetData() で GPU側で得た結果を取得するだけなら、比較的高速(下記の図で 1.03ms と 0.60ms)なので毎フレームやっても問題はなさそうでした。
これは静的情報と動的情報に分ける前の状態なので、最終的にはさらにこれの約半分になってます。
CarController改良
ParticleSystem.Particle も struct なので、そのまま ComputeBuffer に使えるんじゃないか?
そうしたらGPU側でパーティクルを設定できて、パフォーマンス大幅向上できるんじゃないか?
と考えました。
実際やってる人がいました!
https://github.com/sugi-cho/Unity-ParticleSystem-GPUUpdate
ただ、Unityバージョンの違いのせいか、この人のcgincでは上手く動かなかったので、即席で実際の構造を出力するコードを作って調べてみました。
こんな感じで出力されました。どうやら最後のm_Flagsが足りなかった模様。
早速これを使って試してみました。
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);
}
パーティクル対応シェーダー
// 出力先のパーティクルバッファ
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インスタンシング機能があるらしいことを知ったのですが、既に本来の目標からだいぶ横道に逸れているので、これ以上の深入りはしないでおきます。🙄
気づいたこと・まとめ
(※まだ大目標は完了じゃないですが)
複数のComputeBufferを使ったり、GPUで計算した結果をCPUで使ったり、別のシェーダーに渡したりする方法がわかった。当たり前だが、SetDataした後の配列を保持しておいてもGetDataしない限り、GPU側の変更は反映されない。
Macでは一部機能に制限があることを知った。
ComputeBuffer でやりとりする構造体はC#:HLSL間で矛盾があってもチェックされない。例えば float3 が float4 になってたりすると、一見訳のわからないことが起きたりする。Particleの構造体のメンバー数が間違ってた時もおかしな挙動になりました。
ComputeBuffer は確保した数だけ埋める必要がある。あとでセット数を減らすと、以前のデータがGPU上に残ってる(少なくともUnityの再生・停止ぐらいでは消えない)模様。ちゃんとReleaseするのはもちろん、Bufferサイズを適切に管理する必要がある。