前回の続きです。
今回のパートからコードを交えた解説になってます。
元の記事はこちら。
このパートでは、
- 簡単なシェーダの書き方・スクリプトとのやりとり方法
- スレッドグループの使い方、スレッドIDなどの取得方法
- uniformなデータのシェーダからの渡し方
あたりについて書かれてますね。
ではでは、
誤訳などあるかもしれませんが、暖かい目で見てください。
#DirectCompute tutorial for Unity: Kernels and thread groups
基本のところ
さっきの記事では紹介だったけど、この記事からはがっつりコードになるよ。今日は、Unityでコンピュートシェーダを書くための主な概念について書くよ。コンピュートシェーダの中心はKernel。これはシェーダのエントリポイントで、他の言語でいうメイン関数のように動作する。
GPUによってスレッドを敷き詰める方法についてもカバーするよ。この敷き詰めるタイルは、ブロックもしくはスレッドグループとして知られている。DirectComputeは公式にはこのタイルのことをスレッドグループと呼んでいる。
Unityでコンピュートシェーダを作るにはプロジェクトパネルに行って、Create->compute shaderを選ぶ。シェーダをダブルクリックするとMonodevelopで編集ができる。以下のコードを新しく生成したコンピュートシェーダに貼り付けてみよう。
#pragma kernel CSMain1
[numthreads(4,1,1)]
void CSMain1()
{
}
これは、何もしないコンピュートシェーダの最小セットなんだけど、最初にみるものとしてはいいと思う。コンピュートシェーダはUnityのスクリプトから実行しないといけない。そのために、プロジェクトパネルから、Create->C# scriptを開いて、KernelExampleという名前で以下のコードをコピペしよう。
using UnityEngine;
using System.Collections;
public class KernelExample : MonoBehaviour
{
public ComputeShader shader;
void Start ()
{
shader.Dispatch(0, 1, 1, 1);
}
}
それじゃ、スクリプトを適当なゲームオブジェクトにドラッグして追加して、コンピュートシェーダをシェーダのアトリビュートに割り当ててみよう。シェーダはシーンを実行時にスタート関数から実行されてるはず。
でもシーンを実行する前に、dx11をUnityで使えるようにする必要があるね。Edit->Project Settings->Player から”Use Direct3D 11”というボックスにチェックを入れておこう。そうするとシーンを実行できるよ。シェーダはなにもしないけど、エラーは起こってないはずだ。
スクリプトの中では"Dispatch"関数が呼ばれてるよね。これがシェーダを実行する仕事をするんだ。最初の引数は0になってるでしょう。これは君が動かしたいカーネルIDを入れる。
シェーダでは、"#pragma kernel CSMain1"とかいてあるよね。この定義は、シェーダ内のどの関数がカーネルであるかを定義していて、多分たくさんの関数(とたくさんのカーネル)をそのうち見ることになるよ。シェーダの中でCSMain1という名前の関数があって、シェーダはまだコンパイルされてない。
"[numthreads(4,1,1)]"と書かれた行に気づいたかな?これはGPUがいくつのカーネルスレッドをグループごとに走らせるかについて教えてくれる。3つの数字はそれぞれの次元を表している。スレッドグループは3次元から成り立っていて、サンプルでは1次元のグループで4つのスレッドを動かしているね。
この意味は、我々は全部で4つのスレッドを動かしていて各スレッドはカーネルのコピーになっているということ。これがなぜGPUは速いかの理由になる。同時に数千のスレッドを走らせることもできるんだ。
それじゃカーネルに実際に何かさせてみよう。シェーダを以下のように変えてみて。
#pragma kernel CSMain1
RWStructuredBuffer<int> buffer1;
[numthreads(4,1,1)]
void CSMain1(int3 threadID : SV_GroupThreadID)
{
buffer1[threadID.x] = threadID.x;
}
同時にスクリプト側も以下のように変えてみよう。
void Start ()
{
ComputeBuffer buffer = new ComputeBuffer(4, sizeof(int));
shader.SetBuffer(0, "buffer1", buffer);
shader.Dispatch(0, 1, 1, 1);
int[] data = new int[4];
buffer.GetData(data);
for(int i = 0; i < 4; i++)
Debug.Log(data[i]);
buffer.Release();
}
シーンを実行すると、0,1,2,3の数字が表示されるはずだ。バッファについては今は心配しなくていい。そのうちその点についての詳細もカバーするよ。今は、バッファはデータを保存する場所でそれはrelease関数を終了時に呼び出さなくてはならないということを知っておこう。
次の引数がCSMain1の関数に追加されていることに気づくはずだ。"int3 threadID : SV_GroupThreadID" これは実行時にGPUにカーネルにスレッドIDを渡してくださいという要求だ。スレッドIDをバッファ内に書き込んだおかGPUに4つのスレッドを動かしていてIDは0から3の値を持つということを伝えているんだ。同時に我々も表示を見て確認できる。
そして、その4つのスレッドは一つのスレッドグループから作られるんだ。この場合4つのスレッドの1グループを実行しているんだけど複数のスレッドグループを実行することもできる。それでは、1つじゃなくて2つのグループを動かしてみよう。シェーダカーネルを以下のように変えてみて。
void CSMain1(int3 threadID : SV_GroupThreadID, int3 groupID : SV_GroupID)
{
buffer1[threadID.x + groupID.x*4] = threadID.x;
}
スクリプトのStart関数も以下のように変えてみよう。
void Start ()
{
ComputeBuffer buffer = new ComputeBuffer(4 * 2, sizeof(int));
shader.SetBuffer(0, "buffer1", buffer);
shader.Dispatch(0, 2, 1, 1);
int[] data = new int[4 * 2];
buffer.GetData(data);
for(int i = 0; i < 4 * 2; i++)
Debug.Log(data[i]);
buffer.Release();
}
シーンを実行すると、0-3の表示が2回されることがわかる。dispatch関数の変更点に気づくはずだ。後ろの3つの変数(2,1,1)は実行したいグループの数で、スレッドグループの数は3次元で表され、この場合は1次元に2つのグループが存在することになる。
カーネルもまた、引数に変更点を加えていて”int3 groupID : SV_GroupID”を追加している。これは、GPUにカーネル実行時にグループIDを渡すように要求するためなんだ。なぜなら、2グループ4スレッドで8個出力する必要があるからね。
我々はバッファ内のスレッド位置を知ることが必要で、それを求める計算式はスレッドIDにグループIDとスレッド数を掛けたものを足すことで求まる(threadID.x + groupID.x*4)。これはちょっとやりずらいよね。
GPUは本当にスレッド位置を知らないのかね?もちろんそんなことはない。シェーダのカーネルを以下のように書き換えてシーンに戻ろう。
void CSMain1(int3 threadID : SV_GroupThreadID, int3 dispatchID : SV_DispatchThreadID)
{
buffer1[dispatchID.x] = threadID.x;
}
結果は同じになり、2セットの0-3が表示される。グループIDの引数が、”int3 dispatchID : SV_DispatchThreadID”に置き換えられていることに気づいてね。これは計算式で我々が求めると予想される値をGPUが計算してくれているんだ。
スレッドグループ内でのスレッド位置をね。
次元数を増やしてみる
これまでは1次元で全部やってきた。次は少しだけレベルを上げて、カーネルを書き換える代わりにもう一つ別のシェーダを追加して、2次元に進もう。
同じアルゴリズムを実行するシェーダ内の各次元に同じカーネルを持たせるのは一般的でない。最初に、以下のコードをシェーダ内でさきほどのコードの下に追加して、2つのカーネルを持つシェーダにしてみよう。
#pragma kernel CSMain2
RWStructuredBuffer<int> buffer2;
[numthreads(4,4,1)]
void CSMain2( int3 dispatchID : SV_DispatchThreadID)
{
int id = dispatchID.x + dispatchID.y * 8;
buffer2[id] = id;
}
そして、スクリプトは以下のように…
void Start ()
{
ComputeBuffer buffer = new ComputeBuffer (4 * 4 * 2 * 2, sizeof(int));
int kernel = shader.FindKernel ("CSMain2");
shader.SetBuffer (kernel, "buffer2", buffer);
shader.Dispatch (kernel, 2, 2, 1);
int[] data = new int[4 * 4 * 2 * 2];
buffer.GetData (data);
for(int i = 0; i < 8; i++)
{
string line = "";
for(int j = 0; j < 8; j++)
{
line += " " + data[j+i*8];
}
Debug.Log (line);
}
buffer.Release ();
}
シーンを実行して、0-7が表示されその次に8-15といった形で63まで表示されるはずだ。なぜ63なの?我々は4つの2次元グループのスレッドを持っていて、各グループが4x4の16スレッドを持つからだ。つまり全部で64スレッドになる。
我々が表示している値が、”int id = dispatchID.x + dispatchID.y * 8”のラインから来ていることに気づくはずだ。
dispatchIDは各次元でのグループスレッド内でのスレッドの位置である。2次元なのでバッファ内でのスレッドのグローバル位置が必要となり、dispatchIDのXにdispatchIDのYと最初の次元(4*2)の総スレッド数を掛けたものを足しあわせた値になる。
これが、コンピュートシェーダを使って処理するために慣れて置かなければならない概念だ。
なぜなら、バッファはいつも1次元であるのに対して、より高次元で実行する際にはどのインデックスでバッファに書き込みが行われるかを計算する必要があるからだ。
この理論は3次元で動かすときにも同様であるがややこしいので、2次元までのデモで勘弁してほしい。
3次元で知らなければいけないこと、つまりバッファ位置は、”int id = dispatchID.x + dispatchID.y * groupSizeX + dispatchID.z * groupIndexX * groupSizeY”で計算される。group sizeはその次元でのスレッド数とグループ数を掛けたものである。
##Semanticについて
同様に、セマンティクスがどう働いているかを理解しておいた方がいい。例えば、このカーネル引数
int3 dispatchID : SV_DispatchThreadID
SV_DispatchThradIDはセマンティクスで、GPUにこの引数が何の値を渡すのかを伝えるものである。引数の名前は関係ない。どんな名前でも呼べる。例えば以下も上の例と同じように動く。
int3 id : SV_DispatchThreadID
また、変数の方も変えられる。例えば...
int dispatchID : SV_DispatchThreadID
ご覧のように、in3はintに変更されている。これは一次元で動かすときは良い感じに動いてくれる。2次元の場合はint2を使うと同様に動くし、unsigned int(uint)もintの代わりに選んで使うこともできる。
シェーダの中では2つのカーネルが動いているので、dispatch呼び出し時にGPUにどのカーネルを動かしたいのかを伝える必要がある。個々のカーネルには出現順にIDが割り振られる。
最初のカーネルはidが0になり、次は1。シェーダ内のカーネルの数が増えると、間違ったIDをセットしやすくなるので混乱を多少招くことがある。そのために、名前を使ってカーネルIDをシェーダに聞くことで解決することができる。
”int kernel = shader.FindKernel("CSMain2”);"の行でカーネル"CSMain2"のIDを取得している。そしてこのIDは、バッファの設定とdispatch関数のコールの時に使うことになる。
ここまで説明してきたけど、スレッドグループの概念を考えると多少混乱するのではないかと思っている。なぜ一つのグループのスレッドを単純に使わないのか?その理由はGPUによってグループ内のスレッドが並列化されていることになるんだ。
スレッドグループを開始する際に持てるスレッドの数は限定される("[numthreads(x,y,z)]"の行で定義されている)。この制限は今は1024だがハードウェアが新しくなると変わるだろう。例えば、最大のスレッドを"numthreads(1024, 1, 1)"のように1次元で、"numthreads(32, 32, 1)"のように2次元でも持てる。
どれだけのスレッドグループを持てたとして100万単位の要素のデータを頻繁に計算するとしても、スレッドグループの概念が重要である。グループ内のスレッドはメモリをシェアし、劇的なパフォーマンスを特定のアルゴリズム上では得ることができる。これについては次回以降の記事で話をする予定だ。
##スクリプトから変数を渡す
以上で、カーネルとスレッドグループについて大体カバーできたと思う。ここでもう一つだけ話をしておこう。uniformsをシェーダにどうやって渡すのかだ。
これはCgシェーダと同じように働くが、uniformというキーワードを使っていない。ほとんどは比較的簡単なのだが、"Gotcha's"(バグやミスを誘発しやすいもの)がいくつかある。
なので簡潔に説明したいと思う。
例えば、floatデータをシェーダに渡したいと思い、シェーダに以下のラインを記述する。
float myFloat;
そしてスクリプトにはこのように書く。
shader.SetFloat("myFloat", 1.0f);
vectorをセットしたくなったので、次のようにシェーダを書く。
float4 myVector;
スクリプトではこのように書く。
shader.SetVector("myVector", new Vector4(0,1,2,3));
スクリプトからはVector4でないと渡せないと思うだろうけど、uniformは、float、float2、float3、float4でもいけるんだ。適切な値で埋められた形で。
ここからはトリッキーなやりかたを説明する。値の配列を渡したいと考えたとしよう。そして、最初の例(floatデータを渡す例)でも動いてしまう。なぜかを説明してみよう。シェーダでは以下のように書く必要がある。
float myFloats[4];
そしてスクリプトでは、
shader.SetFloats("myFloats", new float[]{0,1,2,3});
このやりかたで動く。これがUnityのデザインなのかバグなのかはわからない。vectorsをuniformsとして使う必要がある。シェーダでは、
float myFloats[4];
スクリプトでは、
shader.SetFloats("myFloats", new float[]{0,1,2,3});
これが動く。float2やfloat3でも同じように動く。単一のfloatはだめだ。vectorsの配列も可能だ。シェーダでは、
float4 myFloats[2];
スクリプトでは、
shader.SetFloats("myFloats", new float[]{0,1,2,3,4,5,6,7});
これによって、float4の2つの配列をスクリプトからは8個のフロートの配列としてセットした。この原理は行列でも同様に適用できる。シェーダでは、
float4x4 myFloats;
そしてスクリプトでは、
shader.SetFloats("myFloats", new float[]{0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15});
もちろん、行列の配列も可能だ。シェーダでは、
float4x4 myFloats[2];
スクリプトでは、
shader.SetFloats("myFloats", new float[]{0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31});
このロジックは、float2x2やfloat3x3では上手く動いていないように見える。もう一度言うけども、これはバグかデザインなのかわからない。
今日についてはここまで。
次のパートでは、テクスチャをどうやってコンピュートシェーダで使うかを説明するよ。カーネルのサンプルについてはプロジェクトファイルをダウンロードできるようにしているよ。
基本的なものだけど、必要なら使って。これからもチュートリアルごとにプロジェクトファイルは追加していくつもりだよ。
#訳してみて
予想以上に長い。。。
これは分割しないといけないかもなー。