캐릭터 로드

This commit is contained in:
2026-08-26 03:39:36 +09:00
parent 8d1881749c
commit 7204afae54
31 changed files with 1750 additions and 41 deletions

View File

@@ -0,0 +1,229 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UniGLTF;
using UniVRM10;
/// <summary>
/// VRM 모델을 실행 중에 파일에서 불러온다.
///
/// 모델을 빌드에 포함하지 않고 사용자가 직접 자기 파일을 넣게 하는 구조다.
/// 부스 등에서 판매되는 아바타 규약(VN3)은 대체로 모델 데이터의 재배포를
/// 금지하므로, 앱이 모델을 유통하지 않는 이 방식이라야 배포가 가능해진다.
/// VSeeFace, VMagicMirror 등이 같은 구조를 쓴다.
///
/// 탐색 순서
/// 1. 이전에 사용자가 고른 경로 (PlayerPrefs)
/// 2. 실행 파일 옆 Models 폴더의 첫 .vrm
/// </summary>
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;
/// <summary>현재 로드된 캐릭터. 없으면 null.</summary>
public ICharacterAvatar Current { get; private set; }
/// <summary>로드 성공 시 호출. 립싱크 등 후속 시스템이 구독한다.</summary>
public event Action<ICharacterAvatar> Loaded;
/// <summary>로드 실패 시 사유를 전달한다.</summary>
public event Action<string> 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();
}
/// <summary>실행 파일 옆의 Models 폴더. 에디터에서는 프로젝트 루트 옆.</summary>
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;
}
/// <summary>지정한 .vrm 파일을 불러온다. 트레이의 "모델 선택"에서도 호출한다.</summary>
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 = faceCamera
? Quaternion.Euler(0f, 180f, 0f) // VRM 은 +Z 를 향하므로 돌려세운다
: Quaternion.identity;
var avatar = currentRoot.AddComponent<VrmAvatar>();
avatar.Bind(vrm);
Current = avatar;
if (addCollider) FitCapsule(currentRoot);
if (addHeadLookAt && currentRoot.GetComponent<HeadLookAt>() == null)
{
currentRoot.AddComponent<HeadLookAt>();
}
if (addProceduralIdle && currentRoot.GetComponent<ProceduralIdle>() == null)
{
currentRoot.AddComponent<ProceduralIdle>();
}
// 배치가 끝난 뒤에 표시. showMeshes:false 로 로드한 이유.
var runtime = vrm.GetComponent<RuntimeGltfInstance>();
if (runtime != null) runtime.ShowMeshes();
Loaded?.Invoke(avatar);
}
/// <summary>렌더러 전체를 감싸는 캡슐 콜라이더. 클릭 히트테스트용.</summary>
static void FitCapsule(GameObject go)
{
var renderers = go.GetComponentsInChildren<Renderer>(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<CapsuleCollider>();
if (col == null) col = go.AddComponent<CapsuleCollider>();
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;
}
}