VR 멀티플레이어 리듬게임

Unity VR + Photon 기반. WAV 바이너리 파싱, 웨이브폼 시각화, 노트 에디터, 멀티플레이어 동기화, GitHub Actions 자동화.

2025.02.20 ~ 2025.05.17 (87일)|팀 프로젝트 (팀장 / 리드 개발)
UnityC#VR / XRPhoton PUNAudio ProcessingGitHub Actions
1 / 4

개요

VR 멀티플레이어 리듬게임

Unity VR + Photon 기반 멀티플레이어 리듬게임. 커스텀 노트 에디터로 채보를 제작하고, VR 환경에서 여러 플레이어가 동시에 경쟁하는 구조.

  • 개발 형태: 기업 협약 PBL 팀 프로젝트 (기획 3 / 프로그래머 5, 팀장)
  • 기간: 2025.02.20 ~ 2025.05.17 (87일)
  • 담당: 노트 에디터 시스템, 오디오 데이터 파이프라인, VR Player 시스템, Photon 멀티플레이어, GitHub 워크플로우 자동화

동기

기업 협약 기반의 VR 리듬게임 프로젝트였다. 노트 채보를 직접 만들 수 있는 에디터, VR 환경에서의 실시간 플레이, 여러 플레이어가 동시에 경쟁하는 멀티플레이 환경까지 구현해야 했다.

기술적으로 가장 큰 과제는 오디오 데이터 처리였다. VR 환경에서는 UnityWebRequest 같은 기본 API가 정상 동작하지 않아, 외부 WAV 파일을 직접 바이너리 수준에서 파싱해야 했다. 에디터에서는 오디오 파형을 실시간으로 시각화해야 했고, 이 모든 과정에서 메인 스레드를 블로킹하지 않는 비동기 처리가 필수였다.

접근

오디오 파이프라인을 세 단계로 나눴다.

첫째, WAV 바이너리 파싱. RIFF/fmt/data 청크를 직접 읽어 샘플레이트, 비트 깊이, 채널 수를 추출하고 PCM 데이터를 float 배열로 변환하는 커스텀 파서(WavUtility)를 구현했다. VR 플랫폼 제약을 우회하면서도 다양한 WAV 포맷을 처리할 수 있는 구조다.

둘째, 웨이브폼 시각화. 전체 오디오 샘플을 구간별로 집계하여 RMS 진폭 기반의 웨이브폼 텍스처를 생성하고, 노트 에디터 UI에 실시간으로 반영했다.

셋째, 노트 데이터 관리. 트랙 메타데이터와 노트맵을 JSON으로 직렬화하고, async/await 기반 비동기 파일 I/O로 에디터가 멈추지 않도록 처리했다.

멀티플레이어는 Photon PUN으로 구현했고, XRPlayer 시스템에서 로컬/원격 플레이어의 컨트롤러, 인터랙터, 오브젝트 상태를 분리 관리했다.

VR 게임 플레이
VR 게임 플레이

핵심 구현

영역내용
WAV 파싱RIFF/fmt/data 청크 직접 파싱, 8/16bit PCM -> float 정규화, VR 플랫폼 호환
웨이브폼샘플 데이터 구간별 집계, RMS 기반 텍스처 생성, 청크 단위 픽셀 할당
노트 에디터GUID 기반 트랙/노트 관리, async 파일 I/O, JSON 직렬화
멀티플레이어Photon PUN RPC, XRPlayer 로컬/원격 분기, 햅틱 피드백
자동화GitHub Actions 커밋 파싱, 이슈 자동 생성, 일일 보고서 갱신

기술 스택

기술적용 영역
C# / Unity 2020.3VR 게임 로직, 노트 에디터, 오디오 처리
XR Interaction ToolkitVR 컨트롤러, 레이 인터랙터, 햅틱 피드백
Photon PUN멀티플레이어 동기화, RPC, 커스텀 프로퍼티
WAV RIFF 파싱바이너리 오디오 데이터 -> AudioClip 변환
GitHub Actions커밋 기반 이슈 자동화, 일일 보고서 생성

문서 구성

문서내용
01-오디오-데이터-파이프라인WAV 바이너리 파싱, 웨이브폼 텍스처 생성, 비동기 로딩
02-노트-에디터-시스템트랙/노트 데이터 관리, JSON 직렬화, 비동기 I/O
03-VR-멀티플레이어XRPlayer 시스템, Photon 동기화, GitHub Actions 자동화
2 / 4

오디오 데이터 파이프라인

WAV 바이너리 파싱 + 웨이브폼 시각화

VR 환경에서 동작하지 않는 Unity 기본 API를 대체하기 위해 WAV 파일을 바이너리 수준에서 직접 파싱하고, 샘플 데이터를 분석하여 웨이브폼 텍스처를 생성하는 파이프라인.


문제

VR 환경에서 UnityWebRequest를 통한 오디오 로딩이 정상 동작하지 않았다. 플랫폼 제약으로 인해 기본 API를 사용할 수 없는 상황이었고, 외부 파일 시스템의 WAV 파일을 런타임에서 직접 AudioClip으로 변환해야 했다.

동시에, 노트 에디터에서는 오디오 파형을 시각화하여 채보 작업자가 파형을 보면서 노트를 배치할 수 있어야 했다. Unity에는 웨이브폼 시각화 기능이 내장되어 있지 않아, 샘플 데이터를 직접 분석해서 텍스처로 렌더링해야 했다.

접근: WAV 바이너리 직접 파싱

WavUtility 클래스를 구현하여 WAV 파일의 바이너리 데이터를 직접 해석했다. MemoryStream과 BinaryReader로 RIFF 헤더를 검증한 뒤, fmt 서브청크에서 오디오 포맷(채널 수, 샘플레이트, 비트 깊이)을 추출하고, data 서브청크에서 실제 PCM 데이터를 읽어낸다.

WAV -> AudioClip 변환 코드
WAV -> AudioClip 변환 코드

fmt와 data 사이에 다른 서브청크가 끼어 있는 경우가 있어, 청크 이름이 "data"가 아니면 해당 청크 크기만큼 건너뛰는 루프를 추가했다. 추출된 PCM 바이트를 float 배열로 변환한 뒤 AudioClip.Create에 할당하여 런타임에서 즉시 재생 가능한 형태로 만든다.

데이터 청크 파싱 및 AudioClip 생성
데이터 청크 파싱 및 AudioClip 생성

PCM -> float 정규화

ConvertWavToFloat에서 비트 깊이에 따라 정규화 방식을 분기한다. 16bit PCM은 -3276832767 범위를 -1.01.0으로 변환하고, 8bit PCM은 0~255 범위를 같은 범위로 정규화한다. BitConverter.ToInt16으로 리틀 엔디안 바이트 순서를 처리했다.

PCM -> float 변환 코드
PCM -> float 변환 코드

트러블슈팅: WAV 포맷 다양성

실제 WAV 파일들은 헤더 구조가 제각각이었다. RIFF 매직 넘버가 없는 파일, fmt 청크 뒤에 예상치 못한 서브청크가 삽입된 파일, 비표준 비트 깊이를 사용하는 파일 등이 있었다. RIFF/WAVE 매직 넘버 검증을 추가하고, 알 수 없는 서브청크를 건너뛰는 로직으로 다양한 포맷에 대응했다. 손상된 파일의 경우 null을 반환하여 상위 로직에서 안전하게 처리한다.


웨이브폼 텍스처 생성

구현

GetWaveformTexture 메서드에서 AudioClip의 전체 샘플 데이터를 float 배열로 추출한 뒤, 구간별로 집계하여 웨이브폼 텍스처를 생성한다. 텍스처 크기는 UI 영역과 pixelsPerUnit을 기반으로 계산하되, 최대 크기를 제한하여 메모리 사용량을 관리했다.

웨이브폼 텍스처 생성 코드
웨이브폼 텍스처 생성 코드

샘플 데이터는 ProcessSamplesInChunks로 구간별 RMS 값을 계산하고, CHUNK_SIZE 단위로 텍스처에 픽셀을 할당한다. 청크 단위 처리를 통해 텍스처 너비가 클 때도 메모리 할당을 분산했다.

청크 단위 픽셀 할당
청크 단위 픽셀 할당

웨이브폼 디스플레이 결과
웨이브폼 디스플레이 결과

트러블슈팅: 메모리와 해상도

대용량 오디오 파일의 전체 샘플을 한 번에 처리하면 메모리 사용량이 급증했다. MAX_TEXTURE_SIZE를 설정하여 텍스처 해상도를 제한하고, 구간별 집계로 원본 샘플 수와 텍스처 크기의 매핑을 조정했다. 오디오 포맷이나 샘플 수에 따라 웨이브폼이 비정상적으로 출력되는 문제도 있었는데, 채널 수를 곱한 전체 샘플 카운트로 정확한 크기를 산출하여 해결했다.


비동기 오디오 로딩

에디터와 VR 런타임 양쪽에서 오디오 파일을 로딩할 때 메인 스레드를 블로킹하지 않도록 비동기 로딩 파이프라인을 구현했다. LoadAudioClip은 async Task<AudioClip>으로, 캐시 확인 -> 파일 경로 해석 -> UnityWebRequest 비동기 전송 -> AudioClip 변환 순서로 처리한다.

비동기 오디오 로딩 코드
비동기 오디오 로딩 코드

진행 상황은 IProgress<float>를 통해 UI에 실시간으로 반영했고, 로딩된 AudioClip은 Dictionary 캐시에 저장하여 동일 트랙의 중복 로딩을 방지했다.

로딩 완료 및 캐시 저장
로딩 완료 및 캐시 저장

VR 런타임에서는 UnityWebRequest가 동작하지 않으므로, File.ReadAllBytes로 바이너리를 읽고 WavUtility.ToAudioClip으로 변환하는 별도 경로를 구현했다. 코루틴 기반의 LoadTrackAudio에서 파일 존재 확인, 바이너리 읽기, AudioClip 변환을 순차 처리한다.

VR 런타임 오디오 로딩
VR 런타임 오디오 로딩

회고: 코루틴과 async의 혼합

에디터 측 리소스 로딩에서 코루틴(UnityWebRequest)과 Task(async/await) 패턴이 혼합되어 있다. 코루틴은 Unity 메인 루프에 종속적이고 Task는 별도 스레드에서 동작할 수 있어, Unity 오브젝트 접근 시 예기치 않은 버그가 발생하기도 했다. 지금 다시 구현한다면 코루틴 기반으로 통일하여 구조를 단순화할 것이다.

3 / 4

노트 에디터 시스템

트랙/노트 데이터 관리 + 비동기 I/O

리듬게임 채보를 제작하기 위한 노트 에디터. GUID 기반 트랙 관리, JSON 직렬화, 비동기 파일 I/O로 실시간 편집 환경을 구현.


문제

리듬게임의 채보 제작에는 트랙 메타데이터(BPM, 곡명, 아티스트)와 노트맵(각 노트의 타이밍, 위치, 종류) 데이터를 동시에 관리해야 한다. 실시간 편집 환경에서 데이터가 자주 변경되므로, 충돌이나 중복 없이 일관성을 유지하면서도 에디터가 멈추지 않는 구조가 필요했다.

접근: GUID 기반 데이터 관리

EditorDataManager를 통해 트랙과 노트맵 데이터의 생성, 수정, 삭제, 저장, 불러오기를 일원화했다. 각 트랙에 GUID를 부여하여 중복 없이 유일하게 식별할 수 있도록 했고, 노트 추가/수정/삭제 시 NoteData 리스트를 즉시 갱신한다.

트랙 메타데이터 JSON 구조
트랙 메타데이터 JSON 구조

데이터는 JSON으로 직렬화하여 파일로 저장했다. JSON을 선택한 이유는 플랫폼 간 호환성과 사람이 읽을 수 있는 가독성 때문이다. 트랙 메타데이터와 노트맵 데이터는 항상 동기화되도록 처리했다.

비동기 파일 I/O

파일 입출력은 async와 Task.Run()을 활용해 메인 스레드를 블로킹하지 않는다. 대용량 노트맵을 저장할 때 에디터 UI가 멈추는 문제를 방지하기 위한 선택이었다.

노트 데이터 관리 코드
노트 데이터 관리 코드

트러블슈팅: 저장 중 데이터 손실

저장 과정에서 예외가 발생하면 파일이 불완전한 상태로 남아 데이터가 손실되는 문제가 있었다. 각 노트와 트랙에 고유 ID를 부여하여 데이터 정합성을 보장했고, 비동기 입출력 과정에 예외 처리를 추가하여 저장 실패 시 기존 데이터가 유지되도록 했다.

4 / 4

VR 멀티플레이어

XRPlayer 시스템 + Photon 동기화 + GitHub Actions 자동화

VR 환경에서 로컬/원격 플레이어를 분리 관리하는 XRPlayer 시스템, Photon PUN 기반 멀티플레이어 동기화, GitHub Actions 기반 팀 워크플로우 자동화.


XRPlayer 시스템

문제

VR 멀티플레이 환경에서는 로컬 플레이어와 원격 플레이어의 XR 오브젝트 상태를 완전히 다르게 처리해야 한다. 로컬 플레이어는 XR Origin, 컨트롤러, 레이 인터랙터가 활성화되어야 하지만, 원격 플레이어에서는 이 오브젝트들이 비활성화되어야 한다. 이 분기가 명확하지 않으면 컨트롤러 입력이 중복 처리되거나 UI가 오동작한다.

구현

Initialize 메서드에서 네트워크 연결 상태, 소유권(photonView.IsMine), 스테이지 모드 여부를 확인한 뒤, 로컬 전용 스크립트/오브젝트와 원격 전용 오브젝트를 명확히 분리하여 활성화/비활성화를 처리했다. 스테이지 모드에서만 레이 인터랙터를 활성화하여, 로비에서는 불필요한 인터랙션이 발생하지 않도록 했다.

XRPlayer 초기화 코드
XRPlayer 초기화 코드

XR 컨트롤러의 UI 입력에 따라 햅틱 피드백을 제공했고, OnDestroy에서 이벤트 핸들러를 해제하여 중복 등록을 방지했다.

이벤트 핸들러 해제 코드
이벤트 핸들러 해제 코드

트러블슈팅: 이벤트 중복 등록

XR 컨트롤러의 입력 이벤트가 중복 등록되어 햅틱 피드백이 여러 번 발생하는 문제가 있었다. OnDestroy에서 -=로 핸들러를 명시적으로 해제하고, 활성화/비활성화 타이밍을 초기화 시점과 상황별로 분리하여 해결했다.


Photon 멀티플레이어 동기화

구현

Photon PUN을 활용해 XRPlayer의 위치, 오브젝트 상태, XR 입력을 실시간으로 동기화했다. 플레이어별 커스텀 프로퍼티로 상태/설정 정보를 네트워크 상에서 공유하고, RPC를 사용해 Rig 정보나 이벤트를 전체 클라이언트에 즉시 전파했다.

멀티플레이어 동기화
멀티플레이어 동기화

Initialize 메서드에서 PhotonNetwork.IsConnected, PhotonNetwork.InRoom, photonView.IsMine을 순차적으로 확인하여, 네트워크 상태에 따라 오브젝트 활성화를 분기한다.

Photon 초기화 분기 코드
Photon 초기화 분기 코드

트러블슈팅: 소유권 판별 오류

네트워크 불안정 시 photonView.IsMine 판별이 지연되어, 원격 플레이어의 오브젝트가 로컬처럼 활성화되거나 XR 입력이 중복 처리되는 문제가 있었다. Photon 네트워크 상태와 소유권을 이중으로 체크하고, 방 입장/퇴장 시 XRPlayer 인스턴스 관리와 이벤트 핸들러 등록/해제를 명확히 처리하여 해결했다.


GitHub Actions 워크플로우 자동화

구현

팀 협업 효율을 위해 GitHub Actions 기반의 자동화 시스템을 구축했다. 커밋 메시지를 파싱하여 이슈 번호와 TODO 항목을 자동 추출하고, 관련 이슈에 커밋 내역을 코멘트로 등록한다. (issue)로 표시된 TODO는 새로운 이슈로 자동 생성되고, 모든 커밋/이슈/작업 내역은 매일 자동 생성되는 개발 보고서 이슈에 정리/업데이트된다.

GitHub 프로젝트 보고서
GitHub 프로젝트 보고서

트러블슈팅: 자동화 안정성

커밋 메시지 포맷 불일치로 파싱이 실패하거나, GitHub API 호출 제한에 걸려 이슈 생성이 재귀적으로 반복되는 문제가 있었다. 커밋 메시지 규칙을 표준화하고, API 호출 로직에 예외 처리와 재시도 제한을 추가하여 해결했다.

VR 멀티플레이어 리듬게임 · 2025.02.20 ~ 2025.05.17 (87일)