본문 바로가기

Unity 개발 공부

[내배캠] 본캠 57 pun2 공부2

Photon Animator View: 애니메이션 동기화
Photon Rigidbody2d View: 리지드바디 2d 동기화
Photon Rigidbody View : 리지드바디 동기화
Photon Transform View : 트랜스폼 동기화
Photon Transform View Classic : 트랜스폼 동기화
커스텀 가능
=> 모두 Photon View에 넣어져 있어야한다.
동기화 해야하는애들은 Photon View 컴포넌트 인스팩터상
Observed Components안에 다 들어가있도록 넣어준다.

Photon Animator View
에서 Synchornize Parameter의 경우
실제 애니메이터 파라메터를 설정한것의 동기화 빈도를 선택해줄 수있다.
Disabled는 말그대로 작동안한다.
Discrete는 변경될때마다 한번
Continuous는 update로 매번 
주로 Discrete로 bool 파라메터를 설정해놓는편이다.

rigidbody가 달려있다면, 마찬가지로 네트워크로 아래와 같은 컴포넌트로 위치, 회전 동기화가 가능하다.
Photon Rigidbody 2d 혹은 Rigidbody View에서
Enable teleport fore large distance 탭에서
Teleport if distance g  앞에 int로 숫자를 넣어주면 
속도가 빨라질때 동기화로 정확한 위치로 조정이 필요하다면 해당 int 유닛만큼 순간이동된다.
속도, 회전속도 까지 동기화 가능하다.

Photon Transform View의 경우 
위치, 회전, 스케일을 동기화 가능하다. 필요없으면 체크안하는게 서버 대역폭을 줄이는데 유리하므로
스케일은 웬만하면 체크안하는게 좋음.

Photon Transform View Classic 의 경우 덜덜이 문제등을 해결할 수 있는 컴포넌트다.
Synchronize Position에서 FIxed speed로 interpolate Option을 설정해두면 덜덜떨리는 동기화문제들이
사라진다.
좀더 세밀하게 조정이가능하다.
interpolate Option 은 데이터가 많이 있을때 보간 
Extrapolate Option은 데이터가 적을때 보간해준다.(끊길때)
fixed speed도 되고 lerp는 부드럽게 움직이도록 해준다.

Photon RIgidbody View는 3D라는 차이만 있을뿐 같은 내용이다.

다양하고 유용한 PhotonNetwork
앞에 PhotonNetwork. 이 다 붙는다고 가정하고 공부한다.
SendRate : 초당 보내는 패킷, 기본은 20이다.
패킷이란 컴퓨터 네트워크에서 데이터를 주고받을때 전송하기 쉽도록 데이터를 작게 나눈 단위를 뜻한다.
이러한 패킷은 데이터의 헤더와 실제 데이터인 페이로드로 구성된다. 헤더에는
수신주소, 발신주소, 데이터 종류등과 같은 정보가 담긴다.
페이로드는 실제로 전송되는 데이터 부분을 뜻한다. 
SerializationRate: OnPhotonSerializeView( 변수 동기화) 호출 양, 기본 10이다.

-> 둘을 비율에 맞게 올리면 예) 60:30 빨라지나 모바일 환경에서는 
렉이 걸릴 수 있기에 그대로 두는 걸 추천한다.

latency( 네트워크 지연) 이란 클라이언트 A가 보낸 메시지(패킷)가 서버 혹은
다른 클라이언트 B에 도착할때까지 걸리는 시간.
왕복시간(RTT, Round-Trip Time)은 보통 50ms ~ 200ms 사이에서 흔히 관측된다.
물리적 거리: 광섬유, 라우터 거리를 전기신호가 이동해야하고
각중간 장치에서 패킷 검사, 큐잉하며
네트워크 혼잡(트래픽)으로 우선순위가 지연되거나 패킷이 재전송되기도한다.
클라이언트/서버 간 처리시간이 또 존재한다 게임로직연산과 직렬, 역직렬화로 인해말이다.
이로인해

게임에서 뛰어다니는 캐릭터가 뚝뚝 끊겨보이고
공격 명중시점이 서버 기준으로 살짝 밀리거나
총알 궤적이 흔들리고, 움직임 예측이 빗나갈 수 있다.

보간은 끊김없어 보이게하는 부드러운 움직임을 구현하는데 도움을 준다.
클라이언트는 들어오는 timestamp, position 쌍을 버퍼에 저장하고
현재시점을 실제시간 - 보간지연(backTime)으로 약간 뒤에 잡고
그 시점에 가장 가까운 과거 상태 두개를 찾아서 
Unity의 Vector3.Lerp(posA, posB, alpha)로 두점을 선형보간한다면
패킷이 조금 늦게 도착해도 이전 프레임과 다음 프레임 사이를 자연스럽게 이어주니까
화면에 절대 순간이동하는 튀는 현상없이 마치 내부적으로는 60fps로 지속적으로 움직이는것처럼
보인다.

SendRate가 높으면 RPC·RaiseEvent·커스텀 프로퍼티 변경 등을 묶어서 보내는 빈도가 올라가고,
SerializationRate가 높으면 위치·회전 같은 스트리밍 데이터를 더 자주 패킷에 담습니다.

패킷 빈도가 높아지면 매전송마다 발생하는 헤더(UDP/IP, Photon 헤더 등) 오버헤드가 커지고
전체 바이트 수가 늘어나 업링크,다운링크가 부족해지면 패킷 지연이 증가한다.
Reliable 채널의 재전송 부담도 발생한다. Photon의 기본 RPC, 커스텀 프로퍼티는 ReliableOrdered 채널을
사용한다.
패킷 손실이나 순서뒤바뀜이 발생하면 ACK 대기 -> 재전송 -> 전체지연이 불가피해진다.
OnPhotonSerializeView 내에서 매번 SendNext/ReceiveNext 호출, Vector3 등 struct 생성,
JSON 변환이나 Hashtable 직렬화 같은 무거운 작업이 반복되면
프레임 드랍이나 GC 히치가 발생할 수 있습니다.
또한 
Photon 내부에는 네트워크 쓰레드와 메인 쓰레드 간에 메시지 큐가 있고,
전송 빈도가 너무 높으면 큐가 밀려서 처리 딜레이가 발생합니다.
따라서 Rate를 너무 올려버리면 렉이 걸린다.

다만 낮은빈도에서는 뚝뚝 끊기는 문제가 생기고 이를해결하기위해 적절히 올릴필요는있는것이다.

지연보상에 대한부분은 다른 얘기다.
보간이란 화면에 보이는 부드러운 움직임을 위한 기술이고,
사격의 판정지연문제는 별도로 지연보상 기법을 써야한다. 
맞아야할때 맞도록 과거 상태를 재현해서 판정하는것이다.

먼저 OnPhotonSerializeView에대해서 간략하게 공부하고가자.
해당 콜백메서드는 네트워크 동기화제어를 세세하게 하기위해 쓰는 옵션으로 커스텀데이터를 보내고싶다면
필요하다.
IPunObservable  인터페이스를 상속받아야만하고,
public interface IPunObservable
{
    void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info);
}
이렇게 쓴다.
PhotonStream stream은 네트워크로 주고받는 데이터를 담는 스트림,
PhotonMessageInfo info는 메시지관련 추가정보(보낸시각, 전송자 등)이다.
OnPhotonSerializeView 내에서 SendNext()와 ReceiveNext()의 호출 순서는 반드시 맞춰야한다.
주로 언제쓰냐면
PhotonView가 자동으로 지원하지 않는 데이터(예: 체력, 스코어, 커스텀 애니메이션 파라미터 등)를 
실시간으로 보내고 받을 때 사용하거나 지금처럼 지연보상을 위해 서버시간과 위치를 찾아줘야할때 쓴다.
매 프레임마다 계속 바뀌는 값을 효율적으로 동기화(transform외에도 속도,충돌상태 등)할때도 활용한다
PhotonView 소유자(owner)가 stream.IsWriting==true로 조건을 걸고 사용하며 보통
PhotonNetwork.Instantiate을 호출한 클라이언트가 소유자가 된다. Ownership Transfer로 소유자를 바꿀순있다.
ReceiveNext()는 모든 클라이언트가 읽어쓴다.
이 콜백메서드는
매 네트워크 틱마다(기본 10번/sec) 자동으로 호출되어, 
실시간으로 바뀌는 값을 연속적으로 스트림에 씁니다.
변동폭이 크지 않은 값(위치, 속도, 애니메이션 파라미터 등)을 
프레임 단위로 부드럽게 동기화할 때 유리하다. 다만 모든 클라이언트에게 보내고 지정할수없다.
반대로 RaiseEvent/OnEvent는 수동으로 호출하는 단발성 이벤트, 한번 발동될때마다 별도의 패킷이 생성되므로
빈번히 보내면 오버헤드가 커진다. 대신 좀더 세세하게 어떤 클라이언트에게 어떤 방식으로 보낼지 선택가능하다.

다시돌아가서 지연보상에대해서 다시 얘기해보자. 우선
아까말했듯 맞아야할때 맞도록 과서상태를 재현해서 판정하려면
이를위해선 포톤에서 쓰는 서버 타임스탬프를 동기화해야한다.
각 프레임마다 혹은 OnPhotonSerializeView에서
stream.SendNext(PhotonNetwork.serverTimestamp);
stream.SendNext(transform.position);

ServerTimestamp를 이용해 모든 클라이언트 시간이 같은 기준을 갖도록한다.
PhotonNetwork.ServerTimestamp란 Photon 서버가 발행하는 일관된 기준시각(밀리초 정수)이다.
클라이언트 마다 로컬시간이 달라도 이 타임스탬프만큼은 모두 같은 기준이 된다.
지금 하는 방법은 총알이 예를들어 t= 1000ms 에 발사됐는데, 네가 그때 어디에 있었냐를 저장해서 
확인하는 것이다.

버퍼링(과거 상태 저장), 버퍼란 데이터전송과정에서 임시로 데이터를 저장하는 공간을 의미한다.
각 플레이어마다 과거 N 프레임의 (timestamp, position, collider state)를 
List<BufferedState> 같은 자료구조에 저장한다.

먼저 순서대로해보자.
 void OnPhotonSerializeview(PhotonStream stream, PhotonMessageInfo info)
{
  if(stream.IsWriting)
  {
   stream.SendNext(PhotonNetwork.ServerTimestamp); // 이 데이터가 서버시간으로 언제 보내졌나
   stream.SendNext(transform.position); // 캐릭터가 그시점에 어디있었느냐 위치정보

  그밖에... 충돌판정을 위한 콜라이더 상태가 필요하면
  stream,SendNext(Collider... 추가);
  }
  else
  {
  수신쪽에서는 ReceiveNext()로 timestamp/postion을 꺼내서
  버퍼에 (timestamp, position) 쌍을 저장한다
  }
}

class BufferedState {
public int timestamp;
public Vector3 position;
필요시 ColliderState등 추가
}

List<BufferedState> buffer = new List<BufferedState>();
//수신부에서
int ts = (int)stream.ReceiveNext();
Vector3 pos = (Vector3)stream.ReceiveNext();
buffer.Add(new BufferedState { timestamp = ts; position = pos });

과거 N개의 상태를 시간순으로 저장해두고 t=shotTs에 가장 가까운 과거 상태를 꺼내 올수있다.

void OnFire()
{
  int shotTs = PhotonNetwork.ServerTimestamp // 서버기준 발사시점
  Vector3 origin = muzzleTransform.position // 발사 지점
  Vector3 dir = muzzleTransform.forward; // 발사방향
  photonView.Rpc("FireShotOnServer", RpcTarget.masterClient, shotTs, origin, dir);
}

그다음 해당 함수를 마스터클라이언트에서 즉 호출한다는걸 브로드케스트한건데. 아래와같다.
[PunRPC]
void FireShotOnServer(int shotTs, Vector3 origin, Vector3 dir)
{
  foreach (var p in allPlayers)
  {
   BufferedState s = p.GetStateAtTimestamp(shotTs);
   Vector3 origPos = p.collider.transform.position;
  Quaternion origRot = p.collider.transform.rotation;

  // (B) 과거 위치로 일시 이동
  p.collider.transform.position = s.position;
  p.collider.transform.rotation = s.rotation;

  // (C) 판정 로직 실행
  bool hit = Physics.Raycast(origin, dir, out hitInfo);

  // (D) 원위치 복원
  p.collider.transform.position = origPos;
  p.collider.transform.rotation = origRot;
  }

  photonView.RPC("ApplyHit", RpcTarget.All, hitPlyaerId, damage);
}
언제쐈는지, 어느지점에서 어디로 쐈는지 정보를 받아서
모든 플레이어에 대해
그 플레이어가 shotTs였을때 버퍼에 기록되 가장 가까운 상태를 찾아주고
 그상태로 플레이어 콜라이더를 일로 이동/설정 하는것이다.
혹은 Physics.Raycast에 s.position을 사용하는것이다.
Raycast(orign, idr) 판정 -> 맞았는지 결정한다.
판정을 위해서 과거로의 위치로 가상이동시킨뒤 검사한뒤에는, 각로컬의 원위치로 복원시켜줘야만 문제가안생긴다. 아무튼
판정결과는 RPC("ApplyHit" ...) 을 통해 모든 클라이언트에게 누구를 맞혔는지 통보하는것이다.

[PunRPC]
void ApplyHit(int hitPlayerId, int damage)
{
  showHItEffect(hitPlayerId); // 즉시 로컬에서 피격 이펙트 모두에게 실행하는것이고 ( 아까 RpcTarget.All 했으니)
 if(hitPlayerId== PhotonNetwork.LocalPlayer.ActorNumber) currentHP -= damage;
}
// 그리고 해당 hitPlayerId가 지금 조종중인 클라이언트 나 자신의 액터넘버와 같다면, 데미지를 입도록해서
실제로 맞은 사람만 데미지를 입도록한다.

lass BufferedState
{
    public int     timestamp;
    public Vector3 position;
}

class PlayerBuffer
{
    public List<BufferedState> buffer = new List<BufferedState>();

    public BufferedState GetStateAtTimestamp(int targetTs)
    {
        // 가장 가까운 두 상태를 찾아 보간하거나, 단순히 가장 앞선 상태 하나를 반환
        BufferedState before = null, after = null;

        foreach (var s in buffer)
        {
            if (s.timestamp <= targetTs)
                before = s;
            else if (s.timestamp > targetTs)
            {
                after = s;
                break;
            }
        }

        if (before != null && after != null)
        {
            // 선형 보간 예시
            float t = (targetTs - before.timestamp) / (float)(after.timestamp - before.timestamp);
            Vector3 interpPos = Vector3.Lerp(before.position, after.position, t);
            return new BufferedState { timestamp = targetTs, position = interpPos };
        }
        else
        {
            // 보간 불가 시 가장 최근 상태 반환
            return before ?? after;
        }
    }
}

여기서는 선형으로 보간해서 BufferdState를 return 해주는데 딱 그 시점(targetTs)와 
그 시점에서의 보간된 위치(interpPos)을 찾아서 돌려주는것이다. 
foreach문은 해석해보면 targetTs가 나오기전까지는 가장 마지막으로 찾은 s를 before에 담아주고
그다음 s가 targetTs를 넘자마자 바로 나오는 s일때 after에 담아준뒤에 break로 빠져나와버린다.
  float t = (targetTs - before.timestamp) / (float)(after.timestamp - before.timestamp);
이거는 숫자를 넣어서 계산해보면 0~1사이의 숫자가 나오는데
before.timestamp와 after.timestamp 사이에 targetTs가 어느 지점에 있는지를 0~1 범위의 실수로 나타낸다.
예를 들어 before.ts = 1000ms, after.ts = 1100ms, targetTs = 1050ms라면
t = (1050 - 1000) / (1100 - 1000) = 50/100 = 0.5
즉, “두 상태 중 절반 지점”이라는 뜻이된다.
즉 선형보간으로 딱 한번 before.position과 after.position의 중간지점을 얻게된다.
보간 불가능시 즉 둘중에 하나라도 null인경우를 체크하는 상황에서 쓴 문법은 ??가 있는데, 이 뜻은
before이 null이 아니라면 before을 return하고, before이 null이라면 after을 return 하도록하였다.

아래예제는 전체적으로 구현된 코드다.
플레이어 프리팹에 붙여서 쓸수있다.

using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;

public class PlayerNetwork : MonoBehaviourPunCallbacks, IPunObservable
{
    // --- 버퍼링용 구조체 & 리스트 ---
    class BufferedState
    {
        public int     timestamp;
        public Vector3 position;
    }
    private List<BufferedState> buffer = new List<BufferedState>();
    private const int bufferSize = 20; // 최근 20프레임 상태만 유지

    // --- HP & 피격 이펙트 ---
    public int maxHP = 100;
    private int currentHP;

    void Start()
    {
        currentHP = maxHP;
    }

    void Update()
    {
        // 내 캐릭터에서만 발사 입력 처리
        if (photonView.IsMine && Input.GetKeyDown(KeyCode.Mouse0))
            FireShot();
    }

    // 1) 발사: 방장에게만 판정 요청
    void FireShot()
    {
        int shotTs = PhotonNetwork.ServerTimestamp;
        Vector3 origin = transform.position + transform.forward * 1.0f;
        Vector3 dir    = transform.forward;

        photonView.RPC(
            "FireShotOnServer",
            RpcTarget.MasterClient,
            shotTs, origin, dir
        );
    }

    // 2) MasterClient에서만 실행: 리와인드 판정 → ApplyHit RPC
    [PunRPC]
    void FireShotOnServer(int shotTs, Vector3 origin, Vector3 dir, PhotonMessageInfo info)
    {
        int    hitPlayerId = -1;
        int    damage      = 25;
        float  maxDist     = 100f;

        // 씬에 있는 모든 PlayerNetwork 인스턴스 순회
        foreach (var p in FindObjectsOfType<PlayerNetwork>())
        {
            // 2-1) 과거 시점 상태 가져오기
            BufferedState s = p.GetStateAtTimestamp(shotTs);
            if (s == null) continue;

            // 2-2) 원위치 저장
            Vector3 origPos = p.transform.position;
            Quaternion origRot = p.transform.rotation;

            // 2-3) 콜라이더 리와인드
            p.transform.position = s.position;

            // 2-4) Raycast 판정 (자기 자신 제외)
            if (p.photonView.Owner.ActorNumber != info.Sender.ActorNumber)
            {
                Ray ray = new Ray(origin, dir);
                Collider col = p.GetComponent<Collider>();
                if (col.Raycast(ray, out RaycastHit hitInfo, maxDist))
                {
                    hitPlayerId = p.photonView.Owner.ActorNumber;
                    // 맞은 사람 결정되면 루프 탈출해도 좋음
                    // break;
                }
            }

            // 2-5) 원위치 복원
            p.transform.position = origPos;
            p.transform.rotation = origRot;

            if (hitPlayerId != -1) break;
        }

        // 3) 판정 결과 브로드캐스트
        if (hitPlayerId != -1)
        {
            photonView.RPC(
                "ApplyHit",
                RpcTarget.All,
                hitPlayerId,
                damage
            );
        }
    }

    // 4) 모든 클라이언트가 받는 ApplyHit
    [PunRPC]
    void ApplyHit(int hitPlayerId, int damage)
    {
        // (A) 모두에게 피격 이펙트
        ShowHitEffect(hitPlayerId);

        // (B) 맞은 본인만 HP 차감
        if (hitPlayerId == PhotonNetwork.LocalPlayer.ActorNumber)
            currentHP = Mathf.Max(0, currentHP - damage);
    }

    // (예시) 피격 이펙트 함수
    void ShowHitEffect(int playerId)
    {
        // 이펙트 매니저에 전달하거나,
        // ID가 나면 로컬에서 소리·스플래시 재생 등
        Debug.Log($"Player {playerId} was hit!");
    }

    // --- 타임스탬프 + 위치 스트리밍 ---
    public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
    {
        if (stream.IsWriting)
        {
            // ① 언제 보낸 데이터인지(서버 타임스탬프)
            stream.SendNext(PhotonNetwork.ServerTimestamp);
            // ② 그 시점의 위치
            stream.SendNext(transform.position);

            // (필요 시 회전·콜라이더 상태 등 추가)
        }
        else
        {
            // ① 수신한 타임스탬프
            int ts = (int)stream.ReceiveNext();
            // ② 수신한 위치
            Vector3 pos = (Vector3)stream.ReceiveNext();

            // 버퍼에 추가 & 오래된 건 제거
            buffer.Add(new BufferedState { timestamp = ts, position = pos });
            if (buffer.Count > bufferSize)
                buffer.RemoveAt(0);
        }
    }

    // 5) 리와인드 판정을 위한 유틸
    BufferedState GetStateAtTimestamp(int targetTs)
    {
        BufferedState before = null, after = null;

        foreach (var s in buffer)
        {
            if (s.timestamp <= targetTs)
                before = s;
            else if (s.timestamp > targetTs)
            {
                after = s;
                break;
            }
        }

        if (before != null && after != null)
        {
            float t = (targetTs - before.timestamp) /
                      (float)(after.timestamp - before.timestamp);
            Vector3 interpPos = Vector3.Lerp(before.position, after.position, t);
            return new BufferedState { timestamp = targetTs, position = interpPos };
        }
        // 보간 불가 시 가장 근접 상태 반환
        return before ?? after;
    }
}

이걸통해알듯이,
Time: 서버시간, double형의 4294967.295부타 0초가 나오는데
서버마다 시간은 다르지만 같은방에선 시간이 같다.
모두에게 정확한 서버시간만 정해주면 동시에 해야하는 작업이 가능하다.
시작점과 끝점의 서버시간을 기록해 차이로 정확한 기록측정이 가능하다.

GetPing() : 서버에 갔다가 되돌아오는시간, 단위 밀리초(ms  1/1000초)
-> 지연시간이 짧을수록 좋고 100ms는 0.1초의 지연시간으로 큰편이다.
핑을 줄이려면 통신이 좋아야하고, 통신량이 적어야한다.

PhotonServerSettings.AppSettings: 포톤서버세팅이다.
-> 서버 셋팅을 동적으로 스크립트상에서 바꿀수있다.
단 한프로젝트에 한 AppId를 써야한다. 그렇지 않으면 라이센스 위반으로 정지된다.

뒤이어서 이걸 부드럽게 보간해보고싶다면

[SerializeField]
[Tooltip("얼마나 뒤로 당겨서 보간할지 (초)")]
float interpolationBackTime = 0.1f; // 100 ms

void Update() {
    // 1) 서버 기준 현재 시각(ms)
    int serverNow = PhotonNetwork.ServerTimestamp;
    // 2) 목표 시각 = 지금 – (뒤로 당길 시간)
    int targetTs = serverNow - Mathf.RoundToInt(interpolationBackTime * 1000f);
    // 3) 그 시각에 맞춰 보간된 위치 계산
    Vector3 interpPos = GetInterpolatedPosition(targetTs);
    // 4) 화면에 그 위치로 그려 주기
    transform.position = interpPos;
}

해주면된다.
serverNow - Mathf.RoundToInt(interpolationBackTime * 1000f); 
이부분은 ServerTimestamp가 밀리초이므로 빼줘야하는 이전 초를 1000을 곱해서 밀리초로 환산해주는것이다.
왜 이렇게 하느냐면, 화면에 지금(current) 보이는 위치가 아니라,
조금 과거(interpolationBackTime = 0.1초) 시점의 버퍼된 위치를 꺼내서 보간(interpolation)해 주면
네트워크 지연이나 패킷 지터가 있어도 훨씬 부드럽고 안정적인 움직임을 얻을 수 있기 때문.