Niagara precise shaped collisions

Updating the pre-existing HLSL for Intra-Particle Collisions to add support for cubes and cylinders.

What's supported:

  • Spherical particles checking against shaped particles for collision

What's not supported:

  • Shaped particles checking against shaped particles for collision
  • Shaped particles checking against spherical particles for collision

Overview

Basically, you make a list of shaped particles that can move other less important particles out of their way. For this support, the shaped particles make use of the existing "unyielding" particle logic -- all non-shapedparticles treat shaped particles as unyielding.

All shaped particles should treat each other as spherical and use the regular intra-pdb collisions.

You can make tiers of shaped particles.

Neighbor Grids should be sized based on particle size. If a grid is too large, you include too many particles and checks get inefficient. If it's too small, a large particle could span across multiple grids and not be detected at its edges for collision.

Example of setting up multiple grids:

  • Tier 2 (Largest): Use a grid with large extents -- extents equal to double the largest particle's length from center to outermost point.
    • These particles will check the Tier 2 grid for regular intra-particle collision.
  • Tier 1 (Medium): Use a grid with medium extents
    • These particles will check the tier 2 grid for shaped intra-particle collision.
    • These particles will check the tier 1 grid for regular intra-particle collision.
  • Tier 0 (Small)
    • These particles will check the tier 2 grid for shaped intra-particle collision.
    • These particles will check the tier 1 grid for shaped intra-particle collision.
    • These particles will check the tier 0 grid for regular intra-particle collision.

Collision order:

For cleanliness and efficiency of collisions, you should check collisions in the following order.

  1. All same tiers against same tiers.
    • Tier 1 against Tier 1
    • Tier 2 against Tier 2
    • Tier 3 against Tier 3
  2. Small (Tier 3) against Medium (Tier 2)
  3. Medium (Tier 2) against Large (Tier 1)

This lets us get smaller objects to an approximately correct position before the larger objects act on them. The larger particles will then have priority, pushing smaller particles out of the way. This may result in smaller objects overlapping each other, but small objects overlapping is less visible than small objects failing to be pushed by larger objects.

If precision matters more than performance you can put all of these in the same update and set it to simulate multiple times. I still suggest running them in this order.

What variables do we need?

  1. CollisionType (int) -- Box (2), Cylinder (1), or Sphere (0). Sphere collisions could use the regular intra-particle collisions, but for grid size and batching purposes we may as well combine them.
  2. MaxCollisionRadius (float) -- This is a separate stat from Collision Radius, used for an early out when small objects check larger objects for collision.
    • It should be equal to the length to the longest outlying point -- From the center of extents to a corner
    • While we could just us CollisionRadius, we will also be using CollisionRadius for intra-particle collisions. It's useful to keep MaxCollisionRadius (early out) separate from CollisionRadius.
    • As you may want the collision radius between like-sized particles to be smaller, and we want to use this early-out to eke out all the efficiency we can.
  3. Extents (Vector) -- Scale * Mesh Extents. Needed for boxes.
  4. Half Height (float) -- Half height of a cylinder
  5. CollisionRadius (Float) -- Needed for spheres. Can be calculated by Niagara or manually input. Also used for Cylinder radius.
  6. Orientation (Quat) -- Needed for boxes and cylinders. Calculated by Niagara.

Editing the intra-particle reader

Editing the intra-particle reader is simple.

  1. Copy the base reader scratchpad into another directory and rename it.
  2. Edit the scratch and add these input pins.
  3. Edit the HLSL and add this code block
HLSL Code

// Code Copyright Epic Games, Inc. All Rights Reserved, when not otherwise denoted
OutPosition = Position;
OutVelocity = Velocity;
CollidesOut = CollidesIn;
NumberOfNeighbors = 0;
displayCount = 0.0;

//AveragedCenterLocation = float3 (0.0,0.0,0.0);

#if GPU_SIMULATION

const int3 IndexOffsets [ 27 ] = 
{
    int3(-1,-1,-1),
    int3(-1,-1, 0),
    int3(-1,-1, 1),
    int3(-1, 0,-1),
    int3(-1, 0, 0),
    int3(-1, 0, 1),
    int3(-1, 1,-1),
    int3(-1, 1, 0),
    int3(-1, 1, 1),

    int3(0,-1,-1),
    int3(0,-1, 0),
    int3(0,-1, 1),
    int3(0, 0,-1),
    int3(0, 0, 0),
    int3(0, 0, 1),
    int3(0, 1,-1),
    int3(0, 1, 0),
    int3(0, 1, 1),

    int3(1,-1,-1),
    int3(1,-1, 0),
    int3(1,-1, 1),
    int3(1, 0,-1),
    int3(1, 0, 0),
    int3(1, 0, 1),
    int3(1, 1,-1),
    int3(1, 1, 0),
    int3(1, 1, 1),
};

float3 UnitPos;
myNeighborGrid.SimulationToUnit(Position, SimulationToUnit, UnitPos);

int3 Index;
myNeighborGrid.UnitToIndex(UnitPos, Index.x,Index.y,Index.z);

float3 FinalOffsetVector = {0,0,0};
uint ConstraintCount = 0;
float TotalMassPercentage =  1.0;

int3 NumCells;
myNeighborGrid.GetNumCells(NumCells.x, NumCells.y, NumCells.z);

int MaxNeighborsPerCell;
myNeighborGrid.MaxNeighborsPerCell(MaxNeighborsPerCell);

for (int xxx = 0; xxx < 27; ++xxx) 
{
    for (int i = 0; i < MaxNeighborsPerCell; ++i)
    {
        const int3 IndexToUse =Index + IndexOffsets[xxx];
        int NeighborLinearIndex;
        myNeighborGrid.NeighborGridIndexToLinear(IndexToUse.x, IndexToUse.y, IndexToUse.z, i, NeighborLinearIndex);

        int CurrNeighborIdx;
        myNeighborGrid.GetParticleNeighbor(NeighborLinearIndex, CurrNeighborIdx);

        // Use indirection table if persistent ids are used
        if (UsePids) 
        {
            bool myValid;
            DirectReads.GetParticleIndexFromIDTable(CurrNeighborIdx, myValid, CurrNeighborIdx);
        }

        // Temp bool used to catch valid/invalid results for direct reads
        bool myBool; 
        float3 OtherPos;
        DirectReads.GetVectorByIndex(CurrNeighborIdx, myBool, OtherPos);
        float3 vectorFromOtherToSelf = Position - OtherPos;
        float dist = length(vectorFromOtherToSelf);

        // Shape collision updates by Michael Royalty start here
        // MaxCollisionRadius is calculated on the large particles at spawn for a quick sphere-based
        //  out if there's no chance of colliding
        float OtherMaxRadius;
        DirectReads.GetFloatByIndex(CurrNeighborIdx, myBool, OtherMaxRadius);

        // Particles are too far away to possibly collide, so skip the detailed check
        if (OtherMaxRadius + CollisionRadius < dist) {continue;}
        float OtherRadius;
        float Overlap;

        int CollisionType;
        DirectReads.GetIntByIndex(CurrNeighborIdx, myBool, CollisionType);

        // Sphere collision code
        if (CollisionType == 0){
            DirectReads.GetFloatByIndex(CurrNeighborIdx, myBool, OtherRadius);
            Overlap = (CollisionRadius + OtherRadius) - dist;
        }

        // Capsule collision code
        else if (CollisionType == 1)
        {
            // A = Capsule endpoint 1 (start)
            // B = Capsule endpoint 2 (end)
            // Radius = capsule radius
            // P = particle position (Position)

            float3 A;
            float3 B;
            DirectReads.GetVectorByIndex(CurrNeighborIdx, myBool, A);
            DirectReads.GetVectorByIndex(CurrNeighborIdx, myBool, B);
            DirectReads.GetFloatByIndex(CurrNeighborIdx, myBool, OtherRadius);

            float3 AB = B - A;
            float3 AP = Position - A;
            float AB_len2 = dot(AB, AB);

            float t = AB_len2 > 1e-8 ? saturate(dot(AP, AB) / AB_len2) : 0.0;
            float3 ClosestPoint = A + t * AB;

            vectorFromOtherToSelf = Position - ClosestPoint;
            dist = length(vectorFromOtherToSelf);

            Overlap = (CollisionRadius + OtherRadius) - dist;
        }

        // Box collision code
        else if (CollisionType == 2)
        {
            // MeshOrientation (quat) is directly copied to QuatRotation (vector4),
            //  since we can't import quats into hlsl
            float4 qr;
            DirectReads.GetVector4ByIndex(CurrNeighborIdx, myBool, qr);

            // Build the rotation matrix needed to convert the large particle to local space
            float3x3 rotMatrix = float3x3
            (
            1 - 2 * (qr.y * qr.y + qr.z * qr.z),
                2 * (qr.x * qr.y - qr.z * qr.w),
                2 * (qr.x * qr.z + qr.y * qr.w),

                2 * (qr.x * qr.y + qr.z * qr.w),
            1 - 2 * (qr.x * qr.x + qr.z * qr.z),
                2 * (qr.y * qr.z - qr.x * qr.w),

                2 * (qr.x * qr.z - qr.y * qr.w),
                2 * (qr.y * qr.z + qr.x * qr.w),
            1 - 2 * (qr.x * qr.x + qr.y * qr.y)
            );

            // Find the local point, rotate it to the Other's rotation,
            //  clamp it to find out if it's inside or outside the box,
            //  then unrotate it
            float3 localPoint = mul(transpose(rotMatrix), vectorFromOtherToSelf);

            float3 OtherBoundsMin, OtherBoundsMax;
            DirectReads.GetVectorByIndex(CurrNeighborIdx, myBool, OtherBoundsMin);
            DirectReads.GetVectorByIndex(CurrNeighborIdx, myBool, OtherBoundsMax);

            // Check to see if the particle is colliding. If not we can skip doing any more checks.
            if (localPoint.x > OtherBoundsMax.x + CollisionRadius
                || localPoint.x < OtherBoundsMin.x - CollisionRadius
                || localPoint.y > OtherBoundsMax.y + CollisionRadius
                || localPoint.y < OtherBoundsMin.y - CollisionRadius
                || localPoint.z > OtherBoundsMax.z + CollisionRadius
                || localPoint.z < OtherBoundsMin.z - CollisionRadius) { continue; }

            // This works if the point is outside the bounds. For small or fast moving particles this can break.
            // For now, we'll let it break rather than overcomplicating the code.
            float3 localClampedPoint = clamp(localPoint, OtherBoundsMin, OtherBoundsMax);

            // Calculate dynamic radius of the neighbor box, based on how far from its center the closest point is
            //OtherRadius = length(localClampedPoint); Not needed here

            float3 closestPointOnBox = mul(rotMatrix, localClampedPoint) + OtherPos;

            vectorFromOtherToSelf = Position - closestPointOnBox;
            dist = length(vectorFromOtherToSelf);

            // Simplifying this because distToBox already accounts for the other particle's radius
            //float Overlap = (CollisionRadius + OtherRadius) - dist;
            Overlap = CollisionRadius - dist;

        } // End of box collision checks


        float3 CollisionNormal = vectorFromOtherToSelf / dist;

        // Shape collision updates by Michael Royalty end here
        //  ..But note the commented out CurrNeighborIdx and related comment below.

        // Removing the CurrNeightborIdx != InstanceIdx check for now. These systems are separate emitters,
        //  so the indexes can and should be equal at times.
        // If we merge them to a single emitter we should add this check back in.
        if (IndexToUse.x >= 0 && IndexToUse.x < NumCells.x && 
            IndexToUse.y >= 0 && IndexToUse.y < NumCells.y && 
            IndexToUse.z >= 0 && IndexToUse.z < NumCells.z && 
            /*CurrNeighborIdx != InstanceIdx && */CurrNeighborIdx != -1 && dist > 1e-5)
        {
            bool otherUnyeilding = false;
            TotalMassPercentage =  1.0;

            if ( Overlap > 1e-5)
            {
                NumberOfNeighbors+=1;
                displayCount = NumberOfNeighbors;
                bool NeighborUnyieldResults;
                DirectReads.GetBoolByIndex(CurrNeighborIdx, myBool, NeighborUnyieldResults);

                CollidesOut = true;

                float OtherMass;
                DirectReads.GetFloatByIndex(CurrNeighborIdx, myBool, OtherMass);

                // 1 moves this particle, 0 moves the other, .5 moves both

                TotalMassPercentage = Mass / (Mass + OtherMass); 

                if ( NeighborUnyieldResults && Unyielding ){ // Both this particle and the other are unyielding
                    TotalMassPercentage = TotalMassPercentage; 
                } 
                else if ( NeighborUnyieldResults ) { // This particle yields but the other does not
                    TotalMassPercentage = lerp ( TotalMassPercentage, 0.0, UnyeildingMassPercentage); //0
                } 
                else if (Unyielding) { // This particle is unyielding but the other is
                    TotalMassPercentage = lerp ( TotalMassPercentage,1.0, UnyeildingMassPercentage); //1
                }

                // If both are yielding then use the normal ratio
                FinalOffsetVector += (1.0 - TotalMassPercentage) * Overlap * CollisionNormal; 
                ConstraintCount += 1;
            }
        }      
    }
}

if (ConstraintCount > 0 && IsMobile)
{
    OutPosition += 1.0*FinalOffsetVector/ (ConstraintCount * RelaxationAmount);


    // Add friction support here


    OutVelocity = (OutPosition - PreviousPosition) * InvDt;
}

#endif

Usage

  1. Create three neighbor grids, all in the same emitter. (Note: If you're using separate emitters you'll want to create the larger neighbor grids under the system instead)
  2. Populate the three neighbor grids based on the Size variable
  3. Set up one intra-particle reader (or three in the small emitter, two in the medium, and one in the large if you're using 3 separate emitters. Possibly you can set these up at the system level instead)
  4. Set up the three intra-particle checks
  5. Set up the medium-to-large shape check
  6. Set up the small-to-medium shape check

You may need to limit the max speed of smaller particles, or make your box shapes thicker, if you see particles going into boxes.

Future Improvements

We can make it easier to set up multiple tiers.

  • Stage 1 (Manual): Combine the intra-particle reader to take multiple particle readers and neighbor grids. We can have one intra-particle reader scratch for 2 sizes, and one for 3 sizes.
  • Stage 2 (Automatic): Combine the multiple readers and neighbor grids into an array. Then we can have as many sizes as we want. This may be overkill, especially if large amounts of collision checks start to slow everything down.