Version: 1.3
语言 : 中文
Dotnet Wasm方案
SGen GC算法

.NET 8

微软官方的.NET已经实现了对WebAssembly的稳定支持,相较于过往的IL2CPP技术,.NET不仅因为其生态系统具有得天独厚的优势,在多方面性能表现上也有提升。我们已在微信小游戏平台加入了以.NET 8为基础的Scripting Backend,使用裁剪优化后的mono作为.Net运行时,充分利用引擎原本对mono的支持,使得用户几乎可以无感的接入使用。

原理介绍

构建流程

下图是WeixinMiniGame默认构建的流程示意图,在实际项目构建过程中,真正执行的构建流程可能会因为PlayerSetting的设置等在细节上有所不同

如上图,.NET 8构建环节与IL2CPP基本相同相同,区别主要在构建Wasm的方式和产物。下面我们对DotNet Wasm方案构建流程逐步展开描述:

DLL Compile and Strip

  1. Compile C# to IL

    使用Roslyn将用户的C#脚本编译为IL,以dll文件形式参与后续构建流程。

    • Input: 用户C#脚本(包含Package和自定义.asmdef)
    • Output: dll文件(IL)
  2. Strip Managed Code

    使用UnityLinker扫描项目用到的Dll并作可选的代码剔除,使得生成的Dll更小。

    • Input: 上一步编译出的dll文件;Unity Module中的dll文件;Plugin中的dll文件;.NET BCL
    • Output: ManagedStripped dlls;UnityLinkerToEditorData.json
  3. Generate icall and Register Unity Modules

    根据UnityLinker的裁剪的结果,生成引擎部分的Native注册类,参与后续构建。注意因为项目区别此处生成的类数量也会有差异,对Wasm体积产生影响。

    • Input: UnityLinkerToEditorData.json, Unity Modules
    • Output: UnityClassRegistration.cpp,UnityICallRegistration.cpp

到目前为止的构建步骤跟产物与IL2CPP完全相同,下面将介绍DotNet Wasm方案的构建路线。

.NET 8 MSBuild

  1. Scan for PInvoke and icall

    扫描所有的ManagedStripped dll文件中,函数实现在Native的部分,生成wrapper function table,为解释执行做准备。这部分函数来源于 .NET BCL 与引擎Native模块,主要是Platform Invoke(PInvoke) 以及 internal call(icall),因此结果保存在 pinvoke-table.h icall-table.h 两个文件中。

  2. Compile Native Files

    根据上一步生成的Wrapper functions连同之前生成的引擎Native注册类参与后续构建; 如果Plugins目录中包含cpp或者c文件则也会在这一步编译。

  3. Link and Compile

    此步骤为生成wasm和js framework的核心步骤,除了上一步生成的Native Objects,Unity的静态链接依赖和.NET运行时依赖也都会参与构建,这一部分最终将会生成dotnet.native.wasm与dotnet.native.js。

    除此之外,Unity JS Framework与.NET 8 Runtime JS以及浏览器基础功能包括IndexedDB,OpenGL API,Audio,Sensor等JS库,也会参与EMCC构建,最终生成dotnet.native.js, Dotnet MSBuild也会生成dotnet.runtime.js,这两个文件作用等同于IL2CPP中的framework.js,用于加载wasm并且提供必要的JS API。

  4. Convert dll to WebCIL

    这个步骤与之前的步骤并没有依赖关系,主要是将之前编译的dll转化为wasm格式,便于加载与调用。解释执行模式下,用户代码程序集、引擎代码程序集以及.NET基础类库(BCL)会被封装成WebCIL。WebCIL是一种将DLL信息封装为符合Wasm Binary Format的容器格式,运行时会将Payload复制到Wasm内存中。由于WebCIL本质是将DLL信息做了一次兼容Wasm规范的转换,因此可以将WebCIL理解为Wasm Binary Format 的DLL。

在项目构建完成后,DotNet Wasm 的标准产物组成大致如下图:

加载流程

在浏览器运行DotNet Wasm构建的游戏时,主要的文件加载顺序和流程如下所示:

  1. Load First Page:

    在浏览器中,首先会下载index.html和loader.js。随后渲染HTML页面,执行loader.js中的获取data文件和dotnet.js文件的逻辑,等待下载完成后进行初始化或解析。对于微信小游戏,则会由微信小游戏的Unity插件来实现与loader.js相同的逻辑来下载data并且初始化dotnet.js(webgl.wasm.framework.unityweb.js)

  2. Fetch data & dotnet.js: loader.js会分别下载data文件和dotnet.js文件,下载dotnet.js之后会马上执行初始化函数,等待初始化结束之后才会执行callMain入口函数

  3. Fetch blazor.boot.json

    dotnet.js初始化过程中,首先会加载blazor.boot.json,其中包含了项目中依赖的文件清单与Hash值,根据此文件内容来确定加载文件的名称以及是否加载缓存。

    其中包含的文件有:

    • wasmNative:即dotnet.native.wasm文件,包含Unity Module Static library、Native Plugin和.NET Runtime Static Library等内容,是wasm代码运行的核心文件。
    • jsModuleNative & jsModuleRuntime:即dotnet.native.xxxx.js和dotnet.runtime.xxxx.js,分别包含JSModule的Native和Runtime部分,负责初始化JS Module。
    • assembly:即WebCILs,从dll转化而来,包含User Script、Unity Modules/Packages和.NET BCL等。

    以上是blazor.boot.json的主要文件清单,根据构建目标的不同,可能有其它的pdb或symbol等文件,其主要目的为方便调试。

  4. Async Download Resources

    dotnet.js初始化获取具体的文件清单后,会异步下载上述所有类型的文件,其中dotnet.native.xxxx.js和dotnet.runtime.xxxx.js不会从缓存加载,其它文件则会根据hash值进行判断,如果hash值发生变化或者本地缓存不存在才重新下载并且缓存文件,否则直接从缓存中加载。

  5. Initialize mono .NET runtime

    dotnet.js初始化在资源下载完成之后,会调用dotnet.native.xxxx.js和dotnet.runtime.xxxx.js相关函数初始化JS Module,dotnet.native.xxxx.js中包含了Unity JS Framwrok、User Plugin JS和Browser Base Library,dotnet.runtime.xxxx.js包含了.NET Runtime JS。

  6. Initialize Assemblies(WebCILs)

    同样,在WebCIL下载或者从缓存加载完成之后,dotnet.js会把WebCIL(以及其它可能存在的symbol和pdb)从ArrayBuffer转换为Unit8Array,并且将其复制进heap,最后exports出去留待运行时按需加载。

  7. Instantiate dotnet.native.wasm

    dotnet.native.wasm作为核心的wasm文件,会在.NET JS Runtime初始化完成之后调用WebAssembly.Instantiate来进行实例化.

  8. Invoke Wasm Main

    在上面所有的步骤都完成之后,回到loader.js会执行Wasm入口函数callMain,正式进入游戏的启动流程。

  9. Load Assemblies (WebCILs)

    在游戏启动后会根据需求加载此前已经在heap的WebCIL,调用相关函数以完成游戏的加载和运行。

执行流程

以下结合一个具体的调用链路,简单描述一下解释执行流程:

如图中,调用由浏览器响应requestAnimationFrame的Task发起后,通过Dynamic Call(dynCall),由js调用进入wasm,触发引擎的MainLoop,由ExecutePlayerLoop进入用户代码调用,此例中是可编辑渲染管线(URP)注册的逐帧执行函数 ScriptableRenderContext::ExtractAndExecuteRenderPipeline

由C#函数触发mono_runtime_invoke,此时开始动态解析用户脚本类别信息,交由mono_interp_exec_method执行。一般来说,引擎模块与.NET BCL以Native形式实现,封装为Wasm函数,以 Internal Call(icall)的形式被C#侧调用。

在Editor中选择Scripting Backend

点击Player Settings,在Other Settings中,根据需求调整编译设置,Scripting Backend可选项为.NET 8或者ILCPP。

关闭 Wasm Exception Handling 以提高兼容性

在使用 .Net 8 Scripting Backend 时,将默认开启 Enable Wasm Exception Handling 选项,这样会获得更好的性能和更小的代码体积。

但是在某些情况下,可能会因为项目中的其他静态库或者插件使用了无法与 Wasm Exception Handling 兼容的代码(比如 CXX Exception),导致构建失败。此时可以关闭 Wasm Exception Handling 选项,以牺牲一部分性能和代码体积为代价,来提高兼容性。

JITerpreter 优化

.NET Scripting Backend默认开启.NET 8提供的JITerpreter优化。JITerpreter是一个.NET运行时中新加入的一个类似JIT(Just-in-Time)编译优化的特性,它可以将部分热点代码转换为细密的.wasm代码(一段热点代码可能生成多份wasm文件,每份wasm不超过4KB),从而提高程序的运行效率。

JITerpreter对解释执行编译的程序可以带来相当可观的性能提升,同时JITerpreter也可以用来优化wasm和JS代码间的相互调用,因此JITerpreter在AOT编译的程序中,有时也能获得小幅的性能提升。

运行JITerpreter会带来非常小量的额外的内存和性能开销(几乎可以忽略不计),总的来说,开启JITerpreter优化是利大于弊的。 JITerpreter优化在勾选了 Development Build 的情况下不会生效

Dotnet Wasm方案
SGen GC算法