259 lines
8.2 KiB
C#
259 lines
8.2 KiB
C#
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")}";
|
|
}
|
|
}
|