This repo showcases using source generators to improve JS and Unity interop with Blazor.
It is in alpha stage only (available only as repo, as manual adjustments for each project are expected), ie there is still cleanup, better separation and fixing bugs and edge cases needed!
With the intent of adding 3D rendering capability to Blazor, which can then be developed using native technology (Typescript or Unity) and source generator handles interop.
Mainly by generating TypeScript types (for ThreeJS and BabylonJS renderers), and setting up automatic implementation of interface methods to be routed through a binary interface (e.g. JS Interop to get data to Javascript world and then back to Unity).
So that developers do not have to add each method to the specific way interop is being handled, but only define interfaces and all boilerplate code is generated by the Source Generator.
Building upon similar ideas as my CodeFirstApi repo of using interfaces in shared library to generate the APIs.
Instead of:
- creating the TypeScript types manually that need to match the C# types
- Marking each method in C# that should be callable from JS with attribute
- Manually ensuring the TypeScript methods that the C# code expects are there and implemented
// Might be less initial boiler plate but then needs manual definition for each method and synchronizing the C# code and TS code
var module = await _jsRuntime.LoadModuleAsync("module.js");
_messageReceiverProxyReference = DotNetObjectReference.Create(new MessageProxy(OnRendererInitialized, ...));
_typescriptApp = await module.InvokeAsync<IJSObjectReference>(initMethod, _containerElementReference,_messageReceiverProxyReference,nameof(BinaryApiJsMessageReceiverProxy.OnRendererInitialized), ...);
public ValueTask InitializeRenderer(RendererInitializationInfo msg)
{
return _typescriptApp.InvokeVoidAsync("InitializeRenderer", msg);
}
...
[JSInvokable]
public ValueTask OnRendererInitialized(RendererInitialized msg)
{
return _eventHandler.OnRendererInitialized(msg, this);
}
...
You can:
- define one or many interfaces in a shared library
- Generated TS representation of the interface and all referenced types
- Either (see BlocksOnGridThreeJSRenderer.cs):
- the generator uses prepared methods for generic Binary API from TypeScript to Blazor (that can send binary array there and back)
- the generator creates wrapper for each method of the interface using this Binary channel
- and using MemoryPack as (for now hard coded) serialization method optimizes serialization of messages
- and ensuring that any changes in the interfaces have to be taken into account in both Unity and Blazor codebases
- the generator uses prepared methods for generic Binary API from TypeScript to Blazor (that can send binary array there and back)
- Or (see BlocksOnGridBabylonDirectRenderer.cs):
- Generating wrapper so that each method in C# that can be callable from JS using Blazor native JS interop
- and that each interface method maps to a JS method in the generated interface
// needs to be initialized, but then interface implementations are directly callable, and TypeScript code is generated to match the C# code
IBlocksOnGrid3DController _eventHandler; // initialized to handler you want to handle any events
var binaryApi = new JsBinaryApiWithResponseRenderer(_jsRuntime, _logger); // prepare binaryAPI communication channel
await binaryApi.InitializeJsApp("module.js", _containerElementReference); // connect the binaryAPI to JS
appApi = new BlocksOnGrid3DRendererOverBinaryApi(binaryApi, new MemoryPackBinaryApiSerializer(), new PoolingArrayBufferWriterFactory(), _eventHandler); // initialize app specific code
appApi.InitializeRenderer(new()); // invoke methods that are mapped to events on TypeScript side
Instead of:
- manually adding each method as Unity to JS method and JS to Blazor method
- with added need for manually serializing the data as only primitive types are supported (due to WASM layer between Unity C# code and the JS world)
- maintaining any change to these method signatures
- Compiling Unity project into WebAssembly on each change of the Unity codebase
// Might be less initial boiler plate but then needs manual definition for each method and synchronizing the C# code and TS code
var module = await _jsRuntime.LoadModuleAsync("module.js");
_messageReceiverProxyReference = DotNetObjectReference.Create(new MessageProxy(OnRendererInitialized, ...));
_typescriptApp = await module.InvokeAsync<IJSObjectReference>(_initMethod, _containerElementReference,_messageReceiverProxyReference,nameof(BinaryApiJsMessageReceiverProxy.OnRendererInitialized), ...);
public ValueTask InitializeRenderer(RendererInitializationInfo msg)
{
return _typescriptApp.InvokeVoidAsync("InitializeRenderer", msg);
}
...
[JSInvokable]
public ValueTask OnRendererInitialized(RendererInitialized msg)
{
return _eventHandler.OnRendererInitialized(msg, this);
}
// JS code to connect Unity instance to Blazor and pass each message along to the right interop method
// And on Unity side (for each method):
[DllImport("__Internal")]
private static extern string _InitializeEventHandlers(Action<int, int>? initializeRenderer, ...);
[MonoPInvokeCallback(typeof(Action<int, int>))]
private static void _InitializeRenderer(int size, int id)
{
}
...
[DllImport("__Internal")]
private static extern int _OnRendererInitialized();
...
You can:
- define one or many interfaces in a shared library
- the generator uses prepared methods for generic Binary API (that can send binary array there and back between Blazor and Unity)
- the generator creates wrapper for each method of the interface using this Binary channel
- and using defined serialization methods (e.g. MemoryPack being faster that JSON)
- and ensuring that any changes in the interfaces have to be taken into account in both Unity and Blazor codebases
- with added benefit of using Websockets as BinaryAPI and connecting to Unity Editor in dev builds (to have faster iteration time)
IBlocksOnGrid3DController eventHandler;// initialized to handler you want to handle any events
binaryApi = new JsBinaryApiWithResponseRenderer(_jsRuntime, _logger); // prepare binaryAPI communication channel
await _binaryApi.InitializeJsApp("module.js", _containerElementReference); // connect the binaryAPI to JS
appApi = new BlocksOnGrid3DRendererOverBinaryApi(binaryApi, new MemoryPackBinaryApiSerializer(), new PoolingArrayBufferWriterFactory(), eventHandler); // initialize app specific code
appApi.InitializeRenderer(new()); // invoke methods that are mapped to events on Unity side
// a generic binary two way channel is prepared in included JS files.
// and Unity side (the methods and events are enforced by the interfaces and generators):
var eventHandler =...; // initialized to handler you want to handle any events
var binaryApi = UnityBlazorApi.Singleton; // prepares binaryAPI communication channel and connects it to JS
var controller = new BlocksOnGrid3DControllerOverBinaryApi(binaryApi, new MemoryPackBinaryApiSerializer(), new PoolingArrayBufferWriterFactory(), eventHandler); // initialize app specific code
await controller.OnRendererInitialized(new()); // invoke methods that are mapped to events on Blazor side
- By creating abstraction via an Interface
- And automatically handling all specifics
- you can then easily create other implementaions of the interface using other approaches or Blazor itself
- e.g. to create HTML only fallback mode in case GPU is not available on the client machine for performant 3D rendering
- See BlocksOnGridHTMLComponent.razor
-
4 Source Generators:
- BlazorWith3d.CodeGenerator.BlazorBinding:
- to generate Blazor JS interop classes to set up the Attributes needed to map JS methods to C# methods, and expose C# methods as JSInvokable methods.
- BlazorWith3d.CodeGenerator.TypeScript:
- to generate Typescript interfaces based on C# interfaces
- to generate appropriate JS code to invoke the JSInvokable C# methods exposed in BlazorBinding generator
- BlazorWith3d.CodeGenerator.DotnetBinaryApiBinding:
- to generate implementations of marked interfaces that implement methods via messages over a two-way binary API in C# (for communication between Blazor and Unity, e.g. via Interop or Websocket connection to Unity Editor)
- BlazorWith3d.CodeGenerator.MemoryPack:
- to generate implementation of marked interfaces that implement methods via messages over a two-way binary API in TypeScript (for now tightly bound to using MemoryPack as serializer for binary messages)
- to supplement MemoryPack's TypeScript generation capabilities with adding TypeScript class generation for Sequential Structs in C# (for use as serializer in code generated in DotnetBinaryApiBinding)
- BlazorWith3d.CodeGenerator.BlazorBinding:
-
Blazor libraries
- BlazorWith3d.Shared.Blazor
- Shared library with Blazor specific types (items that are not specific to JS nor Unity renderers)
- BlazorWith3d.Unity
- Blazor specific code for creating components that use Unity Web and communicate over BinaryApi
- BlazorWith3d.JsRenderer
- Blazor specific code for creating components that use JS library and communicate over BinaryApi or native Blazor JS interop
- BlazorWith3d.Shared.Blazor
-
.NET/Unity Shared libraries
- BlazorWith3d.Shared
- Shared library with types to define types and interfaces for creating BinaryApis
- The same code is exposed as Unity package
- BlazorWith3d.Shared
-
Unity packages
- BlazorWith3d.Unity.UnityPackage
- Unity specific implementation of Binary API over JS interop
- Expose connection WebSocket connection from Unity Editor for debugging/development purposes to be able to connect to Unity Editor from Blazor app to improve iteration speed
- Contains binaries of the BlazorWith3d.CodeGenerator.DotnetBinaryApiBinding generator annotated in a way to be usable in Unity
- Include
Marker.BlazorWith3d.CodeGenerator.DotnetBinaryApiBinding.additionalfile
file in an asmdef you want the generator to be used in Unity
- Include
- BlazorWith3d.Unity.UnityPackage
Simple app for placing and moving blocks on grid. To test renderers and interop capabilities.
Supports using multiple renderers at the same time.
Main projects:
- BlazorWith3d.ExampleApp.Client.Shared project contains the shared code definitions used by the generators.
- BlazorWith3d.ExampleApp.Client project the main app itself.
- BlazorWith3d.ExampleApp.AspNet project is the ASP.NET app that is used to run it.
Native Blazor interop is sufficient for most cases of JS libraries (but some binary approaches can still be faster than underling JSON)
Generators can be used to create the binding classes based on an interface. After initial set up, quick nice to work with and extend.
Simple method/event style interfaces can be implemented even with stream only. useful to get websocket connection to Unity Editr as it is slower than other approaches.
Coordinate systems between different rendering libraries can be a pain. Choose carefully, but conversions are always doable.
This repo shows numerous approaches, but in the end, one or two are probably enough for your app.
The level of abstraction how much to do in 3d specific library vs in main app should depend on use-case and the amount of code that should be shared. e.g. if only one renderer then easier to do more in the renderer. If more are expected than moving more to main Blazor app can be beneficial
This repo show generating a communication channel based on an interface. i.e. shows practical usage of source generators to help with boilerplate
But the approach (as in Unity Editor Websocket debugging) can be used for communication over any channel. The generator is not created to work over some kind of generic channel or Stream. Although it could, it is always intended that the final application might need to fork it and adjust it. As generic approaches loose performance (e.g. binary channels with direct response support being faster than binary channel without it, or having Blazor specific code)
This approach can be used for interprocess communication or any other kinds of native integrations. As source generators can create the binding code on both sides, or create apis on top of simple two-way stream (e.g. as in Unity WebGL interop, the interop is too messy to generate for each method signature, as it is more basic than Blazor/JS interop)
The serializers can be replaced, e.g. use built in JSON serializers. Although Unity one is very limited, e.g. no property support, no nullable struct support, root object must be class, .... Although MemoryPack has own limitations, e.g. no Typescript generation for structs (has to be done in fork)
Built in Unity serializer is not used as it breaks compatibility with native Blazor JS interop (which does not support serializing fields) But can be considered if the required renderers are not using native Blazor JS interop (as the field support can be added) Or a solution based on Newtonsoft JSON can be used, as that is more feature complete but is an extra dependency.
The TS generation is a bit intertwined with MemoryPack, since you could get rid of it, and in some cases it would work fine. But mixed modes need to sync TS code and C# code which is a bit messier. So it is kept connected for now to MemoryPack. But for DirectInterop any other or custom TS type generator from C# types could be used.
- coordinate systems
- Blazor (adjustable as the app should choose this)
- RightHanded
- Screen (0,0) in top left
- World X:right, Y: up, Z: toCamera
- camera looks in NegativeZ
- directionalLight shines in NegativeZ
- Rotation is in Clockwise direction
- Unity
- LeftHanded
- Screen (0,0) is Bottom Left
- World X:right, Y: up, Z : fromCamera
- camera looks in PositiveZ
- Rotation order Z, X, Y (when going local to world)
- Babylon
- LeftHanded
- Screen (0,0) in top left
- World X:right, Y: up, Z : fromCamera
- camera looks in PositiveZ
- Rotation order Y, X, Z (when going local to world)
- rotation is in Counterclockwise direction
- ThreeJS
- RightHanded
- Screen (0,0) in center (positive in direction of top right) range: -1;1
- World X:right, Y: up, Z : toCamera
- camera looks in NegativeZ
- Rotation order Z, X, Y (when going local to world) but can be chosen
- rotation is in Counterclockwise direction
- Blazor (adjustable as the app should choose this)
Can use both MemoryPack serializer and Unity JsonUtility based serialization.
- MemoryPack has better performance and more comprehensive support for language features (e.g. Properties) but needs extra libraries to be added (in case build size is a concern)
- JsonUtility based approach has less dependencies, but performance is slower (messages take roughly 2x the time), and has very limited feature support (no properties and no nullable fields)
There is npm run watch
command to run in the assets
subfolder of the project, e.g. \samples\BlazorWith3d.ExampleApp.Client.ThreeJS\assets
folder, that does live recompilation of the TS codebase. Mainly to adjust the app.ts
as needed.
The Typescript code is built when C# Project is. MemoryPack and own generator create .ts
files as well and after build Webpack is triggered to create single JS file for the Blazor Component to load.
Kept mainly for historical reasons, as ThreeJS seems to be more fitting for this usecase (as this is a game engine first, and threeJS is a renderer first).
Uses 2D screenshot for visuals, to look similar.
https://learn.microsoft.com/en-us/aspnet/core/blazor/performance?view=aspnetcore-9.0
https://github.com/dotnet/runtime/blob/main/src/mono/wasm/features.md
All render modes, including Auto mode are supported ( render mode can be chosen on the Home page)
Blazor Maui Hybrid is supported (only tested as Desktop app) https://learn.microsoft.com/en-us/aspnet/core/blazor/hybrid/tutorials/maui-blazor-web-app?view=aspnetcore-9.0
Small msg is a message with a couple of numbers Large msg is a message with an almost 4k-character long string
Benchmarks are times for 10k messages.
Specific numbers should be only used as relative comparison, as this was times only on my machine.
Timed on my machine with release build.
But in comparison to WebAssembly version, most time is lost on sending data back and forth from server to browser, then on interop specifics.
- Unity with MemoryPack serializer
- Small messages took 2596,00 ms (avg 0,26 ms)
- Large messages took 3223,00 ms (avg 0,32 ms)
- Unity with JsonUtility serializer
- Small messages took 2791,00 ms (avg 0,28 ms)
- Large messages took 4055,00 ms (avg 0,41 ms)
- Blazor JS interop with MemoryPack
- Small messages took 2069,00 ms (avg 0,21 ms)
- Large messages took 2646,00 ms (avg 0,26 ms)
- Blazor JS interop native
- Small messages took 1909,00 ms (avg 0,19 ms)
- Large messages took 2734,00 ms (avg 0,27 ms)
Mainly for use as relative comparison, as they were timed on my machine with deployed demo app which is AOT compiled for webassembly.
For Javascript based render (e.g. Three.JS or Babylon.JS), Blazor JS native interop is quicker for smaller messages, but slower on big ones. But not significantly.
For Unity based renderer memory pack is faster for small messages and significantly faster for large ones, but it introduces extra dependencies making the build larger.
- Unity with MemoryPack serializer
- Small messages took 196.00 ms (avg 0.02 ms)
- Large messages took 282.00 ms (avg 0.03 ms)
- Unity with JsonUtility serializer
- Small messages took 314.00 ms (avg 0.03 ms)
- Large messages took 853.00 ms (avg 0.09 ms)
- Blazor JS interop with MemoryPack
- Small messages took 166.00 ms (avg 0.02 ms)
- Large messages took 291.00 ms (avg 0.03 ms)
- Blazor JS interop native
- Small messages took 155.00 ms (avg 0.02 ms)
- Large messages took 412.00 ms (avg 0.04 ms)
- controller exists first (ie controller is singleton and gets a single renderer attached, the controller does not handle lifecycle of renderers, only should SetController to null when renderer is being replaced)
- renderer is created and prepares to listen and sets the EventHandler to the controller
- renderer calls SetRenderer on the Controller
- Controller starts sending commands during SetRenderer execution
- ie renderer can send messages to controller even before registering (as the controller has multiple renderers so it is considered more as API, than a tightly bound pair)
- and renderer should expect messages to already arrive during SetRenderer execution
CODE: 409 -> projectkudu/kudu#3042 (comment)
Add serializer concept to Typescript as MemoryPack generator needs to be split into BinaryApi and MemoryPack serializer
Handle warnings in Generator
extract the generators into own projects (as this repo is too 3D specific) and publish them and supporting libraries as nuget
-
showcase that there does not have to be direct mapping
- generate wire connecting the instances
- and generate cube under the gltf mesh
-
And drag and drop trigger to add blocks from HTML
-
Add context menu to delete block instance
-
check if GLTF instancing is working
Support of System.Numerics native types
-
sadly the support might be limited due to harder to control serialization of built in types
-
ensure the generator has fully optional dependence on memory pack, even for js direct interop use case
- if turned on via csproj config, generate typescript types intended for direct interop
- ie using asp.net interop types
- or via attribute, have MemoryPack based attribute, or have .net wasm interop generator via another attribute
- if turned on via csproj config, generate typescript types intended for direct interop
-
Optimize Typescript dev experience
- add option to live recompile changes
- add debugging support to IDEs
- switch to Vite as everybody's using it ( see https://doc.babylonjs.com/guidedLearning/usingVite/ )
- better JS isolation
-
better JS plugin via $ as in https://github.com/Made-For-Gamers/NEAR-Unity-WebGL-API/blob/main/Assets/WebGLSupport/WebGLInput/WebGLInput.jslib
-
add explicit serializer interface for Typescript (to be able to override and mainly expose new types serialization)
-
Unity debug socket is logging a lot of errors, handle the disconnect cases more explicitly
-
cleanup, refactor generator, too many things seemingly hardcoded and edge cases not handled (e.g. namespaces of messages, or if multiple apps are defined)
-
Investigate and optimize render modes and stream rendering and better handling of Maui limitations for render mode https://learn.microsoft.com/en-us/aspnet/core/blazor/components/render-modes?view=aspnetcore-9.0 https://learn.microsoft.com/en-us/aspnet/core/blazor/components/rendering?view=aspnetcore-9.0#streaming-rendering
-
try again to get matrix for screen to world as that would reduce the need for extra interop call
- even basic raycast can be then doable in .NET
-
do Isometric or fake-3d in CSS only for HTML version
- https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/perspective
- must refactor it, to recreate the scene approach as in Unity, to make sense of it
- should use pre-rendered images
- mainly as otherwise it is hard to render depth
- add top down thumbnail image of model (for HTML)
-
split packages/libraries by abstraction level (raw message, then typed message, then generated API)
-
test/add support to generator for defining messages from other assemblies
-
with memory pack the Unity build got slower, investigate why exactly!
- e.g. might be worth having a define or something to switch the serialization libraries (have one for faster compile time and one for faster runtime)
- could be worth having a method to negotiate the serialization scheme (kinda send supported schemes when connecting to renderer and it picks one)
-
switch to nicer ways to share memory in WASM special case
- https://learn.microsoft.com/en-us/aspnet/core/client-side/dotnet-interop/?view=aspnetcore-9.0#type-mappings
- there should be better mapping with arraySegments now, potentially preventing memorycopy when creating array for normal JS interop
- Not sure if with TS interop it is worth it, as the IMemoryView either way does not expose the internal array, so a copy would be necessary (or touching private method of a type???)
- But for Unity interop it could be worth it, as the memory could be then set directly into Unity heap
- https://github.com/dotnet/runtime/blob/main/src/mono/browser/runtime/marshal.ts#L558
- // RESOLUTION
- this does not interop all types, so method returning Task<ArraySegment> has to be split into 2 calls retuning Task and ArraySegment
- the interop is staticky, ie you do not have instances to interop with, meaning you need to pass around an ID of the instance (if you want to handle case where you have multiple renders at the same time)
- NOT worth it for now
-
consider support for union types to handle collider definition etc (lower prio as this goes a bit into serialization libraries support)
-
native Veldrid based renderer https://veldrid.dev/
- has MAUI support https://github.com/xtuzy/Veldrid.Samples or https://www.nuget.org/packages/Veldrid.Maui/
-
Maui app with native Unity build
- https://github.com/matthewrdev/UnityUaal.Maui (not working in windows, as MAUI in general does not support embedding other exes as views)
- Unity in Maui is not officially supported. There are ways but more focused on mobile
- Maui windows does not allow unity exe direct yet. There is a feature request for this
- https://github.com/matthewrdev/UnityUaal.Maui (not working in windows, as MAUI in general does not support embedding other exes as views)
-
Winforms app with Blazor and Unity
-
Evergine
- has MAUI support (not tested)
- has WASM support
- Evergine could work better for Maui but wasm Is still weird, as it needs to be hooked into was compilation, not just razor project.
- Also wasm build works, but creating reusable razor component is not officially available, and without payed support harder to achieve as community is very small.
-
three port to C# for maui https://github.com/hjoykim/THREE
-
add support for negotiation of serialization modes
- so unity can do DEBUG build with JSON only serializaion, e.g. for debug builds with embedded WebGL template as using memory pack is cumbersome there
-
consider some generic reactive dictionary or patch requests on object support
- e.g. that both sides can instantiate kind of reactive dictionry and through generic messages they both can be kept automatically in sync, with changes always propagating to the other side
- kinda like flux https://facebookarchive.github.io/flux/docs/in-depth-overview/