Improve multiplayer movement reconciliation
This commit is contained in:
parent
348ae261e1
commit
a3aea98982
3 changed files with 240 additions and 89 deletions
|
|
@ -13,6 +13,7 @@ const ENET_TIMEOUT_MAXIMUM_MS: int = 120000
|
|||
const INPUT_INTERVAL: float = 1.0 / 30.0
|
||||
const IDLE_INPUT_INTERVAL: float = 1.0 / 5.0
|
||||
const SNAPSHOT_INTERVAL: float = 1.0 / 30.0
|
||||
const OWNER_SNAPSHOT_DIVISOR: int = 3
|
||||
const NEAR_REMOTE_SNAPSHOT_DIVISOR: int = 2
|
||||
const FAR_REMOTE_SNAPSHOT_DIVISOR: int = 6
|
||||
const DISTANT_REMOTE_SNAPSHOT_DIVISOR: int = 8
|
||||
|
|
@ -122,6 +123,7 @@ var _last_input_state_hash: int = 0
|
|||
var _pending_movement_inputs: Array[Dictionary] = []
|
||||
var _snapshot_accumulator: float = 0.0
|
||||
var _movement_snapshot_tick: int = 0
|
||||
var _last_local_snapshot_received_msec: int = 0
|
||||
var _animation_refresh_accumulator: float = 0.0
|
||||
var _last_animation_state_by_peer: Dictionary[int, Dictionary] = {}
|
||||
var _pending_animation_state_by_peer: Dictionary[int, Dictionary] = {}
|
||||
|
|
@ -2248,12 +2250,15 @@ func _broadcast_movement_snapshots() -> void:
|
|||
var subject_avatar: Player = _spawn_service.get_avatar(subject_id)
|
||||
if subject_avatar == null:
|
||||
continue
|
||||
if (
|
||||
subject_id != recipient_id
|
||||
and not _should_send_remote_snapshot(
|
||||
if subject_id == recipient_id:
|
||||
# The owner already simulates locally. Its authoritative state is
|
||||
# an audit and acknowledgement, not a presentation stream, so it
|
||||
# does not need the full 30 Hz observer snapshot rate.
|
||||
if _movement_snapshot_tick % OWNER_SNAPSHOT_DIVISOR != 0:
|
||||
continue
|
||||
elif not _should_send_remote_snapshot(
|
||||
recipient_avatar.global_position,
|
||||
subject_avatar.global_position,
|
||||
)
|
||||
):
|
||||
continue
|
||||
var encoded: Array = _encode_movement_snapshot(
|
||||
|
|
@ -2538,8 +2543,10 @@ func receive_movement_snapshots(encoded_snapshots: Array) -> void:
|
|||
)
|
||||
avatar.apply_local_prediction_correction(
|
||||
snapshot,
|
||||
_pending_movement_inputs,
|
||||
_input_sequence,
|
||||
INPUT_INTERVAL,
|
||||
estimated_transit_seconds,
|
||||
_local_snapshot_delta_seconds(),
|
||||
)
|
||||
else:
|
||||
avatar.push_network_snapshot(
|
||||
|
|
@ -2557,6 +2564,20 @@ func _discard_acknowledged_movement_inputs(acknowledged_sequence: int) -> void:
|
|||
_pending_movement_inputs.pop_front()
|
||||
|
||||
|
||||
func _local_snapshot_delta_seconds() -> float:
|
||||
var now_msec: int = Time.get_ticks_msec()
|
||||
if _last_local_snapshot_received_msec <= 0:
|
||||
_last_local_snapshot_received_msec = now_msec
|
||||
return 0.0
|
||||
var elapsed_seconds: float = clampf(
|
||||
float(now_msec - _last_local_snapshot_received_msec) / 1000.0,
|
||||
0.0,
|
||||
Player.LOCAL_PREDICTION_MAX_AUDIT_DELTA_SECONDS,
|
||||
)
|
||||
_last_local_snapshot_received_msec = now_msec
|
||||
return elapsed_seconds
|
||||
|
||||
|
||||
@rpc(
|
||||
"authority",
|
||||
"call_remote",
|
||||
|
|
@ -2861,6 +2882,7 @@ func _teardown_peer() -> void:
|
|||
_pending_movement_inputs.clear()
|
||||
_snapshot_accumulator = 0.0
|
||||
_movement_snapshot_tick = 0
|
||||
_last_local_snapshot_received_msec = 0
|
||||
_animation_refresh_accumulator = 0.0
|
||||
_last_animation_state_by_peer.clear()
|
||||
_pending_animation_state_by_peer.clear()
|
||||
|
|
|
|||
216
player/player.gd
216
player/player.gd
|
|
@ -145,9 +145,14 @@ const NETWORK_MOVEMENT_HISTORY_SECONDS: float = 1.25
|
|||
const NETWORK_MAX_LAG_COMPENSATION_SECONDS: float = 0.75
|
||||
const LOCAL_PREDICTION_EXTRAPOLATION_LIMIT_SECONDS: float = 0.25
|
||||
const LOCAL_PREDICTION_FALLBACK_TRANSIT_RATIO: float = 0.5
|
||||
const LOCAL_PREDICTION_CORRECTION_THRESHOLD: float = 0.12
|
||||
const LOCAL_PREDICTION_SNAP_DISTANCE: float = 2.0
|
||||
const LOCAL_PREDICTION_CORRECTION_WEIGHT: float = 0.18
|
||||
const LOCAL_PREDICTION_CORRECTION_THRESHOLD: float = 1.5
|
||||
const LOCAL_PREDICTION_SNAP_DISTANCE: float = 6.0
|
||||
const LOCAL_PREDICTION_CORRECTION_DELAY_SECONDS: float = 0.35
|
||||
const LOCAL_PREDICTION_MIN_CORRECTION_AUDITS: int = 3
|
||||
const LOCAL_PREDICTION_SOFT_CORRECTION_RATE: float = 2.5
|
||||
const LOCAL_PREDICTION_MAX_SOFT_CORRECTION_STEP: float = 0.15
|
||||
const LOCAL_PREDICTION_MAX_AUDIT_DELTA_SECONDS: float = 0.15
|
||||
const LOCAL_RECONCILIATION_PRESENTATION_RECENTER_RATE: float = 5.0
|
||||
# The target Android handheld exposes its physical right trigger through
|
||||
# Godot's left-trigger axis. Keep the role named here so the platform mapping
|
||||
# remains isolated from camera behavior.
|
||||
|
|
@ -529,6 +534,13 @@ var _network_snapshot_age: float = 0.0
|
|||
var _network_snapshot_jitter: float = 0.0
|
||||
var _network_simulation_only: bool = false
|
||||
var _local_reconciliation_visual_offset: Vector3 = Vector3.ZERO
|
||||
var _local_reconciliation_camera_offset: Vector3 = Vector3.ZERO
|
||||
var _local_prediction_error_seconds: float = 0.0
|
||||
var _local_prediction_error_audits: int = 0
|
||||
var _local_prediction_error_direction: Vector3 = Vector3.ZERO
|
||||
var _local_prediction_soft_corrections: int = 0
|
||||
var _local_prediction_hard_corrections: int = 0
|
||||
var _local_prediction_largest_error: float = 0.0
|
||||
var _authoritative_movement_history: Array[Dictionary] = []
|
||||
var _local_network_jump_intent_pending: bool = false
|
||||
var _local_network_jump_intent_sequence: int = -1
|
||||
|
|
@ -1995,6 +2007,11 @@ func reset_network_movement_state() -> void:
|
|||
_network_input_stale_timeout_seconds = NETWORK_INPUT_STALE_TIMEOUT_SECONDS
|
||||
_network_jump_intent_active = false
|
||||
_last_network_input_sequence = 0
|
||||
_reset_local_prediction_error()
|
||||
_clear_local_reconciliation_offsets()
|
||||
_local_prediction_soft_corrections = 0
|
||||
_local_prediction_hard_corrections = 0
|
||||
_local_prediction_largest_error = 0.0
|
||||
|
||||
|
||||
func capture_network_input(sequence: int) -> Dictionary:
|
||||
|
|
@ -2235,8 +2252,10 @@ func push_network_snapshot(
|
|||
|
||||
func apply_local_prediction_correction(
|
||||
snapshot: Dictionary,
|
||||
pending_inputs: Array[Dictionary] = [],
|
||||
latest_input_sequence: int = 0,
|
||||
input_interval_seconds: float = 0.0,
|
||||
estimated_transit_seconds: float = -1.0,
|
||||
audit_delta_seconds: float = 0.0,
|
||||
) -> void:
|
||||
var parsed: Dictionary = _parse_network_snapshot(snapshot)
|
||||
if parsed.is_empty():
|
||||
|
|
@ -2258,92 +2277,143 @@ func apply_local_prediction_correction(
|
|||
_clear_local_network_jump_intent()
|
||||
if not _sitting_intent_pending:
|
||||
_set_sitting(bool(parsed["sitting"]))
|
||||
var authoritative_position: Vector3 = parsed["position"]
|
||||
var transit_seconds: float = resolve_local_prediction_transit_seconds(
|
||||
acknowledged_input,
|
||||
latest_input_sequence,
|
||||
input_interval_seconds,
|
||||
estimated_transit_seconds,
|
||||
)
|
||||
if transit_seconds > 0.0:
|
||||
authoritative_position += (
|
||||
(parsed["velocity"] as Vector3) * transit_seconds
|
||||
)
|
||||
var error_offset: Vector3 = authoritative_position - global_position
|
||||
var error_distance: float = error_offset.length()
|
||||
_local_prediction_largest_error = maxf(
|
||||
_local_prediction_largest_error,
|
||||
error_distance,
|
||||
)
|
||||
if error_distance <= LOCAL_PREDICTION_CORRECTION_THRESHOLD:
|
||||
_reset_local_prediction_error()
|
||||
return
|
||||
if error_distance >= LOCAL_PREDICTION_SNAP_DISTANCE:
|
||||
_clear_local_reconciliation_offsets()
|
||||
global_position = authoritative_position
|
||||
velocity = parsed["velocity"]
|
||||
_local_prediction_hard_corrections += 1
|
||||
_reset_local_prediction_error()
|
||||
return
|
||||
var error_direction: Vector3 = error_offset.normalized()
|
||||
if (
|
||||
not _local_prediction_error_direction.is_zero_approx()
|
||||
and _local_prediction_error_direction.dot(error_direction) < 0.5
|
||||
):
|
||||
_reset_local_prediction_error()
|
||||
_local_prediction_error_direction = error_direction
|
||||
_local_prediction_error_audits += 1
|
||||
_local_prediction_error_seconds += clampf(
|
||||
audit_delta_seconds,
|
||||
0.0,
|
||||
LOCAL_PREDICTION_MAX_AUDIT_DELTA_SECONDS,
|
||||
)
|
||||
if (
|
||||
_local_prediction_error_audits
|
||||
< LOCAL_PREDICTION_MIN_CORRECTION_AUDITS
|
||||
or _local_prediction_error_seconds
|
||||
< LOCAL_PREDICTION_CORRECTION_DELAY_SECONDS
|
||||
):
|
||||
return
|
||||
var correction_weight: float = 1.0 - exp(
|
||||
-LOCAL_PREDICTION_SOFT_CORRECTION_RATE
|
||||
* clampf(
|
||||
audit_delta_seconds,
|
||||
0.0,
|
||||
LOCAL_PREDICTION_MAX_AUDIT_DELTA_SECONDS,
|
||||
)
|
||||
)
|
||||
var correction: Vector3 = error_offset * correction_weight
|
||||
if correction.length() > LOCAL_PREDICTION_MAX_SOFT_CORRECTION_STEP:
|
||||
correction = (
|
||||
correction.normalized()
|
||||
* LOCAL_PREDICTION_MAX_SOFT_CORRECTION_STEP
|
||||
)
|
||||
_apply_camera_safe_local_correction(correction)
|
||||
_local_prediction_soft_corrections += 1
|
||||
|
||||
|
||||
func _apply_camera_safe_local_correction(correction: Vector3) -> void:
|
||||
if correction.is_zero_approx():
|
||||
return
|
||||
var previous_visual_position: Vector3 = _visuals.global_position
|
||||
var previous_camera_position: Vector3 = _camera_yaw.global_position
|
||||
var base_visual_local_position: Vector3 = (
|
||||
_visuals.position - _local_reconciliation_visual_offset
|
||||
)
|
||||
var previous_position: Vector3 = global_position
|
||||
global_position = parsed["position"]
|
||||
velocity = parsed["velocity"]
|
||||
if input_interval_seconds > 0.0:
|
||||
for input: Dictionary in pending_inputs:
|
||||
_replay_network_movement_input(input, input_interval_seconds)
|
||||
var correction_distance: float = previous_position.distance_to(
|
||||
global_position
|
||||
var base_camera_local_position: Vector3 = (
|
||||
_camera_yaw.position - _local_reconciliation_camera_offset
|
||||
)
|
||||
if correction_distance <= LOCAL_PREDICTION_SNAP_DISTANCE:
|
||||
global_position += correction
|
||||
_visuals.global_position = previous_visual_position
|
||||
_camera_yaw.global_position = previous_camera_position
|
||||
_local_reconciliation_visual_offset = (
|
||||
_visuals.position - base_visual_local_position
|
||||
)
|
||||
else:
|
||||
_local_reconciliation_camera_offset = (
|
||||
_camera_yaw.position - base_camera_local_position
|
||||
)
|
||||
|
||||
|
||||
func _reset_local_prediction_error() -> void:
|
||||
_local_prediction_error_seconds = 0.0
|
||||
_local_prediction_error_audits = 0
|
||||
_local_prediction_error_direction = Vector3.ZERO
|
||||
|
||||
|
||||
func _clear_local_reconciliation_offsets() -> void:
|
||||
if not _local_reconciliation_visual_offset.is_zero_approx():
|
||||
_visuals.position -= _local_reconciliation_visual_offset
|
||||
if not _local_reconciliation_camera_offset.is_zero_approx():
|
||||
_camera_yaw.position -= _local_reconciliation_camera_offset
|
||||
_local_reconciliation_visual_offset = Vector3.ZERO
|
||||
|
||||
|
||||
func _replay_network_movement_input(
|
||||
data: Dictionary,
|
||||
delta: float,
|
||||
) -> void:
|
||||
var axis_value: Variant = data.get("axis", [])
|
||||
if typeof(axis_value) != TYPE_ARRAY or axis_value.size() != 2:
|
||||
return
|
||||
if bool(data.get("sitting", false)) or _water_recovery_active:
|
||||
velocity = Vector3.ZERO
|
||||
return
|
||||
var input_vector := Vector2(
|
||||
float(axis_value[0]),
|
||||
float(axis_value[1]),
|
||||
).limit_length(1.0)
|
||||
var camera_basis := Basis(
|
||||
Vector3.UP,
|
||||
float(data.get("camera_yaw", 0.0)),
|
||||
)
|
||||
var move_direction: Vector3 = (
|
||||
camera_basis.x * input_vector.x
|
||||
+ camera_basis.z * input_vector.y
|
||||
)
|
||||
move_direction.y = 0.0
|
||||
move_direction = move_direction.normalized()
|
||||
_network_sprint = bool(data.get("sprint", false))
|
||||
_network_sneak = bool(data.get("sneak", false))
|
||||
_network_slow_walk = bool(data.get("slow_walk", false))
|
||||
# Replay the speed authored by this exact pending input. Consulting the
|
||||
# current InputMap here would make an older walk replay as a sprint (or the
|
||||
# reverse) whenever the local button changed while a snapshot was in flight.
|
||||
var replay_speed: float = walk_speed
|
||||
if _network_sneak:
|
||||
replay_speed = sneak_speed
|
||||
elif _network_slow_walk:
|
||||
replay_speed = slow_walk_speed
|
||||
elif _network_sprint:
|
||||
replay_speed = sprint_speed
|
||||
if item_effects != null:
|
||||
replay_speed *= item_effects.get_movement_multiplier()
|
||||
var input_strength: float = minf(input_vector.length(), 1.0)
|
||||
velocity.x = move_direction.x * replay_speed * input_strength
|
||||
velocity.z = move_direction.z * replay_speed * input_strength
|
||||
if not is_on_floor():
|
||||
var gravity_multiplier: float = (
|
||||
upward_gravity_multiplier
|
||||
if velocity.y > 0.0
|
||||
else fall_gravity_multiplier
|
||||
)
|
||||
velocity.y -= _gravity * gravity_multiplier * delta
|
||||
elif bool(data.get("jump", false)):
|
||||
velocity.y = jump_velocity
|
||||
move_and_slide()
|
||||
_local_reconciliation_camera_offset = Vector3.ZERO
|
||||
|
||||
|
||||
func _update_local_reconciliation_visuals(delta: float) -> void:
|
||||
if _local_reconciliation_visual_offset.is_zero_approx():
|
||||
if (
|
||||
_local_reconciliation_visual_offset.is_zero_approx()
|
||||
and _local_reconciliation_camera_offset.is_zero_approx()
|
||||
):
|
||||
_local_reconciliation_visual_offset = Vector3.ZERO
|
||||
_local_reconciliation_camera_offset = Vector3.ZERO
|
||||
return
|
||||
var retained_ratio: float = exp(-14.0 * delta)
|
||||
var retained_offset: Vector3 = (
|
||||
var retained_ratio: float = exp(
|
||||
-LOCAL_RECONCILIATION_PRESENTATION_RECENTER_RATE * delta
|
||||
)
|
||||
var retained_visual_offset: Vector3 = (
|
||||
_local_reconciliation_visual_offset * retained_ratio
|
||||
)
|
||||
_visuals.position += retained_offset - _local_reconciliation_visual_offset
|
||||
_local_reconciliation_visual_offset = retained_offset
|
||||
var retained_camera_offset: Vector3 = (
|
||||
_local_reconciliation_camera_offset * retained_ratio
|
||||
)
|
||||
_visuals.position += (
|
||||
retained_visual_offset - _local_reconciliation_visual_offset
|
||||
)
|
||||
_camera_yaw.position += (
|
||||
retained_camera_offset - _local_reconciliation_camera_offset
|
||||
)
|
||||
_local_reconciliation_visual_offset = retained_visual_offset
|
||||
_local_reconciliation_camera_offset = retained_camera_offset
|
||||
|
||||
|
||||
func get_local_prediction_metrics() -> Dictionary:
|
||||
return {
|
||||
"soft_corrections": _local_prediction_soft_corrections,
|
||||
"hard_corrections": _local_prediction_hard_corrections,
|
||||
"largest_error": _local_prediction_largest_error,
|
||||
"out_of_bounds_audits": _local_prediction_error_audits,
|
||||
"out_of_bounds_seconds": _local_prediction_error_seconds,
|
||||
}
|
||||
|
||||
|
||||
static func resolve_network_input_stale_timeout_seconds(
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ func _validate_compact_snapshot_encoding() -> void:
|
|||
1,
|
||||
)
|
||||
assert(NetworkSession.MOVEMENT_SNAPSHOT_BATCH_SIZE == 8)
|
||||
assert(NetworkSession.OWNER_SNAPSHOT_DIVISOR == 3)
|
||||
var encoded_snapshots: Array = []
|
||||
for _peer: int in NetworkSession.MOVEMENT_SNAPSHOT_BATCH_SIZE:
|
||||
encoded_snapshots.append(
|
||||
|
|
@ -283,16 +284,72 @@ func _validate_remote_snapshot_smoothing(avatar: Player) -> void:
|
|||
|
||||
avatar.set_local_control(true)
|
||||
avatar.global_position = Vector3(0.8, 0.0, 0.0)
|
||||
var replay_input: Dictionary = _movement_input(2, false)
|
||||
replay_input["axis"] = [1.0, 0.0]
|
||||
var pending_inputs: Array[Dictionary] = [replay_input]
|
||||
avatar.apply_local_prediction_correction(
|
||||
moving_snapshot,
|
||||
pending_inputs,
|
||||
2,
|
||||
1.0 / 30.0,
|
||||
0.1,
|
||||
0.1,
|
||||
)
|
||||
# Ordinary host/client disagreement is expected while a packet is in flight.
|
||||
# It must never tug the locally controlled body or camera around.
|
||||
assert(is_equal_approx(avatar.global_position.x, 0.8))
|
||||
assert(
|
||||
int(avatar.get("_local_prediction_soft_corrections")) == 0
|
||||
)
|
||||
assert(
|
||||
int(avatar.get("_local_prediction_hard_corrections")) == 0
|
||||
)
|
||||
|
||||
# A larger but still plausible mismatch must persist across multiple audits
|
||||
# before a small correction is allowed. Preserve both visible character and
|
||||
# camera positions while the collision body catches up.
|
||||
avatar.global_position = Vector3(3.0, 0.0, 0.0)
|
||||
var visual_position: Vector3 = avatar.get_node("Visuals").global_position
|
||||
var camera_position: Vector3 = avatar.get_node("CameraYaw").global_position
|
||||
var drift_snapshot: Dictionary = _network_snapshot(
|
||||
Vector3.ZERO,
|
||||
Vector3.ZERO,
|
||||
20,
|
||||
)
|
||||
for _audit: int in 3:
|
||||
avatar.apply_local_prediction_correction(
|
||||
drift_snapshot,
|
||||
20,
|
||||
1.0 / 30.0,
|
||||
0.0,
|
||||
0.1,
|
||||
)
|
||||
assert(is_equal_approx(avatar.global_position.x, 3.0))
|
||||
avatar.apply_local_prediction_correction(
|
||||
drift_snapshot,
|
||||
20,
|
||||
1.0 / 30.0,
|
||||
0.0,
|
||||
0.1,
|
||||
)
|
||||
assert(avatar.global_position.x < 3.0)
|
||||
assert(avatar.global_position.x >= 2.85 - 0.001)
|
||||
assert(avatar.get_node("Visuals").global_position == visual_position)
|
||||
assert(avatar.get_node("CameraYaw").global_position == camera_position)
|
||||
assert(
|
||||
int(avatar.get("_local_prediction_soft_corrections")) == 1
|
||||
)
|
||||
|
||||
# Genuine divergence still recovers immediately, as do the separate reliable
|
||||
# teleport and water-recovery paths used by gameplay transitions.
|
||||
avatar.global_position = Vector3(10.0, 0.0, 0.0)
|
||||
avatar.apply_local_prediction_correction(
|
||||
drift_snapshot,
|
||||
20,
|
||||
1.0 / 30.0,
|
||||
0.0,
|
||||
0.1,
|
||||
)
|
||||
assert(avatar.global_position == Vector3.ZERO)
|
||||
assert(
|
||||
int(avatar.get("_local_prediction_hard_corrections")) == 1
|
||||
)
|
||||
assert(avatar.global_position.x < 0.8)
|
||||
assert(avatar.global_position.x > 0.0)
|
||||
|
||||
|
||||
func _validate_reliable_jump_intent(avatar: Player) -> void:
|
||||
|
|
@ -305,8 +362,10 @@ func _validate_reliable_jump_intent(avatar: Player) -> void:
|
|||
assert(int(avatar.get("_local_network_jump_intent_sequence")) == 20)
|
||||
avatar.apply_local_prediction_correction(
|
||||
_network_snapshot(avatar.global_position, Vector3.ZERO, 20),
|
||||
[],
|
||||
20,
|
||||
1.0 / 30.0,
|
||||
0.0,
|
||||
0.1,
|
||||
)
|
||||
assert(not bool(avatar.capture_network_input(22)["jump"]))
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue