w99hyun
프로필프로젝트포스트연락처
목록
w99hyun

본 사이트는 개인 포트폴리오 게시용으로 제작되었습니다.

ⓒ 2026. w99hyun(Woohyun Cho) All rights reserved.

홈페이지
서비스진행중
개발기간2025. 12. 30. ㅡ
팀9인 팀 (PM 1, 리서치 3, 디자인 1, 개발 4)
역할(개발 총괄) 런타임 에디터 / 전체 시스템 구조 설계 / 생성형 AI API 연동 / UI · UX / VR 상호작용 / 라이브러리
사용 기술
UnityC#XRAIASP.NET CoreMAUI

Spoof!

서브 프로젝트

DreamDiary

Spoof! 서비스 이용 후 회고를 위한 서비스 웹

자세히 보기

시연 영상

스크린샷

개요

Spoof는 XR· AI 기반 악몽 장애 치료 보조 서비스입니다.
XR의 몰입도를 활용해 이미지 리허설 요법(IRT)를 보조하며, AI를 통해 상담사가 시나리오 구성을 용이하게 보조합니다.
클라이언트는 에디터(치료사가 시나리오 편집)와 VR 플레이(사용자 체험) 두 모드로 구성되며,
시나리오는 복수 VirtualScene으로 이루어지며, 플로우에 따라 현재 장면과 그 장면에 포함된 오브젝트만 로드하도록 설계해 장면 단위로 메모리와 로딩 비용을 제한했습니다.
이 프로젝트에서 9인 팀 개발 팀장으로 클라이언트의 전반적인 설계 및 각종 기능 개발(런타임 에디터 구현, 생성형 AI API 연동, UI · UX, VR 상호작용)을 담당했으며, XREAL 학회의 프로젝트로 피칭하여 지속해서 개발을 진행 중입니다.

상단의 시연영상을 통해 프로젝트의 전체 흐름을 확인하실 수 있습니다.

구현

런타임 씬 에디터

Runtime Editor

런타임에서도 씬 오브젝트를 배치·변형·저장할 수 있는 에디터를 설계·구현했습니다.
오브젝트 배치, 스케일 등 런타임 에디터에서 수정한 가상 씬(시나리오)은 json 구조로 데이터 파일에 저장됩니다.
오브젝트 추가는 OBJ/GLB/GLTF 파일을 선택하여 임포트시키거나 프롬프트를 입력해 생성형 AI로 모델을 생성 후 GLTFast로 런타임 임포트하고 에디터 뷰에서 상용 에디터처럼 Transform Handler를 제공해 오브젝트 속성을 편집할 수 있습니다.

가상 씬 구조

Virtual Scene Data

Virtual Scene

하나의 가상 씬은 부정과 긍정으로 나뉩니다. 각 장면별로 부정은 악몽을 의미하고, 긍정은 악몽을 극복했을 때를 의미합니다.
악몽 극복을 위해선 악몽을 자기 자신이 통제할 수 있다는 것이 중요합니다. 부정 장면에서 악몽 극복을 하면 긍정장면으로 변경됩니다.
가상 씬 데이터 구조는 아래 코드와 같습니다. 시나리오는 하나의 Unity 씬으로 이뤄지며, 시나리오 진행에 따라 가상 씬을 로드합니다.

코드 보기코드 닫기
C#
//VirtualSceneData 클래스
public class VirtualSceneData
{
    public string sceneName;
    public List<PlacedObjectData> placedObjects = new List<PlacedObjectData>();
    public List<SceneState> states = new List<SceneState>();
    public int currentStateIndex = 0; // 현재 활성
    public int skyboxMaterialIndex = 0; // 스카이박스 Material 인덱스 (-1은 미설정)
}

//PlacedObjectData 클래스
public class PlacedObjectData
{
    public string objectId;
    public string filePath;
    public string fileType;
    public Vector3 position;
    public Quaternion rotation;
    public Vector3 scale = Vector3.one;
    public string objectName;
    public int animationClipIndex = -1; // 애니메이션 사용 시 ScenarioManager.availableAnimationClips의 인덱스. -1이면 미사용.
    public bool resetSkeletonRootRotation;
    public bool animationLoop = true;

    public PlacedObjectData()
    {
        objectId = Guid.NewGuid().ToString();
    }
}

내담자는 자신의 꿈에서 본 여러 장면을 경험해야 합니다. 이 하나하나의 장면을 가상 씬으로 구성할 수 있도록 설계했습니다.
상담사가 각각의 장면에서 오브젝트를 배치하고 내담자가 시나리오를 진행하면서, 장면을 넘어갈 때마다 가상씬의 index를 증가시켜 다음 가상 씬을 로드합니다.

런타임 애니메이션 재생

https://www.youtube.com/watch?v=r4KRm4h495g

런타임 에디터에서 불러온 gltf 모델의 본 구조와 유니티 애니메이션에서 지원하는 휴머노이드 표준 본구조와 같다면, 런타임에서도 모델에 애니메이션을 임포트할 수 있습니다.
임포트된 모델의 좌표계가 다를 수 있기 때문에 회전 보정 기능을 통해 이를 보정합니다.

코드 보기코드 닫기
C#
public void ApplyAnimationToObject(GameObject obj, int clipIndex, bool resetSkeletonRootRotation, bool animationLoop)
{
  try
  {
      if (clipIndex < 0) { RemoveAnimationFromObject(obj); return; }
      var clip = GetAnimationClip(clipIndex);
      if (clip == null) return;

      NormalizeBoneNamesForClip(obj.transform);
      var animTarget = GetAnimationTarget(obj);
      if (resetSkeletonRootRotation)
          animTarget.transform.localRotation = Quaternion.identity;

      if (clip.legacy)
      {
          RemoveAnimatorFromTarget(animTarget);
          var anim = animTarget.GetComponent<Animation>() ?? animTarget.AddComponent<Animation>();
          try { anim.RemoveClip(clip.name); } catch { }
          anim.AddClip(clip, clip.name);
          anim.clip = clip;
          var state = anim[clip.name];
          if (state != null) state.wrapMode = animationLoop ? WrapMode.Loop : WrapMode.Once;
          anim.Play(clip.name);
      }
      else
      {
          RemoveAnimationFromTarget(animTarget);
          var baseController = GetAnimatorControllerTemplate();
          var templateClip = GetAnimatorTemplateClip();
          var stateName = GetAnimatorStateName();
          if (baseController == null || templateClip == null || string.IsNullOrEmpty(stateName))
          {
              return;
          }
          var animator = animTarget.GetComponent<Animator>() ?? animTarget.AddComponent<Animator>();
          if (animator.runtimeAnimatorController is AnimatorOverrideController existing)
              Destroy(existing);
          var overrideController = new AnimatorOverrideController(baseController);
          overrideController[templateClip] = clip;
          animator.runtimeAnimatorController = overrideController;
          animator.Play(stateName, 0, 0f);
      }
  }
  catch (Exception e)
  {
      LogManager.Instance?.AddLog($"ApplyAnimation failed: {obj?.name}, clipIndex={clipIndex}, {e.Message}", LogType.Error);
  }
}

유니티 애니메이션 clip은 아직 Legacy clip이 남아있기 때문에, 이 경우와 최신 clip을 구분하여 애니메이션을 재생합니다.

코드 보기코드 닫기
C#
private Transform FindHips(Transform root)
{
  if (root.name.IndexOf("Hips", StringComparison.OrdinalIgnoreCase) >= 0)
      return root;
  for (int i = 0; i < root.childCount; i++)
  {
      var found = FindHips(root.GetChild(i));
      if (found != null) return found;
  }
  return null;
}

애니메이션 재생을 위해 하이어라키 구조에서 Hips를 찾고 그 부모를 애니메이션 타겟으로 사용합니다.
애니메이션 클립의 재생 구조가 mixamorig1:Hips와 같은 형식으로 되어있다면, mixamorig1: 부분을 제거해 정규화 시킵니다.

배경(Skybox) 변경

Skybox
각 가상 씬마다 다른 배경을 적용할 수 있도록 했습니다. 현재는 빌드에 포함돼있는 스카이박스만 지정이 가능하지만, 추후 생성형 AI를 도입할 예정입니다.

코드 보기코드 닫기
C#
private void LoadSkyboxForCurrentScene()
{
  var currentScene = currentScenario.GetCurrentScene();
  Material skyboxMaterial = ScenarioManager.Instance.GetSkyboxMaterial(currentScene.skyboxMaterialIndex);

  RenderSettings.skybox = skyboxMaterial;
  DynamicGI.UpdateEnvironment();
}

생성형 AI를 통한 모델 생성 (텍스트 to 3D) (Meshy API)

Text-to-3D Pipeline
Meshy AI의 REST API를 통해 입력한 텍스트 프롬프트를 기반으로 3D 모델을 생성시켜 자동으로 임포트까지 되도록 구현했습니다.

코드 보기코드 닫기
C#
//MeshyAI API 요청 코드 일부
string url = $"{API_BASE_URL}{API_VERSION}/text-to-3d";

var requestData = new PreviewTaskRequest
{
    mode = "preview",
    prompt = prompt,
    art_style = "realistic",
    should_remesh = true,
    topology = "triangle",
    target_polycount = 30000
};

string jsonData = JsonUtility.ToJson(requestData);
byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonData);

string lastError = null;
for (int keyIndex = 0; keyIndex < keys.Count; keyIndex++)
{
    string key = keys[keyIndex];
    using (UnityWebRequest request = new UnityWebRequest(url, "POST"))
    {
        request.uploadHandler = new UploadHandlerRaw(bodyRaw);
        request.downloadHandler = new DownloadHandlerBuffer();
        request.SetRequestHeader("Content-Type", "application/json");
        request.SetRequestHeader("Authorization", $"Bearer {key}");

        yield return request.SendWebRequest();

        bool success = request.result == UnityWebRequest.Result.Success && (request.responseCode == 200 || request.responseCode == 202);
        if (success)
        {
            try
            {
                var response = JsonUtility.FromJson<TaskResponse>(request.downloadHandler.text);
                if (response != null && !string.IsNullOrEmpty(response.result))
                {
                    onTaskCreated?.Invoke(response.result);
                    yield break;
                }
                lastError = "Invalid response format";
            }
            catch (Exception e)
            {
                lastError = $"Failed to parse response: {e.Message}";
            }
        }
        else
        {
            lastError = $"API Error (Code: {request.responseCode}): {request.error}";
            if (request.downloadHandler != null && !string.IsNullOrEmpty(request.downloadHandler.text))
                lastError += $"Response: {request.downloadHandler.text}";
        }
    }
}

시나리오 / 3D모델 라이브러리

Scenario Library
상담사가 생성한 시나리오와 시나리오에 배치된 3D 모델들을 라이브러리에 공유할 수 있습니다.
라이브러리에 공유된 시나리오 및 3D 모델은 모든 상담사가 자유롭게 사용할 수 있도록 중앙서버를 통해 공유됩니다.

서버와 클라이언트는 다음과 같은 구조로 동작합니다. 일부 3D 모델의 경우에는 모델링 단일 파일이 아니라, Material 등의 의존성 파일이 함께 있기 때문에 시나리오와 마찬가지로 압축해서 전송합니다.
Network Flow
중앙 서버는 ASP.NET Core로 구현했고, Ubuntu Server를 구축하여 호스팅하고 있습니다. 모든 네트워크 통신에서 Bearer 토큰을 사용해 인증된 요청만 처리하도록 했습니다.

UI

UI
Unity 6.3부터 UI Toolkit에서 World Space UI를 지원하기 때문에, 2D UI와 VR UI는 모두 UI Toolkit을 사용하여 구현했습니다.
UI 구현에는 MVC 패턴을 적용했습니다. UI Toolkit에서는 MVVM이 주로 권장되지만, 팀 규모와 프로젝트 복잡도를 고려했을 때 MVC가 유지보수와 온보딩 측면에서 유리하다고 판단했습니다.

기술적 챌린지

이 프로젝트를 개발하면서 런타임으로 모델을 임포트할 때 쉐이더가 계속 깨지는 문제가 발생했습니다.
런타임 에디터의 존재이유는 모델을 임포트할 수 있도록 지원하는 것이기 때문에 이 문제는 무조건적으로 해결이 필요했습니다.
이 문제는 GLTFast로 모델을 임포트 할 때 모델의 쉐이더가 빌드에 포함되어있지 않기 때문이었습니다.

모델은 다양하고, 모델이 다양함에 따라 Material의 쉐이더도 다양했습니다.
이 문제의 해결을 위해서는 빌드에 쉐이더가 포함되어 있어야 했습니다.
Unity에서는 Preload Shader 기능을 제공했고, 이 기능을 사용하여 빌드에 다양한 쉐이더를 포함시켰습니다.
셰이더를 일일이 지정하여 포함할 수 없었기 때문에 자동화 툴을 만들어 Shader Variant Collection을 생성했습니다.
Shader Variant collection

Wrapper Application

Spoof!는 상용 프로그램의 형태를 띄웁니다. 단순히 Unity 런타임 애플리케이션으로만 제공하는 것은 사용자 경험 측면에서 가벼운 느낌을 줄 수 있습니다.
이러한 이유로 Unity 런타임 애플리케이션을 감싸는 애플리케이션을 별도로 구현했습니다.
Wrapper1
Wrapper2
Wrapper 프로그램에서 Unity 런타임을 실행시키고, 실행하는 동안 로딩 화면을 띄웁니다.

Wrapper3
MAUI로 제작된 Wrapper 애플리케이션에서는 커스텀으로 제작된 확장된 타이틀바를 제공합니다. 타이틀바에서는 메뉴바도 함께 제공되며, 메뉴에서 다양한 기능을 지원할 수 있습니다.

추후 라이센스 연동을 통해 라이센스 관리를 추가할 예정입니다.

목차

개요구현런타임 씬 에디터가상 씬 구조런타임 애니메이션 재생배경(Skybox) 변경생성형 AI를 통한 모델 생성 (텍스트 to 3D) (Meshy API)시나리오 / 3D모델 라이브러리UI기술적 챌린지Wrapper Application
이전 프로젝트

GRIMLINE

2D 플랫포머 게임

다음 프로젝트

메모 앵커

3D 스캔 공간에 AR로 메모를 고정하는 서비스