P2P Networked Physics Inaccuracy. Seeking Help

Here is the refactored code

PhsicsSynchronizer Code
class_name PhysicsSynchronizer
extends MultiplayerSynchronizer

@onready var sync_object : PhysicsBody3D = get_node(root_path)
@onready var body_state : PhysicsDirectBodyState3D = \
	PhysicsServer3D.body_get_direct_state( sync_object.get_rid() )
@export var sync_pos   : Vector3
@export var sync_lvel  : Vector3
@export var sync_avel  : Vector3
@export var sync_quat  : Quaternion
@export var sync_frame : int = 0

var ring_buffer:RingBuffer = RingBuffer.new()

var last_frame = -1
var set_num = 0

enum {
	ORIGIN,
	LIN_VEL,
	ANG_VEL,
	QUAT, # the quaternion is used for an optimized rotation state
}


#func _ready():
	#synchronized.connect(_on_synchronized)

func _exit_tree():
	ring_buffer.free()

#copy state to array
func get_state(state : PhysicsDirectBodyState3D ):
	sync_pos = state.transform.origin
	sync_quat = state.transform.basis.get_rotation_quaternion()
	sync_lvel = state.linear_velocity
	sync_avel = state.angular_velocity


#copy array to state
func set_state(state : PhysicsDirectBodyState3D, data:Array ):
	state.transform.origin = data[ORIGIN]
	state.linear_velocity = data[LIN_VEL]
	state.angular_velocity = data[ANG_VEL]
	state.transform.basis = Basis(data[QUAT])


func get_physics_body_info():
	# server copy for sync
	get_state( body_state )


func set_physics_body_info():
	# client rpc set from server
	var data :Array = ring_buffer.remove()
	while data.is_empty():
		return
	set_state( body_state, data )


func _physics_process(_delta):
	if is_multiplayer_authority():
		sync_frame += 1
		get_physics_body_info()
	else:
		set_physics_body_info()


# make sure to wire the "synchronized" signal to this function
func _on_synchronized():
	if is_previouse_frame():
		return
	ring_buffer.add([
		sync_pos,
		sync_lvel,
		sync_avel,
		sync_quat,
	])


func is_previouse_frame() -> bool:
	if sync_frame <= last_frame:
		print("previous frame %d %d" % [sync_frame, last_frame] )
		return true
	else:
		last_frame = sync_frame
		return false

class RingBuffer extends Object:
	const SAFETY:int = 1
	const CAPACITY:int = 4 + SAFETY
	var buf:Array[Array]
	var head:int = 0
	var tail:int = 0

	func _init():
		buf.resize(CAPACITY)

	func add(frame:Array):
		if _increment(head) == tail: # full
			_comsume_extra()
		if is_low():
			_produce_extra(frame)
		buf[head]=frame
		head = _increment(head)

	func _comsume_extra():
		#print( "RingBuffer: consume_extra")
		var next_index = _increment(tail)
		buf[next_index] = _interpolate(buf[tail], buf[next_index],0.5)
		tail = next_index

	func _produce_extra(frame:Array):
		#print("RingBuffer: produce_extra")
		var first_frame = _interpolate(buf[tail],frame, 0.33) # assume only one frame exists tail should point at it
		var second_frame = _interpolate(buf[tail],frame, 0.66) # assume only one frame exists tail should point at it
		buf[head]=first_frame
		head = _increment(head)
		buf[head] = second_frame
		head = _increment(head)

	func _interpolate(from:Array, to:Array, percentage:float) -> Array:
		var frame:Array = [
			from[ORIGIN].lerp(to[ORIGIN], percentage),
			from[LIN_VEL].lerp(to[LIN_VEL], percentage),
			from[ANG_VEL].lerp(to[ANG_VEL], percentage),
			from[QUAT].slerp(to[QUAT], percentage)
		]
		return frame

	func _increment(index:int)->int:
		index += 1
		if index == CAPACITY: # avoid modulus
			index = 0
		return index

	func remove() -> Array:
		var frame : Array = buf[tail]
		if is_empty() or is_low():
			frame = []
		else:
			tail = _increment(tail)
		return frame

	func is_empty() -> bool:
		return tail == head

	func is_low() -> bool:
		return _increment(tail) == head

Make sure to connect synchonized signal to _on_synchronized
and add the following props to the replication config.

It removes “the correct_error” function that was never great to begin with and adds a ring buffer to manage frames from the server.
it can be tweeked but it is recommended that it stay at 4+1 at a minimum. This is because I tried to make it lock-free by keeping a space between the head and the tail. (this was never tested)

some ring buffer features:

  • if the server packets are coming in too fast we will consume the frames faster to catch up, by interpolating two frames at the tail and dropping the most oldest frame.
  • if the server is going to slow we will “stretch” two known frames into 4. if we only have one frame in buffer and a new frame coming in, we interpolate two extra frames between the tail and the newest frame. one at 33% and one 66%, with the newest frame being at 100%. (this should be tweaked depending on the buffer size)

this is all to maintain a healthy amount of frames without falling behind or getting ahead, but could be changed based on needs.

I profiled the change and could not detect a meaningful change +/- < 1% .

There are still some jump occurances now and then but overall it is better.

1 Like