Gravity Simulation With Unity DOTS 1.0

Written by deniskondratev | Published 2023/01/02
Tech Story Tags: unity | unity-dots | unity-dots-1.0 | parallel-computing | gravity-simulation | physics | game-development | tutorial

TLDRThis article is intended for those who already have some basic knowledge of Unity DOTS, but want to learn more and see examples of its practical use. This article will not provide a detailed description of ECS or the fundamental concepts of DOTS. However, it will still be useful for those who prefer to learn from examples.via the TL;DR App

With the release of Unity 2022.2, DOTS packages have finally received a pre-release version. As you may know, in the standard Unity approach, access to game objects and MonoBehaviour is only possible from the main stream. One of the major benefits of DOTS is its ability to easily access multithreaded parallel computing, which significantly improves performance.

This article is intended for those who already have some basic knowledge of Unity DOTS, but want to learn more and see examples of its practical use. This article will not provide a detailed description of ECS or the fundamental concepts of DOTS. However, it will still be useful for those who prefer to learn from examples.

To make it easier to understand the code, I recommend downloading the completed project from this link.

What do we need to do?

I want to create a gravity simulation for multiple celestial bodies. The concept is simple. There will be a large number of celestial bodies on the scene, and each body will attract every other body. The problem is that the number of interactions between celestial bodies increases exponentially with the number of bodies. For example, with 10 celestial bodies, there will be 10 x 10 = 100 interactions. With 100 celestial bodies, there will be 100 x 100 = 10,000 interactions. And with 1,000 celestial bodies, there will be 1,000,000 interactions. This is where parallel computing becomes particularly useful.

Following the ECS approach, the core of a celestial body will be an entity. An entity can be described using components, and then we will create systems that will process these components to directly model gravity.

Add the necessary components

The force of gravity is calculated using the formula F = G * m1 * m2 / r^2, where G is the gravitational constant that allows us to adjust the strength of attraction, m1 and m2 are the masses of the interacting celestial bodies, and r is the distance between the bodies. Therefore, the entity needs the following characteristics: mass, position, and velocity.

The Unity Physics package already has LocalTransform components for setting the position and PhysicsVelocity for setting the speed. There is also a PhysicsMass component, but it has too many unnecessary parameters for this task, so let's create our own simple Mass component.

There are several types of components, which you can read more about here. When celestial bodies collide, they will be combined, with the characteristics of one being added to the other. The remaining celestial body will then be removed. However, deleting an entity can cause memory restructuring, which can lead to freezes. To avoid this, I will simply disable unnecessary objects. Let's set up the Mass component as enableable.

public struct Mass : IComponentData, IEnableableComponent
{
    public float Value;
}

We will also need to register collisions. We could do this in a separate system by creating separate entities for registered collisions, but this would be very resource-intensive. Therefore, we will take shortcuts wherever possible.

Let's impose a restriction - for each object, we will only register one collision per simulation step. To do this, we will create the Collision component, which will specify the entity of the object with which the collision occurred.

public struct Collision : IComponentData
{
    public Entity Entity;
}


We now have everything we need to create a celestial body with gravitational properties.

Prefab for bodies

Now we need to create a prefab for a celestial body. To do this, create a sphere on the scene and drag it into the project tab. Make sure that the sphere has a sphere collider.

Let's write a component that will convert this prefab into an entity. We'll call it Body Authoring. To convert, we need to create a class that inherits from Baker<BodyAuthoring>. For simplicity, the class can be placed inside MonoBehaviour, but this is not necessary.

Inside the baker class, we will add the necessary components. The LocalTransform component will

be added automatically, converting from the standard Transform. We just need to add the Mass, PhysicsVelocity, and Collision components.

public class BodyAuthoring : MonoBehaviour
{
    public class Baker : Baker<BodyAuthoring>
    {
        public override void Bake(BodyAuthoring authoring)
        {
            AddComponent<Mass>();
            AddComponent<PhysicsVelocity>();
            AddComponent<Collision>();
        }
    }
}

Don't forget to attach the Body Authoring component to the prefab.

Initializing system

At the start, we need to create objects with initial parameters, which will then interact with each other. Let's create the WorldConfig component that will contain these initial parameters.

[Serializable]
public struct WorldConfig : IComponentData
{
    /// <summary>
    /// Initial count of bodies.
    /// </summary>
    public int BodyCount;
    
    /// <summary>
    /// The radius of the sphere within which the bodies will be created during initialization.
    /// </summary>
    public float WorldRadius;
    
    /// <summary>
    /// The range of initial speed of bodies.
    /// </summary>
    public float2 StartSpeedRange;
    
    /// <summary>
    /// The range of initial mass of bodies.
    /// </summary>
    public float2 StartMassRange;
    
    /// <summary>
    /// The coefficient of increasing the gravitational power.
    /// </summary>
    public float GravitationalConstant;
    
    /// <summary>
    /// The zero distance between objects can lead to infinite force. To avoid the occurrence of extremely large forces
    /// during the calculation, add a restriction on the minimum distance.
    /// </summary>
    public float MinDistanceToAttract;
    
    /// <summary>
    /// The ratio of the body mass to its size. So objects with different masses have different sizes.
    /// </summary>
    public float MassToScaleRatio;
    
    /// <summary>
    /// The coefficient that will determine how close the objects should be to each other to make the collision.
    /// </summary>
    public float CollisionFactor;
}

I added [Serializable] so that this component can later be placed in MonoBehaviour and all these parameters can be set directly in the inspector. I also added descriptions for each parameter in the comments. The meanings of each parameter will become clearer as they are applied.

Next, we will create a component that will contain the prefab of our object. This is straightforward.

public struct BodyPrefab : IComponentData
{
    public Entity Value;
}

Let's write the WorldAuthoring component so that you can set all these values through the inspector. It is similar to BodyAuthoring.

public class WorldAuthoring : MonoBehaviour
{
    public GameObject BodyPrefab;
    public WorldConfig WorldConfig;

    public class WorldConfigBaker : Baker<WorldAuthoring>
    {
        public override void Bake(WorldAuthoring authoring)
        {
            AddComponent(new BodyPrefab
            {
                Value = GetEntity(authoring.BodyPrefab)
            });
            
            AddComponent(authoring.WorldConfig);
        }
    }
}

Next, we will create a framework for the initialization system.

[BurstCompile]
[UpdateInGroup(typeof(InitializationSystemGroup), OrderLast = true)]
public partial struct InitializingSystem : ISystem
{
    public void OnCreate(ref SystemState state) { }

    public void OnDestroy(ref SystemState state) { }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        try
        {
            InitializeWorld(ref state);
        }
        finally
        {
            state.Enabled = false;
        }
    }
}

I've moved all the initialization logic to the InitializeWorld() method. Initialization should only happen once, and then we need to disable this system. If an exception occurs during initialization, the system should still shut down. To ensure this, I wrapped InitializeWorld() in a try block and added system shutdown to the finally block.

Next, we'll proceed with the initialization itself. This involves accessing WorldConfig and BodyPrefab.

var worldConfig = SystemAPI.GetSingleton<WorldConfig>();
var bodyPrefab = SystemAPI.GetSingleton<BodyPrefab>();

We will now create the desired number of objects and prefabs.

state.EntityManager.Instantiate(bodyPrefab.Value, worldConfig.BodyCount, Allocator.Temp);

We will calculate the square root of the radius of the sphere within which the objects will be created in advance. We will need this value later.

var sqrtWorldRadius = math.pow(worldConfig.WorldRadius, 1f/3f);

Now we need to set the initial parameters for the created objects. This can be done in parallel using an Initializing Job. There are several types of Jobs, which you can read more about here: https://docs.unity3d.com/Packages/[email protected]/manual/scheduling-jobs-extensions.html. In this case, it is most convenient to use IJobEntity, which allows us to specify the following parameters in the Execute method:

  • [EntityIndexInQuery] int index - the index of the entity,
  • Entity entity - the entity itself,
  • ref <SomeComponentData> readWriteComponent - a component of the entity for reading and writing,
  • in <SomeComponentData> readOnlyComponent - a read-only component.

We will only need the following arguments.

[BurstCompile]
public partial struct InitializingJob : IJobEntity
{
    [BurstCompile]
    private void Execute(
        [EntityIndexInQuery] int index,
        ref LocalTransform transform,
        ref Mass mass,
        ref PhysicsVelocity velocity)
    {
        // TODO: write the initialization of a body.
    }
}

Let's add the WorldConfig, SqrtWorldRadius, and MassToScaleRatio fields to the structure, which will contain the necessary data for initializing each object.

[BurstCompile]
public partial struct InitializingJob : IJobEntity
{
    public WorldConfig WorldConfig;
    public float SqrtWorldRadius;
    public float MassToScaleRatio;

    [BurstCompile]
    private void Execute(
        [EntityIndexInQuery] int index,
        ref LocalTransform transform,
        ref Mass mass,
        ref PhysicsVelocity velocity)
    {
       // TODO: write the initialization of a body.
    }
}

Now let's write the actual initialization logic.

To initialize Random, we will use the entity index. Since 0 cannot be used for initialization, we will add 1.

var random = new Random();
random.InitState((uint)index + 1u);

Let's set a random mass.

mass.Value = random.NextFloat(WorldConfig.StartMassRange.x, WorldConfig.StartMassRange.y);

Let's write a utility for converting mass to scale. I will explain its purpose in more detail later to avoid distractions.

public struct Scaling
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static float MassToScale(float mass, float massToScaleRatio)
    {
        return math.pow(6 * mass / math.PI, 1/3f) * massToScaleRatio;
    }
}

We will now set the scale of the object based on its mass, which will adjust the size of the celestial body.

transform.Scale = Scaling.MassToScale(mass.Value, MassToScaleRatio);

Creating a uniform random distribution of points inside a sphere is a complex topic that we don't need to delve into deeply. The area of the sphere is calculated as S = 4 * Pi * R^2, which means that as the radius increases, the area grows exponentially. To ensure that the number of objects also increases exponentially as we move away from the center of the sphere, we will adjust the number of objects accordingly.

var radius = WorldConfig.WorldRadius - math.pow(random.NextFloat(SqrtWorldRadius), 2);

Fortunately, the Random class already has the method nextFloat3Direction(), which returns a random point on a sphere with a unit radius. This allows us to set the position of the celestial body as follows.

var position = random.NextFloat3Direction() * radius;
transform.Position = position;

Now let's set a random velocity. The process for setting the speed is standard.

var speed = random.NextFloat(WorldConfig.StartSpeedRange.x, WorldConfig.StartSpeedRange.y);

However, we will set the direction in a way that causes the objects to rotate around the sphere. To do this, we simply rotate the position vector and set a value for the vertical velocity (y).

var direction = math.normalize(new float3(-position.z, position.y / 2, position.x));

Finally, we set the speed directly.

velocity.Linear = speed * direction;

In the end, we obtained the following structure.

[BurstCompile]
public partial struct InitializingJob : IJobEntity
{
    public WorldConfig WorldConfig;
    public float SqrtWorldRadius;
    public float MassToScaleRatio;

    [BurstCompile]
    private void Execute(
        [EntityIndexInQuery] int index,
        ref LocalTransform transform,
        ref Mass mass,
        ref PhysicsVelocity velocity)
    {
        var random = new Random();
        random.InitState((uint)index + 1u);

        // Set random mass and scale (size).
        mass.Value = random.NextFloat(WorldConfig.StartMassRange.x, WorldConfig.StartMassRange.y);
        transform.Scale = Scaling.MassToScale(mass.Value, MassToScaleRatio);

        // Set a random position.
        var radius = WorldConfig.WorldRadius - math.pow(random.NextFloat(SqrtWorldRadius), 2);
        var position = random.NextFloat3Direction() * radius;
        transform.Position = position;

        // Set random velocity.
        var speed = random.NextFloat(WorldConfig.StartSpeedRange.x, WorldConfig.StartSpeedRange.y);
        var direction = math.normalize(new float3(-position.z, position.y / 2, position.x));
        velocity.Linear = speed * direction;
    }
}

We will write the initialization of the Job we just created and schedule its parallel execution in the initialization system.

[BurstCompile]
[UpdateInGroup(typeof(InitializationSystemGroup), OrderLast = true)]
public partial struct InitializingSystem : ISystem
{
    public void OnCreate(ref SystemState state) { }

    public void OnDestroy(ref SystemState state) { }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        try
        {
            InitializeWorld(ref state);
        }
        finally
        {
            state.Enabled = false;
        }
    }

    [BurstCompile]
    private void InitializeWorld(ref SystemState state)
    {
        var worldConfig = SystemAPI.GetSingleton<WorldConfig>();
        var bodyPrefab = SystemAPI.GetSingleton<BodyPrefab>();
        state.EntityManager.Instantiate(bodyPrefab.Value, worldConfig.BodyCount, Allocator.Temp);
        var sqrtWorldRadius = math.pow(worldConfig.WorldRadius, 1f/3f);

        var job = new InitializingJob
        {
            WorldConfig = worldConfig,
            SqrtWorldRadius = sqrtWorldRadius,
            MassToScaleRatio = worldConfig.MassToScaleRatio
        };

        job.ScheduleParallel();
    }
}

With that, the first part is complete. When the application is launched, celestial bodies are created within a given radius and begin to fly in different directions. It looks good.

Attracting system

Let's create a Job for attracting. Each celestial body in the system needs to interact with all the others. We will add the following fields to this structure: arrays with all the masses, positions, and entities of all objects on the scene. These will be Native Arrays, which are collections that do not allocate space in the managed heap. Information about collection objects for them is stored in the unmanaged heap, and they represent structures themselves.

We will also add other fields with the values necessary for calculating the interaction. In the Execute() code, we will only add an index to understand which entity we are currently working with, PhysicsVelocity to write the new speed, and Collision to register a read-only collision with Mass, as collisions will be processed in another system.

The resulting frame looks like this:

[BurstCompile]
public partial struct AttractingJob : IJobEntity
{
    [ReadOnly] public int BodyCount;
    [ReadOnly] public NativeArray<Mass> Masses;
    [ReadOnly] public NativeArray<LocalTransform> Transforms;
    [ReadOnly] public NativeArray<Entity> Entities;
    [ReadOnly] public float DeltaTime;
    [ReadOnly] public float MinDistance;
    [ReadOnly] public float GravitationalConstant;
    [ReadOnly] public float CollisionFactor;
    
    [BurstCompile]
    private void Execute(
        [EntityIndexInQuery] int index,
        ref PhysicsVelocity velocity,
        ref Collision collision,
        in Mass mass)
    { 
        // TODO: write interactions.
    }
}

Next, we will create a variable to record the total force of attraction from other objects:

var force = float3.zero;

For convenience, we will retrieve the position of the object being processed:

var position = Transforms[index].Position;

We will reset the collision value:

collision = new Collision();

Since we introduced a limit of one collision at a time for optimization purposes, we will introduce a hash collision to avoid redundant checks.

var hasCollision = false;

We will create a framework for the loop where we will iterate through all the other objects:

for (var i = 0; i < BodyCount; i++)
{

}

Every time in the loop, we check that the current object is not the same as the one being processed:

if (index == i)
{
    continue;
}

We calculate the distance between the objects and check that the distance is not less than the acceptable minimum for the calculation:

var distance = math.distance(position, Transforms[i].Position);
var permittedDistance = math.max(distance, MinDistance);

We calculate the gravitational force from the object and add it to the rest:

force += GravitationalConstant * mass.Value * Masses[i].Value * (Transforms[i].Position - position)
/ (permittedDistance * permittedDistance * permittedDistance);

Next, we perform a collision check. We also check with objects whose index is larger than the current one, as objects with a smaller index may have already done this check. We will move the calculations themselves to another Hash collision() method. If a collision occurs, we register it:

if (hasCollision || i < index || !HasCollision(distance, index, i))
{
    continue;
}

hasCollision = true;
collision.Entity = Entities[i];

After passing through all the objects, we register a change in the current speed of the object.

velocity.Linear += DeltaTime / mass.Value * force;

As a result, the following method was obtained:

[BurstCompile]
private void Execute(
    [EntityIndexInQuery] int index,
    ref PhysicsVelocity velocity,
    ref Collision collision,
    in Mass mass)
{
    var force = float3.zero;
    var position = Transforms[index].Position;
    collision = new Collision();
    var hasCollision = false;

    for (var i = 0; i < BodyCount; i++)
    {
        if (index == i)
        {
            continue;
        }
        
        var distance = math.distance(position, Transforms[i].Position);
        var permittedDistance = math.max(distance, MinDistance);
        
        force += GravitationalConstant * mass.Value * Masses[i].Value * (Transforms[i].Position - position) 
                 / (permittedDistance * permittedDistance * permittedDistance);

        if (hasCollision || i < index || !HasCollision(distance, index, i))
        {
            continue;
        }

        hasCollision = true;
        collision.Entity = Entities[i];
    }
    
    velocity.Linear += DeltaTime / mass.Value * force;
}

Now let's write a method for detecting collisions. I don't want to spend too much time on it.

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private bool HasCollision(float distance, int i, int j)
{
    var leftScale = Transforms[i].Scale;
    var rightScale = Transforms[j].Scale;
    
    if (distance < (leftScale + rightScale) * CollisionFactor)
    {
        return true;
    }
    
    var (maj, min) = leftScale > rightScale ? (leftScale, rightScale) : (rightScale, leftScale);
    return distance < maj / 2 - min;
}

Next, let's write a collision detection method. If you have any questions about it, I will be happy to answer them in the comments. Here is the complete code for the Job:

[BurstCompile]
public partial struct AttractingJob : IJobEntity
{
    [ReadOnly] public int BodyCount;
    [ReadOnly] public NativeArray<Mass> Masses;
    [ReadOnly] public NativeArray<LocalTransform> Transforms;
    [ReadOnly] public NativeArray<Entity> Entities;
    [ReadOnly] public float DeltaTime;
    [ReadOnly] public float MinDistance;
    [ReadOnly] public float GravitationalConstant;
    [ReadOnly] public float CollisionFactor;
    
    [BurstCompile]
    private void Execute(
        [EntityIndexInQuery] int index,
        ref PhysicsVelocity velocity,
        ref Collision collision,
        in Mass mass)
    {
        var force = float3.zero;
        var position = Transforms[index].Position;
        collision = new Collision();
        var hasCollision = false;

        for (var i = 0; i < BodyCount; i++)
        {
            if (index == i)
            {
                continue;
            }
            
            var distance = math.distance(position, Transforms[i].Position);
            var permittedDistance = math.max(distance, MinDistance);
            
            force += GravitationalConstant * mass.Value * Masses[i].Value * (Transforms[i].Position - position) 
                     / (permittedDistance * permittedDistance * permittedDistance);

            if (hasCollision || i < index || !HasCollision(distance, index, i))
            {
                continue;
            }

            hasCollision = true;
            collision.Entity = Entities[i];
        }
        
        velocity.Linear += DeltaTime / mass.Value * force;
    }
    
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private bool HasCollision(float distance, int i, int j)
    {
        var leftScale = Transforms[i].Scale;
        var rightScale = Transforms[j].Scale;
        
        if (distance < (leftScale + rightScale) * CollisionFactor)
        {
            return true;
        }
        
        var (maj, min) = leftScale > rightScale ? (leftScale, rightScale) : (rightScale, leftScale);
        return distance < maj / 2 - min;
    }
}

Now let's write the system itself, which will fill in and run this Job. To get arrays with data about objects, we will need a query. Add it to the field and initialize it at the start of the system. Also, after the completion of the Job, we must complete the massage. We get such a system.

[BurstCompile]
[UpdateInGroup(typeof(FixedStepSimulationSystemGroup))]
public partial struct AttractingSystem : ISystem
{
    private EntityQuery _query;

    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        _query = new EntityQueryBuilder(Allocator.TempJob)
            .WithAll<LocalTransform, Mass>()
            .Build(ref state);
    }

    public void OnDestroy(ref SystemState state) { }
    
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var worldConfig = SystemAPI.GetSingleton<WorldConfig>();
        var masses = _query.ToComponentDataArray<Mass>(Allocator.TempJob);
        var transforms = _query.ToComponentDataArray<LocalTransform>(Allocator.TempJob);
        var entities = _query.ToEntityArray(Allocator.TempJob);
        
        var job = new AttractingJob
        {
            BodyCount = _query.CalculateEntityCount(),
            Masses = masses,
            Transforms = transforms,
            Entities = entities,
            DeltaTime = SystemAPI.Time.fixedDeltaTime,
            MinDistance = worldConfig.MinDistanceToAttract,
            GravitationalConstant = worldConfig.GravitationalConstant,
            CollisionFactor = worldConfig.CollisionFactor
        };

        job.ScheduleParallel();
        state.Dependency.Complete();
        masses.Dispose();
        transforms.Dispose();
    }
}

Now, we not only create objects, but also attract each other. However, due to the lack of collision processing, the objects simply swarm around each other.

Collision Handling System

Now, you need to create a system that will handle registered collisions between celestial bodies. The characteristics of two colliding celestial bodies will be combined into a single object, and we will deactivate the second celestial body.

Because the same celestial bodies may participate in different collisions, we cannot process them in parallel. Therefore, we will loop through all the celestial bodies sequentially and check if they have registered collisions. If so, we will process them in the Collision Handling System.

To access the components belonging to other celestial bodies in this system, we can use ComponentLookup<T>. We will need to access the mass, speed, and position of the celestial bodies.

private ComponentLookup<Mass> _massLookup;
private ComponentLookup<LocalTransform> _transformLookup;
private ComponentLookup<PhysicsVelocity> _velocityLookup;

Now initialize the data fields at system startup.

_massLookup = SystemAPI.GetComponentLookup<Mass>();
_transformLookup = SystemAPI.GetComponentLookup<LocalTransform>();
_velocityLookup = SystemAPI.GetComponentLookup<PhysicsVelocity>();

We have obtained such a class so far.

[BurstCompile]
[UpdateInGroup(typeof(FixedStepSimulationSystemGroup))]
[UpdateAfter(typeof(AttractingSystem))]
public partial struct CollisionHandlingSystem : ISystem
{
    private ComponentLookup<Mass> _massLookup;
    private ComponentLookup<LocalTransform> _transformLookup;
    private ComponentLookup<PhysicsVelocity> _velocityLookup;

    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        _massLookup = SystemAPI.GetComponentLookup<Mass>();
        _transformLookup = SystemAPI.GetComponentLookup<LocalTransform>();
        _velocityLookup = SystemAPI.GetComponentLookup<PhysicsVelocity>();
    }

    public void OnDestroy(ref SystemState state) { }
    
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        // TODO: Handle collisions.
    }
}

Now, we will perform all the processing in the OnUpdate() method. First, you need to update the ComponentLookup objects.

_massLookup.Update(ref state);
_transformLookup.Update(ref state);
_velocityLookup.Update(ref state);

We will also retrieve the parameter MassToScaleRatio, because we will need to modify a large number of objects.

var massToScaleRatio = SystemAPI.GetSingleton<WorldConfig>().MassToScaleRatio;

We will iterate through the objects using a foreach loop, which we will obtain using SystemAPI.Query().

foreach (var (velocityRef, massRef, transformRef, collisionsRef) 
         in SystemAPI.Query<RefRW<PhysicsVelocity>, RefRW<Mass>, RefRW<LocalTransform>, RefRO<Collision>>())
{
    // TODO: Handle the body collision.
}

In the case of a collision, we will transfer the mass of one object to another. If an object has a mass of zero during iteration, it means it has already been absorbed by another object. If there is no collision, we will move on to the next object.

var anotherBodyEntity = collisionsRef.ValueRO.Entity;
var m1 = massRef.ValueRO.Value;

if (m1 <= 0 || anotherBodyEntity == Entity.Null)
{
    continue;
}

If the mass of the second object is zero, it means it has already collided with another celestial body. In this case, we will not handle the collision.

var anotherMassRef = _massLookup.GetRefRW(anotherBodyEntity, false);
var m2 = anotherMassRef.ValueRO.Value;

if (m2 <= 0)
{
    continue;
}

Next, we will combine the masses, velocities, and positions of the two objects into a single object. We will reset the mass of the second object to zero. The more mass the object has, the more influence it will have on the final result.

var x1 = transformRef.ValueRO.Position;
var v1 = velocityRef.ValueRO.Linear;
var x2 = _transformLookup.GetRefRO(anotherBodyEntity).ValueRO.Position;
var v2 = _velocityLookup.GetRefRO(anotherBodyEntity).ValueRO.Linear;

x1 = (x1 * m1 + x2 * m2) / (m1 + m2);
v1 = (v1 * m1 + v2 * m2) / (m1 + m2);
m1 += m2;
anotherMassRef.ValueRW.Value = 0;

velocityRef.ValueRW.Linear = v1;
massRef.ValueRW.Value = m1;

transformRef.ValueRW = new LocalTransform
{
    Position = x1,
    Rotation = quaternion.identity,
    Scale = Scaling.MassToScale(m1, massToScaleRatio)
};

We ended up with such a class.

[BurstCompile]
[UpdateInGroup(typeof(FixedStepSimulationSystemGroup))]
[UpdateAfter(typeof(AttractingSystem))]
public partial struct CollisionHandlingSystem : ISystem
{
    private ComponentLookup<Mass> _massLookup;
    private ComponentLookup<LocalTransform> _transformLookup;
    private ComponentLookup<PhysicsVelocity> _velocityLookup;

    [BurstCompile]
    public void OnCreate(ref SystemState state)
    {
        _massLookup = SystemAPI.GetComponentLookup<Mass>();
        _transformLookup = SystemAPI.GetComponentLookup<LocalTransform>();
        _velocityLookup = SystemAPI.GetComponentLookup<PhysicsVelocity>();
    }

    public void OnDestroy(ref SystemState state) { }
    
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        _massLookup.Update(ref state);
        _transformLookup.Update(ref state);
        _velocityLookup.Update(ref state);
        var massToScaleRatio = SystemAPI.GetSingleton<WorldConfig>().MassToScaleRatio;

        foreach (var (velocityRef, massRef, transformRef, collisionsRef) 
                 in SystemAPI.Query<RefRW<PhysicsVelocity>, RefRW<Mass>, RefRW<LocalTransform>, RefRO<Collision>>())
        {
            var anotherBodyEntity = collisionsRef.ValueRO.Entity;
            var m1 = massRef.ValueRO.Value;

            if (m1 <= 0 || anotherBodyEntity == Entity.Null)
            {
                continue;
            }

            var anotherMassRef = _massLookup.GetRefRW(anotherBodyEntity, false);
            var m2 = anotherMassRef.ValueRO.Value;

            if (m2 <= 0)
            {
                continue;
            }
                
            var x1 = transformRef.ValueRO.Position;
            var v1 = velocityRef.ValueRO.Linear;
            var x2 = _transformLookup.GetRefRO(anotherBodyEntity).ValueRO.Position;
            var v2 = _velocityLookup.GetRefRO(anotherBodyEntity).ValueRO.Linear;

            x1 = (x1 * m1 + x2 * m2) / (m1 + m2);
            v1 = (v1 * m1 + v2 * m2) / (m1 + m2);
            m1 += m2;
            anotherMassRef.ValueRW.Value = 0;

            velocityRef.ValueRW.Linear = v1;
            massRef.ValueRW.Value = m1;

            transformRef.ValueRW = new LocalTransform
            {
                Position = x1,
                Rotation = quaternion.identity,
                Scale = Scaling.MassToScale(m1, massToScaleRatio)
            };
        }
    }
}

Body Disabling System

This system is quite simple. We will write a job that will disable an object with a mass of zero.

[BurstCompile]
public partial struct BodyDisablingJob : IJobEntity
{
    public EntityCommandBuffer.ParallelWriter CommandBuffer;

    [BurstCompile]
    private void Execute([EntityIndexInQuery] int index, in Entity entity, in Mass mass)
    {
        if (mass.Value <= 0)
        {
            CommandBuffer.SetComponentEnabled<Mass>(index, entity, false);
        }
    }
}

We will create a system that will schedule this Job.

[BurstCompile]
[UpdateInGroup(typeof(FixedStepSimulationSystemGroup))]
[UpdateAfter(typeof(CollisionHandlingSystem))]
public partial struct BodyDisablingSystem : ISystem
{
    public void OnCreate(ref SystemState state) { }
    
    public void OnDestroy(ref SystemState state) { }

    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        var cbs = SystemAPI.GetSingleton<BeginSimulationEntityCommandBufferSystem.Singleton>();
        var commandBuffer = cbs.CreateCommandBuffer(state.WorldUnmanaged).AsParallelWriter();

        var job = new BodyDisablingJob
        {
            CommandBuffer = commandBuffer
        };

        job.ScheduleParallel();
    }
}

Conclusion

In this example, we have demonstrated how Unity DOTS 1.0 can be used to efficiently handle a large number of interactions between celestial bodies. We started by explaining the benefits of using DOTS for multithreaded parallel computing, and then outlined the components and prefabs needed for our celestial bodies, as well as the systems that would handle attracting, collision handling, and disabling inactive celestial bodies. However, it is important to note that Unity DOTS 1.0 is not limited to this specific application – it can be used for a wide range of tasks that require efficient parallel computing. We hope that this example has provided a useful illustration of the capabilities of Unity DOTS 1.0. Thank you for reading!


Written by deniskondratev | Software Engineer / С++ / C# / Unity / Game Developer
Published by HackerNoon on 2023/01/02