김산나
[멋쟁이사자처럼부트캠프 유니티 게임 개발 7기] 2026년 3월 6일 회고록 - 멀티 께임 구현 본문
2026_03_06 강의 요약본
1. Vroid (+Blend Tree)
2. 뒤끝 베이직 세팅
3. 포톤 세팅
1. Vroid
3D 캐릭터를 쉽게 뽑을 수 있는 프로그램.
완전 무료이고, 직접 꾸민 캐릭터는 만든 사람에게 저작권이 귀속된다.
다운로드
체형, 얼굴, 헤어 등 전부 커스텀이 가능하고, 기본적으로 텍스쳐를 수정하는 방식으로 꾸미기 때문에 허접해 보일 수 있다.
다만 블렌더로 모델을 가져와 추가적으로 수정할 수 있기 때문에 충분히 보완할 수 있다.

vrm으로 파일을 export하면 된다.
이 파일은 유니티에서 정상적으로 읽히려면
https://github.com/vrm-c/UniVRM/releases/tag/v0.131.0
Release v0.131.0 · vrm-c/UniVRM
Installation You can install UniVRM using the UnityPackage or the UPM Package. The UniVRM supports Unity 2022.3 LTS or later. UnityPackage Download the unitypackage, and drag and drop it to import ...
github.com
해당 패키지를 다운받으면 된다.
자체적인 툰 셰이더가 있는 것 같다.

이 캐릭터에 애니메이션을 주고 싶다.
이동의 경우에는 "속도"값을 기준으로 애니메이션을 적용하면 자연스러울 것 같다.
그때 사용되는 기능이 "Blend Tree"이다.


Speed 파라미터 생성. (float)
애니메이터 > 블렌드 트리 생성
더블클릭하면

이런 게 뜬다.
이걸 선택하면

이런 창이 뜨는데, Motion List의 +버튼을 눌러 조정할 수 있다.
속도값이 얼마일 때까지 저 모션을 출력하는지 설정한다.
2. 뒤끝 베이스 세팅
https://docs.backnd.com/sdk-docs/backend/base/start-up/
시작하기 | 뒤끝 개발자
SDK 연동 과정을 영상으로 확인하세요!
docs.backnd.com
해당 문서를 읽으면 된다.
SDK를 다운받고, 프로젝트를 생성하여 Client ID, Signature Key를 세팅값에 넣어주면 된다.
<코드>
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
// 뒤끝 SDK namespace 추가
using BackEnd;
public class BackendLogin
{
private static BackendLogin _instance = null;
public static BackendLogin Instance
{
get
{
if (_instance == null)
{
_instance = new BackendLogin();
}
return _instance;
}
}
public void CustomSignUp(string id, string pw)
{
Debug.Log("회원가입을 요청합니다.");
var bro = Backend.BMember.CustomSignUp(id, pw);
if (bro.IsSuccess())
{
Debug.Log("회원가입에 성공했습니다. : " + bro);
}
else
{
Debug.LogError("회원가입에 실패했습니다. : " + bro);
}
}
public void CustomLogin(string id, string pw)
{
Debug.Log("로그인을 요청합니다.");
var bro = Backend.BMember.CustomLogin(id, pw);
if (bro.IsSuccess())
{
Debug.Log("로그인이 성공했습니다. : " + bro);
}
else
{
Debug.LogError("로그인이 실패했습니다. : " + bro);
}
}
public void UpdateNickname(string nickname)
{
Debug.Log("닉네임 변경을 요청합니다.");
var bro = Backend.BMember.UpdateNickname(nickname);
if (bro.IsSuccess())
{
Debug.Log("닉네임 변경에 성공했습니다 : " + bro);
}
else
{
Debug.LogError("닉네임 변경에 실패했습니다 : " + bro);
}
}
}
회원가입, 로그인, 닉변 세 가지 기능을 구현하는 코드.
나중에는 인폿값을 받아 직접 구현하면 된다.
using UnityEngine;
// 뒤끝 SDK namespace 추가
using BackEnd;
public class BackendManager : MonoBehaviour
{
void Start()
{
var bro = Backend.Initialize(); // 뒤끝 초기화
// 뒤끝 초기화에 대한 응답값
if (bro.IsSuccess())
{
Debug.Log("초기화 성공 : " + bro); // 성공일 경우 statusCode 204 Success
}
else
{
Debug.LogError("초기화 실패 : " + bro); // 실패일 경우 statusCode 400대 에러 발생
}
Test();
}
void Test()
{
BackendLogin.Instance.CustomLogin("user1", "1234"); // [추가] 뒤끝 회원가입 함수
BackendLogin.Instance.UpdateNickname("김산나"); // [추가] 뒤끝 닉네임 변경 함수
Debug.Log("테스트를 종료합니다.");
}
}
실제 서버와 연결하는 코드.
3. 포톤 세팅
포톤은 20명 동접까지 무료이다.
기본적으로 클라이언트끼리 연동하는 방식을 갖고 있다.
PUN 2 - FREE | 네트워크 | Unity Asset Store
Get the PUN 2 - FREE package from Photon Engine and speed up your game development process. Find this & other 네트워크 options on the Unity Asset Store.
assetstore.unity.com
먼저 포톤 전용 패키지를 다운받는다.
https://www.photonengine.com/ko-kr
그 다음 포톤에 가입하여 프로젝트를 생성한다. 포톤 종류는 PUN, 멀티게임 선택


포톤 위자드 열기 (이미 열려있으면 거기서 진행)

Setup Project 선택

프로젝트의 ID부분을 클릭하면 복붙할 수 있게 되는데, 복사해서 AppID에 넣어주면 된다.
<서버 코드 (방 생성 및 플레이어 배치)>
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;
public class PhotonManger : MonoBehaviourPunCallbacks
{
[Header("Room Settings")]
[SerializeField] private string roomName = "KatanaRoom";
[SerializeField] private byte maxPlayers = 4;
[Header("Player")]
[SerializeField] private string playerPrefabName = "Player"; // Resources 폴더 내 프리팹 이름
[SerializeField] private Transform spawnPoint; // 없으면 원점 스폰
void Start()
{
PhotonNetwork.ConnectUsingSettings();
}
// 마스터 서버 연결 완료 → 로비 입장
public override void OnConnectedToMaster()
{
Debug.Log("마스터 서버 연결됨. 로비 입장 중...");
PhotonNetwork.JoinLobby();
}
// 로비 입장 완료 → 방 참가 또는 생성
public override void OnJoinedLobby()
{
Debug.Log("로비 입장 완료. 방 참가 시도...");
RoomOptions options = new()
{
MaxPlayers = maxPlayers,
IsOpen = true,
IsVisible = true
};
PhotonNetwork.JoinOrCreateRoom(roomName, options, TypedLobby.Default);
}
// 방 입장 완료 → 캐릭터 스폰
public override void OnJoinedRoom()
{
Debug.Log($"방 입장 완료. 플레이어 수: {PhotonNetwork.CurrentRoom.PlayerCount}");
SpawnPlayer();
}
// 방 생성 실패 시 재시도
public override void OnCreateRoomFailed(short returnCode, string message)
{
Debug.LogWarning($"방 생성 실패: {message}. 재참가 시도...");
PhotonNetwork.JoinOrCreateRoom(roomName, new RoomOptions { MaxPlayers = maxPlayers }, TypedLobby.Default);
}
// 방 참가 실패 시 새 방 생성
public override void OnJoinRoomFailed(short returnCode, string message)
{
Debug.LogWarning($"방 참가 실패: {message}. 새 방 생성 중...");
PhotonNetwork.CreateRoom(roomName, new RoomOptions { MaxPlayers = maxPlayers });
}
// 다른 플레이어 입장 로그
public override void OnPlayerEnteredRoom(Photon.Realtime.Player newPlayer)
{
Debug.Log($"{newPlayer.NickName} 입장. 현재 인원: {PhotonNetwork.CurrentRoom.PlayerCount}");
}
// 다른 플레이어 퇴장 로그
public override void OnPlayerLeftRoom(Photon.Realtime.Player otherPlayer)
{
Debug.Log($"{otherPlayer.NickName} 퇴장. 현재 인원: {PhotonNetwork.CurrentRoom.PlayerCount}");
}
// 연결 끊김 처리
public override void OnDisconnected(DisconnectCause cause)
{
Debug.LogWarning($"연결 끊김: {cause}. 재연결 시도...");
PhotonNetwork.ConnectUsingSettings();
}
private void SpawnPlayer()
{
Vector3 pos = spawnPoint != null ? spawnPoint.position : Vector3.zero;
Quaternion rot = spawnPoint != null ? spawnPoint.rotation : Quaternion.identity;
PhotonNetwork.Instantiate(playerPrefabName, pos, rot);
}
}
<플레이어 움직임>
using Photon.Pun;
using UnityEngine;
public class Player : MonoBehaviourPun, IPunObservable
{
[Header("Movement")]
[SerializeField] private float walkSpeed = 3f;
[SerializeField] private float runSpeed = 6f;
[SerializeField] private float rotationSmoothTime = 0.1f;
[Header("Animation")]
[SerializeField] private float animationDampTime = 0.1f;
[Header("Network Sync")]
[SerializeField] private float positionLerpSpeed = 10f;
[SerializeField] private float rotationLerpSpeed = 10f;
private Animator animator;
private Rigidbody rb;
private Transform cameraTransform;
private float vertical;
private float horizontal;
private bool isRunning;
private float rotationVelocity;
// 원격 플레이어 동기화용
private Vector3 networkPosition;
private Quaternion networkRotation;
private float networkAnimSpeed;
private static readonly int SpeedHash = Animator.StringToHash("Speed");
void Awake()
{
animator = GetComponent<Animator>();
rb = GetComponent<Rigidbody>();
rb.freezeRotation = true;
rb.interpolation = RigidbodyInterpolation.Interpolate;
networkPosition = transform.position;
networkRotation = transform.rotation;
// 내 캐릭터만 카메라 사용, 원격 플레이어는 물리 비활성화
if (photonView.IsMine)
{
cameraTransform = Camera.main.transform;
}
else
{
rb.isKinematic = true;
}
}
void Update()
{
if (photonView.IsMine)
{
HandleInput();
}
else
{
// 원격 플레이어: 수신한 위치/회전으로 보간
transform.SetPositionAndRotation(
Vector3.Lerp(transform.position, networkPosition, Time.deltaTime * positionLerpSpeed),
Quaternion.Lerp(transform.rotation, networkRotation, Time.deltaTime * rotationLerpSpeed)
);
animator.SetFloat(SpeedHash, networkAnimSpeed, animationDampTime, Time.deltaTime);
}
}
void FixedUpdate()
{
if (!photonView.IsMine) return;
Vector2 inputDir = new(horizontal, vertical);
if (inputDir.sqrMagnitude < 0.01f)
{
rb.linearVelocity = new Vector3(0f, rb.linearVelocity.y, 0f);
return;
}
// 카메라 기준 이동 방향 계산
Vector3 camForward = Vector3.ProjectOnPlane(cameraTransform.forward, Vector3.up).normalized;
Vector3 camRight = Vector3.ProjectOnPlane(cameraTransform.right, Vector3.up).normalized;
Vector3 moveDir = (camForward * vertical + camRight * horizontal).normalized;
// 이동 방향으로 캐릭터 부드럽게 회전
float targetAngle = Mathf.Atan2(moveDir.x, moveDir.z) * Mathf.Rad2Deg;
float smoothAngle = Mathf.SmoothDampAngle(transform.eulerAngles.y, targetAngle, ref rotationVelocity, rotationSmoothTime);
rb.MoveRotation(Quaternion.Euler(0f, smoothAngle, 0f));
// 이동
float currentSpeed = isRunning ? runSpeed : walkSpeed;
Vector3 velocity = moveDir * currentSpeed;
rb.linearVelocity = new Vector3(velocity.x, rb.linearVelocity.y, velocity.z);
}
// Photon 직렬화: 위치/회전/애니메이션 동기화
public void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
if (stream.IsWriting)
{
// 내 데이터를 전송
stream.SendNext(transform.position);
stream.SendNext(transform.rotation);
stream.SendNext(animator.GetFloat(SpeedHash));
}
else
{
// 원격 데이터 수신
networkPosition = (Vector3)stream.ReceiveNext();
networkRotation = (Quaternion)stream.ReceiveNext();
networkAnimSpeed = (float)stream.ReceiveNext();
}
}
private void HandleInput()
{
vertical = Input.GetAxisRaw("Vertical");
horizontal = Input.GetAxisRaw("Horizontal");
isRunning = Input.GetKey(KeyCode.LeftShift);
float animSpeed = 0f;
if (vertical != 0f || horizontal != 0f)
animSpeed = isRunning ? runSpeed : walkSpeed;
animator.SetFloat(SpeedHash, animSpeed, animationDampTime, Time.deltaTime);
}
}
<카메라 세팅용 코드>
using Photon.Pun;
using Unity.Cinemachine;
using UnityEngine;
public class CameraConnector : MonoBehaviour
{
[Tooltip("플레이어 프리팹 안의 CameraTarget 자식 이름")]
[SerializeField] private string cameraTargetName = "CameraTarget";
private CinemachineCamera cinemachineCamera;
void Awake()
{
cinemachineCamera = GetComponent<CinemachineCamera>();
}
void Update()
{
if (cinemachineCamera.Follow != null) return;
// 로컬 플레이어의 CameraTarget을 탐색 (연결되면 더 이상 실행 안 함)
GameObject[] players = GameObject.FindGameObjectsWithTag("Player");
foreach (GameObject player in players)
{
PhotonView pv = player.GetComponent<PhotonView>();
if (pv == null || !pv.IsMine) continue;
Transform target = player.transform.Find(cameraTargetName);
if (target == null)
{
Debug.LogWarning($"[CameraConnector] '{cameraTargetName}' 자식을 찾을 수 없습니다.");
return;
}
cinemachineCamera.Follow = target;
cinemachineCamera.LookAt = target;
Debug.Log("[CameraConnector] 카메라 타겟 연결 완료.");
return;
}
}
}

플레이어 오브젝트 (vrm파일 오브젝트)에 플레이어 컨트롤러, 포톤 뷰, 트렌스폼 뷰, 애니메이터 뷰를 세팅한다.
참고로 애니메이터 뷰는 Disabled로 되어있을 시 작동을 하지 않는다.
카메라에 카메라 커넥터 코드를 연결하고, 플레이를 하면 정상 작동한다.
===========================================================
멀티께임을 만들 수 있게 됐다 !
