e.blog

主にUnity/UE周りのことについてまとめていきます

Unity VFX Graphでposition mapを使って導蟲風パーティクルを作る

概要

今回はVFX Graphを使ってパーティクルをMeshにまとわりつかせてモンハンワールドの導蟲風パーティクルを実装してみました。

実際に実行するとこんな感じになります↓

モンハンワールドの導蟲のようにモデルにまとわりついていく様が見れるかと思います。

今回はこれを実装した内容をまとめていきます。

今回のサンプルはGitHubにアップしてあるので、動くものを確認したい人はそちらをご覧ください。

github.com

Meshの頂点をテクスチャにベイクする

パーティクルをMeshにまとわりつかせるためにMeshの頂点をテクスチャにベイクし、それをランタイムで切り替えるという方法で実装しました。

VFX GraphにはPoint Cacheを作るツールもあるのですが、それだとランタイムでVFX Graphに送ることができないのでこうしました。

Meshの頂点のベイクには以下の記事を参考にさせていただきました。

note.com

上記記事を参考に処理を追っていきましょう。
まずPoint CacheのアセットがインポートされるとPointCacheImpoterがそれをフックしアセットの内容をもとに適切にテクスチャを生成します。

(インポーターのファイルの場所は以下)

【ソースの場所】
Packages/Visual Effect Graph/Editor/Utilities/pCache/Impoter/PointCacheImpoter.cs

つまり、Point Cacheは最終的にテクスチャとして保持されている、ということです。

テクスチャの内容

適切にテクスチャを生成すると書きましたが、どういうデータがテクスチャに格納されるのでしょうか。
上記のインポーターのコードを追うことでそれを確認することができます。
が、今回は前述の記事を参考にさせていただきました。

実はテクスチャに保存されているのは、Meshの頂点位置そのものです。つまり(x, y, z)ですね。
なのでその値をそのままテクスチャに保存しておけばいいということになります。(Colorも(r, g, b, a)の4要素を扱えるので、(x, y, z)の3要素は問題なく使える)

ただテクスチャのフォーマットは重要です。通常、色にはマイナス値はありません。そのため、テクスチャのフォーマットによってはマイナス値を保存できないものもあります。

なので以下のようにフォーマットを指定してテクスチャを生成する必要があります。

Texture2D tex = new Texture2D(width, height, TextureFormat.RGBAFloat, false);

Texture2Dに保存したPosition Mapをサンプリングする

Meshの頂点をテクスチャにベイクしたら次はそれを読み出します。
通常、Set **** from Mapブロックを利用する場合はパーティクルIDからよしなにサンプル位置を計算して頂点位置を取得してくれますが、これだと頂点位置がローカル座標として扱われてしまうため今回の動画のように、各モデルにまとわりつかせるということができません。

なので今回は自前で作成したグラフを用いてテクスチャをサンプリングし、それをTarget Positionに設定します。
ですが、テクスチャからどうやってサンプリングしたらいいのでしょうか?

実は幸いなことに、VFX Graphで生成されるコード断片を見ることができるのでそれを参考にノードを組みました。

ただコード断片を見るには設定でDebugを有効化しないとなりません。

PreferencesのShow Additional Debug infoのチェックをオンにすることで確認できるようになります。

f:id:edo_m18:20210105105726p:plain

すると以下のようにインスペクタにコード断片が表示されるようになります。

f:id:edo_m18:20210105105637p:plain

文字としてコードも抜粋しておきます。

uint width, height;
attributeMap.t.GetDimensions(width, height);
uint count = width * height;
uint id = particleId % count;
uint y = id / width;
uint x = id - y * width;
float3 value = (float3)attributeMap.t.Load(int3(x, y, 0));
value = (value  + valueBias) * valueScale;
targetPosition = value;

これを実現するSubgraph Operatorを作ってみました。

f:id:edo_m18:20210105105854p:plain

このSubgraphを実際に使うとこんな感じになります。

f:id:edo_m18:20210106005646p:plain

SampleMapと書かれたオペレータが作ったSubgraphです。
これで汎用的にテクスチャから位置を取ることができるようになりました。

ターゲット位置に徐々に近づける

VFX GraphにはSet Target PositionというBlockがあります。
最初、これはどうやって使うんだろうと思っていたんですが生成されたコードを見て理解しました。

これはなんのことはない、パーティクルのイチ要素として値を設定しているだけでした。
つまりこの値をどう使うかは実装者次第、というわけなんですね。

ということで、パーティクルをターゲット位置に近づける処理は、毎フレームごとに徐々にターゲット位置に近づくように位置を更新する、というグラフを組みます。

実際に作成したグラフは以下です。

f:id:edo_m18:20210108002550p:plain

Get Attribute: targetPosition (Current)から設定されたターゲットポジションを取得します。
そしてパーティクルの1フレーム前の位置との差分を取り、ターゲット位置方向へのベクトルを得ます。

今回は導蟲風にするためにTurbulenceで虫のようなゆらぎを加えつつ、徐々にターゲットに近づけていく、というふうにしました。

また近づいた際も、ピッタリとターゲット位置に張り付くのではなく若干ゆらぎを残すようにしています。
これのおかげで虫が対象にまとわりついているような見た目になりました。

ランタイムにデータをVFX Graphに送る

これでVFX Graphの準備ができました。あとは設定したパラメータに対してC#側から値を送ってやれば完成です。

まずはMeshの頂点位置をベイクするスクリプトから見てみましょう。

using UnityEngine;
using UnityEngine.VFX;

[RequireComponent(typeof(VisualEffect))]
public class BakeMeshToTexture : MonoBehaviour
{
    [SerializeField] private GameObject _target = null;

    public Texture2D BakedTexture { get; private set; }
    public Transform Transform => _target.transform;

    private SkinnedMeshRenderer _skinnedMeshRenderer = null;
    private Mesh _mesh = null;
    private bool _isSkinnedMesh = false;
    private Color[] _colorBuffer = null;

    public void Initialize()
    {
        if (_target.TryGetComponent(out MeshFilter filter))
        {
            _mesh = filter.mesh;
        }

        if (_target.TryGetComponent(out _skinnedMeshRenderer))
        {
            _isSkinnedMesh = true;
            _mesh = new Mesh();
            BakeMesh();
        }

        Vector3[] vertices = _mesh.vertices;
        int count = vertices.Length;

        float r = Mathf.Sqrt(count);
        int size = (int)Mathf.Ceil(r);

        _colorBuffer = new Color[size * size];

        BakedTexture = new Texture2D(size, size, TextureFormat.RGBAFloat, false);
        BakedTexture.filterMode = FilterMode.Point;
        BakedTexture.wrapMode = TextureWrapMode.Clamp;

        UpdatePositionMap();
    }

    public void UploadMeshTexture()
    {
        if (_isSkinnedMesh)
        {
            BakeMesh();
            UpdatePositionMap();
        }
    }

    private void BakeMesh()
    {
        _skinnedMeshRenderer.BakeMesh(_mesh);
    }

    private void UpdatePositionMap()
    {
        int idx = 0;
        foreach (Vector3 vert in _mesh.vertices)
        {
            _colorBuffer[idx] = VectorToColor(vert);
            idx++;
        }

        BakedTexture.SetPixels(_colorBuffer);
        BakedTexture.Apply();
    }

    private Color VectorToColor(Vector3 v)
    {
        return new Color(v.x, v.y, v.z);
    }
}

MeshFilterの場合とSkinnedMeshRendererの場合で処理を分けていますが、基本的にはMeshを取り出してその頂点をテクスチャのピクセルに保存しているだけです。

位置とカラーの変換も以下のようにそのまま値を変換しているだけですね。

private Color VectorToColor(Vector3 v)
{
    return new Color(v.x, v.y, v.z);
}

これで頂点位置をベイクすることができました。あとはこれをVFX Graphに送ってやれば冒頭の動画のエフェクトの完成です。
送っている側は非常にシンプルなので、切り替えている部分だけ抜粋します。

private void Change()
{
    _index = (_index + 1) % _bakers.Length;

    _vfx.SetTexture("PositionMap", CurrentBaker.BakedTexture);
    _vfx.SetInt("Size", CurrentBaker.BakedTexture.width);
    _vfx.SetMatrix4x4("VolumeTransform", CurrentBaker.Transform.localToWorldMatrix);
}

_vfx変数はVisualEffectコンポーネントです。これに各種値を送るメソッドが定義されているのでそれを利用して値を送っているだけです。

まとめ

VFX Graph、慣れてくるとわりと簡単に思った通りの絵が作れるので楽しいですね。
今回のエフェクトはKeijiroさんのUniteのときの動画を見ていたときに思いつきました。

特に、Custom Attributeを使って値を操作するあたりを見てTarget Positionをどう使うのかが分かりました。
グラフという形に惑わされて「勝手にこういうことをやってくれるものだろう」という思い込みがあったのです。

しかしそこがそもそも間違いだということに気づけたのはとても大きな収穫でした。
Compute Shaderを使ってパーティクルを操作したことがある人は、各Blockが{}で囲まれたイチコードに変換されているに過ぎない、ということが分かれば簡単にエフェクトを作ることができるようになると思います。