Connect VFX Subgraphs to GPU Event Outputs

Hi there,

is there a job in the pipeline to allow us to connect Subgraphs to GPU Events, as shown in the attached image.

Currently this does not work so we end up with bloated VFX Graphs and need to edit many graphs if there is a change to a consistent effect.

An example of this may be spawning a dust cloud with debris on impact. This could happen when a weapon hits the ground, an object hits the ground, a wall falls, etc. All using the same VFX Subgraph.

Thanks!

Another great addition would be able to attach a GPU Event to an OutputEvent. This would open up the versatility of VFX Graph immensely

Hello. You are right that connecting a GPU event to a nested VFX is currently not possible. Similarily, connecting a GPU event to an Output event is not possible either. Output event is purely CPU.

We have plans to improve the subgraph system to make it more powerful as well as allowing reading back GPU data to CPU.

In the meantime, some system factorization can be done with subgraph blocks, so you can have a system made of contexts with single factorized subblocks and those can be connected to GPU events.

For particle data readback, it is doable via script with a custom HLSL node writing particle data to a buffer and reading it back using AsyncGPUReadback feature.

Oh I see, so I have a bunch of sub graphs (a init particle subgraph, an update subgraph, an output subgraph) and then I plug those into the contexts that are connected to the GPU event in my main graph. That will at least allow me to have a single point of change for my subgraphs i suppose.

For particle data readback that sounds very interesting, especially if i can get stuff like collision detection position/normal.

Thanks for the answer!

Hi, do you have any starting points for reading on how to create a custom HLSL node and writing/reading the buffer?

I’ve read through the unity docs page on CustomHLSL and this one but the jump from moving a particle in the graph to reading out a graphics buffer is a bit steep :sweat_smile:

Hi! Regarding using custom HLSL for reading back particle data, we don’t have example in the manual. However we posted one in the forum, you can find more information and a sample project here.
Hope this helps

1 Like

Hello! There is also a small GDC presentation where we show an example of using custom HLSL nodes for GPU->CPU readback : https://gdcvault.com/play/1034719/Unity-Developer-Summit-Rendering-Customization, from minute 46 onwards :slight_smile:

1 Like

Thankyou both so much!

For posterity/anyone who is looking into this in the future.

I’ve spent my morning learning all about graphics buffers and HLSL and bitwise shifting memory addresse :sweat_smile:

Ludovic, Julien, please correct any of my mistakes. I’ve added comments to the code as I’ve tried to understand it.

The Custom HLSL Code

Every time a particle has a collision event we increment the counter (freeSlot) and store the collision position and normal data into the Graphics Buffer.

We set a maximum of 16 collisions (each collision takes up 6 x 4 bytes, plus our first counter byte is 388 total bytes) to preserve memory and performance.

void TriggerCPUEvent(inout VFXAttributes attributes, RWByteAddressBuffer readback)
{
    //the collision counter. Keeps track of how many collisions have happened this frame
    uint freeSlot;
    //increment the counter for each time this is called (each collision)
    readback.InterlockedAdd(0, 1, freeSlot);
    //only count the first 16 collisions per frame, graphics buffer memory limitation
    if (freeSlot < 16)
    {
         //set the offset, where we write the first uint of collision data
         //leave 1 uint for the counter, and each chunk is 6 units
         uint offset = 1u + freeSlot * 6u;
         //get the collision position and normal and write them to a float3
         float3 readPosition = attributes.collisionEventPosition;
         float3 readNormal = attributes.collisionEventNormal;
         //write the position and normal data to the buffers, casting to uint. 
         //the <<2u is bit shifting left by 2 bits to convert from byte to bits for the address
         readback.Store3((offset + 0) << 2u, asuint(readPosition));
         readback.Store3((offset + 3) << 2u, asuint(readNormal));
    }   
}

C Sharp Script

The Monobehaviour script is attached to the object with the VisualEffect component on it.
It creates a Graphics Buffer and writes that to the VisualEffect’s Readback_Collisions Property, so it can be written to, and in turn read from.

The Graphics Buffer is created with a stride size of 6 uints (we store 6 floats, cast as uints, 3 for collision position data and 3 for collision normal data). We reserve the first position in the buffer (index 0, the first 4 bytes) for the counter.

In the update loop we check if the Async GPU readback is done and if it is we request it’s data, and feed that to the OnReadback Callback.

In the OnReadback callback we check if there were any collision (if count>0) and step through the data, writing the collision data to variables.

This is Where the Magic Happens!

For anyone reading this in the future, you can ignore everything I’ve written above and below if you don’t want to learn it. Just slap a function in place of the Debug.DrawLine… line like:

  • PlaySFX(position) //play some sound at the collision site
  • SprayBlood(position, normal) //spawn some blood vfx and even pass in the normal so it looks right!
  • DamageEnemiesAtLoc(position) //trigger a Physics.CheckSphere() at the position and damage an enemy in it
public class Read_Collisions : MonoBehaviour
{
    private static readonly int kReadback_CollisionID = Shader.PropertyToID("Readback_Collisions");

    private GraphicsBuffer m_Buffer;
    private AsyncGPUReadbackRequest m_Readback;

    const int kMaxEventCount = 16;

    void OnEnable()
    {
        //Construct the graphics buffer, with a size of 16 x 6 (3 pos uints + 3 normal uints) + 1 (leave 4 bytes for the counter).
        //The stride determines how much each chunk of data is going to be, in this case we're using a uint (4 bytes) so that's the stride.
        m_Buffer = new GraphicsBuffer(GraphicsBuffer.Target.Raw, GraphicsBuffer.UsageFlags.None, kMaxEventCount * 6 + 1, Marshal.SizeOf(typeof(uint)));
        //clear the buffers data initally, so we can write collision data to it on the GPU.
        m_Buffer.SetData(new[] { 0 });

        //link the graphics Buffer to the attached VisualEffect
        GetComponent<VisualEffect>().SetGraphicsBuffer(kReadback_CollisionID, m_Buffer);
    }

    void OnReadback(AsyncGPUReadbackRequest asyncGpuReadbackRequest)
    {
        //get the number of collisions that were detected this frame
        var count = m_Readback.GetData<uint>()[0];
        //if there were any collisions...
        if (count > 0)
        {
            //get all the data in the buffer
            var data = m_Readback.GetData<float>();
            //cursor is pointer to the data in the buffer. Start at 1 because the first value of the buffer is the counter (of collisions)
            var cursor = 1;
            //loop through all the collisions, limited to 16 for memory safety of the buffer
            for (uint index = 0; index < count && index < 16; ++index)
            {
                //get the position data from address 1,2,3 (for the first loop). cursor is read than incremented ++
                var position = new Vector3(data[cursor++], data[cursor++], data[cursor++]);
                //get the normal data from address 4,5,6 (for the first loop)
                var normal = new Vector3(data[cursor++], data[cursor++], data[cursor++]);
                //draw a line. This is the part we want to exploit to do something with the collision data like play a sound, make an enemy take damage, etc.
                Debug.DrawLine(position, position + normal * 0.1f, Color.red, 0.5f);
            }
        }
    }

    void Update()
    {
        //because the graphics buffer is being written to by the GPU, we need to wait for the writing to be done before we can access it.
        //might miss a couple of frames while we wait asynchoronously
        if (m_Readback.done)
        {
            //We asynchronously request the data from the Graphics Buffer, passing the return (AsyncGPUReadbackRequest) to the OnReadback callback
            m_Readback = AsyncGPUReadback.Request(m_Buffer, OnReadback);
            //once we've read the data for this frame, we clear the buffer, in turn resetting the counter (at address 0) back to 0 so we can detect more collisions
            m_Buffer.SetData(new[] { 0 });
        }
    }

    void OnDisable()
    {
        //clean up, not sure if the buffer is going to be collected by the GC so release it manually. 
        m_Buffer.Release();
    }
}

Thanks to Julien and Ludovic for the references, and the sample project!

1 Like