ハツェの真時代傾向璋

興味を持ったことを書いていく鱗片的な場所から先の未来の場

VFXGraphとShaderGraphを連携させて綺麗なパーティクルを作ろう

こんばんは。ハツェです。
今回はョョョねこ Advent Calendar 2021の記事として参加しています。
当記事では、VFXGraphの簡単な作例例をご紹介します。よろしくお願いします。
動作環境はUnity(URP) : 2021.2.6f1、VisualEffectGraph : 12.1.2です。

目次


概要

今回は以下のようなパーティクルを作成します。
チュートリアル感覚で記事を書いていくので、よければ見ていってください。

f:id:hatuxes:20211217134456g:plain

その⓪: VisualEffectGraphをインポートする

Window > Package ManagerからPackages > VisualEffectGraphを選択し、右下のInstallを押します。

f:id:hatuxes:20211217143103p:plain

その①: Shaderを作る

最初に、Cubeに使うShaderを作成します。
ShaderGraphの詳細な説明は、以下のマニュアルを見てください。
docs.unity3d.com

ShaderGraphを作成する

Projectタブの任意のフォルダ内にてCreate > ShaderGraph > URP > Unlit Shader Graphを選択し、.shadergraphを作成します。名前は任意で大丈夫です。

f:id:hatuxes:20211217145048p:plain

作成したらダブルクリックしてノードエディタを開きます。

ShaderGraphの下準備をする

Shaderをいじる前に、やっておくことがあるため、先にそれを行います。
ノードエディタ右上にあるGraph Inspectorをクリックして、以下の画像の赤線の箇所を変更してください。特にSupportVFXGraphにチェックを入れておかないと、VFXとShaderGraphの連携が出来なくなります。

f:id:hatuxes:20211217180207p:plain

もう一つ、Graph Inspectorの左にあるBlackboardボタンを押し、以下の三つのプロパティを作成しておきましょう。それぞれ、Color、Float、Floatです。

f:id:hatuxes:20211223114624p:plain

Shaderを作る

さて、Shaderを作っていきます。最初はUVを縁取りするようなマスクを作ります。以下の感じです。

f:id:hatuxes:20211217152900p:plain

UVを分解する

Splitノードを用いて、UVの次元を分解します。ノードエディタの何もないところでスペースキーを押すことで検索画面が出てくるので、そこでノード名のSplitと検索することでSplitノードを出すことができます。同じ要領でUVノードも出した後、UVからSplitへとノードを繋げてみてください。

f:id:hatuxes:20211217150758p:plain

U軸方向のマスクを作る

詳細なノードの説明は省きます。以下の通りにノードを繋げてください。

f:id:hatuxes:20211223114824p:plain

V軸方向のマスクを作る

似たような感じでV軸方向のマスクも作ります。以下の通りにノードを繋げてください。

f:id:hatuxes:20211223114932p:plain

それぞれのマスクを足してマスクを完成させる

U軸側、V軸側それぞれのマスクを足して、輪郭が出るようにします。以下の通りにノードを繋げてください。
ちなみに、saturationはマスクの角が過加算されているのを修正するために使っています。

f:id:hatuxes:20211217154046p:plain

ここまで綺麗に出来ていれば、Widthを0~1の範囲で動かすと以下の通りになるはずです。

f:id:hatuxes:20211217152654g:plain

マスクを基に色をつける

最後に、作ったマスクと色を掛け合わせて完成です。以下の通りです。

f:id:hatuxes:20211223111821p:plain

これで、必要となるShaderを作成することが出来ました。最後に、左上のSave Assetを押して保存しておいてください。

その②: VFXを作って、ShaderGraphと連携する

ここからは、実際にVFXGraphでパーティクルを作りながらShaderGraphと連携していく方法を紹介します。
詳細な使い方は、以下のマニュアルを見てください。
docs.unity3d.com

VisualEffectオブジェクトを作成する

まず、HierarchyでCreate > Visual Effect > VisualEffectからVisualEffectを作成します。

f:id:hatuxes:20211217143453p:plain

次に、Newボタンから.vfxファイルを作成します。名前は任意で良いです。

f:id:hatuxes:20211217143826p:plain

すると、GameObjectの位置にこのようなパーティクルが出ると思います。

f:id:hatuxes:20211217144121g:plain

変数を作成する

ShaderGraphと同様、最初に変数を作成します。以下の五つを用意してください。

f:id:hatuxes:20211223112326p:plain

Spawnノードをバースト化させる

Spawnノードは文字通りパーティクルのスポーン設定をするシステムです。
初期ノードにおけるSpawnノードは、1秒間に16回パーティクルがスポーンする設定のアトリビュートがアタッチされています。

f:id:hatuxes:20211217160510p:plain

ですが、今回はこれではなく一定間隔ごとに一気にパーティクルが出るものを用いるため、このアトリビュートは使用しません。なので、最初にConstant Spawn Rateを選択し、キーボードのDeleteボタンを押して削除します。
その後、Press space to add blocksと書いてある部分にカーソルを持ってきた後、Spaceキーを押してPeriodic Burstを生成し、以下の通りに変数を繋げます。

f:id:hatuxes:20211217160807p:plain

Initialize Particleノードの設定

次にInitialize Particleノードのアトリビュートを変更します。
Initialize Particleノードはパーティクルが生成される瞬間に速度など、様々な初期設定をするシステムです。
先程の要領で、以下の画像の通りに設定してみてください。今回はアトリビュート内の数値まで揃える必要があります。
また、一部検索しても出てこないものがあります。その場合は、似たようなものを一度作った後、そのアトリビュートを選択した状態でInspectorを見ると詳細設定が出るので、そこで調整してあげることで画像と同じ状態にすることが出来ると思います。

f:id:hatuxes:20211217161853p:plain

初速をランダムにする

VisualEffectGraphでは、プロパティに計算式を入力することも可能となっているので、それについても紹介出来ればと思います。
Initialize Particleノードの外にカーソルを持ってきた後、Spaceキーを押してMultiplyと検索するとMultiplyノードが出てきます。これを用いて変数の値をBに、変数に-1を掛けた値をAにそれぞれSet Velocity Randomに繋げます。

f:id:hatuxes:20211217162745p:plain

Updateノード

Updateノードはパーティクルが生きている間、ずっと影響し続けるシステムです。例えば、Add VelocityをUpdateノードに入れると、生きている間はずっとパーティクルに力を与え続けます。
このノードでは、抗力係数(空気抵抗みたいなやつ)とかを設定することも出来ますが、今回は特に何も入れていません。

f:id:hatuxes:20211217163702p:plain

出力するパーティクルの形状決める

最後はOutputノードです。Outputノードはパーティクルの色や形状を決めるシステムです。デフォルトではOutput Particle Quadが繋がっています。

f:id:hatuxes:20211217165541p:plain

ですが、今回はQuadじゃなくてCubeを出したいのでOutput Particle Quadは消して、Output Particle Meshを作成してUpdateノードから繋げてください。

f:id:hatuxes:20211217170000p:plain

その後、Meshの箇所をCubeにします。

パーティクルの色合いを決める

Output Particle MeshをUpdateから繋げたら、まずShaderGraphのところに最初作ったShaderを入れてください。

f:id:hatuxes:20211223113419p:plain

その後、Set Size over LifetimeとSet Color over Lifetimeを追加してSizeのカーブをいじります。このカーブはパーティクルの大きさを決めるものなので好きにいじってもらって構わないですが、本記事では以下のようにしています。

f:id:hatuxes:20211217173951p:plain

ShaderGraphのプロパティに計算式を当てはめる

最後に、ShaderGraphのプロパティにVFXの変数や計算式を当てはめます。
AlphaとWidthはVFXの変数をそのまま繋げます。
EmissionColorには、HSVで決めた色を渡してあげます。今回は色相をランダム値で決め、それ以外は固定値で決めたHSVカラーをEmissionColorとします。

f:id:hatuxes:20211223113733p:plain

これでVFXGraph側も完成なので、Saveボタンで保存しておいてください。

Inspectorでプロパティの設定をする

VFXGraphによるシステム作りはこれで終わったので、最後にプロパティでパーティクルの数や枠の太さなどを調整します。ここではShaderGraphのプロパティ値も調整できるので、好きな見た目になるように値を決めてみてください。

f:id:hatuxes:20211217182236p:plain

完成

完成です。お好みでポストプロセスを入れてみてください。

f:id:hatuxes:20211217183015g:plain

終わりに

今回はシンプルなVFXとShaderGraphの連携方法について紹介しました。
ShaderGraphを記事で説明するのは難しいですね。どうしても写真だらけになっちゃいますよね。けど、ShaderGraphは途中結果も見れるので、デバッグや試行錯誤がしやすいのが特徴ですね。良いシェーダーを作って良いパーティクルを作ってみてください。

本記事のサンプルはこちらのGithubからclone出来ます。ライセンスを一読した上でご使用ください。
github.com 以上です。ありがとうございました。
明日はqueさんの記事になります!お楽しみにー!

UdonのLate-Joiner対応3選

こんばんは。ハツェです。お久しぶりです。
Udonに関する様々な記事や作成例が公開されてきたおかげか、Udonで面白いものを作る人が増えましたね。嬉しい話です。
さて今回は、VRChat Advent Calendar 2021の記事として参加しており、主に同期周りの話をしようと思います。
動作環境は、Unity : 2019.4.31f1、VRCSDK : 2021.11.24.16.19、U# : v0.20.3です。

目次


今回の概要

今回はワールドのインスタンスに後からJoinしてきた人(以降Late-Joinerと呼ぶ)に対して、それまでの処理や変数を同期させる方法について語ります。
私自身も作っていてどう作るんだっけ?って悩むことが何回かあったので、情報をまとめる感じで伝えられたらと思います。
それではやっていきましょう!

前置き

今回は一応、bool, int, animationの三つを同期させる方法を各種紹介します。

f:id:hatuxes:20211214144748p:plain

また、変更させるAnimatorは以下の通りになっているものとします。

f:id:hatuxes:20211214171804p:plain

その① : 素直に同期変数を用いる

最初は王道の方法を紹介します。というか特段の事情が無ければ、同期変数を用いてあげる方が早いですし、安定しますし、公式も推奨しているぐらいなので、この方法がいいと思います。
Late-Joiner対応は、OnValueChangedの仕組みで勝手にやってくれます。

スクリプト

using UdonSharp;
using UnityEngine;
using UnityEngine.UI;
using VRC.SDKBase;

[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class UseSyncVariable : UdonSharpBehaviour
{
    private const string DEFAULT_STATE_NAME = "Init";
    private const string ANIMATION_STATE_NAME = "PingPong";

    [SerializeField] private Text _boolLabel;
    [SerializeField] private Text _intLabel;
    [SerializeField] private Animator _animator;

    [UdonSynced, FieldChangeCallback(nameof(BoolData))] 
    private bool _boolData;
    [UdonSynced, FieldChangeCallback(nameof(IntData))] 
    private int _intData;
    [UdonSynced, FieldChangeCallback(nameof(AnimData))]
    private bool _animData;

    public bool BoolData
    {
        get => _boolData;
        set
        {
            _boolData = value;

            // 実際の処理
            _boolLabel.text = $"bool: {_boolData}";
        }
    }

    public int IntData
    {
        get => _intData;
        set
        {
            _intData = value;

            // 実際の処理
            _intLabel.text = $"int: {_intData}";
        }
    }

    public bool AnimData
    {
        get => _animData;
        set
        {
            _animData = value;

            // 実際の処理
            _animator.Play(_animData ? ANIMATION_STATE_NAME : DEFAULT_STATE_NAME);
        }
    }


    public void ToggleBool()
    {
        // 同期変数変更するためのオーナー譲渡
        SetOwner();

        // 値変更
        BoolData = !BoolData;
        RequestSerialization();
    }

    public void AddInt()
    {
        // 同期変数変更するためのオーナー譲渡
        SetOwner();

        // 加算処理
        IntData++;
        RequestSerialization();
    }

    public void ToggleAnimation()
    {
        // 同期変数変更するためのオーナー譲渡
        SetOwner();

        // 値変更
        AnimData = !AnimData;
        RequestSerialization();
    }


    private void SetOwner()
    {
        Networking.SetOwner(Networking.LocalPlayer, this.gameObject);
    }
}

メリット

  • SDKベースに作れるので楽
  • 同期ミスがほとんどない
  • setterに処理内容を書いておけば良いので、値を変更するだけで動いてくれる
  • Late-Joinerのためのコードが必要ない

デメリット

  • 同期変数の数に限りがある

その② : 一度Ownerに問い合わせて同期させる

Ownerの値に応じて、それに適した処理をSendCustomNetworkEventで全員に送る方法です。引数を持てない現状では、boolのような二値データの処理に使うぐらいが限界です。intのような想定出来ない値になるもの対しては数値範囲を制限して使う場合に限り、この方法が使えると思います。
Late-Joiner対応は、OnPlayerJoined関数内で行っています。

スクリプト

using UdonSharp;
using UnityEngine;
using UnityEngine.UI;
using VRC.SDKBase;

[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class ViaOwnerRequest : UdonSharpBehaviour
{
    private const string DEFAULT_STATE_NAME = "Init";
    private const string ANIMATION_STATE_NAME = "PingPong";

    [SerializeField] private Text _boolLabel;
    // [SerializeField] private Text _intLabel;
    [SerializeField] private Animator _animator;

    private bool _boolData;
    // private int _intData;
    private bool _animData;

    public override void OnPlayerJoined(VRCPlayerApi player)
    {
        // Late-Joiner対応箇所
        if (Networking.LocalPlayer.IsOwner(this.gameObject))
        {
            // Ownerがtrueの状態になっていたら、一度全員にtrue処理を渡す
            if (_boolData)
            {
                SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(BoolTrueProcess));
            }
            if (_animData)
            {
                SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(AnimationTrueProcess));
            }
        }
    }


    public void ToggleBool()
    {
        // _boolData = !_boolDataと同じ処理内容
        SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, _boolData ? nameof(BoolFalseProcess) : nameof(BoolTrueProcess));
    }

    public void BoolTrueProcess()
    {
        // _boolDataがfalseの人にだけ処理させる(Late-Joiner対応にも使うため)
        if (!_boolData)
        {
            _boolData = true;
            // 実際の処理
            UpdateBoolLabel();
        }
    }

    public void BoolFalseProcess()
    {
        _boolData = false;
        // 実際の処理
        UpdateBoolLabel();
    }


    public void AddInt()
    {
        // SendCustomNetworkEventに引数が使えたら、以下のようなコードで処理が出来そう
        // _intData++;
        // SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, ReceivedIntProcess(_intData));
    }

    public void ToggleAnimation()
    {
        // _animData = !_animDataaと同じ処理内容
        SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, _animData ? nameof(AnimationFalseProcess) : nameof(AnimationTrueProcess));
    }

    public void AnimationTrueProcess()
    {
        // _animDataがfalseの人にだけ処理させる(Late-Joiner対応にも使うため)
        if (!_animData)
        {
            _animData = true;
            // 実際の処理
            UpdateAnimation();
        }
    }

    public void AnimationFalseProcess()
    {
        _animData = false;
        // 実際の処理
        UpdateAnimation();
    }


    private void UpdateBoolLabel()
    {
        _boolLabel.text = $"bool: {_boolData}";
    }

    private void UpdateAnimation()
    {
        _animator.Play(_animData ? ANIMATION_STATE_NAME : DEFAULT_STATE_NAME);
    }
}

メリット

  • 同期変数を用いない
  • 連打されても比較的崩れない

デメリット

  • 関数の数が増えるので、コードが煩雑になりやすい
  • 値一個ずつに対して関数を作成するので、intのようなほぼ無限に関数が必要になるような場合には使えない

その③ : ObjectSyncを活用して同期させる

最後は同期はObjectSyncに任せて、そのtransformの値を使って疑似的に同期処理をしようという方法です。
Late-Joiner対応は、同じくOnPlayerJoined関数内で行っています。

f:id:hatuxes:20211214170547g:plain

スクリプト

using UdonSharp;
using UnityEngine;
using UnityEngine.UI;
using VRC.SDKBase;

[UdonBehaviourSyncMode(BehaviourSyncMode.Manual)]
public class UseTransformData : UdonSharpBehaviour
{
    private const string DEFAULT_STATE_NAME = "Init";
    private const string ANIMATION_STATE_NAME = "PingPong";

    [SerializeField] private Text _boolLabel;
    [SerializeField] private Text _intLabel;
    [SerializeField] private Animator _animator;
    [SerializeField] private Transform _transformForData;


    public override void OnPlayerJoined(VRCPlayerApi player)
    {
        // Late-Joiner対応箇所
        if (player.isLocal)
        {
            // Late-Joinerにだけ、ObjectSyncのTransform値を再取得させる
            UpdateBoolLabel();
            UpdateIntLabel();
            UpdateAnimation();
        }
    }


    public void ToggleBool()
    {
        SetOwner();

        // x座標が四捨五入で1ならtrue、0ならfalseという扱い
        // 処理内容はbool値を反転させているだけ
        var position = _transformForData.position;
        position.x = Mathf.RoundToInt(position.x) == 1 ? 0 : 1;
        _transformForData.position = position;

        // 全員にテキストの更新をさせる
        SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(UpdateBoolLabelBody));
    }

    public void UpdateBoolLabel()
    {
        // すぐ実行すると、Owner以外は同期に時間がかかるため、誤った結果にならないように遅延させている
        SendCustomEventDelayedSeconds(nameof(UpdateBoolLabelBody), 0.1f, VRC.Udon.Common.Enums.EventTiming.Update);
    }

    public void UpdateBoolLabelBody()
    {
        // 実際の処理
        _boolLabel.text = $"bool: {Mathf.RoundToInt(_transformForData.position.x) == 1}";
    }

    public void AddInt()
    {
        SetOwner();

        // y座標の値をそのままintの値とする
        // 処理内容はintをインクリメントしているだけ
        var position = _transformForData.position;
        position.y = Mathf.RoundToInt(position.y) + 1;
        _transformForData.position = position;

        // 全員にテキストの更新をさせる
        SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(UpdateIntLabel));
    }

    public void UpdateIntLabel()
    {
        // 実際の処理
        _intLabel.text = $"int: {Mathf.RoundToInt(_transformForData.position.y)}";
    }

    public void ToggleAnimation()
    {
        SetOwner();

        // z座標が四捨五入で1ならtrue、0ならfalseという扱い
        // 処理内容はbool値を反転させているだけ
        var position = _transformForData.position;
        position.z = Mathf.RoundToInt(position.z) == 1 ? 0 : 1;
        _transformForData.position = position;

        // 全員にテキストの更新をさせる
        SendCustomNetworkEvent(VRC.Udon.Common.Interfaces.NetworkEventTarget.All, nameof(UpdateAnimation));
    }

    public void UpdateAnimation()
    {
        // すぐ実行すると、Owner以外は同期に時間がかかるため、誤った結果にならないように遅延させている
        SendCustomEventDelayedSeconds(nameof(UpdateAnimationBody), 0.1f, VRC.Udon.Common.Enums.EventTiming.Update);
    }

    public void UpdateAnimationBody()
    {
        // 実際の処理
        _animator.Play(Mathf.RoundToInt(_transformForData.position.z) == 1 ? ANIMATION_STATE_NAME : DEFAULT_STATE_NAME);
    }


    private void SetOwner()
    {
        // UdonとObjectSync両方のOwnerになる必要がある
        var localPlayer = Networking.LocalPlayer;
        Networking.SetOwner(localPlayer, this.gameObject);
        Networking.SetOwner(localPlayer, _transformForData.gameObject);
    }
}

メリット

  • 同期変数を用いない

デメリット

  • データ用のTransformが原点から離れすぎると、浮動小数の精度が悪くなり、座標がブレブレになる
  • コードが煩雑
  • 処理を連続で行うと、値が人によってズレる

おわりに

今回はLate-Joinerにも使える同期方法を三つ紹介しました。
結論としてはやっぱり同期変数を使う方法が一番良いです。何しろ記述が楽ですし、安定してデータが届きます
他二つも紹介しましたが、特段の理由がない限りは使わない方が良いでしょう。私としてはオススメ出来ません。
今回のサンプルはGithubに置いてあります。
ライセンスを確認したうえで、お使いください。
github.com
以上です。
VRChat Advent Calendar 2021の明日の記事は、bd_さんの記事になりますー