4171ec15c2d1284e1e0a041d68a05fb870cef5c1
VR 장보기 시뮬레이션 (Shopping VR)
Unity 6 · XR Interaction Toolkit 기반 1인칭 VR 장보기 게임. 주문서대로 매장에서 상품을 골라 셀프 계산대에서 결제하고, NPC와 대화하며 시식·허기 관리까지 수행한다.
데모 영상
- 메인 데모(2분): 업로드 예정
- 하이라이트(30초): 업로드 예정
- 빌드(.apk, Quest 사이드로드): 업로드 예정
프로젝트 정보
| 개발 기간 | YYYY.MM ~ YYYY.MM |
| 인원 | 1인 (기획·개발·일부 에셋 통합) |
| 플랫폼 | Meta Quest (Android XR / OpenXR), PC VR |
| 엔진 | Unity 6000.3.9f1 · URP 17.3 |
| 주요 패키지 | XR Interaction Toolkit 3.3, XR Hands 1.7, OpenXR 1.16, Oculus XR 4.5 |
게임 플로우
- GameStartScene — 시작 화면에서 게임 진입
- GameScene (매장) — 좌측 컨트롤러 Primary 버튼으로 주문서 펼치기
- 상품 픽업 — XR Direct Interactor로 상품을 잡고 손에 든다 (Hover 하이라이트로 피드백)
- 셀프 계산대 — 바코드 스캔 영역에 상품을 가져가면 항목이 행으로 추가됨, 합계 자동 갱신
- 결제 —
PlayerWallet에서 차감 → 주문서 충족 검사 → Clear / Missing 패널 분기 - 카트 — 카트 영역에 떨어뜨려 둔 상품은 일정 시간 후 자동으로 카트에 정착(부착)되어 카트와 함께 움직인다. 다시 손으로 집으면 분리.
- 부가 시스템 — NPC 대화·시식으로 허기 회복, 시간이 지나면 허기가 깎이고 굶주리면 이동 속도 저하
핵심 기능
1. 쇼핑 주문 미션 시스템
ItemData(ScriptableObject) — 상품 ID·브랜드·ItemCategory(과일/유제품/스낵 등)·ProductGroup(브랜드별 구체 상품) 분리. 카테고리는 매대 분류용, 그룹은 미션 매칭용으로 의도적으로 두 단계로 나눔.ShoppingOrderList— 주문서를 SO로 데이터화. 미션마다 다른 주문서를 에셋만 교체해 재사용.PlayerController.ShoppingOrderMissionClearCheck— 결제 시 구매 항목을ProductGroup으로 집계 후 부족분을 (group, shortage) 튜플 리스트로 반환. 부족분 0이면 클리어.
2. 셀프 계산대 (CheckoutMachine, BarcodeScaner)
Physics.OverlapBoxNonAlloc로 스캔 영역 안의 콜라이더를 GC 부담 없이 수집한 뒤, 영역 중심에 가장 가까운ItemInstance한 개만 스캔.- 결제 행은
Dictionary<string, CheckoutProductionRow>로 동일 상품 묶음·수량 카운트를 O(1)로 처리. - UI 행 추가 시
OnEnable지연 트릭: 행을 비활성 상태로 인스턴스화 →LayoutRebuilder.ForceRebuildLayoutImmediate로 레이아웃 확정 → 활성화. 활성 직후 첫 프레임에 RectTransform이 0인 채로 OnEnable이 도는 문제를 피한다.
3. NPC 대화·립싱크 시스템
- 데이터 그래프:
DialogGroup→DialogNode(대사·제스처·표정·보이스·선택지) →DialogChoice→ 다음 노드. 모두 ScriptableObject로 노드 그래프를 자산화. DialogPlayer— Unity 6의Awaitable로await PlayNode → await WaitForChoice → 다음 노드흐름을 코루틴 없이 비동기 직렬화. 대화 종료 시 초기 제스처/표정 상태를CrossFade로 복원.- 플레이어 향해 회전 — 대화 중 NPC가 카메라 방향으로 부드럽게 회전, 종료 시 원래 회전으로 복귀.
- 블렌드셰이프 립싱크 (
LipSync) —AudioSource.GetOutputData로 RMS amplitude 계산 → 0~1로 매핑 → 입 관련 블렌드셰이프 7종에 가중치 분배.LateUpdate에서 적용해 같은 프레임에 Animator가 0으로 덮어쓴 값을 다시 씌우는 방식으로 표정 애니메이션과 충돌 없이 공존.
4. 플레이어 시스템
PlayerHunger— 시간 경과로 허기 게이지 감소. 0이 되면ContinuousMoveProvider.moveSpeed를_starvedMoveSpeed로 교체, 시식으로 회복 시 원본 속도 복구. UI는Action<float, float>이벤트로 게이지 바인딩.PlayerWallet— 결제 시 잔고 차감, 부족 시false반환해 결제 자체를 막음.InputManager— XR 컨트롤러 버튼 이벤트(XRLeftControllerPrimaryButton_Event등)를 게임 로직 측에서 구독.
5. 카트 시스템 (RideController, RideDetectionZone)
VR에서 "물건을 카트에 담는다"는 단순해 보여도 실제로는 네 가지 문제가 동시에 발생한다. 각 문제를 다음과 같이 해결했다.
(1) 플레이어가 카트와 함께 움직이게 만들기 — ParentConstraint
- 카트에
ParentConstraint를 붙이고 source로XROrigin을 잡음. 결과적으로 XROrigin(=플레이어)이 카트의 자식처럼 따라다님. ActiveRide()/DeactiveRide()로 constraint 활성/비활성 토글 → 카트에서 내릴 때는 constraint만 끊으면 되고, XROrigin 계층은 건드리지 않음.
(2) 물건이 카트와 같이 안 움직이는 문제 — Reparent + Kinematic
Rigidbody상태로 바구니에 얹혀 있어도 카트가 움직이면 충돌·관성으로 즉시 튀어나옴.- 정착이 확정되면
transform.SetParent(_itemRoot)로 카트 하위에 부착하고isKinematic = true로 전환. 이때부터는 카트의 transform을 그대로 따라가고 물리 시뮬레이션에서 빠짐.
(3) 관성으로 물건이 빠져나오는 문제 — 정착 타이머
- 단순히 "트리거 진입 = 부착"으로 하면 던져 넣자마자 위로 튀는 운동량 때문에 카트 밖으로 다시 튀어나감. 게다가 한 번 부착되면 더 이상 던질 수 없음(움직이는 카트 바깥으로 떨어뜨릴 수 없음).
OnDetectionStay에서 매 프레임 검사:linearVelocity.magnitude > _settleVelocity(임계 속도)면 타이머 리셋 — 아직 움직이는 중- 임계 속도 이하 상태가
_settleDuration동안 연속으로 유지되면 부착 확정 XRGrabInteractable.isSelected(현재 손에 잡혀있음)면 타이머 무효화 — 잡고 있는 동안엔 절대 부착되지 않음
_settleTimer는Dictionary<Rigidbody, float>로 아이템별 타이머를 독립 관리.
(4) 물건 위에 물건을 쌓는 문제
- 위 정착 타이머는 각 Rigidbody별로 독립적으로 흐름. 아래 물건이 먼저 정착해 kinematic이 되면, 그 위에 떨어뜨린 물건은 단단한 받침대 위에서 속도가 0에 수렴 → 자기 차례에 정착 → 다음 물건의 받침대가 됨.
- 트리거 영역(
RideDetectionZone)을 카트 바구니보다 충분히 높게 잡아 쌓인 물건도 검사 대상에 포함.
(5) 카트에서 다시 꺼낼 때의 함정 — retainTransformParent & isKinematic 복원 덮어쓰기
XRGrabInteractable은 잡힐 때 부모/kinematic 상태를 스냅샷해 두고, 놓을 때 그 상태로 되돌린다. 카트에서 부착될 때 부모=ItemRoot, kinematic=true로 잡혔으니, 손으로 꺼냈다가 어딘가 놔도 다시 ItemRoot 자식으로 + kinematic 상태로 복원되어 공중에 박제되는 버그.- 두 단계로 차단:
- 부착 시
grab.retainTransformParent = false— 부모 복원 비활성화. - 꺼낼 때
selectEntered→ 즉시 부모 해제,selectExited에서isKinematic = false를 다시 강제로 덮어쓰기.
- 부착 시
물건 던져넣기 → 트리거 진입 → [속도 < 임계 && 잡혀있지 않음] 0.3s 유지
→ SetParent(ItemRoot) + isKinematic=true + retainTransformParent=false
→ 카트 이동에 종속
손으로 다시 잡음 → SetParent(null) → 일반 물리로 복귀
→ 놓을 때 isKinematic=false 강제 복원
6. 씬 전환 라이프사이클
ITransScenePossible인터페이스 — 씬 로드 직후OnSceneLoaded()를 일괄 호출해 씬을 가로지르는 매니저(GameManager 등)가 새 씬의 매니저(GameSceneUIManager)와 다시 결선되도록 함. 싱글톤이 죽지 않은 채 새 씬의 UI 참조만 갱신.
아키텍처
Managers (DontDestroyOnLoad, 싱글톤)
├─ GameManager 전역 상태·씬 매니저 결선
├─ InputManager XR 컨트롤러 입력 이벤트 허브
├─ SoundManager BGM / SE
└─ SceneLoadManager ITransScenePossible 알림 디스패치
│
▼
Scene-local (GameScene)
├─ GameSceneUIManager HUD·패널 컨트롤
├─ PlayerController 주문서 토글, 결제 진입점
├─ PlayerWallet / PlayerHunger
├─ RideController + RideDetectionZone 카트 (ParentConstraint로 플레이어 종속)
└─ CheckoutMachine ← BarcodeScaner ← ItemInstance(ItemData SO)
│
▼
Data (ScriptableObject)
├─ ItemData / ShoppingOrderList / ShoppingOrderEntry
├─ DialogGroup / DialogNode / DialogChoice / VoiceClip
└─ CharacterData / ExpressionData / GestureData / BGMClip
폴더 구조
Assets/
├─ 01_Scenes/MyProject/ GameStartScene, GameScene
├─ 02_Scripts/ 자체 구현 코드 (네임스페이스 VRShopping.*)
│ ├─ Managers/ GameManager, InputManager, SoundManager, SceneLoadManager...
│ ├─ Player/ PlayerController, PlayerHunger, PlayerWallet, RideController
│ ├─ Shopping/ BarcodeScaner, CheckoutMachine
│ ├─ Communication/ Dialog/, Voice/ (LipSync, DialogPlayer, CharacterVoiceObject)
│ ├─ Item/ ItemData, ItemInstance, TastingSample
│ ├─ Interact/ ItemHoverHighlight, ItemInfoOnGrab, DialogInteractable
│ ├─ Data/ ScriptableObject 정의
│ └─ UI/ HUD·패널 (HungerHud, GameTimerHud, ChoiceHud, ClearPanel...)
├─ 03_Models/ ~ 10_Audio/ 아트·오디오 에셋
└─ 99_Settings/ XR / URP / Input 설정
사용한 외부 에셋
| 분류 | 에셋 | 용도 |
|---|---|---|
| XR | XR Interaction Toolkit, XR Hands, OpenXR, Oculus XR | 컨트롤러·핸드 인터랙션 기반 |
| XR 확장 | MikeNspired XRI Starter Kit | Climbing, DistanceGrab, HandPoser |
| 캐릭터 | BoZo Modular Anime Characters | NPC 모델·블렌드셰이프 |
| 의상 시뮬 | Magica Cloth 2 | 옷·머리카락 물리 |
| 셰이딩 | RealToon, Umbra Soft Shadows, BadDog AreaLight | 툰 셰이딩·소프트 섀도우 |
| 인터랙션 피드백 | HighlightPlus, Outline Plus | Hover/Grab 외곽선 |
| VFX | CartoonVFX9X | 스캔·결제 이펙트 |
자체 구현은
Assets/02_Scripts/(네임스페이스VRShopping.*).
빌드 / 실행
요구 사항
- Unity 6000.3.9f1
- (Quest 빌드) Android Build Support, OpenXR Loader: Meta Quest
실행
- 저장소 클론 후 Unity Hub에서 프로젝트 열기
Assets/01_Scenes/MyProject/GameStartScene.unity열기- PC: Quest Link / Air Link 연결 후 Play
- Android 빌드: Build Settings → Android → Build, Quest에 사이드로드
트러블슈팅 / 학습 포인트
1. 립싱크와 표정 애니메이션 충돌
- 문제:
Animator가 표정 레이어에서 매 프레임 블렌드셰이프 가중치를 0으로 덮어써 립싱크가 동작하지 않음. - 해결: 립싱크를
LateUpdate에서 적용.Animator는Update이후 동작이지만 같은 프레임의LateUpdate에서 다시 가중치를 씌우는 순서로 충돌을 회피.
2. UI 행 추가 직후 RectTransform 0 문제
- 문제:
Instantiate후 활성 상태에서 바로OnEnable이 돌면 부모 레이아웃이 아직 갱신 전이라 사이즈 0으로 잡힘. - 해결: 비활성 상태로 생성 →
LayoutRebuilder.ForceRebuildLayoutImmediate→ 활성. 첫 프레임 깜빡임 제거.
3. 씬 전환 시 매니저 참조 끊김
- 문제:
DontDestroyOnLoad매니저가 새 씬의 UI 매니저를 못 찾음. - 해결:
ITransScenePossible인터페이스로 통일 →SceneLoadManager가 씬 로드 콜백에서 일괄OnSceneLoaded()호출 → 각자 필요한 씬-로컬 참조를 다시 잡음.
4. 스캔 영역 다중 상품 처리
- 문제: 스캔 박스 안에 여러 상품이 들어오면 한 번에 다 결제됨.
- 해결:
OverlapBoxNonAlloc결과 중 박스 중심에 가장 가까운 한 개만 스캔 대상으로 선택.
5. 카트에 담은 물건이 카트와 따로 노는 문제
- 문제: VR 카트가 움직이면 안에 든 물건이 충돌·관성으로 튀어나옴. 그렇다고 진입 즉시 부착하면 던져 넣을 때 운동량으로 다시 빠져나감.
- 해결: 속도 임계값(
_settleVelocity) 이하 상태가_settleDuration동안 연속 유지되면 카트 자식으로 reparent +isKinematic = true. 잡혀있는 동안엔 타이머 자체를 무효화. (자세한 흐름은 카트 시스템 섹션 참고)
6. XRGrabInteractable의 상태 스냅샷·복원이 카트 부착을 깨뜨림
- 문제:
XRGrabInteractable은 잡힐 때 부모/kinematic을 스냅샷하고 놓을 때 복원함. 카트에 kinematic 상태로 부착된 물건을 잡았다 놓으면, 놓는 순간 다시 카트 자식 + kinematic으로 복원되어 공중에 박제됨. - 해결: 부착 시
retainTransformParent = false로 부모 복원 차단 +selectExited에서isKinematic = false를 직접 덮어씀.
향후 개선
- 카트(
RideController)에 담은 아이템 별도 인벤토리화 - 대화 노드 진행 입력을 VR 컨트롤러 버튼으로 (현재 임시 1초 대기)
- PlayerController의 임시 bool 플래그 → 상태 머신 전환
- 주문서 종류 다양화 (난이도별 SO 추가)
라이선스
자체 구현 코드(Assets/02_Scripts/)에 대해서만 권리 주장. 외부 에셋은 각 에셋 라이선스를 따른다.
Description
Languages
C#
90.5%
ShaderLab
3.1%
HLSL
2.7%
GLSL
1.6%
C++
1.5%
Other
0.6%