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小游戏开发简介(<-没找到官方的链接,这个是腾讯网的)。分享讲到了Unity小游戏的现状,两个方案的优缺点,优化方法和未来的技术发展方向,高屋建瓴,值得一读。
本文仅涉及WebGL方案,文章里面提到的未来的WebGPU也是基于Web标准规范的,可以认为是WebGL方案的延续,其中的变化仅限于Canvas的Context和相关接口。
2.2. WebGL方案的限制
WebGL方案的限制,或者说是浏览器的js执行环境的限制,首先,他是单线程的(先不讨论Web worker,单线程模式在可预见的未来都是要支持的),意味着Unity的执行不能独占执行的上下文,执行完得释放,也不能自发地触发执行。他就像一个黑盒子,任何的操作都只能由js触发,包括帧循环,定时器,事件处理。如果我们暂停js的处理,Unity没有任何代码在执行。
由于安全的原因,js执行环境没有能力访问本地的文件系统,只能从远端下载,只是如果浏览器发现有Cache且有效,会直接返回。这是很大的限制,游戏内目光所及,均是从文件加载(有纯shader实现的动画,比如这个,shader的初始化会卡1分钟,这样做不了游戏)。
所以可以想象,Unity在移植整套方案到WebGL的时候做了大量的工作,同时也暴露了很多实现到JS层,只要编译输出Debug版本,就能得到一份可读的JS中间层代码,给APP接入小游戏带来便利。以此为基础,我们可以分析App小游戏的接入,需要哪些组件,也可以自行分析出错的原因是什么。
2.3. Unity WebGL方案的运行结构
block-beta columns 1 U["Unity Engine and upper"] block:Mandatory Canvas["webgl Context Canvas"] VirtualFileSystem http["HTTP Event System"] WebAudio end
上图是我个人理解,VirtualFileSystem提供的各种资源(assets),HTTP的事件系统提供玩家操作输入,经过Unity引擎和上层逻辑的处理,输出渲染结果到webgl的Canvas和播放音频。
2.4. 方案必要的组件
- webgl Context Canvas,渲染输出
- WebAssembly,加载c/c++库,包括Unity引擎,il2cpp后的c#逻辑,游戏项目的代码库
- VirtualFileSystem,主要是MEMFS
- HTTP fetch,从远端下载资源
- HTTP DOM API,查找元素,事件注册,加载scripts
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#逻辑, 优化参考微信小游戏和相关文档,太专业了,无出其右,对着做就行 |
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,2d不行,webgl2更新一些,后续还有webgpu,技术的更新还是很快的)。
js部分也可以分成两部分,代码不多,这里直接贴代码:
配置
1
2
3
4
5
6
7
8
9
10
11
12var 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,能够开关功能甚至覆盖实现逻辑,留意相关的注释,比如
1
2
3// 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的方式得自己改改。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15var 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也预留了一些判断,可以改变一些行为。
1
2for (var parameter in config)
Module[parameter] = config[parameter];unityInstance本身,常用的主要是给C#发消息的SendMessage,Unity的iOS/Android等各个平台都有,用过的看名字就知道。
1
2
3
4
5
6
7var unityInstance = {
Module: Module,
SetFullscreen: ...
SendMessage: ...
Quit: ...
GetMemoryInfo: ...
};Module.SystemInfo,系统信息。
最后loadBuild开始加载framework和data,进度从0开始,成功后以1结束
1 | return new Promise(function (resolve, reject) { |
按图索骥,最后的loadBuild定义如下:
1 | function loadBuild() { |
上半部分异步加载webgl.framework.js,拿到初始化函数,传入Module初始化。
后半部分preRun加载webgl.data,解析,构造内存文件。为什么要放preRun里面呢,因为前面的framework下载和初始化是异步的,内存文件系统是还没初始化好。
3.2.3. 加载webgl.framework.js
webgl.framework.js代码量很大,除了各种辅助类的接口外,大体有下面几大块:
音频
web的音频定义很复杂,在我调试的这段时间(2023年),还碰到Safari内存泄漏的问题,虽然后续版本有修复,但总体来说各个移动平台的web音频缓存对游戏来说,不太适应,表现为占用内存偏大,缓存释放不及时。
游戏中的音频虽然播放量大,杂,既有长的背景音,也有短的各种音效,但是播放的需求很简单,就是循环播放背景音+单次播放音效实例,复杂一点再调调各自音量,再复杂一点限制同一实例的个数。所以接入各个app原生的audio接口不复杂(我曾经想找一个audio context的polyfill工程,接上小游戏的audio接口,后来发现太复杂,不如直接接),接入后在Unity的原生音频就没用了,可以在设置里把Unity的音频给关了,再把这块代码裁剪掉。
文件系统
文件系统应该是emcripten的实现,非常轻巧,值得一读,原来内存文件系统可以这么做。
- MEMFS
- IDBFS
- FS
- SOCKFS
- DNS
- Linux C file api
其中IDBFS会被用于存放持久化文件,没有用到IDBFS的太多特性。以DB存文件不算完美,纯属没其他办法的办法。Unity在初始化时预留了个扩展(如下),可以在配置里定义一个unityFileSystemInit方法,依葫芦画瓢把文件存其他地方(取代IDBFS);或者就定义一个空方法,把PlayerPrefs数据存服务器。
1 | // Initialize the IndexedDB based file system. Module['unityFileSystemInit'] allows |
GL
封装Canvas webgl Context的操作。
webgl.wasm初始化
下面的代码太多,只能上图把关键的函数列出来:
flowchart TD createWasm --> instantiateAsync instantiateAsync -->D1{!FileURL and 支持流式实例化?} D1--> |Y| F1[fetch] --> WebAssembly.instantiateStreaming --> receiveInstantiationResult D1--> |N| instantiateArrayBuffer --> getBinaryPromise --> D2{FileURL?} D2--> |Y| readAsync --> getBinary D2--> |N| F2[fetch] --> getBinary --> WebAssembly.instantiate --> receiveInstantiationResult receiveInstantiationResult --> receiveInstance --> Init[Module.asm=...\n wasmMemory=...\n Module.asm.__wasm_call_ctors on init]
初始化后得到asm,再用createExportWrapper将asm的符号导出到Module变量,之后看到的_xxx函数基本都是wasm里的xxx,如_main即wasm里的main函数,作为这些后,静待游戏启动。
Browser及帧循环启动
帧循环的相关数据结构为Browser.mainLoop,其启动比Unity内部帧循环早,帧号比Unity C#的帧号大。
flowchart TD Module.preInit --> D1{config.noInitialRun?} --> |N| run --> preRun --> doRun --> initRuntime --> preMain --> callMain --> wasm.main --> _emscripten_set_main_loop --> setMainLoop --> init[init Browser.mainLoop.runner] --> D2{fps>0} D2 --> |Y| timeout[_emscripten_set_main_loop_timing EM_TIMING_SETTIMEOUT] --> Browser.mainLoop.scheduler D2 --> |N| raf[_emscripten_set_main_loop_timing EM_TIMING_RAF] --> Browser.mainLoop.scheduler
C#里的帧循环函数在main函数执行时传入,存在Browser.mainLoop.runner里。Browser.mainLoop.scheduler即启动下一帧的更新,如果定帧,则用浏览器的timeout函数触发下一帧更新;否则,则用浏览器的requestAnimationFrame触发下一帧更新。无论怎样,下一帧更新浏览器都会调用Browser.mainLoop.runner,在Browser.mainLoop.runner里,会调用wasm传入的main_loop方法,然后再Browser.mainLoop.scheduler,如此反复,帧循环就跑起来了。
JSEvents事件处理
flowchart TD callMain --> wasm.main --> wasm.InputInit --> _emscripten_set_xxx_callback_on_thread --> registerXXXEventCallback --> JSEvents.registerOrRemoveHandler --> event.target.addEventListener
事件处理也是在main初始化时,将相关处理函数注册到js层,通过JSEvents统一向浏览器注册事件,各层均有回调,事件发生时,顺着回调函数,调回C#。调回c#时,会构造buffer作为事件参数传入。
4. APP小游戏的接入
小游戏的接入,先接入一遍微信小游戏,虽然微信重写了一遍JS部分代码,但是仍能站在巨人肩膀上,少走很多弯路。
参考Unity必选和可选组件,也感谢开源社区,Github上有大量的替代组件,可行性的问题解决后,后其他的问题应该都是工作量的问题。
- webgl Context Canvas,需要
- WebAssembly,需要。
- VirtualFileSystem,有内存就行。
- HTTP fetch,需要。
- WebSocket,连接服务器需要,如果仅是跑进游戏,是不需要的。
- IndexedDb,用本地文件系统接入,效率更高。
- WebAudio,用app自己的音频系统接入,效率更高。
5. 最后
全部写完,其实也只写了个大概,希望能把脉络给展现出来,方便读者深入进去。