技术博客

异步物理与 GT/PT 解耦:Fixed Timestep 的代价

第 47 / 100 篇 · 2026-05-10 · ue5 · Chaos物理引擎并发源码分析

默认配置下,UE 的物理和 GameThread 绑在同一条线程上按渲染帧率跑:渲染卡 100ms,物理也卡 100ms,掉帧会直观表现为"游戏变慢"。把物理挪到独立的 PhysicsThread、用固定步长推进(PhysicsSettings → AsyncPhysicsTickEnabled,UE5.1 起可用),是中型以上项目几乎必开的开关。这篇讲这个解耦架构的机制和它收下的账单。

双线程模型:推与拉

解耦后每个渲染帧分两个半场:

  • GameThread 半场:游戏逻辑照常跑,所有对物理状态的访问(GetComponentLocation、施力、改速度)不再直接改求解器数据,而是写进命令队列和脏属性集;
  • PhysicsThread 半场:物理线程以固定频率(默认 60Hz,Fixed Timestep)消费队列——应用脏属性、跑若干个子步、把结果写进双缓冲的结果区。渲染帧率高于物理频率时,一个渲染帧对应零个或多个物理子步;帧率低于物理频率时,一个渲染帧要连跑多个子步补齐"游戏时间"。

GT 读物理状态走的是另一本账:不是读求解器内部数据,而是读物理线程最近一次回写的结果快照。这两本账之间隔着线程同步点,源码里的门闩是 FPhysicsScene_Lock 一族接口加 PhysicsCommand 的 GT/PT 分发宏。

Dirty Properties:5.1 的重构核心

5.1 之前,GT 对物理对象的修改靠"线程锁 + 直接写"(FPhysScene_Lock 老路径),锁竞争严重,很多场景还得回退到同步物理。5.1 引入的 Dirty Properties 机制把写路径改成了纯消息式:

每个物理粒子(FGeometryParticle)持有一份属性槽,GT 改 SetVelocity 这类接口时只做两件事:

  1. 往 FChaosDirtyPropertiesManager 的全局脏表登记一个 handle;
  2. 把新值写进槽位,不触碰求解器正在读的数据。

物理线程每个子步开头统一 ApplyDirtyProperties:把脏表里登记的修改批量灌进求解器状态,再清空脏表。这个"所有状态变化先进队列、固定时机统一应用"的模式,和岛管理篇讲过的唤醒事件队列是同一个设计——遍历中绝不改正在被遍历的结构。

读路径也要适应这个模型:GT 在写完的同一帧内立刻读(比如 AddForce 后马上 GetVelocity),拿到的可能是还没被 Apply 的旧值。这种"写完即读"的代码在同步物理下工作正常,开了异步物理就是隐蔽 bug 的重灾区。排查特征:只有开 AsyncPhysicsTickEnabled 才复现、且表现为"状态晚了一拍"。

固定步长:为什么坚持

物理线程按 $h = 1/60$ 的固定步长推进,而不是跟着渲染帧的 $\Delta t$ 跑。原因两条:

数值稳定。约束投影篇讲过,PBD 的等效刚度和 $h$ 强相关;变步长意味着同一根绳子的"硬度"随帧率漂移——90fps 的机器和掉帧到 40fps 的机器玩的是两个物理游戏。固定 $h$ 把数值行为锚定成常量。

接近确定性。游戏逻辑要锁步回放(死亡回放、观战系统、反作弊校验),前提是同样的输入序列产生同样的物理轨迹。固定步长是必要条件——虽然还不是充分条件:Chaos 不做跨平台位级确定性(编译器、SIMD、求和顺序都会引入浮点差),同平台同版本同配置的确定性也只在"不依赖帧率相关输入"时近似成立。做锁步的人要清楚这条边界,锁步同步的正确姿势是把物理也锁进逻辑帧,而不是指望物理自己确定性。

代价随之到来。渲染帧率 $\neq$ 物理频率时,两边时间轴对不齐,要么物理表现"跳帧"(不补间),要么插值(RigidBodyInterpolation)。插值让运动平滑但引入最多一个物理帧的输入延迟——射击游戏开异步物理要权衡这个延迟。

第二个代价是事件延迟。物理碰撞事件在物理子步产生、排队,下一帧 GT 才消费。同样最多晚一个物理帧,做"落地瞬间触发音效"这类需求时不能假设事件和渲染帧对齐。

第三个代价是显存与维护:双缓冲意味着两套变换/速度状态,加上脏属性表、命令队列的额外分配,物理对象的内存占用大约多出三成。对海量小物体的场景(碎块雨),这部分开销会被放大。

源码入口

  • 线程模型:PhysicsProxy/ChaosPhysics.cpp 的 FChaosScene,看 StartFrame / EndFrame 里 GT 与 PT 的交接;
  • 脏属性:Chaos/Public/Chaos/DirtyPropertiesManager.h 的 FChaosDirtyPropertiesManager 和 FDirtyPropertiesManager 双缓冲实现;
  • 子步推进:FPBDRigidsEvolutionGBF::AdvanceOneTimeStep,传入的 dt 来自物理线程累计的固定步长切片;
  • 命令分发:PhysicsCommand.h 的 EXECUTE_GUARD 宏族,定义哪些接口走 GT、哪些走 PT。

结构示意:

GT/PT 解耦:命令下行、快照上行,固定步长切片 GameThread 游戏逻辑:施力/改速度/Spawn 只写脏表,不碰求解器 读结果快照(可能滞后) GetLocation 一族 坑:写完同帧读 = 旧值 等 ApplyDirtyProperties 才生效 PhysicsThread ApplyDirtyProperties 批量灌入脏属性 子步循环 h = 1/60 一渲染帧可能跑 0~N 个子步 写回快照 + 事件队列 碰撞事件延迟一物理帧 命令队列 + 脏属性集 结果快照 账单:双缓冲内存 +30% 左右、输入延迟最多一个物理帧、事件延迟、不做跨平台确定性

什么时候不开

异步物理不是默认正确项。三类场景建议保持同步物理(AsyncPhysicsTickEnabled=false):玩法强依赖"写完立即读"且改造成本高;物理对象数量小、GT 帧时间充裕,解耦收益覆盖不了延迟;做帧级锁步回放——异步物理的插值和事件排队会把回放对拍搞复杂。开与不开的决策依据很朴素:物理耗时占帧时间比例高、且玩法不敏感于一个物理帧的延迟,就开。