こんにちは!
情熱開発部 プログラム課 1 のボルです。もう11月になってきました。1年って早いですね。信じてくれないかもしれませんが、実は1年よりも早いものがあります。そうです。今回はCompute Shader(コンピュート シェーダー)の記事です。
Compute Shader( 以降CSと表記 )というのは文字通り、何かを計算してくれるシェーダーです。CPU上ではなく、GPU上計算したい場合はこちらを使います。GPUの方が 並列処理が強いので、お互いに影響がなく、たくさんの計算が必要な場合はCPUよりかなり早いケースが多いです。例えばグラス(草)のメッシュの生成や、ボリューメトリックな風のシミュレーションなどはCPU上だとかなり負荷かかりますがGPUだと割とサラッといけます。
その二つの例はかなり複雑で説明するのはかなり時間かかりますので、今回は単純の例をUnityで組みましょう:Conway’s Game of Life(ライフゲーム)。
今回はUnity2021.3.13f1(LTS)を使います。Compute Shaderは2020.3で追加された機能なので、それ以降のリリースだと問題なく再現できるはずです。Shader Model 5.0利用可のGPUも必要になります。
ライフゲーム
ライフゲームというのは単純なBooleanのグリッドで走るアルゴリズムです。
グリッドのピクセルが自分の周りをチェックし、自分をOn・Offする。全ピクセルが更新終わったら次のループが始まります。 すべてのピクセルで以下のチェックを行います:
隣に接するピクセルが2つ以下がOnならば現在チェックされてるピクセルをOffにします。斜めのピクセルも隣としてカウントされます。

隣に接するピクセルが4以降がOnになっていると、 現在チェックされてるピクセルをOffにします。

隣に接するピクセルが3がOnになっていると、 現在チェックされてるピクセルをOnにします。

現在がOnになっている状態で隣に接するピクセルが 3つOnになっていると、Onのままです。

実装しやすい上に、前のフレームの情報しか必要ないので別スレッドとの情報共有が必要ない。 見ての通り、 非常にGPU上で実行する価値があるアルゴリズムです。
CPU上の実装
まずはCPU上で実装しましょう。特にCompute Shader関係ないのでコード乗せときますね。まずStartでテクスチャを作成して、ランダムにピクセルOn・Offします。
public bool Run = false;
[SerializeField]
int Size;
[SerializeField]
float Amount;
Texture2D texture;
// Start is called before the first frame update
void Start()
{
//texture = (Texture2D)GetComponent<MeshRenderer>().material.GetTexture("_Values");
texture = new Texture2D(Size, Size);
texture.filterMode = FilterMode.Point;
Color[] cols = texture.GetPixels();
for (int i = 0; i < cols.Length; i++)
{
if (Random.Range(0f, 1f) < Amount)
{
cols[i] = Color.black;
}
else
{
cols[i] = Color.white;
}
}
texture.SetPixels(0, 0, Size, Size, cols);
texture.Apply();
GetComponent<MeshRenderer>().material.SetTexture("_Values", texture);
}
その後Updateで毎フレーム新しいテクスチャを作って、前のテクスチャの色を読み込んで、アルゴリズムを実行する。
void Update()
{
if (!Run)
return;
Texture2D newTex = new Texture2D(Size, Size);
newTex.filterMode = FilterMode.Point;
Color[] cols = texture.GetPixels();
for (int i = 0; i < cols.Length; i++)
{
int x = i % texture.width;
int y = i / texture.width;
int sum = 0;
for (int xx = -1; xx <= 1; xx++)
{
for (int yy = -1; yy <= 1; yy++)
{
if (xx == 0 && yy == 0)
{
continue;
}
if (texture.GetPixel(x + xx, y + yy).r > 0.5f)
{
sum++;
}
}
}
bool pixel = texture.GetPixel(x, y).r > 0.5f;
if (pixel)
{
Color color = (sum == 2 || sum == 3) ? new Color(1, 1, 1, 1) : new Color(0, 0, 0, 1);
cols[i] = color;
}
else
{
Color color = (sum == 3) ? new Color(1, 1, 1, 1) : new Color(0, 0, 0, 1);
cols[i] = color;
}
}
newTex.SetPixels(cols);
texture = newTex;
texture.Apply();
GetComponent<MeshRenderer>().material.SetTexture("_Values", texture);
}
これを実行したら、こうなります!

次はCSで実装しましょう。
CSの実装
アルゴリズム自体はほとんど変わりませんが、テクスチャーを全部ループする代わりに、現在実行されるスレッドが与えられた位置のピクセルの周りだけ確認して、バッファーに書きます。CS独特なところを以下解説します。
シェーダーなので、HLSLで書きます。
#pragma kernel Init
#pragma kernel CSMain
RWTexture2D<float4> Result;
float rand(float2 co) {
return 0.5 + (frac(sin(dot(co.xy, float2(12.9898, 78.233))) * 43758.5453)) * 0.5;
}
[numthreads(32,32,1)]
void Init (uint3 id : SV_DispatchThreadID)
{
float rnd = rand(id.xy);
Result[id.xy] = rnd > .95 ? float4(1, 1, 1, 1) : float4(0, 0, 0, 0);
}
[numthreads(32,32,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
int sum = 0;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
if (x == 0 && y == 0)
{
continue;
}
if (Result[id.xy + float2(x, y)].x > 0)
{
sum++;
}
}
}
if (Result[id.xy].x > 0)
{
Result[id.xy] = (sum == 2 || sum == 3) ? float4(1, 0, 0, 1) : float4(0, 0, 0, 1);
}
else
{
Result[id.xy] = (sum == 3) ? float4(1, 1, 0, 1) : float4(0, 0, 0, 1);
}
}
CSはC#から実行させるので、以下のpragma文でC#のスクリプトにCSの中にどんな関数があるのかを伝えます。後でC#上、CSを実行させる時に”InitTest”と”CSMain”を別々に呼びます。
#pragma kernel Init
#pragma kernel CSMain
ライフゲームだと読み込みも書き出しも必要なので、ReadWrite(RW)のテクスチャーバッファ を用意します。こちらはC#側でRenderTextureに与えて、Unity上で表示させます。
RWTexture2D<float4> Result;
C#から実行する、CS上の関数の上に
[numthreads(32,32,1)]
と書いてあります。こちらはスレッドの数で、3Dのベクトルです。これでやっているタスクを楽にマルチスレッドに分解できます。今回は2Dのテクスチャーの作業をしてますので、zは1にしました。関数の中のidというベクトルがありまして、こちらは現在のスレッドのidになってます。これを使って、テクスチャーを分解します。テクスチャーのサイズが64×64ピクセルの場合、一つのスレッドが2×2ピクセルの四角の計算を行うことになります。もちろん、スレッドを立てるのも負荷かかりますので、ここのスレッド数はテクスチャーの解像度によって最適の数が変わります。
Initという関数でまずランダムにピクセルをOn・Offします。こちらは後でC#上でスタートから呼びます。
[numthreads(32,32,1)]
void Init (uint3 id : SV_DispatchThreadID)
{
float rnd = rand(id.xy);
Result[id.xy] = rnd > .95 ? float4(1, 1, 1, 1) : float4(0, 0, 0, 0);
}
次はライフゲームの実装。Resultを読み込んで、ルールをチェックして、Resultに書き出す。
[numthreads(32,32,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
int sum = 0;
for (int x = -1; x <= 1; x++) {
for (int y = -1; y <= 1; y++) {
if (x == 0 && y == 0)
{
continue;
}
if (Result[id.xy + float2(x, y)].x > 0)
{
sum++;
}
}
}
if (Result[id.xy].x > 0)
{
Result[id.xy] = (sum == 2 || sum == 3) ? float4(1, 1, 1, 1) : float4(0, 0, 0, 1);
}
else
{
Result[id.xy] = (sum == 3) ? float4(1, 1, 1, 1) : float4(0, 0, 0, 1);
}
}
C#の実装だとテクスチャの位置を全部ループする必要がありましたが、ここが書いてあるのは一つのスレッドがやる計算。スレッドが実行されるところでもうidが入っていますので、そのidを使ってテクスチャのどこをサンプルするか、描くかを決めます。
これでCS側の実装ができました。スレッドのidで作業を分けるのはちょっと慣れないといけないが、アルゴリズムの実装としてはそこまで変わってないですね。
CSを呼ぶ
GPU上のコードができましたので、次はUnityから実行するだけです。こちらはC#のスクリプト上でやります。CSのアセットへのレファレンスが必要なので、[SerializeField]で手動で入れておきます。次はAwakeで表示されるRenderTextureを作ります。こちらの解像度はCPU側と同じように、エディターで設定できるようにしています。RenderTextureに書きますので
renderTexture.enableRandomWrite = true;
を設定しないといけないです。表示されるように、使ってるMaterialのTextureとしてセットします。
[SerializeField]
Material graphBackgroundMat;
[SerializeField]
ComputeShader computeShader;
[SerializeField]
int Size = 256;
int kernel;
public bool Run = false;
RenderTexture renderTexture;
// Start is called before the first frame update
void Awake()
{
renderTexture = new RenderTexture(Size, Size, 0);
renderTexture.enableRandomWrite = true;
renderTexture.filterMode = FilterMode.Point;
renderTexture.Create();
graphBackgroundMat.SetTexture("_Values", renderTexture);
int initKernel = computeShader.FindKernel("Init");
computeShader.SetTexture(initKernel, "Result", renderTexture);
computeShader.Dispatch(initKernel, Mathf.CeilToInt(Size / 32), Mathf.CeilToInt(Size / 32), 1);
kernel = computeShader.FindKernel("CSMain");;
}
CSを実行する時は以下のステップが必要になります:
- 正しいKernelをゲットする
- CPU側の必要な変数をセットする
- CSをDispatch(GPUに送る)する
まずはKernelをゲットする。Initの関数もマイフレームのアップデート関数も必要なので両方Startでゲットします。
int initKernel = computeShader.FindKernel(“Init”);
kernel = computeShader.FindKernel(“CSMain”);
次はCSで利用されるバッファ にテクスチャーを入れます。RWTextureというタイプはC#側だとRenderTextureを利用します。以下の通りCSの方に送ります。
computeShader.SetTexture(initKernel, “Result”, renderTexture);
最後はDispatchする。Initの関数はStartの時だけ呼びますのでここ一回Dispatchします。
computeShader.Dispatch(initKernel, Mathf.CeilToInt(Size / 32), Mathf.CeilToInt(Size / 32), 1);
CSの方に32×32のスレッドグループを使うという形にしましたので、こちらの二番目、三番目のパラメターではSizeをスレッドの数に分ける。CSの中だとスレッドのIDを元にしてバッファ をアクセスしているので、テクスチャーを32×32のグリッドに分けて、スレッドに与えます。
Initが終わったら、Updateの方で毎フレームMainのKernelを使い、GPUの方でMainの関数を呼びます。使っているRenderTextureはランタイムで作ったので、OnDestroyでちゃんとReleaseしましょう。
void Update()
{
if (Run)
{
computeShader.Dispatch(kernel, Mathf.CeilToInt(Size / 32), Mathf.CeilToInt(Size / 32), 1);
}
}
private void OnDestroy()
{
renderTexture.Release();
}
実行したら、こうなります!

負荷
さて、この中だとどっちが負荷が少ないでしょうか?今回Gif作る時に両方の解像度は256×256ピクセルにしましたが、CPUの方は~10FPSで、CSの方の実装は700FPS以上で実行できました。両方は毎フレーム実行していますので、フレームが減るとシミュレーションの速度も減ります。Gifだとちょっと分かりにくいかもしれませんが、Unity上だと目でも一瞬でわかります。CSの方がぬるぬるに動いてくれます。
CSの方の実装を8k(7680×7680)の解像度で実行してみると、使っているランダム関数のパターンが見えてきちゃいますね。これで実行すると、、なんと、~250FPSで問題なく動いてくれます!ちなみに、CPUを8kで実行しようとしたら、UnityがクラッシュしてしまいますのでGifのせません。


計算の数が少ない場合はGPUに移す負荷だけでCPUより重くなる場合もあります。今回は試しに32×32の解像度でやってみましたが、CPU250FPS対CS800FPSでそれでもCSの方が早いです。おそらく毎フレームGPUのバッファ をCPUからセットする必要の場合はもうちょっといい勝負になると思います。

Size | CPU | GPU |
32 | 250 | 804 |
256 | 9.5 | 721 |
1024 | 0.6 | 703 |
2048 | 0.1 | 690 |
7680 | – | 275 |
最後に
ということで、Compute Shaderの魅力が分かりましたでしょうか? 並列化できる計算やテクスチャにデーターを描く処理はCompute Shaderを使って、GPUの方に移したら負荷をかなり減ることができます。
どうでもいいですが、前からOnだったピクセルを赤く塗って、新しくOnされたピクセルを黄色く塗ったら、花火みたいなエフェクトになります。

今回はUnity上のCompute Shaderを勉強しました。ここまで読んでくれてありがとうございます。少しでもCompute Shaderを触るまでのハードルを下げることができたら幸いです。以上、 情熱開発部 プログラム課 1のボルでした。
【免責事項】
本サイトでの情報を利用することによる損害等に対し、
株式会社ロジカルビートは一切の責任を負いません。