u/Fun-Significance-958

How I stopped hundreds of VFX Graph effects from killing my game’s performance
▲ 30 r/Unity3D

How I stopped hundreds of VFX Graph effects from killing my game’s performance

How I batch a bunch of VFX Graph effects in Unity using a texture

I've been working on my game in Unity for over 3 years now. For this project I decided to use the Visual Effect Graph system for most of the VFX, because I thought I would need it for the many effects I wanted to implement. I also wanted to learn how it worked, because I was a total VFX graph noob.

One of the first problems I ran into was performance, which was kind of funny because one of the reasons I wanted to use VFX Graph was better performance.

A little bit of context

I am making a tower defense game where a lot of enemies can be alive at the same time, usually somewhere around 200-500. I also have a bunch of effects like burn, poison, bleeding, impact effects, explosions, towers shooting, lightning strikes, smoke, meteors, and more explosions.

My first implementation quite bad.

For continuous effects (like damage over time), I had Visual Effect gameobjects on enemies for each effect. So an enemy could have a burn VFX, poison VFX, bleeding VFX, impact VFX, and some more. This is obviously not very efficient, especially when a lot of enemies are alive at the same time.

For one-time VFX, like explosions, I exposed a Vector3 in the VFX Graph so I could move the effect to the correct position before playing it. But this had another problem: what if two explosions happen in the exact same frame?

With my first implementation, one position would just overwrite the other one, so only one explosion would actually get visualized.

The solution

The solution I found after browsing the web and Unity forums was to use a texture.

Using the module and particleId we can get the correct location on the texture if we know the Amount

The basic idea is that I queue all the VFX calls that happen during the frame, put their data into a texture, upload that texture once, and then play the VFX Graph once with an Amount value.

We spawn the particles in with single burst always

So instead of doing something like:

Play explosion at position A
Play explosion at position B
Play explosion at position C

I store all of those positions in a texture and tell the VFX Graph:

Spawn 3 effects this frame

Then inside the VFX Graph, each spawned particle uses particleId to read the correct position from the texture.

So particle 0 reads entry 0, particle 1 reads entry 1, particle 2 reads entry 2, etc.

One-time VFX with only 4 floats of information

For a simple explosion, I only need 4 floats:

x position
y position
z position
duration

So I can store this in one RGBAFloat texture pixel.

using UnityEngine;
using UnityEngine.VFX;

public class VFXExplosion : MonoBehaviour
{
    [SerializeField] private VisualEffect visual;

    private const int MaxEntries = 2048;
    private const float DisableAfterSeconds = 10f;

    private Texture2D positionTexture;
    private Color[] positions;

    private int entryCount;
    private float lastPlayTime;
    private bool isActive;

    private void Start()
    {
        positionTexture = new Texture2D(MaxEntries, 1, TextureFormat.RGBAFloat, false);
        positions = new Color[MaxEntries];
        visual.SetTexture("PosTexture", positionTexture);
    }

    private void Update()
    {
        if (entryCount > 0)
        {
            positionTexture.SetPixelData(positions, 0);
            positionTexture.Apply(false);

            visual.SetInt("Amount", entryCount);
            visual.Play();

            entryCount = 0;
        }

        if (isActive && Time.time > lastPlayTime + DisableAfterSeconds)
        {
            visual.gameObject.SetActive(false);
            isActive = false;
        }
    }

    public void PlayEffect(Vector3 enemyPosition, float bombDuration)
    {
        visual.gameObject.SetActive(true);

        isActive = true;
        lastPlayTime = Time.time;

        if (entryCount >= MaxEntries)
            return;

        positions[entryCount] = new Color(
            enemyPosition.x,
            enemyPosition.y,
            enemyPosition.z,
            bombDuration
        );

        entryCount++;
    }
}

The main idea is that PlayEffect only records the effect data. It does not immediately play the VFX Graph.

Then in Update, if at least one entry was recorded this frame, the data gets uploaded to the texture, the graph gets the amount, and then the graph is played once.

In my game I have a bunch of these VFX systems, and I found that disabling the GameObject with the Visual Effect component when it is not used helped performance quite a bit. In my case, around 70-90% of the VFX systems are usually inactive at any given time.

What if you need more than 4 floats?

If you need more information than 4 floats, you can increase the texture on the Y axis.

For example, this texture has 2 rows:

positionTexture = new Texture2D(MaxEntries, 2, TextureFormat.RGBAFloat, false);
positions = new Color[MaxEntries * 2];

Then row 0 can store one set of data, and row 1 can store another set of data.

Second texture row added for 4 more floats

using UnityEngine;
using UnityEngine.VFX;

public class VFXMeteor : MonoBehaviour
{
    [SerializeField] private VisualEffect visual;

    private const int MaxEntries = 2048;
    private const float DisableAfterSeconds = 10f;

    private Texture2D positionTexture;
    private Color[] positions;

    private int entryCount;
    private float lastPlayTime;
    private bool isActive;

    private void Start()
    {
        // 2 rows, so we can store 8 floats per effect instead of 4
        positionTexture = new Texture2D(MaxEntries, 2, TextureFormat.RGBAFloat, false);
        positions = new Color[MaxEntries * 2];
        visual.SetTexture("PosTexture", positionTexture);
    }

    private void Update()
    {
        if (entryCount > 0)
        {
            positionTexture.SetPixelData(positions, 0);
            positionTexture.Apply(false);

            visual.SetInt("Amount", entryCount);
            visual.Play();

            entryCount = 0;
        }

        if (isActive && Time.time > lastPlayTime + DisableAfterSeconds)
        {
            visual.gameObject.SetActive(false);
            isActive = false;
        }
    }

    public void PlayEffect(
        Vector3 enemyPosition,
        float meteorDuration = 1.5f,
        float meteorSize = 0.6f,
        float meteorSmokeMin = 0.45f,
        float meteorSmokeMax = 0.6f,
        float meteorImpactSize = 0.25f)
    {
        visual.gameObject.SetActive(true);

        isActive = true;
        lastPlayTime = Time.time;

        if (entryCount >= MaxEntries)
            return;

        // Row 0
        positions[entryCount] = new Color(
            meteorSize,
            meteorSmokeMin,
            meteorSmokeMax,
            meteorImpactSize
        );

        // Row 1
        positions[entryCount + MaxEntries] = new Color(
            enemyPosition.x,
            enemyPosition.y,
            enemyPosition.z,
            meteorDuration
        );

        entryCount++;
    }
}

Then in the VFX Graph you just sample row 0 for the first 4 values, and row 1 for the next 4 values.

For example:

row 0 = size, smoke min, smoke max, impact size
row 1 = position x, position y, position z, duration

information from the second row we set y to 1

How I use it in my project

To make this all work, I have one GameObject with all the VFX scripts on it. Its children are GameObjects with the actual Visual Effect components.

Then I have a VFXCaller component that has references to all these VFX scripts. This lets me call effects from anywhere like this:

VFXCaller.MeteorVFX.PlayEffect(position);

This might not be the cleanest or most perfect setup, but it has worked pretty well for my game. Now I can just have 1 vfx component per different visual effect.

This all allowed me to make effects like these (this is part of the trailer I am making, but it displays some of the effects nicely)

https://reddit.com/link/1tc6r4l/video/b80ziqx7yx0h1/player

Bonus

This approach also allowed me to create a damage number vfx graph. I had some problems with my damage numbers being slow (used text mesh pro and it just tanked performance with over 500 texts alive at a time). The vfx approach allows be to really spam as many damage numbers as I'd like with almost no slowdown

Here is an (extreme) example of the damage numbers spawning with the VFX (bitrate probably quite bad)

https://reddit.com/link/1tc6r4l/video/7oymsvg9yx0h1/player

I am currently working on the 1.0 update of my game which is almost done. You can take a look here https://store.steampowered.com/app/2322090/Crystal_Guardians_TD/

The page is a bit outdated with the current content

note: I used a texture for this, but I think its cleaner to use like a GraphicsBuffer probably, this was just the first thing I came accross and it was easy to setup so I went with it.

reddit.com