1. 文档信息
- 文档类型:架构审计 + 缺陷复盘 + 修复说明
- 文档目标:为博客发布、团队同步、后续重构提供统一依据
- 调试范围:
Source/ThirdParty/AsyncTickPhysicsSource/RTune/Private/Vehicle/RTuneVehicle.cppSource/RTune/Private/Vehicle/SuspensionComponent.cpp
- 调试重点:Async Tick 链路、物理线程边界、复制模式下的车辆状态一致性、悬挂状态更新
- 当前结论:
- 该插件的核心能力是可用的,但原始实现存在明显的线程边界设计缺口
- 最危险的问题不是单一公式错误,而是“物理线程直接执行 UObject / Blueprint / World 查询逻辑”
- 本轮已完成一批高收益、低侵入的修复
- 仍有两类高优先级架构风险需要在后续版本继续治理
2. 执行摘要
这次排查的核心结论可以概括为一句话:
RTuneVehicle 的问题不是“某一个 Async Tick 函数写错了”,而是“整个 Async Tick 设计把过多的游戏线程语义直接带进了物理线程”。
具体表现为:
- 第三方
AsyncTickPhysics原本会在物理线程中直接调用BlueprintImplementableEvent AsyncTick。 RTuneVehicle与SuspensionComponent在物理线程内直接读写大量 UObject 状态。SuspensionComponent在物理线程内直接调用UWorld::SweepSingleByChannel/LineTraceSingleByChannel。ERT_Full复制模式下,非权威端此前会过早返回,导致远端车辆悬挂状态根本不刷新。- 另有两个明确的局部逻辑 bug:
- 刹车俯仰力矩条件判断错误
OnRep_Steering的旋转分支与权威端不一致
本轮已经完成的修复包括:
- 补齐
AsyncTickPhysics生命周期注销逻辑,避免回调悬挂。 - 将 Blueprint
AsyncTick从物理线程改为回派到游戏线程执行。 - 修复
ERT_Full下远端车辆悬挂状态不更新的问题,同时补充同步CurrentTorque与HandbrakeInput。 - 修复刹车俯仰力矩门控错误。
- 修复悬挂转向复制分支错误。
本轮尚未彻底解决,但应被视为下一阶段重点工作的风险包括:
- 物理线程内通过
UWorld做场景查询。 - 游戏线程和物理线程共享成员变量的直接读写,存在数据竞争风险。
3. 调试背景与验证边界
3.1 为什么重点看 Async Tick
车辆物理系统是高频、多状态、强耦合模块。只要引入异步物理回调,问题就不再局限于“公式对不对”,而会升级为:
- 回调在哪个线程运行。
- 这个线程能不能安全访问 UObject。
- 复制状态是在什么线程生成和消费。
- 游戏线程的动画、轮子、内部仪表、反重力系统是否读取了跨线程共享状态。
换句话说,Async Tick 的问题天然具有“隐性、偶发、难复现”的特征:
- 有的机器完全正常。
- 有的机器在高车流、高帧率或多人联机时才暴露。
- 有的问题不是立刻崩溃,而是轮子抖动、悬挂错位、远端车辆状态不对、偶发编辑器断言。
3.2 本轮验证方式
本轮采用的是“源码审计 + 定点修复”的方式,尚未完成完整编译验证。
原因如下:
- 项目入口已确认存在:
C:\Users\KawaiRina\Desktop\RTune_V2\VehiclePhysics.uproject uproject指向EngineAssociation: 5.7- 当前本机标准安装路径下未直接找到 UE 5.7 编辑器/构建工具,因此本轮没有跑完整编译
这意味着:
- 文档中的问题分类、风险评估和修复思路是可靠的
- 已落地补丁需要在真实 UE 5.7 环境中再做一次编译与运行验证
4. 现有架构总览
4.1 模块启动与管理器创建
AsyncTickPhysics 在模块启动时向物理场景生命周期注册委托,并在物理场景初始化时创建 FAsyncTickManager。
关键代码位置:
Source/ThirdParty/AsyncTickPhysics/Source/AsyncTickPhysics/Private/AsyncTickPhysics.cpp:12Source/ThirdParty/AsyncTickPhysics/Source/AsyncTickPhysics/Private/AsyncTickPhysics.cpp:25Source/ThirdParty/AsyncTickPhysics/Source/AsyncTickPhysics/Private/AsyncTickPhysics.cpp:31
链路如下:
StartupModule -> OnPhysSceneInit -> new FAsyncTickManager(Scene) -> OnPhysSceneTerm -> delete FAsyncTickManager(Scene)4.2 AsyncTickManager 的职责
FAsyncTickManager 负责:
- 记录当前
PhysScene对应的管理器实例 - 维护参与 Async Tick 的 Pawn / ActorComponent 列表
- 在
ScenePreTick中把这些对象复制到 sim callback 输入缓冲 - 在 Chaos callback 中驱动这些对象执行
NativeAsyncTick
关键代码位置:
AsyncTickManager.cpp:13AsyncTickManager.cpp:45AsyncTickManager.cpp:55AsyncTickManager.cpp:153
4.3 Async 回调执行链
当前完整调用链如下:
FPhysicsDelegates::OnPhysSceneInit -> FAsyncTickManager -> Scene.OnPhysScenePreTick -> ScenePreTick -> 填充 FAsyncPhysicsInput -> FAsyncPhysicsCallback::OnPreSimulate_Internal -> Pawn->NativeAsyncTick(DeltaTime) -> ActorComponent->NativeAsyncTick(DeltaTime)关键代码位置:
AsyncTickManager.cpp:153AsyncTickCallback.cpp:3AsyncTickCallback.cpp:20AsyncTickCallback.cpp:29
4.4 RTuneVehicle 在链路中的位置
ARTuneVehicle 继承自 AAsyncTickPawn,因此会进入上述 Async 回调。
它在 Async Tick 中主要做四类事:
- 读取当前刚体姿态和速度
- 计算动力总成、空气力、手刹、反重力等物理状态
- 调用
SuspensionComponent::UpdatePhysicsWithState - 同步服务端 / 客户端复制状态
关键代码位置:
RTuneVehicle.cpp:442RTuneVehicle.cpp:592RTuneVehicle.cpp:623RTuneVehicle.cpp:1162
4.5 SuspensionComponent 的职责
USuspensionComponent 同时承担了两个职能:
- 物理线程中的接地检测、悬挂压缩、轮胎侧向力、阻力计算
- 游戏线程中的轮胎模型可视化、转向几何、Debug 绘制
这意味着它天然处于 GT/PT 双线程交界处,是本次排查中最敏感的模块。
关键代码位置:
SuspensionComponent.cpp:311SuspensionComponent.cpp:501
5. 问题分级总表
| 编号 | 问题 | 级别 | 状态 |
|---|---|---|---|
| ATP-01 | Blueprint AsyncTick 在物理线程执行 | P0 | 已修复 |
| ATP-02 | Async callback 生命周期注销不闭合 | P0 | 已修复 |
| ATP-03 | ERT_Full 非权威端悬挂状态不刷新 | P1 | 已修复 |
| ATP-04 | 刹车俯仰力矩门控变量错误 | P1 | 已修复 |
| ATP-05 | OnRep_Steering 旋转分支与权威端不一致 | P1 | 已修复 |
| ATP-06 | 物理线程中直接使用 UWorld 场景查询 | P0 | 未彻底修复 |
| ATP-07 | GT/PT 共享成员变量直接读写 | P0 | 未彻底修复 |
| ATP-08 | 复制状态仍非完整快照模型 | P2 | 部分缓解 |
说明:
P0 = 可能导致崩溃、断言、不可预测线程问题
P1 = 明确错误行为、多人不同步、物理表现明显异常
P2 = 可运行但设计不够稳定,后续容易扩散为 P0/P1
6. 详细问题分析
6.1 ATP-01:Blueprint AsyncTick 在物理线程执行
现象
原始实现中:
AAsyncTickPawn::NativeAsyncTick直接调用AsyncTick(DeltaTime)UAsyncTickActorComponent::NativeAsyncTick直接调用AsyncTick(DeltaTime)
而这两个 AsyncTick 都是 BlueprintImplementableEvent。
关键代码位置:
AsyncTickPawn.cpp原始逻辑位于NativeAsyncTickAsyncTickActorComponent.cpp原始逻辑位于NativeAsyncTick- 调用入口位于
AsyncTickCallback.cpp:25与AsyncTickCallback.cpp:34
风险
这是本轮最危险的问题之一。
原因很直接:
- Blueprint VM 默认不是为物理线程任意执行设计的
- 蓝图图表中很容易访问
Actor、Component、World、Timeline、Niagara、UI、Animation等游戏线程对象 - 即使某个蓝图当前“碰巧没崩”,也不等于它是线程安全的
可能出现的问题包括:
- 偶发崩溃
- PIE 下难以复现的断言
- 编辑器模式下奇怪的 UObject 生命周期问题
- 蓝图逻辑表现不稳定
本轮修复
本轮改为:
NativeAsyncTick仍然保留,用于真正的 C++ 异步逻辑- Blueprint
AsyncTick不再直接在物理线程执行 - 如果检测到蓝图确实实现了
AsyncTick,则通过AsyncTask(ENamedThreads::GameThread, ...)回派到游戏线程执行 - 如果没有蓝图实现,则不创建额外任务
修复后关键代码位置:
AsyncTickPawn.cpp:6AsyncTickPawn.cpp:11AsyncTickPawn.cpp:38AsyncTickActorComponent.cpp:6AsyncTickActorComponent.cpp:11AsyncTickActorComponent.cpp:38
价值
这个修复的价值不在于“性能优化”,而在于把最危险的线程越界行为先切断。
它解决的是“架构级安全性问题”。
6.2 ATP-02:Async callback 生命周期注销不闭合
原问题
模块销毁物理场景时,旧代码会直接:
- 从
SceneToPhysicsManagerMap中移除 delete PhysManager
但如果某些析构顺序或世界清理顺序与预期不同,OnPhysScenePreTick 里的 Raw 委托和 AsyncObject 可能没有被明确注销。
风险
这类问题的危险点在于:
- 代码在多数时候看起来正常
- 真正出问题时往往发生在世界切换、PIE 结束、编辑器关闭、重载模块等“边界时刻”
- 一旦留下悬挂委托,后果通常是悬空指针或回调打到已析构对象
本轮修复
本轮做了三件事:
- 在
FAsyncTickManager::~FAsyncTickManager中显式调用UnregisterCallbacks() - 给
RegisterCallbacks/UnregisterCallbacks增加幂等保护 - 在
PhysScene_OnPhysSceneTerm中删除管理器前先显式反注册
关键代码位置:
AsyncTickManager.cpp:31AsyncTickManager.cpp:114AsyncTickManager.cpp:131AsyncTickPhysics.cpp:31
价值
这属于典型的“生命周期闭环”修复。
它不会让单帧性能明显提升,但能显著降低世界退出、切图、PIE 结束时的偶发崩溃概率。
6.3 ATP-03:ERT_Full 非权威端悬挂状态不刷新
原问题
旧逻辑中:
- 如果
ReplicationMethod == ERT_Full - 且当前实例不是权威端
- 则
ClientStateSync(true)后直接return
关键代码位置在原 RTuneVehicle::NativeAsyncTick 开头。
这意味着远端车辆虽然会拿到基础复制状态,但:
- 悬挂压缩不会更新
- 轮胎接地状态不会更新
- 某些动画状态不会更新
- 手刹 / 轮胎附着相关表现会不完整
本轮修复
本轮将该逻辑调整为:
- 先
ClientStateSync(true) - 再读取当前刚体变换和速度
- 继续执行
SuspensionComponent::UpdatePhysicsWithState - 但把
bApplyForces设为false
也就是说:
- 远端客户端仍然刷新轮子状态
- 但不会把本地算出来的悬挂力重新写回刚体
关键代码位置:
RTuneVehicle.cpp:447RTuneVehicle.cpp:459SuspensionComponent.h:49SuspensionComponent.cpp:501
同时补充的同步字段
为了让远端悬挂表现更完整,本轮额外将以下状态加入 ServerState:
CurrentTorqueHandbrakeInput
关键代码位置:
RTuneVehicle.h:72RTuneVehicle.cpp:1162RTuneVehicle.cpp:1185
价值
这是本轮最重要的“行为正确性修复”之一。
它不只是防崩,而是直接改善远端车辆的肉眼可见表现。
6.4 ATP-04:刹车俯仰力矩门控变量错误
原问题
在 RTuneVehicle::UpdatePhysicsCore 中,刹车俯仰力矩原本写成:
- 条件判断使用
DynamicPitchMomentMultiplier - 实际施加的却是
DynamicBrakeMomentMultiplier
这意味着:
- 如果用户只配置了刹车俯仰系数
- 但加速俯仰系数为 0
- 那么刹车俯仰逻辑根本不会触发
本轮修复
门控条件已改为正确检查 DynamicBrakeMomentMultiplier。
关键代码位置:
RTuneVehicle.cpp对应刹车力矩分支
影响
这是典型“看起来像调参问题,实则是代码条件错了”的 bug。
如果不修,用户会误以为:
- 参数没生效
- 车辆重心不对
- 刹车姿态系统设计有问题
实际上是判断条件写错。
6.5 ATP-05:OnRep_Steering 旋转分支与权威端不一致
原问题
UpdateSteeringGeometry 中权威端与客户端插值分支对 bAllowFullRotation 的解释是一致的:
true时保留 Pitch / Rollfalse时只更新 Yaw
但 OnRep_Steering 中这一分支写反了。
关键代码位置:
- 权威端逻辑:
SuspensionComponent.cpp:207 - 复制响应逻辑:
SuspensionComponent.cpp:251
风险
会导致:
- 客户端悬挂轮子朝向与服务端不一致
- 车辆转向几何在复制场景下表现异常
- 问题容易在多人环境中被误判为网络延迟或插值错误
本轮修复
OnRep_Steering 现在与权威端分支保持一致。
关键代码位置:
SuspensionComponent.cpp:261
6.6 ATP-06:物理线程中直接使用 UWorld 场景查询
问题描述
USuspensionComponent::UpdatePhysicsWithState 目前仍在物理线程路径中执行以下逻辑:
GetWorld()GetRelativeTransform()World->SweepSingleByChannel(...)World->LineTraceSingleByChannel(...)
关键代码位置:
SuspensionComponent.cpp:507SuspensionComponent.cpp:544SuspensionComponent.cpp:569SuspensionComponent.cpp:593
为什么这是高风险问题
这是当前代码里最大的“尚未彻底解决”的结构性风险。
原因不是这些 API 一定会立刻崩,而是它们带有明显的游戏线程 / UObject 世界语义:
UWorld本身并不是一个天然的物理线程专用查询对象Component的相对变换、Owner、层级关系都属于 UObject 世界- 这些 API 的线程安全边界很难靠“经验运行正常”来证明
典型症状
如果这个问题在项目中被放大,常见表现包括:
- 多车压力下偶发 trace 结果不稳定
- PIE / 关卡切换时偶发断言
- 编辑器和打包版行为不一致
- 某些平台表现稳定,某些平台更容易出问题
为什么本轮没有彻底改掉
因为要真正解决它,不能只改一两行:
- 要么引入物理线程可用的查询接口
- 要么改成“游戏线程预采样 + 物理线程只消费快照”
- 要么重做悬挂接地检测的数据来源
这已经是“重构级工作”,不适合在一轮热修里强行塞进去。
建议方向
建议下一阶段选其中一种:
- 方案 A:建立 Physics Query Adapter,所有异步 trace 统一走受控接口
- 方案 B:GT 做环境采样,PT 只做受力计算
- 方案 C:将悬挂接地检测并回物理场景原生接口,不再通过
UWorld
6.7 ATP-07:GT/PT 共享成员变量直接读写
问题描述
当前 RTuneVehicle 和 SuspensionComponent 都存在“物理线程写、游戏线程读”的共享状态。
典型例子:
RTuneVehicle
游戏线程 Tick 会读取:
SpeedRPMSteeringInputLocationVelocitybHandbrakeInput
关键代码位置:
RTuneVehicle.cpp:161RTuneVehicle.cpp:173RTuneVehicle.cpp:202RTuneVehicle.cpp:262
而物理线程 NativeAsyncTick / UpdatePhysicsCore 会写入:
AngularVelocityAngularAccelerationSpeedAccelerationCurrentTorqueRPM
关键代码位置:
RTuneVehicle.cpp:442RTuneVehicle.cpp:520RTuneVehicle.cpp:596
SuspensionComponent
游戏线程 UpdateTick 会读取:
CurrentLengthLinearVelocityBurnoutRotationbSingleRaycastDebug*系列状态
关键代码位置:
SuspensionComponent.cpp:311SuspensionComponent.cpp:327SuspensionComponent.cpp:348SuspensionComponent.cpp:385
而物理线程 UpdatePhysicsWithState 会写入这些成员:
SuspensionComponent.cpp:501
风险
这类问题的危险点在于:
- 代码逻辑上“看起来没问题”
- 但内存模型层面属于无同步共享访问
- 表现可能是偶发抖动、插值跳变、低概率错误
为什么本轮没有直接用锁全包
因为“到处加锁”不一定是正确解:
- 会拉高 PT/GT 竞争
- 会掩盖真正的架构问题
- 锁范围一旦过大,反而可能拖慢 physics tick
建议方向
推荐后续升级到显式快照模型:
- PT 只写
AsyncStateSnapshot - GT 每帧安全拷贝一次
- 所有 UI / 动画 / 调试 / 非权威显示逻辑只读 GT 快照
这是标准、长期可维护的方向。
6.8 ATP-08:复制状态仍不是完整快照模型
虽然本轮已经补充了:
CurrentTorqueHandbrakeInput- 非权威端悬挂状态刷新
但严格来说,ServerState 仍然不是一个完整的“车辆状态快照”。
例如:
- 部分悬挂派生状态仍是客户端自行再计算
- 部分可视化状态仍依赖客户端本地读数
- 反重力、Debug、轮胎局部状态并未统一复制
这不一定是 bug,但它说明当前系统仍然是“半快照、半本地推导”的混合架构。
混合架构的优点是轻量,缺点是:
- 维护成本高
- 问题定位困难
- 容易在新功能加入后变得更脆弱
7. 本轮已落地修复清单
本轮实际修改点如下。
7.1 AsyncTickPhysics 层
FAsyncTickManager析构时显式UnregisterCallbacksRegisterCallbacks增加幂等保护UnregisterCallbacks增加幂等保护PhysScene_OnPhysSceneTerm删除管理器前先反注册FAsyncPhysicsInput::Reset同时重置DebugMessages
7.2 AsyncTick Blueprint 边界
- 为
AAsyncTickPawn增加DispatchBlueprintAsyncTick - 为
UAsyncTickActorComponent增加DispatchBlueprintAsyncTick - 在
BeginPlay中检测蓝图是否真的实现了AsyncTick - 仅当蓝图实现存在时,才派发回 GT
7.3 RTuneVehicle 层
- 车辆 async 路径改为调用
DispatchBlueprintAsyncTick ERT_Full非权威端仍然刷新悬挂状态,但不施力ServerState新增CurrentTorqueServerState新增HandbrakeInputClientStateSync/ServerStateSync同步上述字段- 修复刹车俯仰门控变量
7.4 SuspensionComponent 层
UpdatePhysicsWithState增加bApplyForces- 非权威端可更新悬挂状态但不回写刚体力
- 修复
OnRep_Steering的旋转分支
8. 对项目表现的实际影响评估
8.1 稳定性
本轮修复后,最直接受益的是稳定性:
- Blueprint 不再直接跑在物理线程
- 世界退出 / 场景析构时的异步回调风险降低
- 多人环境下远端车辆状态不完整的问题得到明显缓解
8.2 行为一致性
多人模式下预计会改善:
- 远端轮胎压缩 / 接地状态
- 远端手刹视觉反馈
- 远端驱动轮相关表现
- 复制转向几何一致性
8.3 性能
本轮改动对性能的影响整体可控:
- Blueprint AsyncTick 回派 GT 会增加一些任务调度成本
- 但只在蓝图真正实现该事件时才会触发
- 与其接受不安全的 PT Blueprint 执行,这点成本是合理的
9. 推荐的下一阶段治理路线
如果要把这套插件从“能用”升级到“长期稳定、可规模化使用”,建议按以下顺序推进。
阶段一:线程边界收口
目标:不再让物理线程直接碰高层 UObject 世界语义。
建议动作:
- 梳理所有 PT 路径中的
GetWorld / GetOwner / GetRelativeTransform / SetActor... - 建立白名单接口
- 所有非白名单 API 一律退出 PT 路径
阶段二:快照化
目标:GT/PT 不再共享可变成员变量。
建议动作:
- 给
RTuneVehicle建FVehicleAsyncState - 给
SuspensionComponent建FSuspensionAsyncState - PT 写快照,GT 读快照
阶段三:场景查询重构
目标:悬挂 trace 不再依赖 UWorld 查询。
建议动作:
- 设计统一的异步可用查询接口
- 或改成 GT 采样 + PT 受力计算
- 明确查询的线程模型和数据时序
阶段四:复制模型统一
目标:复制模式行为一致,避免“某种模式正常,另一种模式表现破碎”。
建议动作:
- 定义最小必需状态快照
- 对
ERT_Full / ERT_DataOnly / ERT_None分别写出行为契约 - 明确哪些状态由服务端驱动,哪些状态允许客户端推导
10. 建议的验证清单
在 UE 5.7 环境中,建议至少执行以下验证。
10.1 单机验证
- 单车直线加速、刹车、倒车
- 手刹漂移、连续大角度转向
- 空中姿态控制
- 反重力启用 / 关闭
- 动画模式开启 / 关闭
10.2 多车压力测试
- 同场景 10 辆车
- 同场景 20 辆车
- 频繁生成 / 销毁车辆
- PIE 启停多次
10.3 多人验证
对以下模式分别验证:
ERT_FullERT_DataOnlyERT_None
重点观察:
- 远端悬挂压缩
- 远端转向几何
- 远端手刹与轮胎表现
- 服务器与客户端是否出现明显分歧
10.4 生命周期验证
- 切换关卡
- 退出 PIE
- 关闭编辑器
- 热重载或插件重启
重点看是否出现:
- 委托未注销报错
- 异步回调打到已销毁对象
- 编辑器退出时的偶发崩溃
11. 结论
这套插件的异步车辆物理链路并不是“完全不可用”,但它在设计上明显混用了:
- 物理线程逻辑
- Blueprint 事件逻辑
- UObject 世界逻辑
- 复制状态逻辑
- 游戏线程动画逻辑
这种混用在早期开发阶段很常见,因为它能快速做出结果。
但一旦项目进入多人、压力场景、复杂蓝图扩展、频繁切图的阶段,问题就会集中暴露。
本轮修复解决了最危险的一批问题:
- 切断 Blueprint 直接跑在物理线程的路径
- 补齐 async callback 生命周期闭环
- 修复多人
ERT_Full下远端悬挂状态缺失 - 修掉两个明确的局部逻辑 bug
从“能跑”到“稳定可靠”,后续真正要做的不是继续堆分支判断,而是把线程边界、状态快照和场景查询模型彻底理顺。
部分信息可能已经过时