237 lines
8.6 KiB
C#
237 lines
8.6 KiB
C#
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;
|
|
|
|
// 중요: Vrm10Instance.Runtime 은 지연 생성 프로퍼티다. 여기서 한 번 접근해
|
|
// 컨트롤 리그를 미리 만들어 둔다. 컨트롤 리그가 생성될 때 Animator.avatar 가
|
|
// 교체되는데, 그 전에 붙은 컴포넌트들은 GetBoneTransform() 으로 실제 메시 본을
|
|
// 잡아버린다. 그 본은 매 프레임 ControlRig.Process() 가 덮어쓰므로 우리가
|
|
// 가한 회전이 전부 지워진다. 반드시 컴포넌트 부착보다 먼저 접근해야 한다.
|
|
_ = vrm.Runtime;
|
|
|
|
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;
|
|
}
|
|
}
|