Improve multiplayer movement reconciliation

This commit is contained in:
Alexander Sellite 2026-08-23 23:04:14 -04:00
parent 348ae261e1
commit a3aea98982
3 changed files with 240 additions and 89 deletions

View file

@ -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
)
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
)
_local_reconciliation_camera_offset = (
_camera_yaw.position - base_camera_local_position
)
if correction_distance <= LOCAL_PREDICTION_SNAP_DISTANCE:
_visuals.global_position = previous_visual_position
_local_reconciliation_visual_offset = (
_visuals.position - base_visual_local_position
)
else:
_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()
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
_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(