Photon PUN2 와 Voice2로 멀티플레이, 음성채팅을 적용하기 위한 유닛 테스트다.
거두 절미하고 과정을 빠르게 설명
Asset Store 에서 Photon PUN 2, Photon Voice 2 Free 버전을 다운 받는다.
유니티 공식 에셋 스토어에서 검색해서 다운 받으면 된다.
Photon 프로젝트 및 AppId 설정
https://dashboard.photonengine.com/ko-kr

해당 사이트에 접속해 회원가입 / 로그인 진행
Dashboard (관리화면) 으로 이동
새 어플리케이션 만들기로 Voice와 Pun을 생성해준다.
각각 창에 보이는 AppID를 사용해야한다.
유니티에서 세팅
유니티 프로젝트에서 두 에셋을 다운로드 후 임포트 한다.
Window 탭에 있는 Photon Unity Networking / PUN wizard를 클릭한다.
안에 있는 Setup Project를 클릭하면 AppID를 입력할 수 있다.

여기서 끝이 아니라 세팅으로 들어가면 포톤 설정 관련 SO가 나오는데 거기에 드롭다운 하면 VoiceID도 입력할 수 있다.

일단 개발 단계니까 안정적 연결을 위해 FixedRegion도 kr (혹은 원하는 지역) 으로 해두자.
간단하게 씬 구성

유닛 테스트니까 다음 조건만 충족하면 된다.
1. 빌드 파일 실행
2. 해당 씬에 접근
3. 네트워크 연결
4. Player Prefab 생성
5. 움직임 동기화
6. 음성 채팅
최대한 간단하게 구성해보자.
우선 Plane 깔아주고
Player Prefab을 대충 캡슐로 만들어준다.
플레이어 이동은 간단하게 캐릭터컨트롤러를 사용하자.
일단 플레이어에 넣어 줄 컴포넌트들은 다음과 같다.

각 컴포넌트 설명
- Photon View : 플레이어 오브젝트의 네트워크 ID. 필수 컴포넌트이자 네트워크 분기 기준점
- Photon Transform View : Transform을 네트워크 동기화. CC -> transform 변경 -> 이 컴포넌트가 전파
- Photon Voice View : Photon View - Voice 스트림 연결 브릿지. 어떤 Player의 음성인지 식별. Reorder / Speaker 를 자동으로 묶어줌.
- Recorder : 마이크 입력 캡쳐. 음성 인코딩 후 서버 송신.
- Speaker : 원격 플레이어의 음성을 재생하는 수신기. 실제 음성이 들어와야 활성화 됨.
- Audio Source : Speaker가 사용하는 실제 소리 출력 장치
- Audio Listner : 귀
플레이어한테 귀가 달려야하니 플레이어에 오디오 리스너를 넣었다.
오디오 리스너가 여러개가 되지 않도록 주의하자.
이를 한 번에 처리할 간단한 스크립트 (NetPlayerVoiceMover) 의 내부 코드는 아래와 같다.
using System.Collections;
using Photon.Pun;
using Photon.Voice.PUN;
using Photon.Voice.Unity;
using UnityEngine;
using UnityEngine.InputSystem;
[RequireComponent(typeof(PhotonView))]
public sealed class NetPlayerVoiceMover : MonoBehaviourPun
{
[Header("Move")]
[SerializeField] private float _moveSpeed = 5f;
[Header("Voice (Transmit)")]
[SerializeField] private bool _pushToTalk = false;
[SerializeField] private Key _pushToTalkKey = Key.V;
[Header("Voice (3D Attenuation)")]
[SerializeField] private float _minDistance = 1.5f;
[SerializeField] private float _maxDistance = 25f;
[SerializeField] private AudioRolloffMode _rolloff = AudioRolloffMode.Logarithmic;
[SerializeField] private float _dopplerLevel = 0f;
private CharacterController _cc;
private Recorder _recorder;
private PhotonVoiceView _voiceView;
void Awake()
{
_cc = GetComponent<CharacterController>();
_recorder = GetComponent<Recorder>();
_voiceView = GetComponent<PhotonVoiceView>();
}
void Start()
{
// 원격 플레이어는 마이크 캡처 금지
if (_recorder != null)
_recorder.enabled = photonView.IsMine;
// 내 플레이어 송출 ON(또는 PTT)
if (photonView.IsMine)
{
Recorder rec = null;
if (_voiceView != null && _voiceView.RecorderInUse != null)
rec = _voiceView.RecorderInUse;
else
rec = _recorder;
if (rec != null)
rec.TransmitEnabled = !_pushToTalk;
}
// Speaker가 생성되는 타이밍에 맞춰 3D 오디오 세팅
StartCoroutine(CoConfigureSpeaker3D());
}
void Update()
{
if (!photonView.IsMine) return;
Vector3 dir = Vector3.zero;
var kb = Keyboard.current;
if (kb != null)
{
if (kb.wKey.isPressed) dir += Vector3.forward;
if (kb.sKey.isPressed) dir += Vector3.back;
if (kb.aKey.isPressed) dir += Vector3.left;
if (kb.dKey.isPressed) dir += Vector3.right;
}
if (dir.sqrMagnitude > 1f) dir.Normalize();
Vector3 delta = dir * _moveSpeed * Time.deltaTime;
if (_cc != null) _cc.Move(delta);
else transform.position += delta;
// Push-To-Talk
if (_pushToTalk)
{
var rec = (_voiceView != null && _voiceView.RecorderInUse != null) ? _voiceView.RecorderInUse : _recorder;
if (rec != null)
{
bool pressed = kb != null && kb[_pushToTalkKey].isPressed;
rec.TransmitEnabled = pressed;
}
}
}
private IEnumerator CoConfigureSpeaker3D()
{
if (_voiceView == null) yield break;
// 원격 음성 스트림이 실제로 들어오면 SpeakerInUse가 생김
while (_voiceView.SpeakerInUse == null)
yield return null;
var speaker = _voiceView.SpeakerInUse;
var src = speaker.GetComponent<AudioSource>();
if (src == null) src = speaker.gameObject.AddComponent<AudioSource>();
src.spatialBlend = 1f; // 3D
src.rolloffMode = _rolloff;
src.minDistance = _minDistance;
src.maxDistance = _maxDistance;
src.dopplerLevel = _dopplerLevel;
}
}
이제 씬에 입장시 실행할 PunRoomLauncher를 만들어 보자
using Photon.Pun;
using Photon.Realtime;
using UnityEngine;
public sealed class PunRoomLauncher : MonoBehaviourPunCallbacks
{
[Header("Spawn")]
[SerializeField] private string _playerPrefabName = "Player";
[SerializeField] private Vector3 _spawnPos = new Vector3(0f, 1f, 0f);
[Header("Room")]
[SerializeField] private string _roomName = "voice_test_room";
[SerializeField] private byte _maxPlayers = 8;
[Header("Version (must match)")]
[SerializeField] private string _gameVersion = "dev-voice-2026-01";
void Start()
{
PhotonNetwork.AutomaticallySyncScene = false;
PhotonNetwork.GameVersion = _gameVersion;
Debug.Log($"[PUN] ConnectUsingSettings... AppId={PhotonNetwork.PhotonServerSettings.AppSettings.AppIdRealtime}");
PhotonNetwork.ConnectUsingSettings();
}
public override void OnConnectedToMaster()
{
Debug.Log("[PUN] ConnectedToMaster. JoinOrCreateRoom...");
PhotonNetwork.JoinOrCreateRoom(
_roomName,
new RoomOptions { MaxPlayers = _maxPlayers },
TypedLobby.Default
);
}
public override void OnJoinedRoom()
{
Debug.Log($"[PUN] JoinedRoom: {PhotonNetwork.CurrentRoom.Name} / players={PhotonNetwork.CurrentRoom.PlayerCount}");
PhotonNetwork.Instantiate(_playerPrefabName, _spawnPos, Quaternion.identity);
}
public override void OnJoinRoomFailed(short returnCode, string message)
{
Debug.LogError($"[PUN] JoinRoomFailed {returnCode} {message}");
}
public override void OnDisconnected(DisconnectCause cause)
{
Debug.LogError($"[PUN] Disconnected: {cause}");
}
}
이제 이 스크립트를 씬에 배치하고 실행하.. 면 끝이 아니고
씬에 Pun Voice Client 컴포넌트도 배치해야 한다.
얘가 네트워크 상태를 따라 음성 세션을 연결-관리하는 전역 관리자다.
없으면 임시로 생성하는 거 같은데 안정성이 보장되지 않는다고 하니 그냥 배치하자.
결과
빌드 파일 실행시 해당 씬에 Player 생성
다른 사람이 실행시 또 다른 Player 생성
서로 움직임 동기화
오디오 입/출력 확인
거리가 멀수록 오디오가 작게 들림
유닛테스트 완료