캐릭터 로드
This commit is contained in:
229
Assets/02_Scripts/Character/VrmCharacterLoader.cs
Normal file
229
Assets/02_Scripts/Character/VrmCharacterLoader.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user