캐릭터 로드
This commit is contained in:
BIN
Assets/01_Scenes/MainScene.unity
LFS
BIN
Assets/01_Scenes/MainScene.unity
LFS
Binary file not shown.
8
Assets/02_Scripts/Character.meta
Normal file
8
Assets/02_Scripts/Character.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 736e5279b21711442b1c23fbbdede16f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
65
Assets/02_Scripts/Character/AvatarTypes.cs
Normal file
65
Assets/02_Scripts/Character/AvatarTypes.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 캐릭터가 지을 수 있는 감정. LLM 이 출력하는 인라인 태그(예: [joy])가 여기로 매핑된다.
|
||||
/// VRM 1.0 의 표정 프리셋과 같은 구성이라, 나중에 VRM 으로 갈아타도 그대로 대응된다.
|
||||
/// </summary>
|
||||
public enum AvatarEmotion
|
||||
{
|
||||
Neutral = 0,
|
||||
Happy,
|
||||
Angry,
|
||||
Sad,
|
||||
Relaxed,
|
||||
Surprised,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 입 모양. TTS 오디오 분석 결과가 여기로 들어온다.
|
||||
///
|
||||
/// 모음 5개만 두는 이유: uLipSync 기본 프로파일(A/I/U/E/O), VRM 규격(aa/ih/ou/ee/oh),
|
||||
/// VRChat 비셈 15개 중 모음 부분이 모두 이 5개로 수렴한다. 최소 공통분모라
|
||||
/// 어떤 모델이든 매핑이 가능하다. 자음까지 필요해지면 그때 확장한다.
|
||||
/// </summary>
|
||||
public enum AvatarViseme
|
||||
{
|
||||
Silence = 0,
|
||||
A,
|
||||
I,
|
||||
U,
|
||||
E,
|
||||
O,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 캐릭터 표현 계층의 유일한 창구.
|
||||
///
|
||||
/// 립싱크 / 감정 / 시선 코드는 이 인터페이스만 알고, 모델이 FBX 인지 VRM 인지
|
||||
/// 전혀 모른다. 덕분에 모델을 교체하거나 포맷을 바꿔도 상위 로직은 그대로다.
|
||||
///
|
||||
/// 값 설정과 실제 반영은 분리되어 있다. 구현체가 LateUpdate 에서 한 번에 반영하므로
|
||||
/// 호출자는 한 프레임에 여러 번 불러도 안전하다.
|
||||
/// </summary>
|
||||
public interface ICharacterAvatar
|
||||
{
|
||||
/// <summary>캐릭터 루트. 위치 이동과 히트테스트 기준.</summary>
|
||||
Transform Root { get; }
|
||||
|
||||
/// <summary>휴머노이드 Animator. 없으면 null.</summary>
|
||||
Animator Animator { get; }
|
||||
|
||||
/// <summary>감정 표정 세기 (0~1).</summary>
|
||||
void SetEmotion(AvatarEmotion emotion, float weight);
|
||||
|
||||
/// <summary>모든 감정을 0 으로.</summary>
|
||||
void ClearEmotions();
|
||||
|
||||
/// <summary>입 모양 세기 (0~1).</summary>
|
||||
void SetViseme(AvatarViseme viseme, float weight);
|
||||
|
||||
/// <summary>모든 입 모양을 0 으로. 발화 종료 시 호출.</summary>
|
||||
void ClearVisemes();
|
||||
|
||||
/// <summary>눈 감김 (0=뜸, 1=감음). 자동 깜빡임이 켜져 있으면 그쪽이 덮어쓴다.</summary>
|
||||
void SetBlink(float weight);
|
||||
}
|
||||
2
Assets/02_Scripts/Character/AvatarTypes.cs.meta
Normal file
2
Assets/02_Scripts/Character/AvatarTypes.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2deea8e1d55fbfa40b1004980c68d08f
|
||||
258
Assets/02_Scripts/Character/BlendShapeAvatar.cs
Normal file
258
Assets/02_Scripts/Character/BlendShapeAvatar.cs
Normal file
@@ -0,0 +1,258 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>블렌드셰이프 하나를 가리키는 참조.</summary>
|
||||
[Serializable]
|
||||
public class BlendShapeBinding
|
||||
{
|
||||
public SkinnedMeshRenderer renderer;
|
||||
public int index = -1;
|
||||
|
||||
[Tooltip("이 셰이프의 최대치. 대부분 100 이지만 과하게 벌어지는 모델은 낮춰서 조절한다")]
|
||||
public float maxWeight = 100f;
|
||||
|
||||
public bool IsValid =>
|
||||
renderer != null && renderer.sharedMesh != null &&
|
||||
index >= 0 && index < renderer.sharedMesh.blendShapeCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 표정/입모양 하나를 구성하는 블렌드셰이프 묶음.
|
||||
/// 눈 감김처럼 좌우가 나뉜 경우(ARKit eyeBlinkLeft/Right)를 위해 여러 개를 담는다.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class BlendShapeGroup
|
||||
{
|
||||
public List<BlendShapeBinding> bindings = new List<BlendShapeBinding>();
|
||||
public bool HasAny => bindings != null && bindings.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FBX 캐릭터용 ICharacterAvatar 구현.
|
||||
///
|
||||
/// FBX 는 블렌드셰이프 이름을 표준화하지 않으므로(VRChat 의 vrc.v_* , ARKit 52,
|
||||
/// VRoid 의 Fcl_* , 일본어 あいうえお 등 관례만 있음) 모델마다 한 번 매핑을 잡아준다.
|
||||
/// 인스펙터의 자동 감지 버튼이 흔한 관례를 찾아 대부분 채워준다.
|
||||
///
|
||||
/// 값 설정과 반영을 분리해, 여러 시스템(립싱크·감정·깜빡임)이 같은 프레임에
|
||||
/// 각자 값을 써도 LateUpdate 에서 한 번에 합산 반영된다.
|
||||
/// </summary>
|
||||
public class BlendShapeAvatar : MonoBehaviour, ICharacterAvatar
|
||||
{
|
||||
[Header("참조")]
|
||||
[Tooltip("비우면 이 오브젝트의 Transform 사용")]
|
||||
[SerializeField] Transform root;
|
||||
|
||||
[Tooltip("비우면 자식에서 자동 탐색")]
|
||||
[SerializeField] Animator animator;
|
||||
|
||||
[Header("매핑 (인스펙터의 자동 감지 사용 권장)")]
|
||||
[SerializeField] BlendShapeGroup[] emotionGroups;
|
||||
[SerializeField] BlendShapeGroup[] visemeGroups;
|
||||
[SerializeField] BlendShapeGroup blinkGroup = new BlendShapeGroup();
|
||||
|
||||
[Header("자동 깜빡임")]
|
||||
[SerializeField] bool autoBlink = true;
|
||||
[SerializeField] Vector2 blinkIntervalRange = new Vector2(2.5f, 6.5f);
|
||||
[SerializeField] float blinkDuration = 0.12f;
|
||||
|
||||
static readonly int EmotionCount = Enum.GetValues(typeof(AvatarEmotion)).Length;
|
||||
static readonly int VisemeCount = Enum.GetValues(typeof(AvatarViseme)).Length;
|
||||
|
||||
float[] emotionWeights;
|
||||
float[] visemeWeights;
|
||||
float blinkWeight;
|
||||
|
||||
// 매 프레임 0 으로 되돌릴 대상. 중복 제거해 캐시해둔다.
|
||||
readonly List<BlendShapeBinding> touched = new List<BlendShapeBinding>();
|
||||
|
||||
float nextBlinkTime;
|
||||
float blinkStartedAt = -1f;
|
||||
|
||||
public Transform Root => root != null ? root : transform;
|
||||
public Animator Animator => animator;
|
||||
|
||||
void Reset()
|
||||
{
|
||||
EnsureArrays();
|
||||
root = transform;
|
||||
animator = GetComponentInChildren<Animator>();
|
||||
}
|
||||
|
||||
void OnValidate() => EnsureArrays();
|
||||
|
||||
void Awake()
|
||||
{
|
||||
EnsureArrays();
|
||||
|
||||
emotionWeights = new float[EmotionCount];
|
||||
visemeWeights = new float[VisemeCount];
|
||||
|
||||
if (animator == null) animator = GetComponentInChildren<Animator>();
|
||||
|
||||
CacheTouched();
|
||||
ScheduleNextBlink();
|
||||
}
|
||||
|
||||
void EnsureArrays()
|
||||
{
|
||||
if (emotionGroups == null || emotionGroups.Length != EmotionCount)
|
||||
{
|
||||
var next = new BlendShapeGroup[EmotionCount];
|
||||
for (int i = 0; i < EmotionCount; i++)
|
||||
next[i] = (emotionGroups != null && i < emotionGroups.Length && emotionGroups[i] != null)
|
||||
? emotionGroups[i] : new BlendShapeGroup();
|
||||
emotionGroups = next;
|
||||
}
|
||||
|
||||
if (visemeGroups == null || visemeGroups.Length != VisemeCount)
|
||||
{
|
||||
var next = new BlendShapeGroup[VisemeCount];
|
||||
for (int i = 0; i < VisemeCount; i++)
|
||||
next[i] = (visemeGroups != null && i < visemeGroups.Length && visemeGroups[i] != null)
|
||||
? visemeGroups[i] : new BlendShapeGroup();
|
||||
visemeGroups = next;
|
||||
}
|
||||
|
||||
blinkGroup ??= new BlendShapeGroup();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 매 프레임 초기화해야 할 블렌드셰이프 목록을 만든다.
|
||||
/// 초기화를 빼먹으면 이전 프레임 표정이 그대로 남아 겹친다.
|
||||
/// </summary>
|
||||
void CacheTouched()
|
||||
{
|
||||
touched.Clear();
|
||||
void Collect(BlendShapeGroup g)
|
||||
{
|
||||
if (g?.bindings == null) return;
|
||||
foreach (var b in g.bindings)
|
||||
{
|
||||
if (b == null || !b.IsValid) continue;
|
||||
bool dup = false;
|
||||
foreach (var t in touched)
|
||||
{
|
||||
if (t.renderer == b.renderer && t.index == b.index) { dup = true; break; }
|
||||
}
|
||||
if (!dup) touched.Add(b);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var g in emotionGroups) Collect(g);
|
||||
foreach (var g in visemeGroups) Collect(g);
|
||||
Collect(blinkGroup);
|
||||
}
|
||||
|
||||
// ---------------- ICharacterAvatar ----------------
|
||||
|
||||
public void SetEmotion(AvatarEmotion emotion, float weight)
|
||||
{
|
||||
if (emotionWeights == null) return;
|
||||
emotionWeights[(int)emotion] = Mathf.Clamp01(weight);
|
||||
}
|
||||
|
||||
public void ClearEmotions()
|
||||
{
|
||||
if (emotionWeights == null) return;
|
||||
Array.Clear(emotionWeights, 0, emotionWeights.Length);
|
||||
}
|
||||
|
||||
public void SetViseme(AvatarViseme viseme, float weight)
|
||||
{
|
||||
if (visemeWeights == null) return;
|
||||
visemeWeights[(int)viseme] = Mathf.Clamp01(weight);
|
||||
}
|
||||
|
||||
public void ClearVisemes()
|
||||
{
|
||||
if (visemeWeights == null) return;
|
||||
Array.Clear(visemeWeights, 0, visemeWeights.Length);
|
||||
}
|
||||
|
||||
public void SetBlink(float weight) => blinkWeight = Mathf.Clamp01(weight);
|
||||
|
||||
// ---------------- 반영 ----------------
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
if (autoBlink) UpdateAutoBlink();
|
||||
Apply();
|
||||
}
|
||||
|
||||
void UpdateAutoBlink()
|
||||
{
|
||||
if (blinkStartedAt < 0f)
|
||||
{
|
||||
if (Time.time >= nextBlinkTime) blinkStartedAt = Time.time;
|
||||
return;
|
||||
}
|
||||
|
||||
float t = (Time.time - blinkStartedAt) / Mathf.Max(0.01f, blinkDuration);
|
||||
if (t >= 1f)
|
||||
{
|
||||
blinkWeight = 0f;
|
||||
blinkStartedAt = -1f;
|
||||
ScheduleNextBlink();
|
||||
return;
|
||||
}
|
||||
|
||||
// 0 -> 1 -> 0 삼각파. 감았다 뜨는 한 번의 동작.
|
||||
blinkWeight = 1f - Mathf.Abs(t * 2f - 1f);
|
||||
}
|
||||
|
||||
void ScheduleNextBlink()
|
||||
{
|
||||
nextBlinkTime = Time.time + UnityEngine.Random.Range(blinkIntervalRange.x, blinkIntervalRange.y);
|
||||
}
|
||||
|
||||
void Apply()
|
||||
{
|
||||
// 1) 이번 프레임에 건드릴 셰이프를 전부 0 으로
|
||||
foreach (var b in touched)
|
||||
{
|
||||
if (b.IsValid) b.renderer.SetBlendShapeWeight(b.index, 0f);
|
||||
}
|
||||
|
||||
// 2) 감정 -> 입모양 -> 깜빡임 순으로 누적
|
||||
for (int i = 0; i < emotionGroups.Length; i++)
|
||||
AddGroup(emotionGroups[i], emotionWeights[i]);
|
||||
|
||||
for (int i = 0; i < visemeGroups.Length; i++)
|
||||
AddGroup(visemeGroups[i], visemeWeights[i]);
|
||||
|
||||
AddGroup(blinkGroup, blinkWeight);
|
||||
}
|
||||
|
||||
void AddGroup(BlendShapeGroup group, float weight)
|
||||
{
|
||||
if (group?.bindings == null || weight <= 0f) return;
|
||||
|
||||
foreach (var b in group.bindings)
|
||||
{
|
||||
if (b == null || !b.IsValid) continue;
|
||||
|
||||
float target = weight * b.maxWeight;
|
||||
float current = b.renderer.GetBlendShapeWeight(b.index);
|
||||
b.renderer.SetBlendShapeWeight(b.index, Mathf.Min(current + target, b.maxWeight));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>에디터 자동 감지가 매핑을 바꾼 뒤 캐시를 다시 만들 때 사용.</summary>
|
||||
public void RebuildCache()
|
||||
{
|
||||
EnsureArrays();
|
||||
CacheTouched();
|
||||
}
|
||||
|
||||
/// <summary>매핑이 얼마나 채워졌는지 요약. 에디터 표시용.</summary>
|
||||
public string DescribeMapping()
|
||||
{
|
||||
EnsureArrays();
|
||||
int e = 0, v = 0;
|
||||
foreach (var g in emotionGroups) if (g.HasAny) e++;
|
||||
foreach (var g in visemeGroups) if (g.HasAny) v++;
|
||||
return $"감정 {e}/{EmotionCount - 1}, 입모양 {v}/{VisemeCount - 1}, 깜빡임 {(blinkGroup.HasAny ? "O" : "X")}";
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Character/BlendShapeAvatar.cs.meta
Normal file
2
Assets/02_Scripts/Character/BlendShapeAvatar.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4f705a8a153d76242beee16d85f491f1
|
||||
181
Assets/02_Scripts/Character/HeadLookAt.cs
Normal file
181
Assets/02_Scripts/Character/HeadLookAt.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 캐릭터가 마우스 커서(또는 지정한 대상)를 눈으로 좇게 한다.
|
||||
/// 표정·립싱크와 함께 "살아있다"는 인상을 만드는 핵심 요소.
|
||||
///
|
||||
/// 구현 노트: Animator 가 포즈를 쓴 뒤에 덮어써야 하므로 LateUpdate 에서 동작한다.
|
||||
/// 본의 로컬 축은 리그마다 제각각이라 로컬 오일러로 돌리면 모델에 따라 엉뚱한
|
||||
/// 방향으로 꺾인다. 그래서 캐릭터 루트의 up/right 축을 기준으로 월드 회전을
|
||||
/// 덧씌우는 방식을 쓴다. 리그 구조와 무관하게 동작한다.
|
||||
/// </summary>
|
||||
// VRM 컨트롤 리그는 Vrm10Instance.LateUpdate 에서 Runtime.Process() 로 실제 본에
|
||||
// 반영된다. 우리가 건드리는 것은 그 앞단의 컨트롤 리그 본이므로, 반드시 그보다
|
||||
// 먼저 실행돼야 한다. 순서를 안 정하면 LateUpdate 간 순서가 보장되지 않아
|
||||
// 모델에 따라 적용되기도 하고 안 되기도 한다.
|
||||
[DefaultExecutionOrder(-100)]
|
||||
public class HeadLookAt : MonoBehaviour
|
||||
{
|
||||
[Header("참조")]
|
||||
[Tooltip("비우면 자식에서 자동 탐색")]
|
||||
[SerializeField] Animator animator;
|
||||
|
||||
[Tooltip("휴머노이드가 아니거나 자동 탐색이 실패할 때 직접 지정")]
|
||||
[SerializeField] Transform headOverride;
|
||||
|
||||
[Tooltip("비우면 Camera.main")]
|
||||
[SerializeField] Camera viewCamera;
|
||||
|
||||
[Tooltip("커서 좌표 변환에 필요한 창 핸들 제공자. 비우면 씬에서 탐색")]
|
||||
[SerializeField] TransparentWindow window;
|
||||
|
||||
[Header("대상")]
|
||||
[SerializeField] bool followCursor = true;
|
||||
|
||||
[Tooltip("followCursor 가 꺼져 있을 때 바라볼 대상")]
|
||||
[SerializeField] Transform explicitTarget;
|
||||
|
||||
[Header("제한")]
|
||||
[Tooltip("좌우로 돌아갈 수 있는 최대 각도")]
|
||||
[SerializeField] float maxYaw = 65f;
|
||||
|
||||
[Tooltip("위아래로 돌아갈 수 있는 최대 각도")]
|
||||
[SerializeField] float maxPitch = 30f;
|
||||
|
||||
[Tooltip("클수록 빠르게 따라간다")]
|
||||
[SerializeField] float responsiveness = 8f;
|
||||
|
||||
[Tooltip("목이 나눠 가질 회전 비율. 나머지는 머리가 담당한다. 0.3~0.5 가 자연스럽다")]
|
||||
[Range(0f, 1f)]
|
||||
[SerializeField] float neckShare = 0.35f;
|
||||
|
||||
[Tooltip("대상을 놓쳤을 때 정면으로 돌아가기까지의 유예 시간(초)")]
|
||||
[SerializeField] float returnDelay = 1.5f;
|
||||
|
||||
Transform head;
|
||||
Transform neck;
|
||||
Transform root;
|
||||
|
||||
Vector2 currentAngles; // x = yaw, y = pitch
|
||||
float lastSeenTime = -999f;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
root = transform;
|
||||
|
||||
if (animator == null) animator = GetComponentInChildren<Animator>();
|
||||
if (viewCamera == null) viewCamera = Camera.main;
|
||||
if (window == null) window = FindFirstObjectByType<TransparentWindow>();
|
||||
|
||||
ResolveBones();
|
||||
}
|
||||
|
||||
void ResolveBones()
|
||||
{
|
||||
if (headOverride != null)
|
||||
{
|
||||
head = headOverride;
|
||||
}
|
||||
else if (animator != null && animator.isHuman)
|
||||
{
|
||||
head = animator.GetBoneTransform(HumanBodyBones.Head);
|
||||
neck = animator.GetBoneTransform(HumanBodyBones.Neck); // 없는 리그도 있다
|
||||
}
|
||||
|
||||
if (head == null)
|
||||
{
|
||||
Debug.LogWarning("[HeadLookAt] 머리 본을 찾지 못했습니다. " +
|
||||
"FBX 임포트 설정에서 Animation Type = Humanoid 인지 확인하거나 " +
|
||||
"Head Override 를 직접 지정하세요.");
|
||||
enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
if (head == null) return;
|
||||
|
||||
Vector2 desired;
|
||||
if (TryGetTargetPosition(out Vector3 targetPos))
|
||||
{
|
||||
lastSeenTime = Time.time;
|
||||
desired = ComputeAngles(targetPos);
|
||||
}
|
||||
else if (Time.time - lastSeenTime < returnDelay)
|
||||
{
|
||||
// 커서가 잠깐 창 밖으로 나간 정도로는 바로 정면으로 돌리지 않는다.
|
||||
desired = currentAngles;
|
||||
}
|
||||
else
|
||||
{
|
||||
desired = Vector2.zero;
|
||||
}
|
||||
|
||||
float t = 1f - Mathf.Exp(-responsiveness * Time.deltaTime); // 프레임레이트 독립
|
||||
currentAngles = Vector2.Lerp(currentAngles, desired, t);
|
||||
|
||||
ApplyRotation();
|
||||
}
|
||||
|
||||
bool TryGetTargetPosition(out Vector3 worldPos)
|
||||
{
|
||||
worldPos = default;
|
||||
|
||||
if (!followCursor)
|
||||
{
|
||||
if (explicitTarget == null) return false;
|
||||
worldPos = explicitTarget.position;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (viewCamera == null) return false;
|
||||
|
||||
System.IntPtr hwnd = window != null ? window.Hwnd : System.IntPtr.Zero;
|
||||
if (!DesktopCursor.TryGetScreenPosition(hwnd, out Vector2 screenPos)) return false;
|
||||
|
||||
// 머리와 같은 깊이 평면에 커서를 투영한다.
|
||||
Vector3 camForward = viewCamera.transform.forward;
|
||||
float depth = Vector3.Dot(head.position - viewCamera.transform.position, camForward);
|
||||
if (depth <= 0.01f) return false;
|
||||
|
||||
worldPos = viewCamera.ScreenToWorldPoint(new Vector3(screenPos.x, screenPos.y, depth));
|
||||
return true;
|
||||
}
|
||||
|
||||
Vector2 ComputeAngles(Vector3 targetPos)
|
||||
{
|
||||
Vector3 local = root.InverseTransformDirection(targetPos - head.position);
|
||||
if (local.sqrMagnitude < 0.000001f) return Vector2.zero;
|
||||
local.Normalize();
|
||||
|
||||
float yaw = Mathf.Atan2(local.x, local.z) * Mathf.Rad2Deg;
|
||||
float pitch = Mathf.Asin(Mathf.Clamp(local.y, -1f, 1f)) * Mathf.Rad2Deg;
|
||||
|
||||
return new Vector2(
|
||||
Mathf.Clamp(yaw, -maxYaw, maxYaw),
|
||||
Mathf.Clamp(pitch, -maxPitch, maxPitch));
|
||||
}
|
||||
|
||||
void ApplyRotation()
|
||||
{
|
||||
float yaw = currentAngles.x;
|
||||
float pitch = currentAngles.y;
|
||||
|
||||
// pitch 가 양수면 대상이 위쪽. Unity 에서 right 축 +회전은 고개를 숙이므로 부호를 뒤집는다.
|
||||
float neckPart = neck != null ? neckShare : 0f;
|
||||
float headPart = 1f - neckPart;
|
||||
|
||||
if (neck != null)
|
||||
{
|
||||
neck.rotation = Delta(yaw * neckPart, pitch * neckPart) * neck.rotation;
|
||||
}
|
||||
|
||||
// 목을 먼저 돌렸으므로 head.rotation 에는 그 결과가 이미 반영돼 있다.
|
||||
head.rotation = Delta(yaw * headPart, pitch * headPart) * head.rotation;
|
||||
}
|
||||
|
||||
Quaternion Delta(float yaw, float pitch)
|
||||
{
|
||||
return Quaternion.AngleAxis(yaw, root.up) * Quaternion.AngleAxis(-pitch, root.right);
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Character/HeadLookAt.cs.meta
Normal file
2
Assets/02_Scripts/Character/HeadLookAt.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4acef0f074253e24cbdf756b4f7f3359
|
||||
134
Assets/02_Scripts/Character/ProceduralIdle.cs
Normal file
134
Assets/02_Scripts/Character/ProceduralIdle.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 애니메이션 클립 없이 T포즈를 자연스러운 대기 자세로 바꾼다.
|
||||
///
|
||||
/// 왜 필요한가: VRM 은 모델만 담고 애니메이션은 담지 않는다. 사용자가 임의의 VRM 을
|
||||
/// 넣는 구조에서는 클립이 딸려 온다는 보장이 없으므로, 최소한의 대기 자세는
|
||||
/// 코드로 만들어 두어야 한다. 나중에 실제 클립을 넣으면 이 컴포넌트를 끄면 된다.
|
||||
///
|
||||
/// 구현 노트 1: 본의 로컬 축은 리그마다 제각각이라 로컬 오일러로 돌리면 모델에 따라
|
||||
/// 엉뚱하게 꺾인다. 캐릭터 루트의 축을 기준으로 월드 회전을 덧씌워 리그에 무관하게 만든다.
|
||||
///
|
||||
/// 구현 노트 2: VRM 컨트롤 리그는 Vrm10Instance.LateUpdate 에서 실제 본으로 반영되므로
|
||||
/// 그보다 먼저 실행돼야 한다. HeadLookAt 과 같은 이유로 실행 순서를 앞당긴다.
|
||||
/// </summary>
|
||||
[DefaultExecutionOrder(-100)]
|
||||
public class ProceduralIdle : MonoBehaviour
|
||||
{
|
||||
[Header("참조")]
|
||||
[Tooltip("비우면 자식에서 자동 탐색")]
|
||||
[SerializeField] Animator animator;
|
||||
|
||||
[Header("팔 내리기 (T포즈 해소)")]
|
||||
[Tooltip("위팔을 몸쪽으로 내리는 각도. 70 전후가 자연스럽다")]
|
||||
[Range(0f, 90f)]
|
||||
[SerializeField] float upperArmDown = 70f;
|
||||
|
||||
[Tooltip("아래팔을 조금 더 내려 팔이 늘어지게 한다")]
|
||||
[Range(0f, 40f)]
|
||||
[SerializeField] float lowerArmDown = 10f;
|
||||
|
||||
[Tooltip("팔을 몸에서 살짝 띄운다. 0 이면 몸에 붙는다")]
|
||||
[Range(0f, 20f)]
|
||||
[SerializeField] float armOutward = 5f;
|
||||
|
||||
[Header("호흡")]
|
||||
[SerializeField] bool breathing = true;
|
||||
|
||||
[Tooltip("가슴이 오르내리는 각도")]
|
||||
[Range(0f, 5f)]
|
||||
[SerializeField] float breathAmplitude = 1.2f;
|
||||
|
||||
[Tooltip("한 번 호흡하는 데 걸리는 시간(초)")]
|
||||
[SerializeField] float breathPeriod = 4f;
|
||||
|
||||
[Header("체중 이동")]
|
||||
[SerializeField] bool weightShift = true;
|
||||
|
||||
[Tooltip("좌우로 기우는 각도")]
|
||||
[Range(0f, 5f)]
|
||||
[SerializeField] float swayAmplitude = 1.2f;
|
||||
|
||||
[Tooltip("한 번 왕복하는 데 걸리는 시간(초). 호흡과 주기를 다르게 해야 기계적으로 안 보인다")]
|
||||
[SerializeField] float swayPeriod = 7f;
|
||||
|
||||
Transform root;
|
||||
Transform hips, chest, leftUpperArm, rightUpperArm, leftLowerArm, rightLowerArm;
|
||||
|
||||
float phaseOffset;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
root = transform;
|
||||
if (animator == null) animator = GetComponentInChildren<Animator>();
|
||||
|
||||
if (animator == null || !animator.isHuman)
|
||||
{
|
||||
Debug.LogWarning("[ProceduralIdle] 휴머노이드 Animator 가 없어 비활성화합니다.");
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
hips = animator.GetBoneTransform(HumanBodyBones.Hips);
|
||||
chest = animator.GetBoneTransform(HumanBodyBones.Chest)
|
||||
?? animator.GetBoneTransform(HumanBodyBones.Spine);
|
||||
|
||||
leftUpperArm = animator.GetBoneTransform(HumanBodyBones.LeftUpperArm);
|
||||
rightUpperArm = animator.GetBoneTransform(HumanBodyBones.RightUpperArm);
|
||||
leftLowerArm = animator.GetBoneTransform(HumanBodyBones.LeftLowerArm);
|
||||
rightLowerArm = animator.GetBoneTransform(HumanBodyBones.RightLowerArm);
|
||||
|
||||
// 여러 캐릭터가 있어도 동시에 같은 박자로 숨쉬지 않게 한다.
|
||||
phaseOffset = Random.Range(0f, 100f);
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
ApplyArms();
|
||||
ApplyBody();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// T포즈에서 팔은 좌우로 뻗어 있다. 캐릭터의 forward 축을 중심으로 돌려 내린다.
|
||||
/// 왼팔은 -right 방향이라 +각도, 오른팔은 +right 방향이라 -각도가 아래로 향한다.
|
||||
/// </summary>
|
||||
void ApplyArms()
|
||||
{
|
||||
RotateArm(leftUpperArm, +upperArmDown, +armOutward);
|
||||
RotateArm(rightUpperArm, -upperArmDown, -armOutward);
|
||||
|
||||
// 위팔을 돌리면 아래팔도 따라오므로, 여기서는 추가분만 얹는다.
|
||||
RotateArm(leftLowerArm, +lowerArmDown, 0f);
|
||||
RotateArm(rightLowerArm, -lowerArmDown, 0f);
|
||||
}
|
||||
|
||||
void RotateArm(Transform bone, float downAngle, float outwardAngle)
|
||||
{
|
||||
if (bone == null) return;
|
||||
|
||||
Quaternion delta = Quaternion.AngleAxis(downAngle, root.forward);
|
||||
if (!Mathf.Approximately(outwardAngle, 0f))
|
||||
{
|
||||
delta = Quaternion.AngleAxis(outwardAngle, root.up) * delta;
|
||||
}
|
||||
bone.rotation = delta * bone.rotation;
|
||||
}
|
||||
|
||||
void ApplyBody()
|
||||
{
|
||||
float t = Time.time + phaseOffset;
|
||||
|
||||
if (breathing && chest != null && breathPeriod > 0.01f)
|
||||
{
|
||||
float breath = Mathf.Sin(t * Mathf.PI * 2f / breathPeriod);
|
||||
chest.rotation = Quaternion.AngleAxis(-breath * breathAmplitude, root.right) * chest.rotation;
|
||||
}
|
||||
|
||||
if (weightShift && hips != null && swayPeriod > 0.01f)
|
||||
{
|
||||
float sway = Mathf.Sin(t * Mathf.PI * 2f / swayPeriod);
|
||||
hips.rotation = Quaternion.AngleAxis(sway * swayAmplitude, root.forward) * hips.rotation;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Character/ProceduralIdle.cs.meta
Normal file
2
Assets/02_Scripts/Character/ProceduralIdle.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e5a8a0a8eac6b4944b60be542869f768
|
||||
119
Assets/02_Scripts/Character/VrmAvatar.cs
Normal file
119
Assets/02_Scripts/Character/VrmAvatar.cs
Normal file
@@ -0,0 +1,119 @@
|
||||
using UnityEngine;
|
||||
using UniVRM10;
|
||||
|
||||
/// <summary>
|
||||
/// VRM 1.0 모델용 ICharacterAvatar 구현.
|
||||
///
|
||||
/// FBX 와 달리 매핑 작업이 필요 없다. VRM 규격이 표정과 입모양의 이름을
|
||||
/// 강제하므로 어떤 VRM 모델이든 같은 코드로 동작한다. 런타임 로딩으로
|
||||
/// 사용자가 임의의 모델을 넣는 구조에서는 이 점이 필수적이다.
|
||||
/// </summary>
|
||||
public class VrmAvatar : MonoBehaviour, ICharacterAvatar
|
||||
{
|
||||
[Tooltip("VRM 자체 자동 깜빡임/시선을 쓰지 않고 우리가 제어할지. " +
|
||||
"우리 HeadLookAt 과 충돌하지 않게 기본은 켬")]
|
||||
[SerializeField] bool overrideVrmAutoExpressions = true;
|
||||
|
||||
Vrm10Instance instance;
|
||||
|
||||
float[] emotionWeights;
|
||||
float[] visemeWeights;
|
||||
float blinkWeight;
|
||||
|
||||
public Transform Root => transform;
|
||||
public Animator Animator { get; private set; }
|
||||
|
||||
/// <summary>로더가 인스턴스를 만든 직후 호출한다.</summary>
|
||||
public void Bind(Vrm10Instance vrm)
|
||||
{
|
||||
instance = vrm;
|
||||
Animator = vrm != null ? vrm.GetComponent<Animator>() : null;
|
||||
|
||||
emotionWeights = new float[System.Enum.GetValues(typeof(AvatarEmotion)).Length];
|
||||
visemeWeights = new float[System.Enum.GetValues(typeof(AvatarViseme)).Length];
|
||||
|
||||
if (instance != null && overrideVrmAutoExpressions)
|
||||
{
|
||||
// VRM 이 스스로 깜빡이고 시선을 돌리면 우리 제어와 싸운다.
|
||||
instance.LookAtTargetType = VRM10ObjectLookAt.LookAtTargetTypes.SpecifiedTransform;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- ICharacterAvatar ----------------
|
||||
|
||||
public void SetEmotion(AvatarEmotion emotion, float weight)
|
||||
{
|
||||
if (emotionWeights == null) return;
|
||||
emotionWeights[(int)emotion] = Mathf.Clamp01(weight);
|
||||
}
|
||||
|
||||
public void ClearEmotions()
|
||||
{
|
||||
if (emotionWeights == null) return;
|
||||
System.Array.Clear(emotionWeights, 0, emotionWeights.Length);
|
||||
}
|
||||
|
||||
public void SetViseme(AvatarViseme viseme, float weight)
|
||||
{
|
||||
if (visemeWeights == null) return;
|
||||
visemeWeights[(int)viseme] = Mathf.Clamp01(weight);
|
||||
}
|
||||
|
||||
public void ClearVisemes()
|
||||
{
|
||||
if (visemeWeights == null) return;
|
||||
System.Array.Clear(visemeWeights, 0, visemeWeights.Length);
|
||||
}
|
||||
|
||||
public void SetBlink(float weight) => blinkWeight = Mathf.Clamp01(weight);
|
||||
|
||||
// ---------------- 반영 ----------------
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
if (instance == null || instance.Runtime == null) return;
|
||||
|
||||
var expr = instance.Runtime.Expression;
|
||||
if (expr == null) return;
|
||||
|
||||
// 감정. Neutral(0) 은 "표정 없음"이라 대응 키가 없다.
|
||||
for (int i = 1; i < emotionWeights.Length; i++)
|
||||
{
|
||||
expr.SetWeight(ToKey((AvatarEmotion)i), emotionWeights[i]);
|
||||
}
|
||||
|
||||
// 입 모양. Silence(0) 도 마찬가지로 대응 키가 없다.
|
||||
for (int i = 1; i < visemeWeights.Length; i++)
|
||||
{
|
||||
expr.SetWeight(ToKey((AvatarViseme)i), visemeWeights[i]);
|
||||
}
|
||||
|
||||
expr.SetWeight(ExpressionKey.Blink, blinkWeight);
|
||||
}
|
||||
|
||||
static ExpressionKey ToKey(AvatarEmotion e)
|
||||
{
|
||||
switch (e)
|
||||
{
|
||||
case AvatarEmotion.Happy: return ExpressionKey.Happy;
|
||||
case AvatarEmotion.Angry: return ExpressionKey.Angry;
|
||||
case AvatarEmotion.Sad: return ExpressionKey.Sad;
|
||||
case AvatarEmotion.Relaxed: return ExpressionKey.Relaxed;
|
||||
case AvatarEmotion.Surprised: return ExpressionKey.Surprised;
|
||||
default: return ExpressionKey.Neutral;
|
||||
}
|
||||
}
|
||||
|
||||
static ExpressionKey ToKey(AvatarViseme v)
|
||||
{
|
||||
switch (v)
|
||||
{
|
||||
case AvatarViseme.A: return ExpressionKey.Aa;
|
||||
case AvatarViseme.I: return ExpressionKey.Ih;
|
||||
case AvatarViseme.U: return ExpressionKey.Ou;
|
||||
case AvatarViseme.E: return ExpressionKey.Ee;
|
||||
case AvatarViseme.O: return ExpressionKey.Oh;
|
||||
default: return ExpressionKey.Neutral;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Character/VrmAvatar.cs.meta
Normal file
2
Assets/02_Scripts/Character/VrmAvatar.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0f9c072d633024348ab13315da4ba2a4
|
||||
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;
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Character/VrmCharacterLoader.cs.meta
Normal file
2
Assets/02_Scripts/Character/VrmCharacterLoader.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 49c1dd5cf50ef024cac9a0acfc058af0
|
||||
@@ -139,22 +139,10 @@ bool HitTest(out RaycastHit hit)
|
||||
{
|
||||
hit = default;
|
||||
if (hitTestCamera == null) return false;
|
||||
if (!Win32.GetCursorPos(out var pt)) return false;
|
||||
if (!Win32.GetWindowRect(window.Hwnd, out var rc)) return false;
|
||||
// 좌표 변환은 DesktopCursor 에 모아뒀다. 시선 추적도 같은 변환을 쓴다.
|
||||
if (!DesktopCursor.TryGetScreenPosition(window.Hwnd, out Vector2 sp)) return false;
|
||||
|
||||
int w = rc.Width, h = rc.Height;
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
|
||||
// 창 기준 좌표 (좌상단 원점, Y 아래로)
|
||||
int localX = pt.x - rc.left;
|
||||
int localY = pt.y - rc.top;
|
||||
if (localX < 0 || localY < 0 || localX >= w || localY >= h) return false;
|
||||
|
||||
// Unity 화면 좌표 (좌하단 원점, Y 위로)
|
||||
float ux = localX * (Screen.width / (float)w);
|
||||
float uy = (h - localY) * (Screen.height / (float)h);
|
||||
|
||||
var ray = hitTestCamera.ScreenPointToRay(new Vector3(ux, uy, 0f));
|
||||
var ray = hitTestCamera.ScreenPointToRay(new Vector3(sp.x, sp.y, 0f));
|
||||
return Physics.Raycast(ray, out hit, maxRayDistance, interactableLayers);
|
||||
}
|
||||
|
||||
|
||||
52
Assets/02_Scripts/Desktop/DesktopCursor.cs
Normal file
52
Assets/02_Scripts/Desktop/DesktopCursor.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem;
|
||||
|
||||
/// <summary>
|
||||
/// OS 커서 위치를 Unity 화면 좌표로 변환한다.
|
||||
///
|
||||
/// 우리 창은 WS_EX_NOACTIVATE 라 포커스를 받지 않으므로 Unity 의 Mouse.position 을
|
||||
/// 신뢰할 수 없다. 또 창 밖 좌표는 Unity 가 아예 모른다. 그래서 GetCursorPos 로
|
||||
/// OS 커서를 직접 읽고 창 사각형 기준으로 환산한다.
|
||||
///
|
||||
/// 히트테스트와 시선 추적이 같은 변환을 쓰므로 여기 한 곳에만 둔다.
|
||||
/// </summary>
|
||||
public static class DesktopCursor
|
||||
{
|
||||
/// <summary>
|
||||
/// 커서의 Unity 화면 좌표(좌하단 원점)를 구한다. 커서가 창 밖이면 false.
|
||||
/// </summary>
|
||||
public static bool TryGetScreenPosition(System.IntPtr hwnd, out Vector2 screenPos)
|
||||
{
|
||||
screenPos = default;
|
||||
|
||||
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
|
||||
if (hwnd == System.IntPtr.Zero) return false;
|
||||
if (!Win32.GetCursorPos(out var pt)) return false;
|
||||
if (!Win32.GetWindowRect(hwnd, out var rc)) return false;
|
||||
|
||||
int w = rc.Width, h = rc.Height;
|
||||
if (w <= 0 || h <= 0) return false;
|
||||
|
||||
// 창 기준 좌표 (좌상단 원점, Y 아래로)
|
||||
int localX = pt.x - rc.left;
|
||||
int localY = pt.y - rc.top;
|
||||
if (localX < 0 || localY < 0 || localX >= w || localY >= h) return false;
|
||||
|
||||
// Unity 화면 좌표로. 창 크기와 백버퍼 크기가 어긋날 경우를 대비해 비율 환산.
|
||||
screenPos = new Vector2(
|
||||
localX * (Screen.width / (float)w),
|
||||
(h - localY) * (Screen.height / (float)h));
|
||||
return true;
|
||||
#else
|
||||
// 에디터에서는 일반 마우스 입력으로 대체해 미리보기가 가능하게 한다.
|
||||
var mouse = Mouse.current;
|
||||
if (mouse == null) return false;
|
||||
|
||||
Vector2 p = mouse.position.ReadValue();
|
||||
if (p.x < 0f || p.y < 0f || p.x >= Screen.width || p.y >= Screen.height) return false;
|
||||
|
||||
screenPos = p;
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Desktop/DesktopCursor.cs.meta
Normal file
2
Assets/02_Scripts/Desktop/DesktopCursor.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b4fb0f7484cbf394fbdcc4d532b4ed07
|
||||
@@ -21,10 +21,7 @@ public class TransparentWindow : MonoBehaviour
|
||||
[Tooltip("작업표시줄 / Alt+Tab 에서 숨김 (WS_EX_TOOLWINDOW)")]
|
||||
[SerializeField] bool hideFromTaskbar = true;
|
||||
|
||||
[Header("검증용")]
|
||||
[Tooltip("런타임 큐브 생성. 씬에 AlphaTestCube 가 있으면 그쪽이 우선한다")]
|
||||
[SerializeField] bool spawnTestCube = false;
|
||||
|
||||
[Header("렌더링")]
|
||||
[Tooltip("URP 포스트프로세싱 비활성화")]
|
||||
[SerializeField] bool forceDisablePostProcessing = true;
|
||||
|
||||
@@ -32,7 +29,6 @@ public class TransparentWindow : MonoBehaviour
|
||||
[SerializeField] bool quitOnEscape = true;
|
||||
|
||||
Camera cam;
|
||||
Transform testCube;
|
||||
|
||||
/// <summary>플레이어 창 핸들. 준비되기 전에는 Zero.</summary>
|
||||
public System.IntPtr Hwnd { get; private set; } = System.IntPtr.Zero;
|
||||
@@ -55,11 +51,6 @@ void Awake()
|
||||
}
|
||||
|
||||
Application.runInBackground = true;
|
||||
|
||||
// 씬에 큐브가 있으면 그것을 쓰고, 없을 때만 런타임 생성
|
||||
var inScene = GameObject.Find("AlphaTestCube");
|
||||
if (inScene != null) testCube = inScene.transform;
|
||||
else if (spawnTestCube) CreateTestCube();
|
||||
}
|
||||
|
||||
void Start()
|
||||
@@ -73,8 +64,6 @@ void Start()
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (testCube != null) testCube.Rotate(new Vector3(30f, 45f, 15f) * Time.deltaTime);
|
||||
|
||||
// 포커스가 있을 때만 동작하는 보조 탈출구
|
||||
if (quitOnEscape)
|
||||
{
|
||||
@@ -83,14 +72,6 @@ void Update()
|
||||
}
|
||||
}
|
||||
|
||||
void CreateTestCube()
|
||||
{
|
||||
var cube = GameObject.CreatePrimitive(PrimitiveType.Cube);
|
||||
cube.name = "AlphaTestCube";
|
||||
cube.transform.position = transform.position + transform.forward * 5f;
|
||||
testCube = cube.transform;
|
||||
}
|
||||
|
||||
#if !UNITY_EDITOR && UNITY_STANDALONE_WIN
|
||||
IEnumerator ApplyWindowStyle()
|
||||
{
|
||||
|
||||
314
Assets/02_Scripts/Editor/BlendShapeAvatarEditor.cs
Normal file
314
Assets/02_Scripts/Editor/BlendShapeAvatarEditor.cs
Normal file
@@ -0,0 +1,314 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// BlendShapeAvatar 의 매핑을 흔한 관례에서 자동으로 찾아 채운다.
|
||||
///
|
||||
/// FBX 는 블렌드셰이프 이름을 표준화하지 않지만 사실상의 관례가 몇 가지 있다.
|
||||
/// - VRChat 비셈: vrc.v_aa, vrc.v_ih, vrc.v_ou, vrc.v_E, vrc.v_oh
|
||||
/// - VRoid: Fcl_MTH_A, Fcl_ALL_Joy, Fcl_EYE_Close
|
||||
/// - ARKit 52: eyeBlinkLeft, eyeBlinkRight
|
||||
/// - 일본어: あ い う え お, まばたき, 笑い
|
||||
/// 대부분 모델이 이 중 하나를 따르므로 클릭 한 번으로 상당수가 채워진다.
|
||||
/// 안 맞는 것만 손으로 지정하면 된다.
|
||||
/// </summary>
|
||||
[CustomEditor(typeof(BlendShapeAvatar))]
|
||||
public class BlendShapeAvatarEditor : Editor
|
||||
{
|
||||
struct ShapeRef
|
||||
{
|
||||
public SkinnedMeshRenderer renderer;
|
||||
public int index;
|
||||
public string normalized;
|
||||
}
|
||||
|
||||
// 정규화(소문자화 + 구분자 제거) 후 비교할 후보들. 앞쪽이 우선순위가 높다.
|
||||
static readonly string[][] VisemeCandidates =
|
||||
{
|
||||
null, // Silence: 매핑 없음
|
||||
new[] { "vrcvaa", "aa", "a", "あ", "fclmtha", "mtha", "visemeaa" }, // A
|
||||
new[] { "vrcvih", "ih", "i", "い", "fclmthi", "mthi", "visemeih" }, // I
|
||||
new[] { "vrcvou", "ou", "u", "う", "fclmthu", "mthu", "visemeou" }, // U
|
||||
new[] { "vrcve", "ee", "e", "え", "fclmthe", "mthe", "visemeee" }, // E
|
||||
new[] { "vrcvoh", "oh", "o", "お", "fclmtho", "mtho", "visemeoh" }, // O
|
||||
};
|
||||
|
||||
static readonly string[][] EmotionCandidates =
|
||||
{
|
||||
null, // Neutral: 매핑 없음
|
||||
new[] { "fclalljoy", "joy", "happy", "smile", "笑い" }, // Happy
|
||||
new[] { "fclallangry", "angry", "anger", "怒り" }, // Angry
|
||||
new[] { "fclallsorrow", "sorrow", "sad", "悲しみ", "悲しい" }, // Sad
|
||||
new[] { "fclallfun", "fun", "relaxed", "calm" }, // Relaxed
|
||||
new[] { "fclallsurprised", "surprised", "surprise", "驚き", "びっくり" }, // Surprised
|
||||
};
|
||||
|
||||
static readonly string[] BlinkSingle =
|
||||
{ "fcleyeclose", "blink", "まばたき", "eyeclose", "eyesclosed", "vrcblink" };
|
||||
|
||||
static readonly string[] BlinkArkitPair = { "eyeblinkleft", "eyeblinkright" };
|
||||
|
||||
string report;
|
||||
|
||||
public override void OnInspectorGUI()
|
||||
{
|
||||
var avatar = (BlendShapeAvatar)target;
|
||||
|
||||
EditorGUILayout.HelpBox(
|
||||
"FBX 모델의 블렌드셰이프 이름은 표준이 아니라 관례입니다.\n" +
|
||||
"자동 감지로 대부분 채운 뒤, 비어 있는 항목만 직접 지정하세요.",
|
||||
MessageType.Info);
|
||||
|
||||
EditorGUILayout.LabelField("현재 매핑", avatar.DescribeMapping());
|
||||
|
||||
EditorGUILayout.Space();
|
||||
using (new EditorGUILayout.HorizontalScope())
|
||||
{
|
||||
if (GUILayout.Button("블렌드셰이프 자동 감지", GUILayout.Height(28)))
|
||||
{
|
||||
report = AutoDetect(avatar);
|
||||
}
|
||||
if (GUILayout.Button("매핑 초기화", GUILayout.Width(100), GUILayout.Height(28)))
|
||||
{
|
||||
if (EditorUtility.DisplayDialog("매핑 초기화", "모든 매핑을 지웁니다. 계속할까요?", "지우기", "취소"))
|
||||
{
|
||||
ClearAll();
|
||||
report = "매핑을 초기화했습니다.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (GUILayout.Button("이 모델의 블렌드셰이프 이름 전부 출력"))
|
||||
{
|
||||
DumpNames(avatar);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(report))
|
||||
{
|
||||
EditorGUILayout.Space();
|
||||
EditorGUILayout.HelpBox(report, MessageType.None);
|
||||
}
|
||||
|
||||
EditorGUILayout.Space();
|
||||
DrawDefaultInspector();
|
||||
}
|
||||
|
||||
static List<ShapeRef> CollectShapes(BlendShapeAvatar avatar)
|
||||
{
|
||||
var list = new List<ShapeRef>();
|
||||
var renderers = avatar.GetComponentsInChildren<SkinnedMeshRenderer>(true);
|
||||
|
||||
foreach (var r in renderers)
|
||||
{
|
||||
if (r.sharedMesh == null) continue;
|
||||
for (int i = 0; i < r.sharedMesh.blendShapeCount; i++)
|
||||
{
|
||||
list.Add(new ShapeRef
|
||||
{
|
||||
renderer = r,
|
||||
index = i,
|
||||
normalized = Normalize(r.sharedMesh.GetBlendShapeName(i)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>비교를 위해 소문자화하고 구분자를 제거한다. "vrc.v_aa" -> "vrcvaa"</summary>
|
||||
static string Normalize(string raw)
|
||||
{
|
||||
if (string.IsNullOrEmpty(raw)) return string.Empty;
|
||||
|
||||
// 메시 이름이 접두어로 붙는 경우가 있어 마지막 구간만 쓴다. "Body.vrc.v_aa" 같은 형태 대비
|
||||
var sb = new System.Text.StringBuilder(raw.Length);
|
||||
foreach (char c in raw)
|
||||
{
|
||||
if (c == '.' || c == '_' || c == '-' || c == ' ') continue;
|
||||
sb.Append(char.ToLowerInvariant(c));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
static bool TryMatch(List<ShapeRef> shapes, string[] candidates, out ShapeRef hit)
|
||||
{
|
||||
hit = default;
|
||||
if (candidates == null) return false;
|
||||
|
||||
// 1) 정확히 일치하는 것 우선
|
||||
foreach (var c in candidates)
|
||||
{
|
||||
foreach (var s in shapes)
|
||||
{
|
||||
if (s.normalized == c) { hit = s; return true; }
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 부분 일치. 짧은 후보("a" 등)는 "angry" 같은 데 걸려 오탐이 심해 제외한다.
|
||||
foreach (var c in candidates)
|
||||
{
|
||||
if (c.Length < 3) continue;
|
||||
foreach (var s in shapes)
|
||||
{
|
||||
if (s.normalized.Contains(c)) { hit = s; return true; }
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
string AutoDetect(BlendShapeAvatar avatar)
|
||||
{
|
||||
var shapes = CollectShapes(avatar);
|
||||
if (shapes.Count == 0)
|
||||
{
|
||||
return "블렌드셰이프를 가진 SkinnedMeshRenderer 를 찾지 못했습니다.\n" +
|
||||
"이 컴포넌트가 캐릭터 루트에 붙어 있는지 확인하세요.";
|
||||
}
|
||||
|
||||
Undo.RecordObject(avatar, "Auto Detect BlendShapes");
|
||||
var so = serializedObject;
|
||||
so.Update();
|
||||
|
||||
var found = new List<string>();
|
||||
var missing = new List<string>();
|
||||
|
||||
// 감정
|
||||
var emotionProp = so.FindProperty("emotionGroups");
|
||||
emotionProp.arraySize = EmotionCandidates.Length;
|
||||
for (int i = 1; i < EmotionCandidates.Length; i++)
|
||||
{
|
||||
string label = ((AvatarEmotion)i).ToString();
|
||||
if (TryMatch(shapes, EmotionCandidates[i], out var hit))
|
||||
{
|
||||
SetSingleBinding(emotionProp.GetArrayElementAtIndex(i), hit);
|
||||
found.Add($"{label} → {hit.renderer.sharedMesh.GetBlendShapeName(hit.index)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ClearBindings(emotionProp.GetArrayElementAtIndex(i));
|
||||
missing.Add(label);
|
||||
}
|
||||
}
|
||||
|
||||
// 입 모양
|
||||
var visemeProp = so.FindProperty("visemeGroups");
|
||||
visemeProp.arraySize = VisemeCandidates.Length;
|
||||
for (int i = 1; i < VisemeCandidates.Length; i++)
|
||||
{
|
||||
string label = "입:" + ((AvatarViseme)i);
|
||||
if (TryMatch(shapes, VisemeCandidates[i], out var hit))
|
||||
{
|
||||
SetSingleBinding(visemeProp.GetArrayElementAtIndex(i), hit);
|
||||
found.Add($"{label} → {hit.renderer.sharedMesh.GetBlendShapeName(hit.index)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
ClearBindings(visemeProp.GetArrayElementAtIndex(i));
|
||||
missing.Add(label);
|
||||
}
|
||||
}
|
||||
|
||||
// 깜빡임: 한 개짜리를 먼저 찾고, 없으면 ARKit 좌우 쌍을 시도한다.
|
||||
var blinkProp = so.FindProperty("blinkGroup");
|
||||
if (TryMatch(shapes, BlinkSingle, out var blinkHit))
|
||||
{
|
||||
SetSingleBinding(blinkProp, blinkHit);
|
||||
found.Add($"깜빡임 → {blinkHit.renderer.sharedMesh.GetBlendShapeName(blinkHit.index)}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var pair = new List<ShapeRef>();
|
||||
foreach (var c in BlinkArkitPair)
|
||||
{
|
||||
if (TryMatch(shapes, new[] { c }, out var h)) pair.Add(h);
|
||||
}
|
||||
|
||||
if (pair.Count > 0)
|
||||
{
|
||||
SetBindings(blinkProp, pair);
|
||||
found.Add($"깜빡임 → ARKit 좌우 {pair.Count}개");
|
||||
}
|
||||
else
|
||||
{
|
||||
ClearBindings(blinkProp);
|
||||
missing.Add("깜빡임");
|
||||
}
|
||||
}
|
||||
|
||||
so.ApplyModifiedProperties();
|
||||
avatar.RebuildCache();
|
||||
EditorUtility.SetDirty(avatar);
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine($"블렌드셰이프 {shapes.Count}개를 검사했습니다.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"찾음 ({found.Count})");
|
||||
foreach (var f in found) sb.AppendLine(" " + f);
|
||||
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"못 찾음 ({missing.Count}) — 아래에서 직접 지정하세요");
|
||||
sb.AppendLine(" " + string.Join(", ", missing));
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
static void SetSingleBinding(SerializedProperty group, ShapeRef shape)
|
||||
{
|
||||
SetBindings(group, new List<ShapeRef> { shape });
|
||||
}
|
||||
|
||||
static void SetBindings(SerializedProperty group, List<ShapeRef> shapes)
|
||||
{
|
||||
var bindings = group.FindPropertyRelative("bindings");
|
||||
bindings.arraySize = shapes.Count;
|
||||
|
||||
for (int i = 0; i < shapes.Count; i++)
|
||||
{
|
||||
var b = bindings.GetArrayElementAtIndex(i);
|
||||
b.FindPropertyRelative("renderer").objectReferenceValue = shapes[i].renderer;
|
||||
b.FindPropertyRelative("index").intValue = shapes[i].index;
|
||||
b.FindPropertyRelative("maxWeight").floatValue = 100f;
|
||||
}
|
||||
}
|
||||
|
||||
static void ClearBindings(SerializedProperty group)
|
||||
{
|
||||
group.FindPropertyRelative("bindings").arraySize = 0;
|
||||
}
|
||||
|
||||
void ClearAll()
|
||||
{
|
||||
var so = serializedObject;
|
||||
so.Update();
|
||||
|
||||
var emo = so.FindProperty("emotionGroups");
|
||||
for (int i = 0; i < emo.arraySize; i++) ClearBindings(emo.GetArrayElementAtIndex(i));
|
||||
|
||||
var vis = so.FindProperty("visemeGroups");
|
||||
for (int i = 0; i < vis.arraySize; i++) ClearBindings(vis.GetArrayElementAtIndex(i));
|
||||
|
||||
ClearBindings(so.FindProperty("blinkGroup"));
|
||||
|
||||
so.ApplyModifiedProperties();
|
||||
((BlendShapeAvatar)target).RebuildCache();
|
||||
}
|
||||
|
||||
static void DumpNames(BlendShapeAvatar avatar)
|
||||
{
|
||||
var renderers = avatar.GetComponentsInChildren<SkinnedMeshRenderer>(true);
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.AppendLine($"[BlendShapeAvatar] {avatar.name} 의 블렌드셰이프 목록");
|
||||
|
||||
foreach (var r in renderers)
|
||||
{
|
||||
if (r.sharedMesh == null || r.sharedMesh.blendShapeCount == 0) continue;
|
||||
sb.AppendLine($"--- {r.name} ({r.sharedMesh.blendShapeCount}개) ---");
|
||||
for (int i = 0; i < r.sharedMesh.blendShapeCount; i++)
|
||||
sb.AppendLine($" [{i}] {r.sharedMesh.GetBlendShapeName(i)}");
|
||||
}
|
||||
Debug.Log(sb.ToString());
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Editor/BlendShapeAvatarEditor.cs.meta
Normal file
2
Assets/02_Scripts/Editor/BlendShapeAvatarEditor.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6caf6011db2e0c2418d8b40bb8b2f8c1
|
||||
152
Assets/02_Scripts/Editor/CharacterSetupMenu.cs
Normal file
152
Assets/02_Scripts/Editor/CharacterSetupMenu.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// 씬에 배치한 FBX 캐릭터에 필요한 컴포넌트를 한 번에 붙이고, 흔한 함정을 진단한다.
|
||||
/// 부스에서 받은 VRChat 아바타는 특히 리그 타입과 셰이더에서 걸리는 경우가 많다.
|
||||
/// </summary>
|
||||
public static class CharacterSetupMenu
|
||||
{
|
||||
[MenuItem("Tools/Desktop Overlay/5. Setup Selected FBX As Character")]
|
||||
public static void SetupCharacter()
|
||||
{
|
||||
var go = Selection.activeGameObject;
|
||||
if (go == null || EditorUtility.IsPersistent(go))
|
||||
{
|
||||
EditorUtility.DisplayDialog("캐릭터 셋업",
|
||||
"씬에 배치된 캐릭터 오브젝트를 선택한 뒤 실행하세요.\n" +
|
||||
"(프로젝트 창의 FBX 에셋이 아니라, 씬으로 끌어다 놓은 인스턴스입니다)",
|
||||
"확인");
|
||||
return;
|
||||
}
|
||||
|
||||
var log = new StringBuilder();
|
||||
log.AppendLine($"[캐릭터 셋업] {go.name}");
|
||||
log.AppendLine();
|
||||
|
||||
Undo.RegisterFullObjectHierarchyUndo(go, "Setup Character");
|
||||
|
||||
// 1) 휴머노이드 리그 확인 — 시선 추적과 애니메이션의 전제
|
||||
var animator = go.GetComponentInChildren<Animator>();
|
||||
if (animator == null)
|
||||
{
|
||||
log.AppendLine("✗ Animator 가 없습니다.");
|
||||
log.AppendLine(" FBX 임포트 설정 > Rig > Animation Type = Humanoid 로 바꾸고 Apply 하세요.");
|
||||
}
|
||||
else if (!animator.isHuman)
|
||||
{
|
||||
log.AppendLine("✗ Animator 는 있지만 휴머노이드가 아닙니다.");
|
||||
log.AppendLine(" FBX 임포트 설정 > Rig > Animation Type = Humanoid 로 바꾸고 Apply 하세요.");
|
||||
log.AppendLine(" (시선 추적이 머리 본을 찾지 못합니다)");
|
||||
}
|
||||
else
|
||||
{
|
||||
log.AppendLine("✓ 휴머노이드 리그 확인");
|
||||
}
|
||||
|
||||
// 2) 블렌드셰이프 확인 — 표정/립싱크의 전제
|
||||
int shapeCount = 0;
|
||||
var renderers = go.GetComponentsInChildren<SkinnedMeshRenderer>(true);
|
||||
foreach (var r in renderers)
|
||||
{
|
||||
if (r.sharedMesh != null) shapeCount += r.sharedMesh.blendShapeCount;
|
||||
}
|
||||
|
||||
if (shapeCount == 0)
|
||||
{
|
||||
log.AppendLine("✗ 블렌드셰이프가 하나도 없습니다. 표정과 립싱크가 불가능합니다.");
|
||||
log.AppendLine(" FBX 임포트 설정 > Model > Import BlendShapes 가 켜져 있는지 확인하세요.");
|
||||
}
|
||||
else
|
||||
{
|
||||
log.AppendLine($"✓ 블렌드셰이프 {shapeCount}개 ({renderers.Length}개 메시)");
|
||||
}
|
||||
|
||||
// 3) 셰이더 진단 — VRChat 아바타는 Built-in RP 용 셰이더를 쓰므로 URP 에서 분홍색이 된다
|
||||
var shaders = new HashSet<string>();
|
||||
foreach (var r in go.GetComponentsInChildren<Renderer>(true))
|
||||
{
|
||||
foreach (var m in r.sharedMaterials)
|
||||
{
|
||||
if (m != null && m.shader != null) shaders.Add(m.shader.name);
|
||||
}
|
||||
}
|
||||
|
||||
var nonUrp = new List<string>();
|
||||
foreach (var s in shaders)
|
||||
{
|
||||
if (!s.StartsWith("Universal Render Pipeline/") &&
|
||||
!s.StartsWith("Shader Graphs/") &&
|
||||
!s.StartsWith("VRM10/Universal Render Pipeline/"))
|
||||
{
|
||||
nonUrp.Add(s);
|
||||
}
|
||||
}
|
||||
|
||||
if (nonUrp.Count > 0)
|
||||
{
|
||||
log.AppendLine($"⚠ URP 가 아닌 셰이더 {nonUrp.Count}종 — 분홍색으로 보일 수 있습니다:");
|
||||
foreach (var s in nonUrp) log.AppendLine(" " + s);
|
||||
log.AppendLine(" 해결: Window > Rendering > Render Pipeline Converter 로 일괄 변환하거나,");
|
||||
log.AppendLine(" 머티리얼 셰이더를 Universal Render Pipeline/Lit 으로 직접 바꾸세요.");
|
||||
}
|
||||
else if (shaders.Count > 0)
|
||||
{
|
||||
log.AppendLine("✓ 모든 셰이더가 URP 호환");
|
||||
}
|
||||
|
||||
// 4) 컴포넌트 부착
|
||||
var avatar = go.GetComponent<BlendShapeAvatar>();
|
||||
if (avatar == null)
|
||||
{
|
||||
avatar = Undo.AddComponent<BlendShapeAvatar>(go);
|
||||
log.AppendLine("+ BlendShapeAvatar 추가 (인스펙터에서 '자동 감지'를 눌러 매핑하세요)");
|
||||
}
|
||||
|
||||
if (go.GetComponent<HeadLookAt>() == null)
|
||||
{
|
||||
Undo.AddComponent<HeadLookAt>(go);
|
||||
log.AppendLine("+ HeadLookAt 추가");
|
||||
}
|
||||
|
||||
// 5) 히트테스트용 콜라이더 — 이게 없으면 클릭 통과가 캐릭터를 인식하지 못한다
|
||||
var col = go.GetComponent<CapsuleCollider>();
|
||||
if (col == null)
|
||||
{
|
||||
col = Undo.AddComponent<CapsuleCollider>(go);
|
||||
log.AppendLine("+ CapsuleCollider 추가 (클릭 판정용)");
|
||||
}
|
||||
FitCapsule(go, col);
|
||||
|
||||
EditorUtility.SetDirty(go);
|
||||
Debug.Log(log.ToString());
|
||||
|
||||
Selection.activeObject = avatar;
|
||||
}
|
||||
|
||||
/// <summary>렌더러 전체를 감싸도록 캡슐 콜라이더 크기를 맞춘다.</summary>
|
||||
static void FitCapsule(GameObject go, CapsuleCollider col)
|
||||
{
|
||||
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);
|
||||
|
||||
Vector3 localCenter = go.transform.InverseTransformPoint(world.center);
|
||||
Vector3 scale = go.transform.lossyScale;
|
||||
|
||||
// lossyScale 로 나눠 로컬 크기로 환산 (0 나누기 방지)
|
||||
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 = localCenter;
|
||||
col.height = world.size.y / sy;
|
||||
col.radius = Mathf.Max(world.size.x / sx, world.size.z / sz) * 0.5f;
|
||||
col.isTrigger = false;
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Editor/CharacterSetupMenu.cs.meta
Normal file
2
Assets/02_Scripts/Editor/CharacterSetupMenu.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1be40d32a0484804391ad2b06df735a6
|
||||
25
Assets/02_Scripts/Editor/MyCharacterAgent.Editor.asmdef
Normal file
25
Assets/02_Scripts/Editor/MyCharacterAgent.Editor.asmdef
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "MyCharacterAgent.Editor",
|
||||
"rootNamespace": "",
|
||||
"references": [
|
||||
"MyCharacterAgent",
|
||||
"UniGLTF",
|
||||
"UniGLTF.Utils",
|
||||
"UniHumanoid",
|
||||
"VRM10",
|
||||
"Unity.InputSystem",
|
||||
"Unity.RenderPipelines.Universal.Runtime",
|
||||
"Unity.RenderPipelines.Core.Runtime"
|
||||
],
|
||||
"includePlatforms": [
|
||||
"Editor"
|
||||
],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 68f8234bbfc2e454ab268c0c72b4590f
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
122
Assets/02_Scripts/Editor/VrmShaderBuildSetup.cs
Normal file
122
Assets/02_Scripts/Editor/VrmShaderBuildSetup.cs
Normal file
@@ -0,0 +1,122 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// VRM 런타임 로딩에 필요한 셰이더를 빌드에 강제로 포함시킨다.
|
||||
///
|
||||
/// 왜 필요한가: Unity 는 어떤 머티리얼도 참조하지 않는 셰이더를 빌드에서 제거한다.
|
||||
/// 에디터에서는 프로젝트의 모든 셰이더를 찾을 수 있어 문제가 드러나지 않지만,
|
||||
/// 빌드된 플레이어에서는 Shader.Find 가 null 을 돌려주고
|
||||
/// "ArgumentNullException: Parameter name: Shader" 로 로드가 실패한다.
|
||||
///
|
||||
/// 일반적인 게임이라면 씬에 쓰인 머티리얼을 보고 Unity 가 알아서 판단하지만,
|
||||
/// 우리는 실행 중에 임의의 VRM 을 불러오므로 어떤 셰이더가 필요할지 빌드 시점에
|
||||
/// 알 수 없다. 그래서 Always Included Shaders 에 명시적으로 등록한다.
|
||||
/// </summary>
|
||||
public static class VrmShaderBuildSetup
|
||||
{
|
||||
// UrpVrm10MaterialDescriptorGenerator / BuiltInVrm10MaterialDescriptorGenerator 가
|
||||
// 참조하는 셰이더들. 현재 URP 를 쓰지만 파이프라인을 바꿔도 되도록 둘 다 넣는다.
|
||||
static readonly string[] RequiredShaders =
|
||||
{
|
||||
"VRM10/Universal Render Pipeline/MToon10", // URP 용 MToon (지금 쓰는 것)
|
||||
"VRM10/MToon10", // Built-in 용 MToon
|
||||
"Universal Render Pipeline/Lit", // MToon 이 아닌 PBR 머티리얼
|
||||
"Universal Render Pipeline/Unlit",
|
||||
"UniGLTF/UniUnlit",
|
||||
};
|
||||
|
||||
[MenuItem("Tools/Desktop Overlay/6. Include VRM Shaders In Build")]
|
||||
public static void IncludeShaders()
|
||||
{
|
||||
var graphicsSettings = AssetDatabase.LoadAllAssetsAtPath("ProjectSettings/GraphicsSettings.asset");
|
||||
if (graphicsSettings == null || graphicsSettings.Length == 0)
|
||||
{
|
||||
Debug.LogError("[VrmShaderBuildSetup] GraphicsSettings.asset 을 열지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
var so = new SerializedObject(graphicsSettings[0]);
|
||||
var list = so.FindProperty("m_AlwaysIncludedShaders");
|
||||
if (list == null)
|
||||
{
|
||||
Debug.LogError("[VrmShaderBuildSetup] m_AlwaysIncludedShaders 프로퍼티를 찾지 못했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 들어있는 것 수집
|
||||
var existing = new HashSet<Shader>();
|
||||
for (int i = 0; i < list.arraySize; i++)
|
||||
{
|
||||
var s = list.GetArrayElementAtIndex(i).objectReferenceValue as Shader;
|
||||
if (s != null) existing.Add(s);
|
||||
}
|
||||
|
||||
var added = new List<string>();
|
||||
var already = new List<string>();
|
||||
var notFound = new List<string>();
|
||||
|
||||
foreach (var name in RequiredShaders)
|
||||
{
|
||||
var shader = Shader.Find(name);
|
||||
if (shader == null)
|
||||
{
|
||||
notFound.Add(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.Contains(shader))
|
||||
{
|
||||
already.Add(name);
|
||||
continue;
|
||||
}
|
||||
|
||||
list.InsertArrayElementAtIndex(list.arraySize);
|
||||
list.GetArrayElementAtIndex(list.arraySize - 1).objectReferenceValue = shader;
|
||||
existing.Add(shader);
|
||||
added.Add(name);
|
||||
}
|
||||
|
||||
so.ApplyModifiedProperties();
|
||||
AssetDatabase.SaveAssets();
|
||||
|
||||
var sb = new StringBuilder("[VrmShaderBuildSetup] Always Included Shaders 갱신\n");
|
||||
if (added.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"추가됨 ({added.Count})");
|
||||
foreach (var s in added) sb.AppendLine(" + " + s);
|
||||
}
|
||||
if (already.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"이미 있음 ({already.Count})");
|
||||
foreach (var s in already) sb.AppendLine(" = " + s);
|
||||
}
|
||||
if (notFound.Count > 0)
|
||||
{
|
||||
sb.AppendLine($"찾지 못함 ({notFound.Count}) — 해당 파이프라인을 안 쓰면 정상입니다");
|
||||
foreach (var s in notFound) sb.AppendLine(" ? " + s);
|
||||
}
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("이제 다시 빌드하세요.");
|
||||
|
||||
Debug.Log(sb.ToString());
|
||||
}
|
||||
|
||||
[MenuItem("Tools/Desktop Overlay/7. Log Always Included Shaders")]
|
||||
public static void LogIncluded()
|
||||
{
|
||||
var graphicsSettings = AssetDatabase.LoadAllAssetsAtPath("ProjectSettings/GraphicsSettings.asset");
|
||||
var so = new SerializedObject(graphicsSettings[0]);
|
||||
var list = so.FindProperty("m_AlwaysIncludedShaders");
|
||||
|
||||
var sb = new StringBuilder($"[VrmShaderBuildSetup] Always Included Shaders ({list.arraySize}개)\n");
|
||||
for (int i = 0; i < list.arraySize; i++)
|
||||
{
|
||||
var s = list.GetArrayElementAtIndex(i).objectReferenceValue as Shader;
|
||||
sb.AppendLine($" [{i}] {(s != null ? s.name : "<null>")}");
|
||||
}
|
||||
Debug.Log(sb.ToString());
|
||||
}
|
||||
}
|
||||
2
Assets/02_Scripts/Editor/VrmShaderBuildSetup.cs.meta
Normal file
2
Assets/02_Scripts/Editor/VrmShaderBuildSetup.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b59197c6c6086994b942818da6e28005
|
||||
22
Assets/02_Scripts/MyCharacterAgent.asmdef
Normal file
22
Assets/02_Scripts/MyCharacterAgent.asmdef
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "MyCharacterAgent",
|
||||
"rootNamespace": "",
|
||||
"references": [
|
||||
"UniGLTF",
|
||||
"UniGLTF.Utils",
|
||||
"UniHumanoid",
|
||||
"VRM10",
|
||||
"Unity.InputSystem",
|
||||
"Unity.RenderPipelines.Universal.Runtime",
|
||||
"Unity.RenderPipelines.Core.Runtime"
|
||||
],
|
||||
"includePlatforms": [],
|
||||
"excludePlatforms": [],
|
||||
"allowUnsafeCode": false,
|
||||
"overrideReferences": false,
|
||||
"precompiledReferences": [],
|
||||
"autoReferenced": true,
|
||||
"defineConstraints": [],
|
||||
"versionDefines": [],
|
||||
"noEngineReferences": false
|
||||
}
|
||||
7
Assets/02_Scripts/MyCharacterAgent.asmdef.meta
Normal file
7
Assets/02_Scripts/MyCharacterAgent.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2c6b9b063edcfc447bb977238a16cb11
|
||||
AssemblyDefinitionImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -44,6 +44,8 @@
|
||||
"com.unity.modules.video": "1.0.0",
|
||||
"com.unity.modules.vr": "1.0.0",
|
||||
"com.unity.modules.wind": "1.0.0",
|
||||
"com.unity.modules.xr": "1.0.0"
|
||||
"com.unity.modules.xr": "1.0.0",
|
||||
"com.vrmc.gltf": "https://github.com/vrm-c/UniVRM.git?path=/Packages/UniGLTF#v0.131.2",
|
||||
"com.vrmc.vrm": "https://github.com/vrm-c/UniVRM.git?path=/Packages/VRM10#v0.131.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
},
|
||||
"com.unity.mathematics": {
|
||||
"version": "1.3.3",
|
||||
"depth": 2,
|
||||
"depth": 1,
|
||||
"source": "registry",
|
||||
"dependencies": {},
|
||||
"url": "https://packages.unity.com"
|
||||
@@ -193,6 +193,29 @@
|
||||
},
|
||||
"url": "https://packages.unity.com"
|
||||
},
|
||||
"com.vrmc.gltf": {
|
||||
"version": "https://github.com/vrm-c/UniVRM.git?path=/Packages/UniGLTF#v0.131.2",
|
||||
"depth": 0,
|
||||
"source": "git",
|
||||
"dependencies": {
|
||||
"com.unity.modules.animation": "1.0.0",
|
||||
"com.unity.modules.imgui": "1.0.0",
|
||||
"com.unity.modules.imageconversion": "1.0.0",
|
||||
"com.unity.test-framework": "1.4.6",
|
||||
"com.unity.mathematics": "1.2.6"
|
||||
},
|
||||
"hash": "a4711bbf8c4d10659d3e5568c2e3d7d595005e51"
|
||||
},
|
||||
"com.vrmc.vrm": {
|
||||
"version": "https://github.com/vrm-c/UniVRM.git?path=/Packages/VRM10#v0.131.2",
|
||||
"depth": 0,
|
||||
"source": "git",
|
||||
"dependencies": {
|
||||
"com.unity.timeline": "1.7.6",
|
||||
"com.vrmc.gltf": "0.131.2"
|
||||
},
|
||||
"hash": "a4711bbf8c4d10659d3e5568c2e3d7d595005e51"
|
||||
},
|
||||
"com.unity.modules.accessibility": {
|
||||
"version": "1.0.0",
|
||||
"depth": 0,
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user