Introduction

A large-scale Particle Life simulation built with Unity DOTS (ECS + Jobs + Burst), focusing on emergent behavior through simple force rules. Thousands to particles interact in real time, forming complex, self-organizing patterns.

The source code is available on GitHub.

Init System

Require the global config components and initialize the random number generator.

1
2
3
4
5
6
public struct ParticleCreateRequestComponent : IComponentData
{
public int Count;
public float2 MinPosition;
public float2 MaxPosition;
}
1
2
3
4
5
6
7
8
9
public struct AttractionMatrixBlob
{
public BlobArray<float> Matrix;
}
public struct ColorConfigComponent : IComponentData
{
public int ColorCount;
public BlobAssetReference<AttractionMatrixBlob> AttractionMatrix;
}
1
2
3
4
5
6
7
8
9
protected override void OnCreate()
{
var seed = (uint)UnityEngine.Random.Range(1, 100000);
Debug.Log("Random seed: " + seed);
_rand = new Unity.Mathematics.Random(seed);

RequireForUpdate<ParticleCreateRequestComponent>();
RequireForUpdate<ColorConfigComponent>();
}

Spawn particles based on the create request, storing their color index, position, and velocity.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
protected override void OnUpdate()
{
var query = World.EntityManager.CreateEntityQuery(typeof(ParticleCreateRequestComponent));
if (query.CalculateEntityCount() == 0)
return;

var requestEntity = query.GetSingletonEntity();
var request = SystemAPI.GetComponent<ParticleCreateRequestComponent>(requestEntity);

var entityManager = World.DefaultGameObjectInjectionWorld.EntityManager;
var archetype = entityManager.CreateArchetype(typeof(Particle), typeof(LocalToWorld));

var colorCount = SystemAPI.GetSingleton<ColorConfigComponent>().ColorCount;
for (var i = 0; i < request.Count; i++)
{
var e = entityManager.CreateEntity(archetype);

var pos = _rand.NextFloat2(request.MinPosition, request.MaxPosition);
entityManager.SetComponentData(e,
new Particle { ColorIndex = _rand.NextInt(0, colorCount), Position = pos, Velocity = float2.zero });
entityManager.SetComponentData(e, new LocalToWorld { Value = float4x4.Translate(new float3(pos, 0)) });
}

entityManager.DestroyEntity(requestEntity);
}
1
2
3
4
5
6
public struct Particle : IComponentData
{
public int ColorIndex;
public float2 Position;
public float2 Velocity;
}

Movement System

Read the global config component to update particle positions based on their velocities.

Handle boundary conditions if enabled.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct ParticleMovementSystem : ISystem
{
[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<ParticleSimulationConfigComponent>();
state.RequireForUpdate<BoundaryConfigComponent>();
}

[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var simulationComponent = SystemAPI.GetSingleton<ParticleSimulationConfigComponent>();
if (!simulationComponent.SimulationEnabled) return;

var boundaryComponent = SystemAPI.GetSingleton<BoundaryConfigComponent>();
var job = new MovementJob { DeltaTime = SystemAPI.Time.DeltaTime, BoundaryComponent = boundaryComponent };
job.ScheduleParallel();
}

[BurstCompile]
private partial struct MovementJob : IJobEntity
{
public float DeltaTime;
public BoundaryConfigComponent BoundaryComponent;

private void Execute(ref Particle particle, ref LocalToWorld localToWorld)
{
var pos = particle.Position;
pos += particle.Velocity * DeltaTime;

if (BoundaryComponent.BoundaryEnabled)
{
var min = BoundaryComponent.MinPosition;
var max = BoundaryComponent.MaxPosition;

var width = max.x - min.x;
var height = max.y - min.y;

pos.x = min.x + math.fmod((pos.x - min.x + width), width);
pos.y = min.y + math.fmod((pos.y - min.y + height), height);
}

particle.Position = pos;
localToWorld.Value = float4x4.Translate(new float3(pos, 0));
}
}
}

Force System

Create force jobs to calculate inter-particle forces based on their color indices and positions.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
[UpdateInGroup(typeof(SimulationSystemGroup))]
public partial struct ForceSystem : ISystem
{
private EntityQuery _particleQuery;

[BurstCompile]
public void OnCreate(ref SystemState state)
{
state.RequireForUpdate<ColorConfigComponent>();
state.RequireForUpdate<ParticleSimulationConfigComponent>();
state.RequireForUpdate<BoundaryConfigComponent>();

_particleQuery = SystemAPI.QueryBuilder().WithAll<Particle>().Build();
}

[BurstCompile]
public void OnUpdate(ref SystemState state)
{
var simulationComponent = SystemAPI.GetSingleton<ParticleSimulationConfigComponent>();
if (!simulationComponent.SimulationEnabled) return;

var colorComponent = SystemAPI.GetSingleton<ColorConfigComponent>();
var boundaryComponent = SystemAPI.GetSingleton<BoundaryConfigComponent>();

var particles = _particleQuery.ToComponentDataArray<Particle>(Allocator.TempJob);
var colorIndices = new NativeArray<int>(particles.Length, Allocator.TempJob);
var positions = new NativeArray<float2>(particles.Length, Allocator.TempJob);
var velocities = new NativeArray<float2>(particles.Length, Allocator.TempJob);
for (var i = 0; i < particles.Length; i++)
{
colorIndices[i] = particles[i].ColorIndex;
positions[i] = particles[i].Position;
velocities[i] = particles[i].Velocity;
}

var deltaTime = SystemAPI.Time.DeltaTime;
var frictionFactor = CalFrictionFactor(deltaTime, simulationComponent.FrictionHalfLife,
simulationComponent.FrictionFactor);
var job = new ForceJob
{
// Single
ColorIndices = colorIndices,
Positions = positions,
Velocities = velocities,

// Global
ColorComponent = colorComponent,
DeltaTime = deltaTime,
FrictionFactor = frictionFactor,
SimulationComponent = simulationComponent,
BoundaryComponent = boundaryComponent
};
job.ScheduleParallel(particles.Length, 64, state.Dependency).Complete();

var index = 0;
foreach (var particle in SystemAPI.Query<RefRW<Particle>>())
{
var p = particle.ValueRO;
p.Velocity = velocities[index];
particle.ValueRW = p;
index++;
}

particles.Dispose();
colorIndices.Dispose();
positions.Dispose();
velocities.Dispose();
}

[BurstCompile]
private struct ForceJob : IJobFor
{
// Single
[ReadOnly] public NativeArray<int> ColorIndices;
[ReadOnly] public NativeArray<float2> Positions;
public NativeArray<float2> Velocities;

// Global
[ReadOnly] public ColorConfigComponent ColorComponent;
[ReadOnly] public float DeltaTime;
[ReadOnly] public float FrictionFactor;
[ReadOnly] public ParticleSimulationConfigComponent SimulationComponent;
[ReadOnly] public BoundaryConfigComponent BoundaryComponent;

public void Execute(int i)
{
var colorIndexA = ColorIndices[i];
var posA = Positions[i];

var totalForceDir = float2.zero;
for (var j = 0; j < Positions.Length; j++)
{
if (i == j) continue;

float2 dir;
if (BoundaryComponent.BoundaryEnabled)
{
GetWrappedDelta(posA, Positions[j], BoundaryComponent.MinPosition,
BoundaryComponent.MaxPosition, out dir);
}
else
{
dir = Positions[j] - posA;
}

var dist = math.length(dir);
if (!(dist < SimulationComponent.MaxAttractionDistance)) continue;

var f = CalForce(dist / SimulationComponent.MaxAttractionDistance,
ColorComponent.AttractionMatrix.Value.Matrix[
colorIndexA * ColorComponent.ColorCount + ColorIndices[j]], 0.3f);
totalForceDir += math.normalizesafe(dir) * f;

// {
// var attrFactor = attraction * math.pow(1 - dist / maxDist, 1);
// var repulsionFactor = -math.pow((maxDist - dist) / (maxDist - radius), 1.0f); // 0.9 and 1.0
//
// totalForceDir += math.normalizesafe(dir) * (attrFactor + repulsionFactor);
// }

// {
// var beta = ConfigComponent.Scale * ConfigComponent.AttractionMiddleUnit;
//
// float factor;
// if (dist <= radius)
// {
// factor = dist / radius - 1;
// }
// else if(dist <= beta)
// {
// factor = attraction * (dist - radius) / (beta - radius);
// }
// else
// {
// factor = attraction * (maxDist - dist) / (maxDist - beta);
// }
// totalForceDir += math.normalizesafe(dir) * factor;
// }
}

Velocities[i] *= FrictionFactor;
Velocities[i] += totalForceDir * (SimulationComponent.ForceStrength * DeltaTime);
}
}

[BurstCompile]
private static float CalFrictionFactor(float deltaTime, float frictionHalfLife, float taylor1)
{
// var frictionFactor = math.pow(0.5f, deltaTime / configComponent.FrictionHalfLife);
var taylor2 = -math.LN2 / frictionHalfLife * taylor1 * (deltaTime - 0.3333333f);
var taylor3 = -math.LN2 / frictionHalfLife * taylor2 * (deltaTime - 0.3333333f) / 2;
return taylor1 + taylor2 + taylor3;
}

[BurstCompile]
private static float CalForce(float r, float a, float beta)
{
if (r < beta)
{
return r / beta - 1;
}

if (r > beta && r < 1)
{
return a * (1 - math.abs(2 * r - 1 - beta) / (1 - beta));
}

return 0.0f;
}

[BurstCompile]
private static void GetWrappedDelta(in float2 a, in float2 b, in float2 min, in float2 max, out float2 result)
{
var size = max - min;
var delta = b - a;

if (delta.x > size.x / 2f) delta.x -= size.x;
else if (delta.x < -size.x / 2f) delta.x += size.x;

if (delta.y > size.y / 2f) delta.y -= size.y;
else if (delta.y < -size.y / 2f) delta.y += size.y;

result = delta;
}
}