VFX GraphでParticle SystemのForce Fieldを再現してみる

#はじめに
VFX GraphのVector Fieldの機能を利用することでParticle SystemのForce Field（みたいなもの）を実装してみることにしました．
細かい仕様や未実装な機能は多くありますが，それっぽい力場を作ることができたので満足です．

ある程度実装できたものをサンプルとして[GitHub](https://github.com/Asalato/VFX-ForceField)で公開しておきました．

本記事はCompute ShaderとかVFX Graphに一瞬触ったことがある人くらいが対象です．
それぞれの基本的な動作とかを書いていると永遠に記事が完成しそうにないのでご了承ください．そんな人でもGitHubのリポジトリ眺めてみると意外に勉強になるかもしれません．

今回のサンプルはUnity 2019.2.19f1で作成しました．
環境によっては動かないことがあるかもしれません，今のところは問題なさそうですが．

#Particle SystemのForce Fieldについて
Unity 2018.3頃から，Particle Systemに「[Force Field](https://docs.unity3d.com/ja/2018.4/Manual/class-ParticleSystemForceField.html)」というものが追加されました．
これは範囲内に存在するパーティクルに動きを与える力場のようなものです．
例えば，以下のようなエフェクトを簡単に実装することができます．（[Unite Berlin](https://www.youtube.com/watch?v=r9FvNn_vWJg)で発表されたサンプルです）
![ezgif-5-acab5601b2fb.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/433211/b95f3b6d-65c6-d52d-4b0e-60352146085e.gif)

しかしParticle SystemはCPU実装であるため，パーティクルの数を増やすとどうしようもなくアプリケーションが重くなってしまいます．
常に負荷と相談しながら画面の見栄えを整える作業というものは，とても心と体に良くないものです．

そこで代替案として，[VFX Graph](https://unity.com/visual-effect-graph)が浮上します．

VFX GraphはGPUベースのパーティクルシステムのため非常に軽量であり，数十万というパーティクルが描画されていてもほとんどFPSに影響がありません．
ノードベースで操作でき，同時期に追加された同じくノードベースのShaderエディタであるShader Graphとも相性が良い（らしい）です．

バージョンによってはpreview版である場合もありますが，もしこれが使える環境であれば正直Particle Systemは使いたくなくなるほどに快適です．

#処理の流れ
VFX Graphが受け取れる情報はUnity 2019.3現在

- 変数（int, floatとか）
- ベクトル（Vector2, Vector3とか）
- 画像（Texture2D, Texture3Dとか）

等があります．ここで問題なのは可変長配列をVFX Graphに伝えるのが難しいということです．
~~（Force Fieldの個数を決め打ちにすればいいのですが，それでは拡張性に乏しいと思ってしまいました）~~

そのために，<b>Compute Shaderで範囲内のベクトル場をTexture3Dに出力し，VFX Graphに転送する</b>という手段を取ります．
図にすると以下のような感じです．
![処理.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/433211/efbfa285-280d-4d77-71dd-2868910c6ace.png)

Compute Shaderを利用することで処理負荷を最小限にとどめつつ，カスタマイズ性が高いというVFX Graphの利点を組み合わせようとしています．

<b>以下のスクリプトを利用する前に事前にPackageManagerから`Mathmatics`と`VisualEffectGraph`をインポートする必要があります</b>

##Force Fieldの設定を保持するスクリプト
Particle Systemの方にある「Particle System Force Field」というコンポーネントのように，一つの力場の範囲，力の大きさなどを記述しておくコンポーネントを用意します．

```csharp:VFXForceField.cs
using Unity.Mathematics;
using UnityEngine;

public class VFXForceField : MonoBehaviour
{
    [Header("Shape")]
    public float StartRange;
    public float EndRange;

    [Header("Direction")]
    public float X;
    public float Y;
    public float Z;

    [Header("Gravity")]
    public AnimationCurve Strength = AnimationCurve.Linear(0, 1, 1, 1);
    public float GravityFocus;

    [Header("Rotation")]
    public float Speed;
    public float Attraction;
    public Vector2 RotationRandomness;

    public ForceFieldData GetData() => new ForceFieldData()
    {
        Center = this.transform.position,
        StartRange = this.StartRange,
        EndRange = this.EndRange,
        Direction = new float3(X, Y, Z),
        Strength = new float3x3(Strength.Evaluate(0), Strength.Evaluate(0.125f), Strength.Evaluate(0.25f),
                                Strength.Evaluate(0.375f), Strength.Evaluate(0.5f), Strength.Evaluate(0.625f), 
                                Strength.Evaluate(0.75f), Strength.Evaluate(0.875f), Strength.Evaluate(1f)),
        GravityFocus = Mathf.Clamp01(this.GravityFocus),
        Speed = this.Speed,
        Attraction = Mathf.Clamp01(this.Attraction),
        RotationRandomness = new float2(Mathf.Clamp01(RotationRandomness.x), Mathf.Clamp01(RotationRandomness.y))
    };

    private void OnDrawGizmos()
    {
        var position = transform.position;
        Gizmos.color = Color.yellow;
        Gizmos.DrawWireSphere(position, StartRange);
        Gizmos.DrawWireSphere(position, EndRange);
    }
}

public struct ForceFieldData
{
    public float3 Center;
    public float StartRange;
    public float EndRange;

    public float3 Direction;

    public float3x3 Strength;
    public float GravityFocus;

    public float Speed;
    public float Attraction;
    public float2 RotationRandomness;
}
```

Particle System版のようにデバックをしやすくするためにGizmoを描画するメソッドも併記しておきました．

今回はとりあえず当初の目論見がうまくいくかを確認するためだけなので，Shapeは球のみ，Dragの項目は省いています．

あと，後の処理でデータを引き出しやすいよう参照するためのメソッド(GetData())も追加しています．
ここでの工夫はCompute Shaderに情報を渡すためにAnimationCurveからfloat3x3に変換していることくらいですかね．

##Force Fieldの情報をまとめ，Compute Shaderに転送するスクリプト
ここでは，上のForceFieldに格納された情報をComputeBufferにまとめ，Compute Shaderに転送する処理を行います．
3次元のデータを持つRenderTextureを作成するときは`RenderTexture.depth`ではなく`RenderTexture.volumeDepth`にサイズを格納することがミソでしょうか．

このスクリプトをアタッチしたオブジェクトのインスペクタに対象のVisualEffect，参照元のForceField，次に紹介するComputeShaderを張ることで動作を行います．

```csharp:VFXForceFieldManager
using System.Linq;
using System.Runtime.InteropServices;
using UnityEngine;
using UnityEngine.Experimental.VFX;
using UnityEngine.Rendering;

public class VFXForceFieldManager : MonoBehaviour
{
    [SerializeField] private VisualEffect visualEffect;
    [SerializeField] private VFXForceField[] targets;
    [SerializeField] private ComputeShader shader;

    private int _createVectorFieldIndex;
    private int _clearIndex;
    private RenderTexture _texture;

    private bool _setUp = false;

    private void Awake()
    {
        _clearIndex = shader.FindKernel("Clear");
        _createVectorFieldIndex = shader.FindKernel("CreateVectorField");
    }

    private void Update()
    {
        if (!visualEffect.isActiveAndEnabled) return;

        if (!_setUp)
        {
            _texture = new RenderTexture(256, 256, 0, RenderTextureFormat.ARGBHalf)
            {
                enableRandomWrite = true,
                dimension = TextureDimension.Tex3D,
                volumeDepth = 256
            };
            _texture.Create();
            shader.SetTexture(_createVectorFieldIndex, "Destination", _texture);
            shader.SetTexture(_clearIndex, "Destination", _texture);
            
            visualEffect.SetTexture("Vector Field", _texture);
            
            _setUp = true;
        }

        if (targets.Length == 0)
        {
            shader.Dispatch(_clearIndex, Mathf.CeilToInt(_texture.width / 8f), Mathf.CeilToInt(_texture.height / 8f),
                Mathf.CeilToInt(_texture.volumeDepth / 8f));
            return;
        }
        
        var buffer = new ComputeBuffer(targets.Length, Marshal.SizeOf(typeof(ForceFieldData)));
        buffer.SetData(targets.Select(item => item.GetData()).ToArray());
        
        shader.SetBuffer(_createVectorFieldIndex, "Data", buffer);
        
        var position = this.transform.position;
        shader.SetFloats("Center", position.x, position.y, position.z);
        visualEffect.SetVector3("Center Position", position);
        var lossyScale = this.transform.lossyScale;
        shader.SetFloats("Size", lossyScale.x, lossyScale.y, lossyScale.z);
        visualEffect.SetVector3("Size", lossyScale);

        shader.Dispatch(_createVectorFieldIndex, Mathf.CeilToInt(_texture.width / 8f), Mathf.CeilToInt(_texture.height / 8f),
            Mathf.CeilToInt(_texture.volumeDepth / 8f));
        
        buffer.Release();
    }

    private void OnDrawGizmos()
    {
        var transform1 = this.transform;
        Gizmos.color = Color.cyan;
        Gizmos.DrawWireCube(transform1.position, transform1.lossyScale);
    }
}
```

##ベクトル場画像を生成するComputeShader
先ほどまでで得られたVectorFieldの情報がStructuredBufferとして得られるため，これを用いて3次元座標の各点でのベクトル場の大きさを計算します．
その結果を3次元テクスチャの画素として書き込むことでVFX Graph上で読み込めるようにします．

ベクトル場というのは小学校の時理科の実験でやったような，磁石の周りに砂鉄が向きをもって並ぶようにある場所でそこにある物体が受ける力のことです．
二次元での表現ですが，[Wikipedia](https://en.wikipedia.org/wiki/Vector_field)にある以下の画像が分かりやすいかもしれません．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/433211/be3a97fd-dd92-b226-dc84-5349d83f8525.png)

これを自分で作成していきます．

```computeShader:CreateVectorField.compute
#pragma kernel Clear
#pragma kernel CreateVectorField

struct ForceFieldData
{
    float3 Center;
    float StartRange;
    float EndRange;
    float3 Direction;
    float3x3 Strength;
    float GravityFocus;
    float Speed;
    float Attraction;
    float2 RotationRandomness;
};

StructuredBuffer<ForceFieldData> Data;
RWTexture3D<float3> Destination;

float3 Center;
float3 Size;

float3 mod2D289( float3 x ) { return x - floor( x * ( 1.0 / 289.0 ) ) * 289.0; }

float2 mod2D289( float2 x ) { return x - floor( x * ( 1.0 / 289.0 ) ) * 289.0; }

float3 permute( float3 x ) { return mod2D289( ( ( x * 34.0 ) + 1.0 ) * x ); }

float snoise( float2 v )
{
    const float4 C = float4( 0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439 );
    float2 i = floor( v + dot( v, C.yy ) );
    float2 x0 = v - i + dot( i, C.xx );
    float2 i1;
    i1 = ( x0.x > x0.y ) ? float2( 1.0, 0.0 ) : float2( 0.0, 1.0 );
    float4 x12 = x0.xyxy + C.xxzz;
    x12.xy -= i1;
    i = mod2D289( i );
    float3 p = permute( permute( i.y + float3( 0.0, i1.y, 1.0 ) ) + i.x + float3( 0.0, i1.x, 1.0 ) );
    float3 m = max( 0.5 - float3( dot( x0, x0 ), dot( x12.xy, x12.xy ), dot( x12.zw, x12.zw ) ), 0.0 );
    m = m * m;
    m = m * m;
    float3 x = 2.0 * frac( p * C.www ) - 1.0;
    float3 h = abs( x ) - 0.5;
    float3 ox = floor( x + 0.5 );
    float3 a0 = x - ox;
    m *= 1.79284291400159 - 0.85373472095314 * ( a0 * a0 + h * h );
    float3 g;
    g.x = a0.x * x0.x + h.x * x0.y;
    g.yz = a0.yz * x12.xz + h.yz * x12.yw;
    return 130.0 * dot( m, g );
}

[numthreads(8,8,8)]
void Clear (uint3 id : SV_DispatchThreadID)
{
    Destination[id.xyz] = float3(0, 0, 0);
}

[numthreads(8,8,8)]
void CreateVectorField (uint3 id : SV_DispatchThreadID)
{
    float3 result = float3(0, 0, 0);
    
    float3 Dim;
    Destination.GetDimensions(Dim.x, Dim.y, Dim.z);
    float3 position = (id / Dim - 0.5) * Size + Center;
    
    uint length, stride;
    Data.GetDimensions(length, stride);
    for(int i = 0; i < length; ++i)
    {
        ForceFieldData target = Data[i];
        
        // Check if it's in the range
        float dist = distance(target.Center, position);
        if(dist == 0 || target.StartRange == target.EndRange || dist < target.StartRange || dist > target.EndRange)
            continue;
        
        // Add constant force
        result += target.Direction;
        
        float3 normal = normalize(target.Center - position);
        
        // Add Gravity
        float normDistance = (dist - target.StartRange) / (target.EndRange - target.StartRange) * 8;
        float targetFocus = target.Strength[int(normDistance / 3)][int(normDistance % 3)];
        
        result += normal * targetFocus * (target.GravityFocus - 0.5) * 2;
        
        // Add Rotation        
        // Propel(tangent) direction
        float3 tangent;
        if(dot(float3(0, 1, 0), -normal) == 1)
            tangent = float3(1, 0, 0);
        else
            tangent = mul(-normal, float3(0, 1, 0));
        
        // Blend Vortex attrection and Randomness
        float3 rotationPower = lerp(tangent, -normal, target.Attraction);
        rotationPower = float3(lerp(tangent.x, snoise(position.xy), target.RotationRandomness.x),
                               lerp(tangent.y, snoise(position.yz), target.RotationRandomness.y),
                               tangent.z);
        rotationPower *= target.Speed;
        
        result += rotationPower;
    }
    
    Destination[id.xyz] = result;
}

```

ノイズ関数が大きく少し見づらいですが，一つ以上のForceFieldが格納されているとき，CreateVectorFieldメソッドのforループでベクトルの足し合わせを行います．

##出力を表示するVisualEffect
最後に，先ほど生成したベクトル場画像を受けてパーティクルに力を与えるVFX Graphを作成します．
InitializeノードとOutputノードは任意のものでよく，Updateノードに簡単に追記するだけなので既存のVisual Effectに簡単に追記できます．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/433211/d3f10385-2f61-589e-eb71-d51ef4c9631d.png)
`Size`は力を加える範囲，`Center Position`は中心位置，`Vector Field`は先ほど作成したベクトル場画像です．これらは上の`VFXForceFieldManager`で値を入力しています．

各パーティクルの現在座標を取得し，それを3次元画像のuvw座標に変換することでパーティクルがある位置の力の方向と大きさを得ています．

#動作確認
ここまでの実装で作成したVFX GraphによるForce FieldをParticle Systemのものと比較しました．
同じパラメータに設定していても若干挙動が違うため完全に再現できていませんが，これはパラメータ調整によって何とかなるかと思います．
![ezgif-5-cbdde9d1feef.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/433211/fce7081a-6bf7-1795-7d07-56e73a324ede.gif)
左が今回作成したもので，右がParticle Systemによるものです．

この二つの最大の違いは処理負荷です．
実行している際，VFX Graphの方は全く処理負荷を感じず，FPSにも変化がありませんでした．
しかし，Particle Systemの方はFPSが60ほど低下し，30FPSをキープできませんでした．

同じビジュアルを得られるなら軽量な方がいいと思いますね．

#おわりに
今回はVFX GraphのVector Fieldの機能を利用することでParticle SystemのForce Field（みたいなもの）を実装してみました．
VFX Graphを利用することでParticle Systemより圧倒的に多くのパーティクルを使った表現が可能になります．

実装する上での最大の困難は他にCompute ShaderでVector Field画像を作成し用としている人が見つからず仕様から色々と探った部分でした．

今後は立方体のForce Fieldなど，Particle Systemの本来の機能をより多く実装していきたいと思います．
まあそのうちUnity本体に実装されそうな機能ではありますが.....

この記事書いてるときに見つけましたが，[Property Binders](https://docs.unity3d.com/Packages/com.unity.visualeffectgraph@7.1/manual/PropertyBinders.html)っていうVFX GraphにUnityのイベントを伝えられるものがあるらしいですね，多分これを利用して本記事の実装を代替するのは難しいと思いますが，組み合わせるとなかなか面白そうなので今度やってみます．