
유니티에서 제공하는 템플릿 프로젝트를 사용해도 괜찮다. "AngryBot2Net.zip"을 풀어서 프로젝트로 열면 된다.
https://github.com/UnityTechnologies/AngryBots2
네트워크 게임에 대한 포톤 클라우드 설정 및 스크립트만 보고 싶으면 프로젝트 세팅은 건너뛰면 된다.
포톤 클라우드를 하기 전에 URP 프로젝트를 세팅해보자.
비주얼 효과를 높인 렌더 파이프라인을 적용한 프로젝트로 진행해보자. URP는 범용으로 사용 가능한 렌더링 파이프라인으로 고품질의 렌더링을 제공하며 PC, 콘솔, 모바일, VR, AR 등 거의 모든 플랫폼에 적용할 수 있다. 또한 포스트 프로세싱 기능과 통합되어 다양한 후처리 그래픽 효과를 처리할 수 있다.


Edit > Project Settings > Graphics에서 Scriptable Render Pipeline Settings 속성을 앞에 생성한 UniversalRenderPipelineAsset 에셋을 드래그해 연결해준다.

Project Settings > Quality 섹션을 High로 선택한다. (렌더링 품지을 설정하는 옵션으로 구동 디바이스의 성능에 따라서 적절히 선택한다.) 중간 부분에 있는 Rendering 속성에 UniversalRenderPipelineAsset 에셋을 연결한다.

포스트 프로세싱이란 렌더링된 결과물에 대한 후처리 작업을 말한다. 카메라가 촬영한 영상 또는 이미지를 스크린에 출력하기 전에 다양한 필터와 효과를 적용하는 기술을 말한다.
원본 vs 포트스 프로세싱
Universal RP를 사용하면 포스트 프로세싱 패키지를 따로 설치할 필요없이 같이 사용할 수 있다. 포스트 프로세싱을 사용하려면 프로젝트 뷰에서 URP Asset을 생성할 때 같이 생성된 Renderer 에셋을 선택하고 Post-Processing 섹션의 Enabled 옵션을 체크한다. 자동으로 PostProcessData 에셋이 연결된다.

Gloval Volume 오브젝트로 효과를 줄 수 있다. (없으면 Volume > Global Volume을 하나 생성해준다. ) Volume 컴포넌트의 Profile New 버튼을 눌러 새로운 프로파일 에셋을 적용한다.




Main Camera 를 선택한 후 인스펙터 뷰에서 Rendering > Post Processing 옵션을 체크해야 게임 뷰에서도 비네팅이 표현된다.
Post-Processing에서 제공하는 다양한 효과는 공식 문서를 참고하자.
스테이지는 기본 제공되는 패키지를 임포트해서 후처리 작업을 해보자. 일단 Environment를 불러와서 라이트 매핑 정보를 저장할 에셋을 생성한다.


라이트 매핑이 완료된 스테이지 모델과 비네팅 효과로 인해 모서리 부분이 어둡게 표현된 것을 확인할 수 있다.

톤 매핑과 블룸 포스트 프로세싱 효과를 추가해보자.
톤 매핑은 조명의 밝기를 HDR (High Dynamic Range)에서 사람이 인식할 수 있는 범위로 조정하는 효과를 말하며 블룸 효과와 같이 사용할 경우 좋은 이미지 품질을 낼 수 있다.
Directional Light를 아래와 같이 설정해준다.

이후 Bloom 효과를 추가해주면 (Intensity = 1.5) 아래와 같이 연출할 수 있다. 블룸 효과는 광원 주위로 과장되게 표현하는 광학 효과로 몽환적인 느낌을 주는 효과다.


using System.Collections;
using System.Collections.Generic;
using TMPro;
using Unity.Burst.Intrinsics;
using UnityEngine;
using UnityEngine.Analytics;
public class Movement : MonoBehaviour
{
private CharacterController controller;
private new Transform transform;
private Animator animator;
private new Camera camera;
private Plane plane;
private Ray ray;
private Vector3 hitPoint;
public float moveSpeed = 10.0f;
void Start() {
controller = GetComponent<CharacterController>();
transform = GetComponent<Transform>();
animator = GetComponent<Animator>();
camera = Camera.main;
plane = new Plane(transform.up, transform.position);
}
void Update() {
Move();
Turn();
}
float h => Input.GetAxis("Horizontal");
float v => Input.GetAxis("Vertical");
void Move() {
Vector3 cameraForward = camera.transform.forward;
Vector3 cameraRight = camera.transform.right;
cameraForward.y = 0.0f;
cameraRight.y = 0.0f;
Vector3 moveDir = (cameraForward * v) + (cameraRight * h);
moveDir.Set(moveDir.x, 0.0f, moveDir.z);
controller.SimpleMove(moveDir * moveSpeed);
float forward = Vector3.Dot(moveDir, transform.forward);
float strafe = Vector3.Dot(moveDir, transform.right);
animator.SetFloat("Forward", forward);
animator.SetFloat("Strafe", strafe);
}
void Turn() {
ray = camera.ScreenPointToRay(Input.mousePosition);
float enter = 0.0f;
plane.Raycast(ray, out enter);
hitPoint = ray.GetPoint(enter);
Vector3 lookDir = hitPoint - transform.position;
lookDir.y = 0;
transform.localRotation = Quaternion.LookRotation(lookDir);
}
}
A 좌표 - B 좌표 = B 지점에서 A 지점으로 향하는 벡터
위 식이 이해가 어렵다면 기하와 벡터를 공부하고 오는 것을 추천한다.
그냥 쉽게 정리하면 Turn()함수는 마우스의 위치를 플레이어가 바라보도록 하는 함수고 Move()는 말그대로 움직이는 함수이다.
카메라가 주인공을 따라가는 로직을 별도로 스크립트로 작성하기 보다는 시네머신을 사용해서 간단히 구현할 수 있다.


Follow와 LookAt에 Player를 연결해주고 아래 표를 참고해 속성값을 설정한다.



네트워크 게임에는 서버 (하드웨어) + 네트워크 게임 엔진 (소프트웨어)가 필요한데, 포톤 네트워크 게임 엔진을 도입해서 사용해자

포톤 네트워크 게임 엔진은 이미 성능이 검증됐고 수많은 레퍼런스를 보유하고 있다. 무엇보다 유니티 엔진에 친화적이라 유니티 개발자가 선호하는 네트워크 게임 엔진 중 하나이다. 포톤은 다양한 제품군을 보유하고 있으며 그 중에서 유니티 엔진에 특화된 PUN (Photon Unity Networking)의 경우 20명의 동접자까지는 무료로 사용할 수 있다.


새 어플리케이션을 생성해준다.


이 어플리케이션 ID를 사용한다.
이제 프로젝트로 돌아온다 에셋 스토어에 접근해서 PUN 2 - Free 에셋을 추가해서 설치한다.
https://assetstore.unity.com/packages/tools/network/pun-2-free-119922

PUN 패키지 설치가 완료되면 다음과 같이 PUN Wizard 뷰가 열린다. 여기에 아까 AppId에 어플리케이션 아이디를 입력해준다.

PUN Wizard? Window > Photon Unity Networking > PUN Wizard를 선택해 열 수 있다.
PUN Wizard에서 설정한 내용은 Photon/PhotonUnityNetworking/Resources/PhotonServerSetting에 저장된다. PhotonServerSettings를 선택하면 인스펙터 뷰에서 App Id PUN, Protocol, DevRegion 등의 다양한 설정 정보를 확인할 수 있다.

최초 접속을 시도하면 ping 테스트를 통해 가장 빠른 지역 서버를 Dev Region에 자동으로 설정한다.
네트워크 게임에 참여하려면 먼저 포톤 서버에 접속해야한다. 포톤 서버는 로비(Robby)와 룸(Room)의 개념이 존재한다. 룸 단위 네트워크 기능을 제공하면 포톤 서버에 접속하면 룸을 생성할 수 있다. 룸이란 네트워크 게임을 실행할 수 있는 논리적인 공간으로 룸에 입장해야만 해당 룸에 접속한 다른 유저와 통신이 가능하다.
로비에 입장하는 별도의 함수를 호출해야만 입장 가능하다. 로비에 입장(접속)한 유저는 현재 어떤 룸이 생성됐는지에 대한 정보를 수신받을 수 있다. 따라서 룸의 목록을 받아솨어 특정 룸을 선택해 입장하는 방식의 네트워크 게임을 개발한다면 먼저 로비에 입장해야 한다. 포톤 서버에 접속만 한 경우 룸 목록을 받아올 수 없다. (로비 입장이 필요!)
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Photon.Pun;
using Photon.Realtime;
public class PhotonManager : MonoBehaviourPunCallbacks // PUN의 다양한 콜백 함수를 오버라이드해서 작성
{
private readonly string version = "1.0";
private string userId = "Zack";
void Awake() {
// 마스터 클라이언트 씬 자동 동기화 옵션
PhotonNetwork.AutomaticallySyncScene = true;
// 게임 버전 설정
PhotonNetwork.GameVersion = version;
// 접속 유저의 닉네임 설정
PhotonNetwork.NickName = userId;
// 포톤 서버와의 데이터의 초당 전송 횟수
Debug.Log(PhotonNetwork.SendRate);
// 포톤 서버 접속
PhotonNetwork.ConnectUsingSettings();
}
// 포톤 서버에 접속 후 호출되는 콜백 함수
public override void OnConnectedToMaster() {
Debug.Log("Connected to Master!");
Debug.Log($"PhotonNetwork.InLobby = {PhotonNetwork.InLobby}");
PhotonNetwork.JoinLobby();
}
// 로비에 접속 후 호출되는 콜백 함수
public override void OnJoinedLobby() {
Debug.Log($"PhotonNetwork.InLobby {PhotonNetwork.InLobby}");
}
}
OnConnectedToMaster 콜백 함수는 서버에 접속하면 제일 먼저 실행되는 함수, InLobby 속성으로 로비에 들어왔는지 여부를 나타낼 수 있다.
포톤 서버에 접속한 후 JoinRobby 함수를 실행하면 로비에 입장한다. 정상적으로 입장되면 OnJoinedLobby 콜백 함수가 실행된다.
룸 목록에 대한 정보 수신이 필요 없을 경우 PhotonNetwork.JoinLobby를 호출할 필요 없이 바로 룸을 생성
포톤 서버는 랜덤 매치 메이킹 기능을 제공한다. JoinRandomRoom은 포톤 서버에 접속하거나 로비에 입장한 후 이미 생성된 룸 중에서 무작위로 선택해 입장할 수 있는 함수이다. 아무런 룬이 생성되지 않았다면 룸에 입장할 수 없으면 이 때 OnJoinRandomFailed 콜백 함수가 발생한다.
// 로비에 접속 후 호출되는 콜백 함수
public override void OnJoinedLobby() {
Debug.Log($"PhotonNetwork.InLobby {PhotonNetwork.InLobby}");
PhotonNetwork.JoinRandomRoom();
}
// 랜덤한 룸 입장이 실패했을 때 호출되는 콜백 함수
public override void OnJoinRandomFailed(short returnCode, string message)
{
Debug.Log($"JoinRandom Failed {returnCode}:{message}");
}
왜 Failed? 바로 Room이 없기 때문!
룸을 만들어주도록 스크립트를 또 수정해보자. CreateRoom의 첫 번째 인자는 룸의 이름이면 두 번째 파라미터는 룸 속성이 RommOptions 데이터를 전달한다.
// 포톤 서버에 접속 후 호출되는 콜백 함수
public override void OnConnectedToMaster() {
Debug.Log("Connected to Master!");
Debug.Log($"PhotonNetwork.InLobby = {PhotonNetwork.InLobby}");
PhotonNetwork.JoinLobby();
}
// 로비에 접속 후 호출되는 콜백 함수
public override void OnJoinedLobby() {
Debug.Log($"PhotonNetwork.InLobby {PhotonNetwork.InLobby}");
PhotonNetwork.JoinRandomRoom();
}
// 랜덤한 룸 입장이 실패했을 때 호출되는 콜백 함수
public override void OnJoinRandomFailed(short returnCode, string message)
{
Debug.Log($"JoinRandom Failed {returnCode}:{message}");
// 룸의 속성 정의
RoomOptions ro = new RoomOptions();
ro.MaxPlayers = 20; // 최대 접속자 수
ro.IsOpen = true; // 룸의 오픈 여부
ro.IsVisible = true; // 로비에서 룸을 노출시킬지 여부
// 룸 생성
PhotonNetwork.CreateRoom("My Room", ro);
}
// 룸 생성이 완료된 후 호출되는 콜백 함수
public override void OnCreatedRoom()
{
Debug.Log("Created Room");
Debug.Log($"Room Name = {PhotonNetwork.CurrentRoom.Name}");
}
// 룸에 입장한 후 호출되는 콜백 함수
public override void OnJoinedRoom()
{
Debug.Log($"PhotonNetwork.InRoom = {PhotonNetwork.InRoom}");
Debug.Log($"Player Count = {PhotonNetwork.CurrentRoom.PlayerCount}");
}

룸이 생성되고 InRoom 속성이 True가 됐다. Room에는 방장인 나밖에 없으므로 PlayerCount = 1이다.
룸에 입장한 접속 사용자 정보는 CurrentRoom.Players로 확인할 수 있다.
// 룸에 입장한 후 호출되는 콜백 함수
public override void OnJoinedRoom()
{
Debug.Log($"PhotonNetwork.InRoom = {PhotonNetwork.InRoom}");
Debug.Log($"Player Count = {PhotonNetwork.CurrentRoom.PlayerCount}");
foreach(var player in PhotonNetwork.CurrentRoom.Players) {
Debug.Log($"{player.Value.NickName}, {player.Value.ActorNumber}");
}
}

플레이어를 선택하고 PhotonView 컴포넌트를 추가한다.
PhotonView : 네트워크 상에 접속한 플레이어 간의 데이터를 송수신 하는 통신 모듈이다. 즉, 동일한 룸에 입장한 다른 플레이어에게 자신의 위치와 회전 정보를 동기화 시키고 특정 데이터를 송수힌 하려면 반드시 PhotonView 컴포넌트가 필요하다.

여러 개의 Photon View를 사용할 수 있지만 네트워크 대역폭과 성능상의 이유로 네트워크 객체단 한 개만 사용을 권장
Synchronization : 동기화 방식 (기본값은 Unreliable On Change)

Observed Components : photon view 컴포넌트가 관찰해 데이터를 송수신할 대상을 등록하는 속성, 기본 설정은 Auto Find All로 자동으로 검색해 등록
그럼 어떻게 동기화?
그럼 다른 네트워크 유저와 어떻게 정보를 동기화?
첫 번째 방식은 가장 쉽게 네트워크를 동기화 할 수 있지만 세밀한 조정은 불가능하고 네트워크 레이턴시가 발생했을 때 위치 및 회전값을 수동으로 보간할 수 없다. 두 번째 방식은 포톤에서 제공하는 컴포넌트를 사용하지 않고 OnPhotonSerializeView 콜백 함수를 통해 데이터 송수신을 수동을 관리하는 방식이다. 네트워크 레이턴시에 대응할 수 있는 코드를 작성해 좀 더 유연한 로직을 구현할 수 있따.

Photon Animator View는 자동으로 Animator 컴포넌트의 정보를 읽어와 자동으로 Layer와 Parameter 값을 설정한다. 다만 동기화 속도를 설정해줘야 한다. 네트워크 대역폭과 동기화의 정확성을 고려해 Discrete(이산)와 Continues(연속) 중 하나를 택한다.

이제 Player를 Resources 폴더를 만든 다음 프리팹화 해준다.
포톤에서 네트워크로 동기화할 대상은 PhotonNetwork.Instantiate 함수를 사용하면 모두 Resources 폴더에 위치해야 한다.
// 룸에 입장한 후 호출되는 콜백 함수
public override void OnJoinedRoom()
{
Debug.Log($"PhotonNetwork.InRoom = {PhotonNetwork.InRoom}");
Debug.Log($"Player Count = {PhotonNetwork.CurrentRoom.PlayerCount}");
foreach(var player in PhotonNetwork.CurrentRoom.Players) {
Debug.Log($"{player.Value.NickName}, {player.Value.ActorNumber}");
}
// 출현 위치 정보를 배열에 저장
Transform[] points = GameObject.Find("SpawnPointGroup").GetComponentsInChildren<Transform>();
int idx = Random.Range(1, points.Length);
// 네트워크상에 캐릭터 생성
PhotonNetwork.Instantiate("player", points[idx].position, points[idx].rotation, 0);
}
동적으로 생성된 Player에 카메라 연결
private CharacterController controller;
private new Transform transform;
private Animator animator;
private new Camera camera;
private Plane plane;
private Ray ray;
private Vector3 hitPoint;
private PhotonView pv;
private CinemachineVirtualCamera virtualCamera;
public float moveSpeed = 10.0f;
void Start() {
controller = GetComponent<CharacterController>();
transform = GetComponent<Transform>();
animator = GetComponent<Animator>();
camera = Camera.main;
pv = GetComponent<PhotonView>();
virtualCamera = GameObject.FindObjectOfType<CinemachineVirtualCamera>();
// PhotonView 컴포넌트가 자신의 것일 경우 시네머신 가상 카메라를 연결
if (pv.IsMine) {
virtualCamera.Follow = transform;
virtualCamera.LookAt = transform;
}
plane = new Plane(transform.up, transform.position);
}
void Update() {
// 자신이 생성한 네트워크 객체만 컨트롤
if (pv.IsMine) {
Move();
Turn();
}
}
주인공 캐릭터가 불규칙한 위치에 생성되고 카메라가 주인공을 따라간다.

Edit > Project Settings를 선택해 Player 섹션을 선택
해상도를 적절하게 설정

File > Build Settings를 열어 Builds 폴더를 새로 생성하고 실행 파일명을 입력해 준 다음에 Save를 해준다. Build And Run을 해준다.

당연히 포톤 서버에 연결되고 룸을 생성한 후에 입장한 상태가 된다. 이제 유니티로 실행하면 랜덤 매치 메이킹 기능 때문에 동일한 룸에 입장하게 되며 두번째로 접속한 유저가된다. 서로 이동하는지 확인해볼 수 있다.
같은 룸에 입장한 네트워크 객체 간의 데이터를 동기화하는 두 번째 방식이 바로 OnPhotonSerializeView 콜백 함수를 사용하는 방식이다. 위에서 사용한 컴포넌트 방식은 간단한 동기화 처리는 가능하지만 좀 더 세밀한 보정이 필요하면 이 콜백 함수를 통해 직접 송수신하는게 좋다.
public class Movement : MonoBehaviourPunCallbacks, IPunObservable
IPunObservable 인터페이스를 추가해준다.
// 수신된 위치와 회전값을 저장할 변수
private Vector3 receivePos;
private Quaternion receiveRot;
// 수신된 좌표로의 이동 및 회전 속도의 민감도
public float damping = 10.0f;
void Update() {
// 자신이 생성한 네트워크 객체만 컨트롤
if (pv.IsMine) {
Move();
Turn();
}
else {
transform.position = Vector3.Lerp(transform.position, receivePos, Time.deltaTime * damping);
transform.rotation = Quaternion.Slerp(transform.rotation, receiveRot, Time.deltaTime * damping);
}
}
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info) {
// 자신의 로컬 캐릭터인 경우 자신의 데이터를 다른 네트워크 유저에게 송신
if (stream.IsWriting) {
stream.SendNext(transform.position);
stream.SendNext(transform.rotation);
}
else {
receivePos = (Vector3)stream.ReceiveNext();
receiveRot = (Quaternion)stream.ReceiveNext();
}
}
OnPhotonSerializeView 콜백 함수의 첫 번째 인자인 PhotonStream.IsWriting 속성이 true이면 데이터를 전송하는 것을 의미, IsMine이 true일 경우 해당 네트워크 객체는 자신의 캐릭터를 말한다. 따라서 자신의 캐릭터의 위치와 회전 정보는 같은 룸에 입장한 모든 네트워크 유저에게 전송되어야 한다. IsWriting이 false일 때는 반대로 다른 네트워크 유저의 캐릭터에 추가된 PhotonView 컴포넌트가 송신한 데이터를 수신한다.
데이터를 전송하는 것은 SendNext 함수를 사용해 전달하며 데이터를 수신할 때는 ReceiveNext 함수를 사용한다. 전송하는 데이터의 개수와 데이터 타입은 수신할 데이터의 개수와 타입이 일치해야한 다.
Observable Search 속성이 Auto Find All이기 때문에 Player의 (Movement) 스크립트가 추가된다. 속성을 Manual로 설정했다면 직접 드래그해서 추가해준다.
총알 프리팹에 PhotonView 컴포넌트를 추가해 생성하면 구현할 수 있지만 괴장히 잘못된 방법. Photon View 컴포넌트는 초당 20회 데이터를 전송하기 때문에 스테이지에 많은 총알이 생성하면 생성된 모든 총알에서 데이터 트래픽 발생하고 네트워크 대역폭을 초과한다!
따라서 총알 발사와 같이 이벤트 성 동작을 네트워크 유저와 공유할 때는 RPC(Remote Procedure Calls)를 통해 구현하는 것이 일반적인 방식이다.
RPC : 원격 프로지서 호출은 물리적으로 떨어져 있는 다른 디바이스의 함수를 호출하는 기능으로 RPC 함수를 호출하면 네트워크를 통해 다른 사용자의 스크립트에서 해당 함수가 호출된다. 비슷한 개념으로는 RMI (Remote Method Invocation)이 있다.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class Bullet : MonoBehaviour
{
public GameObject effect;
void Start() {
GetComponent<Rigidbody>().AddRelativeForce(Vector3.forward * 1000.0f);
Destroy(this.gameObject, 3.0f);
}
void OnCollisionEnter(Collision coll) {
var contact = coll.GetContact(0);
var obj = Instantiate(effect, contact.point, Quaternion.LookRotation(-contact.normal));
Destroy(obj, 2.0f);
Destroy(this.gameObject);
}
}
이제 Fire 스크립트를 작성한 다음 Player에 컴포넌트로 추가해준다.
using System.Collections;
using System.Collections.Generic;
using Photon.Pun;
using UnityEngine;
public class Fire : MonoBehaviour
{
public Transform firePos;
public GameObject bulletPrefab;
private ParticleSystem muzzleFlash;
private PhotonView pv;
// 왼쪽 마우스 버튼 클릭 이벤트 저장
private bool isMouseClick => Input.GetMouseButtonDown(0);
void Start() {
pv = GetComponent<PhotonView>();
muzzleFlash = firePos.Find("MuzzleFlash").GetComponent<ParticleSystem>();
}
void Update() {
// 로컬 유저 여부와 마우스 왼쪽 버튼을 클릭했을 때 총알 발사
if (pv.IsMine && isMouseClick) {
FireBullet();
pv.RPC("FireBullet", RpcTarget.Others, null);
}
}
[PunRPC]
void FireBullet() {
if (!muzzleFlash.isPlaying) muzzleFlash.Play(true);
GameObject bullet = Instantiate(bulletPrefab, firePos.position, firePos.rotation);
}
}
RPC 호출 목적으로만 사용하려면 PhotonView 컴포넌트의 Syncrhonization 속성을 Off로 설정해야 한다.
총알 발사는 자신의 캐릭터에만 동작하므로 IsMine 속성을 사용 RPC 함수를 사용해 원격으로 FireBullet 함수 호출한다.
포톤 서버에서 일반적인 RPC 호출은
PhotonView.RPC(호출할 함수명, 호출 대상, 전달할 데이터)
를 사용한다.
원격으로 호출할 함수명 인자는 string 타입으로 전달하고 호출 대상은 특정 플레이어를 지정하거나 RpcTarget 옵션으로 전달 대상의 범위를 정할 수 있다.

RpcTarget 열거형 인자
RPC로 호출할 함수는 반드시 [PunRPC] 어트리뷰트를 함수 앞에 명기해야 한다.
RpcTarget.AllViaServer, RpcTarget.AllBufferedViaSerever
RPC 호출 시 동시성이 필요한 경우 포톤 클라우드 서버에서 접속해 있는 모든 네트워크 유저에게 동시에 RPC를 전송한다. 하지만 클라이언트 통신망 속도에 따라 RPC 도달 속도와 처리 속도는 다르기 때문에 물리적인 동시성 구현은 불가능하다. 따라서 근사치에 가까운 동시성 정도로만 생각하자. 또한 추가적인 트래픽이 발생하기 때문에 사용 여부를 신중히 고려해야한다.


(2)편에 이어서 피격 및 리스폰, 로그인 기능을 구현해보았다.