네스랩 / 2024
Beyond X-Verse
실시간 트래킹부터 시나리오 편집·리플레이까지
PROJECT IN ACTION
실행 화면과 시연

OVERVIEW
프로젝트와 나의 역할
VIVE·OptiTrack 센서 연동, Addressables 리소스 관리, NPC FSM과 음성·리플레이 시스템을 개발했습니다.
- VIVE Focus Vision HMD 및 VIVE Tracker / OptiTrack 센서 연동
- Unity3D Addressable 기능을 이용한 리소스 관리 기능 개발
- Scene 동적 로드 및 훈련 시나리오 제작 기능 개발
- 위해범 및 시민 NPC FSM 초기 디자인
- CSV 파일 포맷 및 XML, Json 파일을 통한 훈련 리플레이 데이터 저장 기능 개발
- 훈련자간 음성 대화 기능 개발
- 상용 네트워크 모듈을 이용한 네트워크 객체 동기화
- 팀원 업무 분장 및 일정 관리
ENGINEERING NOTES
기능별 구현과 문제 해결
담당 기능의 배경부터 구현 방식과 선택한 범위까지 정리했습니다.
HMD와 OptiTrack의 좌표 기준 맞추기
배경과 과제
VIVE HMD와 OptiTrack을 함께 사용하면서 장비별 원점 차이로 움직임이 어긋났습니다.
내가 구현한 방식
OptiTrack 기준 마커로 위치 원점을 정하고, 머리 회전에는 HMD 자체 데이터를 사용했습니다. 트래커 종류에 따라 연동 방식을 선택하도록 구성했습니다.
선택의 이유와 범위
위치 기준과 머리 회전 입력을 분리했습니다. 아래 발췌는 초기화, 트래커 상태 반영, 클라이언트·서버 간 기준 위치 전달 순서로 이어집니다.
관련 구현 · 기존 프로젝트의 설명용 발췌입니다. 의존성이 생략되어 독립 실행 코드는 아닙니다.
HMD·트래커 초기화원문 코드 발췌
/// HMD + OptiTrack 트래커를 한 좌표계에 묶는 초기화
private void CreateAndSet_Tracker()
{
// [해결] 회전은 HMD 자체 데이터 사용
// OptiTrack RigidBody를 붙이지 않고, XRRig의 Main Camera Transform만 참조
// → OpenXR/VIVE Focus Vision이 매 프레임 갱신하는 회전을 그대로 VRIK headTracker로 사용
HMDTracker = SearchAllChild(XRRig, MAIN_CAMERA_NAME);
// [해결] 위치 원점은 OptiTrack 쪽 "표시용 마커/피벗" 1개
// 씬의 OptitrackPivotObject = Motive에서 별도 Rigid Body로 보낼 수 있는 기준점
m_OptitrackPivotObject = ObjectListManager.Instance.FindOptitrackPivot();
if (m_OptitrackPivotObject == null)
{
Debug.LogError("Optitrack Pivot Object not found!");
return; // 원점이 없으면 두 장비 좌표를 맞출 수 없음
}
// XRRig를 피벗 자식으로 두어 HMD 위치를 OptiTrack 원점 기준으로 재배치
SetupXRRigParent();
// 부위별(Hip/Hand/Foot) OptiTrack 트래커는 아직 안 만들었을 때만 생성
if (!completeTrackerSet)
{
CreateTrackers(); // attributes.ini의 RigidBody ID로 선택 연동
CheckTrackerCompletion(); // 5부위 모두 생성되면 completeTrackerSet = true
}
}
/// XR Rig를 OptiTrack 원점 자식으로 붙여 두 장비의 좌표 어긋남을 흡수
private void SetupXRRigParent()
{
// XRRig(VIVE HMD 루트)를 OptitrackPivotObject 아래로 이동
// 이후 피벗이 움직이면 HMD도 같은 월드 원점을 공유
XRRig.transform.parent = m_OptitrackPivotObject.transform;
// VIVE 룸스케일 원점 오프셋을 버리고, OptiTrack 피벗을 (0,0,0)으로 재정의
XRRig.transform.localPosition = Vector3.zero;
// 부모 피벗 기준 초기 자세. 이후 프레임의 고개 회전은 Main Camera(HMD)가 담당
XRRig.transform.localRotation = Quaternion.identity;
}트래킹 상태와 자세 반영원문 코드 발췌
/// Motive에서 이 컴포넌트의 RigidBodyId 스트림만 읽어 Transform에 반영
bool UpdatePose()
{
// RigidBodyId별 최신 프레임. NetworkCompensation=유니캐스트 지연 보정
OptitrackRigidBodyState rbState =
StreamingClient.GetLatestRigidBodyState(RigidBodyId, NetworkCompensation);
bool returnResult = false;
if (!m_Test)
{
if (rbState != null)
{
// 카메라에 안 보이면(가림) 이전 포즈 유지 → 튀는 현상 완화
if (rbState.IsTracked)
{
returnResult = true;
// world가 아니라 local: 부모(OptitrackPivotObject)가 원점
// 피벗 위치를 옮기면 모든 트래커/HMD가 같이 이동하는 이유
this.transform.localPosition = rbState.Pose.Position;
this.transform.localRotation = rbState.Pose.Orientation;
}
}
}
return returnResult; // BulkHead 등에서 m_TrackingState로 재사용
}HMD Pivot 위치 전달원문 코드 발췌
///송신부
public void SendTerrainData(characterData data)
{
PacketID.ClientTerrainData clientStartTutorialData = new PacketID.ClientTerrainData();
clientStartTutorialData.m_Header.PacketID = PacketID.PACKET_ID.CLIENTTERRAIN;
// ...
// Unity 훈련 영역 Transform = 가상공간에서 쓸 OptiTrack 원점
clientStartTutorialData.m_SceneOptitrackPivot =
TrainAreaManager.Instance.t_TrainArea.position;
// ...
SendData(sendData, Marshal.SizeOf(clientStartTutorialData));
}
///수신부
private void SetupTerrainData(ClientTerrainData clientStartTutorialData)
{
if (TutorialManager.Instance != null)
{
// 패킷의 원점 → TutorialManager가 들고 있는 피벗 좌표
TutorialManager.Instance.m_OptitrackPivot =
clientStartTutorialData.m_SceneOptitrackPivot;
if (DBManager.Instance.m_ServerType == ServerType.Viewer)
{
// Viewer는 자체 트래커를 안 만들 수 있으므로 피벗 오브젝트만 찾아 둠
TutorialManager.Instance.m_OptitrackPivotObject =
ObjectListManager.Instance.FindOptitrackPivot();
}
// OptitrackPivotObject.position에 적용 → XRRig/트래커 전부 따라감
TutorialManager.Instance.SettingPlayerArea();
}
// 훈련 구역 벽/스케일도 같은 피벗으로 맞춤
TrainAreaManager.Instance.t_TrainArea.position =
clientStartTutorialData.m_SceneOptitrackPivot;
}외부 목록을 통한 리소스 동적 로드
배경과 과제
리소스 업데이트를 쉽게 반영하기 위해 외부 XML 목록과 Addressables를 연결해야 했습니다. FishNet과 함께 적용하는 과정에서 참고 자료가 부족했습니다.
내가 구현한 방식
XML의 에셋 주소를 읽고 카탈로그를 불러온 뒤, 네트워크 오브젝트를 등록하는 로드 흐름을 구성했습니다. Cursor AI의 코드 지원을 참고하되, 기능을 분석하고 반복 디버깅하며 적용했습니다.
선택의 이유와 범위
XML은 사용할 리소스 목록을, Addressables는 로드를 담당하도록 구성했습니다. 아래는 목록 정의에서 네트워크 에셋 로딩으로 이어지는 설명용 코드입니다.
관련 구현 · 기존 프로젝트의 설명용 발췌입니다. 의존성이 생략되어 독립 실행 코드는 아닙니다.
외부 XML 리소스 목록원문 코드 발췌
<Root>
<!-- Addressables 카탈로그 경로. 실제 런타임은 AssetsbundleLoader의 _catalogPath를 사용 -->
<AssetsbundlePath>.../catalog_0.1.json</AssetsbundlePath>
<AssetsData>
<!-- 쉼표 구분 Addressable 주소. 예: Bodyguard_Male_01 을 키로 프리팹/Preview/Avatar를 로드 -->
<NPCAssets>Bodyguard_Male_01, VIP_Male_01, Civil_Male_01, ...</NPCAssets>
<VRPlayerAssets>Bodyguard_Male_01, ...</VRPlayerAssets>
<WeaponAssets>Glock19</WeaponAssets>
<SkyBoxAssets>Day, Night, Dawn, ...</SkyBoxAssets>
<BulkHeadAssets>BulkHead_Door, BulkHead_Windows, BulkHead_Wall</BulkHeadAssets>
<NPCAnimatorAssets>Friendly, Civilian, Opposing, VIP</NPCAnimatorAssets>
<PlayerAnimatorAssets>Friendly, Opposing, VIP</PlayerAnimatorAssets>
<VRPlayerHeadMaterial>VR_Transparency</VRPlayerHeadMaterial>
</AssetsData>
</Root>XML 주소·카탈로그 읽기원문 코드 발췌
// XML에서 Addressable 주소 목록을 읽고, 외부 카탈로그를 붙여 동적 로드를 시작한다.
public void LoadXMLData()
{
if (_isAllAssetsLoaded) return; // 중복 로드 방지 (씬 전환 후에도 싱글톤 유지)
try
{
LoadXMLConfiguration(); // StreamingAssets의 ExternalAssetsbundle.xml 파싱
// catalog_0.1.json = Addressables 빌드 결과물. 경로만 바꾸면 리소스 세트 교체 가능
StartCoroutine(LoadAssetsBundle(Application.streamingAssetsPath + _catalogPath));
}
catch (Exception e)
{
Debug.LogError($"[AssetsbundleLoader] XML Load Error: {e.Message}");
}
}
private void LoadXMLConfiguration()
{
_xmlDoc = new XmlDocument();
string xmlPath = Application.streamingAssetsPath + _xmlFilePath; // 빌드 후에도 수정 가능
if (!System.IO.File.Exists(xmlPath))
throw new System.IO.FileNotFoundException($"XML file not found: {xmlPath}");
_xmlDoc.Load(xmlPath);
_root = _xmlDoc.SelectSingleNode("Root"); // <AssetsData> 하위의 타입별 노드를 찾는다
}
// 핵심: 로컬/원격 Addressables 카탈로그를 런타임에 붙인다.
// 두 번째 인자 true = 카탈로그 로드 완료 후 핸들 자동 해제 후보. 이후 _dbLoadHandle로 추적한다.
private IEnumerator LoadAssetsBundle(string path)
{
var downloadHandle = Addressables.LoadContentCatalogAsync(path, true);
downloadHandle.Completed += OnCatalogLoadComplete;
while (!downloadHandle.IsDone)
yield return null; // Addressables는 비동기. 메인스레드를 막지 않음
}
private void OnCatalogLoadComplete(AsyncOperationHandle<IResourceLocator> handle)
{
if (handle.Status == AsyncOperationStatus.Succeeded)
{
_dbLoadHandle = handle; // 나중에 Addressables.Release로 메모리 해제
InitializeAssetLists(); // XML → string[] (NPC, Weapon, SkyBox ...)
StartAssetLoading(); // 타입별 Addressables.Load* 시작
}
else
{
Debug.LogError($"[AssetsbundleLoader] Catalog load failed: {handle.OperationException?.Message}");
}
}
// XML 노드 하나를 "A,B,C" 형태로 읽어 Addressable 주소 배열로 만든다.
// 리소스 추가/삭제는 XML만 수정하면 되고, C# switch는 AssetType만 보면 된다.
private string[] ParseAssetList(string nodeName)
{
XmlNode assetDataNode = _root?.SelectSingleNode("AssetsData");
string assetListString = XMLParser.Instance?.GetInnerText(assetDataNode, nodeName);
if (string.IsNullOrEmpty(assetListString))
return new string[0];
// 엑셀/메모장에서 붙여넣은 개행·공백을 제거한 뒤 콤마 분리
assetListString = assetListString.Replace("\r", "").Replace("\n", "").Replace(" ", "");
return assetListString.Split(',');
}
private void GetNPCAssetsList()
{
_npcAssetsList = ParseAssetList("NPCAssets");
// NPC 1개 = Prefab + Preview Sprite + Avatar 3개 로드 → 진행률 분모에 *3
_totalAssetCount += _npcAssetsList.Length * 3;
}네트워크 에셋 동적 로드원문 코드 발췌
// GameObject(NetworkObject) 전용 공통 로더.
// XML 주소 → Addressables.LoadAssetsAsync → FishNet Spawnable 등록 → ObjectListManager 캐시
private IEnumerator LoadGameObjectAsset(
string assetName,
System.Action<GameObject, string> onLoadComplete,
AssetType assetType)
{
yield return new WaitForSeconds(_assetLoadDelay);
SinglePrefabObjects spawnablePrefabs = null;
AsyncOperationHandle<IList<GameObject>> handle = default;
try
{
// FishNet은 Prefab을 CollectionId(ushort)로 찾는다.
// Addressable 이름을 안정 해시로 ID화해서, 빌드 시점 Prefab 목록 없이도 런타임 등록한다.
spawnablePrefabs = (SinglePrefabObjects)_networkManager
.GetPrefabObjects<SinglePrefabObjects>((ushort)GetStableHash(assetName), true);
handle = Addressables.LoadAssetsAsync<GameObject>(assetName, addressable =>
{
if (addressable == null) return;
NetworkObject nob = addressable.GetComponent<NetworkObject>();
if (nob != null)
{
cache.Add(nob); // 로더 내부 캐시 (완료 시 CollectionCaches에 Store)
spawnablePrefabs.AddObject(nob); // ★ FishNet이 Spawn 가능하게 등록
onLoadComplete?.Invoke(addressable, assetName);
}
});
_gameObjectHandles[assetName] = handle; // 해제용 핸들 추적 (메모리 누수 방지)
}
catch (Exception e)
{
Debug.LogError($"[AssetsbundleLoader] Failed to load GameObject asset {assetName}: {e.Message}");
yield break;
}
yield return handle; // Completed까지 대기 후 진행률 +1
_AssetsLoadedCount++;
OnAssetLoadComplete(assetName);
}
// Sprite / Avatar / Material 등 NetworkObject가 아닌 에셋
private IEnumerator LoadSingleAsset<T>(string assetName, System.Action<T, string> onLoadComplete)
where T : UnityEngine.Object
{
yield return new WaitForSeconds(_assetLoadDelay);
AsyncOperationHandle<T> handle = Addressables.LoadAssetAsync<T>(assetName);
_assetHandles[assetName] = handle;
yield return handle;
if (handle.IsValid() && handle.Result != null)
{
onLoadComplete?.Invoke(handle.Result, assetName); // ObjectListManager에 타입별 저장
_AssetsLoadedCount++;
OnAssetLoadComplete(assetName);
}
}
// 같은 이름 → 항상 같은 ushort. Server/Client가 같은 Addressable 키를 쓰면 Prefab ID가 맞는다.
public int GetStableHash(string s)
{
uint hash = 0;
foreach (byte b in System.Text.Encoding.Unicode.GetBytes(s))
{
hash += b;
hash += (hash << 10);
hash ^= (hash >> 6);
}
hash += (hash << 3);
hash ^= (hash >> 11);
hash += (hash << 15);
return (int)(hash % _maxHashValue); // ushort 범위로 맞춤
}씬 로드와 훈련 시나리오 저작
배경과 과제
기능 씬·맵 씬을 동적으로 불러오고, 훈련 종류에 따라 NPC 행동 Task를 구성해야 했습니다. 초기에는 Task의 범위가 명확하지 않았습니다.
내가 구현한 방식
Addressables와 FishNet의 씬 로드 흐름을 연결했습니다. 경찰·특수부대 등 초기 훈련 대상을 먼저 정하고, 해당 시나리오에 필요한 Waypoint와 행동 Task부터 작성했습니다.
선택의 이유와 범위
모든 훈련 유형을 한 번에 다루기보다 초기 대상에 필요한 Task부터 만들고 고도화하는 범위를 선택했습니다.
관련 구현 · 기존 프로젝트의 설명용 발췌입니다. 의존성이 생략되어 독립 실행 코드는 아닙니다.
Addressables 씬 처리원문 코드 발췌
// FishNet SceneManager가 매 프레임 호출하는 추상 메서드를 Addressables API로 재구현한다.
// DefaultSceneProcessor와 인터페이스는 같고, 내부만 Addressables.LoadSceneAsync / UnloadSceneAsync로 교체.
public sealed class AddressableSceneProcessor : SceneProcessorBase
{
// FishNet은 Scene 객체만 알고 AsyncOperationHandle은 모른다.
// 언로드 때 Addressables.UnloadSceneAsync(handle)을 호출하려면 로드 핸들을 우리가 보관해야 한다.
private readonly Dictionary<int, AsyncOperationHandle<SceneInstance>> _loadedScenesByHandle = new(4);
private readonly List<Scene> _loadedScenes = new(4);
private readonly List<AsyncOperationHandle<SceneInstance>> _loadingAsyncOperations = new(4);
private AsyncOperationHandle<SceneInstance> _currentAsyncOperation;
// Addressable이 아닌 오프라인 씬(MovedObjectsHolder 등) 언로드용
private AsyncOperation _currentBasicAsyncOperation;
// Inspector에 등록한 Addressable 씬 목록. 이름은 소문자 키로 조회
private Dictionary<string, AssetReference> _compiledAddressableReferences;
public override void BeginLoadAsync(string sceneName, LoadSceneParameters parameters)
{
sceneName = Path.GetFileNameWithoutExtension(sceneName);
// activateOnLoad=false: FishNet이 ActivateLoadedScenes()에서 일괄 활성화한다.
// 네트워크 씬은 서버/클라가 같은 타이밍에 켜야 하므로 즉시 활성화하면 안 된다.
AsyncOperationHandle<SceneInstance> loadHandle =
Addressables.LoadSceneAsync(sceneName, parameters, false);
_loadingAsyncOperations.Add(loadHandle);
_currentAsyncOperation = loadHandle; // IsPercentComplete()가 이 핸들을 폴링
ClientUIManager.Instance.SetLoadingCanvas();
ClientUIManager.Instance.ActiveTerrainLoadingBar(true);
}
public override void BeginUnloadAsync(Scene scene)
{
// 우리가 Addressable로 로드한 씬이면 핸들로 언로드해야 참조 카운트가 맞는다.
if (_loadedScenesByHandle.TryGetValue(scene.handle, out var loadHandle))
{
AsyncOperationHandle<SceneInstance> unloadHandle =
Addressables.UnloadSceneAsync(loadHandle, false);
_currentAsyncOperation = unloadHandle;
_loadedScenes.Remove(scene);
_loadedScenesByHandle.Remove(scene.handle);
}
else
{
// FishNet이 만든 MovedObjectsHolder 등은 일반 UnloadSceneAsync
_currentBasicAsyncOperation = UnityEngine.SceneManagement.SceneManager.UnloadSceneAsync(scene);
}
}
public override bool IsPercentComplete()
{
// FishNet SceneManager 루프:
// BeginLoadAsync → while (!IsPercentComplete()) yield return null;
// Addressable 핸들의 IsDone / PercentComplete를 FishNet이 기대하는 bool로 변환한다.
if (_currentAsyncOperation.IsValid())
{
float progress = _currentAsyncOperation.PercentComplete;
ClientUIManager.Instance.m_TerrianLoadingProgressBar.value = progress * 100;
bool completed = _currentAsyncOperation.IsDone;
if (completed)
{
// ★ 핵심 우회: FishNet 로딩 루프는 Addressable 핸들을 보관하지 않는다.
// 완료 시점에 우리가 scene.handle → loadHandle 맵을 만들어야 이후 언로드가 가능하다.
AddLoadedScene(_currentAsyncOperation);
ClientUIManager.Instance.ActiveTerrainLoadingBar(false);
ClientUIManager.Instance.SetMainMenuCanvas();
}
return completed;
}
return false;
}
public void AddLoadedScene(AsyncOperationHandle<SceneInstance> loadHandle)
{
Scene scene = _currentAsyncOperation.Result.Scene;
if (_loadedScenesByHandle.ContainsKey(scene.handle)) return;
_loadedScenes.Add(scene);
_loadedScenesByHandle.Add(scene.handle, loadHandle);
}
public override void ActivateLoadedScenes()
{
// BeginLoadAsync에서 activateOnLoad=false로 뒀으므로, FishNet이 준비되면 여기서 활성화
foreach (var loadingAsyncOp in _loadingAsyncOperations)
loadingAsyncOp.Result.ActivateAsync();
}
}시간대별 씬 선택·로드원문 코드 발췌
// 시나리오 시간대(TOD)/날씨에 따라 Addressable 씬 키를 조합한다.
// 예: TrainingMap + Night → "TrainingMap_Night" (XML/에셋 키와 동일한 규칙)
public void LoadTerrain(string mapName, ScenarioTOD tod)
{
ShowTerrainLoadingUI(true);
string finalMapName = GetMapNameWithTOD(mapName, tod);
LoadSceneName = finalMapName;
StartCoroutine(StartLoadSceneAsync(finalMapName));
}
public IEnumerator StartLoadSceneAsync(string mapName)
{
// Additive: 로비/네트워크 씬은 유지하고 지형만 추가
var downloadHandle = Addressables.LoadSceneAsync(mapName, LoadSceneMode.Additive);
downloadHandle.Completed += SceneDownloadComplete;
while (!downloadHandle.IsDone)
{
// Server / Client / Viewer 각각 다른 로딩바
float progress = downloadHandle.PercentComplete;
// ... UI 반영
yield return null;
}
}
// 모든 클라 지형 로드 완료 후, FishNet이 같은 씬을 네트워크 씬으로 다시 맞춘다.
public void FishNetSceneLoad()
{
FishNet.Managing.Scened.LoadOptions loadOptions = new FishNet.Managing.Scened.LoadOptions
{
AutomaticallyUnload = false,
Addressables = true, // SceneProcessor가 Addressable 경로를 쓰도록 표시
};
SceneLoadData sld = new SceneLoadData(m_FinalSceneName);
sld.PreferredActiveScene = new PreferredScene(sld.SceneLookupDatas[0]);
sld.ReplaceScenes = ReplaceOption.None;
sld.Options = loadOptions;
// LoadGlobalScenes → SceneManager가 AddressableSceneProcessor.BeginLoadAsync 호출
InstanceFinder.SceneManager.LoadGlobalScenes(sld);
}NPC와 Task 로드원문 코드 발췌
private void LoadEntities()
{
foreach (var item in ...S_Entity)
{
CreateAndSetupEntity(item); // 아바타 + 진영 FSM 부착
LoadWaypointsForEntity(item); // 경로 + Task 복원
}
}
private void LoadWaypointsForEntity(S_Entity entityData)
{
foreach (var waypointTask in entityData.S_WaypointTask)
{
WaypointManager.Instance.SetLoadXMLDataCreateWaypoint(
waypointTask.Vec_Position,
SpawnManager.Instance._TempCreateObject,
waypointTask.S_Task);
}
}
void Start()
{
m_WaypointData = WaypointManager.Instance.m_SelectedWaypoint.GetComponent<WaypointData>();
m_Task = m_WaypointData.GetTaskData();
GetAllAttackObjectList(); // VIP/경호원/시민/위해범 전부 타겟 후보
// XML에서 읽은 byte → Dropdown 인덱스 (PacketID enum 순서와 동일해야 함)
m_MoveTypeDropDown.value = m_Task.B_MoveType;
m_MoveActionDropDown.value = m_Task.B_MoveMode;
m_ActionTypeDropDown.value = m_Task.B_ActionType;
SetLoadAttackTarget();
}
public void SetActionType()
{
ActionType actionType = (ActionType)m_ActionTypeDropDown.value;
if (actionType == ActionType.Attack)
{
m_SelectAttackObject.interactable = true; // 위해범이 VIP를 노리는 경우
SetAttackTarget();
}
else
{
m_SelectAttackObject.interactable = false; // Wait/의전 액션은 타겟 불필요
}
m_WaypointData.SetTaskAction((byte)actionType);
}시야와 동료 정보를 반영하는 NPC FSM
배경과 과제
NPC가 적을 발견하는 조건뿐 아니라 장애물, 주변 동료와의 상황 공유도 고려해야 했습니다.
내가 구현한 방식
Waypoint 기반 FSM을 초기 설계하고, FOV 및 장애물 검사로 적을 탐지하도록 구성했습니다. 적 발견이나 교전 정보를 주변 아군에게 전달해 무리가 상황을 인지하도록 했습니다.
선택의 이유와 범위
담당 범위는 위해범·시민 NPC의 초기 FSM 설계입니다. 아래 발췌에서 공통 상태 구조, 탐지 인터페이스, 소리·정보 전달을 확인할 수 있습니다.
관련 구현 · 기존 프로젝트의 설명용 발췌입니다. 의존성이 생략되어 독립 실행 코드는 아닙니다.
공통 FSM 구조원문 코드 발췌
class FiniteStateMachine<T, U>
{
private T entity; // 상태 주체 (CivilFSM, GunOpposingFSM 등)
private Dictionary<U, FiniteState<T, U>> stateDic; // StateID → 상태 인스턴스
private FiniteState<T, U> currentState;
public FiniteState<T, U> previousState;
// 서버 FixedUpdate에서 호출됨. 현재 상태의 StateUpdate만 돌린다.
public void ExcuteState()
{
if (currentState != null) currentState.StateUpdate();
}
// 기존 상태 Exit → 새 상태 Enter. 같은 상태면 무시.
public void ChangeState(FiniteState<T, U> nextState)
{
if (currentState == nextState) return;
if (currentState != null)
{
currentState.StateExit();
previousState = currentState;
}
currentState = nextState;
currentState.StateEnter();
}
// 각 NPC FSM.Init()에서 Idle/Move/Attack을 여기 등록한다.
public void AddState(FiniteState<T, U> state)
{
if (state == null) return;
state.SetEntity(entity); // 상태에 Owner(FSM) 주입
stateDic.Add(state._StateID, state);
}
}NPC 시야 인터페이스원문 코드 발췌
///공통 NPC FOV 인터페이스
public interface IAgentFSM
{
Transform m_SearchPoint { get; set; } // 눈(센서) 위치
float m_DetectionRange { get; set; } // OverlapSphere 반경
float m_DetectionAngle { get; set; } // 전방 시야각(도)
LayerMask m_TargetLayer { get; set; } // 적으로 볼 레이어
LayerMask m_ObstacleLayer { get; set; } // 벽/바닥 — 이걸 맞으면 시야 차단
GameObject[] DetectTargetsInFOV();
}
///탐지 코드
public static void DetectTargetsInFOV(...)
{
Vector3 origin = sensorTransform.position; // SearchPoint(눈)
Vector3 forward = sensorTransform.forward;
// 전방 반각: Inspector 60도면 좌우 30도씩
float cosHalfFov = Mathf.Cos(agent.m_DetectionAngle * 0.5f * Mathf.Deg2Rad);
// 1) 반경 안 후보만 구형 Overlap (Trigger 무시)
Collider[] candidates = Physics.OverlapSphere(
origin, agent.m_DetectionRange, agent.m_TargetLayer, QueryTriggerInteraction.Ignore);
foreach (Collider candidate in candidates)
{
if (candidate.transform.root == ownerRootTransform)
continue; // 자기 몸 제외
Vector3 targetPoint = candidate.ClosestPoint(origin);
Vector3 directionToTarget = (targetPoint - origin).normalized;
// 2) FOV 원뿔 밖이면 탈락 (벽과 무관하게 각도로 먼저 컷)
if (Vector3.Dot(forward, directionToTarget) < cosHalfFov)
continue;
// 3) 눈→목표 사이에 ObstacleLayer가 있으면 탈락 = 벽 너머 미탐지
if (!HasLineOfSight(origin, candidate, directionToTarget, distance, agent.m_ObstacleLayer))
continue;
results.Add(candidate.gameObject);
}
}소리 반응·교전 정보 공유원문 코드 발췌
///총격 소리 반응
public static void PublishPlayerGunshot(Transform shooter, float hearRadius = DefaultHearRadius)
{
FloorOccupantTracker shooterFloor = shooter.GetComponent<FloorOccupantTracker>();
shooterFloor.ForceUpdateFloor();
for (int i = s_RegisteredNPCs.Count - 1; i >= 0; i--)
{
GunOpposingFSM npc = s_RegisteredNPCs[i];
if (npc.m_DieState) continue;
// 다른 건물/층이면 총소리를 듣지 않음
if (!npc.FloorTracker.IsSameFloorAs(shooterFloor))
continue;
// NavMesh 경로 거리 우선, 없으면 직선 거리
if (!NavMeshDistanceUtility.IsWithinAlertDistance(npc.transform.position, shooter.position, hearRadius))
continue;
npc.ReceiveGunshotAlert(shooter); // → ApplyExternalAttackTarget → Attack
}
}
///동료가 공격시 주변 무리 반응
void UpdateShot()
{
m_ShotTimer -= Time.fixedDeltaTime;
if (m_ShotTimer > 0f) return;
Vector3 shotDirection = CalculateShotDirection();
if (shotDirection.sqrMagnitude > Mathf.Epsilon)
{
m_Owner.FireShot(shotDirection);
// 총소리/공유만으로 쏜 게 아니라, 눈으로 보고 쐈을 때만 아군에게 알림
if (m_Owner.HasConfirmedTargetDetection())
m_Owner.BroadcastAttackInfoToSiblings();
}
}
///위해범 교전 FSM 대략적 파이프라인
void SearchTargets() // Update에서 m_CheckInterval(0.2초)마다
{
// OverlapSphere + 각도 + 벽 Raycast
AgentFSMFieldOfViewUtility.DetectTargetsInFOV(this, transform, transform, m_DetectedObjectBuffer);
// ... DetectObjectArray 채움 ...
RefreshTargetArrays();
}
void RefreshTargetArrays()
{
// 히트 콜라이더 → 플레이어 루트 (중복/사망 제거)
BuildTargetArray(...);
// FOV에 있으면 가장 가까운 적, 없으면 공유 타겟 유지
m_FinalTarget = ResolveFinalTarget(m_TargetArray, transform, m_SharedAttackTarget, isAttack);
// FinalTarget의 부위 콜라이더만 모아 조준점 후보로
BuildFinalDetectObjectArray(...);
}
public static Transform ResolveFinalTarget(...)
{
Transform closestTarget = FindClosestTarget(targetArray, selfTransform);
if (closestTarget != null)
return closestTarget;
// FOV가 비어도 Attack 중이면 총소리/형제 공유 타겟을 유지
if (isAttackState && sharedAttackTarget != null && IsAlivePlayer(sharedAttackTarget))
return sharedAttackTarget;
return null;
}동작 기록과 음성 기록을 연결하는 리플레이
배경과 과제
프레임을 제어하는 훈련 리플레이에 음성까지 연결하려면, 동작 데이터와 음성 기록을 함께 관리해야 했습니다.
내가 구현한 방식
CSV 기반 동작 기록과 별도 JSON 음성 기록을 구성하고 프레임 정보를 연결했습니다.
선택의 이유와 범위
동작과 음성을 서로 다른 기록으로 보관하고 프레임 정보를 연결한 구조입니다. 저장 형식의 선택과 실제 장비 환경의 동기화 품질은 구분해 확인해야 합니다.
관련 구현 · 기존 프로젝트의 설명용 발췌입니다. 의존성이 생략되어 독립 실행 코드는 아닙니다.
리플레이 CSV 기록원문 코드 발췌
public void CreateReplayCSVFile() ///CSV 파일 초기 생성 함수
{
// ... 경로/파일명 생성 ...
savePath += saveCSVFileName + ".csv";
m_SaveFullPath = savePath;
// 음성은 CSV에 넣지 않고, 같은 세션명으로 JSON 분리 저장
m_SaveFullPathAudio = saveDirectoryPath + saveCSVFileName + "_audio.json";
m_SaveFullPathTTSAudio = saveDirectoryPath + saveCSVFileName + "_tts_audio.json";
m_SaveFullPathRealtimeAudio = saveDirectoryPath + saveCSVFileName + "_realtime_audio.json";
m_SaveFullPathBlendShape = saveDirectoryPath + saveCSVFileName + "_blendshape.json";
}
// ReplayManager.LateUpdate (서버 + Play 모드일 때) 데이터 저장
if (currentPlayMode == PacketID.PlayMode.Play && ShouldRecordReplayToDisk())
{
if (!isSaving && !isApplicationQuitting)
{
// ① Bone/Animator/이벤트 → m_SaveReplayData 채움
SetReplayTraineeTransformData();
// ② 한 행(List<string>)을 큐에 넣고, 백그라운드 스레드가 CSV에 flush
// → 메인 스레드 Frame Spike 완화
SaveCSVDataAsync();
ResetReplayEventData();
// ③ 음성·BlendShape도 "같은" m_UIFrame 을 씀
// → 리플레이 시 타임라인 슬라이더 = 프레임 인덱스 하나로 전부 동기화
foreach (var headBlendShapeData in cachedRealtimeAvatars)
{
// MetaVoice 출력 버퍼에 CSV 프레임 번호 주입
headBlendShapeData.vcAudioSourceOutput
?.SetCurrentCSVFrame((int)m_SaveReplayData.m_UIFrame);
}
ttsAudioManager?.SetCurrentFrame(m_SaveReplayData.m_UIFrame);
ttsAudioManager?.CollectAudioFrameData(); // 메모리에 적재, 디스크는 종료 시
realtimeAudioManager?.SetCurrentFrame(m_SaveReplayData.m_UIFrame);
realtimeAudioManager?.CollectAudioFrameData();
blendShapeControl?.SetCurrentFrame(m_SaveReplayData.m_UIFrame);
blendShapeControl?.CollectBlendShapeFrameData();
}
}
// 리플레이 시 프레임 제어
// ReplayCoroutine — 속도(1/2/4/8)만큼 프레임 점프
public IEnumerator ReplayCoroutine()
{
while (true)
{
if (m_CurrentReplayFrame < m_LoadCSVReplayData.Count)
{
SetCurrentReplayData(); // CSV 행 → Transform/Animator 적용
m_CurrentReplayFrame += m_ReplaySpeed; // 배속 = 프레임 스킵
}
// JSON 오디오/BlendShape도 동일 프레임으로 맞춤
ttsAudioManager?.SetCurrentReplayFrame(m_CurrentReplayFrame);
realtimeAudioManager?.SetCurrentReplayFrame(m_CurrentReplayFrame);
blendShapeControl?.SetCurrentReplayFrame(m_CurrentReplayFrame);
m_TimeLineSlider.value = m_CurrentReplayFrame; // UI 시크
yield return null;
}
}음성 JSON 기록원문 코드 발췌
// 스키마 정보 구조체
// TTSAudioManager — 설명용 주석 보강
public class TTSAudioSaveData
{
// key = SHA256 해시 → 같은 발화 WAV는 한 번만 저장(중복 제거)
public Dictionary<string, TTSAudioClipInfo> ttsAudioClips;
// key = 동일 해시 → 그 클립이 "어느 프레임에서" 재생 중인지 리스트
public Dictionary<string, List<TTSAudioPlaybackInfo>> ttsPlaybackInfos;
public string timestamp;
public uint totalFrames; // 세션 총 프레임(CSV와 맞춤)
}
public class TTSAudioClipInfo
{
public string audioHash;
public byte[] audioBytes; // 실제 음성 바이너리
public float clipLength;
public int npcID;
public string audioSource = "TTS";
}
public class TTSAudioPlaybackInfo
{
public uint frame; // ★ CSV Frame 과 동일 좌표계
public float audioTime; // 클립 내 재생 위치(시크/배속용)
public bool isPlaying;
public int npcID;
}
//NPC 음성 저장
public override void CollectAudioFrameData()
{
// NPC만 (플레이어 실시간 음성은 RealtimeAudioManager)
foreach (var npcPair in ObjectListManager.Instance.All_HeadBlendShapeData_List)
{
if (headBlendShapeData.m_isPlayerAvatar) continue;
// TTS가 실제로 재생 중이고 무음이 아닐 때만
if (shouldSaveTTSAudio)
SaveTTSAudioData(headBlendShapeData, npcID);
// → 새 클립이면 audioBytes 저장
// → 매 프레임 playbackInfo.frame = currentFrame 추가
}
}
public override void SaveAudioData(string filePath)
{
ttsAudioSaveData.totalFrames = currentFrame;
SaveJsonToFile(ttsAudioSaveData, filePath); // → *_tts_audio.json
}
//훈련자 음성 저장
// RealtimeAudioManager
public class RealtimeAudioClipInfo
{
public byte[] audioBytes;
public int startFrame; // 세그먼트 시작 = CSV 프레임
public int endFrame; // 세그먼트 끝
public int segmentIndex;
// ...
}
public class RealtimeAudioPlaybackInfo
{
public uint frame; // startFrame ~ endFrame 사이 매 프레임 1건
public float audioTime; // 세그먼트 내 상대 시간
public int npcID;
public int segmentIndex;
}
// 저장 시: start~end 구간에 대해 frame 단위 playback 행 생성
for (int frameIndex = 0; frameIndex < segmentLength; frameIndex++)
{
realtimePlaybackInfos[audioHash].Add(new RealtimeAudioPlaybackInfo
{
frame = (uint)(csvStartFrame + frameIndex), // CSV와 동일 시계
audioTime = frameIndex * timePerFrame,
isPlaying = true,
npcID = npcID,
// ...
});
}음성 끊김과 지연 사이의 선택
배경과 과제
마이크 데이터를 프레임 단위로 곧바로 전송·재생하는 과정에서 음성이 끊기는 문제가 있었습니다.
내가 구현한 방식
일정량의 음성 프레임을 모으고 오디오 코덱과 버퍼를 활용하는 송수신 흐름을 구성했습니다.
선택의 이유와 범위
음성의 연속성과 전송 지연 사이의 균형을 다룬 구현입니다. 버퍼에 프레임을 모으는 구조를 보여주며, 지연 시간이나 음질의 정량 측정치는 포함하지 않습니다.
관련 구현 · 기존 프로젝트의 설명용 발췌입니다. 의존성이 생략되어 독립 실행 코드는 아닙니다.
음성 버퍼·코덱 설정원문 코드 발췌
// VcConfig.cs — 음성 통신 품질/지연 트레이드오프 설정
[Serializable]
public class VcConfig
{
// 48kHz PCM 기준. Opus VOIP에 맞춘 샘플레이트
public const int SamplesPerSecond = 48_000;
// 네트워크로 묶을 오디오 덩어리 크기 (10/20/40ms)
// 기본 20ms → 초당 50프레임, 프레임당 960 샘플
// ※ 프레임을 크게 하면 트래픽↓, 대신 버퍼 지연↑·패킷 손실 민감↑
public OpusFramesize framesize = OpusFramesize.OPUS_FRAMESIZE_20_MS;
// ★ 해결방안 핵심: 재생 전 최소로 쌓아둘 "분수 프레임" 수
// 1~10 범위. 낮으면 지연↓·끊김↑ / 높으면 지연↑·끊김↓
// 설명의 "5~10프레임 딜레이 감수"와 대응
[Range(1, 10)]
public float outputMinBufferSize = 2.0f;
public void Init()
{
// 예: 20ms → framesPerSecond=50, samplesPerFrame=960
framePeriodMs = /* framesize → 10/20/40 */;
framesPerSecond = 1000 / framePeriodMs;
secondsPerFrame = framePeriodMs / 1000f;
samplesPerFrame = SamplesPerSecond / framesPerSecond;
}
}
// VcMic.cs — Unity Microphone에서 samplesPerFrame만큼 읽어 이벤트로 전달
private IEnumerator CoRecord()
{
float[] samples = new float[samplesPerFrame]; // Opus 프레임 크기와 동일
while (AudioClip != null && Microphone.IsRecording(ActiveDevice))
{
// 마이크 링버퍼에 새 데이터가 쌓였는지 확인
int currPos = Microphone.GetPosition(ActiveDevice);
// ... 절대 위치 계산 ...
if (nextReadAbsPos < currAbsPos)
{
// 한 프레임(예: 20ms=960샘플) PCM을 꺼냄
AudioClip.GetData(samples, offsetSamples);
// → VcMicAudioInput.SendAndFilterFrame → MetaVc.SendFrame
OnFrameReady?.Invoke(NextFrameIndex, samples);
readAbsPos = nextReadAbsPos;
}
yield return null; // 매 유니티 프레임마다 마이크 폴링
}
}마이크 프레임 전송원문 코드 발췌
// MetaVc.cs — 로컬 마이크 프레임 → (뮤트면 빈 데이터) / (말하면 Opus) → Relay
private void SendFrame(int index, float[] samples)
{
// samples == null 이면 "말하지 않음"(필터가 null로 막은 경우 등)
bool isSpeaking = samples != null;
this.isSpeaking.Value = isSpeaking;
// ★ 묵음/뮤트: 실제 PCM 대신 Empty를 릴레이
// - !isSpeaking : 무음/미발화
// - isDeafened : 전체 음성 off
// - isInputMuted: 내 마이크 뮤트 (남이 못 듣게)
bool shouldRelayEmpty = !isSpeaking || isDeafened || isInputMuted;
// (에코 모드일 때는 조건이 약간 다름)
if (shouldRelayEmpty)
{
// 빈 페이로드 → 수신측은 재생 스킵 / speaking=false
ReceiveFrame(index, Timestamp, 0, ReadOnlySpan<byte>.Empty);
netProvider.RelayFrame(index, Timestamp, ReadOnlySpan<byte>.Empty);
return;
}
// ★ 해결방안: 프레임 PCM을 Opus로 압축(오디오 조합/인코딩)
// 원시 float[]를 그대로 보내면 대역폭↑·패킷 타이밍에 취약 → 끊김 유발
var data = encoder.EncodeFrame(samples.AsSpan());
// 뮤트면 인코딩해도 네트워크에는 Empty만 보냄
if (isDeafened || isInputMuted)
netProvider.RelayFrame(index, Timestamp, ReadOnlySpan<byte>.Empty);
else
netProvider.RelayFrame(index, Timestamp, data); // FishNet Unreliable
}
// FishNetNetProvider.cs — MetaVc ↔ FishNet RPC 브리지
void INetProvider.RelayFrame(int index, double timestamp, ReadOnlySpan<byte> data)
{
FishNetFrame frame = new(index, timestamp, Time.deltaTime, /* data copy */);
if (IsServerInitialized)
ObsReceiveFrame(frame); // 서버면 바로 옵저버 브로드캐스트
else
ServerRelayFrame(frame); // 클라 → 서버 RPC
}
[ServerRpc]
private void ServerRelayFrame(FishNetFrame frame, Channel channel = Channel.Unreliable)
{
// Unreliable: 음성은 재전송보다 최신성 우선 (끊김은 버퍼로 완화)
frame = new(..., frame.additionalLatency + Time.deltaTime, ...);
ObsReceiveFrame(frame);
ProcessAudioFrameOnServer(frame); // 서버 로컬 재생도 가능
}
[ObserversRpc]
private void ObsReceiveFrame(FishNetFrame frame, Channel channel = Channel.Unreliable)
{
if (IsOwner) return; // 에코 방지: 내 목소리는 내가 다시 안 들음
MetaVc.ReceiveFrame(frame.index, frame.timestamp, frame.additionalLatency, frame.data);
}수신 프레임 재생원문 코드 발췌
// MetaVc.cs — 수신 프레임 처리
public void ReceiveFrame(int index, double timestamp, float additionalLatency, ReadOnlySpan<byte> data)
{
// ★ 목표 지연 = (프레임시간 × 최소버퍼프레임수) + 프레임시간 + 네트워크누적지연
// 예: 20ms × 2 = 40ms 기본 + jitter
// outputMinBufferSize를 5~10으로 올리면 설명의 "5~10프레임 딜레이"와 일치
float targetLatency =
(config.secondsPerFrame * config.outputMinBufferSize)
+ Time.deltaTime
+ additionalLatency;
if (!isLocalPlayer)
targetLatency += jitter.Update(timestamp); // 도착 간격 흔들림(RMS) 보정
if (data.Length == 0)
{
// 묵음/뮤트 빈 프레임 → 재생 샘플 없음
SetIsSpeaking(false);
audioOutput.ReceiveAndFilterFrame(index, null, targetLatency);
return;
}
// 상대 출력 뮤트 / 로컬 deafen이면 재생 안 함
if (CannotSpeak && !ShouldLocalEcho)
{
audioOutput.ReceiveAndFilterFrame(index, null, targetLatency);
return;
}
// ★ Opus 디코딩: 압축 패킷 → PCM 한 프레임으로 "조합/복원"
var samples = decoder.DecodeFrame(data);
if (samples.Length == config.samplesPerFrame)
audioOutput.ReceiveAndFilterFrame(index, array, targetLatency);
else
audioOutput.ReceiveAndFilterFrame(index, null, targetLatency); // 깨진 프레임 무시
}
// VcAudioSourceOutput.cs — 지터 버퍼 + AudioClip 링버퍼 재생
private void Update()
{
// 초기화 전: 프레임을 모아서 targetLatency만큼 채운 뒤에야 Play
if (!isInit)
{
int receivedFrames = greatestFrameIndex - firstFrameIndex + 1;
float timeSinceFirstFrame =
((float)receivedFrames / framesPerSecond) + TimeSincePreviousFrame;
// ★ "몇 프레임 모은 뒤 재생" — 직접 프레임 재생의 끊김을 막는 지점
if (receivedFrames != 0 && timeSinceFirstFrame >= targetLatency)
{
audioSource.time = GetWrappedTime(firstFrameIndex);
audioSource.Play();
isInit = true;
}
return;
}
// 버퍼 수위(latency)가 목표보다 높/낮으면 pitch로 미세 보정 (드리프트 억제)
float error = targetLatency - GetLatency();
audioSource.pitch = 1f + Mathf.Clamp(-error * pitchProportionalGain, ...);
}
protected override void ReceiveFrame(int index, float[] samples, float targetLatency)
{
if (samples == null || samples.Length == 0)
return; // 묵음 프레임은 클립에 안 씀
this.targetLatency = targetLatency;
// 링 클립의 해당 슬롯에 PCM 기록 (여러 프레임이 이어져 "연속 음성"이 됨)
int offsetFrames = vcAudioClip.GetOffsetFrames(index);
vcAudioClip.WriteFrame(offsetFrames, samples);
// ... first/greatest frame index 갱신 ...
}네트워크 동기화와 팀 개발 운영
배경과 과제
훈련자 아바타와 NPC 상호작용을 연결하면서, 신규 팀원의 Unity·C# 이해도에 맞춘 작업 관리도 필요했습니다.
내가 구현한 방식
FishNet을 이용해 네트워크 객체와 상호작용을 동기화했습니다. 팀에서는 주간 코드 리뷰로 각자 작성한 코드와 문제점을 설명하고 함께 검토했습니다.
선택의 이유와 범위
코드 지원 도구를 활용한 부분도 직접 분석·디버깅한 과정과 함께 설명했습니다. 팀 관리에서는 질문을 통해 팀원이 문제를 파악하도록 돕는 방식을 사용했습니다.
MORE SCREENS
추가 실행 화면
CODE & VALIDATION
코드와 검증 범위
기존 코드 발췌와 2026년 포트폴리오용 재구성 코드를 구분해 제공합니다. 재구성 코드는 실제 제품 소스가 아니며, 일반 원리를 설명하기 위해 새로 작성했습니다.
Unity 6000.0.23f1 컴파일 및 Editor 검증 56개 통과. 실제 장비 연동과 제품 통합은 검증 범위에 포함하지 않습니다.
예제별 사용법과 검증 내용 ↗