プログラムdeタマゴ

nodamushiの著作物は、文章、画像、プログラムにかかわらず全てUnlicenseです

Unityでシェーダープログラムに入門2: コンピュートシェーダ

 はい、どうも。流石にシェーダーばっかり弄りすぎて全くゲームを作るという雰囲気じゃないので、いい加減脱線するのをやめて真面目にUnity触っていきたいと思いました。

 なので、今日はコンピュートシェーダをやってみます。

 初心者がどういう思考をして試行錯誤して理解してくのかの過程が見えた方が、他の初心者の人にも良いかなと思って、やったことをそのまま書いていくよ。

とりあえず何も調べないで触ってみた

 前から、ここに何かあるのが気になってました。ポティッとな。

f:id:nodamushi:20220212152701p:plain

 で、出てきたファイルがこれ。

#pragma kernel CSMain
RWTexture2D<float4> Result;

[numthreads(8,8,1)]
void CSMain (uint3 id : SV_DispatchThreadID)
{
    Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0.0);
}

 ………あー。たぶん、 kernel CSMain がどの関数を実行するかを指定してるんだろうね。

 idが3次元(uint3)なのは上の8,8,1が何か関係が?直感的には8×8×1 = 64スレッド走ってるってことかな。3次元ボクセルを計算するときは最後の1を変えるんだろうな。

 でも、ならなんで id.x & 15 なんて必要なんだ?0-7にならないのか?うーん…?

 ふむ…、取りあえず、もっとガッツリ[numthreads(256,256,1)]ぐらいにしてみて………

 お?何かエラー出てる

f:id:nodamushi:20220212162429p:plain

 1024よりも少なくないとダメらしい………1024?少なくね?10万ノードぐらい行こうぜ?

 ということは、もう根本的に何か勘違いしてるっぽい。いい加減真面目に調べよう。

   

スレッドとグループ

 Microsoftのドキュメント見ても全く意味がわからんのはいつものこと。(MicrosoftのDocsってなんであんなに読みにくいんだろうな。どこ読めば良いのか全く分からないんだよなぁ)

f:id:nodamushi:20220212165302p:plain

 どうにも、スレッドはグループによって管理されていて、そのグループ自体も並列に動くのかな。グループは Dispatch によって数を指定するみたい。グループは各次元が65535まで指定可能らしい。なるほど。

 で、void CSMain (uint3 id : SV_DispatchThreadID)id: SV_DispatchThreadID は上の図を見ると、全体を通してのIDになるみたい。

 つまり、 id.x & 15 なんて必要なんだって思ったけど、グループの数によってはid.xは15を超えるって事だね。

 ふむ?何となくシェーダー側は分かってきたかな…?

コンピュートシェーダの結果をテクスチャとして貼り付ける

 作成したシェーダを動かすにはC#から Dispatch を起動しなくちゃいけないと言うことは、さっきのスレッドとグループを調べててわかりました。

 で、それをUnityでどうやって動かすんだ?結果もどうやって見れば良いんだ?うーん………とりあえず、テクスチャとして貼り付ければ良いかな。昔ながらのやり方だね。

 今回はひとまず、256x256のテクスチャを作って、適当な四角形ポリゴンに貼り付けてみることにしました。

 というわけで、適当にquadをシーンに追加して、カメラの目の前に配置。

f:id:nodamushi:20220212172656p:plain

 んで適当にTextureを貼り付けるためのマテリアルを追加。これにC#からテクスチャを設定すれば良いのだと思うけど、どうやるんだ?

f:id:nodamushi:20220212172827p:plain

 どうやら、 _MainTex にテクスチャを設定すればいけそう。

f:id:nodamushi:20220212172947p:plain

 で、なんとかかんとか調べながら書いたC#コードが以下。

using UnityEngine;

public class sample : MonoBehaviour
{
  [SerializeField] ComputeShader shader;
  private RenderTexture texture;

  void Start()
  {
    // テクスチャの作成。256x256。デプスバッファは無し
    texture = new RenderTexture(256, 256, 0);
    texture.enableRandomWrite = true; // テクスチャに書き込むのに必要らしい。
    texture.Create();

    // マテリアルの取得とテクスチャの設定
    var material = GetComponent<Renderer>().material;
    material.SetTexture("_MainTex", texture);

    // カーネルの実行
    var id = shader.FindKernel("puri");
    shader.SetTexture(id, "Output", texture);
    shader.Dispatch(id, 1, 64, 1);// グループ数 1 x 64 x 1
  }

  void OnDestroy()
  {
    texture.Release(); // テクスチャリソース破棄
  }

  void Update()
  {
  }
}

 

 まずは、以下の様にしてComputeShaderを受け取れるようにする。public じゃなくて[SerializeField]が良いらしい。 よく分かってなかったけど、Inspector から設定可能になるみたいです。(後述)

[SerializeField] ComputeShader shader;

 次に以下の様にしてコンピュートシェーダで書き込むテクスチャを作成して、シェーダーの _MainTex に突っ込めばいいっぽい。

private RenderTexture texture;
  void Start()
  {
    // テクスチャの作成。256x256。デプスバッファは無し
    texture = new RenderTexture(256, 256, 0);
    texture.enableRandomWrite = true; // テクスチャに書き込むのに必要らしい。
    texture.Create();

    // マテリアルの取得とテクスチャの設定
    var material = GetComponent<Renderer>().material;
    material.SetTexture("_MainTex", texture);
  }

  void OnDestroy()
  {
    texture.Release(); // テクスチャリソース破棄
  }

 

 コンピュートシェーダの方は取りあえずグラデーションを表示するだけの ぷりカーネルを定義してみました。

#pragma kernel puri
RWTexture2D<float> Output; // 256 x 256の画像

[numthreads(256, 4, 1)]
void puri(uint3 id: SV_DISPATCHTHREADID)
{
  Output[id.xy] = (float)(id.x + id.y) / 512;
}

 カーネル側ではスレッド数を 256x4x1 に指定してるので256x256の画像を処理をするには、1x64x1個のグループを実行すれば良いはずです。

    var id = shader.FindKernel("puri"); // カーネルの取得
    shader.SetTexture(id, "Output", texture); // テクスチャをコンピュートシェーダに結びつける
    shader.Dispatch(id, 1, 64, 1);// グループ数 1 x 64 x 1

 

 で、作成したC#をquadにアタッチしてゲーム実行してみると………

f:id:nodamushi:20220212173638p:plain

 はい、見事に失敗です。動かない。shaderが設定されてない?どうやって設定するの?

 と、調べると、[SerializeField] を指定すると以下の様に Inspector に表示されるので、ここから設定するらしい。そんなことも知らないゴミカスです。

f:id:nodamushi:20220212173738p:plain

f:id:nodamushi:20220212173913p:plain

 気を取り直して実行すると………

f:id:nodamushi:20220212191322p:plain

 おぉ、グラデーションが出てる

 

アニメーションして見る

 今度は2秒周期で色がグラデーションする ぷりぷりカーネルを追加しました。

#pragma kernel puri
RWTexture2D<float> Output; // 256 x 256の画像

[numthreads(256, 4, 1)]
void puri(uint3 id: SV_DISPATCHTHREADID)
{
  Output[id.xy] = (float)(id.x + id.y) / 512;
}

#pragma kernel puripuri
float Time;
[numthreads(256, 4, 1)]
void puripuri(uint3 id: SV_DISPATCHTHREADID)
{
  Output[id.xy] = frac(Time / 2);
}

 これをUpdateでDispatchすればきっとアニメーションするはず。

using UnityEngine;

public class sample : MonoBehaviour
{
  [SerializeField] ComputeShader shader;
  private RenderTexture texture;
  private int id;

  void Start()
  {
    texture = new RenderTexture(256, 256, 0);
    texture.enableRandomWrite = true; 
    texture.Create();
    var material = GetComponent<Renderer>().material;
    material.SetTexture("_MainTex", texture);

    // カーネルIDをフィールドにとって、Outputとtextureを結びつける
    id = shader.FindKernel("puripuri");
    shader.SetTexture(id, "Output", texture);
  }

  void OnDestroy()
  {
    texture.Release();
  }

  void Update()
  {
    // 時間を指定してDispatch
    shader.SetFloat("Time", Time.time);
    shader.Dispatch(id, 1, 64, 1);
  }
}

 で、動かしてみると………

f:id:nodamushi:20220212193249g:plain

 おぉ、動いたっ。

前の結果から次の結果を算出する

 ぷりぷりは時間から色を算出してたけど、良くあるシミュレーションは前の状態から次の状態を取得します。というわけで、前の状態を処理して次を色を計算してみるぷりぷりぷりぷりを作ってみます。

f:id:nodamushi:20220212201622p:plain

 Fixed Timestampは0.02なので、FixedUpdateは基本的には50fpsで呼ばれるハズ(本当はちゃんと時間情報も渡すべきですが)。

 

 なので、先ほどと同じように2秒で色が変化する様にするには0.01だけ前の値から変化すれば良いね。

#pragma kernel puripuripuri
[numthreads(256, 4, 1)]
void puripuripuri(uint3 id: SV_DISPATCHTHREADID)
{
  Output[id.xy] = frac(Output[id.xy] - 1.0 / 100.0 );
}

 C#側は Update は削除して、FixedUpdateを作る。

  void FixedUpdate()
  {
    shader.Dispatch(id, 1, 64, 1);
  }

 動かしてみた。

f:id:nodamushi:20220212201831g:plain

 OKOK………

 

入力と出力でダブルバッファリングすればいいのかね?

 ところで、ぷりぷりぷりぷりのこれ↓って自分で自分を更新する限りは問題ないけど、隣を参照する場合とか困るはずだよね?

Output[id.xy] = frac(Output[id.xy] - 1.0 / 100.0 );

 

 というわけで、以下の様なぷりぷりぷりぷりカーネルを追加しました。C#側は省略。

// 画像の初期化
#pragma kernel init
[numthreads(256, 4, 1)]
void init(uint3 id: SV_DISPATCHTHREADID)
{
  Output[id.xy] = step(1, frac((float)id.x /64) * 2) * step(1, frac((float)id.y /64) * 2);
}

#pragma kernel puripuripuripuri
[numthreads(256, 4, 1)]
void puripuripuripuri(uint3 id: SV_DISPATCHTHREADID)
{
  Output[id.xy] = Output[(id.xy - uint2(1, 1)) & (uint2(255, 255))];
}

f:id:nodamushi:20220212202724g:plain

 予想通り、最初は綺麗な四角形だったのに、徐々に壊れてますね。

f:id:nodamushi:20220212202611p:plain

f:id:nodamushi:20220212202630p:plain

 

 解決方法はたぶん、ダブルバッファリングすれば良いんですかね?

 まずは、ぷりぷりぷりぷりが入力画像を受け取れるようにします。

#pragma kernel puripuripuripuri

RWTexture2D<float> Input; // 256 x 256の画像
[numthreads(256, 4, 1)]
void puripuripuripuri(uint3 id: SV_DISPATCHTHREADID)
{
  Output[id.xy] = Input[(id.xy - uint2(1, 1)) & (uint2(255, 255))];
}

 C#側ではダブルバッファリングするようにします。

using UnityEngine;

public class sample : MonoBehaviour
{
  [SerializeField] ComputeShader shader;

  private RenderTexture[] texture;// 入力用、出力用
  private int id;// kernel id
  private int front;// 表示するtextureのインデックス(0 or 1)
  private Material material;

  void Start()
  {
    texture = new RenderTexture[2];
    front = 0;
    for (var i = 0; i < 2; i++)
    {
      texture[i] = new RenderTexture(256, 256, 0);
      texture[i].enableRandomWrite = true;
      texture[i].Create();
    }

    material = GetComponent<Renderer>().material;
    material.SetTexture("_MainTex", texture[front]);

    // カーネルの取得
    id = shader.FindKernel("puripuripuripuri");

    // 初期画像作成
    int init = shader.FindKernel("init");
    shader.SetTexture(init, "Output", texture[front]);
    shader.Dispatch(init, 1, 64, 1);
  }

  void OnDestroy()
  {
    texture[0].Release();
    texture[1].Release();
  }

  void FixedUpdate()
  {
    front = 1 - front; // 表示するテクスチャの入れ替え

    shader.SetTexture(id, "Output", texture[front]);
    shader.SetTexture(id, "Input", texture[1 - front]);
    shader.Dispatch(id, 1, 64, 1);

    material.SetTexture("_MainTex", texture[front]);
  }
}

 

 で、動かすとこうなった。

f:id:nodamushi:20220212204610g:plain

 いいね。

 ComputeShaderは何となく分かったかなぁ………。

 早く簡単なゲーム作れるぐらいにはなりたいんですが、初心者にはゲーム開発は難しいですね……。