VRC シェーダー汎用計算解説 GPGPU


これはVRChat Advent Calendar 2019の最終日の記事です。

サンプルをgithubで公開しています。
(サンプルプルジェクトを見つつ読むことを想定しています。)

GPGPUとは

GPGPU(General-purpose computing on graphics processing units)は描画処理ではなく数値計算などの汎用的な目的の為にGPUを使う技術です。雑に言うと、配列の要素をGPUで並列的に処理する感じです。UnityでGPGPUで処理をする場合はComputeShaderを使う場面が多いと思いますが、VRChatの制約上ComputeShaderは使えないので別の方法でGPGPUを実現しています。本記事ではVRC上でのGPGPUの手法や関連するtipsをまとめています。

具体的にGPGPUで何ができるか例をあげます。

このツイートはやぎりさんのGPUパーティクルのひとつです。本記事でもGPUパーティクルをとっかかりにしてGPGPUの手法を解説します。
各パーティクルが1フレーム前の座標や速度を基に現在のフレームでのパーティクルの位置や速度を算出する仕組みで動いています。この1フレーム前の状態を参照できる機構(メモリのようなもの)によってGPGPUが実現されます。

カメラフィードバック

カメラフィードバックと呼ばれる仕組みでフレームをまたいで状態を保持することができます。
カメラフィードバックを簡単な図にすると以下のようになります。

  • RenderTextureを出力先にしたカメラ
  • カメラの描画領域をぴったり覆うQuad
  • RenderTextureをQuadのマテリアルに入力
  • マテリアル用のシェーダーを書く

この構成によって、Quadの色がそのままRenderTextureとして次のフレームに渡されることになるわけです。なので、パーティクルの位置なり速度なり必要な情報を色で表現して次のフレームに送り、このメモリを参照してパーティクルを生成するジオメトリシェーダーを書けばGPUパーティクルが作れますね。なので何かを作る時はメモリ用のシェーダーとメモリを参照して何かしらの動作をするシェーダーの二つを書く事が多いです。

これでGPGPUの準備は完了です。あとはシェーダーを書くだけ。

軽いサンプル

上記のメモリの仕組みを動かすミニマムなサンプルもあるのでいじりたい人は参考にしてください。
単純に入力されたテクスチャの色に0.01を加算した値をfrac()するだけのシェーダーでメモリを更新するサンプルです。frac()しているので白になった時点で(1になった時点)黒に戻ってますね(0に戻ってる)。

図にするとこんな感じですね。

メモリのフラグメントシェーダーはこう。

CameraFeedback.shader
fixed4 frag (v2f i) : SV_Target
{
    fixed4 col = tex2D(_MainTex, i.uv);
    col += 0.01;
    return frac(col);
}

パーティクル側

別のサンプルでパーティクル生成側のミニマムめなコードを解説します。
メモリを参照する側についてです。

GPUParticle_Particle.shader
[maxvertexcount(4)]
void geom(triangle v2g input[3], uint pid: SV_PRIMITIVEID, inout TriangleStream < g2f > outStream)
{
  //pidをパーティクルのidとして扱う。
  float3 pos = getPos(pid);//この中でメモリから座標をサンプリングしてる
  float3 originViewPos = UnityObjectToViewPos(pos);
  [unroll]
  for (uint i = 0; i < 4; i ++)
  {
    float x = i % 2 == 0 ? - 0.5: 0.5;
    float y = i / 2 == 0 ? - 0.5: 0.5;
    float3 viewPos = originViewPos + float3(x, y, 0) * 0.01;
    g2f output;
    output.vertex = mul(UNITY_MATRIX_P, float4(viewPos, 1));
    output.uv = float2(x, y);
    outStream.Append(output);
  }
}

例えばメモリの1ピクセルにパーティクルの座標を保存すると考えると、ジオメトリシェーダーでQuadを生成してQuadの中心の位置をメモリのテクスチャからサンプリングした座標にすれば良いです。テクスチャは縦横にピクセルが並んでいるので二次元配列のような状態ですが考えずらいのでこれを1次元の配列として考えられるように関数を作ったのでそれを使いつつメモリから色を取得します。この辺の便利関数は記事の後の方で解説してます。

ジオメトリシェーダーではSV_PRIMITIVEIDセマンティクスで実行されるジオメトリシェーダーごとのIDが取得できるので、これをパーティクルのIDとして扱います。プリミティブIDがnなら前述した関数でn番目のパーティクルの座標が格納されているテクセルのUVを得てメモリから色をサンプリングすれば各Quadが対応するパーティクルの位置に配置されることになります。
実際には1ピクセルRGBAの4チャンネルで合計32bitしかないのでfloatで座標を保存しようとすると、座標のx,y,z成分それぞれが1ピクセル必要になります。

設定の注意点

カメラ

項目
AllowHDR true
CullingMask メモリだけにした方が無難且つ低負荷
TargetTexture RenderTextureをセット
Depth 場合によって値を調整する(後述します)

AllowHDRをtrueにしないと意図した値を表現する色が出力されない事があるので、true。前述のサンプルとか動かなくなります。frac()してるから動く気がするけど何故か動かない。

RenderTexture

項目
AntiAliasing None
sRGB false
FilterMode Popint

フィルタリングとか補間とか色を変化させる要素は全てオフにしないと意図した値を表現する色が得られないのでオフに。
フォーマットと解像度は用途次第で適宜設定するんですが、負荷の為に適切に設定した方がgood。

Skinned MeshRendererのBB

オブジェクトはメッシュを囲むバウンディングボックスがカメラの視錐台の外側にある時描画されないですが、パーティクル側のシェーダーはジオメトリシェーダーでメッシュを生成するのでバウンディングボックスをはみ出る位置にQuadを生成することがあります。意図しない状態でパーティクルが描画されなくなることを避ける為に、MeshRendererではなくSkinnedMeshRendererを使ってBoundsのExtentsを十分に大きな値にしておくと無難です。
(画像では(100,100,100)にしてますがサンプルは本当は(1,1,1)で足りてます。)

CustomRenderTexture

GPGPUをする手段としてCustomRenderTextureも採用できる場面もありますが、VRC上で挙動が安定しない場面が多いので割愛します。(もしかしたらもうアップデートで安定しているかも?)
CRT(CustomRenderTexture)を使ってBoidsを作ろうとした時に、位置のメモリとしてのCRTと速度のメモリとしてのCRTを作りそれぞれのマテリアルに両方のCRTを入力したら、循環するように参照させるのダメってエラーが出たので複雑なことをするとしようとするとカメラフィードバックを使う事になります。

便利関数

テクスチャの幅と高さが等しい前提のコード載せてますが修正していないだけで幅と高さが違うテクスチャに対応するのも簡単なので必要なら適宜直してください。

UVからテクセルのIDを導出する関数。

導出工程を分解するとこう。

IDに対応するテクセルのUVを導出する関数

導出工程を分解するとこう。

float pack/unpack


//float を fixed4 で表現
fixed4 pack(float value)
{
  uint uintVal = asuint(value);
  uint4 elements = uint4(uintVal >> 0, uintVal >> 8, uintVal >> 16, uintVal >> 24);
  fixed4 color = ((elements & 0x000000FF) + 0.5) / 255.0;
  return color;
}

//fixed4 を floatにデコード
float unpackFloat(fixed4 col)
{
  uint R = uint(col.r * 255) << 0;
  uint G = uint(col.g * 255) << 8;
  uint B = uint(col.b * 255) << 16;
  uint A = uint(col.a * 255) << 24;
  return asfloat(R | G | B | A);
}

formatのお話

サンプルではRenderTextureのフォーマットをARGB32にしていました。ARGB32ではなくARGBFloatにして1ピクセルにFloat精度で座標のxyz成分を保存した方が処理がシンプルになるのでそうしたいですが、どうもARGBFloatにすると次のフレームに色を保持した状態でテクスチャを渡せなかったり負の値が0にクランプされたりで期待通りに動かなかったのでARGB32を採用しています。
なにか条件次第なのかよく分からないですね...
使えるようになったら起こしてください。

もっと知りたい人向け

Shaderで計算機を作る