Client-Side Prediction 은 여러 장점을 가집니다.
하지만, 해당 두 특징 때문에 한 가지 문제가 생깁니다.
현재 Tick 이 100 이라고 했을 때,
제가 조종하는 플레이어는 100 Tick 의 상태이지만,
제가 조종하지 않는 플레이어들은 지연시간만큼 느리게 업데이트 됩니다. ( ex. 96 Tick )
따라서 정확히 적을 조준하고 쐈다고 해도 적은 이미 과거의 잔상이기 때문에, 같은 틱에서 비교를 하면 맞출 수 없게 됩니다.
이럴 때에는, Lag Compensation 기법을 사용하여 "정말 맞췄는지" 를 계산해야 합니다.
서버에서 발사 이벤트가 100 Tick에 발생되었다면,
나를 제외한 모든 플레이어를 96 Tick의 상태로 되돌려서
실제로 "맞췄는지" 를 판단하고
다시 100 Tick 으로 상태를 복원해서 게임을 진행하면 됩니다.
우선 클라이언트 측부터 구현해보도록 하겠습니다.
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 )
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 함수를 실행할 수 있습니다.
순서는 다음과 같습니다.
recentlyReceivedTick 상태로 전부 Rewind결과를 확인해보겠습니다.



위쪽 로그가 서버 측, 아래쪽 로그가 클라이언트 측 로그입니다.
실제로 클라이언트에서 탐지한 적의 위치와 서버에서 Rewind 한 적의 위치가 같은 것을 확인하실 수 있습니다.
또한 클라이언트와 서버의 Fire 이벤트가 동일하게 (총 2번) 일어난걸 보아 정상적으로 Lag Compensation 이 작동하는 것을 확인할 수 있습니다.
글만 보면 정말 간단해 보일 수 있지만 생각보다 구현하는데 시간이 오래 걸렸습니다.
구현하면서 에러가 발생헀던 부분들은 다음과 같습니다.
기존에는 NGO (Netcode for GameObejcts) 의 RPC, Spawn 과 같은 함수를 사용하다보니, 플레이어를 스폰하고 동기화하는데에도 시간이 오래 걸렸습니다.
우선 코드를 정리하고 다음 기능을 마저 구현해보도록 하겠습니다.