캐릭터 로드

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

Binary file not shown.

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 736e5279b21711442b1c23fbbdede16f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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);
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2deea8e1d55fbfa40b1004980c68d08f

View 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")}";
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4f705a8a153d76242beee16d85f491f1

View 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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4acef0f074253e24cbdf756b4f7f3359

View 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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e5a8a0a8eac6b4944b60be542869f768

View 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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 0f9c072d633024348ab13315da4ba2a4

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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 49c1dd5cf50ef024cac9a0acfc058af0

View File

@@ -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);
}

View 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
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b4fb0f7484cbf394fbdcc4d532b4ed07

View File

@@ -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()
{

View 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());
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 6caf6011db2e0c2418d8b40bb8b2f8c1

View 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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1be40d32a0484804391ad2b06df735a6

View 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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 68f8234bbfc2e454ab268c0c72b4590f
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View 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());
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b59197c6c6086994b942818da6e28005

View 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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 2c6b9b063edcfc447bb977238a16cb11
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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"
}
}

View File

@@ -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.