默认配置下,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 这类接口时只做两件事:
- 往
FChaosDirtyPropertiesManager的全局脏表登记一个 handle; - 把新值写进槽位,不触碰求解器正在读的数据。
物理线程每个子步开头统一 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。
结构示意:
什么时候不开
异步物理不是默认正确项。三类场景建议保持同步物理(AsyncPhysicsTickEnabled=false):玩法强依赖"写完立即读"且改造成本高;物理对象数量小、GT 帧时间充裕,解耦收益覆盖不了延迟;做帧级锁步回放——异步物理的插值和事件排队会把回放对拍搞复杂。开与不开的决策依据很朴素:物理耗时占帧时间比例高、且玩法不敏感于一个物理帧的延迟,就开。