Skip to main content

C++ and C# Interop in Cesium for Unity

We recently launched Cesium for Unity, bringing Cesium’s 3D geospatial technology to the Unity game engine. In some ways, this was “just another game engine integration”; our third after Cesium for Unreal and Cesium for O3DE. But Unity’s unusual architecture posed some unique challenges.

The Unity game engine is written in C++, just like Unreal Engine, O3DE, and most other performance-oriented applications. You wouldn’t know it from looking at Unity, though. Whereas with Unreal Engine and O3DE we can extend the engines by writing C++ code, with Unity the only option for extending the engine in most cases is by writing code in C#.

C# and Cesium Native

C# and the .NET platform were developed in the early 2000’s as Microsoft’s answer to Java. C# is a thoughtfully-designed language with good developer ergonomics. Its “managed” environment makes certain kinds of developer errors less likely, and others easier to debug when they do happen. This is a stark contrast to the complex, low-level, feel-free-to-shoot-yourself-in-the-foot environment of C++. With C++’s complexity, however, comes power and performance.

This distinction is important because of Cesium Native. As we started developing Cesium for Unreal in mid-2020, we realized that Unreal Engine would not be our last game engine. So we carefully separated the game engine–agnostic portions of Cesium for Unreal into a separate set of libraries, called Cesium Native. 

Some of the capabilities of Cesium Native include:

  1. A robust glTF loader with wide support for extensions.
  2. Loaders for 3D Tiles bounding volume hierarchy and tile payload formats.
  3. Multithreaded, out-of-core tile selection, loading, and cache management, driven by screen-space error.
  4. High-precision geospatial / WGS84 ellipsoid math.

Cesium Native doesn’t do any rendering itself, but makes it easy to integrate glTF and 3D Tiles into an existing rendering engine. Cesium Native is, of course, written in C++. When we started developing Cesium for O3DE in mid-2021, we leveraged Cesium Native, saving ourselves a huge amount of development time and effort. We hoped to do the same with Cesium for Unity.

Unfortunately, Unity’s reliance on managed C# code made this much more difficult. It is possible to call C++ code (Cesium Native) from C# code (interacting with Unity). A typical architecture looks like this:

In this architecture, the “Cesium for Unity Plugin” box contains our MonoBehaviours, UI elements, etc. to integrate Cesium functionality into Unity. To do that, it needs to use Cesium Native functionality. Cesium Native is written in C++, so it first needs to be wrapped in a C API (the extern “C” layer), and then the C API needs to be wrapped in a P/Invoke layer that makes the C API callable from C# code. The “Unity Helpers” move some Unity-specific work into C++ in order to avoid crossing the interop boundary too often in performance-sensitive code.

Writing the P/Invoke and extern “C” layers by hand is extremely tedious and error-prone. It requires detailed understanding of C# / .NET marshaling as well as careful thought about both C# and C++ object lifetimes. We know of two tools that purport to generate these layers, SWIG and CppSharp. SWIG’s generated wrappers are very general and likely too slow for our use case. CppSharp looks very promising, but we had very little success using it in practice. More often than not it would crash when fed our C++ classes. The fact is, C++ code can be very complicated, so wrapping arbitrary C++ code in C# interfaces is very challenging.

While this approach can definitely be made to work with enough engineering effort, we decided to follow a different, better path.

Instead of developing the Cesium for Unity plugin in C# and doing awkward interop through multiple layers to call into Cesium Native, what if we develop the Cesium for Unity plugin in C++ and do (less?) awkward interop to call into Unity and the .NET platform?

It looks something like this:

The architecture of Cesium for Unity, with interop between Unity Engine (C#) and Cesium Native (C++)

At first glance, this seems a little crazy. Why would we write a Unity plugin in C++?

One reason is because the interaction between the Cesium for Unity plugin and Cesium Native tends to be kind of chatty, especially in comparison with the Cesium for Unity Plugin’s interaction with Unity. For example, creating a Mesh for a Tile involves a lot of interaction with Cesium Native: enumerating glTF primitives and digging through glTF accessor, bufferView, and buffer data structures, to start. But then the interaction with Unity is simpler: create a Mesh object, and provide some buffers as NativeArrays.

This approach also has code reuse and developer skill set advantages for Cesium, as most of our non-web code is written in C++. If we were to write C# code, it would only be useful within Unity.

The most important reason we prefer this approach, though, is because the bindings layers are much easier to generate in this scenario. In Cesium for Unity, we generate all the tedious and error-prone code in the boxes in yellow in the diagram above.

Reinterop

We developed a tool called Reinterop. It is very heavily inspired by a series of articles by Jackson Dunstan titled C++ Scripting. In fact, the very earliest development versions of Cesium for Unity used Jackson’s UnityNativeScripting tool. Reinterop has some key differences, however.

Reinterop is a Roslyn Source Generator. This means that it runs inside the C# compiler (Roslyn) while it is compiling your C# source code. This privileged position gives it all the inside information about the C# code being compiled, and also allows it to generate additional C# code that is included in the same compilation.

Using Reinterop to develop Cesium for Unity first involves writing one or more ExposeToCPP methods. They look something like this:

public void ExposeToCPP()
{
    Camera c = Camera.main;
    Transform t = c.transform;
    Vector3 u = t.up;
    Vector3 f = t.forward;
}

This method doesn’t do much. It accesses Unity’s main camera, and then gets its transform and up and forward vectors, but it doesn’t actually do anything useful. That’s ok, though, because this code is never executed!

This code isn’t meant to be executed, it is meant as instructions to Reinterop. When Reinterop runs inside the C# compiler, it walks through all the code in this method, and learns all of the classes, methods, properties, and fields that it uses. It then generates both C# and C++ code to create an interop layer that allows everything used in this ExposeToCPP method to also be used from C++ code!

This approach is great for developer productivity, because it allows us to express the needs of the interop layer in a very familiar way, taking advantage of the IDE’s Intellisense autocompletion and the compiler’s ability to validate that we’ve expressed syntactically and semantically valid classes, methods, properties, etc.

The end result is that we can write C++ code like this:

#include <DotNet/UnityEngine/Camera.h>
#include <DotNet/UnityEngine/Transform.h>
#include <DotNet/UnityEngine/Vector3.h>

...

DotNet::UnityEngine::Camera mainCamera =
    DotNet::UnityEngine::Camera::main();
DotNet::UnityEngine::Transform transform = mainCamera.transform();
DotNet::UnityEngine::Vector3 up = transform.up();
DotNet::UnityEngine::Vector3 forward = transform.forward();

The important thing to understand here is that an instance of a C++ Camera class is not an instance of a C# Camera. Instead, it represents a reference to a Camera, in much the same way a variable of type Camera represents a reference in C#. Thus, we can interact with C# objects from C++ using the same lifetime semantics that apply in C# code.

So how does this work? Reinterop automatically generates a C++ class called Camera that acts as a reference to a Unity Camera object, and exposes a static method called main that accesses the property called main on the C# instance. A simplified version of the generated C++ class looks like this:

class Camera {
public:
  public: static DotNet::UnityEngine::Camera main();


  explicit Camera(DotNet::Reinterop::ObjectHandle&& handle) noexcept;
  Camera(std::nullptr_t) noexcept;


  DotNet::UnityEngine::Transform transform() const;


private:
  DotNet::Reinterop::ObjectHandle _handle;


  friend std::uint8_t (::initializeReinterop)(
      std::uint64_t validationHash,
      void** functionPointers,
      std::int32_t count);
  static void* (*Property_get_main)();
  static void* (*Property_get_transform)(void* thiz);
};

The implementation of the static main method is fairly straightforward:

Camera Camera::main() {
    auto result = Property_get_main();
    return Camera(
        DotNet::Reinterop::ObjectHandle(result));
}

It calls through a static function pointer called Property_get_main, constructs an ObjectHandle from the returned void*, and finally constructs a Camera instance from the ObjectHandle.

The function behind the Property_get_main function pointer is generated by Reinterop as well, but this time it is written in C#. It looks like this:

[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private unsafe delegate System.IntPtr 
    UnityEngine_Camera_Property_get_mainType();

private static unsafe readonly UnityEngine_Camera_Property_get_mainType
    UnityEngine_Camera_Property_get_mainDelegate =
        new UnityEngine_Camera_Property_get_mainType(
          UnityEngine_Camera_Property_get_main);

[AOT.MonoPInvokeCallback(
    typeof(UnityEngine_Camera_Property_get_mainType))]
private static unsafe System.IntPtr 
    UnityEngine_Camera_Property_get_main()
{
    var result = UnityEngine.Camera.main;
    return Reinterop.ObjectHandleUtility.CreateHandle(result);
}

The method accesses the Camera.main property, as expected, and then creates a “handle” to that Camera instance in the form of an IntPtr. The handle, in this case, is a .NET Framework GCHandle, which is designed for this purpose of allowing native code to reference and control the lifetime of a C# object. The handle is returned to the C++ code as a void*.

While this is a fairly simple example (but imagine writing this code by hand!), Reinterop generates the necessary interop code for whatever is expressed in the ExposeToCPP method, including support for reference and value types, constructors, nullables, generics, operators, and much more. It certainly doesn’t support every C# feature, but it supports many of them and we incrementally add support for more as we need them.

You may be wondering: how does the C++ code get access to a function pointer to the C# code above? In a Reinterop project like Cesium for Unity, that happens when the startup C# code calls the generated ReinteropInitializer.Initialize method.

It contains code like this:

unsafe
{
    IntPtr memory = Marshal.AllocHGlobal(sizeof(IntPtr) * 785);
    Marshal.WriteIntPtr(memory, 0 * sizeof(IntPtr), Marshal.GetFunctionPointerForDelegate(CesiumForUnity_Cesium3DTileset_CallBroadcastCesium3DTilesetLoadFailure_EA9gid9gf99C0Ye5ZgnZYwDelegate));
    Marshal.WriteIntPtr(memory, 1 * sizeof(IntPtr), Marshal.GetFunctionPointerForDelegate(CesiumForUnity_Cesium3DTileset_CallGetInstanceID_1B2M2Y8AsgTpgAmY7PhCfgDelegate));
    // ...
    Marshal.WriteIntPtr(memory, 783 * sizeof(IntPtr), Marshal.GetFunctionPointerForDelegate(UnityEngine_Vector3_Property_get_forwardDelegate));
    Marshal.WriteIntPtr(memory, 784 * sizeof(IntPtr), Marshal.GetFunctionPointerForDelegate(UnityEngine_Vector3_Property_get_upDelegate));
    byte success = initializeReinterop(12129345044111640753UL, memory, 785);
    if (success == 0)
        throw new NotImplementedException("The native library is out of sync with the managed one.");
}

It allocates an array big enough to hold a function pointer for every operation that is present in an ExposeToCPP method. It then creates a function pointer for each and writes it into the array. Finally, the initializeReinterop call at the end calls into the C++ code using the usual C# P/Invoke mechanism and passes the array to it.

The generated C++ implementation looks like this:

extern "C" {
#if defined(_WIN32)
__declspec(dllexport)
#endif
std::uint8_t initializeReinterop(std::uint64_t validationHashValue, void** functionPointers, std::int32_t count) {
  // Make sure the C++ and C# layers are in sync.
  if (count != 785)
    return 0;
  if (validationHashValue != 12129345044111640753ULL)
    return 0;
 ::DotNet::CesiumForUnity::Cesium3DTileset::CallBroadcastCesium3DTilesetLoadFailure_EA9gid9gf99C0Ye5ZgnZYw = reinterpret_cast<void (*)(void*)>(functionPointers[0]);
  ::DotNet::CesiumForUnity::Cesium3DTileset::CallGetInstanceID_1B2M2Y8AsgTpgAmY7PhCfg = reinterpret_cast<::std::int32_t (*)(void*)>(functionPointers[1]);

  // ...

  ::DotNet::UnityEngine::Vector3::Property_get_forward = reinterpret_cast<void (*)(::DotNet::UnityEngine::Vector3*)>(functionPointers[783]);

  ::DotNet::UnityEngine::Vector3::Property_get_up = reinterpret_cast<void (*)(::DotNet::UnityEngine::Vector3*)>(functionPointers[784]);

  // Invoke user startup code.
  start();

  return 1;
}

This code extracts each function pointer from the array, casts it to the proper function pointer type, and assigns it to a static field on the appropriate class.

But first it validates the C# and C++ sides are in agreement about the content of the function pointers. The array must be the same size, of course, but also a hash value computed from the signatures of all the interop functions must match on both sides. This helps avoid crashes when the C# side has been modified and regenerated, but the C++ side has not yet been recompiled.

Fun fact: all the reinterpret_casts in this function are where Reinterop gets its name.

Using Reinterop in Other Projects

Reinterop was developed for Cesium for Unity; use in other applications isn’t a primary goal. Even so, we think it’s potentially quite useful in any Unity project–or any C# project in general–that needs to interface with C++ code. You can find the complete Reinterop source code in the Cesium for Unity repo on GitHub:

https://github.com/CesiumGS/cesium-unity/tree/main/Reinterop~#readme

It’s available under the Apache 2.0 license so it is free for use in both commercial and non-commercial applications. Try it out, and let us know what you think on Cesium Community Forum.

Or, just enjoy using Cesium for Unity itself and be glad you don’t have to think about any of this!