본문 바로가기

유니티 개발 정보/네트워크

유니티를 이용하여 온라인 게임 만들기 - (2/2)

원문 보기


(1편에 이어서)

상태 동기화

네트워크 통신을 하는 데는 2가지 방법이 있다. 첫번째 방법은 상태 동기화이고, 다른 한가지 방법은 원격 프로시저 호출(RPC, Remote Procedure Calls)이다. RPC는 이 글의 후반부에서 다루도록 하겠다. 상태 동기화는 네트워크를 통해 계속해서 값들을 업데이트 한다. 이는 Player의 움직임같은 자주 값이 변경되는 경우에 유용하다. OnSerializeNetworkView()함수에서 변수를 보내고 받으며, 변수들을 빠르고 간단하게 동기화 한다. 이것들이 어떻게 작동되는지 보기 위해서, 우리는 Player의 위치값을 동기화 하는 코드를 작성할 것이다.

Player 프리팹에 있는 network view 컴포넌트로 가보자. Observed 필드는 동기화 될 컴포넌트를 포함하고 있다. 이 필드에는 Transform 컴포넌트가 자동으로 추가되는데, 전송되는 값에 따라 position, rotation, scale값이 업데이트 될 것이다. Observed 필드에 Player 프리팹에 붙어 있는 Player 스크립트 컴포넌트를 넣어보자. 이제 우리만의 동기화 방법을 작성해볼 것이다.

OnSerializedNetworkView()를 Player 스크립트에 추가하자. 이 함수는 데이터를 보내거나 받을 때 마다 매번 자동으로 호출된다. 만약 유저가 스트림을 작성하고 있다면, 이는 데이터를 보내고 있다는 것을 의미한다. stream.Serialize()를 사용함으로써, 변수는 직렬화되고, 다른 클라이언트들이 이를 받을 것이다.
만약 유저가 데이터를 받는다면, 똑같은 Serialize함수가 호출되고, 내부적으로 데이터를 저장하도록 설정될 것이다. 반드시 보내는 데이터와 받는 데이터의 변수 순서는 같아야 한다는 명심해야 한다. 그렇지 않으면 값들은 섞여버릴 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void OnSerializeNetworkView(BitStream stream, NetworkMessageInfo info)
{
    Vector3 syncPosition = Vector3.zero;
    if (stream.isWriting)
    {
        syncPosition = rigidbody.position;
        stream.Serialize(ref syncPosition);
    }
    else
    {
        stream.Serialize(ref syncPosition);
        rigidbody.position = syncPosition;
    }
}

다시 새롭게 빌드를 하고 실행을 해보자. 그전과 결과는 같지만, 이제 우리는 스스로 움직임과 어떻게 동기화가 작동되는지에 대한 제어를 할 수 있게 되었다.

보간법

당신은 전송율(Sendrate)의 이유로 두 인스턴스간의 지연시간에 대한 문제를 알았을 지도 모른다. 유니티는 기본적으로 초당 15번 패킷이 전달되도록 설정되어있다. 테스트를 목적으로, 우리는 전송율을 변경할 수 있다. 이를 위해, 우선 네트워크 설정(Edit -> Project Settings -> Network)에가서 sendrate값을 5로 바꿔보자. 그러면 데이터 패킷이 덜 보내지게 된다. 다시 빌드를 해서 테스트를 해보면, 지연문제(latency)를 보다 더 명확하게 볼 수 있을 것이다.

부드럽게 위치값을 변경시키고, 지연문제를 해결하기 위해서, 보간법이 사용될 수 있다. 구현하는 방법은 여러가지가 있다. 이 튜토리얼에서는 현재 위치값과 동기화된 후 받은 새로운 위치값 사이를 보간할 것이다.

새로운 데이터(현재 위치, 새로운 위치, 업데이트간의 지연시간)을 저장하기 위해OnSerializedNetworkView()을 확장할 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private float lastSynchronizationTime = 0f;
private float syncDelay = 0f;
private float syncTime = 0f;
private Vector3 syncStartPosition = Vector3.zero;
private Vector3 syncEndPosition = Vector3.zero;
 
void OnSerializeNetworkView(BitStream stream, NetworkMessageInfo info)
{
    Vector3 syncPosition = Vector3.zero;
    if (stream.isWriting)
    {
        syncPosition = rigidbody.position;
        stream.Serialize(ref syncPosition);
    }
    else
    {
        stream.Serialize(ref syncPosition);
 
        syncTime = 0f;
        syncDelay = Time.time - lastSynchronizationTime;
        lastSynchronizationTime = Time.time;
 
        syncStartPosition = rigidbody.position;
        syncEndPosition = syncPosition;
    }
}

앞서 Update()에서 객체가 플레이어에 의해 제어되는지 아닌지를 검사했다. 여기에 그렇지 않는 경우에 대한 기능을 작성해야 하는데, 우리는 여기서 동기화된 값들을 보간할 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void Update()
{
    if (networkView.isMine)
    {
        InputMovement();
    }
    else
    {
        SyncedMovement();
    }
}
 
private void SyncedMovement()
{
    syncTime += Time.deltaTime;
    rigidbody.position = Vector3.Lerp(syncStartPosition, syncEndPosition, syncTime / syncDelay);
}

새로 빌드를 하고 테스트를 해보자. Update간에 더 자연스러운 Player의 움직임을 볼 수 있을 것이다.


예측

움직임이 더 부드럽게 보일지는 몰라도, 입력과 실제 움직임간의 작은 딜레이를 발견 했을 것이다.
이는 새로운 데이터를 받고 난 다음에 위치가 업데이트되기 때문이다. 우리는 예전 데이터를 근거로 앞으로 일어날 일을 예측할 수 있다.

다음 위치를 예측하는 한 가지 방법은 속도를 고려하는 것이다. delay(지연시간)을 곱한 속도를 추가함으로써 좀 더 정확한 위치를 계산할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
void OnSerializeNetworkView(BitStream stream, NetworkMessageInfo info)
{
    Vector3 syncPosition = Vector3.zero;
    Vector3 syncVelocity = Vector3.zero;
    if (stream.isWriting)
    {
        syncPosition = rigidbody.position;
        stream.Serialize(ref syncPosition);
 
        syncVelocity = rigidbody.velocity;
        stream.Serialize(ref syncVelocity);
    }
    else
    {
        stream.Serialize(ref syncPosition);
        stream.Serialize(ref syncVelocity);
 
        syncTime = 0f;
        syncDelay = Time.time - lastSynchronizationTime;
        lastSynchronizationTime = Time.time;
 
        syncEndPosition = syncPosition + syncVelocity * syncDelay;
        syncStartPosition = rigidbody.position;
    }
}

빌드를 하고 다시 테스트를 해보면, 물체 간의 이동은 여전히 부드럽고, 입력과 실제 움직임간의 지연시간이 줄어들었다는 것을 알게 될 것이다. 하지만 지연시간이 너무 길면 조금 이상하게 보일 수 있는 상황이 존재하긴 한다. 만약 플레이어가 움직임을 시작할 때, 다른 클라이언트는 여전히 서있는 것으로 예상한다. network setting에서 sendrate를 다시 15로 설정하면 더 좋은 결과를 얻을 수 있을 것이다.

우리의 킥스타터 데모에서, 움직이는데 navmesh를 사용했고 이는 더 나은 보간법과 예측성을 보여주었다. sendrate를 5로 설정했지만, 유저는 좀 처럼 지연문제를 거의 느끼지 못했다. 이 튜토리얼은 navmesh에 관해 다루지 않을 것이지만, 미래의 프로젝트를 위해서 이 방법도 한번 고려해볼만한 가치가 있을 것이다.


원격 프로시저 호출(RPCs)

네트워크 통신을 하는 또 다른 방법은 원격 프로시저 호출(RPCs)를 사용하는 것이다. 이는 데이터 변화가 적게 발생하는 경우에 더 유용하다. 우리가 킥스타터 데모에서 사용했던 만든 채팅 시스템이 RPCs를 사용한 좋은 예이다. 이번 단락에서는 네트워크 상에서 플레이어의 컬러를 바꿔볼 것이다.

RPCs가 하는일은 network view 컴포넌트에서 함수를 호출하고, network view 컴포넌트는 올바른 RPC 함수를 찾는다. 함수 앞에 [RPC]를 붙임으로써, 이는 네트워크를 통해서 호출된다. 이 방법은 오직 int, float, strings, networkViewIDs, vector, quaternion만을 보낼 수 있다. 그래서 모든 매개변수를 전달할 수는 없지만, 다른 방법으로 이 문제를 해결할 수 있다. 게임 오브젝트를 보내기 위해서, 게임 오브젝트에 network view 컴포넌트를 붙이면, 우리는 이 오브젝트의 networkViewID를 사용할 수 있다. 컬러 값을 전달하기 위해서는, 우리는 이를 vector 또는 quaternion 값으로 변경해야 한다.

RPC는 networkView.RPC()를 통해서 전달되는데, 여기서 함수의 이름과 매개변수를 정의할 수 있다.
두번째 매개변수로 RPCMode를 입력해야하는데, "Server"는 데이터를 오직 서버한테만 보내고, "Others"는 자신을 제외한 서버에 있는 모든 이에게 전달하며, "All"은 자신을 포함한 모든 이에게 전달 된다. 마지막으로 Buffered로 설정할 수 있는 "AllBuffered"와 "OthersBuffered"이 있는데, 이는 새롭게 연결된 플레이어가 이 버퍼된 값들을 받게 된다. 우리는 매 프레임마다 이 데이터 패킷을 전송하기 때문에, 버퍼가 필요하지 않다.
(역자 주: 여기서는 RPCMode를 "OthersBuffered"로 설정했는데, 크게 신경쓰지 않아도 됩니다.)

이 함수를 우리의 튜토리얼에 포함시키기 위해서, InputMovement()를 추가했던 것 처럼, 컬러 값을 무작위로 변경시키는 InputColorChange()함수를 만들어야 한다. RPC 함수는 입력 값을 받으면 컬러를 변경하고 만약 플레이어 객체가 유저에 의해서 제어될 경우에는, RPC를 네트워크 상에 있는 다른 모든 이에게 전달한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void Update()
{
    if (networkView.isMine)
    {
        InputMovement();
        InputColorChange();
    }
    else
    {
        SyncedMovement();
    }
}
 
private void InputColorChange()
{
    if (Input.GetKeyDown(KeyCode.R))
        ChangeColorTo(new Vector3(Random.Range(0f, 1f), Random.Range(0f, 1f), Random.Range(0f, 1f)));
}
 
[RPC] void ChangeColorTo(Vector3 color)
{
    renderer.material.color = new Color(color.x, color.y, color.z, 1f);
 
    if (networkView.isMine)
        networkView.RPC("ChangeColorTo", RPCMode.OthersBuffered, color);
}

이제 다시 빌드를 하고 테스트를 해보자. 게임 상에서 "R"를 누르면, 플레이어의 색이 변경되는 것을 볼 수 있을 것이다.


결론

이 튜토리얼이 유니티로 네트워크를 구현하는 좋은 입문글이 되었으면 하는 바람이다. 우리가 다뤘던 주제는 다음과 같다:

  • 서버를 만들고, 접속하는 방법
  • 활동중인 호스트 찾기
  • 네트워크 상에서 플레이어 생성하기
  • 네트워크 통신을 구현하는 방법
  • 동기화된 데이터를 이용해 보간하고 예측하는 방법

여기서 이번 튜토리얼에 쓰였던 프로젝트를 다운받을 수 있다.

아마 이번 튜토리얼을 하면서, 게임의 요구사항에 맞게 별도로 적용해야 할 여러가지 사항을 알게 되었을 것이다. 이 튜토리얼은 우리의 킥스타터 데모(LFG: The Fork Of Trugh)에서 구현된 것을 간단하게 설명한 것이다.