补充输入重放

输入重放是指当收到一个服务器状态包时以那个包为初始状态,然后重新计算所有未确认的输入来得到当前帧的状态,然后再对当前角色的位置进行插值移动。服务器状态包只能给出前几帧的权威数据,而当前帧的状态则需要通过输入回放来得到
This commit is contained in:
SepComet
2026-04-08 10:33:42 +08:00
parent da2b93e59c
commit 75289b5690
29 changed files with 850 additions and 573 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-04-07
@@ -0,0 +1,51 @@
## Context
当前 `MovementComponent` 的 `Reconcile()` 方法在收到服务器 PlayerState 后:
1. 强制 snap 到 server position
2. 重放 pending inputs(可能产生位移)
3. 用 bounded correction 从重放后位置收敛回 server position
问题:server position 每帧都在变化(服务器在广播),bounded correction 的收敛目标一直在变,导致永远追不上。
## Goals / Non-Goals
**Goals:**
- 将模拟层(服务器权威 truth)和表现层(纯视觉插值)解耦
- 表现层用 Lerp 替代 bounded correction,消除追赶震荡
- 模拟层只输出"目标位置",表现层只管插值
**Non-Goals:**
- 不改变网络协议或服务器逻辑
- 不修改远程玩家的插值逻辑
## Decisions
### Decision: 表现层直接设置 rigid.position,不使用 MovePosition
**选择:表现层直接设置 `_rigid.position`**
- Rigidbody interpolation 设为 `None`
- 表现层直接写入 `_rigid.position` 和 `_rigid.rotation`
- 不经过 `Rigidbody.MovePosition`(避免物理引擎介入)
### Decision: 模拟层收到服务器状态时立即计算 target
收到服务器 PlayerState 时:
1. Acknowledge inputs(移除 tick <= AckTick 的输入)
2. 从 authoritative position 重放剩余 pending inputs
3. 计算 `target = authoritativePosition + replayDisplacement`
4. 判断 error:error > SnapThreshold → snap;else → 更新 `_presentationTarget`
**关键**:`_presentationTarget` 在两次收到服务器状态之间保持不变,表现层稳定 Lerp。
### Decision: 插值策略使用 Lerp
固定 alpha = 0.15~0.2。后续可根据 RTT 动态调整或改用 SmoothDamp。
## Risks / Trade-offs
[Risk] 插值延迟导致本地玩家看到的位置比服务器延迟
→ [Mitigation] Lerp 的延迟是固定的(不像 bounded correction 那样持续追赶),且不影响服务器权威性
[Risk] 删除 bounded correction 后无法平滑大误差
→ [Mitigation] Snap 阈值(0.5 unit)处理大误差,直接跳到目标;Lerp 处理小误差自然收敛
@@ -0,0 +1,27 @@
## Why
当前 `MovementComponent` 将模拟层(pending inputs 维护、服务器校正、重放)和表现层(视觉插值、bounded correction)耦合在一起。bounded correction 的收敛目标是实时变化的 server position,导致客户端永远在追赶——每次收到服务器状态,收敛目标就变了。表现为本地移动平滑,加上网络同步后抖动。
## What Changes
- **新增** `LocalPlayerPresentationState` 类型:持有 `_currentPosition/Rotation`(当前显示)和 `_targetPosition/Rotation`(模拟层给的目标)
- **新增** `LocalPlayerSimulationState` 类型:持有 `_lastAuthoritativePosition/Rotation`、`_pendingInputs`、`_presentationTarget`
- **重构** `MovementComponent`:`OnAuthoritativeState` 只更新 `_presentationTarget`,不直接修改 rigid.position
- **重构** 表现层:每帧用 Lerp 将 `_currentPosition` 插值到 `_targetPosition`,再设置 rigid.position
- **移除** `ControlledPlayerCorrection` 相关逻辑:bounded correction 被表现层 Lerp 替代
## Capabilities
### New Capabilities
- `local-player-presentation-state`: 表现层状态(current/target position & rotation)及每帧插值更新
- `local-player-simulation-state`: 模拟层状态(authoritative baseline、pending inputs、presentation target)
### Modified Capabilities
- `local-player-reconciliation`: 模拟层收到服务器状态后计算 PresentationTarget,表现层负责插值收敛,不再使用 bounded correction
## Impact
- 新增 `Assets/Scripts/Network/NetworkApplication/LocalPlayerPresentationState.cs`
- 新增 `Assets/Scripts/Network/NetworkApplication/LocalPlayerSimulationState.cs`
- 修改 `Assets/Scripts/MovementComponent.cs`
- 删除 `Assets/Scripts/ControlledPlayerCorrection.cs`(或保留作他用)
@@ -0,0 +1,36 @@
# local-player-presentation-state Specification
## Purpose
Define how the local player's presentation layer holds current display state and smoothly interpolates toward the simulation layer's target state each frame.
## ADDED Requirements
### Requirement: Presentation layer holds current and target state
The local player's presentation layer SHALL maintain `_currentPosition` and `_currentRotation` (the actively displayed state) separately from `_targetPosition` and `_targetRotation` (the simulation layer's output).
#### Scenario: Presentation state initializes from first simulation target
- **WHEN** the presentation layer is initialized or first receives a simulation target
- **THEN** `_currentPosition` and `_currentRotation` are set equal to the initial target
- **THEN** subsequent updates lerp toward the target
### Requirement: Presentation layer lerps toward target each frame
The presentation layer SHALL each frame interpolate `_currentPosition` and `_currentRotation` toward `_targetPosition` and `_targetRotation` using linear interpolation, then apply the result to the Rigidbody.
#### Scenario: Lerp position and rotation toward target
- **WHEN** the presentation layer updates each frame with interpolation alpha α
- **THEN** `_currentPosition` is updated to `Vector3.Lerp(_currentPosition, _targetPosition, α)`
- **THEN** `_currentRotation` is updated to `Quaternion.Slerp(_currentRotation, _targetRotation, α)`
- **THEN** `_rigid.position` and `_rigid.rotation` are set to `_currentPosition` and `_currentRotation`
### Requirement: Presentation layer snaps when target error exceeds threshold
When the distance between `_currentPosition` and `_targetPosition` exceeds the snap threshold, the presentation layer SHALL immediately snap `_currentPosition` to `_targetPosition` without lerping.
#### Scenario: Snap when error exceeds threshold
- **WHEN** `Vector3.Distance(_currentPosition, _targetPosition) > SnapThreshold`
- **THEN** `_currentPosition` is set equal to `_targetPosition`
- **THEN** `_currentRotation` is set equal to `_targetRotation`
- **THEN** no lerping occurs in this frame
@@ -0,0 +1,46 @@
# local-player-simulation-state Specification
## Purpose
Define how the simulation layer maintains authoritative state, pending inputs, and computes the presentation target when receiving server PlayerState messages.
## ADDED Requirements
### Requirement: Simulation layer maintains authoritative baseline
The simulation layer SHALL maintain `_lastAuthoritativePosition`, `_lastAuthoritativeRotation`, and `_lastAcknowledgedTick` as the authoritative baseline. These are updated when the server acknowledges input through a PlayerState message.
#### Scenario: Authoritative baseline updates on PlayerState
- **WHEN** the client receives a PlayerState with tick T
- **THEN** `_lastAuthoritativePosition` is set to the PlayerState position
- **THEN** `_lastAuthoritativeRotation` is set to the PlayerState rotation
- **THEN** `_lastAcknowledgedTick` is set to T
### Requirement: Simulation layer maintains pending inputs
The simulation layer SHALL maintain a list of pending inputs that have been recorded locally but not yet acknowledged by the server.
#### Scenario: Pending inputs are pruned on acknowledgment
- **WHEN** the client receives a PlayerState with AcknowledgedMoveTick N
- **THEN** all pending inputs with tick <= N are removed from the pending list
- **THEN** remaining pending inputs (tick > N) are preserved for replay
### Requirement: Simulation layer computes presentation target on PlayerState
When receiving a server PlayerState, the simulation layer SHALL compute the presentation target by replaying unacknowledged pending inputs from the authoritative baseline, and update the `_presentationTarget`.
#### Scenario: Presentation target is computed after replay
- **WHEN** the client receives a PlayerState
- **THEN** all acknowledged inputs are pruned (tick <= AcknowledgedMoveTick)
- **THEN** remaining pending inputs are replayed starting from the authoritative position using 50ms fixed-step substeps
- **THEN** `_presentationTarget` is set to (authoritative position + replay displacement, authoritative rotation + replay rotation delta)
### Requirement: Simulation layer updates presentation target only on PlayerState
The simulation layer SHALL only update `_presentationTarget` when a new PlayerState is received. Between PlayerState messages, the presentation target remains constant.
#### Scenario: Presentation target is stable between PlayerState messages
- **WHEN** the client receives a PlayerState and computes `_presentationTarget`
- **AND** no further PlayerState is received in the following frames
- **THEN** `_presentationTarget` remains unchanged
- **THEN** the presentation layer continues lerping toward the same target
@@ -0,0 +1,27 @@
## 1. 准备阶段
- [x] 1.1 阅读 `openspec/specs/local-player-presentation-state/spec.md` 和 `openspec/specs/local-player-simulation-state/spec.md`
- [x] 1.2 阅读现有 `MovementComponent.cs` 和 `ControlledPlayerCorrection.cs`
## 2. 新增表现层状态
- [x] 2.1 添加 `_presentationPosition`、`_presentationRotation`、`_presentationTargetPosition`、`_presentationTargetRotation` 字段到 MovementComponent
- [x] 2.2 实现 `UpdatePresentation()` 方法:Lerp 或 snap 到 target,设置 rigid.position/rotation
## 3. 新增模拟层状态
- [x] 3.1 使用现有的 `_predictionBuffer` 和新增的 authoritative baseline 字段
- [x] 3.2 在 `Reconcile()` 中实现:prune inputs + replay + 计算 target
## 4. 重构 MovementComponent
- [x] 4.1 添加表现层和模拟层状态字段(不使用独立类型,直接在 MovementComponent 中)
- [x] 4.2 `OnAuthoritativeState()` 移除 `ClearPendingInputs()` 和 `_simulationAccumulator = 0f`(移到 Reconcile 后)
- [x] 4.3 `Update()` 中调用 `UpdatePresentation()`
- [x] 4.4 移除 `ControlledPlayerCorrection` 相关逻辑(bounded correction 被 Lerp 替代)
## 5. 验证
- [x] 5.1 编译验证(代码无语法错误)
- [ ] 5.2 关闭网络同步:移动平滑
- [ ] 5.3 开启网络同步:抖动消除或显著减少
@@ -8,7 +8,7 @@ Define the contract that client-side replay of pending movement inputs after aut
### Requirement: Replay uses fixed-step accumulation matching server cadence
The controlled-client prediction replay path SHALL consume each pending `PredictedMoveStep` by applying its input in fixed-duration substeps equal to the server authoritative movement cadence, regardless of the step's total `SimulatedDurationSeconds`. **Forward prediction accumulation SHALL also use the same server authoritative movement cadence as the unit of accumulation, ensuring forward accumulated duration and replay duration are derived from the same cadence constant.** The replay accumulation shape MUST be identical to the live `FixedUpdate` prediction path for the same input values.
The controlled-client prediction replay path SHALL consume each pending `PredictedMoveStep` by applying its input in fixed-duration substeps equal to the server authoritative movement cadence, regardless of the step's total `SimulatedDurationSeconds`. Forward prediction accumulation SHALL also use the same server authoritative movement cadence as the unit of accumulation, and replaying a step MUST NOT remove that step from the pending-input buffer unless the server has acknowledged its tick. The replay accumulation shape MUST be identical to the live `FixedUpdate` prediction path for the same input values.
#### Scenario: Replay produces same trajectory as live prediction for steady input
- **WHEN** the client replays a `PredictedMoveStep` with turn=0, throttle=1, duration=0.15s using a 0.05s server cadence
@@ -25,12 +25,23 @@ The controlled-client prediction replay path SHALL consume each pending `Predict
- **THEN** the replay applies 0.05s + 0.05s + 0.02s substeps sequentially
- **THEN** no remaining duration is lost or double-counted
#### Scenario: Replay preserves unacknowledged inputs for later authoritative rebuilds
- **WHEN** the client replays pending inputs after accepting an authoritative state that acknowledges ticks through `N`
- **THEN** only steps with tick less than or equal to `N` are removed from the pending-input buffer
- **THEN** replayed steps with tick greater than `N` remain available for later replay against a newer authoritative baseline
- **THEN** the pending-input buffer still exposes those unacknowledged steps after replay completes
### Requirement: Replay trajectory determinism is verifiable
The client prediction system SHALL provide a deterministic way to verify that replay and live prediction produce identical trajectories for a given input sequence, enabling regression coverage.
The client prediction system SHALL provide a deterministic way to verify that replay and live prediction produce identical trajectories for a given input sequence, enabling regression coverage. The verification path MUST also support repeated authoritative rebuilds while the same unacknowledged input sequence remains pending.
#### Scenario: Replay and live prediction produce identical results
- **WHEN** a controlled client records a `MoveInput` sequence during live play
- **AND** the client triggers reconciliation and replays those same inputs
- **THEN** the final predicted pose after replay equals the predicted pose that would result from live FixedUpdate simulation for the same input sequence
- **THEN** the result is stable across multiple replays of the same input sequence
#### Scenario: Repeated rebuilds remain deterministic while inputs stay unacknowledged
- **WHEN** the controlled client accepts multiple increasing authoritative snapshots while the same later input ticks remain unacknowledged
- **THEN** replaying the remaining unacknowledged input sequence against each accepted baseline produces deterministic predicted results for that baseline
- **THEN** the test harness can verify replay correctness without requiring those inputs to be consumed from the buffer
@@ -0,0 +1,46 @@
# local-player-reconciliation Specification
## Purpose
Define how the local (controlled) player reconciles client-side prediction with server authoritative state. This capability ensures that when the client receives a server `PlayerState`, it treats that snapshot as the latest gameplay baseline, rebuilds local prediction from still-unacknowledged inputs, and smooths visible presentation toward the rebuilt predicted pose.
## Requirements
### Requirement: Reconcile applies authoritative state and replays unconfirmed inputs in correct order
The controlled-client reconciliation path SHALL treat a newly accepted authoritative `PlayerState` as the latest gameplay baseline, prune only pending movement inputs whose tick is less than or equal to `AcknowledgedMoveTick`, replay all still-unacknowledged pending inputs from that authoritative baseline using fixed-step substeps matching the server authoritative movement cadence, and publish the replay result as the controlled player's latest predicted simulation pose. Presentation smoothing MUST consume that rebuilt predicted pose as a target without redefining the gameplay truth of the replay result.
#### Scenario: Authoritative acceptance rebuilds prediction from the latest baseline
- **WHEN** the controlled player accepts an authoritative `PlayerState` whose acknowledged movement-input tick is `N`
- **THEN** the reconciliation treats the received authoritative position and rotation as the new gameplay baseline
- **THEN** the reconciliation replays all pending inputs with tick greater than `N` using 50ms fixed-step substeps
- **THEN** the replay result becomes the controlled player's latest predicted simulation pose for that authoritative update
- **THEN** the presentation layer receives that predicted pose as its smoothing target
#### Scenario: Repeated authoritative updates rebuild prediction without consuming pending inputs
- **WHEN** the controlled player accepts two increasing authoritative `PlayerState` snapshots before all pending inputs have been acknowledged
- **THEN** each accepted snapshot rebuilds the predicted simulation pose from its own authoritative baseline
- **THEN** only inputs acknowledged by the newer snapshot are pruned from the pending-input buffer
- **THEN** still-unacknowledged inputs remain available for replay against later authoritative snapshots
#### Scenario: Visible smoothing does not redefine rebuilt gameplay truth
- **WHEN** the controlled player has a rebuilt predicted simulation pose and a visible presentation pose that has not yet converged
- **THEN** gameplay logic continues to treat the rebuilt predicted pose as the latest client prediction truth
- **THEN** the visible pose may temporarily differ while smoothing converges
- **THEN** a large divergence may still hard-snap the visible pose directly to the rebuilt predicted pose
### Requirement: Bounded correction handles residual error after replay
The controlled-client reconciliation SHALL compare the controlled player's visible presentation pose against the rebuilt predicted simulation pose after replay, interpolate the visible pose toward that predicted pose for small residual error, and snap the visible pose directly to the predicted pose when divergence exceeds the configured snap threshold.
#### Scenario: Small residual error uses presentation interpolation
- **WHEN** the controlled player completes replay and the remaining distance between visible pose and rebuilt predicted pose is within the configured snap threshold
- **THEN** the client keeps the rebuilt predicted pose as presentation target
- **THEN** the visible pose converges toward that target through presentation smoothing across later frames
- **THEN** the replayed predicted pose remains unchanged as gameplay truth during that convergence
#### Scenario: Large divergence snaps visible pose to rebuilt predicted pose
- **WHEN** the controlled player completes replay and the remaining distance between visible pose and rebuilt predicted pose exceeds the configured snap threshold
- **THEN** the client snaps the visible position and rotation directly to the rebuilt predicted pose
- **THEN** any previous presentation-only smoothing state is cleared or replaced
- **THEN** later local prediction continues from the rebuilt predicted baseline