Significance 预算与布娃娃 / PhysicsControl 的冲突修复
Significance 预算与布娃娃 / PhysicsControl 的冲突修复
ARPG 战斗里经常会同时存在十几到几十个敌人,常见的优化做法是引入 Significance/Budget 系统:按距离、可见性给角色分桶(bucket),根据档位降低 tick 频率甚至关闭 PhysicsControl。这套系统在常态下表现良好,但只要扩大 Physics Control 被关闭的阈值范围,就会暴露出两个典型现象——它们来自同一个根因:Significance 不知道角色当下是不是 ragdoll。
1. 两个现象
| # | 现象 | 触发场景 | 关闭 Significance 后 |
|---|---|---|---|
| 1 | 远处敌人一帧布娃娃、一帧 T-Pose 反复切换 | 敌人数量多、布娃娃状态(击飞 / 击倒 / 死亡) | 消失 |
| 2 | 敌人缓慢"升天" | 击飞类技能(如 GA_KnockoutFly)的后半段(约 0.4–0.8s) | 消失 |
控制台快速验证:
- 关掉角色预算 → 两个现象都消失
- 把所有桶设为 always-tick → 两个现象都消失
两条命令只用于排查,正式修复不依赖它们。
2. 问题 1:布娃娃闪烁(1-frame Ragdoll ↔ T-Pose)
链路
Budget 系统在 bPhysicsControlTick 翻转的那一帧会做:
if (Data.bPhysicsControlTick) {
SkeletalMesh->bUpdateMeshWhenKinematic = true;
SkeletalMesh->SetAllBodiesPhysicsBlendWeight(1.0f); // 布娃娃姿态
} else {
SkeletalMesh->bUpdateMeshWhenKinematic = false;
SkeletalMesh->SetAllBodiesPhysicsBlendWeight(0.0f); // 动画姿态 / T-Pose
}
bPhysicsControlTick 由 actor 所在的 bucket 决定,bucket 一旦跨过 PhysicsControl 关闭阈值就翻转。
放大器
- 桶边界抖动:bucket 分类是硬阈值,
SignificanceFunction的bRecentlyRendered罚分恰好等于一整档桶跨度,边界敌人每帧在 Medium ⇄ Low 之间跳。 - 预算压力二次重分配:bucket 分配每帧清空重填,压力大时进一步把边界敌人挤档。
原有兜底不完整
角色基类上有一个 bForceEnablePhysicsControl flag,目的就是让 Budget 跳过 PhysicsControl 关闭。但它只在死亡路径设置,非死亡的 ragdoll(击飞、击倒、stagger、被击落等玩法驱动的 ragdoll)完全没保护,必然闪。
3. 问题 2:击飞技能后半段升天
技能结构
典型的 GA_KnockoutFly 技能:
激活时一次性做:
- PhysicsControl 初始配置
- 对
pelvis/spine_02/spine_04三根骨头一次性加冲量(bVelChange=true) SetCollisionProfileName → CharacterMeshSetControlsInSetEnabled(true)- 启动 0.8 秒长的 Timeline
Timeline UpdateFunc 每 tick 做:
- 调
PhysicsControlComponent::SetControlMultipliersInSet(multi) multi来自 CurveFloat:t=0→0 / t=0.2→1.022 / t=0.8→0,线性插值
- 调
multi 是 PhysicsControl 的强度乘数,控制「对目标姿态的锁定力度」。
为什么后半段升天
PhysicsControl 本质是每根 bone 的 PD 控制器:
三个时间相关输入在 Significance 节流下都会受影响:
| 输入 | 来源 | 节流影响 |
|---|---|---|
target_pose | 动画 / AnimGraph 输出 | SkeletalMesh->EnableExternalUpdate(false) → target 冻结在上一次更新的动画关键帧 |
current_pose | 物理刚体反馈回 mesh | 同上 → 读到的是 N-1 帧前的值 |
multi | Timeline 驱动 | Timeline Component 节流 → 乘数曲线停在较高值更久 |
后半段(t > 0.4s)特定动力学:
- Timeline 曲线本该从 ~1.0 平滑降到 0,控制力释放,body 顺势自由落体。
- 节流导致
multi维持高值更久。 - 同时
target_pose冻结在"飞起中段"的动画关键帧(向上姿态)。 - PD 控制器看到 target 在高处、multi 仍大 → 持续施加向上的力。
- Chaos 重力按原帧率加,但向上的控制力压过重力 → 净向上漂。
- 前半段冲量主导所以看不出,后半段才显形。
节流的具体动作
const bool bTickThisFrame = (((GFrameCounter + Data.FrameOffset) % Data.TickRate) == 0);
if (bTickThisFrame) {
SkeletalMeshComponent->EnableExternalInterpolation(Data.TickRate > 1 && Data.bInterpolate);
SkeletalMeshComponent->EnableExternalUpdate(bTickThisFrame);
SkeletalMeshComponent->SetExternalDeltaTime(Data.AccumulatedDeltaTime * CustomTimeDilation);
SkeletalMeshComponent->SetExternalTickRate(Data.TickRate);
} else {
SkeletalMeshComponent->EnableExternalUpdate(false); // 关键:冻结 mesh
}
只要敌人落在 Medium/Low 桶,TickRate > 1,mesh 每 N 帧才更新一次。
4. 统一修复:让 Budget「知道」ragdoll 活跃
两个问题的根都是 「Significance 在 ragdoll / PhysicsControl 活跃时仍对角色做预算压制」。修复集中在角色类对 Budget 系统暴露的两个接口上。
4.1 新增 helper IsRagdollActive()
合集判定,任一满足即为「ragdoll 活跃」:
- 经典 ragdoll:
GetMesh()->IsSimulatingPhysics()为真(走SetAllBodiesSimulatePhysics(true)的路径) - PhysicsControl 驱动:任一
UPhysicsControlComponent的PrimaryComponentTick.IsTickFunctionEnabled()为真
4.2 IfForceEnablePhysicsControl() 复用 helper
原本只读 bForceEnablePhysicsControl flag,现在改成:
bool AGameCharacter::IfForceEnablePhysicsControl()
{
return bForceEnablePhysicsControl || IsRagdollActive();
}
效果:任何 ragdoll 活跃时 → bPhysicsControlTick 保持稳定 → SetAllBodiesPhysicsBlendWeight 不再翻转 → 问题 1 消除。
4.3 IfCanSkipTick() 新实现
原本只读 bCanSkipTick flag,现在改成:
bool AGameCharacter::IfCanSkipTick()
{
return bCanSkipTick && !IsRagdollActive();
}
效果:任何 ragdoll 活跃时返回 false → 角色被视为 always-tick → TickRate=1,mesh 每帧更新 → PhysicsControl 的 target / current / multi 三条链路都恢复每帧同步 → 问题 2 消除。
4.4 性能优化:缓存 PhysicsControlComponent 指针
IfForceEnablePhysicsControl / IfCanSkipTick 每帧每角色都会被 Significance 管线调用。如果直接每次 AActor::GetComponents<UPhysicsControlComponent>(),会对 OwnedComponents 做 O(n) 遍历 + 每项 Cast<T>,一个角色约 15–25 个组件 → 100 个敌人 × 20 次指针操作 = 2000 次/帧。
缓存方案:
TArray<TWeakObjectPtr<UPhysicsControlComponent>> CachedPhysicsControlComponents成员PostInitializeComponents()里一次性填充IsRagdollActive()读缓存TWeakObjectPtr保证动态销毁安全
实际每帧每角色成本:
- 非 ragdoll(常态 99%):
IsSimulatingPhysics()一次虚函数读 bool → 早退,个位数 ns - ragdoll 状态:早退 + 读缓存数组(0~2 项)+ 一次 tick bool 检查
5. 兼容性 / 副作用
| 场景 | 行为 |
|---|---|
| 普通 alive 敌人(无 ragdoll) | IsRagdollActive()==false → 两个接口回到原语义,Significance 节流照常工作 |
| 死亡布娃娃 | bForceEnablePhysicsControl=true → 行为与修复前一致 |
| 冻结死亡 ragdoll | tick 已停,Budget 完全跳过这个 Actor,节流与否都不影响 |
| 击飞 / 击倒 / stagger 等活着的 ragdoll | IsRagdollActive()==true → 全速 tick + PhysicsControl 不被关,短暂(通常 ≤ 1 秒)的性能 trade-off,视觉稳定 |
| 玩家主角 | 走 IsMainPlayer() 分支,原本就 always tick,无变化 |
6. 验证方法
不依赖控制台开关
- 问题 1 回归:场景 20+ 敌人同屏,触发远处敌人 ragdoll(击杀、击飞)。目视远处布娃娃不再有 1-frame T-Pose 闪烁。
- 问题 2 回归:触发击飞技能,技能后半段敌人按正常弧线下落,不再升天。
- 非 ragdoll 性能回归:对一般战斗场景做
stat unit/stat unitgraph对比,确认 GameThread 没显著增加。
调试可视化
showdebug Significance 显示每个受管角色头上的 bucket 颜色(Green/Yellow/Orange/Red),以及 CurrentTickTime / TickTimeBudget。
7. 未处理的已知问题
bRecentlyRendered罚分等于桶间距 —— 边界敌人仍会有 bucket 抖动;本次修复让 ragdoll 场景免疫了抖动的可见后果,但其他系统(如阴影投射切换)仍受影响。- bucket 分类无迟滞 —— 距离 / 分值阈值都是硬判断,边界敌人会在阈值两侧抖动。
- Timeline 组件节流 —— 本次靠
IfCanSkipTick==false让 mesh 全速 tick,间接让 Timeline 跟上;如果未来 Timeline 独立节流,需要独立考虑。
8. 经验
- Significance/Budget 系统对「玩法活跃」状态盲。任何按 bucket 切 tick / 切 PhysicsControl 的优化系统,都需要一个明确的 escape hatch:让玩法/物理活跃的对象自动豁免。
- 「flag 只在死亡路径设置」是常见 trap。
bForceEnablePhysicsControl这种语义模糊的 flag 容易被误用,用 helper(IsRagdollActive)做一次性合集判定比加 4 处SetFlag(true)更可靠。 - 优化路径上的每帧调用必须缓存组件查询。
GetComponents<T>()/Cast<T>在多角色场景里是隐形开销,缓存 +TWeakObjectPtr是模板套路。