RUDPClient/openspec/changes/fix-controlled-player-jitte.../design.md

82 lines
3.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

## Context
本地回环测试中受控玩家出现小幅抖动。抖动根源之一是 `ReplayPendingInputs()` 中回放时对每个 `PredictedMoveStep` 的一次性大时长积分,与实时预测路径中 FixedUpdate 按 `Time.fixedDeltaTime` 逐步积分的形状不一致。
当前 `ReplayPendingInputs()` 实现:
```csharp
foreach (var replayInput in replayInputs)
{
ApplyTankMovementToPredictedState(
replayInput.Input.TurnInput,
replayInput.Input.ThrottleInput,
replayInput.SimulatedDurationSeconds); // 一次性传入总时长
}
```
Tank 运动学中旋转影响前进方向:`heading(t+dt) = heading(t) + turnInput * turnSpeed * dt``position(t+dt) = position(t) + forward(heading(t+dt)) * throttleSpeed * dt`。逐步积分和一次性积分在 dt 较大时产生分歧。
## Goals / Non-Goals
**Goals:**
- `ReplayPendingInputs()` 按固定步长逐步积分,与 FixedUpdate 预测路径完全一致
- 回放结果与逐步实时预测的轨迹一致,消除因积分形状不同导致的残余误差
- 不改变外部接口,只修改内部积分方式
**Non-Goals:**
- 不修改服务端的 50ms cadence
- 不解决 send interval 摆动问题Step 3 范畴)
- 不修改 visual correction 逻辑Step 4 范畴)
## Decisions
### Decision: 步长取服务端的 SimulationInterval50ms而非客户端的 Time.fixedDeltaTime20ms
**选择**:按服务端 `SimulationInterval`50ms作为回放步长。
**理由**
- 服务端以 50ms 步长积分产生 authoritative state客户端回放必须与其一致才能消除偏差
- 客户端 FixedUpdate 20ms 是渲染/物理步长,不代表服务端模拟粒度
- 每个 `PredictedMoveStep``SimulatedDurationSeconds` 可能是 50ms、100ms 等,按 50ms 步长逐次推进即可
**替代方案**
- 用 20ms 步长回放:与客户端 FixedUpdate 一致,但与服务端不同步,仍会产生偏差
-`SimulatedDurationSeconds` 作为单步:即当前行为,会导致非线性分歧
### Decision: 循环内部分步模拟,不引入新的状态累积
**选择**:在 `ReplayPendingInputs` 循环内按 50ms 步长迭代调用 `ApplyTankMovementToPredictedState`
**实现方式**
```csharp
private void ReplayPendingInputs(IReadOnlyList<PredictedMoveStep> replayInputs)
{
const float serverStepSeconds = 0.05f; // 50ms服务端 SimulationInterval
foreach (var replayInput in replayInputs)
{
var remaining = replayInput.SimulatedDurationSeconds;
while (remaining > 0f)
{
var step = Mathf.Min(remaining, serverStepSeconds);
ApplyTankMovementToPredictedState(
replayInput.Input.TurnInput,
replayInput.Input.ThrottleInput,
step);
remaining -= step;
}
}
// ...
}
```
**理由**
- 不改变 `PredictedMoveStep` 结构体接口,只修改消费方式
- 无需新增临时状态变量
- 逻辑清晰,与实时预测路径的积分形状完全一致
## Risks / Trade-offs
- **[风险]** 如果 `SimulatedDurationSeconds` 累计值有浮点误差,循环可能产生多一步或少一步的小偏差
- **缓解**:使用 `Mathf.Min(remaining, step)` 保护,最后一步自然截断;或对 `remaining -= step` 后加 epsilon 比较
- **[风险]** 50ms 步长对极短的输入(比如只有一帧的输入)会产生额外计算
- **可接受**:额外一次函数调用,代价可忽略