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

物理系统集成:Flecs 与 Box2D/Jolt Physics 的协同架构

· 0 分钟阅读
物理系统集成:Flecs 与 Box2D/Jolt Physics 的协同架构 Physics Integration — Synchronizing Two Worlds Without Losing Your Mind — Flecs ECS 技术专题

物理系统集成:Flecs 与 Box2D/Jolt Physics 的协同架构 Physics Integration — Synchronizing Two Worlds Without Losing Your Mind

为什么物理集成是 ECS 游戏的第一块试金石

你做一款 2D 平台跳跃:主角跳跃、推箱子、被陷阱顶飞、踩裂木板、拉绳子、被龙卷风卷起来。玩家玩五分钟会给你两种评价——「这游戏手感真不错」,或者「这也太飘了」。这两句话,直接决定你商店页前 30 秒的转化。

但你知道这句话背后到底发生了什么吗?是两套世界观的和平协议。

一套世界叫 Flecs:它管谁是谁、谁有什么、谁跟谁什么关系。另一套世界叫 Box2D 或者 Jolt:它管谁该被重力拉下去、谁该撞到谁、谁该被弹开。这两套世界各有一套时间、各有一份位置数据、各有一根心跳。它们不签协议,就会在你的手机里互相打架:箱子瞬移、角色卡墙、伤害事件延迟一帧、连锁反应不触发、销毁了 body 还在内存里鬼混。

我做过一个 2D 沙盒原型,第一版就是把物理 body 的位置当成玩家位置直接用。听起来很合理对吧?物理引擎不就是负责位置的吗?结果做到「玩家被弹簧床顶飞」那一刻,整个游戏全乱套了——弹簧床是脚本控制的运动学物体,它的位置有游戏逻辑一份、物理引擎一份、渲染插值一份,谁写谁读没写清楚。那一周我天天在三种坐标系里游泳,晚上闭眼全是箱子的影子。后来老老实实重构成「意图态 / 物理态 / 渲染态」三份,才把它救回来。

本文要讲的不是「怎么调 Box2D 的参数」——那本手册 Rovio 的人已经翻烂了。我要讲的是两个世界之间的外交礼仪:谁先说话、谁后说话、什么话能信、什么时候必须驳回。这套东西如果不在第一个可玩版本之前定下来,后面每一个新玩法都会踩你一脚。

第一层:物理体在 ECS 里怎么安家——代理外交的握手协议

先看玩家能感知到什么。你打开一款 2D 沙盒,把一个木箱往悬崖边推。玩家脑子里只有四件事:箱子有惯性吗?被撞会晃吗?卡在斜坡上会不会慢慢滑下去?掉下悬崖那一刻有没有加速度? 这四条全在物理引擎里,跟 ECS 半毛钱关系都没有。但到底是哪个木箱在动、动的是哪个对象、碎掉之后生成什么、能不能存档恢复——这些全是 ECS 的活儿。

所以最核心的问题是:ECS 实体和物理 body 这两个存在,到底是什么关系?现实中会出现三种做法,我逐个给你判个死刑或者缓刑。

方案做法优点代价我的判罚
A:实体持有 body 句柄组件里存 id 或指针,物理世界是外部库双方各自干净,ECS 不需要懂物理句柄失效要自己管,销毁要写清楚推荐,工程上最稳
B:物理 body 里塞 user data 反查实体物理侧持有 ECS 实体 id碰撞时能立刻知道是谁方向反了,本地物理还行,跨线程/网络会炸短期可用,长期不建议
C:两边各存一份位置ECS 一份、物理一份,谁读谁的没规定写起来爽永远会出现「瞬移」「拔河」「漂移」直接死刑

看表格别急着点头。C 方案为什么是死刑?因为两边各存位置之后,你迟早要面对这个问题:玩家站在一个移动平台上,平台往左走,玩家自己往右走,最后玩家的位置是谁算的?物理引擎说「我算的」,游戏脚本说「我写的」,渲染又读了一份,结果就是每帧位置来回打架——这是所有 2D 平台跳跃原型里最经典的「我明明站在平台上为什么一直往左飘」。

真正的做法是把「实体关心什么」说清楚。一个动态木箱,实体身上需要的组件大概只有三类:

  • 身份与状态组件:BodyType(Static / Kinematic / Dynamic)、碰撞层掩码、材质(摩擦 / 弹性 / 密度)。
  • 句柄组件:BodyHandle,指向 Box2D 或 Jolt 里那个真实 body。它不参与逻辑,只作为「联系方式的电话号码」。
  • 标记组件:NeedsSync(脏标记)、SleepingTag、AwakeOnHitTag、InterpolateTag。

这里有个非常反直觉但极其重要的点:ECS 里不需要存位置本身。位置只在物理世界有一份权威,ECS 里只存「意图」——你想让这个物体去哪、施加多大速度、受多大力。物理模拟完成后,回写一份「渲染位置」给渲染系统读。否则你就回到了 C 方案的泥潭。

再聊聊销毁。销毁是这里最容易翻车的地方。实体被摧毁了,物理 body 还在 Box2D 的 island 里推箱子;或者反过来,物理引擎回调里通知你一个 body 失效了,你去摧毁实体,然后别名系统又访问到它。我的做法是单向责任链:Flecs 里谁创建实体,谁负责注册一个移除钩子,在移除时延迟到帧末销毁 body。不要在物理回调里直接删东西,一步都别。

说到这必须请出一位同行的实战样板:《愤怒的小鸟》。它用 Box2D 做到了一件事——把整个游戏压缩成「鸟撞木板,木板撞猪,猪碎,木头碎,连锁反应」这一条物理链。听起来简单,但每一环都是精心调过的:小鸟的弹性系数、木板的摩擦、猪的受击阈值、木头的断裂阈值。Rovio 当年最大的工程成就不是玩法,而是把所有物理参数做成了数据表,让策划一遍遍在手机上调。这恰恰就是 ECS 能开出的药方:参数是组件字段,调参数是改数据,不需要重编译,不需要重新挂 body。

你看,如果当年那款游戏重构成 Flecs,它会是这样的结构:鸟是动态刚体 + PlayerControlled 标签 + 拖尾渲染组件;木板是动态刚体 + Destructible 组件(被撞超过阈值就 spawn 一堆碎片);猪是动态刚体 + Enemy 标签 + 计分组件。碰撞是链接点,破碎是组件状态切换,粒子是派生实体。每一个虚拟体都有一份唯一的权威物理状态,没有任何一方偷偷存第二份位置。这就是第一层握手协议的全部内容。

当然,也得承认一个反面。这个设计对小型原型来说确实有点啰嗦——你只是想 20 行代码做个箱子和地面玩一玩,结果要先想清楚五种组件的边界。我的建议是原型阶段可以先允许方案 B(物理里塞实体 id),一个人做 3 周内完全够用;但只要你打算迭代 3 个月以上,就得回到 A。

第二层:固定步长与 Pipeline Phase——你的帧和物理的帧不是一回事

这一层,是手感生死线。

你想象一个画面:玩家按跳跃键。如果游戏是 144 帧的显示器、物理步是 60 Hz,按道理「按下按键」到「角色起跳」之间最多差一个物理步,也就是大约 16.7 毫秒。听起来还好对吧?但如果 Phase 顺序错了,物理步在输入处理之前跑完,那这 16.7 毫秒会变成 33.4 毫秒,甚至更长。格斗游戏社区有一句老话:「玩家能感觉到 2 帧的差别,虽然他未必说得出来。」这句话真的不是玄学,它就是 Phase 顺序。

我在一个格斗原型里就翻过这个车。玩家按攻击键,角色要隔一帧才反应。我一开始以为是输入系统不够快,改了半天输入;后来用打点日志一看——物理步明明跑完了,伤害判定却在上一个 Phase 结算,中间插了一整个渲染帧。把物理后置结算挪到物理步后立刻执行,手感立刻变了,攻击开始有「黏性」。那一瞬间我明白了一件事:手感差不是数值问题,是时间问题。

所以物理在 Flecs 的 Pipeline 里必须有一套明确的 Phase 顺序。我提议一个最小可用的七段式:

顺序Phase 名称做什么谁写数据谁读数据
1Input采集输入,写入「意图组件」输入系统物理前同步
2PhysicsPreSync把意图写进物理 body(速度 / 力 / 期望位置 / wake-up)Flecs → 物理物理引擎
3PhysicsStep调用 Box2D/Jolt 推进一次或 N 次固定步物理引擎物理后同步
4PhysicsPostSync读回位置,收集碰撞 / 接触事件到 ECS 组件物理 → Flecs游戏逻辑
5Gameplay伤害、状态机、AI、技能、拾取游戏系统渲染前
6RenderPrepare插值出视觉位置,写入渲染组件渲染同步系统渲染器
7Render提交绘制渲染器屏幕

注意第 4 段和第 5 段之间的顺序。我见过很多项目的伤害判定放在第 4 段之前,理由是「物理刚算完,赶紧处理」。这是反的——你必须在物理刚算完之后、游戏逻辑真正开始之前,把所有接触事件收集完整,再统一由游戏逻辑去消费。否则你会发现有些接触被处理了两次,有些一次都没被处理,因为事件还在物理引擎内部排队。

然后是固定步长和插值。物理引擎几乎都推荐固定步长,比如 60 Hz 或者 120 Hz。但玩家显示器的刷新率不是固定的:60、120、144、165、240 Hz 都有。你需要一个时间累加器:每帧把真实 delta time 加进桶里,桶够一个「固定步」就倒出来一次跑物理,剩下的部分算成一个 0 到 1 之间的 alpha,给渲染插值用。

插值是给视觉补帧的,不是给逻辑补帧的。比如一个球以 60 Hz 物理在 144 Hz 屏上运动,不插值的话每一帧都是同一次物理步结果,你会看到明显的「抖动」;插值之后,画面平滑了,但玩家按的键仍然是按固定步进处理的。这个分界线对玩家的意义是:他看到的动作是丝滑的,但他打的每一拳都能准点判定。

这里有个坑我得提前叫一声。玩家操控的主体不要也套同一套插值——玩家看到自己的角色永远比按键晚一帧,那个感觉就是要吐。这是我被吐槽过的最多的一件事。正确做法是:玩家自身的视觉位置直接对准当前物理位置,只让摄像机做平滑追帧(或者反过来,如果你想让整个画面稳定,可以接受给所有东西插值,但那是另一种取舍)。

回头看 Box2D 和 Jolt 的差异。Box2D 内部在一次 Step 里会跑速度迭代和位置迭代,默认参数下 8 次速度迭代 + 3 次位置迭代,专业术语叫 solver 迭代次数。Jolt 更狠,官方文档里直接描述了它天生为多线程设计,有 job system、contact collector、内部 collider。这意味着你在 Flecs 的 Phase 里必须给它们预留一次完整的时空,不能让别的系统在它内部插队。

如果你熟悉 Bevy 生态,你大概已经想到了它的 FixedUpdate。Bevy 把固定步和渲染步硬拆成两个 Schedule,这套范式在 Godot 4 里也是类似的物理帧和渲染帧分离。Flecs 的 Pipeline/Phase 你也能做到一样的事——你定义 Phase 的时候就是定义「时间和空间的墙」。墙只要立起来,剩下的系统各走各路。

那为什么我要强调这一段是「手感生死线」而不是「性能生死线」?因为性能你可以事后优化,但手感错了,做完整个游戏再改,就是推倒重来。跳跃的土狼时间(coyote time,允许玩家离开平台后仍能起跳的宽容窗口)、输入缓冲、落地延迟这些所谓「平台跳跃手感,全建立在你把每一帧的什么地方留给谁」这个基础之上。这一段结构定死之后,才轮得到后面的所有调优。

第三层:碰撞事件怎么进 ECS——事件队列、Observer 与触发链的三方博弈

玩家能感知到的碰撞是很少的,但开发者要处理的碰撞是海量的。比如玩家手里的一个火球撞到一桶油,玩家能感知到的只有「爆了」这一个瞬间。但开发者要处理的是:火花粒子、音效、伤害判定、油桶状态切换、燃烧蔓延、连锁爆炸、成就解锁、玩家 FPS 抖动。一个碰撞,七个后果。

很多新手 (包括当年的我) 的做法是在物理引擎的碰撞回调里直接把所有事干了。这个做法在原型阶段非常爽,但它会在你做完八个玩法之后让你痛苦:改一个碰撞效果要先绕开一堆老逻辑、物理回调里读 ECS 状态会读到锁住的表、堆栈跑飞的时候根本不知道是谁触发的。

正解是把碰撞在 ECS 里表达成三种不同语义的东西,各走各的路:

语义适合的载体典型场景优缺点
瞬时接触(发生一下就结束)事件组件 / 队列,帧末统一 drain撞击伤害、破碎触发、金币拾取、成就计数解耦最好,便于排序、去重、回放;但不能在物理步内立即感受
持续状态(接触到结束)用查询每帧检测接触标志或 pair 组件角色站在地面上、贴着墙滑动、绳索拉拽可读性好,能直接进渲染和状态机;但需要自己维护去重
副作用响应(一碰就做事)Flecs 的 Observer碰到即死、触发器开关、关系建立代码短;但在物理内部触发是致命陷阱

我个人推荐的分配比例是 7 成事件队列、2 成查询、1 成 Observer。绝大部分碰撞都应该先收集到事件组件里,等 Gameplay Phase 统一处理。这样设计还有个副作用——它能天然支持回放和网络同步,因为事件是可以序列化的。你在做一局游戏的录像回放,最烦的是什么?物理引擎的接触顺序在不同平台上未必一样。但如果你把事件当数据存下来,回放时直接读事件表,就不需要重新跑物理。

Flecs 的 Observer 是很强的工具,但你必须明白它触发的时机。如果你把 Observer 订阅在「碰撞开始」这种物理事件上,然后观察者的负载里又去改 ECS 状态——恭喜你,你会遇到那种「明明改了却发现下一帧又变回去」的诡异问题。正确的用法是把 Observer 也当成一个轻量的「搬运工」:碰撞时它只负责往事件组件里塞条数据,真正的游戏逻辑还是回到 Gameplay Phase 处理。

去重是这一层里最容易被忽略、也最容易出 bug 的地方。同一个碰撞在两个 body 之间,引擎通常会给你两次通知(A 撞 B、B 撞 A),有时还按子步给你来两次。所以你必须有一个「碰撞对」的概念来去重。我一般会在事件组件上带三个字段:发起方实体、接收方实体、接触帧号。在 Gameplay Phase 处理时用一个「已经处理过的对」集合来过滤。看起来很繁琐,但能省掉你后面 80% 的「为什么伤害加倍了」的调试时间。

在真实项目里,这一层做得最狠的其实是 Jolt Physics。Jolt 的作者 Jorrit Rouwe 在行业内的份量,从它的使用项目就能看出来——Guerrilla 的《地平线:西之绝境》公开采用 Jolt 作为角色和载具物理的一部分,小岛工作室的《死亡搁浅 导演剪辑版》也用 Jolt 处理部分物理。Jolt 内部最值得学习的不是它的求解器,而是它对接触的收集机制:它不是让游戏逻辑在回调里跑,而是让 contact listener 或者 contact collector 先把接触数据排队收集起来,等物理步结束再统一交给上层处理。你看,这和我们刚说的「事件队列优先」是同一个思想——只是它把这个思想做进了引擎内部。

Jolt 这个设计对 Flecs 开发者的启示是:不要在物理世界里触发生命周期事件。你的 ECS Observer 想监听「Boss 碰到墙」,本质上应该监听「Boss 这一帧的事件组件里新增了一个 WallHit 记录」,而不是监听物理引擎的接触回调。这两者在语义上差了一层,但风险级别完全不在一个量级。

再说一个真实的玩法例子——《愤怒的小鸟》的连锁反应。这个玩法看起来像一个简单的物理效果,但它在工程上是这么一条链:鸟撞木板 → 产生一次强接触事件 → 木板血量减少 → 木板状态切到破碎 → 派生出一堆碎片实体 → 碎片和猪发生接触 → 猪掉血 → 猪死亡 → 计分 → 触发三星评价判定。这一整条链,如果每一步都在 Box2D 的接触回调里当场做完,你会在碎片生成那一刻撞到内存分配的墙,然后整个物理世界崩掉。而如果每步都排队到 Gameplay Phase 统一处理,那么碎片生成可以批量 deferred,接触可以按帧分组,猪掉血可以延后一帧——玩家根本察觉不到。

这就是为什么我说这一层是「三方博弈」。物理引擎想要快,ECS 想要整齐,玩家想要爽,三者的共交点就是让事件先排队,让逻辑统一爆发。这不是折中,这是三方都赢。

第四层:反向回写与脏标记——物理改完 ECS,游戏逻辑怎么办

前面三层都在讲怎么把 ECS 的东西送进物理,这一层反过来:物理算完之后,结果怎么回到 ECS,游戏逻辑怎么不打架。

先看玩家能感知到什么。你玩一个 2D 沙盒,往地上扔一个弹簧,再扔一个木箱上去,木箱被弹飞撞到吊灯,吊灯掉下来砸到 Boss。玩家感受到的是「一切都很自然」,感受不到的,是这一帧里位置数据在三个世界之间来回搬了多少次。

要理解这一层,得先把「位置」这个东西拆成三份,各自有各自的职责:

  • 意图位置:脚本或玩家希望物体去哪。比如平台的终点、玩家的目标速度、AI 的巡逻路径点。
  • 物理位置:物理引擎算出来的权威状态。这是唯一的真实。
  • 视觉位置:渲染用的插值结果。它可以是物理位置和上一帧物理位置的加权平均。

很多项目翻车就是三份位置混成两份。比如玩家被陷阱顶飞的时候,脚本想改位置、物理也算出了新位置,如果视觉读的又不一致,就变成了「玩家一会瞬移一会橡皮筋」。我见过不少游戏里的「卡墙」,就是玩家实体在 ECS 里被脚本推动,但物理 body 觉得他撞墙了,于是双方各写一份,人就在墙缝里挤着。

脏标记是这一层的核心工具。Flecs 的 Tag 组件天然适合做这个:

脏标记含义谁设置谁消费清除时机
IntentDirty游戏逻辑改了意图,需要写进物理输入、AI、脚本PhysicsPreSyncPhysicalSync 完成后
PhysicsDirty物理算完有了新状态,需要回写PhysicsStep 后PhysicsPostSync回写完成后
VisualDirty渲染数据需要刷新PhysicsPostSyncRenderPrepare渲染提交后
WakeRequested想唤醒一个睡眠中的物理体任何伤害 / 力系统PhysicsPreSync唤醒完成后

这个表格看起来平凡,但每条都对应一个具体的社区血案。比如 WakeRequested 那个,你没写就会遇到「玩家一刀砍下去,敌人没反应」——因为敌人处于物理睡眠状态,物理引擎根本不会重新计算它的接触。我在第一个沙盒项目里就因为这个问题被测试的人折磨了三天:敌人站在一段斜坡上睡着了,玩家推它推不动,骂它骂不动,只有等它自己醒才能打。

回写方向有三个方向必须想清楚:

方向一:动态物体(Dynamic)。物理引擎是权威。ECS 里读到的位置来自物理回写。玩家和 AI 只能通过「意图」影响它——速度、力、冲量,而不是直接设位置。如果你非要直接设位置,比如传送,那就要先把它在物理里设成「运动学」或者「瞬移后重置速度」,否则物理会试图用插值把它拉回原位,玩家看到的就是瞬移回原点。

方向二:运动学物体(Kinematic)。脚本是权威。电梯、移动平台、旋转门、飞行敌人的巡逻路径都是这一类。脚本写意图位置 → 物理引擎推着其它物体避让 → 物理回写视觉位置。这里最经典的坑是「运动学平台推不动动态箱子」——运动学物体在物理引擎里是被当作无限质量处理的,如果你没把它的速度设进物理世界,那物理引擎不知道它有速度,就不会给站在上面的物体施加推力。

方向三:层级(ChildOf 关系)。Flecs 的关系系统在这一层特别好用。玩家骑在马上、玩家站在电梯上、玩家抓着一个绳子——这些都是父子关系。物理回写时,子实体的物理位置是独立的,但视觉位置应该受父实体影响。这里有两种做法:一种是把父子的物理都用真实约束连接(关节、绳、焊接),让物理自己算;另一种是只画层级,物理里父子是分开的。前者更真实但有约束求解的成本,后者更省但容易穿模。我一般建议载具和平台用真实约束,挂件和 UI 挂点用视觉层级。

插值这一层还有个反直觉的点:不是所有实体都值得插值。子弹、瞬间产生的碎片、粒子,它们的生命周期可能不到 3 帧,插值对它们没有意义,反而增加回写负担。所以 InterpolateTag 不应该是全局的,而应该是每个实体「按需挂」的。这件事直接影响 2D 沙盒的帧时间分布——我们在一个 2000 物体的沙盒里做过对比:所有实体插值和「只对有 InterpolateTag 的实体插值」差了大概 15-20% 的 CPU 时间。那个钱,够你多跑 2 层粒子了。

现在我们回过头看两个真实项目,看它们是不是跟我们的理论吻合。

《地平线:西之绝境》用的是 Jolt。Guerrilla 公开的分享里提到,他们把角色物理、载具物理、绳索、布料拆成不同的物理世界或不同的 layer 来处理,为的就是不让一个物理世界的复杂性互相拖累。这个做法和 Flecs 里「多 World 隔离子系统」的思路如出一辙——你不用把所有的东西塞进一个 ECS World,也不用把所有东西都塞进一个物理 World。这就是个很典型的工程取舍:隔离比性能更值钱,因为隔离能救你的调试时间。他们如果用 Flecs 做上层,会非常自然地写成「角色系统 + 角色物理子 World」「载具系统 + 载具物理子 World」——回写策略、脏标记、插值开关,都能按子系统独立设置。

《愤怒的小鸟》在这层做得更极端一点:它几乎不做插值。因为它的物理步长相对爆裂的动作来说够用,而且它是一款回合制的弹射游戏,你大部分时间在看慢镜头重放。慢镜头重放等于把游戏逻辑帧率降到很低,一道抛物线分成 20 帧慢慢放,每帧都直接采物理位置。这是很聪明的取舍——你不需要为每秒都在飞的碎片做插值,你只需要在「关键的慢动作时刻」保证物理状态不丢。用 Flecs 的话,就是给 TimeScale 做一层可变步长控制,或者做一层「重放用的物理世界快照」。这也顺便解释了为什么《愤怒的小鸟》能在 2010 年的手机上跑得那么顺——不是物理牛,是它知道哪里不需要做。

反过来,反面例子是不少 2D 沙盒爱好者做的「万物皆可物理」项目。什么都是动态刚体,什么都没睡眠,什么都在插值。跑 60 帧没问题,一旦屏幕上超过 2000 个物体帧率就崩。问题不在物理,在回写:每一个物理体的位置每帧都在 ECS 里改一次,ECS 的 Archetype 会因为 Tag 频繁添加移除而不断迁移,碎片化一旦起来就没救了。这也是我为什么把「InterpolateTag 要按需挂」写在这一层——它是性能问题,更是一个架构问题。

参数化设计工具:一张能调的物理协同配置表

下面这张表不是给你抄的,而是给你当「体检单」用的。你做到一半觉得手感不对、性能不对、同步不对,就回来逐格对一遍,看自己哪一项没设。

参数含义建议区间影响的玩家感受我的踩坑备注
PhysicsHz物理固定步频率格斗 / 平台跳跃 120;休闲沙盒 / 模拟 60;高速竞速 240手感精度、碰撞穿透60 到 120 是玩家能感觉到差异的临界点,再高收益递减
MaxSubSteps每渲染帧允许跑的最大物理子步3~8卡顿防御设 0 会卡死;设太大一次卡顿能滚成雪球
InterpolationMode插值策略玩家本体关、NPC 开、弹道开、粒子关输入延迟 vs 视觉抖动给玩家本体开插值是最常见的手感错误
ContactQueueDepth事件队列容量512~4096连锁反应爆满时必须给出「丢弃最低优先级」的兜底策略
ContactDedupWindow碰撞去重窗口1~2 帧伤害是否叠加设 0 会出现伤害翻倍;设 3 会吞掉正常的连续碰撞
SleepVelLin / SleepVelAng物理休眠阈值线速度 0.01~0.05,角速度 0.02~0.1性能 vs「敌人被定住」阈值太松会导致你推不动睡着的东西
WakeRadius唤醒半径(自定义)0.1~0.5 单位打击感物理自带唤醒不够用,自己在伤害系统里主动唤醒
CollisionLayerMask碰撞层掩码16 位以内玩法规则层设计要一次性定好,后面改层等于重做碰撞规则
SyncFieldGranularity回写字段粒度位置 + 速度;不写质量CPU回写字段越多,ECS 因 Tag 变更引起的迁移越多
WorldLayering物理 World 分几个1~3 个顶层 World(主游戏 / 远景模拟 / 工具预览)稳定性多 World 不是银弹,它省钱也费人
DeterministicMode确定性模式网络游戏或回放才开存档 / 联机开了会掉 10~30% 性能,普通项目别开
TimeScaleRange时间缩放范围0.1~4.0慢动作 / 加速慢动作低于 0.5 就要考虑改用「帧重放」而不是真的物理慢跑

这张表还有个「元规则」:每一条都要能在运行时被调。你说做一款 2D 沙盒,物理手感好不好,策划一拍脑门就要改 restitution、friction、sleep 阈值。如果他改一次要重编译 10 分钟,你会疯的。Flecs 的组件本来就适合做「可热重载的配置」,把这张表映射成组件字段,游戏内开调试面板直接拧。

再补一句,接触去重窗口这个参数,很多人不会去调,但它是 2D 沙盒里最值钱的一个旋钮。它的英文原意是「多久之内把相同的碰撞对视为同一次」。设得太短,你的伤害系统会收到翻倍的伤害;设得太长,你要做「连续挤压伤害」就会失灵。调到 1~2 帧基本上能覆盖 99% 的情况,但具体值真的得靠测试。

双路径对照:初级玩家能跑的版本 vs 进阶工程师定死的版本

同一件事,两个路径。我把两条路都给你描一遍,你对号入座。

初级路径:2D 平台跳跃 Demo,目标两周内做出一个能玩的垂直切片。你需要的东西其实非常少。ECS 里挂三样组件:刚体(类型 + 掩码)、意图(速度 / 跳跃 / 抓取)、视觉标记。系统也只有三个:物理前同步、物理步、物理后回写。碰撞事件先不用 Observer,直接在回写时把接触对塞进一个队列组件里,Gameplay Phase 统一消费。插值?先别插,60 Hz 物理跑 60 Hz 屏幕也没什么抖。睡眠?先不睡。多 World?不用。你可以用一份约 800 行代码做出一个手感不错的平台跳跃原型,跑 200 个物体完全没压力。

这条路最容易翻车的地方是「什么时候开始加复杂度」。我建议是:当你发现自己在第 10 次重写物理同步逻辑的时候,就是该升级的时刻。你重写三次以内,说明你还在原型阶段,别过度设计;重写超过五次,说明架构已经欠债了,必须回来把脏标记和回写方向理清楚。

进阶路径:跨平台 2D 沙盒,目标上线,支持万级物体、多世界、可回放、可联机。这条路上,你会需要:

  • 三个 State:意图、物理、视觉,各自独立的组件和生命周期。
  • 五个 Phase:Input、PreSync、Step、PostSync、Gameplay,而且这几个 Phase 要能被外部工具(比如 Flecs Explorer 或 Tracy)可视化。
  • 四个脏标记:IntentDirty、PhysicsDirty、VisualDirty、WakeRequested,每个都有自己的清除时机。
  • 一个接触事件表:以事件组件形式存在 ECS 中,带发起方、接收方、帧号、优先级四个字段。
  • 至少两个 World:主模拟 World + 远景 World,或者主 World + 回放 World。
  • 一个确定性模式开关:单机可以关,联机/回放必须开。

这两条路不是要你选一个,而是要你知道自己在哪一格。你现在做平台跳跃,就在初级格;做到「联机仓库联网回放」那一格,就是进阶格。我见过太多项目在原型阶段就按进阶格设计,结果两周都跑不起来一个能玩的场景,最后放弃了。别学他们。

说起来有点自嘲:我自己第一版物理集成就是奔着进阶格去的,结果做卡帕平台上反复横跳一个月没跑通一个像样的箱子,最后老老实实回到初级格重做,三天跑通了整个玩法,然后再一层一层加脏标记。所以这条路的顺序是先能跑,再能撑,最后才谈能扛。

常见坑:那些在物理回调里改 ECS 的人后来都怎么样了

坑 1:在物理回调里改 ECS 状态。这是第一大致命伤。物理引擎在步进期间,你的 ECS 世界处于「半锁」状态,任何修改要么被延迟、要么直接作用于无效内存。我在一个项目里把「踩到敌人头顶」的逻辑直接写在 Box2D 的接触回调里,结果每 3 局就崩一次,定位了整整一周。后来老老实实改成「回调只写事件,逻辑在 Gameplay Phase 消费」,一秒稳。

坑 2:双写位置。ECS 里存了一份位置,物理里存了一份,物理算完回写 ECS 之后游戏脚本又改一次,然后再回写给物理。玩家看到的就是「橡皮筋」和「瞬移」。这个问题没法通过调参解决,只能通过架构解决。

坑 3:物理时间步跟渲染时间步混着用。物理引擎如果吃到的是真实 delta time,那么帧率越高物理步越小,碰撞精度和弹性系数都会变,同一款游戏在 60 Hz 和 144 Hz 的机器上手感不一样。这是《愤怒的小鸟》这种老项目才会被传播的教训,但每年还是有新项目在踩。

坑 4:插值给了玩家本体。玩家看到自己的角色永远慢一帧,按跳跃键到手起跳之间有 16 毫秒的延迟感。格斗游戏社区把这个叫「延迟感」,平台跳跃玩家会抱怨「飘」。但奇怪的是,很多项目在做优化的时候会不自觉地给主角也加上插值,因为「看起来更平滑」。千万别。

坑 5:休眠的敌人打不动。物理引擎有睡眠机制,睡着的物体不会参与碰撞检测,性能很香。但你的伤害系统、玩家攻击系统不会自动唤醒它们,玩家发现「打一个站在斜坡上的史莱姆没反应」,以为是命中判定坏了,其实是物理在睡觉。手动设置 WakeRequested 是必须的动作。

坑 6:实体销毁,物理 body 没销毁。内存泄漏的经典形态。Flecs 的移除钩子要负责清理 body,但如果你在 Observer 里做这件事,又没处理好顺序,就会导致「body 已经被销毁了,实体还在引用它」的悬空指针。我见过最惨的一个项目,跑两小时游戏内存涨了 2 GB,原因就是木箱碎了不删 body。

如果你已经在骂「这些我都中过」,那太好了,这篇文章的意义就是让你把清单贴在显示器边上,下次中招前一秒想起来。

FAQ

Q1:Box2D 和 Jolt 到底选哪个?

我的回答是:2D 用 Box2D,3D 用 Jolt,别纠结。Box2D v3 对 2D 的调度和精度已经优化了十几年,社区和教程成熟得不能再成熟。Jolt 是 3D 物理的多线程野兽,性能好、确定性强、自带角色控制器,被多个 3A 项目采用。中间还有一条岔路——如果你的项目是 2.5D 或者需要 3D 碰撞但玩法是 2D 的,那就用 Jolt 把第三个轴锁死。别为了「统一技术栈」硬把 3D 物理塞进 2D 游戏,那会让你调参数调到吐。

Q2:物理步应该在 Pipeline 里跑一次还是分几次?

一次。物理步是原子操作,它跑一次就完成一次完整的时间推进,你不要把它拆到各个系统里,也不能让别的系统在它中间插队。Flecs 的 Pipeline 允许你声明阶段和顺序,就乖乖按 Input → PreSync → Step → PostSync → Gameplay → Render 的顺序排。你要插的逻辑,插在 Step 前后,别插在中间。

Q3:物理和 ECS 都存位置,到底谁是权威?

动态物体,物理权威。运动学物体,脚本(意图)权威。静态物体,谁都不权威——它就不该动。你把这三类分清楚,很多同步 bug 自己就消失了。反过来,如果你让动态物体的位置由脚本权威,你就得接受物理世界的摩擦力和碰撞会全部失效,玩家会看到箱子穿墙。

Q4:2D 沙盒的帧时间分布里,最大开销是物理还是 ECS?

看规模。物体在 200 个以内,物理和 ECS 差不多,都在 2-3 毫秒量级。物体超过 2000 个之后,物理通常占大头,因为 Jolt 和 Box2D 的碰撞检测是平方级增长的(虽然有 broad phase 缓和),而 ECS 本身是线性增长的。真正的杀手不是物理本身,是 ECS 回写引起的 Archetype 迁移——一个 Tag 在几千个实体上每帧被加和减,会把帧时间拉到一个不可接受的水平。所以「脏标记要合并、批量回写要批量化」,是工程量最大的优化方向。

Q5:Flecs Observer 监听碰撞,为什么总会有一帧延迟?

因为 Observer 在物理步内触发的时候,你写的游戏逻辑如果修改了 ECS 状态,会被延迟到下一次迭代。这不是 bug,是 Flecs 的语义。你要么接受这个「一帧延迟」,把它当成回滚网络的天然缓冲;要么改成事件队列 + Gameplay Phase 消费。我建议后者,除非你是专门做单帧反应游戏的。

Q6:物理步长能不能跟随帧率浮动?

能,但你要先接受两件事:第一,弹性系数会漂移,同一款游戏在不同机器上弹跳高度不一样;第二,做回放和联机会直接做不到。所以答案不是「能」或「不能」,而是「你愿不愿意放弃确定性」。如果你做的是休闲单机小游戏,浮动步长可以省下很多精力;如果你有任何一丝「可能会做联机/回放/存档校验」的念头,立刻锁固定步长。

最后一个问题,我想反过来问你:你有没有算过,你的游戏里「物理」和「游戏逻辑」之间那一帧的延迟,玩家能不能感觉到? 如果你没测过,那你其实还没真正开始做物理集成——你只是让物理跑起来了。

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