Flecs ECS 游戏开发技术专题全层级22 / 31 已发布

Flecs 在 WebAssembly 环境下的部署:浏览器游戏实战

· 0 分钟阅读
Flecs 在 WebAssembly 环境下的部署:浏览器游戏实战 Flecs on WebAssembly: Shipping a Browser Game — Flecs ECS 技术专题

Flecs 在 WebAssembly 环境下的部署:浏览器游戏实战 Flecs on WebAssembly: Shipping a Browser Game

为什么浏览器是 Flecs 最狠的一次体检

先把话说在前面:这篇不教你怎么装工具链。那种事文档里有,照着敲就行,敲完你会得到一个能跑的构建,然后被现实按在地上摩擦。

我第一次把一套 Flecs 原生原型搬到浏览器上,构建通过那一刻还挺得意。打开标签页:白屏九秒,控制台孤零零一行字说跨线程共享内存不可用,World 起来之后帧率看着还行,跑到四百来帧开始抖,手机上一分半钟之后整页被系统回收,什么都没留下。那次我盯着屏幕想了很久,最后承认一件事——我写的不是移植,是把原生时代所有偷懒的假设重新验算了一遍。

浏览器是个特别诚实的甲方,它不听你解释。它只跟你算四笔账:

第一笔,包里装了什么。原生里你多链一个库,无非是安装包大几兆,玩家骂两句也就装了。浏览器里每多一百 KB,都是首屏时间里明码标价的一截。

第二笔,一帧里干了什么。原生里你有八个核,实在不行还能甩一句「多线程优化一下」。浏览器里主线程就一条,十六点六毫秒要交作业,交不出来玩家立刻感觉得到——不是掉帧,是手指按下去没反应。

第三笔,内存涨到哪去了。原生里内存吃紧最多是卡;浏览器里内存吃紧是标签页消失,玩家连报错都没看见,只看见一个空白的「页面已崩溃」。

第四笔,第一次打开要等多久。社群里有个说法我特别认同:网页游戏的前十秒不是加载,是第一场战斗——玩家在这十秒里决定要不要留下来,而你的转圈图标就是对手。

这四笔账,恰好就是 ECS 架构最擅长的四个地方,也是最容易翻车的四个地方。上一篇我们聊模块系统,说模块的价值是复用和可维护。这一篇要补一句:在主机构建里,模块划分的收益是工程性;在浏览器里,模块划分直接就是下载体积和首屏时间——同一件事,换了个计价方式,账目全变了。

所以这篇聊的不是「Flecs 能不能跑在浏览器里」。能跑,这件事三年前就有答案了。这篇聊的是:当你的 World 被塞进一个标签页,哪些设计决定会当场变成玩家的手感,哪些「本来挺好」的习惯在浏览器里会变成账单。

第 1 层:编译期瘦身——先分清哪些模块在浏览器里注定是死代码

WebAssembly 这东西经常被讲成「把 C 的性能搬进浏览器」。这话没错,但只说了半句。它是按揭:首付是你的包体、编译时间和内存水位,月供是你每次改完代码要重新等一遍冷启动。

先想清楚浏览器到底缺什么。缺真线程(主线程之外只有 Worker,而且 Worker 之间不共享 World 的内存);缺端口监听(页面不能开服务器,这是安全模型,不是框架的锅);缺文件系统(除非你愿意用内存虚拟盘或者浏览器自家的源私有存储);缺动态链接(一个包就是一整块);缺高精度时钟(为了防止指纹追踪,计时器精度会被打折)。

这六条不是「性能问题」,是「存在性问题」。你的模块只要碰了其中一条,在浏览器里就是一段永远不会执行的死代码——而它照样占着你的下载体积、编译时间和初始化开销。

这里有个判断标准,我建议你贴在墙上:「能不能跑」和「该不该进包」是两个完全独立的问题。大部分团队的翻车都发生在把第一个问题的答案当成了第二个。三条判据,从上往下筛:

① 这个模块需不需要操作系统能力(线程、网络监听、文件、进程)?需要就得拆或砍。
② 这个模块是不是只在开发期用(调试面板、性能剖析、远程检查)?是就单独打一个包,别给玩家。
③ 这个模块的体积,配得上它给玩家带来的实际收益吗?说不上来就砍。

模块(按职能说人话)浏览器里能不能用建议为什么
多线程调度 / 线程池条件可用默认关要跨源隔离响应头才能拿到共享内存,很多内嵌环境(App 内 WebView、带广告的落地页)根本给不了
网络监听、远程调试端点不能砍页面开不了端口;把协议留在原生侧,浏览器只做客户端
文件读写相关不能换实现存档走浏览器存储,配置走内存虚拟盘
高精度计时降级可用保留,但别拿它做微基准精度被打折到毫秒级很常见,测出来的数字会骗你
脚本 DSL / 表达式求值能跑看你用不用它写内容用它写关卡就留,只当调试玩具就砍,体积不小
元数据 / 反射能跑强烈建议保留浏览器里你唯一的调试窗口就是它撑起来的
序列化(快照 / JSON)能跑保留存档和远程看状态的唯一通道,删了你会后悔
告警 / 性能统计 / 单位换算能跑按需告警本质就是一堆条件判断,便宜且意外地好用,单位换算基本是给编辑器用的
系统调度 / 管线 / 计时器能跑必留这是游戏的心跳,删了就没游戏了

再说体积。我这边一套典型配置的量级是这样(brotli 压缩后的传输体积):只留核心,三百到四百多 KB;加上序列化和元数据,五百到七百 KB;全开(脚本 DSL、单位、告警、调试全套)要奔着一兆二以上去。别把这些数字当合同,它会随编译器版本、优化等级、异常处理模型浮动一大截——你测你自己的。但有一件事是确定的:你游戏自己的代码,大概率比 Flecs 本体还大。所以别光盯着框架抠体积,那是捡芝麻。

举个真实的反面教材,我自己干的:第一版我把调试面板一起打进了发布包,理由是「反正就几十 KB」。结果那玩意儿初始化时要把所有组件的元数据注册一遍,首屏硬生生多了一秒二。更讽刺的是,玩家永远打不开它——它没有 UI 入口。等于我给每个玩家下发了一个只有开发者能用、但所有人都要等它加载的功能。

说到这儿必须请出官方那个浏览器端的 World 可视化调试器,它的产品形态本身就是一份架构教案。它是这么切分的:浏览器这一侧跑一个由 C 编译成 WASM 的 Flecs World 前端,负责解析数据、跑查询、画图;真正需要「开端口」的那段代码,留在原生进程里当服务端。你注意这个边界——协议在原生侧,界面在浏览器侧。所以它的浏览器包里压根不需要网络监听模块,也不需要一整套服务端。

这个切分看起来是工具设计的小聪明,实际上是一条通用原则。很多团队把模块塞进 WASM,并不是因为「浏览器里跑得动」,而是因为「懒得拆协议」。拆协议是体力活,砍模块是判断活。而「能用」和「必须有」之间隔着的,就是这一层判断——那层判断的名字,叫包体。

顺手给个操作顺序,别再走我走过的弯路:先用最小配置把 World 跑起来,确认渲染和输入通了;然后按上面那张表逐个往回加模块,每加一个记一次体积和首屏时间;最后你会发现有两三个模块是你以为需要、其实早就用别的方式解决了。这个过程花半天,能省下后面三个月的首屏优化。

第 2 层:单线程生存指南——把并行红利换成时间片红利

单线程不是「少了一半性能」,是「少了一个偷懒的选项」。这句话我想强调一下,因为它决定了你后面所有优化的方向。

原生开发里遇到卡顿,第一反应往往是「这块挪到后台线程」。浏览器里这个反应要改掉,因为主线程跑的不只是你的逻辑,还有页面合成、垃圾回收、第三方脚本(统计、广告、支付 SDK)。真实可用的逻辑预算,通常只有名义预算的六到七成。桌面浏览器一帧十六点六毫秒,你能拿来算游戏逻辑的两三毫秒就得偷着乐;手机上如果你锁三十帧,预算看着翻倍到三十三毫秒,但低端机的 CPU 也让每一步都更贵,等于预算翻倍、单价也翻倍。

好消息是,Flecs 那套调度体系在单线程下并没有变成废物。这可能是本文最反直觉的一段:并行度没了,但执行顺序的约定还在。系统之间的依赖声明,在原生里主要是给并行调度器用的;在单线程里它退化成一个严格的串行顺序表,而这个顺序表在浏览器里价值反而更高——因为浏览器最怕的不是慢,是「这一帧做了什么」不可预测。你有一个明确的阶段划分(输入、逻辑、物理、渲染提交、清理),就有一个可以被分帧切割的骨架。

还有一个红利很多人没注意到:延迟写入的合并点变成了唯一的安全写入口。并行的时候,延迟命令是为了避坑;单线程的时候,它反而把「谁在什么时候改世界」这件事天然串行化了。我自己的体会是,移植到浏览器之后,那类「改了实体结构导致迭代器失效」的诡异 bug 反而少了——不是因为框架变聪明了,是因为没有并发了,没有并发的写入顺序问题也就没有了。所以别急着骂单线程,它让你少写不少防御代码。

具体怎么把并行红利换成时间片红利?核心就一句话:把「一帧干完」改成「几帧干完」,并且明着记账。做法是给每类工作发预算令牌:每帧开头给 AI 三毫秒、给寻路两毫秒、给生成器一毫秒,谁用完谁这一帧就歇着,剩下的活儿排队到下一帧。玩家完全看不出区别,因为人的眼睛分辨不出「这个 NPC 的决策晚了两帧」,但分辨得出「整体掉帧」。

降频是最便宜的一招。下面这张表是我在某次实际项目里用的默认配置,你可以当起点:

工作类型执行频率理由
输入采样、相机、玩家逻辑每帧手感的地基,绝不能省
常规 AI 决策每 2–3 帧少两三帧的思考延迟,人眼几乎无感
群体感知 / 目标筛选每 4–6 帧 + 分摊到不同帧这类计算最贵,且天然可以慢
寻路请求队列化,每帧限量处理不做完不阻塞,要多少帧都行
非关键视觉特效的状态同步每 2 帧视觉容错度高
存档快照、数据统计按秒级触发,绝不放帧内它会是帧时间的尖刺

Worker 呢?Worker 能救你,但只在你用对了边界的时候。正确的用法是「纯数据进、纯数据出」:把一片地形的高度图丢过去算噪声、把一份快照丢过去做大块序列化编解码、把一堆点丢过去算可见性。错误的用法是「想让 Worker 帮着推进世界」——那需要共享内存,而共享内存需要跨源隔离响应头,而那个响应头在很多发行环境里给不了。我见过有人把整个项目架在这个前提上,最后发现微信内置浏览器、某些 Android 应用内嵌页面、带广告的合作方页面全都开不起来,整个演示版本当场作废。这个成本太高了,高到不值得赌。

提到这个坑,就得说《Godot 4》Web 导出的那件事:同一个工程,开不开线程几乎是两个产品。开了线程,页面必须被跨源隔离,于是它就不能被随意嵌进第三方页面、不能和别的域共享顶层窗口,很多发行渠道直接判死。这是引擎层面替所有人踩过的坑,而给 Flecs 的教训是一样的:别把并行写进「必须有」的清单里。凡是把并行当必需的设计,最后都会变成「运维条件决定你能不能上线」。

最后补一个容易被忽略的点:单线程下,渲染提交时间会变贵。原生里你可能习惯了「逻辑写循环、渲染自己扛」,浏览器里主线程要把绘制调用一条条递交给图形层,命令越多,这段开销越实。这和系列里讲渲染那篇是同一个话题的另一面:在浏览器里,你的实体预算实际上是被渲染提交次数而不是逻辑运算量卡住的。这也是为什么批量渲染和实例化在这里不是优化项,是及格线。

第 3 层:内存天花板——浏览器里没有「还回去」这个动作

这一层是最容易被低估的,因为它平时不响,一响就是玩家流失。

WebAssembly 目前主流是 32 位内存模型,地址空间上限四 GB。这个数字听起来很宽裕,但浏览器自己也要内存、页面里还有别的东西、系统还要留余量。社区里流传的安全区是 1 到 1.5 GB,而且这还得看成色——移动端浏览器(尤其是 iOS 上的那些)什么时候把你的页面回收,基本是玄学。它不给你异常、不给你回调,玩家看到的就是一个白屏加一句「页面已崩溃」。

更要命的是内存增长的形态。线性内存是按页向上申请的,且只增不减。也就是说,一次峰值会定义你整局游戏的水位:加载关卡时你为了快,把一整张图集解成位图堆在内存里,解完释放了,看起来没事——但你脚下的水位已经被抬上去了,之后再也降不下来。玩家玩十分钟没事,玩到二十分钟,同样的操作突然崩了。这种 bug 极难复现,因为它不取决于你做了什么,只取决于你已经做了多久。

所以我给一个可能有点反直觉的结论:在浏览器里,ECS 的核心美德是「分配次数少」,而不是「分配得快」。而这一点,恰好是 Archetype 式连续内存的强项——同类实体的组件数据放在一起,一次大块分配顶很多次小块分配,遍历时缓存命中率高,中间不产生临时对象。你在原生里可能只是觉得它「快」,在浏览器里它是「稳」。稳定比快值钱,因为快可以慢慢优化,崩了直接没得谈。

反过来,碎片化在浏览器里的代价被放大了。原生里你把内存搞碎了,最坏结果是性能下降,程序还在;浏览器里你把水位抬上去了,那是一张账单,而且不能分期。所以系列里讲碎片化诊断的那套方法(看表的数量、看实体在表之间搬家的频率),在浏览器场景下要从「性能问题」升级成「稳定性问题」来处理。

这里必须请出《shapez 2》当锚点,因为它把「ECS 性能故事」讲得最响:整条传送带、机器、物品的模拟压在 Unity 的 DOTS 上(实体组件系统加编译期优化加作业系统),目标就是让几十万级的物件在桌面上同时动起来还不掉帧。这个案例的价值不在数字,在于它暴露了前提——ECS 的性能红利是三样东西一起给的:多核并行、连续内存、编译期优化。

而浏览器把第一样抽走了。所以你在桌面上量出来的「几十万实体六十帧」,搬到标签页里要按数量级往下砍。不是你换了个慢框架,是你少了一整块预算。这就是为什么我一直劝人:浏览器版别拿桌面基准测试做预算,要拿你自己的真机做预算,而且要拿三年前的安卓中端机做预算。桌面上的数字负责让你开心,中端机的数字负责让你活下来。

给个起步用的档次表,请把它当「建议起点」而不是「物理定律」:

设备档位目标实体数警戒线目标帧率备注
桌面浏览器(近三年)2 万–5 万(逻辑简单)8 万60逻辑一复杂,这个数要砍半
移动中端(三年前安卓)3 千–8 千1.5 万30–60优先锁 30 换稳定
低端机 / App 内嵌 WebView1 千–3 千5 千30内存比算力更先见底

另外提醒一句:实体数不是唯一维度。真正卡你的往往是「每帧遍历的次数乘以每实体要做的事」,十个系统各扫一遍全表,和两个系统各扫一遍,手感完全是两种游戏。所以比起数实体,我更建议你数「每帧的组件访问总量」——这个数字对优化方向的指示性,比实体计数强十倍。

第 4 层:首屏与包体——玩家的耐心只有一次

我们先说一个容易被开发者忽略的事实:网页游戏的加载界面不是等待,是关卡。玩家在这几秒里做决定——而这个决定的依据,是他看到的进度条、画面和有没有东西可点。

首屏总耗时由三块拼起来,很多团队只盯第一块:下载时间、编译与实例化时间、World 初始化与首关加载时间。麻烦在于中间那块是非线性的:桌面浏览器上编译一兆多的 WASM 可能半秒就完事,三年前的安卓机上同样的包能干到三秒以上。你测桌面觉得「还行」,玩家在手机上是「这游戏没做好」。

怎么规划,我给四档,从「Demo 档」到「编辑器档」,用不同配置编译同一个工程:

配置档位包含内容传输体积目标适用场景
Demo / 试玩档核心 World + 系统调度 + 序列化≤ 800 KB落地页、投放素材、Itch 试玩
完整游戏档上面 + 元数据 + 告警 + 必要扩展≤ 1.5 MB正式发行版本
调试档上面 + 性能统计 + 可视化面板不限内部测试,绝不下发给玩家
编辑器档上面 + 脚本 DSL + 单位 + 完整反射2–4 MB单独的页面,只给内容作者

分档的意义不只是「玩家少下点东西」,更是给你一个纪律:凡是只在编辑期需要的能力,一行都不许进玩家包。我见过最惨的一种情况是:项目里所有内容都编译进 WASM,改一个关卡数值要重新编译、重新下发、玩家重新下载整包。上线第一个月就废了,因为一波数值调整要等到下个版本。

所以浏览器场景下最值钱的架构选择,不是哪个模块留哪个模块砍,而是内容放在数据里,不要放在编译包里。关卡、单位配置、技能表,全都做成运行时加载的数据;WASM 里只留逻辑。这一条能带来两个立刻见效的红利:改内容不用重新编译(迭代速度),玩家不用重新下载(留存)。这也是为什么我一直觉得声明式的场景描述方式在 Web 场景下比在原生场景下更有生命力——它把「改一个数」的成本从分钟级降到秒级。当然它也有争议,那是另一个话题了。

其他几招都是常规操作,但别省:编译结果会被浏览器缓存,所以第二三次访问会快很多,可以在首次加载时给个「已缓存,下次秒开」的提示;能流式编译就流式编译,别等整块下载完;首屏先给设置页或者静态标题画面,让玩家有事可干,顺便把后面的加载塞进这段时间。

存档这块单独说两句。浏览器里没有文件系统,你的快照要么进浏览器存储(IndexedDB 这类),要么进源私有文件系统。模式是现成的:定期打整份快照,中间夹增量。但有一条铁律——序列化绝不要在帧内做,哪怕它看起来只有几毫秒。它会变成周期性尖刺,而周期性尖刺在浏览器里特别显眼,因为它和渲染合成抢同一条主线程。放到 Worker 里做,或者至少分帧做。

最后讲一个架构层面的对照案例,我觉得它对独立开发者特别有启发:《Dungeon Crawl Stone Soup》的网页版。这是一个相当复杂的传统 Roguelike,它的网页版架构极其朴素——游戏世界、规则、AI 全跑在服务器上,浏览器只当一个显示终端和输入终端,两边靠一条长连接传消息。结果是什么?它在浏览器里跑得比很多「什么都往客户端塞」的游戏还稳,因为客户端根本没有 World,也就没有内存天花板和算力预算的问题。

我不是建议所有人这么干——它有它自己的代价(服务器成本、延迟、离线不可玩、断线重连)。但它是选项清单上必须存在的一项,而且很多人做技术选型时根本没把它列上去。在你花两周优化浏览器端的内存水位之前,先花半小时想一想:这个 World 是不是非得跑在玩家机器上?单机玩法当然得在本地;但如果你本来就是联机、本来就是权威服务器,那把 World 留在服务端、浏览器只做表现层,可能是成本最低的一条路。

参数化设计工具:四张能直接用的预算表

下面这些数字都是我踩过坑之后往回推的起点值,不是标准答案。你要做的是拿它们当靶子,然后用自己的真机去校准。

表一:模块保留决策表。用法是逐行打勾,凡是「需要操作系统能力」且「只在开发期用」的,一律不进玩家包。

判据命中就砍命中就拆包命中就保留
需要线程 / 端口 / 文件且不是核心玩法且是核心玩法—
只在开发期使用—单独打调试档—
体积占比超过总量 5%且玩家感知不到收益—且直接决定手感

表二:帧预算分配表。桌面按六十帧算,移动端建议直接锁三十帧换稳定,用稳定换口碑,这笔买卖几乎永远划算。

项目桌面浏览器 60fps(16.6ms)移动浏览器 30fps(33ms)
输入 + 玩家逻辑 + 相机3–4 ms5–7 ms
AI / 寻路(已降频、已分摊)2 ms4–5 ms
物理(固定步长)2 ms4 ms
渲染提交(不是 GPU 时间)2–3 ms5–6 ms
预留(GC、页面合成、第三方脚本)4–5 ms8–10 ms

表三:首屏耗时预算表。总时长超过八秒,你就已经在跟玩家的耐心赌博了。

阶段目标警戒线急救手段
WASM 传输体积≤ 1.5 MB> 4 MB砍模块、分档编译
下载(4G 网络)≤ 1.5 s> 4 s分卷、先给可交互的假首屏
编译 + 实例化桌面 ≤ 0.8 s / 移动 ≤ 2 s移动 > 4 s流式编译、缩小包体
World 初始化 + 首关≤ 0.7 s> 2 s延迟注册元数据、内容外置成数据
合计可交互≤ 3 s> 8 s上面全上

表四:内存水位表。不要等崩了才看,把这个当成每次构建都要过一遍的体检项。

指标健康区危险区说明
峰值线性内存< 400 MB(移动)> 800 MB移动端超过 1 GB 就是在赌运气
加载期峰值与稳态比值< 1.5 倍> 2.5 倍比值超标说明你的水位被一次性峰值绑架了
表(Archetype)数量< 200> 800数量暴涨通常是碎片化前兆
实体搬家次数(每帧)< 总数的 2%> 10%频繁增删组件的信号

双路径:你现在的处境决定你从哪条路进

初级路径:三步把 World 搬进标签页,别再踩我踩过的坑。

第一步,先做「最小可跑」。用最精简的配置编出一个能跑的包,只留 World、系统调度、渲染胶水,别的全关。目标不是好玩,是能在手机上打开且不崩。这一步九成的人会失败在渲染和输入的胶水层上,跟 Flecs 一点关系都没有,别在那儿怀疑架构。

第二步,做「一次诚实测量」。拿三台设备:你现在用的电脑、一台三年前的安卓中端机、一台 iPhone(越老越好)。量四个数:首屏总时长、稳态帧时间、峰值内存、崩溃前能跑多久。这四个数写下来贴墙上,之后每次改动都对比。相信我,你会被第一次的测量结果吓到,那个惊吓值就是这篇文章的全部价值。

第三步,做「内容外置」。把关卡、单位数值、技能表从代码里搬出去变成运行时加载的数据。这一步看起来跟性能无关,实际是所有后续优化的前提——因为它让你后面每次调参都不需要重新编译、重新下发。做完这一步,你的迭代速度会有一个数量级的变化,那种感觉像是从绿皮火车换成了高铁。

进阶路径:把预算变成纪律。

第一件事,把帧预算做成可观测的东西。不是「感觉有点卡」,而是每帧结束时记下各类工作的实际耗时,超出预算就记一次警告。有了这个,优化就从「猜」变成了「查」。

第二件事,把 Worker 用在对的边界上。列一张清单,只写「纯数据进、纯数据出」的活:地形噪声、可见性计算、大块序列化、解压。清单之外的一律拒绝,尤其是「把逻辑搬过去」这种提案,一律打回。

第三件事,做配置分档构建。Demo 档、完整档、调试档、编辑器档,同一份代码四套开关,出两个产物。这套纪律一旦立起来,包体就不会再失控,因为任何人想加一个模块,都得先回答「它进哪一档」。

第四件事,把内存水位纳入每次构建的体检。峰值、加载期与稳态的比值、表数量、搬家次数,四个数字过一遍。二十分钟的事,能省下你上线第一周的通宵。

争议与四个常见坑

坑一:相信「WASM 只比原生慢一点点」。那些数字是真的,但它们来自微基准——紧密循环、无分配、无跨语言调用。你的游戏不是微基准,你的损耗集中在内核边界调用、内存分配、缺失的并行、以及单线程下的渲染提交。真实体感差距往往在一个数量级上下,而且全在那些「不可见的地方」。

坑二:把调试模块打进生产包。我干过,代价是一秒二的首屏。这里最容易自欺欺人的理由是「留着万一线上出问题呢」。正确做法是:调试能力做成单独的档位、独立的页面,线上出问题时用同一个引擎版本构建一个调试档去复现。想在生产包里留诊断能力,是可以的,但要留的是「数据」不是「界面」——打点计数器、告警条件、轻量统计,这些便宜;可视化面板不便宜。

坑三:把浏览器的每帧回调当固定步长用。它的节奏受刷新率、页面可见性、后台标签节流、系统省电策略影响,随时会变。物理和需要确定性的逻辑必须跑在自己的固定步长上,帧回调只负责「推进多少步」。这件事在原生里是常识,在浏览器里因为高刷屏和后台节流的存在,翻车概率高得多。

坑四:把跨线程共享内存当成默认可用的能力。它需要响应头配合,而响应头这件事经常不在你手里——渠道、投放页、App 内嵌环境,任何一个环节不给,你的方案就废了。设计上要保证:没有并行也能完整玩,有并行只是更好。

再说一个真正的争议:浏览器游戏到底该不该上 ECS?

我的立场比较明确,但分情况。如果你做的是一个玩法固定、内容量小、实体规模在几百以内的网页小游戏,ECS 是过度设计,你用一套简单的对象池加数组就够了,别为了架构的优雅给自己加学习成本。

但如果你的游戏满足两条中的任意一条——实体数量会持续增长,或者内容要靠组合出新玩法——那 ECS 在浏览器里的价值反而比在原生里更高。原因很朴素:浏览器游戏最稀缺的资源不是算力,是迭代速度。你的分发成本几乎是零,你可以一天发十个版本,那你就应该把架构设计成「改内容不用碰代码、改数值不用重新编译」。组合式的数据结构天然适合这件事,因为它让「加一个新单位」从写一个子类变成填一行配置。

反过来说,我特别理解另一种声音:ECS 的代码读起来确实不如面向对象直觉,新人上手会懵。这在浏览器场景下更明显,因为你的团队往往更小、更没有时间做培训。这个批评是成立的,缓解办法不是嘴上说「习惯就好」,而是把系统的命名、分组、依赖关系做成一份谁都能看懂的表——这件事跟包体优化一样,是纪律问题,不是天赋问题。

FAQ

问:Flecs 在浏览器里到底能不能开多线程?

能,但条件苛刻:需要页面处于跨源隔离状态,而这要求服务端返回特定的响应头,并且页面不能被别的域随便嵌入。所以我的建议是把多线程当「增强」而不是「功能」:默认单线程跑通、跑稳,检测到环境支持再开。判断标准很简单——如果你的游戏在单线程下不好玩,那它在多线程下也不会好玩,只是卡得没那么明显。

问:包体砍到多少算合格?

别看绝对值,看首屏总时长。我的及格线是:桌面三秒内可交互、移动五秒内可交互。在这个约束下反推体积,通常落在 WASM 本体一到一点五兆之间(压缩后)。如果你的包超过四兆,先别优化代码,先去把不该进包的模块砍掉——那才是大头。顺便,你游戏自己的资源(贴图、音频)几乎一定比 WASM 大,别只盯着框架。

问:浏览器里最多能扛多少实体?

这问题跟「一辆车最多能开多快」一样,得看路况。同样是两万实体,全是静态装饰和全都在做寻路加感知,是两个世界。所以我建议你换一个问法:每帧的组件访问总量是多少?每帧的绘制提交次数是多少?这两个数字才是真正卡你的东西。上文的实体预算表只是给你一个起步的坐标系。

问:工具链怎么选,走浏览器胶水那套还是走 WASI 那套?

这是两条不同的路。浏览器游戏需要 DOM、图形上下文、音频、输入事件、浏览器存储,这些都得靠浏览器胶水那套工具链去接;WASI 的思路是「把 WASM 当可移植容器」,适合服务端和命令行工具,跟浏览器游戏不是一个目标。你要做网页游戏,就走胶水那条路,别被「WASI 更现代」这种说法带偏。

问:存档和热更怎么办?

整份快照加增量,快照放浏览器存储,序列化过程丢到后台做、绝不放帧内。热更的核心是内容外置:关卡和数值走数据文件,不进 WASM。至于版本升级后组件字段增删的兼容,这是序列化设计的基本功——给每个字段留默认值,读旧档时缺什么补什么,别做「版本不匹配就拒绝加载」这种硬判定,那是把玩家往外推。

最后一句话留给你自己判断:浏览器从来不是 Flecs 的降级平台,它是一面镜子。你架构里所有含糊的地方——模块边界不清、内容耦合在代码里、预算没记过账——在十六点六毫秒里都藏不住。原生的宽容让你可以把这些问题拖到明年,浏览器不会。

所以真正的问题不是「我的游戏能不能跑在浏览器里」,而是:你愿不愿意让玩家用一次白屏,替你做这次代码审查?

文章标签
Flecs ECSEntity Component SystemC++ ECSArchetype ECSSparse Set ECSEnTTBevy ECSUnity DOTSECS 框架对比Flecs RelationshipsFlecs QueryFlecs Pipeline
更多专题全部专题
觉得有价值?点赞或收藏支持内容持续产出。
← 返回专题:Flecs ECS 游戏开发技术专题