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_"; /// /// 현재 활성 포트로부터 합성된 디바이스 base name. NatNet 모드에서 사용. /// 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 ChannelNames; } private List natnetResolvedDevices = new List(2); private float natnetLastResolveAttempt = -10f; private string natnetLastResolvedFor = ""; // 포트 변경 감지용 private ulong[] natnetLastSeenSeq = new ulong[2]; // resolved 디바이스별 frame seq 추적 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 euroFilters = new Dictionary(); private float euroMinCutoffPrev, euroBetaPrev, euroDCutoffPrev; // EMA 필터링용 이전 값 private Dictionary prevBlendShapeValues = new Dictionary(); // 연속 스파이크 추적 private Dictionary blendShapeSpikeCount = new Dictionary(); private Dictionary blendShapeSpikeDirection = new Dictionary(); // Median 필터 (MedianOneEuro용) private Dictionary medianFilters = new Dictionary(); // MedianOneEuro용 Euro 필터 (별도 인스턴스) private Dictionary medianEuroFilters = new Dictionary(); private int medianWindowSizePrev; // 빠르게 변하는 BlendShape 목록 (눈 깜빡임, 입 등) private static readonly HashSet FastBlendShapes = new HashSet(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 blendShapeIntensityOverrides = new List(); [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 intensityOverrideMap = new Dictionary(StringComparer.OrdinalIgnoreCase); private bool intensityMapDirty = true; // 프레임레이트 독립 스무딩을 위한 기준 FPS private const float ReferenceFPS = 60f; // 성능 최적화를 위한 캐시 private Dictionary> 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 EyeMirrorMap = new Dictionary() { {"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; // ── 마스터 모드에서 호출되는 메서드 ── /// /// UdpMaster가 메시지를 분배할 때 호출. 직접 호출하지 마세요. /// 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(); 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>(); 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(); } 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}"); } } } 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; natnetLastResolvedFor = baseName; natnetResolvedDevices.Clear(); var defs = natnetStreamingClient.GetDeviceDefinitions(); if (defs != null) { // Match base name (primary device) and ANY device whose name starts with // baseName + "_" (overflow / future suffixes like _A, _B, _overflow). // Order matters for downstream merging: primary first, then secondaries. ResolvedDevice? primary = null; List secondaries = new List(); for (int gi = 0; gi < defs.Count; gi++) { var d = defs[gi]; if (d.Name == baseName) { primary = new ResolvedDevice { Id = d.Id, WireIndex = gi, ChannelNames = d.ChannelNames }; } else if (d.Name.StartsWith(baseName + "_")) { secondaries.Add(new ResolvedDevice { Id = d.Id, WireIndex = gi, ChannelNames = d.ChannelNames }); } } if (primary.HasValue) natnetResolvedDevices.Add(primary.Value); natnetResolvedDevices.AddRange(secondaries); } 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|...= // 채널값은 NatnetDeviceListener (wire 직접 파싱) 에서, 채널 이름은 description에서. // **WIRE INDEX 기반 매칭** — Motive가 모든 plugin device id를 0으로 broadcasting하는 // 버그 회피. description 순서 = wire 순서 가정 (Motive registration order). 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; 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[0] == 'h' && name.StartsWith("head_")) continue; if (name[0] == 'l' && name.StartsWith("leftEye_")) continue; if (name[0] == 'r' && name.StartsWith("rightEye_")) continue; if (!first) natnetMsgBuilder.Append('|'); natnetMsgBuilder.Append(name); natnetMsgBuilder.Append('-'); natnetMsgBuilder.Append(vals[c].ToString("0.####", CultureInfo.InvariantCulture)); first = false; } } // 파서가 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 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; } } /// /// 포트 핫스왑: 지정된 인덱스의 포트로 즉시 전환. /// 두 모드 공통: DirectUDP는 UDP listener 재바인드, NatNet은 다음 polling 사이클에서 /// 새 포트의 디바이스로 자동 재해석됨. /// 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(); } } /// /// 페이셜 모션 캡처 재접속 (프리징 없는 코루틴 방식) /// 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] 재접속 완료"); } /// /// 프레임레이트 독립적 EMA 계수 계산 /// float GetFrameIndependentSmoothing() { float dt = Time.deltaTime; if (dt <= 0f) return smoothingFactor; // 기준 60fps에서의 smoothingFactor를 현재 dt에 맞게 보정 return 1f - Mathf.Pow(1f - smoothingFactor, dt * ReferenceFPS); } /// /// BlendShape 값 필터링: filterMode에 따라 적절한 필터 적용 /// 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; } } /// /// 1€ Euro Filter: 속도 기반 적응형 cutoff /// 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); } /// /// Median + OneEuro 복합 필터: /// 1단계: Median 필터로 스파이크/이상치 제거 /// 2단계: OneEuro 필터로 적응형 스무딩 /// → 스파이크에 강하면서도 빠른 움직임에 반응하는 최고 품질 필터링 /// 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); } /// /// EMA 필터: 연속 스파이크 판별 + 카테고리별 임계값 + 프레임독립 EMA (기존 로직) /// 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 GetAll(this GameObject obj) { List allChildren = new List(); allChildren.Add(obj); GetChildren(obj, ref allChildren); return allChildren; } public static void GetChildren(GameObject obj, ref List allChildren) { Transform children = obj.GetComponentInChildren(); if (children.childCount == 0) { return; } foreach (Transform ob in children) { allChildren.Add(ob.gameObject); GetChildren(ob.gameObject, ref allChildren); } } }