WebAssembly 游戏开发技术专题全层级11 / 31 已发布

WebAssembly 游戏循环的性能极限:帧率、GC 暂停与渲染管线协同

· 0 分钟阅读
WebAssembly 游戏循环的性能极限:帧率、GC 暂停与渲染管线协同 Frame Budget, GC Pauses & Render Pipeline Coordination — WebAssembly 游戏开发技术专题

WebAssembly 游戏循环的性能极限:帧率、GC 暂停与渲染管线协同 Frame Budget, GC Pauses & Render Pipeline Coordination

为什么游戏循环是第一块基石

你在 Unity 里写 Update,从来不问它什么时候被调用。原生平台把这件事包得很好:操作系统给你一个线程,垂直同步给你一个节拍,你只管在函数里改坐标。到了浏览器,这些约定全变了——你不再是一个拥有时间的程序,你是一个被允许在每个画面刷新前插队几毫秒的访客。

我见过太多团队在 Wasm 这条路上翻车的姿势惊人的一致:编译跑通了,Hello World 出来了,把引擎塞进去,本地能跑,一上线就变成「能动但很卡」。然后他们开始怀疑 WebAssembly 是不是被吹过头了。真不是。问题基本都出在同一个地方——他们还按原生的方式在安排时间。

WebAssembly 给了你接近原生的计算速度,这一点 [01] 和 [27] 都掰扯过。但它没有、也不可能给你一个属于你自己的时钟。浏览器的主线程是公共资源,渲染、样式、布局、其它脚本、扩展插件,全在这同一条跑道上。你的游戏循环不是这条跑道的主人,只是跑得比较快的那位。

所以这篇要聊的不是「怎么让 Wasm 更快」,那是个伪命题。要聊的是:怎么在一个你无法控制节拍的环境里,让一个每秒要做六十次决策的系统,每次都按时交卷。这件事拆开有四层:主循环的所有权归谁、垃圾什么时候来敲门、数据从 Wasm 内存搬到 GPU 要交多少过路费、以及你怎么把「卡」这个模糊的感受变成可读的数字。四层都过了,帧率才稳得住。

上一棒 [06] 讲的是怎么把 C/C++ 引擎搬进浏览器,那是把车开进赛车场;这一篇讲的是进了场之后怎么跑圈。下一棒 [08] 会讲 Rust 工具链的具体写法,那更偏工程配置。中间这一篇,是纯纯的「节奏感」。

第一层:主循环的所有权——rAF 是房东,你是租客

浏览器里驱动游戏的那个东西叫 requestAnimationFrame,行内人简称 rAF。它的行为说起来简单:浏览器在每次准备合成新画面之前,把你注册的回调叫一遍。你从进去到出来的这段时间,就是你的全部预算。

这里有个很多人忽略的细节:你不是第一个被叫的。同一帧里可能有别的回调、可能有待处理的样式计算、可能有滚动事件。浏览器也不知道你会跑多久,它只能等你。你要是跑超了,这一帧就不合成了——用户看到的还是上一帧,也就是所谓的掉帧。

更狠的是 rAF 在某些状态下会直接停摆。标签页切到后台,rAF 基本不跑了。笔记本盖上再打开,你的游戏已经「暂停」了十秒钟。手机开省电模式,刷新率可能从 60 掉到 30,甚至更低。你要是天真地用「这一帧和上一帧的时间差」去推物理,回来那一瞬间你的角色能穿墙飞出地图。

所以第一件必须接受的事:那个漂亮的 while 死循环,搬不进浏览器。原生引擎的骨架是「初始化,然后永远循环,每圈读输入、跑逻辑、画一帧」。浏览器里没有「永远」这回事,只有「每隔一会儿叫醒你一次」。Emscripten 对此的处理手法相当野:把死循环的循环体注册成一个回调,然后在试图进入下一圈的时候,抛一个特殊异常,把整条 C 调用栈连锅端掉,控制权还给浏览器。等下一次 rAF 触发,再从头把这个函数叫起来。

第一次听说这个技巧的人都觉得它脏,我当年也是。但它是当时唯一能兼容「引擎假设自己是时间主人」这个前提的办法。代价也有:栈展开的时候,你写在主循环作用域里的那些清理动作可能根本不会执行,析构函数在那一刻形同虚设。所以把资源管理挂在主循环栈上的老代码,移植过来要格外小心。

接下来是步长。这是所有帧率问题的总闸。

可变步长,也就是每帧拿真实的时间差去推物理,写起来最省事,跑起来最不稳。帧率一波动,跳跃高度就变,碰撞检测的穿透概率就上升,联机回放更是对不上。而且它会把开发机的性能当成玩家的性能——你在 M 系列的 Mac 上测试一切正常,玩家用五年前的核显笔记本打开,物理直接崩坏。

固定步长才是正解,但固定步长会带来第二个问题:死亡螺旋。一帧算得太久,下一帧的时间差更大,要补的步数更多,算得更久。三帧之内你的游戏就从 30fps 变成 5fps 然后不动了。破法只有两条:给单帧时间差设上限,给追赶步数设上限。超出部分的时间直接扔掉,业内叫「豁免时间债」。听着很粗暴,但玩家宁可看到角色瞬移半步,也不愿意看游戏卡死。

还有个必须配的搭档是渲染插值。物理按 60Hz 固定推,屏幕可能是 144Hz 刷新,那每两到三个渲染帧才推进一次物理状态。不插值的话,你会看到明显的「台阶感」。做法很朴素:留一个累加器记录「上一物理帧到这一渲染帧过了多少比例的步长」,渲染时用上一帧状态和当前帧状态做线性混合。这一步做完,60Hz 逻辑在 144Hz 屏幕上看起来就是丝滑的。没做,就是 60Hz 的卡顿感套了个高刷的外壳。

说到这,得请出第一个案例。我觉得没有比它更适合讲主循环的了。

案例拆解:《Quake 3》的浏览器版(QuakeJS)

2014 年前后,有人用 Emscripten 把整份《Quake 3》的引擎代码编译到了浏览器里,跑在 WebGL 上,取名 QuakeJS。后来 Wasm 成熟,这套东西也跟着换成了 Wasm 后端。它是「老引擎搬进浏览器」这条路上最有参考价值的一个样本。

为什么它能成?因为 id Software 在 1999 年就把时钟分了三层。最底层是服务端 tick,默认 20 赫兹,负责游戏世界的权威状态;中间层是客户端本地预测,把玩家移动拆成固定的 8 毫秒小片去推进,这是让操作跟手的关键;最上层是渲染帧率,和上面两层完全解耦,你爱跑多少跑多少。这三层时钟,搬到浏览器之后,只有最上层被 rAF 接管了,下面两层原封不动。

反过来看那些搬不动的引擎:如果一套架构是「渲染帧驱动一切」,一帧里既读输入又推物理又画画面,那它到了浏览器就是被 rAF 牵着走,节拍一抖全身抖。这不是 Wasm 的问题,是架构的问题。能被移植到浏览器的引擎,基本都是本来就做对了时钟分层的引擎。

代价也很真实。第一是加载,《Quake 3》的资源包本来就是几十兆量级,浏览器里首帧还要现场编译大量着色器脚本,那个编译尖峰在原生里能藏进加载条,在浏览器里藏不住。第二是输入延迟,社区的反馈高度一致:「这不是 Q3 吗,怎么感觉鼠标粘手上了」。pointer lock 的输入事件传到你的回调,再走完一帧渲染,整体比原生多出二十到四十毫秒。这跟 Wasm 快不快没半毛钱关系,是浏览器的输入管线本身就这么长。

第三点最有意思:老玩家习惯把最高帧率设到 125,因为某些跳跃动作和帧率耦合。浏览器版里这个设置形同虚设——你没法比 rAF 更快,除非改用定时器,但那样会撕裂。社区为此吵过一阵,最后也只能接受。这件事给我的启发是:帧率一旦和玩法耦合,它就是玩法的一部分,移植的时候会被一起带走。做新游戏的时候,尽量别让玩法依赖帧率。

第二层:三种垃圾,三种疼法

「Wasm 没有 GC」这句话,正确率大概百分之五十。它对线性内存里的裸数据成立——你手动分配的那块字节,没有任何东西会自动帮你清。但它只说了故事的一半。

浏览器里跑 Wasm 游戏,堆上有三层垃圾在排队:

第一层是 JS 堆。你所有的胶水代码、事件对象、字符串、Promise、每次临时创建的数组视图、WebGL 对象的包装,全在里面。这层的回收由浏览器引擎决定,你完全无法干预时机。V8 用的是分代回收,新生代那一档收得频繁但很快,一般几毫秒;老生代那一档慢,一次几十毫秒也不稀奇。关键在于——它可能在任何一次 JS 调用时发生,包括你正画到一半的那一帧。

第二层是 Wasm 线性内存里的宿主 GC。Unity WebGL 用的是 Boehm 那套保守式垃圾回收,.NET 编译过来的方案也有自己的 GC。这层的特点是暂停时间跟堆大小强相关,堆越大,一次全量回收越久。而且它是 stop-the-world,讲话讲到一半直接给你按暂停。

第三层是 Wasm GC 提案带来的托管堆,[11] 那篇专门讲过。这层的好处是引擎有能力做增量、做分代,暂停会变得可预测。但它现在还不是游戏引擎的主流选择,先按住。

垃圾来源谁能决定什么时候收典型暂停你能做什么
JS 堆(胶水、视图、字符串)浏览器引擎,你完全无法控制几毫秒到几十毫秒减少产生量,把每帧分配压到接近零
Wasm 内部 GC(Boehm / .NET)宿主运行时,时机半可控与堆大小正相关,十毫秒级起步对象池、控制堆大小、拆大对象
Wasm GC 提案托管堆引擎,理论上可增量待观察暂时只能等生态成熟

这三层的疼法完全不同。JS 堆是「时机不可控但数量可控」,你没法决定它什么时候来,但你能决定少扔。宿主 GC 是「时机半可控但时长不可控」,堆越大越疼。明白这个区别,优化方向就出来了:别去研究怎么让 GC 变快,去研究怎么让它别来。

那 16.67 毫秒到底怎么花掉的?我给个我自己项目里常用的预算模板,你可以直接拿去套。

开销项建议预算(60fps)说明
输入处理 + 游戏逻辑3~5ms物理、AI、状态机
提交渲染(draw call、uniform 上传)3~6ms浏览器里这项比原生贵得多
JS 胶水(事件、视图、桥接)1~2ms越薄越好
GC 缓冲余量2~4ms你不能假设它为零
浏览器自留(合成、样式等)2~3ms这块钱不在你手里

加起来你会发现,真正属于「游戏」的其实只有一半出头。所以那些在浏览器里能做到稳定 60 帧的项目,往往不是因为代码写得多聪明,是因为他们一开始就把预算表贴在墙上。

具体怎么把分配压到零?我给个粗暴的清单,都是我用血换来的:主循环里不新建数组、不拼字符串、不用匿名对象传参、不写序列化、不在热路径里创建闭包、C# 里能用结构体就别用类、别让集合的遍历产生迭代器。说实话这年头很多引擎已经帮你兜了不少底,但浏览器这条路上,兜底的人是你自己。

还有个小坑特别隐蔽:每一帧给 WebGL 传数据时新建一个类型化数组视图,看起来人畜无害,实际上是在往新生代里丢垃圾。一帧丢十几个,六十帧就是一秒钟几百个对象,几秒钟就来一次回收。这个数字在桌面端你可能感知不到,在中低端安卓上就是肉眼可见的周期性顿挫。

案例拆解:Unity WebGL 的保守式 GC

Unity 导出到 Web 之后,C# 代码经过 IL2CPP 变成 Wasm,托管堆就落在那一整块线性内存里,垃圾回收器是 Boehm。这玩意儿的「保守」两个字很要命:它分不清一个整数和一个指针,只要某块内存上的数值看起来像一个合法的堆地址,它就认为那块内存还活着。结果就是堆里长期躺着一些「看起来还活着」的垃圾,回收效率打折,堆只涨不降。

玩家侧的体验非常典型:前三十秒很流畅,一分钟后突然卡顿一下,然后再流畅三十秒,再卡一下。开发者打开性能面板一看,每帧分配两三千字节,一分钟就是一两百兆的累计分配,隔一段时间必然触发一次全量回收。

我自己翻过的车更蠢。早些年做一个 2D 卡牌小游戏,打开手牌界面的瞬间卡了三百毫秒。查了半天发现是「重建整个列表」——一次性实例化了六十个界面对象。后来改成对象池加逐帧构建,每帧只创建四个,卡顿没了,界面还多了个「手牌一张张发出来」的动效,玩家反而觉得更爽。有时候性能优化顺手把体验也优化了,这种便宜不占白不占。

堆大小这一项也得说清楚。Unity WebGL 的堆可以在项目设置里调,很多人习惯性地往大了给,觉得大就是安全。错。堆越大,一次全量回收越久;而且移动端 Safari 对单个标签页的内存本来就抠门,老设备上几百兆就可能被系统直接杀掉页面,玩家看到的是一次毫无征兆的白屏。我的经验值:桌面端按「峰值占用乘一点五」给,通常两百五十六到五百一十二兆;移动端砍一半。启动的时候允许内存增长,跑起来之后尽量别长。

第三层:渲染管线的海关——从 Wasm 内存到 GPU 的过路费

Wasm 的线性内存是一整块字节缓冲区。你要画东西,得把这块缓冲区里的数据喂给 WebGL 或者 WebGPU。这中间隔着一道关,每一次过境都要报关、要检查、要搬运。

很多从原生转过来的程序员,脑子里想的还是「内存里的顶点数组,指针传给显卡就行」。浏览器里没有指针这回事,你必须先在缓冲区上开一个视图,再把视图交给图形接口。就这一步,成本分三份:

第一份是调用本身。Wasm 和 JS 之间每次穿越都有固定开销,量级在几十纳秒。单看微不足道,但你要是每帧穿几千次,那就是几百微秒——这还没算每次调用前后要做参数转换。所以铁律是:能批量就批量,把上千次小调用合成几次大调用。这条在 [04] 那篇讲边界的文章里详细说过,不重复。

第二份是视图对象的创建。这就是上一节说的垃圾来源。正确做法是创建一次,缓存住,反复用。听起来是常识,但你在重构的时候随手写一行「这里取一下顶点数据」,垃圾就回来了。

第三份是真正的拷贝。驱动把数据从 CPU 侧搬到 GPU 侧。这份成本你消不掉,只能减少搬运总量——顶点数据尽量别每帧改,能放到显存里的就放进去,改的时候用局部更新而不是整块重建。

还有个隐形的成本,藏在「同步点」里。图形接口里有几个操作会强制把 CPU 和 GPU 拉成串行:读像素、查询错误码、等待完成。这些操作在原生里也很贵,但原生开发者知道要避开;到了浏览器里,很多框架为了「方便调试」,把错误检查开在了每一帧的渲染路径上。在桌面浏览器上你可能看不出来,在中低端手机上,光这一个调用就能吃掉好几毫秒。我的建议简单粗暴:开发构建里随便开,发布构建里一个都别留。

然后是这篇文章里我认为最硬核、也最容易被忽略的一个坑:内存增长会把你的视图全部作废。

Wasm 的线性内存要扩容的时候,运行时做的事情是:申请一块更大的缓冲区,把旧数据拷过去,然后把旧的那块标记为「分离」。所有基于旧缓冲区开的视图,在这一瞬间全部失效——长度变成零,读写直接抛异常。

这在游戏里的表现是什么?跑到某个时刻,模型全黑,控制台刷出一屏错误;或者更糟,某些视图没失效但数据错位了,画面变成一团乱麻。而且它只在玩家玩了足够久、堆涨到临界点的时候才发生,你在测试机上压根复现不出来。

对策有两条,各有代价。第一条是固定堆:启动时一次性要够,运行期永不增长。代价是启动时要预留可能用不满的内存,移动端要小心被系统判定为内存超限。第二条是登记所有缓存视图,在内存增长的那一刻统一重建。这条听起来优雅,但你得保证主循环里绝不做「每帧检查一下视图还有没有效」这种操作,否则等于每帧都在分配,比不修还糟。

我自己的取舍是:加载期允许增长,进入游戏前把堆锁死;真要涨,就在关卡切换的黑屏里涨,涨完重建一次。

WebGPU 时代这套东西换了个形式,坑还是那些坑。往缓冲区写数据有内部暂存,异步映射的接口不能每帧同步等待,命令的编码发生在 JS 侧所以每帧创建绑定组会产生垃圾。用持久化的绑定组、用环形的暂存缓冲、把写入批量合并——思路和 WebGL 一模一样,只是换了个名字。

还有个在浏览器里比原生更值钱的优化:减少绘制调用。原生游戏里一千次绘制调用,现代显卡顶得住;浏览器里同样的数字,光是 JS 侧和驱动侧的固定开销,在中低端手机上就是二三十毫秒。所以合批、实例化这些手段,在浏览器里的投入产出比比原生高得多。你以为你在优化 GPU,其实你在优化 CPU 的那条缝。

案例拆解:Godot 4 的 Web 导出

Godot 4 是少数把「导出一份就跑浏览器」这条路走通的开源引擎。它的 Web 后端用 WebGL2,渲染器只保留了兼容性那一档。我在两个小项目里用过它的 Web 导出,踩到的坑很有代表性。

第一个坑是多线程。Godot 4 的 Web 导出要走多线程,页面必须带上跨源隔离的响应头,也就是常说的 COOP 和 COEP。你本地起个测试服务器加上头就没事了,传到 itch.io 这种平台——它默认不加,于是游戏黑屏、起不来、或者悄悄退回单线程。单线程模式下,物理、脚本、渲染全在主线程排队,帧时间大概翻一倍。这不是引擎的锅,是分发平台的配置问题,但玩家不会去区分这个,他只知道「这游戏在你的网站上坏了」。

第二个坑是渲染分辨率。手机上的设备像素比常常是三,一个逻辑上 1080p 的画布,实际渲染是三千多乘一千九,中端 GPU 直接跪。Godot 有渲染缩放选项,但默认值对移动端并不友好。我实测过的数字:把渲染缩放从 1.0 降到 0.6,中端安卓机上帧率从二十几提到五十以上,而在一块六寸屏上,肉眼几乎分辨不出来。

这件事对我冲击挺大的。在原生游戏的世界里,降分辨率是一种「认输」的姿态,很多人不愿意。在浏览器游戏里,它是第一手段,而且往往是最有效的那一个。思路得转过来:不是「我的游戏要跑 1080p」,而是「我的游戏要跑得顺,分辨率是达成它的一个旋钮」。

第四层:把「卡」变成数字——可观测性与自适应退化

到这一层,前面三件事你都做对了,游戏大概率已经能跑。但「能跑」和「玩家觉得顺」之间还隔着一层:你得知道自己什么时候不顺。

先说一个反直觉的结论:平均帧率是最骗人的指标。一个平均 60 帧的游戏,如果每五秒来一次两百毫秒的卡顿,玩家的评价一定是「优化很差」。反过来,一个稳定 45 帧的游戏,只要没有尖刺,玩家的评价往往是「还行」。人对稳定性的敏感度远高于对绝对数值的敏感度。所以你要看的不是平均值,是那些难看的尾巴:百分之九十五分位、百分之九十九分位、以及最长的那一帧。

我习惯的做法是记录一个环形缓冲,存最近几百帧的时间差,然后每次统计出四档分布:十六毫秒以内、十六到三十三、三十三到五十、五十以上。目标很明确——超过五十毫秒的那些帧,占比要压到千分之一以下。这个数字不是拍脑袋,是因为五十毫秒以上的卡顿人眼一定察觉得到,而千分之一的频率意味着玩家玩二十分钟才撞见一两次,还在可接受范围内。

有了数据,才谈得上自适应。所谓自适应退化,说白了就是:机器扛不住,就悄悄把画质降一档,别问玩家。

降档的顺序是有讲究的。第一档永远先降渲染分辨率,因为它对帧率的影响最直接、对观感的影响最不直观。第二档降后处理和阴影,这些是纯视觉的。第三档降粒子、降特效数量,这时候玩家开始能察觉了。最后一档才动物理刷新频率和远端实体的更新频率——动了这些,手感和联机体验就开始变味了,能不动就不动。

这里有个细节很多人栽过:升降档要有滞回。你在降档阈值附近来回横跳,画面一会儿清楚一会儿糊,玩家看着比卡顿还难受。我的做法是降档门槛设高一点、升档门槛设低一点,中间留个安全区,而且升档前要连续观察一两秒的富余,不然刚升上去又掉下来。

最后一件容易被忽略的事:把最贵的那一帧,藏在玩家看不见的地方。着色器编译、纹理上传、堆预分配、对象池预热,这些动辄几百毫秒的活儿,全部塞进加载条。加载屏这东西存在的意义不是什么品牌展示,它的意义就是替主循环挨打。玩家能接受「加载慢」,不能接受「玩着卡」——这是两个完全不同的心理账户,别搞混。

参数化设计工具:一张能贴墙上的配置表

下面这张表是我在几个浏览器项目里反复调出来的,不是什么金科玉律,但拿来做起点够用了。数值都是「建议区间」,具体项目要按自己的类型微调。

参数建议值影响调错的后果
模拟固定步长8ms(动作/格斗)/16.67ms(通用)/20~33ms(策略/模拟)手感精度与 CPU 占用太粗:碰撞穿透、手感发飘;太细:CPU 吃不消
单帧时间差钳制100~250ms防止后台切回来时物理爆炸不设:角色穿墙飞出地图
最大追赶步数3~5 步防止死亡螺旋不设:一次卡顿永久卡死
渲染插值开启高刷屏观感关闭:60Hz 逻辑在 144Hz 屏上有台阶感
每帧 JS 堆分配目标 0 字节,上限 1KBGC 频率超了:周期性顿挫,移动端尤其明显
初始堆大小桌面 256~512MB/移动 128~256MB回收时长与内存占用给太大:单次回收变长、可能被系统杀页
运行期允许内存增长关闭(只在加载期开)视图失效风险开着:玩到一半画面全黑
渲染分辨率缩放桌面 0.85~1.0/移动 0.5~0.75帧率的第一杠杆移动端给满:中端机直接二十帧
绘制调用预算桌面 <1000/移动 <300CPU 侧固定开销超了:驱动和 JS 双份开销叠加
发布构建错误检查每帧 0 次同步点开销留着:移动端白吃几毫秒
降档触发阈值连续 20 帧超 20ms自适应灵敏度太灵敏:画质抖动;太迟钝:玩家先骂了
升档触发阈值连续 90 帧低于 13ms防抖动滞回太小:画质来回横跳
对象池预热比例峰值用量的 80%首局卡顿不预热:玩家第一次开枪就卡

用这张表的方式很简单:先把你项目的每个参数填上实际值,然后找那些空着的——空着的就是没想过的,迟早会咬你。

一句话记住这四层:主循环定步长、内存早要够、视图别乱建、指标看尾巴。这四句背下来,百分之八十的浏览器帧率问题都能自己定位。

双路径对照:你现在该做哪几件事

初级路径:先做四件事,做完就有明显改善

如果你刚开始把游戏往 WebAssembly 上搬,别去看什么多线程和 WebGPU,先把这四件事做了,效果立竿见影。

第一件,固定时间步加时间差钳制。把物理和游戏逻辑从渲染帧里拆出来,用固定步长推进;给单帧时间差设个两百毫秒的天花板;追赶步数封顶。这一件事做完,你的游戏在性能差的机器上会「变慢」而不是「发疯」,这就是质变。

第二件,渲染插值。半小时就能加上,加完之后高刷屏玩家会回来夸你。这是投入产出比最高的一行代码,没有之一。

第三件,主循环零分配。把每帧会创建的对象全部挪到加载期,用对象池、环形缓冲、预分配数组。你不用做到绝对零,做到个位数个对象就已经比大多数人强了。

第四件,渲染分辨率做成可调旋钮。别等优化,先给个开关。移动端自动降到 0.6 到 0.75,这一下往往就能把二十帧变成五十帧。

口诀我给你编一个:「逻辑定步长,渲染看情况,内存早要够,垃圾别乱扔」。念起来土,但管用。

进阶路径:什么时候该往下挖

上面四件事做完还稳不住,那说明你的瓶颈在更细的地方,得开始上工具了。

第一层进阶是埋点。自己记录帧时间分布,看百分之九十九分位和最长帧,别只看平均值。找到那些尖刺出现的时间点,然后反推那一帧在干什么。绝大多数尖刺都不是持续性的性能不足,而是一次性的初始化没挪走——某张贴图第一次上场、某个特效第一次播放、某段文字第一次渲染。

第二层进阶是内存治理。建立视图登记表,在内存增长时统一重建;把堆大小锁死在加载期结束的那一刻;监控实际堆占用曲线,看它是不是只涨不降。

第三层进阶是上传路径优化。持久化绑定组、环形暂存缓冲、把每帧多次小写入合并成一次大写入。这块的收益在中低端设备上特别明显,在高端机上你根本测不出来,但你的玩家不全用高端机。

第四层进阶才是多线程。把物理、AI、资源解压扔进 Worker。但我得泼盆冷水:多线程不是免费的午餐,跨源隔离的响应头要配,共享内存的同步要自己写,调试难度上一个台阶。而且如果你的主线程本来就只花了八毫秒,你把它拆成两个四毫秒的线程,收益远不如你想象的那么大——反倒多出一堆同步开销。[09] 那篇专门讲这个,动手之前建议先读。

至于 WebGPU,我的态度是:新项目可以关注,老项目先别急着迁。它的上限确实更高,但生态还在长,工具链的坑比 WebGL 多得多。等到你现有的方案确实撞墙了,再考虑换。

争议与常见坑:这几件事我劝你别干

坑一:拿定时器当主循环。总有聪明人想绕过 rAF 的节拍限制,用零延迟定时器跑循环,幻想能跑到两百帧。结果三个:一是浏览器会给你节流,隐藏标签页里直接降到一秒一次;二是你的画面和屏幕刷新对不齐,撕裂感明显;三是功耗爆炸,玩家笔记本风扇起飞。rAF 慢,但它是唯一和显示器对齐的节拍器,认了吧。

坑二:以为 Wasm 没有 GC 就没有 GC 问题。线性内存里确实没有自动回收,但你的胶水代码、你的引擎运行时、你的视图对象,全在 JS 堆和宿主堆上。把「Wasm 无 GC」当成免死金牌,是最常见的认知偏差。正确的说法是:Wasm 把 GC 的锅从语言层甩给了你,而你通常背不动。

坑三:以为十六点六七毫秒全是你的。浏览器要留时间做合成、做样式、处理别的回调,还要给 GC 留余量。真正能安心用的大概是十到十三毫秒。按十六点六去写的项目,在开发机上勉强六十帧,一到玩家手里就掉。

坑四:迷信多线程。论坛上到处是「上了多线程帧率翻倍」的帖子,但没人告诉你他们的场景是重物理的计算密集型游戏。如果你的瓶颈是绘制调用和内存分配,开十个线程也没用。先测,再拆。

坑五:追求高帧率。有人觉得在 144Hz 屏上把游戏跑满 144 帧很酷。但你的物理是固定六十步的,渲染插值之后玩家看到的还是六十帧的信息量,多出来的帧只是在做重复劳动,顺带把功耗翻倍。高刷屏的正确用法是降低单帧耗时、留出更多余量,不是把余量全花掉。

坑六:用平均值衡量性能。这个坑我踩过。做了个优化,平均帧时间从 17 降到 15,很开心,上线后玩家还在骂卡。因为我把一次卡顿平滑成了三次小卡顿,平均值好看了,尾巴更长了。

还有一个不成文的争议,我想摆到台面上说:到底该不该为了帧率牺牲画面?浏览器游戏的社区里,两派吵得很凶。一派说浏览器用户耐心差,宁可糊一点也要顺;另一派说画面就是卖相,第一眼不惊艳,玩家根本不会玩第二眼。

我的看法比较务实,可能有点得罪人:第一眼靠画面,第二分钟靠帧率,第二天靠玩法。这三关是串联的,前两关过不了,第三关根本没机会展示。所以正确的顺序是——把画面做到「截图能看」的程度,然后把剩下的算力全砸给帧率稳定性,最后一分钱都别浪费在玩家察觉不到的特效上。

FAQ

问:我的游戏本地跑六十帧,传上 itch.io 就掉到三十,是怎么回事?

先别怀疑引擎。九成情况是这三件事之一:一是页面被嵌在 iframe 里,某些环境下 iframe 的刷新调度和外层不同步,还会被限流;二是设备像素比没处理,玩家的高分屏把你的渲染分辨率放大了两三倍;三是多线程的跨源隔离头没配,游戏悄悄退回了单线程。排查顺序就按这个来,十分钟能定位。

问:Wasm 内部的 GC 暂停,会被浏览器的垃圾回收影响吗?

两套东西是独立的,各收各的,但它们共享同一个帧预算。也就是说,浏览器引擎的回收和你的运行时回收可能在同一帧里前后脚发生,叠加起来就是一次大卡顿。这也是为什么我建议把堆大小控制在合理范围——不是为了省内存,是为了让这两次暂停别叠得太狠。

问:固定步长还是可变步长,怎么判断?

看一条:你的玩法对时间精度的要求,是不是到了「毫秒级差异会改变结果」的程度。动作、格斗、平台跳跃、任何有联机或者回放需求的,一律固定步长,没得商量。纯点击、纯回合、纯叙事的,可变步长能省不少事。中间地带比如塔防、模拟经营,固定步长但可以放宽到二十毫秒,反正玩家也感觉不出来。

问:偶尔会出现一帧两百毫秒的卡顿,但只出现一次,要紧吗?

要紧,而且这种「只出现一次」的最难查,因为它多半是一次性的初始化——着色器第一次编译、纹理第一次上传、某个界面第一次构建、堆中途扩容。做法是把首局游戏的每一帧时间都打出来,找到那个尖刺,看它对应什么操作,然后把它挪到加载期或者拆到多帧里。别指望它自己消失。

问:现在值得为 WebGPU 重写渲染层吗?

看你是否已经撞墙。如果你的游戏在中端设备上稳定跑满六十帧,重写只会给你带来一堆新 bug。如果你卡在绘制调用上,而且确定这是主要瓶颈,那值得评估。但请记住,换图形接口解决不了主循环设计的问题——那四层里的前两层,跟图形接口一点关系都没有。

最后留个我自己也还没想明白的问题:浏览器厂商对后台标签页、低电量模式、省电策略的控制只会越来越激进,也就是说「每帧叫我一次」这个承诺本身在变弱。当这个承诺进一步松动,我们这套围绕 rAF 建立起来的循环设计,还能撑多久?我不知道。但我知道,凡是把时钟当成理所当然的游戏,在这种时候总是最先出问题的。

文章标签
WebAssemblyWasmEmscriptenWASIwasm-bindgenComponent ModelSharedArrayBufferAtomicsWasm 多线程Wasm SIMDwasm-packRust Wasm
更多专题全部专题
觉得有价值?点赞或收藏支持内容持续产出。
← 返回专题:WebAssembly 游戏开发技术专题