[Unity Dedicated Server] #9 - LagCompensation

qweasfjbv·2026년 5월 19일

UnityServer

목록 보기
14/14

개요


Client-Side Prediction 은 여러 장점을 가집니다.

  • 입력 즉시 클라이언트에 반영
  • 서버 상태를 받아와서 서버 권위적 모델 구현 가능

하지만, 해당 두 특징 때문에 한 가지 문제가 생깁니다.

현재 Tick 이 100 이라고 했을 때,

제가 조종하는 플레이어는 100 Tick 의 상태이지만,
제가 조종하지 않는 플레이어들은 지연시간만큼 느리게 업데이트 됩니다. ( ex. 96 Tick )

따라서 정확히 적을 조준하고 쐈다고 해도 적은 이미 과거의 잔상이기 때문에, 같은 틱에서 비교를 하면 맞출 수 없게 됩니다.

이럴 때에는, Lag Compensation 기법을 사용하여 "정말 맞췄는지" 를 계산해야 합니다.

서버에서 발사 이벤트가 100 Tick에 발생되었다면,
나를 제외한 모든 플레이어를 96 Tick의 상태로 되돌려서
실제로 "맞췄는지" 를 판단하고
다시 100 Tick 으로 상태를 복원해서 게임을 진행하면 됩니다.

구현


우선 클라이언트 측부터 구현해보도록 하겠습니다.

Client

private void HandleTestFireFX(in FireResult result)
{
	if (!result.isFired) return;

	RaycastHit[] hits = Physics.RaycastAll(
					result.origin,
					result.direction,
					60f,
					LayerMask.GetMask("Player", "Wall"),
					QueryTriggerInteraction.Collide
					);

	System.Array.Sort(hits, (a, b) =>
	a.distance.CompareTo(b.distance));

	RaycastHit hit;
	foreach (var h in hits)
	{
		var target =
			h.collider.GetComponentInParent<PlayerController>();

		if (target == null)
			continue;

		if (target.gameObject == this.gameObject)
			continue;

		Debug.Log("Client Hit! : " + target.transform.position + ", " + result.origin + ", " + result.direction);
		hit = h;
	}

}

Fire 이후 클라이언트 측에서 실행되는 Test용 Raycast 이벤트입니다.

클라이언트와 서버에서의 이벤트가 동일하게 실행되어야하기 때문에, 테스트용으로 작성하였습니다.

[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct PlayerInput
{
	public Vector3 muzzleDir;				// Featured
	public Vector3 muzzlePos;				// Featured
	public Vector2 move;
	public Vector2 lookDir;
	public int tick;
	public int recentlyReceivedTick;		// Featured
	public bool isJump;
	public bool isCrouch;
	public bool isFired;
	public bool isReload;
}

PlayerInput 구조체에 서버에 필요한 정보들을 몇 개 더 추가합니다.

특히 recentlyReceivedTick 는 최근 받았던 Server Snapshot의 Tick으로, 다른 플레이어들의 Tick 시간을 나타냅니다.
( 위 예시에서 96 Tick )

Server

public bool LagCompensationRaycast(
	int rewindTick,
	Vector3 origin,
	Vector3 direction,
	float distance,
	int shooterLocalId,
	out RaycastHit hit)
{
	// Current State Backup
	Dictionary<int, Vector3> backupPositions = new();

	// Rewind
	foreach (var kv in playerObjects)
	{
		if (kv.Key == shooterLocalId)
			continue;

		if (kv.Value.GetComponent<PlayerController>()
			.TryGetHistoricalState(rewindTick, out PlayerState pastState))
		{
			backupPositions[kv.Key] = kv.Value.transform.position;
			kv.Value.transform.position = pastState.position;

			Debug.Log("REWINEDED : " + pastState.position);
		}
	}

	Debug.Log("Shoot Info : " + origin + ", " + direction);
	Physics.SyncTransforms();

	RaycastHit[] hits = Physics.RaycastAll(
		origin,
		direction,
		distance,
		LayerMask.GetMask("Player", "Wall"),
		QueryTriggerInteraction.Collide
		);

	// Restore
	foreach (var kv in backupPositions)
	{
		playerObjects[kv.Key].transform.position = kv.Value;
	}
	Physics.SyncTransforms();

	System.Array.Sort(hits, (a, b) =>
	a.distance.CompareTo(b.distance));

	foreach (var h in hits)
	{
		var target =
			h.collider.GetComponentInParent<PlayerController>();

		if (target == null)
			continue;

		if (target.gameObject == playerObjects[shooterLocalId])
			continue;

		hit = h;
		return true;
	}

	hit = default;
	return false;
}

이제 서버에서 LagCompensation 함수를 실행할 수 있습니다.

순서는 다음과 같습니다.

  1. 플레이어들의 상태를 Backup
  2. recentlyReceivedTick 상태로 전부 Rewind
  3. Fire Event 처리
  4. Backup 해뒀던 상태로 Restore
  5. 충돌 결과 반환

결과를 확인해보겠습니다.

위쪽 로그가 서버 측, 아래쪽 로그가 클라이언트 측 로그입니다.

실제로 클라이언트에서 탐지한 적의 위치와 서버에서 Rewind 한 적의 위치가 같은 것을 확인하실 수 있습니다.

또한 클라이언트와 서버의 Fire 이벤트가 동일하게 (총 2번) 일어난걸 보아 정상적으로 Lag Compensation 이 작동하는 것을 확인할 수 있습니다.

마무리


글만 보면 정말 간단해 보일 수 있지만 생각보다 구현하는데 시간이 오래 걸렸습니다.

구현하면서 에러가 발생헀던 부분들은 다음과 같습니다.

  • Client, Server 간 Tick 동기화
  • Transform -> Physics 씬에 반영
  • animation에 의한 muzzle position/direction 차이 (서버는 anim 실행 X)

기존에는 NGO (Netcode for GameObejcts) 의 RPC, Spawn 과 같은 함수를 사용하다보니, 플레이어를 스폰하고 동기화하는데에도 시간이 오래 걸렸습니다.

우선 코드를 정리하고 다음 기능을 마저 구현해보도록 하겠습니다.

0개의 댓글