微软官方的 .NET 已经实现了对 WebAssembly 的稳定支持,相较于过往的 IL2CPP 技术,.NET 不仅因为其生态系统具有得天独厚的优势,在多方面性能表现上也有提升。我们已在小游戏平台加入了以 .NET 8 为基础的 Scripting Backend ,使用裁剪优化后的 mono 作为 .Net 运行时,充分利用引擎原本对 mono 的支持,使得用户几乎可以无感的接入使用。
下图是小游戏默认构建的流程示意图,在实际项目构建过程中,真正执行的构建流程可能会因为 PlayerSettings 的设置等在细节上有所不同:
如上图,.NET 8 构建环节与 IL2CPP 基本相同相同,区别主要在构建 Wasm 的方式和产物。下面我们对 DotNet Wasm 方案构建流程逐步展开描述:
Compile C# to IL
使用 Roslyn 将用户的 C# 脚本编译为 IL ,以 dll 文件形式参与后续构建流程。
Strip Managed Code
使用 UnityLinker 扫描项目用到的 dll 并作可选的代码剔除,使得生成的 dll 更小。
Generate icall and Register Unity Modules
根据 UnityLinker 的裁剪的结果,生成引擎部分的 Native 注册类,参与后续构建。注意因为项目区别此处生成的类数量也会有差异,对 Wasm 体积产生影响。
到目前为止的构建步骤跟产物与 IL2CPP 完全相同,下面将介绍 DotNet Wasm 方案的构建路线。
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 两个文件中。
Compile Native Files
根据上一步生成的 Wrapper functions 连同之前生成的引擎 Native 注册类参与后续构建;如果Plugins 目录中包含 cpp 或者 c文件 则也会在这一步编译。
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。
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 构建的游戏时,主要的文件加载顺序和流程如下所示:
Load First Page
在浏览器中,首先会下载 index.html 和 loader.js。随后渲染 HTML 页面,执行 loader.js 中的获取 data 文件和 dotnet.js 文件的逻辑,等待下载完成后进行初始化或解析。对于小游戏,则会由小游戏的 Unity 插件来实现与 loader.js 相同的逻辑来下载 data 并且初始化 dotnet.js(webgl.wasm.framework.unityweb.js)
Fetch data & dotnet.js loader.js 会分别下载 data 文件和 dotnet.js 文件,下载 dotnet.js 之后会马上执行初始化函数,等待初始化结束之后才会执行 callMain 入口函数
Fetch blazor.boot.json
dotnet.js 初始化过程中,首先会加载 blazor.boot.json,其中包含了项目中依赖的文件清单与 Hash 值,根据此文件内容来确定加载文件的名称以及是否加载缓存。
其中包含的文件有:
以上是 blazor.boot.json 的主要文件清单,根据构建目标的不同,可能有其它的 pdb 或 symbol 等文件,其主要目的为方便调试。
Async Download Resources
dotnet.js 初始化获取具体的文件清单后,会异步下载上述所有类型的文件,其中 dotnet.native.xxxx.js 和 dotnet.runtime.xxxx.js 不会从缓存加载,其它文件则会根据 hash 值进行判断,如果 hash 值发生变化或者本地缓存不存在才重新下载并且缓存文件,否则直接从缓存中加载。
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。
Initialize Assemblies(WebCILs)
同样,在 WebCIL 下载或者从缓存加载完成之后,dotnet.js 会把 WebCIL(以及其它可能存在的 symbol 和 pdb )从 ArrayBuffer 转换为 Unit8Array,并且将其复制进 heap,最后 exports 出去留待运行时按需加载。
Instantiate dotnet.native.wasm
dotnet.native.wasm 作为核心的 wasm 文件,会在 .NET JS Runtime 初始化完成之后调用 WebAssembly.Instantiate 来进行实例化.
Invoke Wasm Main
在上面所有的步骤都完成之后,回到 loader.js 会执行 Wasm 入口函数 callMain,正式进入游戏的启动流程。
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# 侧调用。
在使用 .Net 8 Scripting Backend 时,将默认开启 Enable Wasm Exception Handling 选项,这样会获得更好的性能和更小的代码体积。
但是在某些情况下,可能会因为项目中的其他静态库或者插件使用了无法与 Wasm Exception Handling 兼容的代码(比如 CXX Exception),导致构建失败。此时可以关闭 Wasm Exception Handling 选项,以牺牲一部分性能和代码体积为代价,来提高兼容性。
.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 的情况下不会生效。