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

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

ComputeShaderを勉強してみる

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

始めに

前回、雪のシェーダーを作成しました。その時「波も作れるんじゃね?」と思い勉強をしてましたが、ComputeShaderに出会いました。

一度前に、こちらの本を見たときに簡単に触れましたが、難しいすぎて理解できませんでした.....

https://indievisuallab.stores.jp/items/59edf11ac8f22c0152002588indievisuallab.stores.jp

その雪辱を果たすべくもう一度ちゃんと勉強してみようとこちらのサイトで改めて勉強しました。

neareal.com

加えて今回のブログは自分の理解のために書くのであまり参考にはならないかもしれません、ご了承ください


ComputeShaderって?

Unityでは普通、C#を使ってCPUで実行します。しかし、かなりの量の演算をするとfpsが落ちてしまいます。

そこでGPUを使って並列で演算を行うことによって、CPUと比べて爆速で演算することが出来ます。



カーネル スレッド グループ

初めてComputeShaderを触れたときは「どれがどれだ!?」ってなり最終的な理解まで行きませんでした。

そこを今回ちゃんと覚えることが出来ました。

以下サイトのプログラムです。

#pragma kernel KernelFunction_A
#pragma kernel KernelFunction_B

RWStructuredBuffer<int> intBuffer;
float floatValue;

[numthreads(4, 1, 1)]
void KernelFunction_A(uint3 groupID : SV_GroupID,
                      uint3 groupThreadID : SV_GroupThreadID)
{
    intBuffer[groupThreadID.x] = groupThreadID.x * floatValue;
}

[numthreads(4, 1, 1)]
void KernelFunction_B(uint3 groupID : SV_GroupID,
                      uint3 groupThreadID : SV_GroupThreadID)
{
    intBuffer[groupThreadID.x] += 1;
}



カーネルについて

サイトの引用です。

正確な定義はさておき、カーネルとは、GPU で実行される1つの処理を指し、コード上では 1 つの関数として扱われます。 関数やメソッドと呼べば良いような気がしますが、多くの資料でカーネルとされる便宜上、カーネルで覚えておくべきでしょう。


ということで、上のプログラムの関数それぞれがカーネルに相当します。

[numthreads(4, 1, 1)]
void KernelFunction_A(uint3 groupID : SV_GroupID,
                      uint3 groupThreadID : SV_GroupThreadID)
{
    intBuffer[groupThreadID.x] = groupThreadID.x * floatValue;
}

[numthreads(4, 1, 1)]
void KernelFunction_B(uint3 groupID : SV_GroupID,
                      uint3 groupThreadID : SV_GroupThreadID)
{
    intBuffer[groupThreadID.x] += 1;
}

今回は関数名にKernelと書いてあるので一目瞭然ですが、他のプログラムを読んでいるときは別の関数名になっているので「関数がカーネルなんだ」とは意識できませんでした。


しかし、関数(カーネル)はプログラムの上部で次のように定義されています。

#pragma kernel KernelFunction_A
#pragma kernel KernelFunction_B

そこでしっかりとkernelと書かれていますね。ということで、ここを意識すれば「関数がカーネルなんだ」と覚えることが出来ます。



スレッドについて

次にスレッドについて。

サイトからの引用です。

スレッドとは、カーネルを実行する単位です。 コンピュートシェーダではカーネルを複数のスレッドで並行して同時に実行することができます。 スレッドは (x, y, z) の3次元で指定しす。

ということだそうです。


スレッドは「どこで意識するのだ!?」と思っていたらカーネル同様書いてありました。

[numthreads(4, 1, 1)]
void KernelFunction_A(uint3 groupID : SV_GroupID,
                      uint3 groupThreadID : SV_GroupThreadID)
{
    intBuffer[groupThreadID.x] = groupThreadID.x * floatValue;
}

関数の前に書いてある属性(Attribute)でnumthreadsと書いてありますね。

また、numthreads(4, 1, 1)ということで、このスレッドは4 * 1 * 1 = 4つのスレッドが並列で実行されます。


グループについて

いつものサイトからの引用です。

最後にグループとは、スレッドを実行する単位です。また、あるグループが実行するスレッドは、グループスレッドと呼ばれます。


ではグループはどこに書いてあるのだ!?と思っていたら、ComputeShader内ではなくC#スクリプトから指定されていました。

以下、サイトのプログラムです。

this.computeShader.Dispatch(this.kernelIndex_KernelFunction_A, 1, 1, 1);

この関数については後に解説するので、今はグループについて解説します。

このDispatchメソッドの最後に (1, 1, 1) と渡されています。これがグループになります。

この関数では (1, 1, 1) なので 1 * 1 * 1 = 1のグループ数が実行されます。

実行されるスレッドが 4 * 1 * 1 = 4だったので合計のスレッド数は

グループ数 * スレッド数 = 4 * 1 = 4 になります。

残念ながら、ここではGroupという単語が出てこないのでここは覚えないとですね。


図にしてみる

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

この図は分かりにくいかもしれませんが、私の頭の中でこのように整理しました。



C#からComputeShaderを実行する

ComputeShaderはGameObjectにアタッチすることが不可で、それ自体では動きません。なのでC#から実行する必要があります。

ComputeShaderを実行する際は実行する関数(カーネル)をインデックスで指定します。

関数はComputeShaderで #pragma kernel で定義された順に0, 1, 2, .... と与えられますが、FindKernelメソッドでインデックス情報を取得します。

int kernelIndex_KernelFunction_A;
int kernelIndex_KernelFunction_B;
.....
.....
void Start()
{
    this.kernelIndex_KernelFunction_A
        = this.computeShader.FindKernel("KernelFunction_A");
    this.kernelIndex_KernelFunction_B
        = this.computeShader.FindKernel("KernelFunction_B");
.....
.....


この関数でインデックスを取得し、実行する際にグループ数を指定しながら実行します。

this.computeShader.Dispatch(this.kernelIndex_KernelFunction_A, 1, 1, 1);


値を渡す 受け取る

ここではComputeShaderに値を渡す、受け取る方法を解説します。

まず振り返りとしてComputeShaderの上部に変数が定義されていることを確認します。

#pragma kernel KernelFunction_A
#pragma kernel KernelFunction_B

RWStructuredBuffer<int> intBuffer;
int intValue;
.....
.....


RWStructuredBufferはT型の一次元の配列です。 int intValueは言わずもがなfloat型の変数です。

これらの変数に値を渡す際はC#から以下のようにします。

public ComputeShader computeShader;
int kernelIndex_KernelFunction_A;
int kernelIndex_KernelFunction_B;
ComputeBuffer intComputeBuffer;

void Start()
{
    this.kernelIndex_KernelFunction_A
        = this.computeShader.FindKernel("KernelFunction_A");
    this.kernelIndex_KernelFunction_B
        = this.computeShader.FindKernel("KernelFunction_B");

    this.intComputeBuffer = new ComputeBuffer(4, sizeof(int));
    this.computeShader.SetBuffer
        (this.kernelIndex_KernelFunction_A,
         "intBuffer", this.intComputeBuffer);

    this.computeShader.SetInt("intValue", 1);

細かく分けて解説します。

バッファの生成、受け渡し、解放

以下のコードでバッファを生成しています。

 this.intComputeBuffer = new ComputeBuffer(4, sizeof(int));
    this.computeShader.SetBuffer
        (this.kernelIndex_KernelFunction_A,
         "intBuffer", this.intComputeBuffer);

new ComputeBuffer(4, sizeof(int))で使用する領域を確保してthis.intComputebufferに代入しています。C言語をやっている方であればmalloc関数を思い出しますね。

そして、実行する関数にSetBufferメソッドで設定しています。引数で関数のインデックス、ComputeShader内の変数名、C#で確保したバッファ領域を渡していします。

ここでは、KernelFunction_A が実行されるときに参照される (コンピュートシェーダ内にある)intBuffer なるバッファ領域は、 intComputeBuffer で指定する領域だけ確保する、となっています。(サイト引用)

サイトによるとバッファの領域は実行前にあらかじめ確保しておく必要があり、動的に用意することが出来ないそうです。


そして、ComputeShaderを実行した結果を受け取る際は以下のように受け取ります。

int[] result = new int[4];

this.intComputeBuffer.GetData(result);


最後に必要なくなったバッファ領域は明示的に開放しなくてはいけません。ここもC言語っぽいです。

this.intComputeBuffer.Release(); 


値を渡す

スクリプトからintValueに値を渡す際は次のようにします。

this.computeShader.SetInt("intValue", 1);

ここではバッファ領域を渡すのと異なり、関数のインデックスを指定していません。そして、バッファ領域ではない変数からは値を受け取ることが出来ないみたいです。

係数を指定する際はこのように渡し、計算結果を受け取りたい場合はバッファを使わないといけないみたいです。



関数(カーネル)に渡す引数について

ComputeShaderの関数(カーネル)の引数ではセマンティックを設定します。そうすることによってどんな値が渡されるか指名することができます。

[numthreads(4, 1, 1)]
void KernelFunction_A(uint3 groupID : SV_GroupID,     // この部分!
                      uint3 groupThreadID : SV_GroupThreadID)
{
    intBuffer[groupThreadID.x] = groupThreadID.x * floatValue;
}

勉強したときはうろ覚えだったのでもう一度復習します。

SV_GroupID

SV_GroupIDはグループのIDとなります。グループのIDはC#でComputeShaderの関数を呼び出すときに指定したグループ数で、そのグループの何番目が実行されているかを表します。

this.computeShader.Dispatch(this.kernelIndex_KernelFunction_A, 1, 1, 1);


ここではグループ数は 1 * 1 * 1 = 1 なのでグループIDはすべて0になります。

もしこれが (8, 8, 1) のようになれば 0 <= x, y <= 7, z = 0になります。


SV_GroupThreadID

SV_GroupThreadIDはスレッドIDとなります。スレッド数は関数(カーネル)の定義をした時の属性(Attribute)で指定されています。

[numthreads(4, 1, 1)]
void KernelFunction_A(uint3 groupID : SV_GroupID,
                      uint3 groupThreadID : SV_GroupThreadID)
{
    intBuffer[groupThreadID.x] = groupThreadID.x * floatValue;
}

ここではnumthread(4, 1, 1)と指定されていますので、取り得る範囲は 0 <= x <= 3, y, z = 0 となります。


SV_DispatchThreadID

SV_DispatchThreadIDはグループIDとスレッドIDから算出されます。今までのプログラムでは使われていませんが、次のサイトで使われていたのでなんぞやと思い調べました。 qiita.com

SV_DispatchThreadIDは(グループID * スレッド数) + スレッドIDで求められます。


今までの例ではグループ数 (1, 1, 1)、スレッド数 (4, 4, 1) でグループID (0, 0, 0)、スレッドID (2, 1, 0) とすると

( (0, 0, 0) * (4, 4, 1) ) + (2, 1, 0) = (0, 0, 0) + (2, 1, 0) = (2, 1, 0)となります。


もしグループ数 (5, 5, 5)、スレッド数 (4, 4, 1) でグループID (2, 3, 1)、スレッドID(0, 4, 1) とすると

( (2, 3, 1) * (4, 4, 1) ) + (0, 4, 1) = (8, 12, 1) + (0, 4, 1) = (8, 16, 2)となります。


言葉にすると難しいですが、3次元配列のグループの中にある3次元配列のスレッドを1つの3次元配列に表しているということみたいです。

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

これをこんな感じに!

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

最後に

今回はこのサイトについて個人的にまとめました。

拙い内容ですが、個人的にこのブログを書いてComputeShaderの基礎を理解することが出来ました。

このサイトには続きがあるのでこれから読んでいきたいと思います。

neareal.com


追記

次回です。

shitakami.hatenablog.com



参考

neareal.com

edom18.hateblo.jp


一度このサイトでComputeShaderを使って波を作りました。 qiita.com