×

Arshes v1.4.0でcomputeシェーダーが使えるようになった

v1.4.0

外部テストの審査が終わり、Arshes v1.4.0が先ほど公開された。 今回は審査が早く終わってありがたい。Apple Storeの人、ありがとうございます。

v1.3.0からの差分は80PR、200 commitsくらいあり、UIをがっつり変えたり、シェーダーの機能を変えたりと結構大きなリリースになった。 特に新機能のcomputeシェーダーが楽しい感じなので紹介したい。

背景

Arshesではユーザー書いたfragmentシェーダーをコンパイルして実行している。 CPUからシェーダーに渡してほしい情報をシェーダーのuniform変数で宣言し、CPUはそれを元に挙動を変えている。 例えば、uniform Texture2D<float3> cameraTex と書かれていたらカメラを起動するし、uniform float microphoneと書かれていたらマイクを起動する。

つまりCPUで作ったデータをGPUに送り、GPUからそれを読み込むという構成になっている。 fragmenシェーダーの役割はピクセルの色を決定することなので、描画結果としての色以外の情報を残すことができない。

例えば、音に反応して色が変わるエフェクトを作りたいとする。 オーディオリアクティブな何かを作ったことがある人ならわかると思うが、音声入力は高周波すぎるので、時間に対して平滑化してから使う方が見栄えがよくなることが多い。 fragmentシェーダーは色以外の情報を残すことができないので、描画結果にマイク入力をエンコードする必要がある。

Arshesではbackbufferに1フレーム前の描画結果が格納されているので、 以下のようなシェーダーを書いてbackbufferのアルファchにsmoothingしたマイク入力の値を格納することでマイク入力を平滑化できる。

let bb = backbuffer.Sample(ss, uv);
let m = lerp(bb.a, microphone, 0.2);

...

return float4(col, m);

1chで足りない場合は値をRGBAにエンコードして、右下の数ピクセルに格納する…みたいなことをやる必要がある。 こういうシェーダーのハックはパズルめいていて好きだが、パフォーマンスは落ちるし描画結果にも影響がでるし、できれば避けたいものである。

これを解決するためにはシェーダーから書き込むことができるバッファを用意する必要がある。 以前、sp4ghetさんがSh4derJockey作ってるときに、こういうハックをしなくて済むようにしたと話していたのを思い出したので Arshesでも似たような仕組みを採用することにした。

解決策

フレームを跨いで使用するバッファの宣言にはRWStructuredBuffer<T>を使う。 先に書いた音声の平滑化のように全てのピクセルで同じ結果になる場合はfragmentシェーダーから書き込んでも問題なく動く(良性レースコンディション)。 が、本質的に毎フレーム1回だけやればよいことは、1回だけやる方が筋が良い。

Arshesでは以下のように書く。

[range(0, 1, 0.9)]
uniform float smoothness;

[size(1)]
RWStructuredBuffer<float> state;

[dispatch(1, 1, 1)]
[numthreds(1, 1, 1)]
[shader("compute")]
void smoothMic(uint3 id : SV_DispatchThreadID)
{
  state[0] = lerp(microphone, state[0], smoothness); 
}

RWStructuredBuffer<T>Tに自前のstructを入れることも可能で、バッファを複数宣言することもできる。 バッファは配列なので長さを宣言しないとメモリを確保できない。Arshesでは[size(N)]という属性を用意した。 コンパイル時にこれをみてCPU側でバッファを生成し、GPUに渡している。

[numthreads(x, y, z)] は computeシェーダーの記法で、1つのスレッドグループにスレッドをいくつ入れるかを宣言する。 例えば [numthreads(4, 4, 1)]ならスレッドグループ1つに 4 * 4 * 1 = 16本のスレッドが含まれる。 [dispatch(x, y, z)] はArshesが提供する属性で、スレッドグループをいくつ実行するかを定義する。 UnityのComputeShader.Dispatchと同じだと思って良い。 [dispatch(10, 1, 1)]なら10グループ実行することになるので、先の例では160本のスレッドが実行されることになる。 各スレッドごとにバッファの要素を割り当てたいなら[size(160)]にすればぴったりになる。 computeシェーダー内で、どのスレッドで実行しているかは SV_DispatchThreadIDSV_GroupIDSV_GroupThreadIDなどを使うことで取得できる(この辺はcomputeシェーダーで一般的な記法なので詳細は省略する)。

何ができるようになったのか

computeシェーダーはGPUで動いて並列計算が得意!という印象があるが、Arshesにおいては先の音声入力の平滑化のような1スレッドの”Updateループ”が書けるようになることのインパクトが大きいように思う。 v1.4.0でuniform float3 touchでタップ判定を取れるようにしたので、これと組み合わせると簡単なUIが作れるようになった。

uniform float3 touch;

struct State {
  float2 position;
  float radius;
}

[size(1)]
RWStructuredBuffer<State> state;

[dispatch(1,1,1)]
[numthreads(1,1,1)]
[shader("compute")]
void updateState(uint3 id : SV_DispatchThreadID) {
  var s = state[0];
  if (touch.z > 0) {
    s.position = lerp(s.position, float2(touch.x, touch.y), 0.08);
    s.radius = lerp(s.radius, 0.2, 0.05);
    state[0] = s;
  } else {
    s.radius = lerp(s.radius, 0, 0.1);
    state[0] = s;
  }
}

[shader("fragment")]
float4 fragmentMain(float2 uv : TEXCOORD) : SV_Target {
  var s = state[0];
  float3 col = 1;
  col *= step(s.radius, length(s.position - uv));
  return float4(col, 1);
}

AIに頼めば簡単なゲームみたいなのを作ることも可能だが、どちらかというと カメラのフィルターをインタラクティブにできるというのが面白いところだと思う。

この動画のシェーダーは長時間露光の真似をしていて以下のような状態管理をしている。

  1. 画面をタップすると真っ暗にしてから露光を開始する
  2. 露光中は前フレームの描画結果に今のフレームの色を加算する
  3. 露光時間に到達したら露光を止めて白枠を表示する

他にも、レトロ風カメラアプリなどである複数の写真を重ね合わせる、とかもタップ判定とbackbufferを使って容易に作ることができると思う。

頂点シェーダー

computeシェーダーを使うとなると、やはりGPUパーティクルがやりたくなる。

Arshesは元々フルスクリーンでfragmentシェーダーを実行するアプリだったので、頂点シェーダーは組み込みのものfull screen quadを使っていた。 GPUパーティクルをやる場合は頂点シェーダーでパーティクルを動かすので、以下のようなシグネチャで書けるようにした。

struct VertexOutput {
  float4 position : SV_Position;
  float4 color : COLOR;
}

[primitive(4, 100)]
[shader("vertex")]
VertexOutput vertexMain(uint vid : SV_VertexID, uint iid : SV_InstanceID) {
  ...
}

[primitive(n, m)] はArshes定義の属性で、nでprimitiveの形、mでインスタンスの数を宣言する。 primitiveの形は1~4でpoint、line、triangle、quadを使えるようにした。

例えば、256個パーティクルがあって、それぞれを三角形で描画したいなら[primitive(3, 256)]として、SV_InstanceIDを使って RWStructuredBufferにアクセスする形になる。

次にやりたいこと

Shader Spec周りで、次にやると面白そうなのはマルチパスである。 今は1つのSlangファイルに複数のシェーダーを書いて、uniformなどは共有する形になっている。 お手軽にcomputeとfragmentが連携できて快適だが、より複雑なことをやろうとすると、やはりファイルを分けたり パイプラインの宣言を別の場所でやる方が良いように思われる。 UnityのShaderLabみたいになるかもしれないし、Sh4derJockeyのconfig.yamlのようになるかもしれない。 あるいは、複数の独立したシェーダーを組み合わせてユーザーが独自のパイプラインを組めるようにする、とかも面白そうである。

Continues

Continued by