Tuesday, 9 June 2015

Particles revamp Part 3 : Selectors

In the last 2 posts, I explained how I moved my particle system to have GPU managed counter and to allow regions to have better emission/behaviour control.

This already offers a pretty great deal of flexibility, but then there are 2 parts that are missing:

  • Now with regions, my valid particles can be scattered within my main buffer, that means emit count is not for the full particle system anymore, but per region.
  • Many times I want to be able to only apply behaviours to particles that satisfy a particular condition (within an area, only particles that collided at leqst once, any particle within mouse raycast....)

In a funny way both problems can be solved using a similar technique... Selectors

So the idea is really simple, you have a predicate function (eg: anything from a particle that returns true or false), if a particle satisfies this predicate, then apply effectors, else do nothing.

As the amount of buffers to copy would be huge, we will do it in the most simple way eg: If a particle satisfies condition then add the particle index into a selection buffer, then create another dispatcher and only process the particles that satisfied the condition.

The only difference now is that behaviours must be aware that they operate either on plain buffer, or on a selection.

So first we need the following data:

Code Snippet
  1. ByteAddressBuffer SelectionCountBuffer : SELECTIONCOUNTBUFFER;
  2. StructuredBuffer<uint> SelectionIDBuffer : SELECTIONIDBUFFER;
  3. RWStructuredBuffer<uint> RWSelectionIDBuffer : RWSELECTIONIDBUFFER; //Use for deterministic selection count
  4. AppendStructuredBuffer<uint> AppendSelectionIDBuffer : APPENDSELECTIONIDBUFFER; //Use for unordered selection

Now we use some helper function:

Code Snippet
  1. void AppendToSelection(uint id, bool predicate)
  2. {
  3.     if (predicate)
  4.     {
  5.         AppendSelectionIDBuffer.Append(id);
  6.     }
  7. }

Next we do a little cooking with our append buffer to generate new dispatch calls.

Now one difference is that our behaviours also need to fetch data either in a linear fashion (no selector), or using that lookup table, so a couple helper functions and a few defines do a great job for this purpose:

Code Snippet
  2. uint GetParticleIndex(uint tid)
  3. {
  4.     return SelectionIDBuffer[tid];
  5. }
  7. bool IsOverBounds(uint tid)
  8. {
  9.     uint selectionCount = SelectionCountBuffer.Load(0);
  10.     return tid >= selectionCount;
  11. }
  12. #else
  13. uint GetParticleIndex(uint tid)
  14. {
  15.     return tid;
  16. }
  18. bool IsOverBounds(uint tid)
  19. {
  20.     return tid >= EmitCount;
  21. }
  22. #endif

So for each behaviour, we now have 2 shaders, one to process linearly, one to process using lookup table.

So now here is an example of a "Within Sphere" selector

Code Snippet
  1. float4 sphere : SPHERE;
  3. [numthreads(64, 1, 1)]
  4. void CS( uint3 i : SV_DispatchThreadID )
  5. {
  6.     if (IsOverBounds(i.x))
  7.         return;
  8.     int idx = GetParticleIndex(i.x);
  9.     float3 p = PositionBuffer[idx];
  10.     float d = length(sphere.xyz - p) - sphere.w;
  11.     AppendToSelection(idx, d < 0.0f);
  12. }

As any acute reader will notice, I'm also using the helper functions in the case of a selector, which means a selector can operate on a selection!

This part means you can stack selectors (in which case you have a "AND" operator. i did not implement that yet, since it might make things overcomplex, but there is also another rationale over it.

You remember that I said that now with regions my emitted particles are scattered inside the buffer, so that means any selector/global behaviour needs to operate only on emitted particles.

For global elements, you can call them once per region, but if you have 20 that multiplies your Dispatches a lot.

So instead, let's think simpler, we can easily for each region create a global lookup table that only contains emitted particles.

Code Snippet
  1. cbuffer cbRegionInfo : register(b11)
  2. {
  3.     uint StartRegionOffset;
  4.     uint RegionElementCount;
  5. }
  7. //This is called once per region
  8. [numthreads(64,1,1)]
  9. void CS_CopyRegionIndices(uint3 tid : SV_DispatchThreadID)
  10. {
  11.     if (tid.x >= EmitCount)
  12.         return;
  13.     //Get location in global buffer
  14.     uint id = tid.x + StartRegionOffset;
  15.     //Push particle ID
  16.     AppendSelectionIDBuffer.Append(id);
  17. }

Yes, it is that simple, we call this shader once per region (we clear Append buffer counter on first call, but preserve it on next iterations), and we have our global emission count.

So now we can select from within this buffer (which contains global indices, not local ones), and perform our behaviours/display only on emitted elements.

So now selectors can be owned by 2 "containers"

  • Within the particle system: We can select particles and apply effectors
  • Outside of the particle system : So we can select particles to route them to a custom render shader.
Since my selectors don't own any buffers, but are provided contexts, this is really easy.

So of course in case of selectors, you can also have them within a region, or globals, as the screenshot here shows:

You can see the Filter nodes grabs a particle selector for custom display, the Selector node can allow to select elements within the particle system, they both use the same "WithinSphere" node.

So this provides a huge amount of flexibility, and still keeps pretty high performances (as stream compaction makes sure that we only operate on needed elements).

But now we can also see that we operate in "Immediate mode", eg:If the particle satisfies the condition, apply, else , don't. This is done every frame.

In some cases this is not ideal, we want to say: If the particle has satisfied the condition once (for example , mouse raycast), then apply the effector until we release the button.

So in that case the switch is simple, as you seen previously, I the particle satisfies a condition, it is pushed into an append buffer.
So now let's change the code by:

Code Snippet
  2. void AppendToSelection(uint id, bool predicate)
  3. {
  4.     if (predicate)
  5.     {
  6.         AppendSelectionIDBuffer.Append(id);
  7.     }
  8. }
  9. #else
  10. void AppendToSelection(uint id, bool predicate)
  11. {
  12.     if (predicate)
  13.     {
  14.         RWSelectionIDBuffer[id] = 1;
  15.     }
  16. }
  17. #endif

We just write in a flag in order to tell that we satisfied the condition once.

Then our selector becomes:

Code Snippet
  1. [numthreads(64, 1, 1)]
  2. void CS_Reset(uint3 tid : SV_DispatchThreadID)
  3. {
  4.     if (IsOverBounds(i.x))
  5.         return;
  6.     int idx = GetParticleIndex(i.x);
  8.     RWSelectionIDBuffer[idx] = 0;
  9. }
  11. [numthreads(64, 1, 1)]
  12. void CS_Append(uint3 tid : SV_DispatchThreadID)
  13. {
  14.     if (IsOverBounds(i.x))
  15.         return;
  16.     int idx = GetParticleIndex(i.x);
  18.     uint isFlagged = SelectionIDBuffer[idx];
  20.     if (isFlagged)
  21.     {
  22.         AppendSelectionIDBuffer.Append(idx);
  23.     }
  24. }

So we add a second pass, which grabs every particle that has been flagged at least once (until we reset), and we now have our retained collector.

Here we go, revamping this particle system was actually really fun, allowed to have a reasonable code clean up alongside improvements, and now I got a lot of new funky effectors/emitters and selectors to write ;)

For the next posts, I'm not too sure yet, maybe some pipeline tricks, but there is something else coming that is much more interesting (hint for readers: read a bit of F# code, that will help you) :)

No comments:

Post a Comment