Imaginantia

思ったことを書きます

VRChatでGPGPUする (2023年版)

とりあえず私のやりかたについて情報を書いておこうかなと思います。

簡潔に言うと:

  • RenderTextureをUdonで用意して
  • VRCGraphics.Blit で計算を回して
  • CPUで情報取りたければ VRCAsyncGPUReadback.Request から OnAsyncGpuReadbackComplete で拾います。

サンプル作ってみたのでそっちを見る方が早いかもしれません。ご自由にどうぞ: particles0925.unitypackage (Rawからダウンロードできます)

あとリポジトリにフォルダごとアップロードしたのでGitHub上でも見やすくなりました: Particles/

ちなみに何かに部分的に流用した場合でも特に私のことは書かないで大丈夫です (書かないでください)。いい感じによろしくおねがいします。

基本的な仕組み

要はGPU側のバッファを作って、用意したシェーダで中身を更新していきたいわけです。

今はUdonnew RenderTexture すればGPUバッファを作れるのでめちゃ簡単ですね。

中身の更新は素直に VRCGraphics.Blit を使います。source をちゃんと指定してもまぁいいんだと思いますが、なんか怖いので、自前で渡すようにしてます。

状態の更新を行うには「前フレームの自分自身のバッファ」を拾う必要がありますが、多分 Double Buffering の機能は無いので (いつ使えるのか微妙によくわからないので)、バッファは必ず2個セットで作るようにしています。

こんなかんじ:

public Material update;

private RenderTexture rt0;
private RenderTexture rt1;
private bool outputIs0 = true;

void Start()
{
    int W = 256;
    int H = 256;
    rt0 = new RenderTexture(W, H, 0, RenderTextureFormat.ARGBFloat);
    rt0.filterMode = FilterMode.Point;
    rt0.Create();
    rt1 = new RenderTexture(W, H, 0, RenderTextureFormat.ARGBFloat);
    rt1.filterMode = FilterMode.Point;
    rt1.Create();
    update.SetFloat("_Init", 1);
}

void FixedUpdate()
{
    if(outputIs0) {
        update.SetTexture("_Store", rt0);
        VRCGraphics.Blit(null, rt1, update);
    } else {
        update.SetTexture("_Store", rt1);
        VRCGraphics.Blit(null, rt0, update);
    }
    update.SetFloat("_Init", 0);
    outputIs0 = !outputIs0;
}

初期化は毎回 _Init 変数を使ってやってます。なんかもったいない気もするけどしょうがない気もする。バッファは何故か _Store という名前をずっと使い続けていますが、好みで大丈夫です。

ちなみに何度 Blit を呼んでももちろん大丈夫なので、複数パスのシミュレーションとかも気軽に書けるかなって思います。

Double Buffering を配列でやるかどうかは好みだと思います。

計算シェーダ

ここからはもう個人のやり方なので、参考にしたりしなかったりしてください

基本的に .shader ファイルにはほぼ何も書かず、.cginc に処理を記述するようにしています。理由は:

  • どうせ描画するときにも使う関数が結構多い
  • インデントが深くて邪魔
  • 複数パスを1ファイルにまとめて書ける

という感じです。

なのでこれはほぼ固定です。

Shader "Im/Test/Update"
{
    Properties
    {
        _Store ("Store", 2D) = "black" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "Core.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
#if UNITY_UV_STARTS_AT_TOP
                o.vertex = float4(v.uv.x*2-1,1-v.uv.y*2,1,1);
#else
                o.vertex = float4(v.uv.x*2-1,v.uv.y*2-1,1,1);
#endif
                o.uv = v.uv;
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                uint2 iuv = i.uv * float2(W,H);
                return update(iuv);
            }
            ENDCG
        }
    }
}

念のため UV_STARTS_AT_TOP を気にしたりしつつ座標 (uint2) を update 関数に投げます。メイン処理は Core.cginc にあるという想定。

ちなみに i.uv * float2(W, H) は基本的には「整数 + 0.5」になっているはずです。なので uint2 に突っ込んだらちゃんと floor になって正しい値がとれるはず。

バッファをサンプルする関数はだいたいコレです:

#define W 256
#define H 256

sampler2D _Store;

float4 pick(uint2 iuv) {
    return tex2Dlod(_Store, float4((iuv+0.5)/float2(W,H), 0, 0));
}

言うことは特にないですね。これを表示側のシェーダでも使えばまぁいい感じになると思います。更新部分を少し書き換えるのを忘れずに。

if(outputIs0) {
    update.SetTexture("_Store", rt0);
    VRCGraphics.Blit(null, rt1, update);
    visual.SetTexture("_Store", rt1);
} else {
    update.SetTexture("_Store", rt1);
    VRCGraphics.Blit(null, rt0, update);
    visual.SetTexture("_Store", rt0);
}

その他

複数ピクセル

1pxだと情報が足りないので複数ピクセル使って1要素を構成したいことがあります。その場合は適当に縦横に並べています。2x2pxで同じリソースを参照するほうが多分効率が良いのでこれがいいはず。

ちなみに9pxで足りるときでも16px使います。きれいに揃えたほうが多分速い…というよりもGPU的に気持ちが良いと思います。

あと、このときは pick の引数を増やしたほうがわかりやすい感じになります。多分。

#define EW 4
#define EH 2
float4 pick(uint2 particleIndex, uint2 elementIndex) {
    return tex2Dlod(_Store, float4((particleIndex*float2(EW, EH)+elementIndex+0.5)/float2(W*EW, H*EH), 0, 0));
}
float4 d0 = pick(pi, uint2(0,0));
float4 d1 = pick(pi, uint2(1,0));
float4 d4 = pick(pi, uint2(0,1));
float4 d5 = pick(pi, uint2(1,1));

こうやって使う。

逆に書き込みのときは「選ぶ」関数を使って return することになります。

float4 select(uint2 elementIndex, float4 d0, float4 d1, float4 d2, float4 d3, float4 d4, float4 d5, float4 d6, float4 d7) {
    return elementIndex.y == 0
        ? elementIndex.x < 2 ? elementIndex.x == 0 ? d0 : d1: elementIndex.x == 2 ? d2 : d3
        : elementIndex.x < 2 ? elementIndex.x == 0 ? d4 : d5: elementIndex.x == 2 ? d6 : d7;
}

Mipmap

変なこと をするときにMipmapがほしいときがあります。

RenderTexture を作るときに設定すればいいです。

rt.useMipMap = true;
rt.autoGenerateMips = true;

こうすると Blit 後に自動的にMipmapが更新されるようになるっぽいです。

Udonに読み戻す

これをまず読むのが一番いいと思います。

creators.vrchat.com

VRCAsyncGPUReadback.RequestTexture 型を引数に取ります。なのでもちろん RenderTexture を渡せます。

引数がいっぱいありますが要は「一部分だけ読み出せる」っぽいので、必要最低限のところを切り出すのが効率良いんじゃないかなと思います。

Callbackとして指定する IUdonEventReceiver インタフェースは VRC.Udon.Common.Interfaces にあるので using しておきましょう。

完了すると OnAsyncGpuReadbackComplete メソッドを呼んでくれますが、この間に1フレーム経つことには注意が必要です。

ちょうど1フレームとは仕様には明言されていませんが、実験的には1フレームのようです。でもそれに依存したコードはあんまり良くないかも。まぁ「そのうち帰ってくる」わけです。だからいい感じにロジック側で遅延を吸収してあげる必要があります。非同期処理あるあるですね。

ちなみにデータは byte[] Color[] Color32[] float[] のどの形式でも受け取ることができます。これは例えば AudioClip.SetData に直接渡したりもできるということです。たのしそう。

あと、現在は Quest 上だと VRCAsyncGPUReadback が動きません (hasError = true になります) が、Unity 2022 になると直ります (Quest の open-beta で直っています)。わくわく。

おわりに

phi16.hatenablog.com

懐かしいですね。良い環境になりました。いっぱい計算しましょう。

 

サンプルの解説は特にありませんが、まぁわからないところがあったら気軽に呼んでください。