Monday, August 28, 2017

Using SkiaSharp in Unity

For the past two weeks I've been trying to make SkiaSharp work inside Unity. It has been zero fun but I got it to work. Painfully and creakily, but it works. I suspect I'm the first person to do this so I'm going to document the process here.
Yes, this is for a game project. I'll let you know when it's closer to release.
Background: Skia is an open-source 2D graphics library from Google. It lets you fill and stroke vector shapes (polygons, circles, spline curves, text). 2D graphics features are easy for native apps and web pages, but Unity doesn't have any such feature.
(There's LineRenderer but that's very limited. No polygon fill, no true curves.)
Skia is a native library -- you can download compiled libraries for Mac, Win, Linux, etc. Then the Xamarin people created SkiaSharp, which is a C# wrapper for Skia. Problem solved, right? Drop the library into Unity, build Mac and Windows apps, go.
Nope. It was a headache. But I made it work, in a very clunky way.
UPDATE, SEPT 4: Many thanks to Marshall Quander, who read my original blog post and clued me into the right way to set things up. Or, at least, the less-wrong way. This post is much shorter now!
I'm not going to describe every blind alley. (See the end of this post for a taste.) Instead, I will give a recipe for creating a tiny Unity project which displays some 2D graphics. Follow along!

Orientation: the library files

SkiaSharp consists of two libraries. You will need both:
  • The managed library (compiled C# code). This is called SkiaSharp.dll.
  • The native library (compiled C++ code). This is called libSkiaSharp.dll on Windows, libSkiaSharp.bundle on Mac, and I guess it would be libSkiaSharp.so on Linux.
I have handily collected all the files you need here. Download SkiaComponents.zip and unpack it.
You now have these files:
  • managed/SkiaSharp.dll
  • native/mac/libSkiaSharp.bundle
  • native/win-x86/libSkiaSharp.dll
  • native/win-x64/libSkiaSharp.dll
  • RawImageDraw.cs
  • README.md

Where I got these files.

If you just want to make the demo work, skip ahead to the Recipe section. This is archeology.
You can download most of these files from SkiaSharp's nuget page. Hit Manual Download, rename the resulting skiasharp.1.59.1.nupkg file to skiasharp.1.59.1.zip, and unzip it.
Critical note: for Windows, you must use the native DLLs from runtimes/win7-x64 and runtimes/win7-x86. I spent days trying to get the one from runtimes/win10-x64 to work on my 64-bit Windows 10 machine. It would... not... load. Stick with the win7 versions.
The other critical note: for Mac, you have to rename their libSkiaSharp.dylib file to libSkiaSharp.bundle. This is clearly silly, since the standard OSX suffix for dynamically-loadable libraries is .dylib and has been forever. But Unity doesn't like that. .bundle works, so we go with that.
The managed/SkiaSharp.dll file is (essentially) the same one that nuget distributes as lib/net45/SkiaSharp.dll. As I said, this isn't really portable. The reason is that it tries to import libSkiaSharp.dll with that file suffix hardwired in. I had to build a new version which imports libSkiaSharp, without a file suffix. C# is smart enough to pick the right suffix.
But wait, you ask, what about SkiaSharp.dll.config? This is a config file from the nuget package which is supposed to paper over all those suffix problems. Yeah, well, that seemed to work for command-line C# builds, but I couldn't make it work inside Unity. So I'm ignoring it.

The recipe

Okay, on with the recipe. I am doing this on a Mac, in case it matters. (I hope it doesn't matter, but...)
  • Launch Unity 2017.1.0f3.
  • Create a new 2D project called SkiaDemo.
  • Select Edit / Project Settings / Player to open PlayerSettings in the inspector pane. Select the Other Settings subpane. Under Configuration, set Scripting Runtime Version to "Experimental (.NET 4.6 Equivalent)". Unity will then insist on restarting.
This is necessary because the SkiaSharp library is built for .NET 4.5. At least, the version that I got to work is, and I'm scared to change the recipe any further.
  • Select GameObject / UI / Raw Image. Unity will create a white square. In the hierarchy window, you'll see a Canvas object containing a RawImage.
If you don't see the white square, make sure you've selected the Game tab in the central window.
  • Hit Save and save your scene with the name Main (in the Assets folder).
  • Select the Canvas. In the inspector, set UI Scale Mode to "Scale With Screen Size". This isn't necessary but this mode makes the most sense to me.
  • In the bottom pane, switch to the Project tab. Select the Assets folder. Select Assets / Create / Folder to create a new folder inside Assets; name it SkiaSharp. Then create folders called x64 and x86 inside Assets/SkiaSharp
  • Copy managed/SkiaSharp.dll to your Assets/SkiaSharp folder.
  • Copy native/win-x64/libSkiaSharp.dll to your Assets/SkiaSharp/x64 folder. Select it in the bottom pane. In the import inspector, select the Platform Settings / Editor (Unity) pane, and set CPU to x86_64. Then select the Platform Settings / Standalone (⬇) pane, and uncheck x86. Then hit the Apply button.
  • Copy native/win-x86/libSkiaSharp.dll to your Assets/SkiaSharp/x86 folder. Select it in the bottom pane. In the import inspector, select the Platform Settings / Editor (Unity) pane, and set CPU to x86. Then select the Platform Settings / Standalone (⬇) pane, and uncheck x86_64. Then hit the Apply button.
  • Copy native/mac/libSkiaSharp.bundle to your Assets/SkiaSharp folder. Don't mess with its import settings. It's not necessary, and when I tried it I broke my Unity project entirely.
When you are adjusting the import settings, make sure you're adjusting them for the right file each time! It's easy to leave the wrong file selected. The Information section at the bottom of the inspector pane will show the one you're editing.
  • Copy RawImageDraw.cs to your Assets folder.
  • In the hierarchy window, select the RawImage component. In the inspector, hit Add Component; select Scripts / Raw Image Draw.
  • Pray sincerely to the .NET gods and hit the Run button. You should see:

The RawImageDraw.cs code draws these shapes and lines on a transparent background. (The dark-blue background color comes from the Main Camera component; the RawImage is drawn on top of that.)
Note that the triangle and semicircle are drawn in translucent yellow (50% opacity).
  • You should now be able to build Mac and Windows apps. Both x86 and x86_64 should work on both platforms. (Although all Macs are 64-bit-capable, and have been for the last ten years, so you can ignore Mac x86.)

The pain points

The big one: What about Linux? Sorry, the SkiaSharp project doesn't offer a compiled Linux library. (See README.) If someone builds one, let me know.
A second problem: Unity does some incomprehensible caching of libraries. Sometimes your app will refuse to run in the editor even though you have the right libraries installed. Sometimes it will run correctly even though you don't have the right libraries installed. (Even if you restart Unity!) This made writing this post very, very horrible. If you follow these instructions and they don't work for you, all I can say is that I sympathize.

Some more notes

  • To understand the Skia calls in RawImageDraw.cs, see the SkiaSharp reference docs.
  • I always draw into a Unity RawImage object. That is, I take the Texture2D created by the Skia render process and put it into a RawImage. You can probably do other stuff with that Texture2D, but I haven't experimented.
  • If you create a RawImage in your Unity project and then draw into it at run time, the player might see a flash of a blank white square. To avoid this, create a small transparent PNG in your Assets and set the RawImage to that texture.
  • Remember that the RawImage size is completely independent of the Skia canvas size. In this example, we render a 256x256 pixel canvas and then drop it into the RawImage, which is scaled to a percentage of your window size. (Because of the "Scale With Screen Size" canvas setting.) Choosing a canvas size is outside the scope of this recipe; I don't have any advice for you.
I hope this is helpful! And I hope it stays helpful for more than a few months! (I know how it is to find five-year-old programming advice on a blog somewhere which no longer works...)
Again, please comment or contact me if you have anything to add.

Old bad version

Don't follow the instructions in this last section. I am preserving my notes from my original attempt at making this work. I made it work, but it required hand-tweaking of your game packages after building. (On both Mac and Windows.)
Before Marshall Quander explained how to build the right managed library, I was building two SkiaSharp.dll files -- one for Mac, one for Windows. One of them linked to the native libSkiaSharp.dll library, one to the native libSkiaSharp.dylib library. In theory, a managed library is portable and you'd use the same one on every platform, but in practice I couldn't make that work.
Or rather, I thought of a way to make it work, but it would cost performance on every graphics call, and why bother? We're already going to have to install a different native library on every build. Might as well install a different managed library too.
To make life even worse, I couldn't even include both SkiaSharp.dll files in the same project. They conflicted at build time. Yes, Unity has import settings which are supposed to let you include separate Mac and Windows libraries such that right one loads on each platform. Guess what? They don’t work. You have to install just one set in your project and stick to editing on the same platform all the time.
In the course of getting that to work, I wound up putting both the Mac and Windows native libraries into nonstandard locations. The Mac library went into SkiaDemo.app/Contents/Frameworks/MonoEmbedRuntime/osx; the Win library went into SkiaDemo_Data/Mono. Why these locations? (Neither appears in a normal Unity-build app at all.) Basically, these were the first locations I found that worked. I was so worn out by the experimentation process that I just put a pin in that solution and moved on.
Of course, Unity wasn't putting the files in my special locations. (In fact Unity wasn't putting the Mac library anywhere, since it was a .dylib and Unity doesn't recognize those.) So I had to manually copy the files into place. I wrote a Python script to help with this, not that Python is particularly comfortable for most Unity developers.
Then I came up with a nice hack based on PostProcessBuildAttribute, which made the file-copying happen magically inside the Unity build process.
I was pretty pleased with the PostProcessBuildAttribute trick, in fact. But it's not necessary now that I've got everything else sorted out. Oh well. Maybe I'll use it for something else someday.

2 comments:

  1. Followup: I have been pointed at https://docs.unity3d.com/Manual/BuildPlayerPipeline.html , which should let me automate the build postprocessing. Will investigate.

    ReplyDelete
  2. Marshall Quander has explained how to make the managed library (SkiaSharp.dll) truly cross-platform, so you don't have to fuss with different versions of that. In short: compile it with *no* file suffix on the DllImport line, and it will figure out the right suffix for your platform!

    "That was easy."

    With this change, the Windows native library loads correctly from where Unity puts it, so there's no need to post-install that. (Or maybe that always worked right, and I just didn't test enough...)

    So now the only post-processing needed is to install the Mac native library. I'm still looking to see if BuildPlayerPipeline can handle that.

    I will update the post this weekend to explain the simpler procedure.

    ReplyDelete