네스랩 / 2024
Beyond X-Verse
실시간 트래킹부터 시나리오 편집·리플레이까지

프로젝트 연도 / 상태2024 · 개발 진행 중
개발팀 · 리소스 인력 제외2인 · 팀장
사용 기술Unity3D · VR · C# · CursorAI
OVERVIEW
프로젝트와 나의 역할
VIVE·Optitrack 센서 연동, Addressables 리소스 관리, NPC FSM과 음성·리플레이 시스템을 개발했습니다.
- VIVE Focus Vision HMD 및 VIVE Tracker / Optirack 센서 연동
- Unity3D Addressable 기능을 이용한 리소스 관리 기능 개발
- Scene 동적 로드 및 훈련 시나리오 제작 기능 개발
- 위해범 및 시민 NPC FSM 초기 디자인
- CSV 파일 포맷 및 XML, Json 파일을 통한 훈련 리플레이 데이터 저장 기능 개발
- 훈련자간 음성 대화 기능 개발
- 상용 네트워크 모듈을 이용한 네트워크 객체 동기화
- 팀원 업무 분장 및 일정 관리
CHALLENGE
해결해야 했던 문제
장비별 원점 차이와 음성·동작 데이터의 시간축 불일치
APPROACH
선택한 해결 방식
기준 마커로 위치 원점을 맞추고 HMD 회전을 활용했습니다. CSV 프레임과 별도 음성 기록을 연결해 리플레이를 구성했습니다.
SOURCE EXCERPTS
기존 Notion 코드 발췌
원문에 포함된 설명용 발췌입니다. 생략된 의존성과 문맥이 있어 독립 실행 코드는 아닙니다.
코드 발췌 01
/// 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;
}코드 발췌 02
/// 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로 재사용
}코드 발췌 03
///송신부
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;
}코드 발췌 04
<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>코드 발췌 05
// 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;
}코드 발췌 06
// 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 범위로 맞춤
}코드 발췌 07
// 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();
}
}코드 발췌 08
// 시나리오 시간대(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);
}코드 발췌 09
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);
}코드 발췌 10
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);
}
}코드 발췌 11
///공통 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);
}
}코드 발췌 12
///총격 소리 반응
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;
}코드 발췌 13
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;
}
}코드 발췌 14
// 스키마 정보 구조체
// 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,
// ...
});
}코드 발췌 15
// 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; // 매 유니티 프레임마다 마이크 폴링
}
}코드 발췌 16
// 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);
}코드 발췌 17
// 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 갱신 ...
}PROJECT MEDIA
실행 화면과 시연
기존 프로젝트 상세 설명 보기
# X-Verse 경호처 시뮬레이터 개발(개발 진행 중)
Unity3D 기반 경호처 훈련 시뮬레이터로, Addressable을 통한 리소스 관리, VIVE HMD·Optitrack 실시간 모션 트래킹, TOD·아바타·Task·시나리오 편집, 위해범·시민 NPC FSM, CSV/XML 데이터 저장·리플레이 기능을 포함합니다.
## 프로젝트 시연 영상
### 01. 프로젝트 구성 (리소스 개발 인력 제외)
팀 구성 : 2인 (팀장으로 참여)
### 02. 프로젝트 내 본인 작업 내용
1. Unity 관련
1. VIVE Focus Vision HMD 및 VIVE Tracker / Optirack 센서 연동
2. Unity3D Addressable 기능을 이용한 리소스 관리 기능 개발
3. Scene 동적 로드 및 훈련 시나리오 제작 기능 개발
4. 위해범 및 시민 NPC FSM 초기 디자인
5. CSV 파일 포맷 및 XML, Json 파일을 통한 훈련 리플레이 데이터 저장 기능 개발
6. 훈련자간 음성 대화 기능 개발
7. 상용 네트워크 모듈을 이용한 네트워크 객체 동기화
2. 프로젝트 관리
1. 팀원 업무 분장 및 일정 관리
### 03. 담당 기능 개발
# Unity 관련
1. VIVE Focus Vision HMD 및 VIVE Tracker / Optirack 센서 연동
1. VR 장비 연동 및 Tracker 종류에 따른 센서 연동 선택 기능 개발
- 개발 방안 : VIVE HMD와 VIVE Tracker, Optitrack 모션센서 연동을 통해 선택적 트래킹 방식 적용 기능 개발
- 이슈 및 문제점 : Optrack과 VIVE HMD를 함께 사용할때 원점의 차이 및 장비의 차이로 인한 움직임 동기화 문제 발생
- 해결방안 : Optitrack 사용시 HMD의 회전 데이터는 HMD 자체의 회전 데이터를 사용, 원점은 Optitrack의 원점 위치에 표시용 마커 하나를 추가하여, 해당 마커의 위치를 HMD 원점으로 지정하여 위치 동기화
- 해결 가능 이유 : Optitrack은 마커 데이터를 개별적으로 사용할 수 있기 때문에 하나의 마커로 기준이 되는 원점 지정할 수 있음
- 주요 코드
초기 카메라 설정 코드
코드
[코드: 아래 기존 코드 발췌 참고]
2. VR 카메라와 트래커 트래킹 설정 코드
코드
[코드: 아래 기존 코드 발췌 참고]
3. 클라이언트, 서버간 HMD Pivot 위치 동기화 코드
코드
[코드: 아래 기존 코드 발췌 참고]
2. Unity3D Addressable 기능을 이용한 리소스 관리 기능 개발
1. Unity3D Addressable 을 통한 리소스 관리
- 개발 방안 : 프로젝트 리소스 업데이트의 용이성을 위해 Addressable 기능 및 외부 XML 파일을 이용하여 리소스 동적 로드 기능 개발
- 이슈 및 문제점 : 상용 네트워크 모듈 (Fishnet)을 이용한 Addressable 기능 사용에 대한 문서 및 예제 부족
- 해결방안 : Cursor AI를 통한 자료 확보 및 코드 서포팅을 통해 Addressable 기능 적용
- 해결 가능 이유 : 기본적인 Addressable 기능 및 사용법을 확인하고 Cursor AI의 코드를 분석하여 적용 방향 확인 및 여러 차례의 코드 디버깅을 진행하여 처리
- 주요 코드
외부 XML 에셋 파일 포맷
코드
[코드: 아래 기존 코드 발췌 참고]
2. 외부 에셋 카탈로그 정리 및 키 배열 코드
코드
[코드: 아래 기존 코드 발췌 참고]
3. Fishnet + Addressable 에셋 동적 로드 코드
코드
[코드: 아래 기존 코드 발췌 참고]
3. Scene 동적 로드 및 훈련 시나리오 제작 기능 개발
1. Unity3D Addressable 을 통한 Scene 동적 로드
- 개발 방안 : Addressable 기능 기능을 이용한 기능 Scene 및 Map Scene의 효율적인 운용을 위한 Scene 동적 로드 기능 개발
- 이슈 및 문제점 : 상용 네트워크 모듈 (Fishnet)을 이용한 Addressable 기능 사용에 대한 문서 및 예제 부족
- 해결방안 : Cursor AI를 통한 자료 확보 및 코드 서포팅을 통해 Addressable 기능 적용
- 해결 가능 이유 : 기본적인 Addressable 기능 및 사용법을 확인하고 Cursor AI의 코드를 분석하여 적용 방향 확인 및 여러 차례의 코드 디버깅을 진행하여 처리
2. 훈련 시나리오 제작 기능 개발
- 개발 방안 : 시나리오의 동적 생성을 통한 멀티 훈련 시나리오 저작 도구 개발
- 이슈 및 문제점 : 훈련 종류에 따른 Task 정의의 불투명
- 해결방안 : 기본 훈련 대상 (경찰, 특수부대)을 지정하여 훈련 시나리오를 특정하여 시나리오에 필요한 Task 제작
- 해결 가능 이유 : 처음부터 수많은 Task를 개발하는 것보다는 초기 판매 대상의 훈련 타입을 분석하여 1차적인 Task를 개발하여 추후 고도화 하는 방향으로 개발 계획이 수립됨
훈련 시나리오 결과 영상 (시나리오 NPC의 Waypoint 생성)
훈련 시나리오 결과 영상 (시나리오 NPC의 행동 Task 생성)
- 주요 코드
Fishnet 셋팅이 이루어진 Addressable Scene 에셋 로드 코드
코드
[코드: 아래 기존 코드 발췌 참고]
2. 로드된 Scene 에셋 실제 Additive 로딩 코드
코드
[코드: 아래 기존 코드 발췌 참고]
3. NPC 로딩 및 NPC내 Task 관련 코드
코드
[코드: 아래 기존 코드 발췌 참고]
4. 위해범 및 시민 NPC FSM 초기 디자인
1. 상호간 교전하는 NPC무리 초기 디자인
- 개발 방안 : NPC 간 상호 교전이 가능한 Waypoint 기반 NPC FSM 개발
- 이슈 및 문제점 : 사실적인 NPC의 특성상, 소리, 시야, 동료간의 상호 인터랙션이 가능하도록 개발해야 함
- 해결방안 : 기본 FOV 시야를 통해 적을 탐지하고, 벽 너머의 적은 시야에 탐지되지 않도록 수정, 근처의 아군이 공격하거나 발견한 적이 있다면 일정 범위내 아군에게 정보를 전달하여 근처의 무리 전체가 상황을 인지 할 수 있도록 개발
- 해결 가능 이유 : -
- 주요 코드
공통 FSM 코드
코드
[코드: 아래 기존 코드 발췌 참고]
2. 위해범 NPC의 타겟 발견 FOV 코드
코드
[코드: 아래 기존 코드 발췌 참고]
3. 위해범 무리 정보 공유 및 교전 코드
코드
[코드: 아래 기존 코드 발췌 참고]
5. CSV 파일 포맷 및 XML, Json 파일을 통한 훈련 리플레이 데이터 저장 기능 개발
1. CSV 기반 훈련 리플레이 기능 개발
- 개발 방안 : CSV 파일 기반 프레임 컨트롤이 가능한 리플레이 기능 개발
- 이슈 및 문제점 : 훈련자 간 음성 데이터 적용은 CSV 파일만으로는 개발이 어렵다는 문제가 발생
- 해결방안 : 음성 데이터 저장에 대해서 가장 좋은 파일 형식인 Json 파일 형식을 이용하여 음성 데이터 저장 Json 파일 내에 프레임 데이터 및 프레임당 음성 데이터 저장
- 해결 가능 이유 : 프레임당 음성 데이터를 통해 리플레이 시 프레임 컨트롤 기능 개발에도 용이
- 주요 코드
CSV 및 Json 파일 저장 코드
코드
[코드: 아래 기존 코드 발췌 참고]
2. 음성 데이터 Json 저장 코드
코드
[코드: 아래 기존 코드 발췌 참고]
6. 훈련자간 음성 대화 기능 개발
1. 훈련자간 음성 대화 기능 개발
- 개발 방안 : 마이크 묵음에 대한 음성 처리 및 네트워크 전송을 통한 클라이언트 간 음성 대화 가능 개발
- 이슈 및 문제점 : 훈련자간 음성 데이터를 프레임당 데이터로 직접 전송하여 재생하니 음성이 끊어지는 현상 발생
- 해결방안 : 어느정도의 딜레이를 감수하고, 몇 프레임의 음성 데이터를 오디오 코덱을 통한 오디오 조합으로 음성 목소리 재생
- 해결 가능 이유 : 5\~10프레임 정도의 음성 딜레이는 실제 훈련에서 큰 차이를 느끼지 못하므로 음성 통신의 딜레이 보단 보다 깨끗한 음성 데이터 처리를 선택
- 주요 코드
음성 데이터 딜레이 및 캡처 처리 코드
코드
[코드: 아래 기존 코드 발췌 참고]
2. 음성 데이터 네트워크 전송 처리 코드
코드
[코드: 아래 기존 코드 발췌 참고]
3. 음성 데이터 네트워크 수신 및 재생 처리 코드
코드
[코드: 아래 기존 코드 발췌 참고]
7. 상용 네트워크 모듈을 이용한 네트워크 객체 동기화
1. Fishnet을 이용한 네트워크 객체 동기화
- 개발 방안 : Fishnet을 이용한 네트워크 아바타 동기화 및 클라이언트 훈련자와 NPC간 인터랙션 동기화 개발
- 이슈 및 문제점 : 상용 네트워크 모듈 (Fishnet)을 이용한 기능 사용에 대한 문서 및 예제 부족
- 해결방안 : Cursor AI를 통한 자료 확보 및 코드 서포팅을 통해 네트워크 동기화 기능 적용
- 해결 가능 이유 : 기본적인 Fishnet 기능 및 사용법을 확인하고 Cursor AI의 코드를 분석하여 적용 방향 확인 및 여러 차례의 코드 디버깅을 진행하여 처리
# 프로젝트 관리 관련
1. 팀원 업무 분장 및 일정 관리
1. 프로젝트 일정 관리
- 이슈 및 문제점 : 신입 팀원을 이끌고 시작한 프로젝트이므로 낮은 Unity/C# 이해도를 가진 신입들의 일정을 관리하는 것에 큰 애로사항이 발생
- 해결방안 : 매주 팀원들과 프로젝트 코드 리뷰 진행, 코드 리뷰를 통해 팀원들 본인이 작업한 코드에 대한 보다 심도 깊은 이해와 문제점을 파악하고, 팀원간 문제점을 해결하기 위한 논의를 진행. 팀원들이 문제점을 파악하지 못할 경우 유도질문을 통해 스스로 무엇이 문제인지 파악할 수 있도록 유도함.