Derby Case Study : Unity ECS For Small Things
Updated 8 months ago
I've been building a small racing game, Derby, to explore the use cases of Unity's Entity Component System. While Unity's ECS is much more advanced than the current C++ ECS framework that I'm used to, OpenECS, the style and thinking remains relatively the same.
  • What is the minimum amount of data you need?
  • Where do you read this data from?
  • Where do you write this data to?
  • What do you do with this data?
A lot of the use cases I've seen on Twitter, Unity Connect, and the forums relate to somewhat massive projects, but a lot of the comments I hear in person from developers who are familiar to Unity relate to:
  • My game isn't massive, so why would I need to switch from MonoBehaviours to ECS?

Derby's Use Case

While, I think Unity has marketed ECS as a good solution for massive games, it has it's uses in small games. Derby is by no means a massive game right now, in fact it's still in the early phases of development, building the infrastructure. But, over the past few weeks I've been able to dish out a few features of the game using ECS:
  1. VehicleSystem(s)
  2. A CatmullRomSplineSamplingSystem to determine relative position of the vehicle on a track (we'll focus on this)
When I think about a racing game that uses physics, I typically think of using rigidbodies and an input system to read controller data. Now, this is can easily be done using a RigidBody component and a few MonoBehaviour scripts, but I would lose the scale, efficiency, and flexibility to:
  • estimate accurately
  • filter and manipulate data associated to vehicles (e.g. status debuffs specific to some vehicles, area affects like oil spills, etc)

Estimating Accurately: The Problem

When we watch a race, the easiest way to determine visually who is in nth place of the race is, whomever is ahead. The first person to cross the finish line is in 1st place, the 2nd is in second place, and so forth. The very same concept can be applied in a digital racing game.
We can add a collider or a raycast for each checkpoint and just let the component attempt to hit a car that passes the checkpoint. Whoever finishes x laps wins the race and is first. But, what if I wanted to tell in realtime which place a player is in? In long stretches of laps where there is no checkpoint, any single player can be in nth place. If car A is ahead of car B between checkpoints 1 and 2, then car A is first. So the problem became,
How can I tell who is in whatever place in real time in Derby?
A solution I saw online from various people on Unity Answers was to make more "invisible" colliders along the path and just see whoever hits whatever collider first. But, that seems more of an over engineered solution, as I'm encouraging myself to create more data to solve what is actually, a small problem. By thinking of the implications of the already available data, the solution isn't so difficult.
  1. We have checkpoints, therefore we can assume there is some kind of path.
  2. We have the position of the vehicle. Cool, that's all we need!
The reality is that, if the track is wide, no person racing will seriously stick to a single path. So, we need to flatten a wide range of potential vehicle positions along a single path and determine their relative positions.
Thus, estimating accurately is one of most important functionalities in solving this problem and this is where ECS is extremely helpful.

Estimating Accurately: The Solution

So my tracks are usually built using a Catmull Rom Spline. A position along the spline is defined by a parametric value t (if you're unfamiliar with this, just think of it like the Mathf.Lerp function you find in Unity). t is typically a value between 0 and 1 and by determining the vehicle's relative position, we can associate the value t to the vehicle and just sort from greatest to smallest value to have an accurate representation of placement order.
Now where is accuracy in all of this?
Well, to determine t along a spline, I need a set of points. If there are four checkpoints, then that's enough to determine the general shape of the spline. But of course, I need to sample between each point, so each segment is broken up into smaller segments.
I get the position of the vehicle, the direction between the vehicle and a point on the spline, and I calculate the angle between binormal of the spline. The angle closest to 0 represents the possible position along the spline. Now with 10 line segments, between each checkpoint, it's not very accurate to determine t. With two vehicles relatively close to each other, an angle of approximately 4° and 5° would probably fall to the same value of t. Not ideal, so we sample more points along the spline for a degree of higher accuracy.
Now imagine if we did this in an OOD and in Unity, on a single thread. If we have a sample size of 400, and 2 cars and we implement this algorithm naively, we potentially iterate through 400 elements for each car; that's 800 elements! Add in AI and we then sample x number of cars * m sample points (where m and x are both positive integers).
Of course, we can write checks to try and narrow down our search results by finding the lower and upper bound of the most likely value of t. But if you're going for the naive approach, so you can move onto other tasks, then you'll need to scale down and solve the issue.
Note: Of course it's not to say that even when using MonoBehaviours you can't thread, you can most certainly thread some things that are thread safe in the UnityEngine namespace or at the very least, thread your own functions.

Using Unity ECS

Doing this in ECS is actually quite simple. We need to store control points and cache a table of sampled points, so we can compute the angles. Now 800 points of iteration isn't extremely large but it might as soon as vehicles tend to get more complex. In Unity's ECS we can certainly iterate through this much more easily.
Majority of your data must be using value based data. Take a look at the MSDN's documentation on blittable types, but in a nutshell, they're mainly primitives with the exception of a few, like bools and chars. We can collect and iterate through each sample on separate threads through the job system.
But here's some actual code to see how this kind of potential is achieved. The job below implements a naive algorithm of finding the min, we can always improve it later to look for a upper and lower bound and narrow down the search results.
Note: The code below is not the complete script as this wasn't meant to be a full fledged programming tutorial.
struct PotentialAngleCollectionJob : IJobProcessComponentData<Position, PlayerId> { [ReadOnly] public float constraint; [ReadOnly] public NativeArray<float3> splinePoints; [ReadOnly] public NativeArray<float3> binormals; [WriteOnly] public NativeArray<ValueTuple<int, float>> table; #if UNITY_EDITOR [WriteOnly] public NativeArray<float3> direction; #endif public void Execute(ref Position position, ref PlayerId id) { var prevAngle = float.MaxValue; for (int i = 0; i < splinePoints.Length; i++) { var binormal = binormals[i]; var dir = position.Value - splinePoints[i]; // This is a custom function I built which compute the angles given two vectors // using Unity's new Math lib. var angleLHS = VectorUtils.Angle(binormal, dir); if (angleLHS < constraint && angleLHS < prevAngle) { var t = ((float)i) / splinePoints.Length; table[id.value] = Tuple.Create(id.value, (float)angleLHS); #if UNITY_EDITOR direction[id.value] = dir; #endif prevAngle = (float)angleLHS; } } } } // In JobHandle OnUpdate(JobHandle deps) function, we can simply schedule this job and let it run. // The sampled points (splinePoints and binormals) can simply be retrieved from other // components/chunks. protected override JobHandle OnUpdate(JobHandle inputDeps) { var table = new NativeArray<ValueTuple<int, float>>(playerLength, Allocator.TempJob); inputDeps = new PotentialAngleCollectionJob { constraint = constrain, splienPoints = splinePoints, binormals = binormals, table = table }.Schedule(this, inputDeps); inputDeps.Complete(); // ... Dispose all of the native containers return inputDeps; }
With that said, a lot of this understanding and potential came from simply changing the way you think about data. Objects are convenient and simple, but if we imagined a look up table (like a spreadsheet), we can understand how memory should be laid out and how we can access it.
New versions of ECS allow chunk iteration, dynamic buffers, LODs and much more that potentially solve some of the problems that, we as game developers and designers run into. We're limited by the hardware we build upon, but understanding a much lower level approach allows us to leverage the hardware we have and take advantage of it. With that said, it's worth gradually delving into Unity's ECS even for small projects and smaller problems.
You can grab the spline tool here:
Porrith Suong
Game Developer/Co-Founder - Programmer