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 
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!