Unity 社区
3
0

从源码走进Unity WebGL技术方案

Dong Lu
https://noodle1983.github.io/
阅读 1858
2024年4月9日
随着WebGL小游戏方案的成熟,各类APP直接接入小游戏将流量变现成为可能。我对Unity的WebGL技术方案的接入方式做了研究,这里做个总结,希望优秀的APP能活得更好,优秀的游戏渗透得更快。

1. 概述

小游戏刚出来时,因为跑不起太炫的效果,常被人认为low。但是如果我们知道每个用户的成本,就会发现小游戏可以以更低成本把游戏推向我们的用户,低得让人没法拒绝。随着手机硬件性能的提升和Unity/微信这等行业巨头做的努力,小游戏优化后运行3D的MMO也没什么问题。
另一方面,很多的互联网产品都面临着流量变现的压力,比如有道云笔记免费用户突然只能支持两个终端,虽然影响不大,但我能感受到其中的无奈。
我的想法是,何不接入小游戏试试?正是看到这里大有可为,借着项目接入支付宝小游戏的机会,我对Unity的WebGL技术方案的接入方式做了研究,基本能跑进游戏,对APP怎么接入Unity WebGL也有了基本了解。这里做个总结,希望优秀的APP能活得更好,优秀的游戏渗透得更快。
此文均为我一家之言,个人理解难免有误,欢迎斧正。

2. Unity WebGL技术方案

2.1. Unity小游戏的两种技术方案

  • Native Instant Game
  • WebGL
具体介绍请参考Unity官方引擎底层架构技术主管赵亮在2023年中Unity开放日上的分享:Unity小游戏开发简介 https://new.qq.com/rain/a/20230612A0AUZE00(<-没找到官方的链接,这个是腾讯网的)。分享讲到了Unity小游戏的现状,两个方案的优缺点,优化方法和未来的技术发展方向,高屋建瓴,值得一读。
本文仅涉及WebGL方案,文章里面提到的未来的WebGPU也是基于Web标准规范,可以认为是WebGL方案的延续,其中的变化仅限于Canvas的Context和相关接口。

2.2. WebGL方案的限制

WebGL方案的限制,或者说是浏览器的js执行环境的限制,首先,他是单线程的(先不讨论Web worker,单线程模式在可预见的未来都是要支持的),意味着Unity的执行不能独占执行的上下文,执行完得释放,也不能自发地触发执行。他就像一个黑盒子,任何的操作都只能由js触发,包括帧循环,定时器,事件处理。如果我们暂停js的处理,Unity没有任何代码在执行。
由于安全的原因,js执行环境没有能力访问本地的文件系统,只能从远端下载,只是如果浏览器发现有Cache且有效,会直接返回。这是很大的限制,游戏内目光所及,均是从文件加载(有纯shader实现的动画,比如这个 https://noodle1983.github.io/mouton-webgl/index.html,shader的初始化会卡1分钟,这样做不了游戏)。
所以可以想象,Unity在移植整套方案到WebGL的时候做了大量的工作,同时也暴露了很多实现到JS层,只要编译输出Debug版本,就能得到一份可读的JS中间层代码,给APP接入小游戏带来便利。以此为基础,我们可以分析App小游戏的接入,需要哪些组件,也可以自行分析出错的原因是什么。

2.3. Unity WebGL方案的运行结构

上图是我个人理解,VirtualFileSystem提供的各种资源(assets),HTTP的事件系统提供玩家操作输入,经过Unity引擎和上层逻辑的处理,输出渲染结果到webgl的Canvas和播放音频。

2.4. 方案必要的组件

  • webgl Context Canvas,渲染输出
  • WebAssembly,加载c/c++库,包括Unity引擎,il2cpp后的c#逻辑,游戏项目的代码库
  • VirtualFileSystem,主要是MEMFS
  • HTTP fetch,从远端下载资源
  • HTTP DOM API,查找元素,事件注册,加载script

2.5. 方案可选组件

  • WebSocket,连服务器,单机不用不影响
  • IndexedDb,默认缺失相关组件会报错,可以通过初始配置关闭,关闭后相关文件(主要是PlayerPrefs)没有持久化(但不影响HTTP Cache,即通过上面HTTP fetch拿到的资源)。
  • WebAudio,默认缺失相关组件会报错,可以在Unity编辑器里关闭。

3. 深入JS源代码

下面会基于一个空工程的debug版js代码,回答下面几个问题
  • 各个部分如何初始化
  • 怎么启动帧循环
  • 事件处理如何触发
示例代码由Unity2022.3.xx导出,部分输出文件名随输出目录改变,示例代码的输出目录为webgl。

3.1. Unity Webgl平台输出文件

部分文件名随输出目录改变,本文以webgl为例,空工程的输出文件如下:
  • index.html , 包含Canvas定义,渲染输出;脚本网页入口(相当于main函数入口)。
  • TemplateData/,网页模板资源,游戏主要是Canvas,网页上的资源没有也不影响,不重要。
  • Build/webgl.loader.js, 加载framework.js, webgl.data。
  • Build/webgl.framework.js,加载/桥接wasm,有一js内存文件系统,事件转发,音频 。
  • Build/webgl.data,打包Bundle外的数据文件,应该都在这里了。
  • Build/webgl.wasm,引擎+C#逻辑, 优化参考微信小游戏和相关文档 https://github.com/wechat-miniprogram/minigame-unity-webgl-transform,太专业了,无出其右,对着做就行。

webgl.data

空的工程包含这些文件,就是Unity打包出来的全局的数据。之前折腾过Android的热更,知道这是对应asset/bin/Data下的子集,webgl版本没这方面的需求,没太关注这些文件。
  • data.unity3d
  • RuntimeInitializeOnLoads.json
  • ScriptingAssemblies.json
  • boot.config
  • Il2CppData/Metadata/global-metadata.dat
  • Resources/unity_default_resources

3.2. 启动流程

下面会按启动流程,过一遍JS的源代码。帧循环的启动,操作响应事件的注册都在这里面,本章节开始的几个问题,就不另外再说了。 小游戏页面的加载从index.html开始,以拿到unityInstance结束,之后unity的帧循环开启。

3.2.1. index.html

index.html很简单,分为两部分:
  • html元素,定义html页面元素,套模板(TemplateData/)。
  • 另一部分是js脚本,完成系统的初始化。
把啰嗦的部分去掉,html部分必须的只有canvas,游戏渲染输出必备,至少支持webgl Context(其他两种是2d和webgl2,我主要做3d,2d不适合,webgl2更新一些,后续还有webgpu,技术的更新还是很快的)。
js部分也可以分成两部分,代码不多,这里直接贴代码:
  • 配置
var buildUrl = "Build"; var loaderUrl = buildUrl + "/webgl.loader.js"; var config = { dataUrl: buildUrl + "/webgl.data", frameworkUrl: buildUrl + "/webgl.framework.js", codeUrl: buildUrl + "/webgl.wasm", streamingAssetsUrl: "StreamingAssets", companyName: "DefaultCompany", productName: "DefaultProduct", productVersion: "0.1", showBanner: unityShowBanner, };
默认的配置看上去只是些很常规的配置,不能做太多的东西。其实这里面大有乾坤,配置的属性和值会被一项一项地复制到unityInstance的底层framework,能够开关功能甚至覆盖实现逻辑,留意相关的注释,比如
// To lower canvas resolution on mobile devices to gain some // performance, uncomment the following line: // config.devicePixelRatio = 1;
  • 加载启动脚本, 构造出unityInstance就ok了。这里的unityInstance是js和Unity c#逻辑层交互的关键,一般一个页面就一个,全局存一下。如果有多个(同个页面同时操控多个游戏实例,实在想不出应用的场景),可以拿key在全局变量存一下。Unity的加载脚本都是createElement的方式加载到全局作用域,import的方式得自己改改。
var script = document.createElement("script"); script.src = loaderUrl; script.onload = () => { createUnityInstance(canvas, config, (progress) => { progressBarFull.style.width = 100 * progress + "%"; }).then((unityInstance) => { loadingBar.style.display = "none"; fullscreenButton.onclick = () => { unityInstance.SetFullscreen(1); }; }).catch((message) => { alert(message); }); }; document.body.appendChild(script);

3.2.2. 加载webgl.loader.js

loader.js全局定义了一个大方法:createUnityInstance,其他的林林总总都在这里面,index.html传入canvas和config,异步拿到unityInstance。文件前面定义大量函数和逻辑,入口在最后面。
前面的函数和逻辑有几个重要的变量:
  • Module, 后面的framework会以此变量去做初始化,初始化好后,这个变量就是framework。
  • config的转移,此前的Module定义的方法,可以在这里直接override,此后Unity也预留了一些判断,可以改变一些行为。
for (var parameter in config) Module[parameter] = config[parameter];
  • unityInstance本身,常用的主要是给C#发消息的SendMessage,Unity的iOS/Android等各个平台都有,用过的看名字就知道。
var unityInstance = { Module: Module, SetFullscreen: ... SendMessage: ... Quit: ... GetMemoryInfo: ... };
  • Module.SystemInfo,系统信息。
最后loadBuild开始加载framework和data,进度从0开始,成功后以1结束
return new Promise(function (resolve, reject) { if (!Module.SystemInfo.hasWebGL) { reject("Your browser does not support WebGL."); } else if (!Module.SystemInfo.hasWasm) { reject("Your browser does not support WebAssembly."); } else { Module.startupErrorHandler = reject; onProgress(0); Module.postRun.push(function () { onProgress(1); delete Module.startupErrorHandler; resolve(unityInstance); }); loadBuild(); }
按图索骥,最后的loadBuild定义如下:
function loadBuild() { downloadFramework().then(function (unityFramework) { unityFramework(Module); }); var dataPromise = downloadBinary("dataUrl"); Module.preRun.push(function () { Module.addRunDependency("dataUrl"); dataPromise.then(function (data) { var view = new DataView(data.buffer, data.byteOffset, data.byteLength); ... Module.removeRunDependency("dataUrl"); }); }); }
上半部分异步加载webgl.framework.js,拿到初始化函数,传入Module初始化。
后半部分preRun加载webgl.data,解析,构造内存文件。为什么要放preRun里面呢,因为前面的framework下载和初始化是异步的,内存文件系统是还没初始化好。

3.2.3. 加载webgl.framework.js

webgl.framework.js代码量很大,除了各种辅助类的接口外,大体有下面几大块:

音频

web的音频定义 https://www.w3.org/TR/webaudio/很复杂,在我调试的这段时间(2023年),还碰到Safari内存泄漏的问题,虽然后续版本有修复,但总体来说各个移动平台的web音频缓存对游戏来说,不太适应,表现为占用内存偏大,缓存释放不及时。
游戏中的音频虽然播放量大,杂,既有长的背景音,也有短的各种音效,但是播放的需求很简单,就是循环播放背景音+单次播放音效实例,复杂一点再调调各自音量,再复杂一点限制同一实例的个数。所以接入各个APP原生的audio接口不复杂(我曾经想找一个audio context的polyfill工程,接上小游戏的audio接口,后来发现太复杂,不如直接接APP的播放接口),接入后在Unity的原生音频就没用了,可以在设置里把Unity的音频给关了,最好再把这块代码裁剪掉。

文件系统

文件系统应该是emcripten的实现 https://emscripten.org/docs/api_reference/Filesystem-API.html#,非常轻巧,值得一读。
  • MEMFS
  • IDBFS
  • FS
  • SOCKFS
  • DNS
  • Linux C file api
其中IDBFS会被用于存放持久化文件,没有用到IDBFS的太多特性。以DB存文件不算完美,纯属没其他办法的办法。Unity在初始化时预留了个扩展(如下),可以在配置里定义一个unityFileSystemInit方法,依葫芦画瓢把文件存其他地方(取代IDBFS);或者就定义一个空方法,把PlayerPrefs数据存服务器。
// Initialize the IndexedDB based file system. Module['unityFileSystemInit'] allows // developers to override this with their own function, when they want to do cloud storage // instead. var unityFileSystemInit = Module['unityFileSystemInit'] || function () { FS.mkdir('/idbfs'); FS.mount(IDBFS, {}, '/idbfs'); ... }; unityFileSystemInit();
其他的部分写得挺好,值得一看,原来内存文件系统可以这么做。

GL

封装Canvas webgl Context的操作。

webgl.wasm初始化

下面的代码太多,只能上图把关键的函数列出来:
初始化后得到asm,再用createExportWrapper将asm的符号导出到Module变量,之后看到的_xxx函数基本都是wasm里的xxx,如_main即wasm里的main函数,作为这些后,静待游戏启动。

Browser及帧循环启动

帧循环的相关数据结构为Browser.mainLoop,其启动比Unity内部帧循环早,帧号比Unity C#的帧号大。
C#里的帧循环函数在main函数执行时传入,存在Browser.mainLoop.runner里。Browser.mainLoop.scheduler即启动下一帧的更新,如果定帧,则用浏览器的timeout函数触发下一帧更新;否则,则用浏览器的requestAnimationFrame触发下一帧更新。无论怎样,下一帧更新浏览器都会调用Browser.mainLoop.runner,在Browser.mainLoop.runner里,会调用wasm传入的main_loop方法,然后再Browser.mainLoop.scheduler,如此反复,帧循环就跑起来了。

JSEvents事件处理

事件处理也是在main初始化时,将相关处理函数注册到js层,通过JSEvents统一向浏览器注册事件,各层均有回调,事件发生时,顺着回调函数,调回C#。调回c#时,会构造buffer作为事件参数传入。

4. APP小游戏的接入

小游戏的接入,先接入一遍微信小游戏 https://github.com/wechat-miniprogram/minigame-unity-webgl-transform,虽然微信工程重写了一遍JS部分代码,但是接入一遍仍能站在巨人肩膀上,少走很多弯路。
参考Unity必选和可选组件,也感谢开源社区,Github上有大量的替代组件,可行性的问题解决后,后其他的问题应该都是工作量的问题。
  • webgl Context Canvas,需要
  • WebAssembly,需要。
  • VirtualFileSystem,有内存就行。
  • HTTP fetch,需要。
  • WebSocket,连接服务器需要,如果仅是跑进游戏,是不需要的。
  • IndexedDb,用本地文件系统接入,效率更高。
  • WebAudio,用app自己的音频系统接入,效率更高。

5. 最后

全部写完,其实也只写了个大概,希望能把脉络给展现出来,方便读者深入进去。
最后为自己营销一下,我也在找广州工作机会。
GitHub:https://github.com/noodle1983/
Blog:https://noodle1983.github.io/
Email:noodle1983@126.com
发布于小游戏
0条评论

AI

全新AI功能上线

1. 基于Unity微调:专为Unity优化,提供精准高效的支持。

2. 深度集成:内置于团结引擎,随时查阅与学习。

3. 多功能支持:全面解决技术问题与学习需求。

AI