using System; using System.IO; using System.Threading; using System.Threading.Tasks; using UnityEngine; using UniGLTF; using UniVRM10; /// /// VRM 모델을 실행 중에 파일에서 불러온다. /// /// 모델을 빌드에 포함하지 않고 사용자가 직접 자기 파일을 넣게 하는 구조다. /// 부스 등에서 판매되는 아바타 규약(VN3)은 대체로 모델 데이터의 재배포를 /// 금지하므로, 앱이 모델을 유통하지 않는 이 방식이라야 배포가 가능해진다. /// VSeeFace, VMagicMirror 등이 같은 구조를 쓴다. /// /// 탐색 순서 /// 1. 이전에 사용자가 고른 경로 (PlayerPrefs) /// 2. 실행 파일 옆 Models 폴더의 첫 .vrm /// public class VrmCharacterLoader : MonoBehaviour { const string PrefKey = "MyCharacterAgent.ModelPath"; [Header("배치")] [Tooltip("로드한 모델을 놓을 위치")] [SerializeField] Vector3 spawnPosition = Vector3.zero; [Tooltip("VRM 1.0 규격상 모델은 +Z 를 향한다. 카메라 쪽으로 돌려세운다")] [SerializeField] bool faceCamera = true; [Header("컴포넌트")] [Tooltip("로드 후 시선 추적 컴포넌트를 붙일지")] [SerializeField] bool addHeadLookAt = true; [Tooltip("클릭 판정용 콜라이더를 붙일지. 없으면 클릭 통과가 캐릭터를 인식하지 못한다")] [SerializeField] bool addCollider = true; [Tooltip("애니메이션 클립이 없을 때 T포즈를 대기 자세로 바꿔주는 컴포넌트. " + "실제 클립을 넣으면 끄면 된다")] [SerializeField] bool addProceduralIdle = true; CancellationTokenSource cts; /// 현재 로드된 캐릭터. 없으면 null. public ICharacterAvatar Current { get; private set; } /// 로드 성공 시 호출. 립싱크 등 후속 시스템이 구독한다. public event Action Loaded; /// 로드 실패 시 사유를 전달한다. public event Action LoadFailed; GameObject currentRoot; async void Start() { string path = ResolveInitialPath(); if (string.IsNullOrEmpty(path)) { string dir = ModelsDirectory; Debug.LogWarning($"[VrmCharacterLoader] 불러올 .vrm 이 없습니다. " + $"다음 폴더에 넣어주세요: {dir}"); LoadFailed?.Invoke($"{dir} 폴더에 .vrm 파일을 넣어주세요."); return; } await LoadFrom(path); } void OnDestroy() { cts?.Cancel(); cts?.Dispose(); } /// 실행 파일 옆의 Models 폴더. 에디터에서는 프로젝트 루트 옆. public static string ModelsDirectory { get { #if UNITY_EDITOR string baseDir = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; #else string baseDir = Directory.GetParent(Application.dataPath)?.FullName ?? Application.dataPath; #endif return Path.Combine(baseDir, "Models"); } } string ResolveInitialPath() { // 1) 사용자가 이전에 고른 파일 string saved = PlayerPrefs.GetString(PrefKey, string.Empty); if (!string.IsNullOrEmpty(saved) && File.Exists(saved)) return saved; // 2) Models 폴더의 첫 .vrm try { string dir = ModelsDirectory; if (!Directory.Exists(dir)) { Directory.CreateDirectory(dir); return null; } var files = Directory.GetFiles(dir, "*.vrm", SearchOption.TopDirectoryOnly); if (files.Length > 0) { Array.Sort(files, StringComparer.OrdinalIgnoreCase); return files[0]; } } catch (Exception e) { Debug.LogError($"[VrmCharacterLoader] Models 폴더 접근 실패: {e.Message}"); } return null; } /// 지정한 .vrm 파일을 불러온다. 트레이의 "모델 선택"에서도 호출한다. public async Task LoadFrom(string path) { if (!File.Exists(path)) { string msg = $"파일이 없습니다: {path}"; Debug.LogError("[VrmCharacterLoader] " + msg); LoadFailed?.Invoke(msg); return; } cts?.Cancel(); cts?.Dispose(); cts = new CancellationTokenSource(); try { Debug.Log($"[VrmCharacterLoader] 로드 시작: {path}"); var vrm = await Vrm10.LoadPathAsync( path, canLoadVrm0X: true, // VRM 0.x 를 로드 시점에 1.0 으로 마이그레이션 showMeshes: false, // 배치를 끝낸 뒤 한 번에 보여줘야 깜빡임이 없다 awaitCaller: new RuntimeOnlyAwaitCaller(), materialGenerator: null, // null 이면 현재 렌더 파이프라인에 맞는 생성기를 UniVRM 이 고른다 ct: cts.Token); if (vrm == null) { string msg = "VRM 로드에 실패했습니다. 파일이 손상되었거나 VRM 형식이 아닐 수 있습니다."; Debug.LogError("[VrmCharacterLoader] " + msg); LoadFailed?.Invoke(msg); return; } Setup(vrm); PlayerPrefs.SetString(PrefKey, path); PlayerPrefs.Save(); Debug.Log($"[VrmCharacterLoader] 로드 완료: {vrm.name}"); } catch (OperationCanceledException) { // 다른 모델로 교체하는 중. 정상 경로. } catch (Exception e) { Debug.LogError($"[VrmCharacterLoader] 로드 중 예외: {e}"); LoadFailed?.Invoke(e.Message); } } void Setup(Vrm10Instance vrm) { // 이전 모델 정리 if (currentRoot != null) Destroy(currentRoot); currentRoot = vrm.gameObject; currentRoot.transform.SetParent(transform, false); currentRoot.transform.localPosition = spawnPosition; // 컨트롤 리그를 만드는 동안에는 회전을 걸어두지 않는다. 아래 설명 참고. currentRoot.transform.localRotation = Quaternion.identity; // 중요: Vrm10Instance.Runtime 은 지연 생성 프로퍼티다. 여기서 한 번 접근해 // 컨트롤 리그를 미리 만들어 둔다. 컨트롤 리그가 생성될 때 Animator.avatar 가 // 교체되는데, 그 전에 붙은 컴포넌트들은 GetBoneTransform() 으로 실제 메시 본을 // 잡아버린다. 그 본은 매 프레임 ControlRig.Process() 가 덮어쓰므로 우리가 // 가한 회전이 전부 지워진다. 반드시 컴포넌트 부착보다 먼저 접근해야 한다. _ = vrm.Runtime; // 돌려세우는 것은 반드시 컨트롤 리그가 만들어진 "뒤"라야 한다. // // Vrm10RuntimeControlRig 는 리그 루트를 이렇게 만든다: // _controlRigRoot = new GameObject(...).transform; // _controlRigRoot.SetParent(vrmRoot); // 인자 하나 = worldPositionStays:true // // 새 오브젝트는 월드 회전 identity 로 생기고, SetParent 의 기본값이 월드 트랜스폼 // 유지라서 부모에 붙어도 월드 identity 를 지킨다. 즉 리그 루트의 로컬 회전이 // inverse(vrmRoot.rotation) 이 되어, 미리 걸어둔 회전을 정확히 상쇄해 버린다. // 그러면 애니메이션 클립이 만든 포즈가 루트 회전을 무시하고 늘 월드 +Z 를 향한다 // (= 카메라에 등을 보인다. faceCamera 를 꺼도 달라지지 않는다). // // 리그가 다 만들어진 뒤에 돌리면 리그 전체가 자식으로서 같이 돌아가므로 문제없다. // ProceduralIdle 만 쓸 때는 기존 포즈에 작은 델타만 얹어서 이 함정이 안 보였다. currentRoot.transform.localRotation = faceCamera ? Quaternion.Euler(0f, 180f, 0f) // VRM 은 +Z 를 향하므로 돌려세운다 : Quaternion.identity; var avatar = currentRoot.AddComponent(); avatar.Bind(vrm); Current = avatar; if (addCollider) FitCapsule(currentRoot); if (addHeadLookAt && currentRoot.GetComponent() == null) { currentRoot.AddComponent(); } if (addProceduralIdle && currentRoot.GetComponent() == null) { currentRoot.AddComponent(); } // 배치가 끝난 뒤에 표시. showMeshes:false 로 로드한 이유. var runtime = vrm.GetComponent(); if (runtime != null) runtime.ShowMeshes(); Loaded?.Invoke(avatar); } /// 렌더러 전체를 감싸는 캡슐 콜라이더. 클릭 히트테스트용. static void FitCapsule(GameObject go) { var renderers = go.GetComponentsInChildren(true); if (renderers.Length == 0) return; Bounds world = renderers[0].bounds; for (int i = 1; i < renderers.Length; i++) world.Encapsulate(renderers[i].bounds); var col = go.GetComponent(); if (col == null) col = go.AddComponent(); Vector3 scale = go.transform.lossyScale; float sx = Mathf.Approximately(scale.x, 0f) ? 1f : Mathf.Abs(scale.x); float sy = Mathf.Approximately(scale.y, 0f) ? 1f : Mathf.Abs(scale.y); float sz = Mathf.Approximately(scale.z, 0f) ? 1f : Mathf.Abs(scale.z); col.direction = 1; // Y 축 col.center = go.transform.InverseTransformPoint(world.center); col.height = world.size.y / sy; col.radius = Mathf.Max(world.size.x / sx, world.size.z / sz) * 0.5f; } }