I started dabbling with Unity’s DOTS ever since it came out in 2018. I have used every evolution of its API from [Inject] to IJobForEach then to job structs (chunk iteration) and Entities.ForEach(). There’s even a new one now, IJobEntity (haven’t explored it yet). To me, job structs are the best. They’re more verbose but they pay that back in more staying power. That’s very good to me maintainability wise. No matter how other APIs change, job structs remain the same. In this post, I’ll enumerate the reasons why I think job structs are better than the other dominant API which is Entities.ForEach().
What job structs?
Job structs I mean here are those structs that implement either IJob, IJobEntityBatch, IJobEntityBatchWithIndex, and other IJob* interfaces that doesn’t result to code generation. What you write is what you get. On the other hand, Entities.ForEach() is a magical API that makes it possible to write entity or component iteration with less code but with some limitations and caveats. What it does in the end is it generates the complete C# code equivalent which then gets compiled normally. This complete C# code equivalent uses job structs! I surmise that the new API IJobEntity is the same. So if you know how to use job structs, you basically know the fundamental building blocks of any magical DOTS API.
Here’s an Entities.ForEach() example:
public partial class ForEachMovementSystem : SystemBase {
protected override void OnUpdate() {
float deltaTime = this.Time.DeltaTime;
this.Entities.ForEach((ref Translation translation, in Movement movement) => {
translation = new Translation() {
Value = translation.Value + (movement.direction * (movement.speed * deltaTime))
};
}).ScheduleParallel();
}
}
And here is its job struct equivalent:
public partial class JobStructMovementSystem : SystemBase {
private EntityQuery query;
protected override void OnCreate() {
this.query = GetEntityQuery(typeof(Translation), typeof(Movement));
}
protected override void OnUpdate() {
UpdateMovementJob job = new UpdateMovementJob() {
translationType = GetComponentTypeHandle<Translation>(),
movementType = GetComponentTypeHandle<Movement>(),
deltaTime = this.Time.DeltaTime
};
this.Dependency = job.ScheduleParallel(this.query, this.Dependency);
}
[BurstCompile]
private struct UpdateMovementJob : IJobEntityBatch {
public ComponentTypeHandle<Translation> translationType;
[ReadOnly]
public ComponentTypeHandle<Movement> movementType;
public float deltaTime;
public void Execute(ArchetypeChunk batchInChunk, int batchIndex) {
NativeArray<Translation> translations = batchInChunk.GetNativeArray(this.translationType);
NativeArray<Movement> movements = batchInChunk.GetNativeArray(this.movementType);
for (int i = 0; i < batchInChunk.Count; ++i) {
Movement movement = movements[i];
Translation translation = translations[i];
// Modify
translations[i] = new Translation() {
Value = translation.Value + (movement.direction * (movement.speed * this.deltaTime))
};
}
}
}
}
It looks scary, right? The job struct has a way lot more code. This is why Entities.ForEach() is more preferred but it does have its pain points. You could say that JobStructMovementSystem looks like the generated code of ForEachMovementSystem.
Flexibility
Job structs are more flexible than Entities.ForEach(). The number of parameters allowed in the lamba passed to the ForEach() method is not infinite. You’re only allowed up to 8 types. Aside from that, order of types also matters. Like if you want to iterate on a set of entity, components, and dynamic buffers, there’s a certain order that you have to follow. You have to specify the entity first, then components, then dynamic buffers. Again, the number of components and dynamic buffers allowed is not infinite. You’re only allowed up to 8.
Job structs don’t have these limitations. You can specify any number of ComponentTypeHandle and BufferTypeHandle and work on them in the Execute() method. Working on 8 or more different types of components or dynamic buffers seems excessive but in a production code, you might hit these limits.
Another limitation that Entities.ForEach() has is code organization. You can only use static methods in it. You can’t use local functions or normal methods that regular structs has. You can’t call member methods from the enclosing system class. This means that you can’t easily refactor to say move some code in a new method. In a complex project, the bodies of the passed lambda are usually big walls of code with nests of if statements and for statements. This is untenable for the long term. This is usually fixed by defining a helper struct which is then used in the lambda body. However, if you do this, you will lose access to the magic methods like GetComponent() and SetComponent(). You will have to resort to using ComponentDataFromEntity and in this case, you might as well use a job struct.
Job structs on the other hand does not have this problem. Since it’s just a struct, you can define methods inside it. You can even define instance methods that has access to the struct’s member variables. Code organization is better when using job structs.
Another problem with Entities.ForEach() is that it can’t be used with generic types. Generics are very important in DOTS production code as they are a vehicle in making reusable code and to have some kind of polymorphism. Note that reference types are not allowed in BurstCompiled code which means no OOP. In other words, your ability to make some kind of a framework or library is stunted without generics.
Job structs don’t have this problem. I have made a vast amount of framework code that uses generics. It’s just marvelous. So if you don’t know how to code using job structs, say bye bye to generics. What a great loss of flexibility.
Reusability
You can’t reuse the bodies of code inside the Entities.ForEach() lambda but you can reuse job structs. Since they’re just structs, you can just instantiate them in any part of your code. If they’re inside a system class, you can extract that to its own C# file, set it to public, and it’s ready for reuse. You can’t do that with code passed to Entities.ForEach() other than to copy it which sets you up for disaster later on.
In our current game in development City Hall Simulator, we have found lots of reusable job structs. Since we prefer using job structs, it’s easy to extract them to be in their separate C# file if they’re nested inside system classes and become part of the game’s library that can be used anywhere. A common occurrence of this is to collect some entities with some properties and put them in a container:
[BurstCompile]
public struct CollectHouseholdsByIncomeLevelJob : IJobEntityBatch {
[ReadOnly]
public EntityTypeHandle entityType;
[ReadOnly]
public ComponentTypeHandle<Household> householdType;
[ReadOnly]
public IncomeLevel.Converter converter;
public NativeMultiHashMap<IncomeLevel, Entity>.ParallelWriter resultMap;
public void Execute(ArchetypeChunk batchInChunk, int batchIndex) {
NativeArray<Entity> entities = batchInChunk.GetNativeArray(this.entityType);
NativeArray<Household> households = batchInChunk.GetNativeArray(this.householdType);
for (int i = 0; i < batchInChunk.Count; ++i) {
Household household = households[i];
IncomeLevel incomeLevel = this.converter.ConvertFromIncomeValue(household.incomeValue);
this.resultMap.Add(incomeLevel, entities[i]);
}
}
}
Not Magic
Job structs are not magic and this is big advantage. There’s no code generation. There are no specific do’s and don’ts to think about. There are no surprising gotchas. Magical APIs like Entities.ForEach() were created for ease of use but the drawback is that they can change and may still change a lot. Their caveats and gotchas change, too. Just recently, Unity released a big update for DOTS. It’s now 0.50 from 0.17. There are some issues found when using Entities.ForEach(). We do use a little Entities.ForEach() in our codebase and there were issues like passing a delegate is no longer allowed. The API is stricter now to just use lambdas.
I could say that our DOTS version update has not been so bumpy as compared to if our code used Entities.ForEach() in majority. I couldn’t imagine the headache of looking for bugs caused by the issues of the updated Entities.ForEach(). Good thing someone enumerated these issues but you still have to look for them and fix them yourself manually. If most of your code uses Entities.ForEach(), you have a lot of places to look into. We dodged this problem because we primarily use job structs.
What is Entities.ForEach() good for?
I’m not saying that Entities.ForEach() is bad. I just don’t think that it should be the primary API to use for code in production. We do use Entities.ForEach() but only for stupidly simple component iteration like the ForEachMovementSystem example. Once it goes out of stupidly simple like using EntityCommandBuffer, GetComponent()/SetComponent(), or using native containers, it gets flagged in code review and must be translated into to a job struct. The rationale is that, if these things are used, what you’re doing is probably more complex than you think and relying on Entities.ForEach() will only get in your way. Not to mention the possible caveats and gotchas that we don’t know about. It’s also cheaper to translate the thing into a job struct while there’s less code compared to when the Entities.ForEach() code has become a monster. Job structs are also easier to update and refactor if you do need more features later on.
Conclusion
These are my reasons for shilling job structs – they are more flexible, more reusable, and has less gotchas. This is why I use them in my DOTS related posts and I’ll continue to use them in my future posts. I am hoping that more devs will get used to using job structs instead of Entities.ForEach(). DOTS usability is already hard as it is and using job structs is even harder. However, their value in production code can’t be denied.
That’s all for now. If you like my posts, please subscribe to my mailing list. I’ll send you a free game if you do.
-Job structs are more flexible than Entities.ForEach(). The number of parameters allowed in the lamba passed to the ForEach() method is not infinite. You’re only allowed up to 8 types.
Actualy you can use more then 8 arguments with help of custom delegates https://docs.unity3d.com/Packages/com.unity.entities@0.17/manual/ecs_entities_foreach.html
LikeLike
I see. That’s cool.
LikeLike
And theoretically there’s no parameter limit when using IJobEntity 🙂
LikeLike