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

从 asm.js 到 WebAssembly:浏览器执行效率的十年战争

· 0 分钟阅读
从 asm.js 到 WebAssembly:浏览器执行效率的十年战争 asm.js to WebAssembly: The Ten-Year War for Browser Execution Efficiency — WebAssembly 游戏开发技术专题

从 asm.js 到 WebAssembly:浏览器执行效率的十年战争 asm.js to WebAssembly: The Ten-Year War for Browser Execution Efficiency

为什么这段「考古」是理解 Wasm 的第一块基石

先给你一个画面。2014 年 3 月,旧金山 GDC 的展台上,Mozilla 的人开了台普通笔记本,浏览器里跑着 Unreal Engine 3 的 Citadel 演示——没有插件,没有安装包,没有「请稍候,正在加载 300MB」,就是一个标签页,30 帧上下。台下坐着 Epic 的人。同一个页面在隔壁 Chrome 里打开,帧率大概只剩三分之一。

后来大家才知道,把 UE3 搬进浏览器,Mozilla 的工程师花了四天。

四天这个数字比任何跑分都狠。它等于当众宣布:C++ 大引擎进浏览器这件事,技术上已经通了,剩下的只有一个问题——谁来为「通」买单。

我做了很多年数值,习惯把任何系统都看成一条预算曲线。浏览器游戏也一样:一帧 16.7 毫秒是硬预算,砍成三份——下载、翻译、执行。过去二十年浏览器干的所有事,本质是从「翻译和执行 JavaScript」这一栏里,一分一毫往回抠时间。asm.js 和 WebAssembly 这十年,抠得最凶。

所以你会在很多地方看到这样的说法:Wasm 比 JS 快。这句话既对又不对,而答案就藏在这十年里。下面我按四层往下拆:asm.js 到底聪明在哪、它的死穴为什么是结构性的、四大浏览器厂商那几年在谈判桌上算的是什么账、以及最后那次身份跃迁究竟改变了什么。中间会给你两张表——一张拿去量你自己的项目,告诉你该不该迁;另一张告诉你现在的性能瓶颈到底卡在哪一栏。

说句实话,这段历史现在被讲得很像「旧技术被新技术淘汰」的励志故事。不是的。asm.js 死掉的原因跟技术几乎没有关系,这一点,比它当年有多快,重要得多。

第一层:asm.js 不是新语言,是给 JS 打的一个「编译器读得懂的暗号」

2013 年,Mozilla 的 Alon Zakai 提出了 asm.js。这个人同时也是 Emscripten 的作者——一个把 C/C++ 编译成 JavaScript 的工具链。他的动机极其朴素:手上有一堆被编译成 JS 的 C++ 代码,跑得慢,他想让它们跑快点。

要理解他做对了什么,得先理解 JS 引擎当年为什么慢。不是语法慢,是「不知道类型」。引擎拿到一段代码,变量里装的是整数还是小数还是字符串,它事先不知道,只能一边跑一边猜——猜对了走快路,猜错了推倒重来。这套机制叫类型推测,代价有两个:预热期慢,而且随时可能因为一次意外全盘重来。

Zakai 的洞察是:如果我能提前告诉引擎「这个变量从头到尾都是 32 位整数,这块内存是我自己管的原始字节」,引擎就能像编译 C 一样编译它,压根不用猜。

但他不能发明新语法——新语法意味着新引擎,新引擎意味着没人用。所以他做了个极其聪明的妥协:不发明任何东西,只约定一套写法。整数用位运算硬摁住;浮点数用特定写法标明精度;内存不用对象,用一大块原始字节缓冲区加几个类型化视图来模拟,偏移量自己算;热路径上不许出现闭包和动态对象,因为那要走垃圾回收。

然后在函数开头挂一个特殊的声明标记。引擎看到这个标记,再跑一遍规则校验,全过了,就跳过类型推测,直接走提前编译。Mozilla 给这条捷径起了个名字叫 OdinMonkey,2013 年随 Firefox 22 上线。

玩家侧看到的是什么?是帧率。而且是真的回来了——因为游戏的热代码高度集中:物理、动画混合、寻路、粒子更新,这几个模块吃掉九成 CPU 时间,它们全是数值密集、循环密集、不碰字符串也不碰 DOM。asm.js 只要把这 5% 的代码拿下,帧率就能从 15 抬到 50。这是个漂亮的二八开买卖。

但它有一个隐藏条款,这个条款后来要了它的命:全局一致性。

整个模块必须一起守规矩。你在热路径上不小心留了一个闭包捕获,或者某个循环里的临时变量忘了摁成整数,校验器就整块拒绝,代码退回普通 JS 路径——而且不报错。你只会突然发现帧率从 60 掉到 22,然后花两个小时翻代码,最后发现是一行写得随意的声明。

我自己就翻过这个车。2015 年给一个 2D 物理 demo 做 asm.js 化,改完一测,帧率反而掉了。查了半天,发现是我在碰撞检测的循环里用了一个匿名函数做排序比较——就这一处,整块模块降级。当时那个心情,大概等于你精心调了一条成长曲线,结果发现数值表里有个人把 0.1 打成了 0.01。

这种「沉默降级」的体验,是 asm.js 在开发者口碑里始终差一口气的根本原因。它不是不够快,是你不确定它什么时候不生效。对一个把上线日期钉死的团队来说,这种不确定性比慢百分之十难受得多。

第二层:暗号的代价——把源码当中间表示,等于让浏览器干三份活

asm.js 最要命的设计决定,是把 JS 源码本身当成了中间表示。这是它最聪明的地方(任何浏览器都能跑),也是它的死穴。

我们按预算的三栏逐个算。

第一栏:下载

文本比二进制大。同一份游戏逻辑,asm.js 文本和 Wasm 二进制,压缩前大概是 2 到 2.6 倍的关系;上 gzip 之后差距收窄到 1.2 到 1.6 倍,但差距还在。别小看这点——一个 30MB 的游戏,多出 30% 就是多几秒白屏。而加载体验是断崖式的:用户能忍 3 秒,忍不了 8 秒,中间没有过渡带。

第二栏:翻译

这一栏才是真正的血亏。浏览器拿到 asm.js,要干三遍活:先当普通 JS 做词法语法分析、建语法树;再按 asm.js 的规则做一遍类型校验;再把它编译成机器码。同一份东西,读三遍。

Wasm 呢?二进制格式天生就是「给机器读的」,边下载边编译都行,而且编译结果还能缓存下来。这一栏的差距是数量级的,不是百分比。

第三栏:执行

这一栏,说句可能让很多人意外的话:两者差不多。稳态执行的时候,asm.js 提前编译出来的机器码,和 Wasm 编译出来的机器码,差距普遍在 0 到 20% 之间,多数场景不到 5%。因为它们本来就是同一份 C++ 源码,被同一条 LLVM 工具链优化过。

所以「Wasm 比 asm.js 快」这句话,如果指的是跑起来之后的帧率,那基本是错觉;如果指的是启动,那是真的快,而且是成倍的快。这个区分极其重要,因为 27 篇要拆的「Wasm 性能神话」的所有误会,源头都在这里。

三栏摊开看,结构是这样的:

预算项asm.jsWasm差距量级
下载体积(gzip 后,同功能)基准少 20%–40%秒级
解析 + 编译(首帧前)语法分析 + 规则校验 + 编译二进制直读 + 编译,结果可缓存2–3 倍
稳态执行(热循环)基准高 0%–20%,多数 <5%基本持平
能力上限受 JS 语言演进约束由 Wasm 提案路线独立决定结构性
跨浏览器一致性取决于各引擎是否愿意实现优化标准义务,实现度高结构性

还有一层是能力上限。asm.js 寄生在 JS 语言上,JS 没有的东西它就没有。想要向量化的并行计算?等 JS 加。想要多线程?等 JS 加,而且等来了还被浏览器安全策略按下去好几年(09 篇细讲)。想要 64 位整数、原生异常?同样得等。你的游戏技术路线,被一个跟你毫无关系的标准组织的排期表绑架了。

这是「寄生」的第一重代价:能力上限由宿主决定。

第二重代价更隐蔽,也更致命:性能上限由每个浏览器厂商的意愿决定。

Figma 是最能说明这件事的案例。他们的设计工具引擎是 C++ 写的,先编译成 asm.js。2017 年迁到 Wasm 之后,官方公布的数字是:Chrome 上首次可交互时间缩短两成左右,Safari 上超过三倍。三倍——同一份代码,同一套迁移工作,两个浏览器的收益差了一个数量级。

为什么?因为 Safari 的 JS 引擎从来没为 asm.js 做过真正的提前编译优化。苹果没兴趣。他们的账很简单:为一个冷门方言单独维护一条编译后端,成本是实打实的,收益看不见。

Figma 这个案例在游戏圈之外,但它把这个机制暴露得比任何游戏都清楚:asm.js 的性能不是一个技术常数,是各家厂商意愿的加权平均。你写的是同一份代码,在不同浏览器里跑的是不同程度的优化。对一个面向全平台的网页游戏来说,这是灾难——你没法跟用户说「请在 Firefox 里玩我们的游戏」。

顺带说一句,迁移从来不是只赚不赔。任何一个把引擎从 asm.js 搬到 Wasm 的团队都会撞上同一类问题:原来靠 JS 生态顺手解决的东西(字符串处理、内存回收、和页面元素的交互),迁过去之后得自己重写一遍。这笔账,05 篇讲 Unity 迁移的时候还会再算一次。

第三层:四张桌子上的谈判——asm.js 死于「没人有动机」

2014 年 GDC 那个 UE3 demo 之后,行业的气氛变了。Mozilla 用一次公开演示,把「浏览器能不能跑 3A 引擎」这件事从实验室拽到了展台上,还顺手把 Chrome 的帧率数字变成了自己的营销素材。这招很有效,也很得罪人。

但真正的战争不在展台,在会议室。要理解 asm.js 为什么输,你得先理解四家厂商各自在算什么账。

厂商对 asm.js 的真实态度算的是哪笔账对 Wasm 的转向
Mozilla提出者,全力推浏览器性能口碑直接等于市场份额,它需要一个能对抗 V8 叙事的抓手最积极,Zakai 本人是 Wasm 早期推动者之一
Google能跑,但始终不肯为它单独做提前编译通道维护两套编译器成本翻倍,收益只覆盖一小撮把 C++ 编到网页的开发者转向了,但不是被说服,是算过账:统一标准意味着不用为任何方言单独投资
Apple基本无视Safari 上做 asm.js 优化的收益看不见加入并积极实现,因为标准是通用的,顺带还能省电
Microsoft做了支持,性能尚可Edge/Chakra 时代跟进,后来整体转向 Chromium中立偏积极

这张表里最关键的一格是 Google。很多人把 Chrome 当年在 asm.js 上表现平庸,解释成「谷歌不配合」。不完全是。V8 团队的理由其实很正当:为 asm.js 单独做一条提前编译通道,等于维护两套编译器,而受益者只有一小部分人。任何一个理性的工程团队都会犹豫。

这就把 asm.js 的处境说清楚了:它要求每一家浏览器厂商,为「别人的方言」单独投资。而 Wasm 把这件事翻了个面——它把「为性能买单」从厂商的自愿行为,变成了标准的义务。标准一旦立起来,谁不实现谁就是短板,压力方向反过来了。

这是我从这段历史里学到的最有用的一条:一个技术方案能不能活,往往不取决于它多好,而取决于它让多少人不得不动手。

然后是 MVP。2017 年真正落地的 WebAssembly,是一个极度克制的版本。它只干三件事:一个可验证的二进制格式、一块线性内存、一套结构化的控制流。垃圾回收?没有。异常?没有。多线程?没有。向量指令?没有。

当年很多人嘲笑这个 MVP「功能残缺」。现在回头看,这份克制恰恰是它赢的原因。它把「能跑 C/C++ 游戏」这条最小闭环先打通,让 Emscripten、Unity 这些下游立刻能出货;出货了就有真实用户,有真实用户就有正反馈,有正反馈其他厂商就不得不跟。特性可以一年一年往上加(02 篇会把整套提案演进捋一遍),但生态的窗口期不会等你。

2017 年 3 月,Chrome 57 和 Firefox 52 几乎同一个月上线 Wasm,Safari 11 和 Edge 16 在同年跟进。从 2013 年 asm.js 公开算起四年,从 Emscripten 那条工具链算起大概五六年。十年战争这个说法稍微夸张了点,但那几年的密度确实高。

再说回 UE3 那个 demo。它的意义从来不在 30 帧。它证明的是「浏览器能跑 3A 级引擎」,然后把这个结论变成一个公开的、有观众的压力测试。Mozilla 选在 GDC 而不是在博客上发布,就是在借开发者社区的舆论,逼其他厂商表态。用 demo 换标准话语权,这招后来在 Wasm 时代一直在被复用——27 篇讲性能跑分怎么变成叙事武器的时候,你会看到一模一样的套路。

代价也有。Chrome 上的糟糕表现成了长期攻击素材,两家结了梁子。做技术政治和做游戏平衡其实很像:你能用一次漂亮的压制换来短期优势,但对手会记住这件事,然后在下一个版本里找回来。

第四层:范式跃迁——从「JS 的一种写法」到「独立于 JS 的编译目标」

前面三层讲的都是「快不快」。这一层讲的是身份变化,而身份变化才是真正值钱的东西。

asm.js 的身份是「JS 的一种特殊写法」。Wasm 的身份是「一个独立的编译目标」。听起来像文字游戏,实际差得很远,体现在三个地方。

第一,编译变成了一次性的、可缓存的。 asm.js 是源码,浏览器每次打开页面都得重新解析、重新验证、重新编译。Wasm 是二进制,可以边下载边编译,编译好的机器码还能存起来下次直接用。对玩家来说,这就是「第二次打开秒进」和「每次都等五秒」的差别。这个体验差异,比任何跑分都更能决定一个网页游戏能不能留住人。

第二,语言自由。 asm.js 事实上只服务 C/C++,因为 Emscripten 就是那条工具链。Wasm 从标准第一天起就设计了「任何语言都能编」——Rust、Go、C#、Kotlin、Dart 后来全都接上了(11 篇讲 GC 提案落地之后,这条路彻底闭环)。对一个独立团队来说,这意味着你终于可以把「用什么语言写游戏」和「游戏跑在哪」分开考虑。这在 2013 年是奢望。

第三,内存模型独立。 线性内存加显式类型,让 Wasm 可以脱离 JS 的对象体系,活在自己的沙箱里。这条的深远意义当时没多少人意识到:既然它不依赖浏览器,那它就能跑在浏览器外面。后来的 WASI、边缘计算、容器化,全部是从这一条长出来的(03、21、22 篇)。

玩家侧真实感受到的变化,其实不是「帧率变高」,而是三件更朴素的事:加载更快、帧率更稳、Safari 用户终于不被歧视了。

「帧率更稳」这条值得多说一句。asm.js 时代,即使进了提前编译的快路径,页面其他部分仍然是普通 JS,垃圾回收照样会发生,主线程照样会被阻塞。Wasm 之后,引擎可以更干净地把游戏逻辑关进一个内存自己管、回收器不插手的盒子里,帧时间的方差明显收窄。做游戏的人都知道,玩家抱怨的从来不是平均帧率,是掉帧。60 帧里突然插一个 40 毫秒的卡顿,比稳定 45 帧难受十倍。这是手感问题,不是跑分问题。

Unity 的例子可以对照着看:他们 2017 年前后把 Wasm 变成 WebGL 的默认构建目标,asm.js 又当了两年的兜底选项才彻底退场。为什么这么慢?因为引擎的迁移成本从来不在编译器,在运行时——垃圾回收、异常处理这些当年 Wasm 都没有,得靠工具链模拟,模拟一层就多一层开销和 bug。05 篇会把这笔账拆开算。

参数化设计工具:一张「该不该迁 / 瓶颈卡在哪一栏」的体检表

我把这十年里最实用的东西压缩成一张表。不管你是要从 asm.js 迁 Wasm,还是已经在 Wasm 上但感觉「没变快」,都可以对着这张表量一遍。

指标怎么量(人话版)健康区间越界意味着什么
二进制与源码文本体积比同一份构建分别出两种格式,各自 gzip 后比一比1.2–1.6 倍低于 1.1 说明压缩配置没对齐;高于 1.8 说明代码里字符串和调试信息占比过高,先清这些
首帧前总耗时从用户点击开始算到画面出现≤ 3 秒(中等网络)超过 5 秒,先把下载砍下去,别急着优化执行
编译耗时占比编译耗时 ÷(下载 + 编译)≤ 50%超了说明模块太大,或者没开编译缓存
每 MB 二进制的编译耗时编译总时长 ÷ 包体大小30–80 毫秒/MB(桌面)超过 100 说明优化档位过高,或者没做流式编译
热循环占单帧比例用性能面板看排前五的函数占了多久≤ 60%超了说明瓶颈在渲染提交而不是逻辑(07 篇)
热路径每帧内存分配次数数一数热循环里有没有新建对象0任何一个都可能在回收时变成 20 毫秒的卡顿
线性内存峰值运行时读一下内存页用量32 位下保守线 2GB逼近上限先查泄漏,再考虑 Memory64(15 篇)

再说优化档位。Emscripten 大致三档:中档(默认推荐,绝大多数独立游戏停在这)、体积优先档(包体吃紧时用,性能损失通常 5%–15%)、激进档(向量化加极限内联,收益 5%–10%,但包体会涨)。我的建议很土:默认中档,包体超标才降档,激进档只有在你实测有正收益的时候才开。10 篇会把每个档位摊开测一遍。

还有一个特别容易被忽略的参数:内存初始大小。默认值往往偏大,小游戏直接白占几 MB 下载量。把它调到你实测峰值的 1.2 倍左右,是个不花力气的便宜。

最后补一句方法论上的话:这张表里我最看重的不是绝对值,而是各项之间的比例。就像调战斗数值时,单看一个技能的伤害没意义,你得看它在整场战斗总输出里占多少。性能预算也是同一回事——编译占了首帧时间的一半还是十分之一,决定了你该先动哪一刀。

双路径:你要在这段历史上花多少时间

初级用户速览(3 分钟版)

记住三句话就够了。

一、asm.js 是让 JS 装成 C,Wasm 是让 C 直接说话。前者靠约定,后者靠格式。

二、快的是启动,不是跑起来。稳态执行两者差距 5% 以内是常态。

三、asm.js 死于动机问题,不是技术问题——它要求每家浏览器厂商为别人的方言单独投资。

行动建议:不需要学 asm.js 的写法,但一定要理解它留下的两个概念——线性内存和显式类型。这两个词会一直跟着你,从你第一次查 Wasm 的内存泄漏,到你第一次调垃圾回收造成的抖动,全靠它们。你试着把这两个词先吃透,再去看任何 Wasm 性能文章,会发现自己读得比想象的快。

进阶深入(给要动引擎的人)

如果你要自己接引擎或者做运行时选型,有三个点值得往下钻。

分层编译。主流引擎对 Wasm 都是两条编译路径——一条快的基线编译器,先把代码跑起来,生成的机器码质量一般;一条慢的优化编译器,在后台慢慢优化,优化好了再把执行权换过去。设计任何加载策略时,你的目标不是「编译得快」,而是「让基线版本足够快,撑到优化版本上线」。这个思路和游戏里「先给玩家一件能用的白装,再慢慢养成」是一模一样的,连取舍逻辑都通用。

验证器为什么能做得又快又一致。Wasm 的验证算法是形式化的,有参考实现和测试套件,各家引擎做出来的行为高度一致。asm.js 的校验规则散落在各引擎自己的实现里,你说的「合法」和我说的「合法」可能不是一回事。这就是前面说「跨浏览器一致性」是结构性差异的原因——它不是性能问题,是工程治理问题。

结构化控制流。Wasm 不允许随意跳转,只能用结构化的块。这个限制看起来像倒退,实际上它是「下载的同时就能验证和编译」的前提。任何关于「能不能并行编译」「能不能流式编译」的问题,答案都埋在这一条里(09 篇讲多线程时会再用到)。

争议与常见坑

坑一:以为换 Wasm 峰值性能翻倍。我见过太多项目立项时写「迁 Wasm 性能提升 2 倍」,最后实测 8%。这是立项预期建立在错误对比上——你比的是「asm.js 在没做优化的浏览器上」,不是「asm.js 在做了优化的浏览器上」。

坑二:以为 asm.js 是纯粹的历史垃圾。它至少留下三样东西:线性内存模型、用类型化数组自己管堆的思路、以及「给引擎一个明确的提前编译开关」这个设计。这三样全是 Wasm 的地基。而且 2014 到 2017 那批用它真上线、真跑流水的游戏,是这段历史最硬的证据。

坑三:以为 MVP 特性少是「Wasm 还不成熟」。反了。恰恰是那份克制让它能按时出货,让下游工具链跟得上。一个塞满特性的标准,很可能到今天还在提案阶段打转。

坑四(我自己的):以为换 Wasm 能解决掉帧。当年我把一个项目的后端换完,帧率一点没动,最后发现瓶颈在每帧往渲染管线提交的绘制调用数量上。Wasm 优化的是 CPU 逻辑侧,它管不到你的渲染管线。07 篇会展开,别重蹈我的覆辙。

坑五:把「快」当成单维度的。下载、编译、执行三栏各有各的优化手段,也各有各的收益上限。上来就优化执行,往往是三栏里最不划算的一刀——因为它本来就是差距最小的那一栏。

FAQ

Q1:asm.js 现在还要不要管?
不用写,但要读得懂。存量代码和老项目还有历史包袱,而且你读别人工具链的输出时偶尔会撞见它。更重要的是思维方式:线性内存、显式类型、提前编译开关——这三样是你后面看任何 Wasm 性能问题的底层直觉。

Q2:给个数字,Wasm 到底比 asm.js 快多少?
分三栏。下载:gzip 后省 20%–40%。编译:省一半以上。稳态执行:0–20%,多数场景 5% 以内。所以如果你的游戏卡在「跑起来之后」,换 Wasm 解决不了问题,去查渲染和回收。

Q3:为什么 MVP 不直接做垃圾回收和多线程?
垃圾回收会拖慢一切——一旦 Wasm 里出现需要被自动管理的对象,引擎就不能再假设「这块内存是死的」,很多优化路径直接堵死。多线程则卡在浏览器安全策略上(共享内存因为侧信道攻击被禁过一轮,09 篇细讲)。先把最纯粹、最容易验证的子集做出来,是唯一能在 2017 年按时出货的路。

Q4:Unity 为什么拖到 2017 年才把 Wasm 当默认?
因为引擎的迁移成本在运行时,不在编译器。IL2CPP 的输出链路和 Emscripten 绑得很深,换后端要重新验证整套运行时——尤其垃圾回收和异常,这两块当年的 Wasm 都没有,得靠工具链模拟。05 篇会把这条链路完整拆一遍。

Q5:现在学 Wasm,值得从这段历史入手吗?
值得,而且比直接啃规范快。Wasm 里那些看起来「奇怪」的设计——内存为什么是线性的、为什么默认不带垃圾回收、为什么要拆成几十个提案慢慢加——答案全在这十年里。你先把「为什么」搞明白,后面看「怎么做」会顺很多。

最后留一个我觉得挺有意思的观察。这场十年战争,每一个设计决定都是为了「在浏览器里跑得更快」——显式类型、线性内存、可验证的二进制、不依赖宿主的回收机制。但你猜怎么着?恰恰是这些决定,让它今天能跑在 CDN 的边缘节点上、跑在容器里、跑在单片机上。一场为网页和游戏打的仗,最后可能把整个软件分发的方式重写一遍。

当年在 GDC 展台上盯着那 30 帧的人,大概没想过这个结局。你觉得这是意外,还是必然?

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