Running a whole
2D Game Engine
in Unity
I created an open source 2D Game Engine in Haxe language and made it run inside Unity in order to extend its capabilities and support additional platforms like consoles. This post is a detailed technical overview of how it was achieved.
About Unity
No need to tell much about Unity: it is a well established game engine and editor and you probably know what it's about already! A lot of great games have been created with it, and you might have heard good (or bad) stories about it already!
About the 2D Game Engine: Ceramic
Ceramic is an open source and code oriented 2D Framework. It is written in Haxe language and can export natively to Windows
, Mac
, Linux
, iOS
, Android
and HTML5/WebGL
.
You can read an introduction article about it here if you want to know more about the tech and the motivations behind it. Feel free to also check the online examples out, or try a mobile game, created with it, Make More Views (in french) to see what Ceramic is capable of!
It doesn't need Unity
The tech stack of Ceramic is standalone. It can export and run apps by itself, without Unity. You simply run a command from CLI or Visual Studio Code, and voilà, you got a working app!
It was a strong motivation to make Ceramic standalone. I created this tool to have a quick way of creating small games and apps without having to use a closed source (and sometimes bloated) software like Unity. Ceramic is open source and most of its dependencies are open source as well.
But how about using Unity without depending on it?
Ceramic not depending on Unity doesn't mean it couldn't optionally take advantage of it to extend its capabilities right?
Why not getting the best of both worlds?
Well, Ceramic is written in Haxe, so its whole code base can be transpiled to C#
, and it happens that Unity scripting is done via C#
too. Could the Ceramic Haxe
code transpiled to C#
run in Unity?
In other words: how about running a whole engine inside another one? The idea was silly enough that I had to try to see for myself if it was possible!
Could I make a Ceramic app run inside Unity?
No need to hide you the truth: the answer is YES, I managed to make this work, it is what this article is about, and it opens the door to nice things like:
-
Exporting a Ceramic project to all the platforms Unity supports. That means technically being able to export a 2D Ceramic game to consoles as Unity supports those targets.
-
Giving access to Unity packages within a Ceramic project, thanks to Haxe's
C#
externs. You can call anything provided by Unity or its packages as long as it has aC#
API. Gives quite a lot of possibilities in hybrid projects that use both Ceramic and Unity. -
Still enjoying quick iterations thanks to Ceramic's native web target (which is quite fast to compile and run, compared to Unity).
-
Keeping writing Haxe code. You may not know this programming language, but it's actually a very good one. Lucas Pope came to the same conclusion when he ported his award winning Papers Please game to phones using Unity: he tried
C#
, but finally decided to stick withHaxe
because it was a better option for him.
Ceramic in Unity: a step by step technical overview
All of this sounds great, but obviously, making Ceramic run inside Unity was not just about transpiling Haxe
code to C#
! For this to work, Ceramic needed to communicate with Unity to draw graphics on screen, play audio, get input from mouse, keyboard, touchscreen and gamepad etc...
A summary of what was on the table
First, I made categorized lists of what had to be done to make Ceramic work in Unity:
Graphics | |
|
|
Audio | Input |
|
|
IO | Networking |
|
|
Tooling |
|
Many things to do!
Yes, seeing all the features involved, there was quite a lot of work ahead to make Ceramic work in Unity.
But good news, Ceramic has been designed in a way that it's main API is cross-platform and platform-specific code is isolated into backends. That means I don't have to reimplement the whole Ceramic API to port it to a new platform. Creating a new backend should be enough for that. Still a lot of work, but doable!
Ceramic's default backend is called Clay. It's what make it standalone and allows to export apps to desktop, mobile and web natively, without Unity. Making Ceramic work in Unity means: creating a new Unity backend.
The Unity backend
So I started writing that new Unity backend for Ceramic.
1. Graphics
The most complicated part of the backend was definitely the graphics part. How do I render Ceramic's visual objects within Unity? To explain how to do this, I first need to tell you how Ceramic rendering works.
How Ceramic renders graphics on screen
High Level |
Cross-platform |
Backend |
||
Images | ➔ | Transform Visuals into Textured Triangles |
➔ | Send to the GPU as Vertex Buffers |
Texts | ||||
Shapes | ||||
Tilemaps | ||||
... |
Ceramic can display various types of objects: images, shapes, text, 2D meshes, tilemaps... All these objects are transformed by Ceramic into textured triangles that are then sent to the GPU via a backend to finally be visible on screen.
The transformation into textured triangles is cross-platform, it's done in the Renderer
class of Ceramic. No need to reimplement that! We however need to provide the part that will make Unity send those triangles to the GPU, ideally using an API that is low level enough so that it wouldn't add too much overhead to the app!
Meet Unity's CommandBuffer
and Mesh
API
After doing some research, I excluded options like creating many GameObject
instances in Unity with attached SpriteRenderer
or MeshRenderer
components to them. It would be way too heavy and too high level to allow me to implement all the details of Ceramic graphics API. I would have very quickly hit a wall and not been able to make an implementation that works exactly the same as the default Clay backend.
What seemed to be a good fit however was using Unity's CommandBuffer
API because it allows to send commands to the GPU directly, gives the possibility to draw a Mesh
object with a given Material
without needing any GameObject
or Component
mess. Sounds like something I wanted!
Drawing a quad
Anyone that has played with graphics APIs like OpenGL
, DirectX
, Vulkan
or Metal
knows the joy of finally seeing his first triangle or quad on screen after writing a lot of boilerplate code.
Although not as difficult, I started in the same fashion: drawing my first quad using CommandBuffer
, Mesh
and Material
objects!
Seeing that quad on screen was great! That meant the proof of concept of using CommandBuffer
+ Mesh
+ Material
to display arbitrary triangles worked as expected! It was the starting point to write the actual graphics backend code!
Rendering a frame
Ceramic has a cross platform Renderer
class whose job is to iterate through every Visual instance at each frame and convert them into commands that are sent to the backend so that they become visible on screen.
If a game runs at 60 FPS (60 frames per second), this Renderer
job will happen 60 times per second as well.
Detailed steps of how Ceramic renders a frame and what it meant for Unity backend
To know how to write the Unity graphics backend code, I had to review what the Ceramic Renderer
needs to do at each frame, then decide how that should be handled by the Unity backend.
Below is the detailed process of what the Renderer
does in a frame, and my implementation plans for the Unity backend that would work with it.
BEGIN FRAME |
RENDERER |
UNITY BACKEND |
Configure settings for the next Visual object in the list: which texture to use, what shader to use, do we clip screen or mask with another visual, do we need to render onto a specific render target etc... Each of these setting changes are sent to the backend. | Receive settings from Renderer , and depending on that setting:
|
RENDERER |
UNITY BACKEND |
Iterate over the visuals and convert each of them to data that can be sent to the backend: textured triangles / vertices... | Receive vertices data (textured triangles) and store them into C# native arrays. |
RENDERER |
UNITY BACKEND |
If encountering a visual that needs different settings than the previous visual to be rendered, call flush() on the backend to send the pending buffers. |
flush() is called by the Renderer : configure a Material object on the fly from the current settings, get a Mesh object and assign it the vertices data we kept around at 3 in C# native arrays. Send all of this to the GPU using CommandBuffer.DrawMesh() . A draw call happens! |
Go back to 1 until there is no visual to render anymore. |
END FRAME |
This gives you an overview of how the Renderer
works and what should be done in the Unity backend so that graphics can actually be displayed and visible on screen!
These details showing a clear roadmap, I started implementing everything needed, until it worked as expected.
There are a lot of implementation details I can't talk about here because this article would be way too long otherwise, but you can take a look at the resulting source code here, if you are curious to see how it is actually written. It's mostly Haxe
code with calls to C#
externs and inline C#
that interact with Unity API.
2. Audio
Audio was much easier than graphics. I mostly had to map Ceramic's audio backend API to Unity's AudioSource
and AudioPlayer
objects. Didn't have much issues here, it quickly worked well!
3. Input
Input was a bit tricky but still easier than graphics. I decided to use the more recent InputSystem
module provided by Unity.
It took some time to get right because the way Ceramic is providing input events is quite different from how Unity does that, but I finally got it to provide input data consistent with other backends, with support of gamepads, screens with multiple finger touch, mouse and keyboard input...
4. IO
The backend needs to give Ceramic a way to save and load textual data (useful for saving game progress or preferences).
Thanks to Haxe
and it's standard library compatible with all its targets, including the C#
target, I had file system access out of the box and could quickly implement what was required!
5. Networking
Networking can be a requirement for an app / game that needs to interact with the outside world. Ceramic comes with a cross-platform http
plugin that is available as long as the backend provides what is needed for it on the current target.
The HTTP part of the backend for Unity was implemented using the UnityWebRequest
class. So far no issue, it worked as expected!
6. Tooling
Like other Ceramic targets, when building or running an app, Ceramic needs to be capable of exporting a ready to use project or app. Here, we wanted to export an Unity project.
I created a project template, first with Built-in Render Pipeline
, then with Universal Render Pipeline
enabled by default, so that Ceramic can use that when exporting for Unity.
With that ready, I could implement the initial project generation and we became able to run this command:
ceramic unity run unity --setup --assets
That is enough to export the Unity project. You then simply have to open that project, click Play and see your app running!
Promising results
With a Unity backend implemented properly, exporting an existing Ceramic project to Unity became as easy as running the single command showcased above.
Trying that on the Ceramic Demo example gives you this result:
Yes, it is a fully working Unity project that runs your Ceramic app INSIDE the Unity editor! How cool is that? 🤯🤪🎉
It is the same app as the original one, it works exactly the same, see the original web version for reference:
Let's also try exporting the Pixel Platformer example to Unity:
Again, the project seems to work just fine! Too good to be true?
Encountered issues
Reading this article, you might think it was straightforward to go from the roadmap to an actual working implementation that can run Ceramic projects inside Unity.
Well, it wasn't that simple!
I encountered several issues and challenges that had to be sorted out.
Haxe's C#
target and its quirks
Haxe is a great tool. Not many programming languages can be transpiled to other target languages like it does. Unfortunately, the C#
target of Haxe
is one of the least maintained. Overall, it is working fine, but in some specific situations, you can hit edge cases. I did hit some of them and had to do something about it!
Boxing was destroying performances
In C#
, when you assign a primitive type like int
, float
or bool
to an object
type, it gets boxed. That means a new object is allocated in memory implicitly to hold the primitive value and then later becomes garbage.
➔ Read more about boxing and unboxing in C# here.
In the Ceramic Renderer
, written in Haxe
, a lot of Float
and Int
types are used. They translate into C#
double
and int
primitive types. The generated C#
renderer code contains several casts to double
or int
.
Casting a primitive type to another primitive type shouldn't be a problem for a modern CPU. These are well optimized, should be fast and not generate any allocation.
But for some reasons, those casts did generate allocations!
To test that, I created a bunnymark project and used the Unity profiler to track allocations.
A bunnymark is a benchmark with bunnies used to measure raw performances of the engine. Although it is not meant to reflect real world use cases, it can help track down performance improvements or regressions when changing the engine code base.
I noticed that thousands of allocations per second were happening in the Renderer
class. This Renderer
class is really a hot spot of Ceramic. Every visual object is processed by it, many times per second, so anything going wrong there will add up very quickly, and it did!
Why those allocations?
Well, in the renderer, I am using custom inlined C#
array access to skip some checks the code normally does, but ironically it is what generated casts that are looking like this:
double someDoubleValue =
((double) (global::haxe.lang.Runtime.toDouble(
someDoubleArray.__a[index]
)) );
These casts should be fine theoretically (going from double
to double
) but checking the source code of the toDouble()
method used for the cast, in the haxe.lang.Runtime
class generated by Haxe
compiler, I found this:
public static double toDouble(object obj) {
if (( obj == null )) {
return .0;
}
else if (( obj is double )) {
return ((double) (obj) );
}
else {
return ( obj as global::System.IConvertible )
.ToDouble(default(global::System.IFormatProvider));
}
}
Do you see the problem? It takes an object
as argument: that means everytime toDouble()
was called, the primitive type given as argument was boxed and generated a new allocation!
A solution?
At first, I was thinking I'm stuck. How can I solve this problem without tweaking into the haxe compiler itself that I don't know much about?
But I finally found a pretty straightforward solution to remove all these unneeded allocations: add overloads that accept primitive types for toDouble()
and toInt()
methods in the generated C#
code!
To do so, I automated the process at build stage: right after the C#
code is generated by the Haxe
compiler for the Unity build, Ceramic is patching the Runtime.cs
file that contains the cast methods. It adds the required overloads:
public static int toInt(int val) { return val; }
public static int toInt(float val) { return (int)val; }
public static int toInt(double val) { return (int)val; }
public static double toDouble(int val) { return (double)val; }
public static double toDouble(float val) { return (double)val; }
public static double toDouble(double val) { return val; }
After this change, testing the bunnymark project again gave very good results: all those cast allocations had disappeared, and I noticed a strong increase in performances: 60FPS
all the way with thousands of objects moving around!
The patch was definitely worth it! Ultimately, this should probably be fixed directly on the Haxe
compiler, but in the meantime, being able to patch the generated C#
code was quite handy.
Large projects were failing to build
when exporting with IL2CPP enabled
When the Unity export was working mostly as expected, I was eager to try it on a larger project like Make More Views. That game is a project with hundreds of classes made with Ceramic and taking advantage of most of its features. It was a great way to test if the Unity backend is behaving exactly how it should when everything is put together in a "real world" use case.
Various bugs in the backend were fixed thanks to testing on this larger scale project, but then, when I tried to export (via Unity) to iOS
with IL2CPP
enabled, it failed with a C++ compile error!
What happened?
It was odd: a smaller project would build just fine using IL2CPP
, why a bigger one would fail?
IL2CPP
is a tool used by Unity to transpile the C#
code into C++
code. It helps to meet the requirements of some targets like iOS
. The resulting C++
can also provide a good performances boost to a game, compared to the default code export.
After investigating a bit more, I found the culprit in, again, the C#
code generated by Haxe. The Haxe
programming language supports reflection, and more specifically, you can retrieve a field from its name as string
at runtime. For this to work in C#
target, Haxe
is generating a FieldLookup.cs
file that embeds a string array (string[]
) with all the possible field names possible in the code base. Bigger the project becomes, longer is this array!
protected static string[] fields = new string[]{"A", "B", "C",
"a", "b", "c", "d", "h", "i", "m", "q", "r", "t", "x", "y",
"z", "a1", "a2", "dx", "dy", "ff", "gg", "hh", "hx", "id",
"ii", "im", "io", "sm", "to", "tx", "ty", "x1", "x2", "y0",
"y1", "y2", "stencil", "averageFrameTime",
"__cbOnceOwnerUnbindTileQuadsChange", "runNextLoader",
"isGlobal", "moveDistance",
"__cbOnceOwnerUnbindTexturesDensityChange", "tsxRawData",
"__a", "_dx", "_dy", "_id", "_sx", "_sy", "add", "all", "buf",
"cmn", "col", "crc", "cur", "cwd", "dev", "dir", "doc",
"onGridHeightChange", "internalId", "ext", "fps", "get", "gid",
"hex", "ino", "srcAlpha", "key", "len", "mX1", "mX2", ...
Apparently, there was a hard limit where the C++
generated by IL2CPP
from the C#
code would fail to compile if that string
array became too long. It seemed that the compiler didn't like the fact that, in the generated C++
, there were too many string literals.
Again, tweaking into the haxe compiler was not an option, but I could add another automatic patch at build time, similar to the allocation / boxing fix I did before.
In order to remove all these string literals from the generated code, I wrote a patch that extracts this string list from FieldLookup.cs
, removes it from the code and instead stores it into a text asset that can be loaded by Unity at startup! The resulting data at runtime would be identical, the same as before, so the reflection would still work as expected.
This solution worked very well: the large project Make More Views was now building and compiling fine and I could successfuly run it on a device 🎉.
A few things had to be adapted in Ceramic code base
In general, the C#
export of Haxe
is doing what you would expect: it generates code that behave the same as in other targets. But there are a few edge cases where C#
target is going haywire. I had to change a few fings in Ceramic code base to ensure it would compile fine when targetting C#
.
That said, all these changes where minor and very quick to do. It's just that sometimes, the Haxe
code you write does not always export usable C#
on the first try. You might need to fix a few things here and there, but nothing serious!
It might look like I had a lot of issues with Haxe, but don't get me wrong: it's an amazing programming language with awesome features, and it works very well in general. It is made by passionate people, and as a long time Haxe user, I'm very grateful that it exists and is made open source. You should definitely try Haxe!
Recycling Material
and Mesh
objects
In Ceramic, there is no such thing as a Material
to configure a visual texture or color. Instead, you directly set properties on the visual:
var visual = new Quad();
visual.texture = someTexture;
visual.color = Color.BLUE;
...
On the backend side, we send textured triangles to Unity using Mesh
instances and render that with a Material
. In order to avoid allocating Material
and Mesh
objects at each frame, I had to recycle them, which is what I did. Material
instances are now created on the fly depending on the required rendering configuration and kept in memory to be reused everytime the same settings need to be used again. Same goes for each Mesh
instances, that are reused at each frame with updated vertices data.
Using Unity's advanced Mesh
API
Because I needed to send a lot of data to the GPU, I used Unity's Advanced Mesh API to skip the checks Unity normally does on the supplied data. In a way, it's an API closer to what you would do directly with an OpenGL and is a better option performance-wise.
➔ More info about Unity's Mesh API
Overall experience
Despite some issues related to the C#
export of Haxe, I could manage to find solutions to every problem encountered.
It is incredible that running a 2D game engine like Ceramic inside Unity is even possible! It works consistently the same as other targets, and performances are fairly good, especially if you enable IL2CPP
when exporting from Unity.
Although this Unity backend is quite recent, and new issues could arise in the future when trying new things with it or when newer Unity versions get released, I can really see this solution as a serious option to export a Ceramic project to additional platforms like consoles.
Even after months of playing with it, I am still amazed when I hit build for unity on a Ceramic project and see it work inside the Unity editor right after!
I enjoyed experimenting with this idea and finally make it a reality!
What now?
I hope you liked this article! It has been a long while since I wanted to share those technical details. Feel free to give some feedback in the comments if you want. You can also join the #ceramic channel on Haxe Discord, get some help there, or just share what you want to try with Ceramic!
Want to try Ceramic?
➔ Install it and get started now!
➔ Check platform setup for Unity
Continue reading ➔ It's 2024! What's up with Ceramic?