【VRChat】Quest でも GPU Particle したい！

GPU Particle を書いたことがある人向けの記事です．

# 概要
GPU Particle ではよく Geometry Shader を使いますが，Quest (Android) 環境では Geometry Shader が使えません．
そこで，専用のメッシュと Vertex Shader で Geometry Shader 相当の処理を代替し，Quest でも見える GPU Particle を作ります．

# Particle 用メッシュを作成
Geometry Shader で作りたい四角形をあらかじめ静的なメッシュとして作成しておきます．
- 四隅の UV が (0,0), (0,1), (1,0), (1,1)

`SV_PrimitiveID` に相当するインデックスが欲しいので，頂点カラーとしてメッシュに格納します．
- index の下位 24bit を頂点カラーの RGB に格納
※ Mobile 環境で uint を使える保証がないので float の精度に合わせます．

```cs:CreateParticleMesh.cs
using UnityEngine;
using UnityEditor;
using System.Linq;
using System.Collections.Generic;

public class CreateParticleMesh : ScriptableWizard
{
    public int number = 10000;
    public string saveFolder = "Assets/Temp";

    [MenuItem("Utils/Mesh/Create Particle Mesh")]
    static void Open()
    {
        DisplayWizard<CreateParticleMesh>("Create Particle Mesh");
    }

    void OnWizardCreate()
    {
        List<Vector3> points = new List<Vector3>();
        List<Vector2> uvs = new List<Vector2>();
        List<Color> colors = new List<Color>();
        List<int> triangles = new List<int>();

        List<Vector2> uv = new List<Vector2>
        {
            new Vector2(0, 0),
            new Vector2(1, 0),
            new Vector2(0, 1),
            new Vector2(1, 1)
        };

        // メッシュの数を 24bit に制限
        number = Mathf.Min(number, 0xffffff);

        for (int i = 0; i < number; i++)
        {
            // 四角形の頂点(なんでもいい)
            points.Add(new Vector3((float)(i    ) / number - 0.5f, -0.5f, 0));
            points.Add(new Vector3((float)(i + 1) / number - 0.5f, -0.5f, 0));
            points.Add(new Vector3((float)(i    ) / number - 0.5f,  0.5f, 0));
            points.Add(new Vector3((float)(i + 1) / number - 0.5f,  0.5f, 0));
            // 四角形のUV
            uvs.AddRange(uv);
            // 四角形のインデックスを VertexColor に格納
            Color c = new Color32(
                (byte)((i >>  0) & 0xff),
                (byte)((i >>  8) & 0xff),
                (byte)((i >> 16) & 0xff),
                1  // float の精度に合わせて上位 8bit は破棄 (Mobile で uint が使えないかもしれないため)
            );
            colors.Add(c);
            colors.Add(c);
            colors.Add(c);
            colors.Add(c);
            // 面を張るためのインデックス
            triangles.Add(i * 4 + 0);
            triangles.Add(i * 4 + 2);
            triangles.Add(i * 4 + 1);
            triangles.Add(i * 4 + 2);
            triangles.Add(i * 4 + 3);
            triangles.Add(i * 4 + 1);
        }

        // メッシュを作成
        Mesh mesh = new Mesh();
        if (number * 4 > 65535)
        {
            mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
        }
        mesh.SetVertices(points.ToList());
        mesh.SetUVs(0, uvs.ToList());
        mesh.SetColors(colors.ToList());
        mesh.SetTriangles(triangles, 0);
        // バウンディングボックスを 1x1x1 に設定
        mesh.bounds = new Bounds(Vector3.zero, Vector3.one);

        // 保存先フォルダのフォーマットを整える
        saveFolder = saveFolder.Replace('\\', '/').Trim('/');
        if (!saveFolder.StartsWith("Assets/"))
        {
            saveFolder = "Assets/" + saveFolder;
        }
        // フォルダが存在しない場合は作成
        if (!AssetDatabase.IsValidFolder(saveFolder))
        {
            System.IO.Directory.CreateDirectory(Application.dataPath + saveFolder.Substring(6));
        }
        // 保存先のパスを生成
        string savePath = saveFolder + $"/Particle Mesh {number}p.asset";
        // メッシュをアセットとして保存
        savePath = AssetDatabase.GenerateUniqueAssetPath(savePath);
        AssetDatabase.CreateAsset(mesh, savePath);
        AssetDatabase.SaveAssets();

        // メッシュを選択状態にする
        Selection.activeObject = mesh;
    }
}
```

# GPU Particle 用 Shader の作成
いつも Geometry Shader で行っている処理を Vertex Shader で行います．頂点カラーに格納したインデックスを復号して使います．

```hlsl:GPUParticleQuest.shader
Shader "TsukimiWS/Test/GPUParticleQuest"
{
    Properties
    {
        [NoScaleOffset] _MainTex ("Texture", 2D) = "white" {}
        _Color ("Color", Color) = (1,1,1,1)
        [PowerSlider(2)] _Size ("Size", Range(0,0.1)) = 0.02
        _Alpha ("Alpha", Range(0,1)) = 1
        _ParticleCount ("Particle Count", Int) = 10000
        [Space(15)]
        _WaveSpeedX ("Wave Speed", Float) = 1
        _WaveFrequencyX ("Wave Frequency", Float) = 2
        _WaveHeightX ("Wave Height", Float) = 0.05
        _WaveSpeedZ ("Wave Speed", Float) = 2
        _WaveFrequencyZ ("Wave Frequency", Float) = 3
        _WaveHeightZ ("Wave Height", Float) = 0.05
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent" "VRCFallback"="Hidden" }
        ZWrite Off
        Blend One One

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 2.0
            #pragma multi_compile_fog
            #pragma multi_compile_instancing

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                fixed4 color : COLOR;
                float2 uv : TEXCOORD0;
                UNITY_VERTEX_INPUT_INSTANCE_ID
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
                float2 uv : TEXCOORD0;
                UNITY_FOG_COORDS(1)
                UNITY_VERTEX_OUTPUT_STEREO
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;
            half4 _Color;
            half _Size;
            half _Alpha;
            float _ParticleCount;
            half _WaveSpeedX;
            half _WaveFrequencyX;
            half _WaveHeightX;
            half _WaveSpeedZ;
            half _WaveFrequencyZ;
            half _WaveHeightZ;

            v2f vert(appdata v)
            {
                v2f o;
                UNITY_SETUP_INSTANCE_ID(v);
                UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(o);
                
                // パーティクルのインデックス
                float index = dot(float3(v.color.rgb) * 255.0, float3(1, 256, 65536));

                // パーティクルの中心位置
                float particleCountX = sqrt(_ParticleCount);
                float particleCountZ = _ParticleCount / particleCountX;
                float4 particlePos;
                particlePos.xz = float2(index % particleCountX, index / particleCountX) / float2(particleCountX, particleCountZ) - 0.5;
                particlePos.y = ((1 + sin(particlePos.x * _WaveFrequencyX * 2 * UNITY_PI + _WaveSpeedX * _Time.y)) * _WaveHeightX + 
                                 (1 + sin(particlePos.z * _WaveFrequencyZ * 2 * UNITY_PI + _WaveSpeedZ * _Time.y)) * _WaveHeightZ);
                particlePos.w = 1;

                // パーティクルの中心位置をビュー座標に変換
                particlePos.xyz = UnityObjectToViewPos(particlePos);

                // パーティクルに大きさを与えてスクリーン座標に変換
                particlePos.xy += (v.uv.xy - 0.5) * _Size;
                o.vertex = mul(UNITY_MATRIX_P, particlePos);

                // パーティクルの設定数を超えた場合は描画しない
                if (index >= _ParticleCount)
                {
                    o.vertex = float4(0, 0, 0, 0);
                }
                
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                UNITY_TRANSFER_FOG(o, o.vertex);

                return o;
            }

            fixed4 frag(v2f i) : SV_Target
            {
                half4 col = tex2D(_MainTex, i.uv);
                col.rgb *= _Color.rgb * col.a * _Alpha;
                UNITY_APPLY_FOG_COLOR(i.fogCoord, col, fixed4(0, 0, 0, 0));
                return col;
            }
            
            ENDCG
        }
    }
    FallBack Off
}
```

# 完成
メッシュにシェーダーを適用すればパーティクルが動き出します．うれしいですね．

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3825773/23d182ac-4e8b-4e7a-8e60-3595abce189d.png)

# 雑記
単純にポリゴン数分の負荷がかかるので，たくさんパーティクルを出したい場合は負荷に注意です．(Quest 3 で 1,048,576 パーティクル表示したら 22fps でした．)
同様の方法でトレイルもできるはずです．