LoginSignup
2
3

More than 3 years have passed since last update.

[Unity] ComputeShaderでモブを動かす【その6:二次元スレッド・複カーネル化/効果なし】

Last updated at Posted at 2019-07-04

経緯

コンピュートシェーダー(ComputeShader)を学ぶため、自動車を動かす交通シミュレーターもどきを作ってみようと思いました。個々の自動車がそれぞれ衝突を回避しつつ適切な経路で目的地に移動できるようになるのが目標です。
road07_gpu_run.gif
前回組み込んだ衝突予測と減速 が、200台に満たない程度でも時々カクつくのが気になりました(上のキャプチャは170台前後で推移)。そこでシェーダーのスレッドグループをチューニングしてパフォーマンス向上できないか試してみました。

なお、コードはいままでの分も含め全てGithubにあります。

◀【その5:衝突予測・リベンジ
その7:if文削減で高速化/イマイチ】▶

修正方針

ずっと気になってたのはこの部分です。

DrivingComputeShader04.compute
[numthreads(8,1,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
  uint idMin = id.x;
  int timeMin = 1000; // このままだとあと何回で衝突するか

  for(uint i = 0; i < count; i++) {
    if(i == id.x) continue;
    if(CarsStatic[i].size.z == 0) break; // 削除済みのデータ=末端に到達
    FindMostDangerCar(id.x, i, timeMin, idMin);
  }

id.x を車のindexとして使用していますが、下のfor文で全部の車に対して衝突予測を繰り返しています。
ここはこんな風に↓for文をやめて 二次元スレッドのグループ で回したら、シェーダーが本領発揮できるんじゃないかと思いました。

[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
  uint idMin = id.x;
  int timeMin = 1000; // このままだとあと何回で衝突するか

  if(id.x == id.y) return;
  if(CarsStatic[id.x].size.z == 0) break;
  FindMostDangerCar(id.x, id.y, timeMin, idMin);

※↑あくまでイメージです。実際は このままではいろいろ問題ある ので、詳細は後述します。

なお、複数カーネル化の修正にあたって、下記記事を参考にさせていただきました。
【凹みTips: Unity で Compute Shader を使ったスクリーンスペース衝突有りの GPU パーティクルを作ってみた】
http://tips.hecomi.com/entry/2016/05/08/160626

構造体の修正

カーネル間で情報を渡すため、フィールドを追加しました。
colider はデバッグ目的で以前から入れてましたが、ロジックで利用するのは今回が初めてです。

CarTemprate05.cs
public struct Car05d : ICarDynamicInfo
{
    public Vector2 pos { get; set; }
    public Vector2 direction { get; set; }
    public float velocity { get; set; }
    public int lane { get; set; }
    public int colider { get; set; } // 衝突予想相手
    public float ticks { get; set; } // 衝突予想時間

    public override string ToString()
    {
        return string.Format("[{0},{5}({1:0.0},{2:0.0})>>({3:0.0},{4:0.0})]", 
            typeof(Car05d).Name, 
            pos.x, pos.y, 
            direction.x * velocity, direction.y * velocity,
            lane
        );
    }
}

シェーダーの修正

for文の部分は二次元スレッドにするとして、それ以外の部分は一次元で回したいです。そのため既存のコードを 複数カーネルに分割する 必要があります。

まず、for文の前にあった変数の初期化処理を第一のカーネルとして抽出しました。

DrivingComputeShader06.compute[1]
// (64, 1, 1)のスレッドで回す
[numthreads(64,1,1)]
void Init (uint3 id : SV_DispatchThreadID)
{
  CarD carD = CarsDynamic[id.x];
  carD.colider = id.x;
  carD.ticks = 100000;

  CarsDynamic[id.x] = carD;
}

次は、for文に相当する二次元スレッド部分。最初から6行目あたりまでは CSMain から、残りは前回までの FindMostDangerCar 関数の内容ほぼそのままです。

DrivingComputeShader06.compute[2]
// (8, 8, 1)のスレッドで回す
// 最も衝突の可能性の高い車のid(index)を返す
[numthreads(8,8,1)]
void Scan (uint3 id : SV_DispatchThreadID)
{
  uint id1 = id.x;
  uint id2 = id.y;

  if(id1 == id2) return;

  CarS carS1 = CarsStatic[id1];
  CarS carS2 = CarsStatic[id2];
  if(carS1.size.z == 0 || carS2.size.z == 0) return; // 削除済みのデータ=末端に到達

  CarD carD1 = CarsDynamic[id1];
  CarD carD2 = CarsDynamic[id2];
  if(carD1.ticks <= 0) return; // 時すでに遅し

  // 別車線は無視
  if (carD1.lane != carD2.lane)
  {
      return;
  }

  // 相対位置ベクトル
  float2 diffPos = carD2.pos - carD1.pos;

  // 背後から接近してくるものは回避しない(相手任せ)
  if (dot2d(carD1.dir, diffPos) <= 0) return;

  // 相対速度ベクトル
  float diffVel = (carD1.velocity - carD2.velocity) * 0.28;
  if(diffVel < 0.00001){
    return; // 接近していない
  }

  float absPos = length(diffPos);
  float countAssume = absPos / diffVel;
  if(countAssume > 100000){
    return; // 遠い未来過ぎるので無視
  }

  // 二つの車のサイズを考慮した距離を求める
  // 同一車線なので基本的に両車の長さの半分を足したもの
  float distance = (carS1.size.z + carS2.size.z) * 0.5; 
  // どちらかが高速で移動しているなら停止距離には余裕を持つ
  distance += max(carD1.velocity, carD2.velocity) * 0.28f;

  float t = max(0, (absPos - distance) / diffVel);
  // このままだと近い将来衝突しそう
  if(t > carD1.ticks){
    return; // もっと近い相手が既にいる
  }

  // 最小値更新
  carD1.ticks = t;
  carD1.colider = id2;

  CarsDynamic[id.x] = carD1;
}

最後に for文の後のブロック、衝突予測結果を用いて加減速と、速度に応じた移動を行うブロックです。

DrivingComputeShader06.compute[3]
// (64, 1, 1)のスレッドで回す
[numthreads(64,1,1)]
void Drive (uint3 id : SV_DispatchThreadID)
{

  CarD carD = CarsDynamic[id.x];
  CarS carS = CarsStatic[id.x];

  if(carD.colider == id.x) { // 衝突の可能性の高い車はない
    if(carD.velocity < carS.idealVelocity) {
      carD.velocity = min(carD.velocity + carS.mobility, carS.idealVelocity);
    }
  }
  else {
    if(carD.velocity > 0) {
      carD.velocity = max(0,  carD.velocity - carS.mobility * 2.0);
      if( length(CarsDynamic[carD.colider].pos - carD.pos) < 5 ){
        carD.velocity = 0;
      }
    }
  }

  // それぞれの位置情報に移動ベクトルを加算 (0.28はkm/hをm/sに変換する係数)
  carD.pos += carD.dir * carD.velocity * DeltaTime * 0.28;
  CarsDynamic[id.x] = carD;
}

おっと、増やした&リネームした関数をファイルの先頭でカーネルとして宣言するのも忘れずに。

DrivingComputeShader06.compute[0]
#pragma kernel Init
#pragma kernel Scan
#pragma kernel Drive

コントローラー(C#)の修正

カーネルが複数になったので、Start時に FindKernel を使って正しい index を取得、フィールドに保持しておきます。

CarsController06.cs
   private int[] Kernels;

    /// <summary>
    /// コンピュートバッファの初期化(Startから呼ばれる)
    /// </summary>
    void InitializeComputeBuffer()
    {
        factory = new CarRepository(MAX_CARS, CarTemplate05.dictionary);
        factory.AssignBuffers();


        StartCoroutine(WatchLoop(OnEachScan, OnEachElement));

        Kernels = new int[]
        {
            carComputeShader.FindKernel("Init"),
            carComputeShader.FindKernel("Scan"),
            carComputeShader.FindKernel("Drive")
        };

        factory.ApplyData();
    }

Updateでカーネルを呼び出す部分も修正します。
コンピュートバッファーはカーネルが変わるたびにセットし直す必要があるようです。

CarsController06.cs
    void Update()
    {
        carComputeShader.SetInt("count", factory.ActiveCars);
        carComputeShader.SetFloat("DeltaTime", Time.deltaTime);
        var carnum = factory.ActiveCars;
        foreach (int index in Kernels)
        {
            carComputeShader.SetBuffer(index, "CarsStatic", factory.StaticInfoBuffer);
            carComputeShader.SetBuffer(index, "CarsDynamic", factory.DynamicInfoBuffer);
            if(index == 1)
            { // Scanフェーズだけ二次元
                carComputeShader.Dispatch(index, carnum / 8 + 1, carnum / 8 + 1, 1);
            }
            else
            {
                carComputeShader.Dispatch(index, carnum / 64 + 1, 1, 1);
            }
        }
    }

if(index == 1) は横着しました。FindKernel使う意味が無くなるような決め打ちです。すみません。
せっかく FindKernel("Scan") 使って、今後のindex変動に影響がないようにしてるのに、ここで固定値使ったら片手落ちですね。
 

パフォーマンス比較

冒頭に貼った画像の状態でプロファイリングしました。車の数は 160〜170台で落ち着きます。
road07_gpu_run.gif

多次元グループ化

スレッドグループ、一次元と二次元
[numthreads(64,1,1)] prf_r06_64.jpg
[numthreads(8,8,1)] prf_r07_64.jpg

ご覧の通り、顕著な差は見られません。ちょっと期待してたのですが残念な結果に。
参考にしたサイトには「二次元配列を一つのindexで回すような計算の無駄を省ける」とか書いてあったが、逆に計算量が変わらないのならば効果はないということか。

時々カクつくのは、GPU Usage に時々ピークがあるせいでしょう。(CPU Usage にもばらつきありますが、fps的に無視できるレベル。)

スレッド総数

最初に参考にしたソースが[numthreads(8,1,1)]だったので、ずっとそれでやってきましたが、8というのはどうやって決めたのかふと疑問に思ったので、スレッド数を変えることでどんな効果があるのか4と128という極端なスレッド数で比較してみました。

グループ内スレッド数、4スレッドと128スレッド
[numthreads(4,1,1)] prf_r06_4.jpg
[numthreads(128,1,1)] prf_r06_128.jpg

こちらも顕著な差は見られませんでした。
ちょっとつまらない気もしたけれど、 最適値にチューニングする必要がないと考えれば気が楽ですね。
スレッド数はデータを扱いやすい単位で好きに指定すればよさそうです。

まとめ・感想

  • パフォーマンス向上を期待して、二次元スレッド・多段カーネル化してみた。
  • しかしパフォーマンスに顕著な差はみられなかった
  • 計算量が変わらない限り、スレッドの次元やカーネル数変えてもあまり意味はないようだ。
  • 一度に回すスレッド数も、全体の計算量が変わらない限り意味はなさそうだ。
  • カクつきは GPU Usage の偏りが原因と思われる。⇒ if文を減らすべきかも
2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3