Posted by Jérémy Faivre on November 28, 2022

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.

Ceramic Pixel Platformer in Unity

About Unity

Unity logoNo 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!

Ceramic window

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 both (MEME)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 a C# 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 with Haxe 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
  • Draw quads
  • Draw meshes
  • Display textured quads and meshes
  • Display shapes
  • Use and draw into render textures (with antialiasing support)
  • Apply clipping/masking to visuals (with stencil buffer)
  • Apply custom shader parameters (uniforms)
  • Apply custom vertex attributes (to do advanced things like tint black shaders in 2D...)
  • Built-in ceramic shaders ported to unity shaderlab format
  • Text rendering (including MSDF text rendering)
  • Multi-texture batching (to reduce draw calls in many common situations)
  • Make it compatible with Built-in Render Pipeline
  • Make it compatible with Universal Render Pipeline
Audio Input
  • Load audio assets
  • Play audio
  • Keyboard input
  • Text input
  • Touch input
  • Mouse input
  • Gamepad input
IO Networking
  • Save and load textual data
  • HTTP request
Tooling
  • Generate or update a Unity project when running ceramic unity build

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 architecture

A chart showing how Ceramic is architected and what platforms it can export to.

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
Visual API
Cross-platform
Renderer
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!

Quad Vertex Unity

A first test to draw a quad with colored vertices using CommandBuffer, Mesh and Material

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
1
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:
  • Use CommandBuffer to change some graphic config on Unity side
  • Keep the info around to configure a Material that will be used for the next draw call.
2
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.
3
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:

The Ceramic Demo project running in Unity Editor.

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:

The original Ceramic Demo project running in the browser.

Let's also try exporting the Pixel Platformer example to Unity:

The Pixel Platformer example project running in Unity Editor.

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:

A cast in the C# code generated by Haxe
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:

The C# implementation provided by Haxe to cast to double
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:

Additional overloads that use primitive types only
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.

Make More Views key art

Make More Views, a real use case, with a large code base: ideal to test the new backend!

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!

An example of generated C# string 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.

Make More Views running from inside the Unity Editor although it was initially a Ceramic project.

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:

Changing a visual texture or color in Ceramic
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?