Using Rust with Unity ... and Why ?

Maël Naccache Tüfekçi February 17, 2023 [GameDev] #Rust #Unity

Rust ? In my Unity ?? Turns out, it's more common easier than you think! In this article, I want to teach you how to write a library in Rust and use it in Unity. But I also want to have a small discussion about if you should do it in the first place. After all, just because you can, should you ?

Anyway, let's get started !

How to create a Rust library and call it from Unity

1. Create a library in Rust

Creating a library in Rust (using cargo) is very easy, just add the --lib flag to the cargo new command:

cargo new --lib mylib

You will also need to add the following lines to your Cargo.toml:

[lib]
crate-type = ["cdylib"]

In short, this make sure we compile to a dynamic library format (.so, .dylib, .dll). You can read about all the type of crate-type in the rust documentation.

2. Disable name mangling and use the C call convention

By default, Rust use name mangling. You can read why in the RFC.

This behavior is unwanted when making a library since we want our symbol to keep their name so that we can find and call them from outside. Thankfully, it is very simple to disable per-function using the #[no_mangle] decorator.

Open the default src/lib.rs and add the decorator just before the default add function:

#[no_mangle]
pub fn add(left: usize, right: usize) -> usize {
    left + right
}

A good practice is to only add #[no_mangle] to the functions that are supposed to be exposed to the outside. This has the added bonus of making it easy to identify which function are "internal" and which communicate with the outside world.

Another thing is to add extern "C" to the function declaration:

#[no_mangle]
pub extern "C" fn add(left: usize, right: usize) -> usize {
    left + right
}

This simply indicate that this function use the C calling convention which is the one C# will use.

3. Compile and copy-paste

This is technically the minimum you need to get up and running, so now you can just run cargo build and you should find a dll/so/dylib in the target/debug folder of your project (or in target/release if you built with the --release flag).

To add the library to your Unity project, you just need to copy-paste the library file somewhere in the Asset folder of your project.

Inside Unity, you might want to change the platform settings of the library to match the OS and CPU arch you compiled your lib for :

The library open in the unity editor

4. Create the binding and run

Now we need to tell C#/Unity how to call our function, so we need to do a binding by declaring our function definition and associating it with the library:

using System.Runtime.InteropServices;
public class MyLibBindings
{
    [DllImport("mylib")]
    public static extern int add(int a, int b);
}

You can put the library file pretty much anywhere in your project tree relative to your binding script. As long as the name in the DllImport is correct, Unity should be able to find it.

Then you can call it like a normal C# sharp function in your MonoBehaviour, scriptable objects or any other C# classes. For example, we can put it in a MonoBehaviour that will call it on start:

public class CallOnStart : MonoBehaviour
{
    void Start()
    {
        int r1 = MyLibBindings.add(int.MaxValue, 1);
        Debug.Log(r1);
    }
}

Fun challenge: what do you think this code print?

Writing binding tend to be quite redundant, so this is something you can consider code-generation for. Although I know a lot of people who like to handcraft their binding file to have them properly organized and documented.

Anyway, that's it ! You now know how to "call Rust" from Unity. Unity call such library Native Plugins, you can find more info there.

But we might want to go further. After all, if we can just call function from our library, we are pretty limited in what we can do. So the second step we should try is: can we go the other direction and call Unity from our Rust library.

Turns out we can, using callback !

Calling Unity from Rust with callbacks

1. Creating our callback type in Rust

The first step we need to do is create the callback type and a function that will take one and call it in our library. This is simply achieved using what we previously learned:

// Ou callback type. Note that we have to also add 'extern "C"' for it to work
type CBFn = extern "C" fn(f32, f32) -> f32;

// Then we can just use this type as our function argument.
// Here we make a simple function that will call the callback with predefined argument.
#[no_mangle]
pub extern "C" fn retcall(callback: CBFn) -> f32 {
    return callback(2.0, 6.0);
}

And we can now just cargo build and copy the updated DLL into our project. Note that you might have to restart Unity to force it to load the new DLL. There are ways to force Unity to reload the DLL, Resolution Games talk about in their article on rust and unity.

2. Creating a delegate in Unity

Now, we need to "copy" our callback type in Unity. This is done by declaring a C# delegate with the same argument and return value. In our case, we get the following:

delegate float callback(float a, float b);

You can put it in the same class that we put our previous declaration and add the binding for the new retcall function:

using System.Runtime.InteropServices;
public class MyLibBindings
{
    [DllImport("mylib")]
    public static extern int add(int a, int b);

    public delegate float callback(float a, float b);

    [DllImport("mylib")]
    public static extern float retcall(callback cb);
}

So know, we need to create or callback and call it using the retcall binding. We can go back to our previous MonoBehaviour and add a function with the same type as our delegate and then call retcall with it as the argument:

using UnityEngine;

namespace Assets.SalmonVR.Scripts.MonoBehaviours
{
    public class CallOnStart : MonoBehaviour
    {
        void Start()
        {
            int r1 = MyLibBindings.add(int.MaxValue, 1);
            Debug.Log(r1);
            float r2 = MyLibBindings.retcall(pow);
            Debug.Log(r2);
        }

        float pow(float a, float b)
        {
            return Mathf.Pow(a, b);
        }
    }
}

Note that this is a perfectly standard method, and therefor we have access to the instance member. And we can access Unity objects and change them within the callback. For example, we can change our code to move a transform to the params passed to the callback:

using UnityEngine;

namespace Assets.SalmonVR.Scripts.MonoBehaviours
{
    public class CallOnStart : MonoBehaviour
    {
        // The transform we wich to move
        public Transform obj;

        void Start()
        {
            int r1 = MyLibBindings.add(int.MaxValue, 1);
            Debug.Log(r1);
            float r2 = MyLibBindings.retcall(pow);
            Debug.Log(r2);
            MyLibBindings.spawn(multiplyAndPrint);
        }

        float pow(float a, float b)
        {
            obj.position = new Vector3(a, b, 0f);
            return Mathf.Pow(a, b);
        }
    }
}

If you run this code, you should now see you transform move when the callback is called !

Now we are left with one more subject to talk about. Right now, everything is executed within Unity main thread. Depending on the use of the library we are programming, we might want to be able to execute tasks concurrently. So what about multithreading ?

3. Multithreading and Unity

Rust is an ideal language to program multithreaded systems. After all, it was one of its design goal. Adding multithreading is very easy with the standard library, we can add a spawn function to our library that will call a callback after 2 seconds:

#[no_mangle]
pub extern "C" fn spawn(callback: CBFn) -> () {
    std::thread::spawn(move || {
        std::thread::sleep(Duration::from_secs(2));
        callback(2.0, 7.0);
    });
}

Again, we can just cargo build, copy our updated DLL, and declare our binding in Unity:

[DllImport("mylib")]
public static extern void spawn(callback cb);

And again, go to our MonoBehaviour and add a method to be our callback and call the spawn function:

using UnityEngine;

namespace Assets.SalmonVR.Scripts.MonoBehaviours
{
    public class CallOnStart : MonoBehaviour
    {
        public Transform obj;

        void Start()
        {
            int r1 = MyLibBindings.add(int.MaxValue, 1);
            Debug.Log(r1);
            float r2 = MyLibBindings.retcall(pow);
            Debug.Log(r2);

            // Our new spawn function should be executed concurently.
            MyLibBindings.spawn(multiplyAndPrint);
        }

        float pow(float a, float b)
        {
            obj.position = new Vector3(a, b, 0f);
            return Mathf.Pow(a, b);
        }

        // Our new call back
        float multiplyAndPrint(float a, float b)
        {
            float c = a * b;
            Debug.Log(c);
            return c;
        }
    }
}

And again, if you run your game, you should see the correct log in your console.

But what if you want to modify a Unity object:

using UnityEngine;

namespace Assets.SalmonVR.Scripts.MonoBehaviours
{
    public class CallOnStart : MonoBehaviour
    {
        public Transform obj;

        void Start()
        {
            int r1 = MyLibBindings.add(int.MaxValue, 1);
            Debug.Log(r1);
            float r2 = MyLibBindings.retcall(pow);
            Debug.Log(r2);
            MyLibBindings.spawn(multiplyAndPrint);
        }

        float pow(float a, float b)
        {
            obj.position = new Vector3(a, b, 0f);
            return Mathf.Pow(a, b);
        }

        float multiplyAndPrint(float a, float b)
        {
            float c = a * b;
            // Crash! 
            obj.position = new Vector3(c, 0f, 0f);
            return c;
        }
    }
}

If you run this code, you will get a nasty error in your console: Unity informing us that we cannot modify and object outside the main thread

Well, turns out that Unity is not thread safe, and you cannot modify anything outside its main thread. But it's okay! We can actually work around that. One easy work around is to use a class member to store our value and then update our object in a function that will be executed in Unity main thread, like Update for example:

using UnityEngine;

namespace Assets.SalmonVR.Scripts.MonoBehaviours
{
    public class CallOnStart : MonoBehaviour
    {
        // This will store the value calculated in the callback.
        public float MyValue = 0f;
        public Transform obj;

        void Start()
        {
            int r1 = MyLibBindings.add(int.MaxValue, 1);
            Debug.Log(r1);
            float r2 = MyLibBindings.retcall(pow);
            Debug.Log(r2);
            MyLibBindings.spawn(multiplyAndPrint);
        }

        float pow(float a, float b)
        {
            obj.position = new Vector3(a, b, 0f);
            return Mathf.Pow(a, b);
        }

        float multiplyAndPrint(float a, float b)
        {
            float c = a * b;
            // Instead of modifying the object in the callback we just store the value
            MyValue = c;
            return c;
        }

        void Update()
        {
            // And we update our object with the value here !
            obj.position = new Vector3(MyValue, 0f, 0f);
        }
    }
}

Of course, we might not want to update our position every frame but only when the callback as been called. To do this we could use a boolean that we only set to true in the callback, and gate our update with this boolean.

But anyway, that's pretty much everything ! You now know how to make a unity native plugin with Rust, communicate with it and even make it multithreaded. You can find even more information on unity's documentation on native plug-ins and on the mono documentation about interoperability.

Now we should talk about the elephant in the room: Why?

Now, why would I do that ?

Well, to rewrite it in Rust of course ! In all seriousness, adding a native plugin has a lot of impact:

Therefor, we should really think before starting to develop a native plugin. But there is some case where it makes a lot of sense. Let's talk about (some of) them.

1. Interacting with the outside world

This is usually the main use of native plugin. Sometimes you need to be able to call a function from the OS, another library or software (like a middleware), etc. If Unity doesn't already integrate built-in bindings, you are going to have to do your own.

Usually, this is something you would do with C (or in some case C++, Objective-C if you need to work with Apple, etc). So why would you do it in Rust ?

Naturally we could just say "Safety!", but to be completely fair, this is not a case where Rust safety will shine the most. When writing bindings to another library, unless it is also written in Rust, you will often have to use a lot of unsafe. You might have to manipulate raw pointer, manually handling the lifecycle of some object (like calling a library function to free some resources, ...), etc. But I do find that Rust allows you to make "safer" bindings thanks to all the error management tools the language gives you (like Result and such).

But, if you are not doing just a thin-wrapper over some library's functions, you do get a lot of Rust advantages. So I find that using Rust is most useful when doing "high-level" wrapper over a library.

2. Making a library not just for Unity

Another common case is having to create a library which functionality you need to share between different engine/platform. For example, if you are making a multiplayer game, you might want to have your protocol and netcode in a common library that can easily be shared by the client and the server. I find that Rust is an awesome language to implement protocols (network or not) with its supercharged Enums, so this is a very good fit. But this can also apply for any other feature you might want to share across several platforms.

3. Going low-level

Sometimes you need to do some byte wrangling, you want to avoid the GC at all cost or need to have a very precise control over your memory. C# usually allows you to do most of what you would need, but it might just be easier to go native.

As an example, I once had to interact with a piece of hardware using a binary protocol in serial. While it would have been completely possible to do it in C#, doing it in Rust was just plain simpler and gave me more safety around the parsing and generating packets. It also means that it was very easy to integrate it in another solution later on (as mention earlier).

Conclusion and other consideration

I hope that this article taught you everything you needed to start creating and integrating your Rust library in Unity. As usual, I cannot recommend enough that you carefully consider the pros and cons before doing it (unless it's for a fun personal project, in which case, go wild!). I love Rust, but often, simplicity is best, and if you can just do it in C# directly in Unity, you probably should :) .

And to finish, here some random thought you might want to consider:

Back to top