I’m a big fan of Unity’s ECS. I used to doubt it but then I tried it and I saw the huge speed gains when used with the Jobs system and the Burst compiler. I made simple projects to get the hang of it like a simple shooter game and an FSM system. One negative thing about it, though, is that it’s hard to integrate it with a MonoBehaviour based project. Our current game Academia is already so big with lots of interdependent systems. You can’t easily turn a MonoBehaviour component into a blittable struct component which is used in ECS and call it a day. The “hybrid” ECS is one option but it doesn’t really gain you much. If you’re using reference types in your hybrid solution, you can’t leverage the Jobs system and the Burst compiler. That doesn’t mean I didn’t try.

One day while I was optimizing our game, I saw that rendering still takes the biggest pie of the execution. I looked into the code of a Sprite Manager that we’re using which renders sprites in a single draw call. This particular call took a huge time to execute:

void TransformSprites() { for (int i = 0; i < activeBlocks.Count; ++i) { ((Sprite)activeBlocks[i]).Transform(); } ... } // Sprite.Transform() looks like this public void Transform() { meshVerts[mv1] = clientTransform.TransformPoint(v1); meshVerts[mv2] = clientTransform.TransformPoint(v2); meshVerts[mv3] = clientTransform.TransformPoint(v3); meshVerts[mv4] = clientTransform.TransformPoint(v4); ... }

What this clever Sprite Manager does is it maintains its own mesh, transforms the vertices of the sprites it manages and renders this single mesh. This is how it can render thousands of non static sprites in a single draw call. However, it runs slower when the number of sprites gets higher (thousands). That's to be expected of course.

I thought, what if I could turn this manager into pure ECS and use Jobs system and Burst compiler. It will run 1/10 of the current running time. It will be highly scalable. This is exactly the kind of problem that ECS works best. It's also good for practice.

So I began this little side project in July. I worked on it in a separate repository from Academia. I worked on it for thirty minutes to an hour almost every day. It has to support all the features that we're using in the current Sprite Manager before I could integrate it. I added the needed features one by one. Needless to say it was easier than I thought and I had a lot of fun doing it.

In late August, I was able to complete the features. It's time to integrate it to the monster. But before I could do that, I have to benchmark my to see if it's indeed faster than the old implementation.

Benchmark

For context, my PC has a CPU that can run at 3.70GHz, 4 cores. It has 8GB RAM.

I made another scene that simulates the same test that I did for my ECS Sprite Manager but uses the old sprite manager from Unify. I had it run 8000 sprites. Here’s what the editor profiler looks like:



Click here for bigger image.

The vertex transformations are called in LinkedSpriteManager.LateUpdate(). It runs from 4.5ms to 5.0ms.

My ECS Sprite Manager, on the other hand, with the same 8000 sprites looks like this:



Click here for bigger image.

The transformation system runs around 0.9ms to 1.0ms. The transformation running time takes around 1/5 of the original implementation. I couldn’t be more happier than that. Even when I doubled the sprites to 16,000, the running time only hovers around 1.8ms to 2.0ms.

Integration

My plan was to use GameObjectEntity to the game objects where the sprites are attached. I also used a MonoBehaviour component wrapper that adds my ECS Sprite component to such entity. This way, they will be accessible in the ECS world and my own sprite manager systems can do their magic.

This is the same vertex transformation but done in ECS with Jobs and Burst compiled.

public class SpriteManagerGameObjectTransformSystem : JobComponentSystem { private struct Data { public readonly int Length; public ComponentDataArray Sprite; public TransformAccessArray TransformAccess; // We only collect non static sprites so we reduce computation [ReadOnly] public SubtractiveComponent Static; } [Inject] private Data data; [BurstCompile] private struct Job : IJobParallelForTransform { public ComponentDataArray sprites; public void Execute(int index, TransformAccess transform) { Sprite sprite = this.sprites[index]; float4x4 matrix = new float4x4(transform.rotation, transform.position); sprite.transformedV1 = math.mul(matrix, new float4(sprite.v1, 1)).xyz; sprite.transformedV2 = math.mul(matrix, new float4(sprite.v2, 1)).xyz; sprite.transformedV3 = math.mul(matrix, new float4(sprite.v3, 1)).xyz; sprite.transformedV4 = math.mul(matrix, new float4(sprite.v4, 1)).xyz; this.sprites[index] = sprite; // Modify the data } } protected override JobHandle OnUpdate(JobHandle inputDeps) { Job job = new Job() { sprites = this.data.Sprite }; return job.Schedule(this.data.TransformAccess, inputDeps); } }

Everything was good. The sprites were rendering fine and the features that I needed worked as intended. That’s until I deactivated a game object that has the sprite component wrapper. The problem is that when you deactivate a game object, the entity associated with it gets destroyed. There’s no “deactivated” state for entity. What happened is that when I activate the object again, the entity no longer has the custom sprite component. A new entity is created for it but my sprite component is already gone.

I tried to salvage this by making some handlers in OnEnable() and OnDisable(). The resulting code is clunky and buggy. There are so many moving parts. I just didn’t like the solution so I threw it away.

Another idea was what if I created a separate entity for the sprite instead of using the same game object as the entity. The plan is to copy the position and rotation of the game object to the associated entity that contains the sprite. The copying can be done in Jobs system with Burst compiler. This will be fast since it is only copying values. So I did this and it was working great. No more clunky handling when a game object is deactivated and activated again.

However, I ran into another problem. Our game has a speed up mode where time moves fast so agents will move faster. I’ve observed that the sprites are one frame delayed. It’s very obvious when played in fast mode. I really couldn’t figure out why this is. Maybe copying position and rotation is not the best idea. Maybe it’s always delayed when using this solution. I tried switching the sequence of running systems. Nothing works.

I finally gave up. I thought maybe I should revert to the original way which uses the game object as the entity itself. The sprite transformation will use the game object’s transform directly. No more copying. So I did this, but only to be disappointed. The bug still persisted! I’m banging my head at this point.

I remember getting up, took a shower and ate some snack away from the computer. While eating, I was deciding what to do. Should I stop this silly project and revert to using the old Sprite Manager? If I do that, I have wasted my effort on these past few weeks. My first real ECS project would be a failure.

But no. I decided that I will do one last deep dive debugging. If I can’t figure it out tonight, I will revert to the old implementation.

The first thing I did was I made the simplest environment, a map with just one character and nothing else. This way, I can debug just this one character. I put debug log to the code that moves the character and another one in the system that transform the vertices. I noticed something strange. Why is the ECS system updating first than the move code? I’m pretty sure that MonoBehaviour’s Update() gets called first before ECS’s Update(). I placed a breakpoint in the move code to see the whole call stack. Lo and behold, the move update was called in an ECS system. I forgot that I transformed some update managers into ECS systems using the hybrid approach so I could get rid of such manager classes. This system is invoked after the vertex transformations. No wonder the positions and rotations the vertex transformation system got are always one frame old. It’s an easy fix of just adding an UpdateBefore attribute to the move update system. Yeah, that was dumb of me. Eating a snack fixes bugs.

What about the problem of deactivating/activating the game object where the entity gets destroyed? I figured that this is only a problem when I manually deactivate/activate the game object in the editor. I don’t usually do this, anyway. Plus, I already have handling when the game object is deactivated when recycled (I use object pooling). I also realized that I need not revert it to the implementation where another entity is used. I can save some CPU from copying positions and rotations. I have come to accept it as a limitation of my custom sprite manager. Everything else works just like the old implementation.

Conclusion

The main takeaway of this wall of text is that adding a pure ECS code to enhance a MonoBehaviour based project is definitely doable. I’d do it again if I can find parts of the game that’s possible to turn into ECS. It’s just really hard to do when your code is using OOP heavily. In fact, it would be better to start from scratch. I do wish that I have started Academia when ECS is already available. I would have used it right of the bat.

[Edit: Added benchmark as requested by some readers.]