Search Unity

  1. Get the latest news, tutorials and offers directly to your inbox with our newsletters. Sign up now.
    Dismiss Notice

[Source Code] C# / JavaScript interoperability library

Discussion in 'Project Tiny' started by supron, Sep 11, 2020.

  1. supron

    supron

    Joined:
    Aug 24, 2013
    Posts:
    63
    TLDR: I created C# <-> JS interoperability library. Additional features compared to plain DllImport("_Internal"):

    * Complex objects support
    * Exceptions support - you can catch C# exceptions in JS and JS Errors in C# (right now only in legacy WebGL - tiny does not allow exceptions)
    * Export C# static methods with one attribute
    * Call JS global functions with one attribute (like console.log)

    Repository: https://github.com/supron54321/com.tinyutils.jsinterop

    Example WebGL project: https://github.com/supron54321/JsInteropExample-webgl

    Example code for Tiny (I'll create project soon):
    C#:
    Code (CSharp):
    1.  
    2.     public class JsInteropTests
    3.     {
    4.         public class ComplexObject
    5.         {
    6.             public string StringField;
    7.             public byte ByteField;
    8.             public int IntField;
    9.             public uint UintField;
    10.             public float FloatField;
    11.             public double DoubleField;
    12.             public NestedClass NestedField;
    13.  
    14.             public class NestedClass
    15.             {
    16.                 public string NestedString;
    17.             }
    18.  
    19.             public string AsString()
    20.             {
    21.                 return
    22.                     $"StringField: {StringField}\nByteField: {ByteField}\nIntField: {IntField}\nUintField: {UintField}\nFloatField: {FloatField}\nDoubleField: {DoubleField}\nNestedField.NestedString: {NestedField?.NestedString}";
    23.             }
    24.         }
    25.  
    26.         // log string
    27.         [JsFunction("window.console.log")]
    28.         public static extern void ConsoleLog(string msg);
    29.  
    30.         // log object
    31.         [JsFunction("window.console.log")]
    32.         public static extern void ConsoleLogObject(ComplexObject obj);
    33.  
    34.  
    35.         [JsFunction("window.prompt")]
    36.         public static extern string Prompt(string message, string defaultValue);
    37.  
    38.         [JsCallback("window.JsInteropTests.CallbackTest")]
    39.         public static ComplexObject CallbackTest(ComplexObject complexObject)
    40.         {
    41.             ConsoleLog("As string:");
    42.             ConsoleLog(complexObject.AsString());
    43.             ConsoleLog("As object:");
    44.             ConsoleLogObject(complexObject);
    45.             return new ComplexObject()
    46.             {
    47.                 StringField = "Returned from C#"
    48.             };
    49.         }
    50.     }
    JavaScript (paste in console to test):
    Code (JavaScript):
    1. var fromCS = window.JsInteropTests.CallbackTest({
    2.     StringField: "test string 1",
    3.     ByteField: 2,
    4.     IntField: -2000000000,
    5.     UintField: 4000000000,
    6.     FloatField: 100.0,
    7.     DoubleField: 4000000000.0,
    8.     NestedField: {
    9.         NestedString: "nested string test"
    10.     }
    11. });
    12. console.log(fromCS);
    Very important note. Current callback implementation does not work with "Develop" compilation mode (only in Tiny runtime, WebGL should work). There is a bug with NativeList when it's created within callback in "Develop" mode. Everything works fine in Release. Since MsgPackWriter uses NativeList under the hood, callback throws an exception when it tries to pack the returned object. I'm working on a temporary fix.

    About this library...

    Interacting with web browser is very useful, but can be really hard in some cases. Even calling simple API with string, requires both C# and JS code to work. For more complex objects, interoperability becomes a large and laborious task. I encountered this problem earlier while I was working on an older WebGL project in my work. We created an advanced library to make this task easier, but it wasn't perfect. It was incompatible with new tiny/dots and solved only a few of our problems. Three weeks ago I started a completely new interop library for Tiny runtime. I just finished the first working version, and I would like to share it with you to make your life a little bit easier :)

    Design

    I started with three key requirements in mind. I wanted to export C# method with one simple attribute. No initialization code, no additional JS scripts. Another requirement was automated parameters serialization. With such feature I could translate complex JS object to C# class or struct. The last but very important feature was exceptions support.

    In old WebGL i could use reflection, to read metadata from method and types, but the new Tiny runtime does not support types metadata. For this reason, I got interested in new ILPostProcessor and code generation. It's still experimental and undocumented feature, but with few tips from DOTS forum, I was able to use it.

    Parameters/return serialization

    I decided to use Message Pack format, to serialize function parameters and return. I was thinking about JSON, but Message Pack was faster, easier to implement, and required less memory. I built a low level message pack reader/writer. It works with primitive types, but it does not include serialization code. Serializers are built by assembly postprocessor. Code is generated in all assemblies with js interop attributes. It's not possible to reuse serialization code for the same types in different assemblies, so code is duplicated (WASM optimizer should merge it anyway).

    Code generation

    ILPostProcessor looks for JsCallback attribute, and creates two classes. First class contains static constructor with Preserve attribute and initialization code:


    The second class is a wrapper with MonoPInvokeCallback. It reads parameters packed with message pack, calls the exported callback function, and then packs the returned value:



    The serialization code is also generated by ILPostProcessor. As I mentioned earlier there is no way to detect and merge serialization code from different assemblies, so if one type is used in two different assemblies, both will get code generated for this type. Example deserialization code:



    Future work

    After playing with ilpostprocessor for a while, I see great potential. Right now the system can only serialize and transfer a copy of an object, but it could possibly pass delegates or create cross-language references. With a few improvements we could write C# code with access to DOM elements:

    Code (CSharp):
    1. void CallJs()
    2. {
    3.     var webGlContext = document.getElementById<Canvas>("canvas").getContext<WebGL2Context>("webgl2");
    4.     var webGLVersion = webGlContext.getParameter<string>(webGlContext.VERSION);
    5. }
    Yes, it will be less efficient than direct JS calls, but it's way easier and less error-prone.


    That's all. I hope that this code will help folks like me, who struggled with js/webgl interop in the past.
     
    KamilDA, Stexe, tarikisildar and 5 others like this.
  2. SINePrime

    SINePrime

    Joined:
    Jan 24, 2019
    Posts:
    54
    Awesome! I'm giving it a go right now, but I've hit a snag:

    How do I call my own JavaScript functions, C#->JS? The only examples I see are per-existing functions (eg, window.alert). Are we still meant to use the mergeInto function via Emscripten?
     
  3. supron

    supron

    Joined:
    Aug 24, 2013
    Posts:
    63
    You can use anything you declare in the global scope. If you want to declare your functions without initialization call from C#, you can store them in *js file in prejs folder. These files are merged and run before engine code. The structure of this file is simple:

    Code (JavaScript):
    1. (function(global, module){
    2.  
    3. // your code here
    4.  
    5. })(this, Module);
    To Declare function in global scope, you can simply write:

    window.yourFunction = function(args){

    }

    check this file from my msgpack library:

    https://github.com/supron54321/com....aster/TinyUtils.MsgPack/prejs~/TinyMsgPack.js

    in line 539, you can see serialization and deserialization functions, exported to global scope:

    Code (JavaScript):
    1.     window.tinyMsgPack = {
    2.         serialize: serialize,
    3.         deserialize: deserialize,
    4.     };
    In this example, you could import it with JsFunction attribute like this:

    Code (CSharp):
    1. [JsFunction("window.tinyMsgPack.serialize")]
     
    SINePrime and tonialatalo like this.
  4. SINePrime

    SINePrime

    Joined:
    Jan 24, 2019
    Posts:
    54
    With that advice, I managed to get it working; Thanks for your help!
     
  5. AlexisSphero

    AlexisSphero

    Joined:
    Oct 29, 2020
    Posts:
    12
    I've been trying to add this project to the Tiny3D example project to play with it, and haven't had much luck. Unity doesn't seem to like me tyring ot use the TinyUtils.JsInterop namespace I always get this error:

    Code (CSharp):
    1. The type or namespace name 'TinyUtils' could not be found (are you missing a using directive or an assembly reference?)
    I've tried both the installation methods you describe in the readme. I can go into either of the two packages, intentionally break something, and Unity sees the issue and flags it, so it is compling the package. I've also tried using both VS and VSCode on both and PC and Mac, to see if it's an issue with Unity not talking to the editor. But I always seem to get the same result.

    The JSInterop menu does appear in Unity so it is finding something. I'm not sure what the Analyze option is supposed to do, doesn't seem to do much of anything.

    Am I using the wrong namespace? Anyother ideas on what may be going wrong? I'd love to try and use this to experiment with having Project Tiny in a webview of a native Android app and sending messages back and forth, but so far I haven't been able to make it work.
     
  6. SINePrime

    SINePrime

    Joined:
    Jan 24, 2019
    Posts:
    54
    Is this preventing builds, or is it only an IDE problem?

    I had a similar problem a while ago (VS Code not working with referenced assembly definitions). I fixed it by downgrading the C# plugin in VS Extension to v1.23.2
     
  7. AlexisSphero

    AlexisSphero

    Joined:
    Oct 29, 2020
    Posts:
    12
    It's preventing builds, so it's not just an IDE problem. I see the error both in the IDE but also in Unity.

    In fact trying to fix it, at one point I got VS to not recognize any of the Unity namespaces. I've gotten that working again, it's just these packagse that Unity refuses to see, although they do show up in the package manager and are listed in the manifest.json.
     
  8. SINePrime

    SINePrime

    Joined:
    Jan 24, 2019
    Posts:
    54
    Are you referencing the package's assembly definition in your root tiny assembly definition?
     
    AlexisSphero likes this.
  9. AlexisSphero

    AlexisSphero

    Joined:
    Oct 29, 2020
    Posts:
    12
    Thanks. That was in fact the step I missed. Now everything seems to be working fine.
     
  10. SINePrime

    SINePrime

    Joined:
    Jan 24, 2019
    Posts:
    54
    Any plans on developing this further? Array support would be extremely useful.
     
  11. djsell

    djsell

    Joined:
    Aug 29, 2013
    Posts:
    59
    This reminded me that I had an old project where I was doing interop between a Unity WebGL build and SignalR JS library for multiplayer.

    When I wanted to pass an array of bytes, I just packed it up as a string and had no problem sending it back and forth.

    Code (CSharp):
    1.         private void Send(string group, byte[] bs)
    2.         {
    3.             if (string.IsNullOrEmpty(group)) throw new ArgumentNullException(nameof(group));
    4.             if (bs == null || bs.Length == 0) throw new ArgumentNullException(nameof(bs));
    5.             SignalRSendTick(group, Convert.ToBase64String(bs));
    6.         }
    7.  
    8.        [DllImport("__Internal")]
    9.        static extern void SignalRConnect(string url, string user, string password,
    10.               Action onConnected,
    11.               Action onConnectError,
    12.               Action<IntPtr, IntPtr> onData,
    13.               Action onClose);
    14.  
    15.         [DllImport("__Internal")]
    16.         static extern void SignalRSendTick(string group, string data);
    17.  
    18.         [MonoPInvokeCallback(typeof(Action))]
    19.         private static void OnData(IntPtr group, IntPtr payload)
    20.         {
    21.             var groupId = Marshal.PtrToStringAnsi(group);
    22.             var encoded = Marshal.PtrToStringAnsi(payload);
    23.             var data = Convert.FromBase64String(encoded);
    24.             OnTick?.Invoke(groupId, data);
    25.         }
    and on the JS side
    Code (JavaScript):
    1. mergeInto(LibraryManager.library, {
    2.     SignalRConnect: function (url, user, password, onConnected, onConnectError, onData, onClose) {
    3.         url = Pointer_stringify(url);
    4.         user = Pointer_stringify(user);
    5.         password = Pointer_stringify(password);
    6.  
    7.         var onconnect = function () {
    8.             console.log("connected", arguments);
    9.             Runtime.dynCall('v', onConnected, 0);
    10.         }
    11.  
    12.         var onconnecterr = function () {
    13.             console.log("connect error", arguments);
    14.             Runtime.dynCall('v', onConnectError, 0);
    15.         }
    16.  
    17.         var ondata = function () {
    18.             console.log("on data", arguments);
    19.  
    20.             // https://emscripten.org/docs/porting/connecting_cpp_and_javascript/Interacting-with-code.html
    21.             var ptrs = [];
    22.             for (var i = 0; i < arguments.length; i++) {
    23.                 var data = arguments[i];
    24.                 ptrs.push(allocate(intArrayFromString(data), 'i8', ALLOC_NORMAL));
    25.             }
    26.             Runtime.dynCall('v' + 'i'.repeat(ptrs.length), onData, ptrs);
    27.             for (var i = 0; i < ptrs.length; i++) {
    28.                 _free(ptrs[i]);
    29.             }
    30.         }
    31.  
    32.         var onclose = function () {
    33.             console.log("on close", arguments);
    34.             Runtime.dynCall('v', onClose, 0);
    35.         }
    36.  
    37.         ncc.Connect(url, user, password, onconnect, onconnecterr, ondata, onclose);
    38.     },
    39.     SignalRSendTick: function (group, data) {
    40.         group = Pointer_stringify(group);
    41.         data = Pointer_stringify(data);
    42.         ncc.SendTick(group, data);
    43.     },
    44.     SignalRJoinGame: function (game) {
    45.         game = Pointer_stringify(game);
    46.         ncc.JoinGame(game);
    47.     }
    48. });
    Not great on allocations, but it works.

    Hope the examples help.
     
  12. SINePrime

    SINePrime

    Joined:
    Jan 24, 2019
    Posts:
    54
    Thanks, your example helped point me in the right direction of passing pointers to byte arrays between C#/ JS. For anyone interested, I made some snippets: https://gitlab.com/-/snippets/2095872
     
    djsell likes this.
  13. gtk2k

    gtk2k

    Joined:
    Aug 13, 2014
    Posts:
    154
    Code (CSharp):
    1. [DllImport("__Internal")]
    2. private static extern void binaryInit(byte[], int size);
    3.  
    4. byte[] byteArray = new byte[2];
    5.  
    6. protected override void OnCreate()
    7. {
    8.     binaryInit(byteArray, byteArray.Length);
    9. }
    Code (JavaScript):
    1. mergeInto(LibraryManager.library, {
    2.     binaryInit: function (ptrArray, size) {
    3.         Module.refByteArray = new Uint8Array(buffer, ptrArray, size);
    4.     }
    5. }
    With this definition, you can also use byteArray(C#), Module.refByteArray(JS) to exchange values directly without using a callback function.

    sample snippets
    https://gist.github.com/gtk2k/db31c545cb0906061b24d785d50a5703
    https://gist.github.com/gtk2k/01f1fed49fb6ca561262bd975a66a19f
     
    Last edited: Apr 18, 2021
  14. supron

    supron

    Joined:
    Aug 24, 2013
    Posts:
    63
    Array support is not that hard to add, but I'm too busy to update this library right now. Honestly, I forgot about this project and I'm quite surprised anyone is still using this library :D. In that case, I'll try to find some free time to implement arrays.
     
  15. SINePrime

    SINePrime

    Joined:
    Jan 24, 2019
    Posts:
    54
    Ah, so you can just use byte arrays directly. I suppose that makes sense since arrays are just pointers.

    That would be awesome! I'm definitely still using it, and I've seen others on the forum reference this thread as well.
     
unityunity