1080 lines
34 KiB
C#
1080 lines
34 KiB
C#
using UnityEngine;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System;
|
|
using System.Globalization;
|
|
|
|
public class StreamingleFacialReceiver : MonoBehaviour
|
|
{
|
|
public bool mirrorMode = true; // 좌우 반전 모드 설정
|
|
private bool StartFlag = true;
|
|
|
|
//object references
|
|
public SkinnedMeshRenderer[] faceMeshRenderers;
|
|
|
|
private UdpClient udp;
|
|
private Thread thread;
|
|
private volatile bool isThreadRunning = false; // 스레드 실행 상태 플래그
|
|
private string messageString = "";
|
|
private string lastProcessedMessage = ""; // 이전 메시지 저장용
|
|
|
|
// ── 데이터 소스 모드 ──
|
|
public enum FacialSourceMode
|
|
{
|
|
DirectUDP, // 기존: iFacialMocap 앱에서 직접 UDP 수신
|
|
NatNetDevice // 신규: Motive NatNet의 device 채널 데이터에서 수신
|
|
}
|
|
|
|
[Header("Source Mode")]
|
|
[Tooltip("DirectUDP=기존 iFacialMocap UDP 수신 / NatNetDevice=Motive 거쳐서 NatNet으로 받기")]
|
|
public FacialSourceMode sourceMode = FacialSourceMode.DirectUDP;
|
|
|
|
[Header("NatNet Mode (sourceMode=NatNetDevice일 때)")]
|
|
[Tooltip("OptitrackStreamingClient. 비워두면 Start에서 자동 검색. (디바이스 이름→ID, 채널 이름 메타데이터용)")]
|
|
public OptitrackStreamingClient natnetStreamingClient;
|
|
[Tooltip("NatnetDeviceListener. 비워두면 Start에서 자동 검색하고, 없으면 자동 생성. (실제 채널값을 NatNet wire 직접 파싱으로 가져옴 — wrapper 우회)")]
|
|
public NatnetDeviceListener natnetDeviceListener;
|
|
[Tooltip("Motive 디바이스 이름 prefix. 활성 포트가 붙어 최종 base name = prefix + activePort (예: \"iFacialMocap_\" + 40001 = \"iFacialMocap_40001\"). 32채널 초과로 분할되었으면 _A/_B 접미사 자동 감지.")]
|
|
public string natnetDeviceNamePrefix = "iFacialMocap_";
|
|
|
|
/// <summary>
|
|
/// 현재 활성 포트로부터 합성된 디바이스 base name. NatNet 모드에서 사용.
|
|
/// </summary>
|
|
public string EffectiveNatnetDeviceBaseName => natnetDeviceNamePrefix + LOCAL_PORT;
|
|
|
|
// NatNet 모드: device name → (device id, channel names) 캐시.
|
|
// OptitrackStreamingClient의 description으로부터 1회 lookup.
|
|
private struct ResolvedDevice
|
|
{
|
|
public int Id;
|
|
// NatNet 프레임 전체에서의 위치 (description 순서 = wire 순서 가정).
|
|
// listener는 wire 글로벌 인덱스로 키잉되므로 로컬 리스트 인덱스를 넘기면 안 됨
|
|
// — 다른 포트의 device 데이터를 잘못 가져오게 됨.
|
|
public int WireIndex;
|
|
public List<string> ChannelNames;
|
|
}
|
|
private List<ResolvedDevice> natnetResolvedDevices = new List<ResolvedDevice>(2);
|
|
private float natnetLastResolveAttempt = -10f;
|
|
private string natnetLastResolvedFor = ""; // 포트 변경 감지용
|
|
private ulong[] natnetLastSeenSeq = new ulong[2]; // resolved 디바이스별 frame seq 추적
|
|
private bool natnetDiagLogged = false; // [NatNet Align Diag] 1회만 출력
|
|
private const float k_NatnetResolveRetrySec = 1.0f;
|
|
private System.Text.StringBuilder natnetMsgBuilder = new System.Text.StringBuilder(2048);
|
|
|
|
// ── 공유 포트 (마스터 모드) ──
|
|
[Header("Shared Port (Master Mode)")]
|
|
[Tooltip("활성화 시 UdpMaster를 통해 같은 포트의 데이터를 여러 Receiver가 공유 수신")]
|
|
public bool useSharedPort = true;
|
|
|
|
// 포트 핫스왑 시스템
|
|
[Header("Port Hot-Swap")]
|
|
[Tooltip("사용 가능한 아이폰 포트 목록")]
|
|
public int[] availablePorts = new int[] { 40001, 40002, 40003, 40004, 40005 };
|
|
[Tooltip("현재 활성화된 포트 인덱스 (0~4)")]
|
|
[Range(0, 4)]
|
|
public int activePortIndex = 0;
|
|
public int LOCAL_PORT => availablePorts != null && activePortIndex < availablePorts.Length ? availablePorts[activePortIndex] : 49983;
|
|
|
|
// 필터 모드 설정
|
|
public enum FilterMode { None, EMA, OneEuro, MedianOneEuro }
|
|
|
|
[Header("Data Filtering")]
|
|
[Tooltip("필터링 모드: None=필터없음, EMA=스무딩+스파이크, OneEuro=1€ 적응형, Kalman=칼만필터, MedianOneEuro=메디안+1€ 복합")]
|
|
public FilterMode filterMode = FilterMode.MedianOneEuro;
|
|
|
|
// EMA 필터 설정 (기존 호환)
|
|
[Header("EMA Filter Settings")]
|
|
[Tooltip("스무딩 강도 (0=필터없음, 1=최대 스무딩). 프레임레이트 독립적으로 동작")]
|
|
[Range(0f, 0.95f)]
|
|
public float smoothingFactor = 0.1f;
|
|
[Tooltip("프레임 간 최대 허용 변화량 (BlendShape, 0~100 스케일)")]
|
|
[Range(1f, 100f)]
|
|
public float maxBlendShapeDelta = 30f;
|
|
[Tooltip("프레임 간 최대 허용 회전 변화량 (도)")]
|
|
[Range(1f, 90f)]
|
|
public float maxRotationDelta = 25f;
|
|
[Tooltip("눈 깜빡임 등 빠른 BlendShape의 임계값 배수")]
|
|
[Range(1f, 3f)]
|
|
public float fastBlendShapeMultiplier = 2.0f;
|
|
[Tooltip("스파이크 판정 전 허용할 연속 프레임 수 (연속이면 실제 움직임으로 판단)")]
|
|
[Range(1, 5)]
|
|
public int spikeToleranceFrames = 2;
|
|
|
|
// 1€ Euro Filter 설정
|
|
[Header("1€ Euro Filter Settings")]
|
|
[Tooltip("최소 cutoff 주파수 (Hz). 낮을수록 정지 시 스무딩 강함, 높을수록 반응 빠름")]
|
|
[Range(0.01f, 10f)]
|
|
public float euroMinCutoff = 1.0f;
|
|
[Tooltip("속도 계수. 높을수록 빠른 움직임에 즉시 반응")]
|
|
[Range(0f, 20f)]
|
|
public float euroBeta = 0.5f;
|
|
[Tooltip("미분 cutoff 주파수 (Hz). 속도 추정의 스무딩. 보통 1.0 유지")]
|
|
[Range(0.1f, 5f)]
|
|
public float euroDCutoff = 1.0f;
|
|
|
|
// Median+OneEuro 복합 필터 설정
|
|
[Header("Median+Euro Filter Settings")]
|
|
[Tooltip("Median 윈도우 크기 (홀수). 스파이크 제거용. 3=최소 지연, 5=일반, 7+=강한 제거")]
|
|
[Range(3, 11)]
|
|
public int medianWindowSize = 5;
|
|
|
|
// ── 필터 인스턴스 ──
|
|
// Euro 필터
|
|
private Dictionary<string, OneEuroFilter> euroFilters = new Dictionary<string, OneEuroFilter>();
|
|
private float euroMinCutoffPrev, euroBetaPrev, euroDCutoffPrev;
|
|
// EMA 필터링용 이전 값
|
|
private Dictionary<string, float> prevBlendShapeValues = new Dictionary<string, float>();
|
|
// 연속 스파이크 추적
|
|
private Dictionary<string, int> blendShapeSpikeCount = new Dictionary<string, int>();
|
|
private Dictionary<string, float> blendShapeSpikeDirection = new Dictionary<string, float>();
|
|
// Median 필터 (MedianOneEuro용)
|
|
private Dictionary<string, MedianFilter> medianFilters = new Dictionary<string, MedianFilter>();
|
|
// MedianOneEuro용 Euro 필터 (별도 인스턴스)
|
|
private Dictionary<string, OneEuroFilter> medianEuroFilters = new Dictionary<string, OneEuroFilter>();
|
|
private int medianWindowSizePrev;
|
|
|
|
// 빠르게 변하는 BlendShape 목록 (눈 깜빡임, 입 등)
|
|
private static readonly HashSet<string> FastBlendShapes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"eyeblinkleft", "eyeblinkright",
|
|
"eyesquintleft", "eyesquintright",
|
|
"eyewideleft", "eyewideright",
|
|
"jawopen", "mouthclose",
|
|
"mouthfunnel", "mouthpucker",
|
|
"mouthsmileright", "mouthsmileleft",
|
|
"mouthfrownright", "mouthfrownleft",
|
|
};
|
|
|
|
// 페이셜 개별 강도 조절
|
|
[Header("Facial Intensity")]
|
|
[Tooltip("전체 BlendShape 강도 배율 (1.0 = 원본)")]
|
|
[Range(0f, 3f)]
|
|
public float globalIntensity = 1.0f;
|
|
|
|
[Tooltip("개별 BlendShape 강도 오버라이드 (이름, 배율). 여기에 없는 항목은 globalIntensity 적용")]
|
|
public List<BlendShapeIntensityOverride> blendShapeIntensityOverrides = new List<BlendShapeIntensityOverride>();
|
|
|
|
[System.Serializable]
|
|
public class BlendShapeIntensityOverride
|
|
{
|
|
[Tooltip("ARKit BlendShape 이름 (예: EyeBlinkLeft, JawOpen, MouthSmileLeft 등)")]
|
|
public string blendShapeName;
|
|
[Range(0f, 3f)]
|
|
public float intensity = 1.0f;
|
|
}
|
|
|
|
// 런타임 빠른 조회용 Dictionary
|
|
private Dictionary<string, float> intensityOverrideMap = new Dictionary<string, float>(StringComparer.OrdinalIgnoreCase);
|
|
private bool intensityMapDirty = true;
|
|
|
|
// 프레임레이트 독립 스무딩을 위한 기준 FPS
|
|
private const float ReferenceFPS = 60f;
|
|
|
|
// 성능 최적화를 위한 캐시
|
|
private Dictionary<string, List<BlendShapeMapping>> blendShapeCache;
|
|
private readonly char[] splitEquals = new char[] { '=' };
|
|
private readonly char[] splitPipe = new char[] { '|' };
|
|
private readonly char[] splitAnd = new char[] { '&' };
|
|
private readonly char[] splitDash = new char[] { '-' };
|
|
|
|
// Mirror mode용 정적 매핑 테이블
|
|
private static readonly Dictionary<string, string> EyeMirrorMap = new Dictionary<string, string>() {
|
|
{"eyelookupleft", "EyeLookUpRight"},
|
|
{"eyelookupright", "EyeLookUpLeft"},
|
|
{"eyelookdownleft", "EyeLookDownRight"},
|
|
{"eyelookdownright", "EyeLookDownLeft"},
|
|
{"eyelookinleft", "EyeLookInRight"},
|
|
{"eyelookinright", "EyeLookInLeft"},
|
|
{"eyelookoutleft", "EyeLookOutRight"},
|
|
{"eyelookoutright", "EyeLookOutLeft"},
|
|
{"eyewideleft", "EyeWideRight"},
|
|
{"eyewideright", "EyeWideLeft"},
|
|
{"eyesquintleft", "EyeSquintRight"},
|
|
{"eyesquintright", "EyeSquintLeft"},
|
|
{"eyeblinkleft", "EyeBlinkRight"},
|
|
{"eyeblinkright", "EyeBlinkLeft"}
|
|
};
|
|
|
|
// BlendShape 매핑 정보를 저장하는 구조체
|
|
private struct BlendShapeMapping
|
|
{
|
|
public SkinnedMeshRenderer renderer;
|
|
public int index;
|
|
|
|
public BlendShapeMapping(SkinnedMeshRenderer r, int i)
|
|
{
|
|
renderer = r;
|
|
index = i;
|
|
}
|
|
}
|
|
|
|
// 재접속 코루틴 중복 방지
|
|
private Coroutine reconnectCoroutine;
|
|
|
|
// ── 마스터 모드에서 호출되는 메서드 ──
|
|
/// <summary>
|
|
/// UdpMaster가 메시지를 분배할 때 호출. 직접 호출하지 마세요.
|
|
/// </summary>
|
|
public void SetMessageFromMaster(string msg)
|
|
{
|
|
messageString = msg;
|
|
}
|
|
|
|
// Start is called
|
|
void StartFunction()
|
|
{
|
|
if (StartFlag == true)
|
|
{
|
|
StartFlag = false;
|
|
|
|
FindGameObjectsInsideUnitySettings();
|
|
|
|
// BlendShape 인덱스 캐싱 초기화
|
|
InitializeBlendShapeCache();
|
|
|
|
if (sourceMode == FacialSourceMode.NatNetDevice)
|
|
{
|
|
// NatNet 모드: 자체 UDP 서버 안 띄움. 두 컴포넌트에 의존:
|
|
// - OptitrackStreamingClient: device 이름→ID + 채널 이름 메타데이터
|
|
// - NatnetDeviceListener: 실제 채널값 (wire 직접 파싱)
|
|
if (natnetStreamingClient == null)
|
|
natnetStreamingClient = FindObjectOfType<OptitrackStreamingClient>();
|
|
if (natnetStreamingClient == null)
|
|
{
|
|
Debug.LogError("[Streamingle] NatNet 모드인데 OptitrackStreamingClient를 못 찾음.", this);
|
|
}
|
|
else if (!natnetStreamingClient.ReceiveDevices)
|
|
{
|
|
Debug.LogWarning("[Streamingle] OptitrackStreamingClient.ReceiveDevices 가 꺼져있음 — 켜야 device description이 들어옴.", this);
|
|
}
|
|
|
|
if (natnetDeviceListener == null)
|
|
natnetDeviceListener = NatnetDeviceListener.Instance;
|
|
}
|
|
else if (useSharedPort)
|
|
{
|
|
// 마스터 모드: UdpMaster에 등록하여 공유 수신
|
|
StreamingleFacialUdpMaster.Instance.Register(LOCAL_PORT, this);
|
|
}
|
|
else
|
|
{
|
|
// 독립 모드: 자체 UDP 서버 생성
|
|
CreateUdpServer();
|
|
}
|
|
}
|
|
}
|
|
|
|
void Start()
|
|
{
|
|
StartFunction();
|
|
}
|
|
|
|
// BlendShape 인덱스를 미리 캐싱하여 매번 검색하지 않도록 함
|
|
void InitializeBlendShapeCache()
|
|
{
|
|
blendShapeCache = new Dictionary<string, List<BlendShapeMapping>>();
|
|
|
|
if (faceMeshRenderers == null) return;
|
|
|
|
foreach (var meshRenderer in faceMeshRenderers)
|
|
{
|
|
if (meshRenderer == null || meshRenderer.sharedMesh == null) continue;
|
|
|
|
for (int i = 0; i < meshRenderer.sharedMesh.blendShapeCount; i++)
|
|
{
|
|
string shapeName = meshRenderer.sharedMesh.GetBlendShapeName(i);
|
|
string normalizedName = shapeName.ToLowerInvariant();
|
|
|
|
// 여러 변형 이름들을 모두 캐싱
|
|
AddToCache(normalizedName, meshRenderer, i);
|
|
|
|
// _L, _R 변환 버전도 캐싱
|
|
if (shapeName.Contains("_L"))
|
|
{
|
|
string leftVariant = shapeName.Replace("_L", "Left");
|
|
AddToCache(leftVariant.ToLowerInvariant(), meshRenderer, i);
|
|
}
|
|
else if (shapeName.Contains("_R"))
|
|
{
|
|
string rightVariant = shapeName.Replace("_R", "Right");
|
|
AddToCache(rightVariant.ToLowerInvariant(), meshRenderer, i);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void AddToCache(string key, SkinnedMeshRenderer renderer, int index)
|
|
{
|
|
if (!blendShapeCache.ContainsKey(key))
|
|
{
|
|
blendShapeCache[key] = new List<BlendShapeMapping>();
|
|
}
|
|
blendShapeCache[key].Add(new BlendShapeMapping(renderer, index));
|
|
}
|
|
|
|
void CreateUdpServer()
|
|
{
|
|
try
|
|
{
|
|
udp = new UdpClient(LOCAL_PORT);
|
|
udp.Client.ReceiveTimeout = 5;
|
|
|
|
isThreadRunning = true;
|
|
thread = new Thread(new ThreadStart(ThreadMethod));
|
|
thread.IsBackground = true; // 백그라운드 스레드로 설정
|
|
thread.Start();
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Debug.LogError($"[iFacialMocap] UDP 서버 생성 실패: {e.Message}");
|
|
}
|
|
}
|
|
|
|
|
|
void OnValidate()
|
|
{
|
|
intensityMapDirty = true;
|
|
SyncEuroParams();
|
|
}
|
|
|
|
void SyncEuroParams()
|
|
{
|
|
if (euroFilters != null)
|
|
{
|
|
foreach (var filter in euroFilters.Values)
|
|
filter.UpdateParams(euroMinCutoff, euroBeta, euroDCutoff);
|
|
}
|
|
if (medianEuroFilters != null)
|
|
{
|
|
foreach (var filter in medianEuroFilters.Values)
|
|
filter.UpdateParams(euroMinCutoff, euroBeta, euroDCutoff);
|
|
}
|
|
euroMinCutoffPrev = euroMinCutoff;
|
|
euroBetaPrev = euroBeta;
|
|
euroDCutoffPrev = euroDCutoff;
|
|
}
|
|
|
|
void RebuildIntensityOverrideMap()
|
|
{
|
|
intensityOverrideMap.Clear();
|
|
foreach (var entry in blendShapeIntensityOverrides)
|
|
{
|
|
if (!string.IsNullOrEmpty(entry.blendShapeName))
|
|
{
|
|
intensityOverrideMap[entry.blendShapeName] = entry.intensity;
|
|
}
|
|
}
|
|
intensityMapDirty = false;
|
|
}
|
|
|
|
float GetBlendShapeIntensity(string normalizedName)
|
|
{
|
|
if (intensityOverrideMap.TryGetValue(normalizedName, out float val))
|
|
return val * globalIntensity;
|
|
return globalIntensity;
|
|
}
|
|
|
|
// Update is called once per frame
|
|
void Update()
|
|
{
|
|
// 강도 오버라이드 맵 갱신 (인스펙터 변경 반영)
|
|
if (intensityMapDirty)
|
|
{
|
|
RebuildIntensityOverrideMap();
|
|
}
|
|
|
|
// NatNet 모드: device 채널을 폴링해 iFacialMocap 와이어 포맷의 messageString을
|
|
// 만든 뒤 기존 SetAnimation 파이프라인을 그대로 태운다. (이렇게 하면 필터/미러/
|
|
// intensity 코드 한 줄도 안 고치고 NatNet 경로 추가됨)
|
|
if (sourceMode == FacialSourceMode.NatNetDevice)
|
|
{
|
|
PollNatNetDevice();
|
|
}
|
|
|
|
// 메시지가 변경되었을 때만 처리 (성능 최적화)
|
|
if (!string.IsNullOrEmpty(messageString) && messageString != lastProcessedMessage)
|
|
{
|
|
try
|
|
{
|
|
SetAnimation_inside_Unity_settings();
|
|
lastProcessedMessage = messageString;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Debug.LogWarning($"[iFacialMocap] Animation 처리 중 오류: {e.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2026-05-13 PROTOCOL-LEVEL FIX. Plugin 과 양쪽 모두 하드코딩된 canonical
|
|
// blendshape 순서를 공유. Motive 가 description 을 reorder/strip 해도 wire 데이터
|
|
// 만큼은 plugin 이 쓴 그대로 도달하므로 alignment 보장. 두 사이드는 이 리스트가
|
|
// 일치해야 함 — plugin 의 FaceDevice.cpp `kCanonicalBlendshapeNames` 와 동일.
|
|
private const int kCanonicalPrimaryCount = 31; // primary device 가 담는 first canonical names
|
|
private static readonly string[] kCanonicalBlendshapeNames = new string[]
|
|
{
|
|
"browDown_L", "browDown_R", "browInnerUp", "browOuterUp_L", "browOuterUp_R",
|
|
"cheekPuff", "cheekSquint_L", "cheekSquint_R",
|
|
"eyeBlink_L", "eyeBlink_R",
|
|
"eyeLookDown_L", "eyeLookDown_R", "eyeLookIn_L", "eyeLookIn_R",
|
|
"eyeLookOut_L", "eyeLookOut_R", "eyeLookUp_L", "eyeLookUp_R",
|
|
"eyeSquint_L", "eyeSquint_R", "eyeWide_L", "eyeWide_R",
|
|
"hapihapi",
|
|
"jawForward", "jawLeft", "jawOpen", "jawRight",
|
|
"mouthClose", "mouthDimple_L", "mouthDimple_R",
|
|
"mouthFrown_L", "mouthFrown_R", "mouthFunnel",
|
|
"mouthLeft", "mouthLowerDown_L", "mouthLowerDown_R",
|
|
"mouthPress_L", "mouthPress_R", "mouthPucker",
|
|
"mouthRight", "mouthRollLower", "mouthRollUpper",
|
|
"mouthShrugLower", "mouthShrugUpper",
|
|
"mouthSmile_L", "mouthSmile_R",
|
|
"mouthStretch_L", "mouthStretch_R",
|
|
"mouthUpperUp_L", "mouthUpperUp_R",
|
|
"noseSneer_L", "noseSneer_R",
|
|
"tongueOut", "trackingStatus",
|
|
};
|
|
|
|
void PollNatNetDevice()
|
|
{
|
|
if (natnetStreamingClient == null || natnetDeviceListener == null) return;
|
|
|
|
string baseName = EffectiveNatnetDeviceBaseName;
|
|
|
|
// 포트 변경 또는 정의 미발견 시 재해석. OptitrackStreamingClient의 device descriptions
|
|
// 에서 우리 base name과 매칭되는 device id + channel names를 캐시.
|
|
bool portChanged = natnetLastResolvedFor != baseName;
|
|
if (portChanged ||
|
|
natnetResolvedDevices.Count == 0 ||
|
|
Time.unscaledTime - natnetLastResolveAttempt > k_NatnetResolveRetrySec)
|
|
{
|
|
natnetLastResolveAttempt = Time.unscaledTime;
|
|
if (portChanged) natnetDiagLogged = false;
|
|
natnetLastResolvedFor = baseName;
|
|
natnetResolvedDevices.Clear();
|
|
|
|
// 2026-05-13 PROTOCOL-LEVEL FIX. Description 완전 무시. Plugin/Unity 가
|
|
// 공유하는 kCanonicalBlendshapeNames 가 채널 이름 source of truth.
|
|
// 1) wire 의 sentinel 값으로 우리 port 의 wire device 식별 (multi-iPhone 환경 OK)
|
|
// 2) 채널 수 큰 게 primary, 작은 게 overflow
|
|
// 3) ChannelNames 는 canonical 배열에서 슬라이스로 부여
|
|
int port = LOCAL_PORT;
|
|
int wireCount = natnetDeviceListener.GetWireDeviceCount();
|
|
var wireCandidates = new List<(int wi, int len)>();
|
|
for (int wi = 0; wi < wireCount; wi++)
|
|
{
|
|
float[] vals = natnetDeviceListener.GetDeviceChannelsByIndex(wi);
|
|
if (vals == null || vals.Length < 1) continue;
|
|
int sentinel = Mathf.RoundToInt(vals[0]);
|
|
if (sentinel != port) continue;
|
|
wireCandidates.Add((wi, vals.Length));
|
|
}
|
|
wireCandidates.Sort((a, b) => b.len.CompareTo(a.len));
|
|
|
|
// canonical 을 primary slice + overflow slice 로 분리
|
|
var primarySlice = new List<string>(1 + kCanonicalPrimaryCount);
|
|
primarySlice.Add("sentinelPort");
|
|
for (int i = 0; i < kCanonicalPrimaryCount && i < kCanonicalBlendshapeNames.Length; i++)
|
|
primarySlice.Add(kCanonicalBlendshapeNames[i]);
|
|
|
|
var overflowSlice = new List<string>(1 + Math.Max(0, kCanonicalBlendshapeNames.Length - kCanonicalPrimaryCount));
|
|
overflowSlice.Add("sentinelPort");
|
|
for (int i = kCanonicalPrimaryCount; i < kCanonicalBlendshapeNames.Length; i++)
|
|
overflowSlice.Add(kCanonicalBlendshapeNames[i]);
|
|
|
|
// 0: primary (큰 채널 수), 1: overflow (작은 채널 수)
|
|
if (wireCandidates.Count > 0)
|
|
natnetResolvedDevices.Add(new ResolvedDevice { Id = 0, WireIndex = wireCandidates[0].wi, ChannelNames = primarySlice });
|
|
if (wireCandidates.Count > 1)
|
|
natnetResolvedDevices.Add(new ResolvedDevice { Id = 0, WireIndex = wireCandidates[1].wi, ChannelNames = overflowSlice });
|
|
if (natnetLastSeenSeq.Length < natnetResolvedDevices.Count)
|
|
natnetLastSeenSeq = new ulong[natnetResolvedDevices.Count];
|
|
}
|
|
|
|
if (natnetResolvedDevices.Count == 0) return;
|
|
|
|
// GC OPT: skip rebuild if no listener has new wire frame since last poll.
|
|
// (Unity Update tick can run faster than NatNet broadcast rate.)
|
|
bool anyNew = false;
|
|
for (int di = 0; di < natnetResolvedDevices.Count; di++)
|
|
{
|
|
int wi = natnetResolvedDevices[di].WireIndex;
|
|
ulong seq = natnetDeviceListener.GetDeviceFrameSeqByIndex(wi);
|
|
if (di < natnetLastSeenSeq.Length && seq != natnetLastSeenSeq[di])
|
|
{
|
|
natnetLastSeenSeq[di] = seq;
|
|
anyNew = true;
|
|
}
|
|
}
|
|
if (!anyNew) return;
|
|
|
|
// iFacialMocap 와이어 포맷 합성: name-value|name-value|...=
|
|
// 채널 이름은 canonical 리스트(plugin과 공유)에서, 값은 NatnetDeviceListener wire 파싱.
|
|
natnetMsgBuilder.Length = 0;
|
|
bool first = true;
|
|
for (int di = 0; di < natnetResolvedDevices.Count; di++)
|
|
{
|
|
var rd = natnetResolvedDevices[di];
|
|
float[] vals = natnetDeviceListener.GetDeviceChannelsByIndex(rd.WireIndex);
|
|
if (vals == null || rd.ChannelNames == null) continue;
|
|
|
|
if (!natnetDiagLogged)
|
|
{
|
|
int dumpLen = Math.Min(5, vals.Length);
|
|
var valDump = new string[dumpLen];
|
|
for (int k = 0; k < dumpLen; k++) valDump[k] = vals[k].ToString("0.##", CultureInfo.InvariantCulture);
|
|
int nameDump = Math.Min(5, rd.ChannelNames.Count);
|
|
var nameArr = new string[nameDump];
|
|
for (int k = 0; k < nameDump; k++) nameArr[k] = rd.ChannelNames[k];
|
|
Debug.Log($"[NatNet Align Diag] dev[{di}] WireIdx={rd.WireIndex} canonicalLen={rd.ChannelNames.Count} wireLen={vals.Length} firstNames=[{string.Join(",", nameArr)}] firstVals=[{string.Join(",", valDump)}]");
|
|
}
|
|
|
|
// Canonical 매핑: rd.ChannelNames 는 [sentinelPort, canonical[a], canonical[a+1], ...]
|
|
// 의 슬라이스. wire vals 는 plugin 이 같은 순서로 채움 (plugin 검증됨).
|
|
// vals[0] = sentinel (40001), vals[c] = ChannelNames[c]'s blendshape value.
|
|
int n = Math.Min(vals.Length, rd.ChannelNames.Count);
|
|
for (int c = 0; c < n; c++)
|
|
{
|
|
string name = rd.ChannelNames[c];
|
|
if (string.IsNullOrEmpty(name)) continue;
|
|
if (name == "sentinelPort") continue;
|
|
|
|
if (!first) natnetMsgBuilder.Append('|');
|
|
natnetMsgBuilder.Append(name);
|
|
natnetMsgBuilder.Append('-');
|
|
natnetMsgBuilder.Append(vals[c].ToString("0.####", CultureInfo.InvariantCulture));
|
|
first = false;
|
|
}
|
|
}
|
|
if (!natnetDiagLogged && natnetResolvedDevices.Count > 0) natnetDiagLogged = true;
|
|
// 파서가 split('=', RemoveEmptyEntries) 길이 >= 2를 요구함.
|
|
// 빈 transforms 섹션은 RemoveEmptyEntries에 의해 제거되므로 dummy 채워넣음.
|
|
natnetMsgBuilder.Append("=head#0,0,0,0,0,0|leftEye#0,0,0|rightEye#0,0,0");
|
|
|
|
messageString = natnetMsgBuilder.ToString();
|
|
}
|
|
|
|
//BlendShapeの設定
|
|
//set blendshapes (캐시 사용으로 최적화)
|
|
void SetBlendShapeWeightFromStrArray(string[] strArray2)
|
|
{
|
|
if (blendShapeCache == null) return;
|
|
|
|
string shapeName = strArray2[0];
|
|
float weight = float.Parse(strArray2[1], CultureInfo.InvariantCulture);
|
|
|
|
// 정규화된 이름으로 캐시 검색
|
|
string normalizedName = NormalizeBlendShapeName(shapeName).ToLowerInvariant();
|
|
|
|
// 강도 배율 적용
|
|
weight *= GetBlendShapeIntensity(normalizedName);
|
|
weight = Mathf.Clamp(weight, 0f, 100f);
|
|
|
|
// 필터링 적용
|
|
if (filterMode != FilterMode.None)
|
|
{
|
|
weight = FilterBlendShapeValue(normalizedName, weight);
|
|
}
|
|
|
|
if (blendShapeCache.TryGetValue(normalizedName, out List<BlendShapeMapping> mappings))
|
|
{
|
|
foreach (var mapping in mappings)
|
|
{
|
|
if (mapping.renderer != null)
|
|
{
|
|
mapping.renderer.SetBlendShapeWeight(mapping.index, weight);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// BlendShape 이름 정규화 함수
|
|
string NormalizeBlendShapeName(string name)
|
|
{
|
|
if (name.EndsWith("_L"))
|
|
name = name.Substring(0, name.Length - 2) + "Left";
|
|
else if (name.EndsWith("_R"))
|
|
name = name.Substring(0, name.Length - 2) + "Right";
|
|
|
|
// 카멜케이스화: 언더스코어 기준 분리 후 각 파트 첫 글자 대문자
|
|
string[] parts = name.Split('_');
|
|
for (int i = 0; i < parts.Length; i++)
|
|
{
|
|
if (parts[i].Length > 0)
|
|
parts[i] = char.ToUpper(parts[i][0]) + parts[i].Substring(1);
|
|
}
|
|
return string.Join("", parts);
|
|
}
|
|
|
|
//BlendShapeとボーンの回転の設定
|
|
//set blendshapes & bone rotation (최적화 버전)
|
|
void SetAnimation_inside_Unity_settings()
|
|
{
|
|
try
|
|
{
|
|
string[] strArray1 = messageString.Split(splitEquals, StringSplitOptions.RemoveEmptyEntries);
|
|
|
|
if (strArray1.Length >= 2)
|
|
{
|
|
//blendShapes
|
|
string[] blendShapeMessages = strArray1[0].Split(splitPipe, StringSplitOptions.RemoveEmptyEntries);
|
|
foreach (string message in blendShapeMessages)
|
|
{
|
|
if (string.IsNullOrEmpty(message)) continue;
|
|
|
|
string[] strArray2;
|
|
if (message.Contains("&"))
|
|
{
|
|
strArray2 = message.Split(splitAnd, StringSplitOptions.RemoveEmptyEntries);
|
|
}
|
|
else
|
|
{
|
|
strArray2 = message.Split(splitDash, StringSplitOptions.RemoveEmptyEntries);
|
|
}
|
|
|
|
if (strArray2.Length == 2)
|
|
{
|
|
// 이름 정규화 먼저 적용
|
|
strArray2[0] = NormalizeBlendShapeName(strArray2[0]);
|
|
if (mirrorMode)
|
|
{
|
|
string originalShapeName = strArray2[0];
|
|
string shapeNameLower = originalShapeName.ToLowerInvariant();
|
|
|
|
// 정적 Dictionary 사용
|
|
string mirroredName = originalShapeName;
|
|
if (EyeMirrorMap.TryGetValue(shapeNameLower, out string mappedName))
|
|
{
|
|
mirroredName = mappedName;
|
|
}
|
|
else if (originalShapeName.Contains("Right"))
|
|
{
|
|
mirroredName = originalShapeName.Replace("Right", "Left");
|
|
}
|
|
else if (originalShapeName.Contains("Left"))
|
|
{
|
|
mirroredName = originalShapeName.Replace("Left", "Right");
|
|
}
|
|
strArray2[0] = mirroredName;
|
|
}
|
|
SetBlendShapeWeightFromStrArray(strArray2);
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Debug.LogWarning($"[iFacialMocap] Animation 설정 중 오류: {e.Message}");
|
|
}
|
|
}
|
|
|
|
void FindGameObjectsInsideUnitySettings()
|
|
{
|
|
// 모든 Transform 참조는 이미 인스펙터에서 할당되므로 추가 초기화가 필요 없음
|
|
}
|
|
|
|
void ThreadMethod()
|
|
{
|
|
while (isThreadRunning)
|
|
{
|
|
try
|
|
{
|
|
IPEndPoint remoteEP = null;
|
|
byte[] data = udp.Receive(ref remoteEP);
|
|
|
|
// 데이터를 받았을 때만 업데이트
|
|
if (data != null && data.Length > 0)
|
|
{
|
|
messageString = Encoding.ASCII.GetString(data);
|
|
}
|
|
}
|
|
catch (SocketException e)
|
|
{
|
|
// 스레드 종료 중이면 로그 생략
|
|
if (!isThreadRunning) break;
|
|
|
|
if (e.SocketErrorCode != SocketError.TimedOut)
|
|
{
|
|
Debug.LogError($"[iFacialMocap] 데이터 수신 오류: {e.Message}");
|
|
}
|
|
}
|
|
catch (ObjectDisposedException)
|
|
{
|
|
// UDP 소켓이 닫힌 경우 (정상 종료 시 발생)
|
|
break;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
// 스레드 종료 중이면 로그 생략
|
|
if (!isThreadRunning) break;
|
|
|
|
Debug.LogError($"[iFacialMocap] 예상치 못한 오류: {e.Message}");
|
|
}
|
|
|
|
// CPU를 양보하는 Sleep 사용 (5ms 대기)
|
|
Thread.Sleep(5);
|
|
}
|
|
}
|
|
|
|
|
|
public string GetMessageString()
|
|
{
|
|
return messageString;
|
|
}
|
|
|
|
void OnEnable()
|
|
{
|
|
StartFunction();
|
|
}
|
|
|
|
void OnDisable()
|
|
{
|
|
try
|
|
{
|
|
OnApplicationQuit();
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Debug.LogWarning($"[iFacialMocap] OnDisable 중 오류: {e.Message}");
|
|
}
|
|
}
|
|
|
|
void OnApplicationQuit()
|
|
{
|
|
if (StartFlag == false)
|
|
{
|
|
StartFlag = true;
|
|
|
|
if (sourceMode == FacialSourceMode.NatNetDevice)
|
|
{
|
|
// NatNet 모드: UDP 자원 없음. OptitrackStreamingClient는 다른 곳이 관리.
|
|
return;
|
|
}
|
|
|
|
if (useSharedPort)
|
|
{
|
|
// 마스터에서 해제
|
|
if (StreamingleFacialUdpMaster.Instance != null)
|
|
{
|
|
StreamingleFacialUdpMaster.Instance.Unregister(LOCAL_PORT, this);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
StopUDP();
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
public void StopUDP()
|
|
{
|
|
// 안전한 스레드 종료
|
|
isThreadRunning = false;
|
|
|
|
// UDP 종료
|
|
if (udp != null)
|
|
{
|
|
try
|
|
{
|
|
udp.Close();
|
|
udp.Dispose();
|
|
}
|
|
catch (Exception) { }
|
|
udp = null;
|
|
}
|
|
|
|
// 스레드가 종료될 때까지 대기 (최대 300ms)
|
|
if (thread != null && thread.IsAlive)
|
|
{
|
|
thread.Join(300);
|
|
thread = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 포트 핫스왑: 지정된 인덱스의 포트로 즉시 전환.
|
|
/// 두 모드 공통: DirectUDP는 UDP listener 재바인드, NatNet은 다음 polling 사이클에서
|
|
/// 새 포트의 디바이스로 자동 재해석됨.
|
|
/// </summary>
|
|
public void SwitchToPort(int portIndex)
|
|
{
|
|
if (availablePorts == null || portIndex < 0 || portIndex >= availablePorts.Length)
|
|
{
|
|
Debug.LogError($"[iFacialMocap] 잘못된 포트 인덱스: {portIndex}");
|
|
return;
|
|
}
|
|
|
|
int oldPort = LOCAL_PORT;
|
|
activePortIndex = portIndex;
|
|
Debug.Log($"[iFacialMocap] 포트 전환: {availablePorts[portIndex]}");
|
|
|
|
if (sourceMode == FacialSourceMode.NatNetDevice)
|
|
{
|
|
// NatNet 모드: 다음 PollNatNetDevice 호출에서 새 base name으로 자동 재해석.
|
|
// 즉시 강제하려면 캐시 무효화.
|
|
natnetResolvedDevices.Clear();
|
|
natnetLastResolveAttempt = -10f;
|
|
return;
|
|
}
|
|
|
|
if (useSharedPort)
|
|
{
|
|
// 마스터 모드: 포트 전환 요청
|
|
StreamingleFacialUdpMaster.Instance.SwitchPort(oldPort, LOCAL_PORT, this);
|
|
}
|
|
else
|
|
{
|
|
Reconnect();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 페이셜 모션 캡처 재접속 (프리징 없는 코루틴 방식)
|
|
/// </summary>
|
|
public void Reconnect()
|
|
{
|
|
if (reconnectCoroutine != null)
|
|
{
|
|
StopCoroutine(reconnectCoroutine);
|
|
}
|
|
reconnectCoroutine = StartCoroutine(ReconnectCoroutine());
|
|
}
|
|
|
|
private IEnumerator ReconnectCoroutine()
|
|
{
|
|
Debug.Log("[iFacialMocap] 재접속 시도 중...");
|
|
|
|
if (sourceMode == FacialSourceMode.NatNetDevice)
|
|
{
|
|
// NatNet 모드: 디바이스 재해석 강제 (다음 PollNatNetDevice 호출에서 다시 검색).
|
|
natnetResolvedDevices.Clear();
|
|
natnetLastResolveAttempt = -10f;
|
|
yield return null;
|
|
reconnectCoroutine = null;
|
|
Debug.Log("[iFacialMocap] NatNet device 재해석 강제 완료");
|
|
yield break;
|
|
}
|
|
|
|
if (useSharedPort)
|
|
{
|
|
// 마스터 모드: 해제 후 재등록
|
|
StreamingleFacialUdpMaster.Instance.Unregister(LOCAL_PORT, this);
|
|
yield return null; // 1프레임 대기
|
|
StreamingleFacialUdpMaster.Instance.Register(LOCAL_PORT, this);
|
|
}
|
|
else
|
|
{
|
|
// 독립 모드: UDP 종료 후 코루틴으로 대기 (프리징 없음)
|
|
StopUDP();
|
|
|
|
// OS가 포트를 해제할 시간을 코루틴으로 대기 (메인 스레드 블로킹 없음)
|
|
yield return new WaitForSeconds(0.5f);
|
|
|
|
// 플래그 리셋
|
|
StartFlag = true;
|
|
|
|
// 재시작
|
|
StartFunction();
|
|
}
|
|
|
|
reconnectCoroutine = null;
|
|
Debug.Log("[iFacialMocap] 재접속 완료");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 프레임레이트 독립적 EMA 계수 계산
|
|
/// </summary>
|
|
float GetFrameIndependentSmoothing()
|
|
{
|
|
float dt = Time.deltaTime;
|
|
if (dt <= 0f) return smoothingFactor;
|
|
// 기준 60fps에서의 smoothingFactor를 현재 dt에 맞게 보정
|
|
return 1f - Mathf.Pow(1f - smoothingFactor, dt * ReferenceFPS);
|
|
}
|
|
|
|
/// <summary>
|
|
/// BlendShape 값 필터링: filterMode에 따라 적절한 필터 적용
|
|
/// </summary>
|
|
float FilterBlendShapeValue(string name, float rawValue)
|
|
{
|
|
switch (filterMode)
|
|
{
|
|
case FilterMode.OneEuro:
|
|
return FilterOneEuro(name, rawValue);
|
|
case FilterMode.MedianOneEuro:
|
|
return FilterMedianOneEuro(name, rawValue);
|
|
case FilterMode.EMA:
|
|
return FilterEMA(name, rawValue);
|
|
default:
|
|
return rawValue;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 1€ Euro Filter: 속도 기반 적응형 cutoff
|
|
/// </summary>
|
|
float FilterOneEuro(string name, float rawValue)
|
|
{
|
|
float t = Time.time;
|
|
|
|
if (!euroFilters.TryGetValue(name, out var filter))
|
|
{
|
|
filter = new OneEuroFilter(60f, euroMinCutoff, euroBeta, euroDCutoff);
|
|
euroFilters[name] = filter;
|
|
// 첫 샘플은 필터 초기화용 → 그대로 반환
|
|
filter.Filter(rawValue, t);
|
|
return rawValue;
|
|
}
|
|
|
|
// 파라미터 변경 감지 → 모든 필터 업데이트
|
|
if (euroMinCutoffPrev != euroMinCutoff || euroBetaPrev != euroBeta || euroDCutoffPrev != euroDCutoff)
|
|
{
|
|
SyncEuroParams();
|
|
}
|
|
|
|
return Mathf.Clamp(filter.Filter(rawValue, t), 0f, 100f);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Median + OneEuro 복합 필터:
|
|
/// 1단계: Median 필터로 스파이크/이상치 제거
|
|
/// 2단계: OneEuro 필터로 적응형 스무딩
|
|
/// → 스파이크에 강하면서도 빠른 움직임에 반응하는 최고 품질 필터링
|
|
/// </summary>
|
|
float FilterMedianOneEuro(string name, float rawValue)
|
|
{
|
|
// Median 윈도우 크기 변경 감지 → 필터 재생성
|
|
if (medianWindowSizePrev != medianWindowSize)
|
|
{
|
|
medianFilters.Clear();
|
|
medianWindowSizePrev = medianWindowSize;
|
|
}
|
|
|
|
// 1단계: Median 필터
|
|
if (!medianFilters.TryGetValue(name, out var medianFilter))
|
|
{
|
|
medianFilter = new MedianFilter(medianWindowSize);
|
|
medianFilters[name] = medianFilter;
|
|
}
|
|
float medianValue = medianFilter.Filter(rawValue);
|
|
|
|
// 2단계: OneEuro 필터
|
|
float t = Time.time;
|
|
if (!medianEuroFilters.TryGetValue(name, out var euroFilter))
|
|
{
|
|
euroFilter = new OneEuroFilter(60f, euroMinCutoff, euroBeta, euroDCutoff);
|
|
medianEuroFilters[name] = euroFilter;
|
|
euroFilter.Filter(medianValue, t);
|
|
return medianValue;
|
|
}
|
|
|
|
// 파라미터 변경 감지
|
|
if (euroMinCutoffPrev != euroMinCutoff || euroBetaPrev != euroBeta || euroDCutoffPrev != euroDCutoff)
|
|
{
|
|
SyncEuroParams();
|
|
}
|
|
|
|
return Mathf.Clamp(euroFilter.Filter(medianValue, t), 0f, 100f);
|
|
}
|
|
|
|
/// <summary>
|
|
/// EMA 필터: 연속 스파이크 판별 + 카테고리별 임계값 + 프레임독립 EMA (기존 로직)
|
|
/// </summary>
|
|
float FilterEMA(string name, float rawValue)
|
|
{
|
|
if (prevBlendShapeValues.TryGetValue(name, out float prevValue))
|
|
{
|
|
float diff = rawValue - prevValue;
|
|
float delta = Mathf.Abs(diff);
|
|
|
|
// 빠르게 변하는 BlendShape는 임계값을 높여줌
|
|
float threshold = maxBlendShapeDelta;
|
|
if (FastBlendShapes.Contains(name))
|
|
{
|
|
threshold *= fastBlendShapeMultiplier;
|
|
}
|
|
|
|
// 스파이크 감지
|
|
if (delta > threshold)
|
|
{
|
|
// 연속 스파이크 추적: 같은 방향이면 카운트 증가
|
|
float prevDir = 0f;
|
|
blendShapeSpikeDirection.TryGetValue(name, out prevDir);
|
|
bool sameDirection = (diff > 0 && prevDir > 0) || (diff < 0 && prevDir < 0);
|
|
|
|
int count = 0;
|
|
blendShapeSpikeCount.TryGetValue(name, out count);
|
|
|
|
if (sameDirection)
|
|
{
|
|
count++;
|
|
}
|
|
else
|
|
{
|
|
count = 1;
|
|
}
|
|
|
|
blendShapeSpikeCount[name] = count;
|
|
blendShapeSpikeDirection[name] = diff;
|
|
|
|
// 연속 프레임 이상 같은 방향이면 실제 움직임으로 판단 → 통과
|
|
if (count >= spikeToleranceFrames)
|
|
{
|
|
blendShapeSpikeCount[name] = 0;
|
|
prevBlendShapeValues[name] = rawValue;
|
|
return rawValue;
|
|
}
|
|
|
|
// 단발성 스파이크 → 허용량만큼만 이동
|
|
float clamped = prevValue + Mathf.Clamp(diff, -threshold, threshold);
|
|
prevBlendShapeValues[name] = clamped;
|
|
return clamped;
|
|
}
|
|
|
|
// 정상 범위 → 스파이크 카운터 리셋
|
|
blendShapeSpikeCount[name] = 0;
|
|
|
|
// 프레임레이트 독립 EMA 스무딩
|
|
float alpha = GetFrameIndependentSmoothing();
|
|
float smoothed = Mathf.Lerp(rawValue, prevValue, alpha);
|
|
prevBlendShapeValues[name] = smoothed;
|
|
return smoothed;
|
|
}
|
|
|
|
// 첫 프레임
|
|
prevBlendShapeValues[name] = rawValue;
|
|
return rawValue;
|
|
}
|
|
|
|
|
|
}
|
|
public static class StreamingleFacialReceiverExtensions
|
|
{
|
|
public static List<GameObject> GetAll(this GameObject obj)
|
|
{
|
|
List<GameObject> allChildren = new List<GameObject>();
|
|
allChildren.Add(obj);
|
|
GetChildren(obj, ref allChildren);
|
|
return allChildren;
|
|
}
|
|
|
|
public static void GetChildren(GameObject obj, ref List<GameObject> allChildren)
|
|
{
|
|
Transform children = obj.GetComponentInChildren<Transform>();
|
|
if (children.childCount == 0)
|
|
{
|
|
return;
|
|
}
|
|
foreach (Transform ob in children)
|
|
{
|
|
allChildren.Add(ob.gameObject);
|
|
GetChildren(ob.gameObject, ref allChildren);
|
|
}
|
|
}
|
|
}
|