カイワレスタイル

ゲーム、アニメ、プログラム、興味のあることをツラツラと。

【VRChat】GPUパーティクル同士が線で結ばれるワールド「Simple Plexus」の解説

f:id:kaiware007:20191130135303p:plain

前置き

本記事は、VRChatアドベントカレンダー2019の2日目の記事です。
前日はほたてねこまじんさんの「自分の部屋をデジタル空間に持っていった話」でした。

adventar.org

今回は、VRChatで自作したワールドの簡単な紹介と、そのワールドで全体的に使用している自作GPUパーティクルの実装の解説を書きたいと思います。

ワールド概要

記事のトップの画像が今回作成した「Simple Plexus」ワールドのスクリーンショットです。
空間全体にGPUパーティクルを敷き詰め、パーティクル同士が近づくと線で結ばれる仕組みになってます。
いわゆるAfterEffectの「Plexus」プラグインをシェーダーで一部再現してみた感じです。

ワールドのリンクは下記にあるのでVRChatユーザーはぜひ来てください。 vrchat.com

ワールドの中央には、ローポリでツルピカな大仏が鎮座して周囲のパーティクルの光を反射しています。 f:id:kaiware007:20191130140306p:plain モデル自体は、Yahoo! JapanCreative Commonsライセンスで配布している大仏の3DデータをBlenderで頂点数を削減してカクカクにしたものを使用しています。

www.thingiverse.com

モデルのシェーダー自体はUnity標準のStandardShaderですが、ReflectionProbeで毎フレームCubeMapを生成してパーティクルの明滅を反映させてます。

大仏の右の方には、パーティクルの制御用のコントロールパネルがあります。

f:id:kaiware007:20191130142452p:plain

左から、

  • Wave
    パーティクルは、一定周期で大きさが変わったり線が繋がらなくなったりですが、その変化の度合いの調整をするパラメータです。0にすると明るさがほぼ変わらなくなって線もつながる条件ならつながり続けます。
  • Time Speed
    パーティクルの動く速さのスライダーです。0にすると止まります。
  • Connect Distance
    パーティクル同士を線で結ぶ距離のしきい値のスライダーです。パーティクル同士の距離がしきい値以下になると線で結ばれます。簡単に言うとスライダーを上げると線が結ばれやすくなって、下げると結ばれにくくなります。
  • Reset Parameter
    上記3つのパラメータを初期値にリセットするボタンです。
  • Mirror
    鏡を出したり消したりするボタンです(撮影用)

これらのパラメータを変更することでワールドの雰囲気を多少変化させることができます。
Connect Distanceを最大まで上げた状態↓ f:id:kaiware007:20191130144739p:plain

実装解説

ワールドの紹介はここまでにして、実装の解説に移ります。
VRChatは、ユーザーが自作したアバターやワールドをアップロードすることができますが、ユーザーが自作したC#スクリプトコンポーネントは使用できないという制約があります。
ただし、自作シェーダーは使えるので、工夫次第でいろんな事ができます。
「Simple Plexus」ワールドも、パーティクル部分の仕組みはすべてシェーダーで実現しています。
今回サンプルとしてGPUパーティクルの部分だけを取り出したプロジェクトをgithubで公開します。

github.com

簡略化のために、スライダーによるシェーダーパラメータの変更を抜いているので、実際にVRChatで公開してるワールドとは若干仕様が違います。

CustomRenderTextureについて

今回、UnityのCustomRenderTextureという機能を使ってパーティクルの座標更新を行いました。 CustomRenderTextureの詳しい仕様についてはUnityのマニュアルか、凹みさんのブログを参照してください(いつも大変お世話になっております)

カスタムレンダーテクスチャ - Unity マニュアル

tips.hecomi.com

ものすごく簡単に説明すると、シェーダーつきのテクスチャです。毎フレームシェーダーを走らせて絵を変えることができます。
今回の自分の使い方としては、パーティクルの更新した座標を格納するのに使っています。
まず、CustomRenderTextureの設定ですが、32x32x32頂点の立方体メッシュの一頂点をCustomRenderTextureの1ピクセルとして扱いたかったので、32x32x32の解像度の3次元テクスチャにしています。

f:id:kaiware007:20191130153436p:plain

パーティクルの素になるメッシュの作成

VRChatはC#スクリプトが使えないので、Graphics.DrawProceduralなどの直接的に描画処理を呼ぶAPIが使えません。
そのため、通常のMeshRenderにMeshを登録して描画させなければなりません。
しかし、32x32x32頂点の立方体をBlenderで作るのも(操作に慣れてないのもあり)面倒なので、一辺をN頂点で分割した立方体状の点群メッシュを生成するEditor拡張を作りました。

gist.github.com

32x32x32回分Vertexシェーダーが走ればいいだけなので、必要分の頂点座標の配列と、ただ順番に番号を詰め込んだIndex配列だけセットしています。
パーティクルや線のポリゴンはGeometryシェーダー内で生成しているので、UV座標もセットしていません。
注目ポイントは、Mesh.SetIndicesの2番めの引数でMeshTopology.Pointsを指定して点群メッシュとして生成しているところです。

パーティクルの座標更新

前項でMeshを作りましたが、実際の描画にはMeshの頂点座標の値は使いません。 頂点座標は、CustomRenderTextureの中で計算して書き込み、描画時にそれを取り出して使います。

実際にCustomRenderTextureの更新に使っているシェーダー(PlexusUpdate.shader)のメインの部分は下記になります。

 float4 fragUpdatePosition(v2f_customrendertexture i) : SV_Target
    {
        // テクスチャのUV座標(3次元)
        float3 pos = i.globalTexcoord;
        
        // 1ドット1メートルとする
        pos *= float3(_CustomRenderTextureWidth, _CustomRenderTextureHeight, _CustomRenderTextureDepth);

        // スライダーから時間の速さを取得
        float3 sliderPos = readDataPos(1);
        float range = _MaxY - _MinY;
        float limitY = clamp(sliderPos.y, _MinY, _MaxY);
        float normalizeY = saturate((limitY - _MinY) / range);
        float timeSpeed = lerp(_TimeSpeedMin, _TimeSpeedMax, normalizeY);

        // RTX2080系だとノイズ関数で浮動小数点演算の誤差かなんかで結果が偏りまくるのであまり大きな値にならないようにしている(ループするときに微妙だけど…)
        float time = fmod(_Time.y * timeSpeed, 256) + 138.21;

        // シンプレックスノイズで座標をゆらゆらさせてる
        float noiseSpeed = snoise(float2(pos.x + pos.y * _CustomRenderTextureWidth + pos.z * _CustomRenderTextureWidth * _CustomRenderTextureHeight / 34.2148, time * _PositionSpeed1 + 32.153));
        float yz = time * noiseSpeed * _PositionSpeed2;
        pos.xyz += snoise3D(pos.xyz  * _PositionNoiseScale + float3(yz * 0.1, yz * 0.34, yz * 0.75)) * _PositionRange;
        pos /= float3(_CustomRenderTextureWidth, _CustomRenderTextureHeight, _CustomRenderTextureDepth); // 正規化する

        // テクスチャに座標を書き込む
        return float4(pos, 1);
    }

3次元テクスチャのピクセル数分fragUpdatePositionが実行されます。
3次元テクスチャの1ピクセルを1頂点として、その頂点の座標と時間をもとにノイズでゆらゆらさせた座標を最後にRGBチャンネルにXYZ座標を書き込んでます。 明らかに1を超えたり負の値になったりしますが、テクスチャのColor Formatの設定をRGBA Floatにしているので問題ありません。

パーティクルの描画

描画は、PlexusDraw.shaderで行っています。
シェーダーの中身は、主にVertexシェーダーとGeometryシェーダーとFragmentシェーダーに分かれています。
Vertexシェーダーから見ていきましょう。

struct appdata
{
    float4 vertex : POSITION;
    uint vid : SV_VertexID;
};

struct v2g
{
    float4 vertex : SV_POSITION;
    uint vid : TEXCOORD0;
    float scale : TEXCOORD1;
    float particleIntensity : TEXCOORD2;
    float lineIntensity : TEXCOORD3;
    float4 positions[13] : TEXCOORD4;
};

// 省略~~~~

sampler2D _MainTex;
float4 _MainTex_ST;
UNITY_DECLARE_TEX3D(_PosTex3D);
float3 _PosTexTexelSize;

float _Size;
float _SizeNoiseScale;
float _SizeNoiseSpeed;
float _LineWidth;
float _Intensity;
float _FogPower;

float4 _Color;
float3 _ColorNoiseScale;
float _ColorNoiseSpeed;
float _ColorSat;

float _ConectDist;


v2g vert(appdata v)
{
    v2g o;

    float3 vpos = v.vertex.xyz + _PosTexTexelSize.xyz * 0.5; // 自身のUV座標

    // 3次元テクスチャから頂点座標を取り出す
    float4 pos = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos, 0);

    float time = fmod(_Time.y, 256) + 132.5347;

    // 自身の頂点座標をワールド座標に変換
    o.vertex = mul(unity_ObjectToWorld, pos);

    o.vid = v.vid;
    o.scale = clamp((_Size + (snoise(float2((float)v.vid / 234.2148, time * _SizeNoiseSpeed + 32.153)) * 0.5 + 0.5) *_SizeNoiseScale), 0.01, 1);

    // パーティクルや線のの明るさ計算
    o.particleIntensity = saturate(exp(-distance(o.vertex.xyz, _WorldSpaceCameraPos.xyz) * _FogPower));
    o.lineIntensity = saturate(o.particleIntensity);

    // 隣接する頂点の座標を取得
    float4 pos0  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(0, 0, _PosTexTexelSize.z), 0);
    float4 pos1  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.x, 0, _PosTexTexelSize.z), 0);
    float4 pos2  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.x, 0, 0), 0);
    float4 pos3  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(0, _PosTexTexelSize.y, 0), 0);
    float4 pos4  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(0, _PosTexTexelSize.y, _PosTexTexelSize.z), 0);
    float4 pos5  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.x, _PosTexTexelSize.y, _PosTexTexelSize.z), 0);
    float4 pos6  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.x, _PosTexTexelSize.y, 0), 0);
    float4 pos7  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.x, -_PosTexTexelSize.y, _PosTexTexelSize.z), 0);
    float4 pos8  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.x, -_PosTexTexelSize.y, 0), 0);
    float4 pos9  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.x, -_PosTexTexelSize.y, _PosTexTexelSize.z), 0);
    float4 pos10 = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.x, 0, -_PosTexTexelSize.z), 0);
    float4 pos11 = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(0, _PosTexTexelSize.y, -_PosTexTexelSize.z), 0);
    float4 pos12 = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.xy, -_PosTexTexelSize.z), 0);

    // ワールド座標に変換
    o.positions[0] = mul(unity_ObjectToWorld, pos0);
    o.positions[1] = mul(unity_ObjectToWorld, pos1);
    o.positions[2] = mul(unity_ObjectToWorld, pos2);
    o.positions[3] = mul(unity_ObjectToWorld, pos3);
    o.positions[4] = mul(unity_ObjectToWorld, pos4);
    o.positions[5] = mul(unity_ObjectToWorld, pos5);
    o.positions[6] = mul(unity_ObjectToWorld, pos6);
    o.positions[7] = mul(unity_ObjectToWorld, pos7);
    o.positions[8] = mul(unity_ObjectToWorld, pos8);
    o.positions[9] = mul(unity_ObjectToWorld, pos9);
    o.positions[10] = mul(unity_ObjectToWorld, pos10);
    o.positions[11] = mul(unity_ObjectToWorld, pos11);
    o.positions[12] = mul(unity_ObjectToWorld, pos12);

    return o;
}

元のメッシュの頂点座標を3次元のUV座標に変換して、3次元テクスチャからUNITY_SAMPLE_TEX3D_LODで頂点座標の情報を取り出しています。
また、次のGeometryシェーダーで隣接する頂点と線を結ぶために、隣接する頂点の座標も配列にセットしています。
3次元空間をグリッド状に区切り、1グリッドの中に1つの頂点が入ってると仮定します。
その場合、隣接するグリッドは全部で26個1あります。流石にすべてのグリッドが26個ずつ調べるのは負荷が大きそうです。
しかし、線は点と点を結ぶものなので、線がつながるかどうかは片方の点(グリッド)が判定するだけでいいのです。
つまり、すべての点(グリッド)が自分の右下方向だけを見るようにすれば、各グリッドが判定する隣接グリッド数は7個になります。
f:id:kaiware007:20191130231725p:plain

1. (0,0,1) 2. (1,0,1) 3. (1,0,0) 4. (0,1,0) 5. (0,1,1) 6. (1,1,1) 7. (1,1,0)

自分の右下方向の頂点7つを次のGeometryシェーダーに渡しています。

※(2019/12/8修正)隣接するグリッド数は7個では無く、13個でした。
自分でも勘違いしてたので、Unityで模型を作って確認してみました。
下図では、赤い球体は自身のグリッド、周りの青い球体は隣接するグリッド、水色の球体は今までの自信から参照する隣接するグリッドを表しています。 f:id:kaiware007:20191208152150j:plain

これに隣接するグリッドからの参照も表示したものが下図です。

f:id:kaiware007:20191208152431j:plain

線が多すぎてわかりにくいので、正面(Z軸方向を奥にして)から見てみます。

f:id:kaiware007:20191208152953j:plain

あれ?斜め上方向に線が繋がってないぞ…?ということがわかりました。
下図の赤い線です。

f:id:kaiware007:20191208153305p:plain

これはZ軸だけを見たものですが、他の軸から見ても同様なので、斜め方向にも参照を追加します。
下図の緑の球体と線が追加した参照です。

f:id:kaiware007:20191208153558j:plain

既存のグリッド数も合わせると全部で13個になります。

  1. (0,0,1)
  2. (1,0,1)
  3. (1,0,0)
  4. (0,1,0)
  5. (0,1,1)
  6. (1,1,1)
  7. (1,1,0)
  8. (1,-1,1)
  9. (1,-1,0)
  10. (1,-1,-1)
  11. (1,0,-1)
  12. (0,1,-1)
  13. (1,1,-1)

f:id:kaiware007:20191208155945j:plainf:id:kaiware007:20191208155954j:plain

追加した参照の線も周辺グリッドに配置すると、隣接するグリッドすべてがちゃんと結ばれるようになりました(わかりにくいですが…)
おこうさん、ご指摘ありがとうございました!

で、話をもどしまして…

Geometryシェーダーは大きく2つの構成に分かれています。
前半は、自身のパーティクルの板ポリ(ビルボード)を作成するパート。
後半は、隣接する頂点を線で結ぶかの判定と実際の線の板ポリを作成するパートです。

struct v2g
{
    float4 vertex : SV_POSITION;
    uint vid : TEXCOORD0;
    float scale : TEXCOORD1;
    float particleIntensity : TEXCOORD2;
    float lineIntensity : TEXCOORD3;
    float4 positions[7] : TEXCOORD4;
};

struct g2f
{
    float4 pos : SV_POSITION;
    float2 uv : TEXCOORD0;
    float intensity : TEXCOORD1;
    float3 color : TEXCOORD2;
};

// 省略~~~~~

// ジオメトリシェーダ
[maxvertexcount(82)]
void geom(point v2g input[1], inout TriangleStream<g2f> outStream)
{
    g2f output;
    float4 pos = input[0].vertex;
    float particleIntensity = input[0].particleIntensity;
    float lineIntensity = input[0].lineIntensity;
    float scale = input[0].scale;
    float3 color = hsv2rgb(float3(snoise(float4(pos.xyz * _ColorNoiseScale, _Time.y * _ColorNoiseSpeed)) * 0.5 + 0.5, _ColorSat, 1));

    // ビルボード用の行列
    float4x4 billboardMatrix = UNITY_MATRIX_V;
    billboardMatrix._m03 = billboardMatrix._m13 = billboardMatrix._m23 = billboardMatrix._m33 = 0;

    // パーティクル(点)の板ポリ作成
    // 四角形になるように頂点を生産
    for (int x = 0; x < 2; x++)
    {
        for (int y = 0; y < 2; y++)
        {
            // UV
            float2 uv = float2(x * 0.5, y);
            output.uv = uv;

            // 頂点位置を計算
            output.pos = pos + mul(float4((float2(x, y) * 2 - float2(1, 1)) * scale, 0, 1), billboardMatrix);
            output.pos = mul(UNITY_MATRIX_VP, output.pos);
            output.intensity = particleIntensity;
            output.color = color;

            // ストリームに頂点を追加
            outStream.Append(output);
        }
    }

    // トライアングルストリップを終了
    outStream.RestartStrip();

    // 線の処理
    if (lineIntensity > 0.0) {
        // 近い点同士を線で結ぶ
        float3 cameraDiff = pos.xyz - _WorldSpaceCameraPos;
        float3 normal = normalize(cameraDiff);

        for (int i = 0; i < 13; i++)
        {
            float4 targetPos = input[0].positions[i];

            // 点同士の距離を判定
            float len = distance(targetPos, pos);
            if (len <= _ConectDist)
            {
                // 点と点を結ぶベクトル(線の方向)
                float3 dir = normalize(targetPos.xyz - pos.xyz);
                // 線の幅方向のベクトル
                float3 right = normalize(cross(dir, normal)) * _LineWidth * 0.5;

                // 線のポリゴンを構成する4頂点の座標
                float4 v0 = mul(UNITY_MATRIX_VP, float4(pos.xyz - right, 1));
                float4 v1 = mul(UNITY_MATRIX_VP, float4(pos.xyz + right, 1));
                float4 v2 = mul(UNITY_MATRIX_VP, float4(targetPos.xyz - right, 1));
                float4 v3 = mul(UNITY_MATRIX_VP, float4(targetPos.xyz + right, 1));

                float3 targetColor = hsv2rgb(float3(snoise(float4(targetPos.xyz * _ColorNoiseScale, _Time.y * _ColorNoiseSpeed)) * 0.5 + 0.5, _ColorSat, 1));

                // 点同士の距離に応じて線の明るさを変える(近いほど明るい)
                float distIntensity = 1 - smoothstep(0.0, 0.5, saturate(len / _ConectDist));
                output.intensity = lineIntensity * distIntensity;

                // 線が見える時にだけ線を引く
                if (output.intensity > 0.0)
                {
                    // triangle line
                    output.pos = v0;
                    output.uv = float2(0.5, 0);
                    output.color = color;
                    outStream.Append(output);

                    output.pos = v2;
                    output.uv = float2(0.5, 1);
                    output.color = targetColor;
                    outStream.Append(output);

                    output.pos = v1;
                    output.uv = float2(1, 0);
                    output.color = color;
                    outStream.Append(output);

                    outStream.RestartStrip();

                    output.pos = v2;
                    output.uv = float2(0.5, 1);
                    output.color = targetColor;
                    outStream.Append(output);

                    output.pos = v3;
                    output.uv = float2(1, 1);
                    output.color = targetColor;
                    outStream.Append(output);

                    output.pos = v1;
                    output.uv = float2(1, 0);
                    output.color = color;
                    outStream.Append(output);

                    outStream.RestartStrip();
                }
            }
        }
    }
}

最後にFragmentシェーダーです。
特に複雑なことはしておらず、Geometryシェーダーで生成したポリゴンにUV座標に応じてテクスチャの色とパーティクルの明るさを乗算しているだけです。

struct g2f
{
    float4 pos : SV_POSITION;
    float2 uv : TEXCOORD0;
    float intensity : TEXCOORD1;
    float3 color : TEXCOORD2;
};

// 省略~~~~

fixed4 frag(g2f i) : SV_Target
{
    // sample the texture
    fixed4 col = tex2D(_MainTex, i.uv);
    col.rgb *= _Intensity * i.intensity * i.color;

    return col;
}

スライダーによるパラメータの変更

リポジトリには入れてないんですが、実際のVRChatのワールドではパーティクルのパラメータをスライダーで調整できるようになっています。

f:id:kaiware007:20191130184228p:plain

スライダーのつまみの座標位置をシェーダーに渡すため、@yagiri_pgさんの「座標、姿勢をRenderTextureに書込、読込機構」を使わせていただきました。圧倒的感謝! yagiri.booth.pm

前述したとおり、VRChatはC#スクリプトが使えないので、オブジェクトの座標をシェーダーに正攻法では渡せません。
正直自分ではどうすればいいかわからなかったところに、@yagiri_pgさんが機構を公開してくれたので非常に助かりました。
実際に中身を見て驚愕したんですが、概要を書くと下記の通りです。

  1. Quadにシェーダーを割り当てる
  2. 特定のカメラにだけ映るようにレイヤー設定する
  3. 特定のカメラにRenderTextureを割り当てる
  4. 特定のカメラに該当Quadが映るときに、頂点シェーダーでカメラの指定した位置にQuadを移動させて座標を色にしてレンダリングさせる

こうすることで、RenderTextureの特定の位置に値を書き込めるという寸法らしいですC#スクリプトなら1行くらいで済むことをやるのにこれだけの手順を踏まないといけないと知った時は辛い気持ちになった)

また、スライダーのパラメータの表示に@butadiene121さんの「数字表示シェーダー」を使わせていただきました。圧倒的感謝!

まとめ

長々とした説明になってしまいましたが、まとめると以下のとおりです。

  • 必要な頂点数分の点群メッシュを作成
  • CustomRenderTextureでパーティクルの座標を更新
  • VetexシェーダーでCustomRenderTextureから頂点座標を取り出して頂点の座標を移動
  • Geometryシェーダーでパーティクルと線のポリゴンを作成
  • VRChatのシェーダー芸人達は凄い

最後に、「Simple Plexus」ワールド作成に助力頂いた方々に圧倒的感謝!

f:id:kaiware007:20191130143016p:plain

※画像に載ってないけど初公開時にワールドに来てくださった方々に圧倒的感謝!


  1. 27個と書いてたけど、自身を抜いたら26個でした。