e.blog

主にUnity/UE周りのことについてまとめていきます

ComputeShaderを触ってみる その2 ~バッファ・テクスチャ編~

概要

前回の記事(ComputeShaderを触ってみる その1 ~スレッド編~)で、Compute Shaderのスレッドの概念について書きました。

edom18.hateblo.jp

今回は、Compute Shaderを実際に使って、少し意味のある計算をしてみたいと思います。
意味のある計算をさせるためには当然、CPU側との連携が必要になるので、そのあたりを中心に書いていきたいと思います。

ちなみに今回実装したのは、とある案件で実際に使うことにしたのでそれを元にしたメモです。
具体的には、ひとつのテクスチャを渡すと、決められたブロック単位に切り分け、そのブロック内の透明度を判断、不透明と判断されたところを有効、それ以外を無効としてマークする、というものです。

実際に実行したイメージ図は以下のような感じです。

f:id:edo_m18:20171004110055p:plain

左の画像が渡したテクスチャで、黒い部分が透明なところです。
右の絵がそれを元に計算した、ざっくりと「不透明な部分」を青く色づけしたものです。

なんとなく、不透明な部分と青い部分が一致しているのが分かるかと思います。

まぁぶっちゃけ、CPUでやってもまぁ問題にならないレベルの処理だと思いますが、Compute Shaderを使ういい練習にはなるかなとw

ComputeShaderに値を渡す

さて、コンピュートシェーダで大事なスレッドの概念を説明したあとは、シェーダに対してCPU側から値を送る方法を見てみましょう。

設定についてはSetXXXX系メソッドを使います。

例えば、特定のfloat値を渡したい場合は以下のように記述します。

シェーダ側

float myFloat;

スクリプト

shader.SetFloat("myFloat", 1.0f);

intを渡したい場合は、

shader.SetInt("myInt", 5);

などのようにしてやれば大丈夫です。

CPUとGPUでは基本的にはメモリ空間が異なるため(物理的にも離れているケースがだいたい。のはず)、CPUで使っているデータを、GPUで使えるメモリにコピーする(転送する)必要があります。
そのため、上記のように、GPU側に「これからこのデータをこういう変数名で送りますよ」という宣言をしているわけですね。

ちなみに、GPU周りのことについて詳しく知りたい方は、以下の書籍がオススメです。
最近のグラフィック事情から、GPUのハード・ソフト的な面の説明に、CPUとどうやって連携しているか、など細かい内容が詳細に書かれています。

GPUを支える技術 ――超並列ハードウェアの快進撃[技術基礎] (WEB+DB PRESS plus)

GPUを支える技術 ――超並列ハードウェアの快進撃[技術基礎] (WEB+DB PRESS plus)

閑話休題

基本的には、シェーダ側で使用したい変数を宣言しておき、SetXXXX系メソッドでCPUからデータを転送してやればOKなわけですね。

データを受け取るバッファ

さて、送るだけなら上記のように記述してやればOKですが、GPGPUということは、なにかしら計算した結果をCPU側で利用したいはずです。
そのため、GPUで計算した結果を受け取る方法が必要になります。

そのために利用されるのがComputeBufferクラスです。

まずは簡単に、使い方のコード断片を。

RWStructuredBuffer<float> Result;

シェーダ側ではRWStructuredBuffer<T>型のバッファを宣言しておきます。
ジェネリックで指定する型は、実際に利用したいデータの型です。

続いてCPU側。

ComputeBuffer buffer = new ComputeBuffer(num, sizeof(float));
int kernelID = _shader.FindKernel("CSMain");
_shader.SetBuffer(kernelID, "Result", buffer);

CPU側ではComputeBufferクラスのインスタンスを、これまたSetBufferでセットします。
ComputeBufferのコンストラクタに渡している第一引数はバッファの個数です。第二引数で1要素のサイズを指定します。

このあたりはC言語などを触ったことがある人であればイメージしやすいかと思います。

実際にデータを受け取る

さて、バッファをセットしただけでは当然、データは取得できません。
実際にデータを取得するには以下のようにします。

float[] data = new float[num];
buffer.GetData(data);

GPUに設定していたバッファのGetDataメソッドを利用して、GPUの計算結果をCPUに転送します。
受け取るために、バッファと同じサイズのデータをCPU側で確保して、その確保した領域にデータを転送してもらう感じですね。

あとは、dataをいつものようにfor文などでループさせて、目的の処理を行います。

バッファの後処理

普段、C#を触っているとGCでメモリを開放してくれるのであまり気にすることはないかもしれませんが、今回利用したバッファは自身で適切に解放してやらないとなりません。

buffer.Release();

Releaseメソッドを呼ぶことで適切にメモリが開放されます。

このあたりは、C言語C++を触っている人ならそこまで気にならないかもしれませんね。

テクスチャのread / write

次はテクスチャへのアクセスについてです。
本来、GPUはグラフィクス周りを担当するハードウェアなので、当然ながらテクスチャへのアクセスも行えます。

GPU側では以下のように宣言します。

RWTexture2D<float4> texCopy;
Texture2D<float4> tex;

さて、RWと付いたものと付いていないものがあります。
これは、(多分)Read / Writeの略だと思いますが、つまり、読み込み専用か、書き込みも行えるか、の違いです。
そして上記のtexCopy変数のほうは読み書きが行なえます。

前回の記事のコードを再掲すると、

#pragma kernel CSMain

RWTexture2D<float4> texCopy;
Texture2D<float4> tex;

[numthreads(8,8,1)]
void CSMain(uint2 id : SV_DispatchThreadID)
{
    float4 t = tex[id];
    texCopy[id] = t;
}

これはただ、渡されたテクスチャの値をコピーするだけの簡単なサンプルです。
ここで重要な点は、テクスチャの各要素へのアクセスが非常に簡単だ、という点です。

上記の例では分かりやすさのために、一度テクセルを変数に入れたのちに、コピー対象のテクスチャに入れていますが、それを省略すれば一行で記述できてしまうほどの簡単さです。
テクスチャは2次元の配列になっていて、添字にはuint2型の値を使うことができます。
上記の例ではiduint2型なので、そのままアクセスできている、というわけですね。

そしてなぜそれが適切に各テクセルにアクセスできるのか、については前回の記事を参照してください。

テクスチャの内容を、畳込み処理で透明・不透明を判断する

以上で、バッファ、変数周りの記述とその意味についての解説が終わりました。
最後は、これらをまとめて、少し意味のある計算を行う例を解説したいと思います。

冒頭でも書いたように、今回は、テクスチャに対して指定したブロック単位に区切り、そのブロックの中のテクセルの平均が、「透明」に属するのか「不透明」に属するのかの計算をしてみたいと思います。

まずは今回書いたシェーダコードを載せます。

#pragma kernel CSMain

RWStructuredBuffer<float> Result;
Texture2D<float4> Texture;

int Length;
int Width;
int Height;

[numthreads(1, 1, 1)]
void CSMain (uint2 id : SV_DispatchThreadID)
{
    float result = 0;
    int halfWidth = Width * 0.5;
    int halfHeight = Height * 0.5;

    // 指定された分の、縦横の透明度を合計する
    for (int i = -halfHeight; i <= halfHeight; i++)
    {
        for (int j = -halfWidth; j <= halfWidth; j++)
        {
            int u = (id.x * Width) + halfWidth + j;
            int v = (id.y * Height) + halfHeight + i;
            float4 tex = Texture[uint2(u, v)];
            result += tex.a;
        }
    }

    float denom = 1.0 / (Width * Height);
    int index = id.y * Length + id.x;
    Result[index] = result * denom;
}

とても短いコードですね。

まず、なにをしているかを図解します。

今回、CPU側では以下のようにComputeShaderを起動しています。

_shader.Dispatch(kernelID, divCount, divCount, 1);

つまり、縦横に同じ数だけ分割したスレッドグループを起動しています。
なので図にすると以下のようなブロック分、スレッドグループが起動されるわけですね。

f:id:edo_m18:20171007165123p:plain

今回は10 x 10分割したので、全部で100個のブロックがあることになります。
そして、各ブロックに含まれるピクセルすべての透明度を足しこみ、最後に全体のピクセル数で割ることで、そのブロックの平均透明度を求めている、というわけです。

利用する際は、その平均値を元に、しきい値以上あれば不透明扱い、それ以下なら透明扱いと判断することで、冒頭の画像のように、透明・不透明の判定を行っている、というわけですね。

CPU側は以下のように起動しています。

_shader = ComputeShader.Instantiate(Resources.Load<ComputeShader>("Shaders/HitAreaDetector"));

// 分割数で算出された一区画に対するピクセル数をさらに奇数に補正する
int pixelPerDivW = texture.width / divCount;
pixelPerDivW = pixelPerDivW - (1 - pixelPerDivW % 2);

int pixelPerDivH = texture.height / divCount;
pixelPerDivH = pixelPerDivH - (1 - pixelPerDivH % 2);

int num = divCount * divCount;
ComputeBuffer buffer = new ComputeBuffer(num, sizeof(float));

int kernelID = _shader.FindKernel("CSMain");

_shader.SetBuffer(kernelID, "Result", buffer);
_shader.SetTexture(kernelID, "Texture", texture);
_shader.SetInt("Length", divCount);
_shader.SetInt("Width", pixelPerDivW);
_shader.SetInt("Height", pixelPerDivH);

float[] rawData = new float[num];

_shader.Dispatch(kernelID, divCount, divCount, 1);
buffer.GetData(rawData);

buffer.Release();

やっていることはシンプルに、ひとブロック分のピクセル数を計算し、GPUへは、計算対象となるテクスチャと、結果を受け取るバッファ、そしてピクセル数などのパラメータを送っているのみです。
あとは、計算結果を取得して必要なデータとして処理を行う、という流れです。

ハマった点

最後に、いくつかハマった点を。

今回、テクスチャをブロックに分割してあたりを付ける、という処理を書きましたが、ComputeShaderの仕様なのか、ひとつのシェーダを複数個同時起動すると、バッファへ値が正常に格納されず、意図した動作にならない、という挙動になりました。
シェーダ自体を複製すると問題が解決するので、ロードしたシェーダを複製したところ、以下のエラーが・・・。

!(transfer.IsRemapPPtrTransfer() && transfer.IsReadingPPtr())
UnityEngine.Object:Instantiate(ComputeShader)

どうやらUnityのバグ?らしく、以下の投稿を見つけました。

https://forum.unity.com/threads/error-when-instantiating-compute-shader.408533/

ただ、エラーは出るものの、意図した挙動になっているので、いったんはこのエラーに目をつぶって実装を進めました・・。

このあたりについて、なにか情報をお持ちの方は連絡いただけるとうれしいです( ;´Д`)