Creating, connecting, and optimizing native plugins for Unity

MY.GAMES
MY.GAMES
Published in
5 min readOct 18, 2023

Learn the reasons to use plugins in Unity, and their advantages and disadvantages. Create, connect, and optimize native plugins.

In Unity, most development is done using C#. This is because it’s fast, convenient, and the architecture of the C# language allows us to smooth out a lot of “edges”. Unity also lets us use the “Low-level Plugin Interface” to interact with plugins in “binary” form.

Hi there! My name is Dmitry Antonov, and I’m a Senior Developer at MY.GAMES. In this article, we’ll talk about the reasons to use plugins in Unity, as well as their advantages and disadvantages.

Creating and connecting a plugin in Unity

First, I’ll show you how you can create and integrate a plugin in Unity. Creating a dynamic library is quite simple. We can do this using, for example, the configuration file CMakeLists.txt:

# setting up the name of the project
project(unity_plugin VERSION 0.1 DESCRIPTION "Unity test plugin")
# adding the library object, which will be called unity_plugin,
# it will be dynamic, and built from the unity_plugin.cpp file
add_library(unity_plugin SHARED unity_plugin.cpp)
# using the language standard с++17 (optional)
set_property(TARGET unity_plugin PROPERTY CXX_STANDARD 17)

The unity_plugin.cpp may look like this:

extern "C"
{
float GetSum_F(float a, float b)
{
return a + b;
}
}

extern “C” is used to indicate that the C standard “calling convention” will be used for nested functions.

To enable the functions of a dynamic library like this in Unity, you need to create a script like:

public class TestNativePlugin : MonoBehaviour
{
[DllImport("unity_plugin")]
public static extern float GetSum_F(float a, float b);
}

After building via CMake, the result will be the libunity_plugin.dylib file, which you just need to copy into the project, and then place in the Assets directory.

Once you find the library file through the editor interface, you can specify the following parameters: the platform, the architecture the plugin was built for, as well as the place where it will be used, either in the Editor or in the final build (Standalone).

This is where the main drawback of such plugins comes into play: each library is tied to a specific platform/architecture. As such, it will be impossible to use the same binary library, for example, on Mac and Windows — and the Web player doesn’t support them at all.

The disadvantages also include something the warning message eloquently states in the plugin window: the plugin is loaded once at the startup, and in order to reload it, you need to restart the application/editor.

Potential features and disadvantages of native plugins

Moving on, there are several potential uses for native plugins:

  • Directly accessing an SDK (IOS/Android)
  • Interacting with specific devices
  • Using an existing API
  • Rendering
  • Dealing with performance

From a practice point of view, the first four points have a lot in common. They require the direct use of the functionality of a ready-made system, whether a graphical API or a 3rd-party library, the calls of which are hard to replace for some reason.

It’s worth noting that for rendering in the native plugin there’s a separate interface IUnityRenderingExtensions.h (supplied according to the documentation along with the Unity\Editor\Data\PluginAPI engine), which is necessary for implementing renderer calls on the native library side. There are quite a few examples showing how this is implemented.

Getting a bit ahead of things, I will say that optimizing rendering on the native library side makes sense when, for example, we’re performing a rather difficult operation like modifying a texture and a large vertex array, as in the given example (link above).

Optimization

The optimization issue stands on its own, because here we’re talking about our own code.

I conducted performance tests; the tests were based on the operation of multiplying a fixed-point number from our project. This operation was performed 99999999 times, and in case of overflow, the number was assigned an initial value and multiplication continued.

The library included both a separate multiplication function (FixMul) and a function that performs the entire operation internally (FixMulLoop). The result is the following code:

        void Test()
{
var f1 = new fix(sDefault);
var f2 = new fix(sDefault);
Stopwatch sw = new Stopwatch();
sw.Start();
for (var i = 0; i < sSteps; ++i)
{
var sum = FixMul(f1.Raw, f2.Raw);
f1 = fix.from_raw(sum);
if (f1 == fix.MaxValue)
f1 = new fix(sDefault);
}
sw.Stop();

Debug.Log($"RESULT NATIVE: {f1} TIME: {sw.Elapsed}");

sw = new Stopwatch();
f1 = new fix(sDefault);
f2 = new fix(sDefault);
sw.Start();
var sumI = FixMulLoop(f1.Raw, f2.Raw, sSteps, fix.MaxValue.Raw);
f1 = fix.from_raw(sumI);
sw.Stop();

Debug.Log($"RESULT NATIVE INTERNAL: {f1} TIME: {sw.Elapsed}");

f1 = new fix(sDefault);
f2 = new fix(sDefault);
sw = new Stopwatch();
sw.Start();
for (var i = 0; i < sSteps; ++i)
{
f1 = f1 * f2;
if (f1 == fix.MaxValue)
f1 = new fix(sDefault);
}
sw.Stop();

Debug.Log($"RESULT ORIGINAL: {f1} TIME: {sw.Elapsed}");
}

The basic library build produced the following result:

RESULT NATIVE: 7965880.23307832 TIME: 00:00:03.3179360

RESULT NATIVE INTERNAL: 7965880.23307832 TIME: 00:00:02.0874084

RESULT ORIGINAL: 7965880.23307832 TIME: 00:00:01.3594913

As you can easily see, library call performance is significantly inferior to the option where the original operation is used — even when only one call is being performed (the FixMulLoop option).

At first, this surprised me a little, because even calls to dynamic library functions shouldn’t produce such a large overhead, given that the fixed-point multiplication function is quite non-trivial.

We came to the conclusion that it was not the call slowing it down, but the function body. Here is the test output at different optimization levels, from lowest (-O1) to highest (-O3):

1.

RESULT NATIVE: 7965880.23307832 TIME: 00:00:01.8980130

RESULT NATIVE INTERNAL: 7965880.23307832 TIME: 00:00:00.6798092

RESULT ORIGINAL: 7965880.23307832 TIME: 00:00:01.1324974

2.

RESULT NATIVE: 7965880.23307832 TIME: 00:00:01.5023200

RESULT NATIVE INTERNAL: 7965880.23307832 TIME: 00:00:00.3422969

RESULT ORIGINAL: 7965880.23307832 TIME: 00:00:01.1981343

3.

RESULT NATIVE: 7965880.23307832 TIME: 00:00:01.4892372

RESULT NATIVE INTERNAL: 7965880.23307832 TIME: 00:00:00.3479376

RESULT ORIGINAL: 7965880.23307832 TIME: 00:00:01.1550455

To hand over the optimization level to the native library compiler, you need to add the following lines to CMakeLists.txt:

set(COMPILE_FLAGS “-O3”)

target_compile_options(unity_plugin PUBLIC ${COMPILE_FLAGS})

Even though performance fluctuates, we notice the following outcomes:

  • Native code optimization does its job
  • Multiple calls are always worse than executing the same code on the engine side
  • Performing complex, expensive operations within a single call gives a noticeable performance boost

And a less obvious detail:

  • Library build optimizations will produce different results on different systems and processors

Conclusion

Native-Plugin Interface is a highly specialized solution, regardless of the application: for rendering, optimization, shader generation, and so on. Implementing this will require changes in the application architecture and/or design at the initial stage. For some platforms, it may be convenient to provide certain functionality by updating the library — but, it’s worth remembering that the rules of publishing AppStore applications (for example) prohibit this. Optimizations provide better performance the more competently the plugin is integrated into the application architecture. Conversely, thoughtless use of native libraries can negatively affect it.

--

--

MY.GAMES
MY.GAMES

MY.GAMES is a leading European publisher and developer with over one billion registered users worldwide, headquartered in Amsterdam.