参考:团结引擎 DotNet WebAssembly(Wasm) 介绍
一、当前编译流程
- 通过IL2CPP将C#转成C/C++;
- 通过Emscripen将C/C++转成WebAssembly;
二、 当前存在问题
- IL2CPP在处理类似泛型、反射结构时,由于缺少运行时信息,必须全量生成泛型模板代码,引起Wasm的运存进一步膨胀
三、一些解决方案
1. 基于IL2CPP的的分包处理和代码裁剪
- 优势
- 可减少单个Wasm文件的体积
- 劣势
- 分包和裁剪都依赖运行时信息,需要引入的其他信息具有不确定性和复杂性
2. 基于.Net 8的Blazor方案
用户的C#代码以Blazor的方式做运行时解释执行代码,引擎的代码保持Wasm的实现;
- 优势
- 将用户的C#代码与Wasm分离,极大减少Wasm文件的体积,显著减轻运行内存的压力
- 劣势
- 解析执行在运行时处理,会增加额外的CPU使用和延迟
- 解析执行的代码,无法做代码优化(如删除冗余代码、重排指令顺序、内联函数等)
- 解析执行需要做类型检查,也会增加CPU开销
四、DotNet Wasm方案
DotNet Wasm 方案以 .NET8 为基础,依赖于 Emscripten 工具链构建 WebAssembly,并且使用裁剪优化后的 mono 作为 .Net 运行时,充分利用引擎原本对 mono 的支持,使得用户几乎可以无感地接入使用。
五、DotNet Wasm整体流程
1. IL2CPP与DotNet Wasm的编译流程图
2. 构建流程
①DLL Compile and Strip
Compile C# to IL
使用 Roslyn 将用户的 C# 脚本编译为 IL,以 dll 文件形式参与后续构建流程;
输入:
- 用户 C# 脚本(包含 Package 和自定义 .asmdef)
输出:
- dll 文件(IL)
Strip Managed Code
使用 UnityLinker 扫描项目用到的 Dll 并作可选的代码剔除,使得生成的 Dll 更小;
输入:
- 上一步编译出的 dll 文件
- Unity Module 中的 dll 文件
- Plugin 中的 dll 文件
- .NET BCL
输出:
- ManagedStripped dlls
- UnityLinkerToEditorData.json
Generate icall and Register Unity Modules
根据 UnityLinker 的裁剪的结果,生成引擎部分的 Native 注册类,参与后续构建。注意因为项目区别此处生成的类数量也会有差异,对 Wasm 体积产生影响;
输入:
- UnityLinkerToEditorData.json
- Unity Modules
输出:
- UnityClassRegistration.cpp
- UnityICallRegistration.cpp
②.NET8 MSBuild
Scan for PInvoke and icall
扫描所有的ManagedStripped dll 文件,在 .NET BCL、引擎 Native 模块的函数生成wrapper function table,并生成两个头文件记录;
输入:
- 所有的 ManagedStripped dll 文件
输出:
- wrapper function table
- pinvoke-table.h
- icall-table.h
Compile Native Files
输入:
- 上一步的wrapper function table
- Generate icall and Register Unity Modules时生成的引擎Native注册类
- Plugins目录中的C/CPP文件
输出:
- Native Objects
Link and Compile
此步骤为生成 wasm 和 js framework 的核心步骤;
输入:
- 上一步的Native Objects、Unity的静态链接依赖、.NET运行时依赖(对应产物dotnet.native.wasm)
- Unity JS Framework、 .NET8 Runtime JS、浏览器基础功能包括 IndexedDB,OpenGL API,Audio,Sensor 等 JS 库(对应产物dotnet.native.js)
- Dotnet MSBuild(对应产物dotnet.runtime.js)
输出:
- dotnet.native.wasm(等同于IL2CPP的Webgl.wasm)
- dotnet.native.js(等同于IL2CPP的Webgl.framework.js)
- dotnet.runtime.js(等同于IL2CPP的Webgl.framework.js)
Convert dll to WebCIL
将一些DLL转化为WebCIL的wasm格式,便于运行时的JIT进行解释执行;
输入:
- 用户代码程序集、引擎代码程序集以及 .NET 基础类库 (BCL)
输出:
- WebCIL类型的wasm
3. 构建产物
4. 加载流程
①Load First Page
浏览器:
- 下载 index.html 和 loader.js
- 随后渲染 HTML 页面,执行 loader.js 中的获取 data 文件和 dotnet.js 文件的逻辑,等待下载完成后进行初始化或解析
WX Game:
- 下载loader.js
- 执行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 值,根据此文件内容来确定加载文件的名称以及是否加载缓存
加载的文件:
- dotnet.native.wasm文件:wasm 代码运行的核心文件
- dotnet.native.xxxx.js 和 dotnet.runtime.xxxx.js:负责初始化 JS Module
- WebCILs:从 dll 转化而来
④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 来进行实例化
⑧Export Engine Instance
此时引擎的 Instance 准备完毕,export 以供 loader.js 调用。
⑨Start Game
在上面所有的步骤都完成之后,回到 loader.js 会执行 Wasm 入口函数 callMain,正式进入游戏的启动流程。
⑩Load Assemblies (WebCILs)
在游戏启动后会根据需求加载此前已经在 heap 的 WebCIL,调用相关函数以完成游戏的加载和运行。
六、新旧两种Wasm方案性能情况
1. 性能对比
测试环境:
- Code Optimization:Runtime Speed
- 测试环境为 MacBook 32G M1 Pro
- 使用 Instruments - Activity Monitor 记录运行时内存和 CPU 使用率
- 使用 Chrome DevTool Performace 和 Memory 工具记录 Frame Time 和 Wasm Heap 大小
- WebGL 检测 WebContent 基于 WebKit miniBrowser,iOS 检测 WebContent 基于 iOS17.2.1
- 测试多轮,分别取采样区间中的最小值/中位数/最大值计入表中
2. 构建时间对比
七、总结
官方总结:
- 相比 IL2CPP,DotNet Wasm 方案对 Wasm Heap 基本没有影响
- 相比 IL2CPP,DotNet Wasm 方案的 Frame Time 有轻微的增加,但在浏览器普遍的 60FPS 刷新频率的条件下并不会产生帧率差异
- 相比 IL2CPP,DotNet Wasm 方案可以获得显著内存收益。并且随着用户脚本复杂度的提高,由于脚本不进入 Wasm 编译链路,相对 IL2CPP 的内存收益会越发明显
- 相比 IL2CPP,DotNet Wasm 方案并不对 CPU 带来额外负担,并且部分测试用例中 CPU 负载与波动表现优于 IL2CPP
- 相比 IL2CPP,DotNet Wasm 方案构建时间相对 IL2CPP 大幅降低,这可以让开发者快速进行开发迭代
- WebCLI的形式将用户代码剥离出来,且不再合并构建,并在Wasm初始化后各自独立加载,天然支持代码热更新
部分新特性:
- 除了GC Boehm,还支持mono的分代GC Sgen(Simple Generation GC),后者会将GC分散到帧中,在频繁小内存的分配释放场景中帧率更高且波动更小
- 通过统计解释执行时命中函数的频率,将热点代码动态生成为 Wasm Module,让热点部分进入 Wasm 从而进一步提升执行效率;此外,也可以自己选择部分 DLL 参与 AOT 编译从而直接进入 Wasm,牺牲部分运存换取性能提升(源自 DotNet 8 的新概念,基于JIT)
- 对于用户的脚本代码,可以在浏览器中直接调试 C#,Native C/C++ 以及 JavaScript,这种开箱即用的调试体验可以极大提升开发效率
个人总结:
- 新方案摒弃了IL2CPP的方案,改用23年新推出的.NET Blazor方案
- 新方案使用时间换空间,CPU耗时会增加,而Wasm运存会降低;
- 新方案将用户代码、部分引擎Manager代码从构建中剥离出来,作为单独Wasm包存在,一定程度上便于热更新;但是这个Wasm包需要通过服务器下载来加载到运存中,暂时不清楚是放在自己的服务器还是官方引擎的服务器中(像微信小游戏的插件Wasm包就必须上传到微信的服务器并通过校验)
- 新方案中的用户代码在调试模式下,能够在浏览器上直接调试,可能存在安全风险
- 新方案的编译产物和现有IL2CPP的产物有很大差异,暂时不清楚微信小游戏SDK是否有对应的适配版本
八、看法与疑问
1. 看法
- 原有通过Emscripten编译生成的Wasm并在运行时加载执行的流程,类似于AOT;
- 而新增的Dotnet Wasm方案更像将部分代码以JIT形式执行,其他部分仍然保留AOT形式;
2. 存疑
Q:产物都是WASM格式文件,浏览器时如何识别并处理哪些可直接执行,哪些需要JIT解释执行?
Q:DotNet Wasm和IL2CPP的产物不同,WX Game SDK是否有对应的适配版本?
Q:该方案需要联网下载部分文件(如dotnet.native.xxxx.js 和 dotnet.runtime.xxxx.js),无网络连接的情况下如何处理?
Q:用户的脚本代码可以在浏览器直接调试,是否存在一定的安全风险?
九、知识延伸
1. Roslyn
微软开源的.NET编译器,支持将C#编译成中间代码IL
2. Unity Linker
特点:
- 用于剥离托管代码
- 基于Mono IL Linker的定制版本
执行流程:
- 分析项目中的所有程序集,首先标记顶级、根类型、方法、属性、字段等;
- 分析已标记为要进行识别的根,并标记这些根所依赖的托管代码;
- 完成此静态分析后,所有剩余的未标记代码都无法通过应用程序代码中的任何执行路径来访问,并将从程序集中删除;
3. Blazor WebAssembly
特点:
- Blazor应用、其依赖项及.Net运行时并行下载到浏览器;
- 应用将在浏览器线程中直接执行
- .NET运行时包含.NET中间语言IL解释器,并支持JIT运行时
4. WebCIL
特点:
- 是一种适用于 .NET 程序集的 Web 友好打包格式,旨在支持在限制性网络环境中使用 Blazor WebAssembly;
- 文件格式为标准的.wasm的WebAssembly文件;
- 可以将DLL 信息封装为符合 Wasm Binary Format 的容器格式;
- 运行时会将 Payload 复制到 Wasm 内存中;