Using threads in native plugins for Unity WebGL

A bit of emscripten magic to translate c++ code to js web workers.


The problem

I have a native plugin which takes game state description as an input and returns some kind of decision after a while. The game runs as WebGL build. What seems to be the problem?

Using native code for WebGL is trivial, you don’t even need to compile it as library or bundle of any kind. It’s compiled to JS directly by emscripten — the tool that both Unity and Unreal use to build for WebGL. So we just have some .cpp and .h files put into Assets/Plugins/WebGL/ and we can access them from C# quite easily.

In this case, the problem lays in that “while”, which the plugin takes to process the request. It’s quite sophisticated AI solver, which considers a number of possible solutions and is trying to come up with the best decision. Delegating work and waiting for answer is simple — the drawback being the fact that the native plugin calls need to be made from UI thread in Unity. What does it mean for a developer? Everything is freezed until we hear back from the plugin. And when the “while” extends to 10 or 15 seconds, we reach unbearably bad user experience level.

C++ solution

My first thought — just use a standard <thread> library. A couple of hours later I had a working version of a bridge, that would create a thread with a solver instance. It processed the input and returned a decision through a callback to Unity. That worked fine for OS X targeted bundle run through Unity Editor. What I didn’t know at that time, was that WebGL can’t take std::thread and translate it to anything meaningful (actually, it’s not entirely true — support for std::thread just emerged in nightly builds of Firefox, which means it’s being developed, but the feature is highly experimental. So, no-go in terms of a fast and universal solution for now).

WebGL solution

Ok, so what’s a better solution? It turns out that we can reference emscripten.h in any native code which is marked as WebGL plugin in Unity. This opens up a whole bunch of possibilities, in particular using a web worker API.

A web worker is a JavaScript script executed from an HTML page that runs in the background, independently of other user-interface scripts that may also have been executed from the same HTML page. Web workers are able to utilize multi-core CPUs more effectively. [https://en.wikipedia.org/wiki/Web_worker]

The usage is pretty simple:

The detailed description can be found here: emscripten API reference. To complete the example we need the worker callback definition:

So, as you can see, that’s pretty straightforward in terms of usage from native code perspective. But what about the “worker.js” file that is referenced in emscripten_create_worker call?

Bulding web worker

To be able to build your own .js files from native code you need to install emscripten SDK. Then just use emcc command tool to build from your native code:

What’s worth noting here? First of all, you need to explicitly pass the function names that you want to have access to through web worker API. Then you indicate that the output should be build as a worker, rather than a classic .js script that is to be embed on the web page. And a last bit: the actual native code that we call through emscripten API, a “bridge” that glues native code that we want to run in background and classic native code that we can reach from Unity (first two snippets). So this is how it can look like:

Again, we include emscripten.h, and that allows us to make a callback to the function that we registered through emscripten_call_worker.

Summary & tips

Now we just need to put worker.js somewhere in web page structure, so it can be accessed by index.html generated by Unity. It should work like a charm. On Unity side you just need to have a single .cpp file that creates the worker and defines a callback, if needed (snippets 1 & 2).

What are some good practices? Try not to create a new worker for each task — instead create a worker once, keep the handle and address existing instance when you need it. Besides, workers can’t share memory, so they should operate entirely on their own, on exclusive data sets. That can be a deal breaker for some.