Emscripten 深度解析:把 C/C++ 游戏引擎搬进浏览器的完整路径 From Clang to SharedArrayBuffer
引擎不是「编译一下就能跑」,是被 Emscripten 一节一节拆开、再装回去的
为什么 Emscripten 是把引擎搬进浏览器的第一块基石
2013 年,我在一间月租八百的出租屋里,试图把一个自己写了两年的 C++ 小引擎塞进浏览器。做法很原始:能砍的全砍了,剩下一个渲染循环加一个物理步进,然后对着 Emscripten 的文档一行一行试。第一次跑起来的时候,屏幕黑了十几秒,然后跳出一个转动的立方体,帧率 22。
我当时的第一个反应不是「成了」,而是——这玩意儿到底对我的代码做了什么?
这个问题十年后依然值得问。因为绝大多数人用 Emscripten 的方式,都是把它当成一个黑盒:点一下,出 wasm,能跑就行。直到某天你的游戏在某个机型上内存爆炸、某个回调在 iOS Safari 上卡死、某个多线程版本传到 itch.io 上干脆白屏——你才会发现这个黑盒里装的不是魔法,是四道你必须亲手摸过的工序。
说句实话,Emscripten 大概是我见过「入门门槛最低、精通门槛最高」的工具之一。装上 SDK,五分钟就能跑通一个 hello world;但要把它当成一个可靠的游戏引擎交付通道,你得同时懂编译器、懂内存布局、懂浏览器安全模型。这三样东西凑在一起,才叫「搬引擎」。
这篇就干一件事:把 Emscripten 拆开给你看。从你的 C++ 源码到浏览器里跑起来的那一帧,中间到底过了几道手,每一道手会在哪里咬你一口。
第一层:编译管道——你的 C++ 要过四道安检
很多人以为 Emscripten 是个编译器。不是。它是一个工具链集合 + 一套 C 运行时 + 一层 JavaScript 胶水。真正干编译这件事的,是 Clang。
你的代码从源文件到 .wasm,大致走这么四段:
第一段,Clang 前端。把 C/C++ 啃成 LLVM 中间表示。这一步会做语法语义检查、模板展开、大量内联。你在代码里写的每一个宏、每一个模板特化,都在这里被摊平成一棵 IR 树。这一步出的错,你比较熟——就是普通的编译错误。
第二段,LLVM 的优化器。常量传播、死代码消除、循环展开、向量化,都在这里。开 -O2 和开 -O3,回来的东西可能差一截。开 -flto(链接时优化)的话,跨编译单元的优化也在这个阶段被撑开,代价是编译时间肉眼可见地翻倍。
第三段,wasm-ld 链接。把一堆目标文件和运行时库(Emscripten 自带的 libc、libc++、libc++abi)合成一个 wasm 模块。这一步负责分配地址、生成函数表、处理重定位。顺带说一句:Emscripten 用的 C 库是 musl,不是你在桌面上用的 glibc。这意味着某些 POSIX 函数的行为会有出入,某些函数干脆没有。这是很多「在 Linux 上好好的,编到 wasm 就不对」的根源。
第四段,Binaryen。这是 Emscripten 相对裸 LLVM 的独门武器。为什么需要它?因为 LLVM 的 wasm 后端在字节码层能看到的优化机会有限,很多模式只有在 wasm 指令序列这个层面才显现出来——比如相邻的内存访问能不能合并、某个局部变量能不能提升成栈上值、某个间接调用能不能内联。Binaryen 里的 wasm-opt 就是干这个的。经验值:一个中等规模的模块,在 -O3 下过一遍 Binaryen,体积还能再掉 5% 到 15%,运行性能提升 5% 到 20%,具体看代码形态。别小看这一道,它经常是「为什么别人的 wasm 比我的小一半」的答案。
最后产出三样东西:.wasm(真正的字节码)、.js(胶水层)、以及可选的 .mem(内存初始化文件)。胶水层千万别当成可有可无的附属品。它负责加载 wasm、创建内存、跑静态构造函数、把 C 侧的系统调用桥接到浏览器 API 上——你的代码调文件操作,胶水层拦下来去内存文件系统或者 IndexedDB;你调图形接口,胶水层翻译成 WebGL 调用;你调音频接口,它转成 WebAudio。它是引擎和浏览器之间的翻译官兼替身。
案例时间。QuakeJS,2017 年由开发者 Inolen 把 ioquake3 编译到浏览器里跑,是那个年代最有说服力的演示之一。但你去翻他的移植笔记会发现,卡他最久的根本不是渲染,是环境。Quake 3 的网络层用 UDP,浏览器只给你 WebSocket,所以必须在 C 侧写一个 UDP-over-WebSocket 的桥;Quake 的 pk3 资源包要靠 HTTP 拉取,而 Emscripten 的内存文件系统需要你提前把文件塞进去。这两件事都不是「编译」问题,是「搬家」问题。
这就是我想说的第一层结论:Emscripten 给你搬引擎的能力,不给你搬环境的运气。你的引擎越依赖操作系统的脾气(网络、文件、线程、时钟),你要重写的部分就越多。管道本身是可靠的,不可靠的是管道两端的世界不一样。
第二层:线性内存——一整块荒地,和在上面画地契
WebAssembly 只有一块线性内存。说人话:它就是一大块连续的字节,本质上是一个可增长的缓冲区。没有分段,没有页表,没有操作系统帮你兜底。你的引擎所有的栈、堆、全局变量、字符串常量,全挤在这一块地上。
Emscripten 在这块地上是这么划分的:低地址放静态数据(全局变量、字符串常量、虚函数表),然后是一段栈,再往上是堆,堆往上长,栈往下长,中间留出空间给它们互相试探。函数表(用来做间接调用)放在另一个结构里,不占这块地。
JS 这一侧怎么访问它?靠「视图」——8 位视图、32 位视图、浮点视图,其实就是在同一块缓冲区上套不同的读数方式。你以为这是一层很方便的封装,其实这是 Emscripten 给新手埋的头号地雷。
地雷是这么炸的:这块缓冲区一旦增长(也就是堆不够用了、向系统要了更多内存),所有已有的视图全部失效。你之前在 JS 里缓存的那个浮点视图,增长之后继续往里写,写的是空气,或者当场抛异常。我自己的翻车记录:做一个音频波形可视化,图省事在 JS 里缓存了浮点视图,结果玩家一进大关卡、内存一增长,波形直接变成一条直线。我查了两天,最后发现问题不在音频,在内存。
所以参数上有个铁律:原型期可以开内存自动增长,正式版尽量关掉,改成一开始就预留够。因为增长本身是有代价的——要把整块内存重新安置,大内存下足够让你掉一帧。而且增长会连锁触发上面那串视图失效问题。宁可启动时多要一点,别在战斗中途向系统伸手。
栈这一项也特别容易翻车。Emscripten 的默认栈大小是 64KB 这个量级。你在桌面上写引擎的时候,栈上有几个大数组、递归深一点、用一下栈上分配,根本不当回事;搬到 wasm 里,64KB 瞬间见底。栈溢出在 wasm 里的表现不是给你一个漂亮的错误,而是静默地把相邻内存踩坏,或者跳进一个陷阱指令直接挂掉。这种 bug 的排查难度,我称之为「玄学级」。游戏项目建议把栈开到 1MB 到 8MB,具体看你的递归深度和栈上数据结构。
案例说 Godot 4 的 Web 导出,这个案例特别典型。Godot 整个引擎是 C++ 写的,导出 Web 时是把整个引擎编译成一个 wasm 模块。你在编辑器里导一个空场景,出来的 wasm 是二十几到三十几 MB 这个量级,浏览器加载时还得先给这块线性内存分配几百 MB 的空间。这在桌面浏览器上不算事,在移动端 Safari 上就是硬门槛——iOS 对单个页面的内存有硬性上限,超了直接崩溃,连个像样的错误都不给你。
所以 Godot 官方文档里写得很直白:移动端 Web 不是推荐平台。这不是 Godot 做得不好,是「一块连续大内存」这个模型和移动浏览器内存管理之间的结构性矛盾。你换任何引擎来都一样。理解了这一层,你就不会再问「为什么我的游戏在手机上打开就闪退」这种问题了——它在要一块手机根本给不起的地。
第三层:JS 与 C/C++ 的边界——过路费、翻译官和黑话
wasm 的函数签名只有四种类型:32 位整数、64 位整数、32 位浮点、64 位浮点。指针就是 32 位整数。没有字符串,没有对象,没有结构体,没有数组。
这意味着每一次跨界调用,都是一次「报关」。你得把要传的东西拆成这几个基本类型,塞过去,对面再拼回来。字符串?C 侧得要一块内存,JS 侧把字符串按 UTF-8 编码写进去,传指针过去,调完再释放。结构体?传指针,JS 侧按偏移量一个字段一个字段读写。64 位整数?JS 的普通数字装不下,得用大整数类型,转换本身就有成本。返回值如果是大结构体?编译器会偷偷改成「调用方传一块内存进来,被调方往里写」,你看到的签名和实际跑的不是一回事。
Emscripten 提供了好几种过境方式,选错了就是白白交过路费:
| 方式 | 怎么用 | 开销 | 适合场景 |
|---|---|---|---|
| 直接导出 | C 侧声明成 C linkage,编译时导出,JS 侧直接拿到函数引用 | 最低,几十纳秒级 | 高频、参数简单的调用,比如每帧的输入注入 |
| ccall | 按函数名查找 + 类型签名,自动处理字符串和数组 | 较高,每次要做名字查找和参数打包 | 低频、参数复杂的调用,比如存档、设置 |
| cwrap | 把 ccall 包一层,返回一个可直接调的函数 | 中等,省掉每次的名字查找 | 中频调用,是 ccall 的日常用法 |
| EM_JS | 在 C 代码里内联一小段 JS,编译时抽到胶水层 | 接近直接导出 | 只有几行、但调用极频繁的桥接 |
| embind | C++ 层的绑定,把类、方法、枚举直接暴露给 JS | 中等偏高,每次有装箱和虚调用 | 工具层、编辑器 UI、低频接口 |
用一句话总结这张表:调用越方便,单次越贵。embind 写起来最舒服,能把 C++ 类直接当 JS 对象用,但它会把一堆 C++ 运行时(类型信息、异常、字符串)拖进包里,小游戏用它很爽,大引擎要掂量。ccall 灵活,但每次调用都在做字符串查找,你要是拿它做每帧几百次的渲染数据传递,那帧率就是你亲手掐死的。
设计原则只有一条,但极其重要:把跨界次数压到最低。不要每帧调用几百次小函数,要「打包一次,批量处理」。渲染的顶点数据、输入事件、音频缓冲,都应该走「一次传一大块」的模式。这是所有 wasm 游戏性能优化的第一性原理,没有例外。
案例拿 Unity WebGL 说,因为它太有代表性了。Unity 的路径比一般引擎还绕:C# 先被 IL2CPP 转成 C++,再交给 Emscripten 编成 wasm。也就是说——所有 Unity WebGL 开发者其实一直在用 Emscripten,只是自己不知道。这也顺便解释了一个老现象:Unity WebGL 那些著名的坑(消息传递开销大、内存增长导致崩溃、栈溢出),和原生 Emscripten 的坑一模一样。因为底下就是同一套东西。Unity 官方这些年做的很多优化,本质上就是在减少 C# 和 JS 之间的往返次数,把零散的小调用合并成批量调用。你看,连 Unity 都在交这笔过路费。
第四层:pthreads 与 SharedArrayBuffer——把多核塞进浏览器
2018 年 1 月,Spectre 漏洞公开。那几天里,所有浏览器厂商几乎同时把共享内存关掉了。理由很硬:共享内存能构造出高精度计时器,让侧信道攻击变得可行。整个 Web 上的多线程游戏开发,一夜之间被打回原形。
直到 2020 年,Chrome 才在满足两个 HTTP 响应头的条件下重新开放。这两个头一个叫跨源 opener 策略,一个叫跨源嵌入策略,作用简单说就是「声明这个页面不和别的源乱来往」。浏览器确认了这一点,才敢把共享内存还给你。Firefox 和 Safari 后来也跟进了,但条件依然是那两个头。
这就是 Emscripten 多线程的第一道门槛,也是最现实的一道:你的服务器必须能设置这两个响应头。而 itch.io、GitHub Pages 这类静态托管,很多时候你根本没法自定义响应头。于是你辛辛苦苦编出来的多线程版本,玩家打开就是白屏,控制台里一行红字告诉你不满足跨源隔离条件。
过了这道门,Emscripten 的线程模型是这样的:编译时开线程开关,wasm 里会用原子指令和共享内存;运行时创建一组 Web Worker(数量由线程池大小决定),每个 Worker 加载同一份 wasm,共享同一块内存;线程之间同步靠原子等待和原子唤醒,等价于操作系统里的那套等待唤醒机制。
但这里有个浏览器独有的限制,能把人卡死:主线程不能阻塞。在主线程上执行原子等待,浏览器会直接抛异常——它不允许你卡住 UI 线程。同样,你在主线程上去等一个子线程结束,也是不行的。
Emscripten 给的两条解法:
一是把主函数整个挪到 Worker 里跑。主线程彻底空出来,只负责渲染和事件转发,阻塞发生在 Worker 里,合法。这个方案干净、代价小,是首选。
二是把同步代码改造成可以暂停再恢复的形式。原理是把函数拆成状态机,遇到要等的地方就存个档、退出去,等条件满足再恢复。听起来很美,代价也很实在:体积通常涨 30% 到 100%,性能掉 20% 到 50%,具体看你的代码里有多少函数被波及。它是救命稻草,不是日常方案。我见过有团队把它当默认选项开着,结果包体翻倍、帧率对半砍,最后还得回头一个个拆。
还有一条路是把渲染也搬进 Worker,用离屏画布,主线程彻底解放。这条路技术上成立,工程上麻烦——所有和 DOM 相关的交互都得重新设计。
案例说 Godot 4 的 Web 多线程导出,这是社区里被问烂了的问题。Godot 4 支持多线程渲染和物理,导出 Web 时如果开线程,就必须满足跨源隔离。社区里最常见的求助帖是什么?「我的 Godot 游戏在本地跑得好好的,传到 itch.io 就打不开。」原因就是 itch.io 默认不给那两个头。Godot 后来提供了单线程回退方案,但单线程下物理和渲染挤在一个线程里,帧率肉眼可见地掉。
这是一个特别干净的工程取舍样本:你要么牺牲部署便利性换性能,要么反过来。没有第三条路。你做独立游戏,发行渠道就那么几个,这个决定其实在你选引擎的时候就已经做了一半了。
参数化设计工具:一份可以直接抄的配置表
下面这张表是我这些年攒下来的默认值,不是标准答案,但能帮你少走很多弯路。每个项目的峰值负载不一样,具体数字自己调。
| 参数 | 作用 | 游戏项目建议 | 踩坑提示 |
|---|---|---|---|
| 初始内存 | 启动时向系统要多少线性内存 | 256MB 到 1GB,按关卡峰值估,宁多勿少 | 太小会频繁增长,太大拖慢启动 |
| 允许内存增长 | 堆不够时能不能向系统再要 | 原型期开,正式版关掉 | 增长会让 JS 侧缓存的内存视图全部失效 |
| 内存上限 | 硬顶,超过就中止 | 设成初始值的 1.5 到 2 倍 | 不设的话,出错时表现是浏览器崩,不是你的游戏崩 |
| 栈大小 | 函数调用和局部变量的空间 | 1MB 到 8MB | 默认 64KB 量级,递归或大数组必炸 |
| 线程池大小 | 预先创建多少个 Worker | 等于目标线程数,别贪多 | 每个 Worker 都要加载一遍 wasm,启动时间线性上涨 |
| 优化级别 | 编译器的优化档位 | 发布用 -O3,体积敏感用 -Oz | -Oz 体积小但性能会掉,别在性能敏感的模块上用 |
| 链接时优化 | 跨编译单元优化 | 发布开 | 编译时间翻倍,开发期别开 |
| 同步转异步 | 把阻塞代码改造成可暂停的形式 | 只在确实需要时开 | 体积和性能代价都很大,别当默认选项 |
| 运行时方法导出 | 控制哪些胶水层方法暴露给 JS | 按需,精确到个位数 | 全导出会让死代码消除失效,包体直接膨胀 |
再说三个经验判断,比数字本身更值钱:
- 内存宁可在启动时多要,也别在战斗中增长。启动时多花 200 毫秒,比战斗里掉一帧划算一万倍。
- 栈要按最坏情况配置。你的引擎里一定有一条最深递归路径,你得知道它有多深。别等它炸了再猜。
- 线程池别开大。4 个 Worker 已经能吃掉大部分物理和资源加载的活了,开 16 个只会让启动变慢、调度变乱。浏览器不是服务器。
双路径:你现在该读到哪一层
初级路径(先把东西跑起来)
如果你现在只想把手上的 C/C++ 项目跑进浏览器,先别管管道细节。装官方 SDK,跑通官方示例,然后把你的项目当一个黑盒塞进去。这个阶段你只需要盯三件事:初始内存够不够、栈够不够、服务器有没有跨源隔离头。
说句实话,我见过的「跑不起来」案例里,九成是这三件事之一。剩下的那一成里,有一半是资源文件没打包进去。真正需要你改代码的,很少。
这个阶段的心态应该是:先让它跑,跑得丑没关系,跑起来你才有资格谈优化。别一上来就研究 Binaryen 的优化通道,那是给自己找罪受。
进阶路径(把它当交付通道)
如果你要把 wasm 版本当成正式的发行渠道,那就得往下钻了。四件事:
第一,锁死你的工具链版本。Emscripten 的破坏性变更不算少,升个小版本换个默认值,你的项目就可能跑不起来。在持续集成里固定版本,升级单独开分支验证。
第二,自己设计接口边界。哪些功能走直接导出、哪些走 embind、哪些用内联 JS 写死,这个划分会决定你整个项目的性能天花板。我的建议是:高频走直接导出,低频复杂走 embind,中间地带用包装过的调用方式。
第三,自己做体积和帧率的回归测试。每次合并代码,跑一遍包体大小和关键场景的帧率,画成曲线。Emscripten 的体积膨胀是渐进式的,你今天加一个功能涨 200KB,一个月后你会发现包体翻倍,但不知道是哪天涨的。
第四,规划线程策略。单线程和多线程是不是都出、多线程版怎么部署、跨源隔离头怎么保证。这是个产品问题,不只是技术问题。
常见坑与争议:那些我亲手踩过的
这一节我写得比较狠,因为每一条都是真金白银的时间换来的。
坑一:在 JS 里缓存内存视图。这是新手翻车率最高的一条。内存一增长,视图全部失效,你后续的写入全打在空气上。解法很简单:不要在 JS 侧长期持有视图对象,每次用之前重新拿一次,或者干脆关掉内存增长。
坑二:栈溢出静默踩内存。wasm 里栈溢出不会给你一个漂亮的报错,它会踩坏相邻的数据,然后你的游戏在十分钟后以一个完全无关的症状崩掉。这种 bug 我能查一整天。
坑三:把同步转异步当成免费午餐。这个功能确实能让很多阻塞代码直接跑起来,代价是包体翻倍、性能对半砍。它应该是一个「过渡手段」,不该是架构的一部分。你要是发现项目里到处都是它的痕迹,说明你的异步设计从一开始就没做。
坑四:主线程上阻塞。原子等待在主线程会直接抛异常,这个错在开发机上可能被吞掉,在用户机器上就是白屏。养成习惯:任何可能阻塞的代码,先问一句「这段跑在主线程还是 Worker 上」。
坑五:异常处理的体积代价。C++ 的异常在 wasm 里是个老大难。用 JS 模拟的那套方案体积代价很大,高到能让你的包体翻倍;用原生方案的编译器支持又在演进中。如果你的引擎重度依赖异常,这块要提前规划,别等到发布前才发现包体收不住。
坑六:以为拿到的是完整的 POSIX。Emscripten 用的是 musl,不是 glibc。某些函数的行为有出入,某些函数根本没有。你的引擎里那些「在 Linux 上跑得好好的」系统调用,搬过来之后有一部分会变成空实现或者直接失败。
坑七:静态托管没有跨源隔离头。这条已经说过一次,但值得再说一次,因为它是最容易在发布前夜炸掉的一条。上线前一定要在真实的托管环境里点一遍,别只在本地开发服务器上测。
争议这块,行业里其实有个分歧:一派认为应该尽量少碰 Emscripten 的胶水层,把它当纯编译器用,所有桥接自己写;另一派认为既然用了就用全套,embind、文件系统、异步那套都上。我的态度偏前者——胶水层越薄,你越知道自己踩在哪。但那是我做引擎的习惯,你要是做个工具型应用,全套上反而省事。没有对错,只有代价你愿不愿意付。
FAQ
问:Emscripten 编出来的 wasm,能不能给 Rust 或者别的语言直接用?
能加载,但不好用。wasm 模块本身是标准的,可它依赖一整套 Emscripten 的运行时——内存分配器、系统调用桥接、胶水层的初始化流程,全都绑在一起。你拿一个非 Emscripten 的宿主去加载它,等于接手一个没有地基的房子。跨语言复用要重新设计边界,不是换个加载器就行。
问:为什么我的游戏在 Chrome 上 60 帧,Safari 上只有 30 帧?
原因通常是三样东西叠在一起:一是内存增长策略,Safari 对大内存更保守;二是 wasm 的优化器实现不同,Safari 的编译器在某些模式上更谨慎;三是音频和画布的默认参数差异。排查顺序建议先从内存入手,在 Safari 上把内存增长关掉试一次,很多问题当场就没了。
问:一定要用 embind 吗?
不一定,而且我建议你先别用。embind 舒服,写起来像在 JS 里直接操作 C++ 对象,但它会把一堆运行时拖进包里,每次调用还有额外开销。先用直接导出把核心链路跑通,等确实需要一个好用的接口层了,再局部引入。工具层和编辑器 UI 用它很合适,游戏主循环里慎用。
问:单线程和多线程版本要不要都出?
看你的分发渠道。如果你的主要渠道是能自定义响应头的自建站,只出多线程版就够了;如果你要靠 itch.io、Poki 这类平台,最好两个版本都准备,用同一个判断逻辑在启动时探测环境。代价是构建流水线变复杂,收益是覆盖面变宽。这笔账我算过,独立项目基本都得两个都出。
问:Emscripten 的版本要不要锁死?
锁死。这条没得商量。它的破坏性变更不算少,默认值会变、行为会调、某些开关会废弃。在持续集成里固定一个版本,升级单独开分支跑完整的回归,包括包体大小、启动时间、关键场景帧率。升级这件事,最好永远不要和别的事情混在一起做。
最后抛个问题给你:如果 Emscripten 的这四层(管道、内存、边界、线程)你只能深入一层,你会选哪一层?我的答案是内存。因为其他三层的坑,报错都会告诉你;只有内存的坑,是笑着把你送走的。