Niagara Advanced Guide – Position Based Dynamics

Epic Games distributes a project called “Content Examples,” which is a collection of various sample projects showcasing different features of Unreal Engine. Since UE4.26, there is a map called Niagara Advanced.

This map contains a wealth of useful samples, including examples of new Niagara features and practical applications. Upon reviewing the implementations, there’s a lot to learn.

This article will break down these samples into multiple parts for detailed explanations.

By the way, Content Examples can be downloaded from this link in Fab. Please note that the content may vary depending on the version of UE.

※Note: This blog is written based on UE4.26 Contents Example, so it might differ from the latest version in small details, but the concept itself explained here won’t change.

TLDR;)

  • Position-Based Dynamics (PBD) is a type of physics simulation that is based on positions (such as collision detection).
  • By using Position-Based Dynamics, you can achieve collision handling between particles.

Introduction

In this article, I will explain Position-Based Dynamics (PBD). Rather than being a brand-new feature, it makes use of a set of functionalities added since UE4.26, such as Simulation Stage, Particle Attribute Reader, and Neighbor Grid3D. The basic functionalities for PBD are modularized, making them available for use.

So, for those who are not yet familiar with these features, I recommend reading the following articles first:

This time, I will explain the “3.6 Position-Based Dynamics” sample from Niagara Advanced. But before diving into the sample, let’s first discuss what PBD is.

What is Position-Based Dynamics (PBD)?

In simple terms, PBD is a position-based physics simulation.

While standard physics calculations work by applying forces → accelerations → velocities → positions based on physical laws, PBD adjusts positions based on certain constraints (e.g., objects not overlapping), and then calculates velocities from there.

For more details, please refer to articles such as:
[CEDEC 2014]剛体から流体まで,セガのプログラマーが語る「位置ベース物理シミュレーション」の最前線

In Niagara, Neighbor Grid 3D and Particle Attribute Reader are used to gather position information of nearby particles. By calculating whether particles overlap based on their predefined radius, it is possible to adjust their positions to avoid overlap.

By using the Simulation Stage to repeat this process, we can achieve highly accurate adjustments.

The great thing about this is that particle-to-particle collisions, which were previously not possible, can now be handled.

It’s clear from the difference: In the left image, the particles are drawn toward the center, but they don’t overlap.

By the way, the “Popcorn Man” demo created by Epic also uses PBD technology.

Now, let’s jump into the explanation of the sample.

3.6 Position Based Dynamics Explanation

In this sample, multiple spheres are drawn toward the center but behave in a way that makes them collide with each other. This is made possible by PBD.

Let’s go through the details.

In the System Update phase, the Initialize Neighbor Grid module is used to initialize Neighbor Grid3D.

A fairly large 4x4x4 grid is set up.

The Emitter is configured as follows, and the actual PBD calculations are performed in the PBD Particle Collision module within the Particle Collisions Simulation Stage.

I will explain step by step.

Emitter Update

First, 10 particles are spawned simultaneously using the Spawn Burst Instantaneous module.

Then, the Particle Attribute Reader is initialized. The Emitter Name is set to the name of the emitter itself, allowing it to read information from other particles.

Particle Spawn

The Initialize Particle module sets the particle’s Mass randomly. The Calculate Size and Rotational Inertia by Mass module then sets the mesh’s scale (and rotational inertia) based on the mass value and various settings. (The size is calculated based on mass and density, so this module adjusts them accordingly.)

This adds some randomness to the mesh size based on the randomly set mass.

The particles are spawned at random positions within a sphere using the Sphere Location module.

Particle Update

Three attributes, CollisionRadius (float), Unyielding (bool), and Previous Position (Vector), are initialized. These attributes are used later in the PBD Particle Collision module.

  • CollisionRadius is calculated using the Calculate Particle Radius dynamic input. It’s not too complex; it calculates the radius by mesh dimensions and the particle’s scale attribute based on the Method for Calculation Particle Radius.
    • This time, since the Method is Minimum Axis and Mesh Dimensions is (2.0, 2.0, 2.0), the value of 2.0 / 2 * Scale will be used for the CollisionRadius.
  • Previous Position is set to the attribute added by the Solve Forces and Velocity module.
  • Unyielding is set to false.

The rest of the settings are not very complicated. A movement is created by the Point Attraction Force, which pulls towards the center.

The Collision here is just to enable collisions with other objects, so it is not related to PBD (Position-Based Dynamics).

To briefly explain, since this sample uses GPU Simulation, three methods can be used for collision detection:

  1. GPU Depth Buffer: This uses the Scene Depth information for collision detection.
  2. GPU Distance Fields: This uses the Mesh Distance Fields information for collision detection. Mesh Distance Fields stores the distance from each voxel (coordinate in the world space) to the closest mesh. This topic can get quite deep, so researching it further might be interesting.
  3. Analytical Planes: This method detects collisions only with user-defined infinite planes.

Finally, there’s the DynamicMaterialParameter set, but it’s not being used.

Populate Grid

In this simulation stage, the execution index of each particle is saved to the Neighbor Grid3D.

The Populate Neighbor Grid used here is an existing module. In a previous article that explained Neighbor Grid3D, these operations were implemented using the Scratch Pad module, but it was already available as a dedicated module.

However, since the attributes set by the Initialize Neighbor Grid3D module are used as input values, if you’re not using that module, you need to manually provide those values.

Since the internals are the same, I’ll skip the detailed explanation here.

Particle Collisions

Now we get to the core part.

First, the number of iterations is set to 4. In a single pass, it’s possible that, after adjusting the overlap with other particles, a new overlap could occur with a different particle. By repeating the process multiple times, the adjustments become more accurate. Of course, this comes with a trade-off in performance.

As for the PBD process in PBD Particle Collision, this is also an existing module. However, since it’s experimental, it won’t show up in search results unless you uncheck the Library Only option, so be aware of that.

The input values are as follows:

The input values are as follows:

We’ll take a look at the details shortly, but honestly, it still feels like it’s under development.

In fact, KinectFriction and StaticFriction are not functional yet, so changing their values won’t have any effect.

RelaxationAmount: Increasing this value weakens the PBD effect, making the collisions feel more fluid. A value of 1.0 is usually fine.

Simulate flag: This flag enables or disables PBD.

The other values are predefined, so there’s no need to change them.

Let’s look inside; it’s implemented in HLSL.

Ultimately, the values are updated as shown below, but in this sample, only Position and Velocity are used.

Before we dive into the HLSL code, let me first explain the premise.

How PBD is Achieved

In this method, particles in the 3x3x3=27 cells (including the particle’s own cell) adjacent to the cell it belongs to in the Neighbor Grid3D are checked for overlap using the pre-set CollisionRadius. This is done by performing a brute-force search through all the particles.

When an overlap is detected, the length of the overlap is calculated, and a weighted distance is applied based on the masses of the two particles. The position of the particle is then adjusted in the opposite direction, and the velocity is also updated in that direction.

This process is repeated 4 times in a single frame to achieve PBD.

The “Unyielding” Flag

There’s also a flag called Unyielding. When this flag is set to true for a particle, if it overlaps with a particle that has this flag set to false, the true particle will not move. Only the false particle will have its position adjusted. (More specifically, you can control how much a particle resists movement by setting the UnyieldingMassPercentage.)

This allows for different collision behaviors for each particle. However, in this sample, all particles have it set to false, so there’s no difference in behavior between them.

Red represents Unyielding=true, and blue represents false. The UnyieldingMassPercentage is set to 1.0.

With that in mind, let’s take a look inside the HLSL code.

// Initialize variables for output
OutPosition = Position;
OutVelocity = Velocity;
CollidesOut = CollidesIn;
NumberOfNeighbors = 0;
displayCount = 0.0;

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

#if GPU_SIMULATION

// Define the index offsets to access the 27 adjacent cells, including the current one
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),
};

// Convert position to grid space coordinates
float3 UnitPos;
myNeighborGrid.SimulationToUnit(Position, SimulationToUnit, UnitPos);

// Get the index of the cell the particle belongs to
int3 Index;
myNeighborGrid.UnitToIndex(UnitPos, Index.x, Index.y, Index.z);

// Define variables for intermediate use
float3 FinalOffsetVector = {0,0,0};
uint ConstraintCount = 0;
float TotalMassPercentage = 1.0;

// Get the number of cells in the grid
int3 NumCells;
myNeighborGrid.GetNumCells(NumCells.x, NumCells.y, NumCells.z);

// Get the maximum number of neighbors per cell
int MaxNeighborsPerCell;
myNeighborGrid.MaxNeighborsPerCell(MaxNeighborsPerCell);

// Search the 27 adjacent cells
for (int xxx = 0; xxx < 27; ++xxx) 
{
    // For each cell, search MaxNeighborsPerCell times
    for (int i = 0; i < MaxNeighborsPerCell; ++i)
    {
        // Get the index of the particle stored in the cell
        const int3 IndexToUse = Index + IndexOffsets[xxx];
        int NeighborLinearIndex;
        myNeighborGrid.NeighborGridIndexToLinear(IndexToUse.x, IndexToUse.y, IndexToUse.z, i, NeighborLinearIndex);

        // Get the Execution Index of the particle based on NeighborLinearIndex
        int CurrNeighborIdx;
        myNeighborGrid.GetParticleNeighbor(NeighborLinearIndex, CurrNeighborIdx);

        // Get the position of the particle using the Execution Index
        bool myBool; 
        float3 OtherPos;
        DirectReads.GetVectorByIndex<Attribute="Position">(CurrNeighborIdx, myBool, OtherPos);
        
        // Calculate the distance and direction between this particle and the target particle
        const float3 vectorFromOtherToSelf = Position - OtherPos;
        const float dist = length(vectorFromOtherToSelf);
        const float3 CollisionNormal = vectorFromOtherToSelf / dist;

        // Get the CollisionRadius of the target particle
        float OtherRadius;
        DirectReads.GetFloatByIndex<Attribute="CollisionRadius">(CurrNeighborIdx, myBool, OtherRadius);

        // Calculate if there's an overlap (if Overlap >= 0, they are overlapping)
        float Overlap = (CollisionRadius + OtherRadius) - dist;

        // Check for valid indices
        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)
        {
            // Initialize variables
            bool otherUnyielding = false;
            TotalMassPercentage = 1.0;

            // Process only if there's an overlap
            if (Overlap > 1e-5)
            {
                NumberOfNeighbors += 1;
                displayCount = NumberOfNeighbors;

                // Get the Unyielding flag of the target particle
                bool NeighborUnyieldResults;
                DirectReads.GetBoolByIndex<Attribute="Unyielding">(CurrNeighborIdx, myBool, NeighborUnyieldResults);

                CollidesOut = true;

                // Get the mass of the target particle
                float OtherMass;
                DirectReads.GetFloatByIndex<Attribute="Mass">(CurrNeighborIdx, myBool, OtherMass);

                // Calculate a weighting factor based on the masses of the two particles (this will be used later for adjusting the movement)
                TotalMassPercentage = Mass / (Mass + OtherMass); 

                // If both particles are unyielding, do nothing
                if (NeighborUnyieldResults && Unyielding) { // both this particle and the other are unyielding
                    TotalMassPercentage = TotalMassPercentage; 
                }
                // If only the target particle is unyielding, adjust the TotalMassPercentage closer to 0
                else if (NeighborUnyieldResults) { // this particle yields but the other does not
                    TotalMassPercentage = lerp(TotalMassPercentage, 0.0, UnyieldingMassPercentage);
                }
                // If this particle is unyielding, adjust the TotalMassPercentage closer to 1
                else if (Unyielding) { // this particle is unyielding but the other is
                    TotalMassPercentage = lerp(TotalMassPercentage, 1.0, UnyieldingMassPercentage);
                }

                // Add the weighted overlap distance to the final offset vector
                FinalOffsetVector += (1.0 - TotalMassPercentage) * Overlap * CollisionNormal; 
                
                // This part seems to be calculating momentum, but it’s not used currently. It might be used when friction is implemented.
                float3 OtherPreviousPos;
                DirectReads.GetVectorByIndex<Attribute="Previous.Position">(CurrNeighborIdx, myBool, OtherPreviousPos);
                const float3 P0Velocity = Position - PreviousPosition;
                const float3 PNVelocity = OtherPos - OtherPreviousPos;
                const float3 P0RelativeMomentum = P0Velocity * Mass - PNVelocity * OtherMass;
                const float3 NormalBounceVelocity = (P0RelativeMomentum - dot(P0RelativeMomentum, CollisionNormal) * CollisionNormal) / Mass;
                const float NormalBounceVelocityMag = length(NormalBounceVelocity);
                
                // Record how many particles were overlapping
                ConstraintCount += 1;
            }
        }      
    }
}

// If there is an overlap and IsMobile (= input value of Simulate) is true
if (ConstraintCount > 0 && IsMobile)
{
    // Add the adjusted position vector, divided by the number of constraints and RelaxationAmount (biased average value), to the current position
    OutPosition += 1.0 * FinalOffsetVector / (ConstraintCount * RelaxationAmount);

    // Add friction support here (friction might be implemented here)

    // Update velocity based on the adjusted position and the previous position
    OutVelocity = (OutPosition - PreviousPosition) * InvDt;

    // If Unyielding is true, set velocity to zero
    if (Unyielding) {
        // May want to make this return to the previous velocity
        OutVelocity = float3(0, 0, 0);
    }
}

#endif

I’ve included some explanations in the comments for you to be able to understand by reading them. However, since this is still a work in progress, I expect the implementation to change in future versions.

Conclusion

I honestly think it’s a great evolution that particle-to-particle collisions can now be detected.

As for performance, during the Popcorn Man demo, they mentioned that the processing time for this part was under 1ms, so with proper adjustments, it seems like it could be practical for realtime use.

Regarding PBD, there is another sample available, so I hope to introduce it in a separate article.

Leave a Reply

Your email address will not be published. Required fields are marked *