From edf6896d654c2de289e6fada2af32c5db2a7e492 Mon Sep 17 00:00:00 2001 From: "qsxft258@gmail.com" Date: Wed, 13 May 2026 01:50:09 +0900 Subject: [PATCH] =?UTF-8?q?Fix=20:=20=ED=8E=98=EC=9D=B4=EC=85=9C=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=ED=8C=A8=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../StreamingleFacialReceiver.cs | 274 +++++------------- 1 file changed, 68 insertions(+), 206 deletions(-) diff --git a/Assets/External/StreamingleFacial/StreamingleFacialReceiver.cs b/Assets/External/StreamingleFacial/StreamingleFacialReceiver.cs index 35d6a92e3..b243bb16e 100644 --- a/Assets/External/StreamingleFacial/StreamingleFacialReceiver.cs +++ b/Assets/External/StreamingleFacial/StreamingleFacialReceiver.cs @@ -410,84 +410,33 @@ public class StreamingleFacialReceiver : MonoBehaviour } } - // Plugin 이 디스크에 쓴 등록 순서 파일을 파싱. Motive 의 description 재정렬 우회용. - // 파일 포맷: - // === CHANNEL DIAG '' tried=N lastIdx=M rejected=K === - // ORDER: [0]name0,[1]name1,... - // (빈 줄) - // Returns null if file missing/unreadable. - private const string kPluginDiagPath = "C:/Users/user/Documents/OptiTrack/iFacialMocap_diag.log"; - private Dictionary> _cachedPluginOrder = null; - private System.DateTime _cachedPluginOrderTime = System.DateTime.MinValue; - Dictionary> LoadPluginChannelOrder() + // 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[] { - try - { - if (!System.IO.File.Exists(kPluginDiagPath)) return null; - var info = new System.IO.FileInfo(kPluginDiagPath); - if (_cachedPluginOrder != null && info.LastWriteTimeUtc == _cachedPluginOrderTime) - return _cachedPluginOrder; - - var result = new Dictionary>(); - string[] lines = System.IO.File.ReadAllLines(kPluginDiagPath); - string currentDevice = null; - foreach (var raw in lines) - { - string line = raw.Trim(); - if (line.StartsWith("=== CHANNEL DIAG '")) - { - int nameStart = line.IndexOf('\'') + 1; - int nameEnd = line.IndexOf('\'', nameStart); - if (nameEnd > nameStart) currentDevice = line.Substring(nameStart, nameEnd - nameStart); - } - else if (currentDevice != null && line.StartsWith("ORDER:")) - { - string body = line.Substring("ORDER:".Length).Trim(); - var names = new List(64); - // 각 토큰: "[N]name" - foreach (var tok in body.Split(',')) - { - string t = tok.Trim(); - int rb = t.IndexOf(']'); - if (rb > 0 && rb + 1 < t.Length) names.Add(t.Substring(rb + 1)); - } - result[currentDevice] = names; - currentDevice = null; - } - } - _cachedPluginOrder = result; - _cachedPluginOrderTime = info.LastWriteTimeUtc; - Debug.Log($"[Plugin Order] loaded {result.Count} device(s) from diag.log; e.g. primary jawOpen at idx {(result.Values.GetEnumerator().MoveNext() ? FindNameIndex(result, "jawOpen") : -1)}"); - return result; - } - catch (System.Exception e) - { - Debug.LogWarning($"[Plugin Order] read failed: {e.Message}"); - return null; - } - } - static int FindNameIndex(Dictionary> dict, string name) - { - foreach (var kv in dict) for (int i = 0; i < kv.Value.Count; i++) if (kv.Value[i] == name) return i; - return -1; - } - - // "NN_" -> idx=NN, raw=. Returns false if no valid 2-digit prefix. - static bool TryParseIdxPrefix(string s, out int idx, out string raw) - { - if (s != null && s.Length >= 3 && char.IsDigit(s[0]) && char.IsDigit(s[1]) && s[2] == '_') - { - idx = (s[0] - '0') * 10 + (s[1] - '0'); - raw = s.Substring(3); - return true; - } - idx = -1; raw = s; - return false; - } - static bool HasIdxPrefix(string s) - { - return s != null && s.Length >= 3 && char.IsDigit(s[0]) && char.IsDigit(s[1]) && s[2] == '_'; - } + "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() { @@ -507,76 +456,40 @@ public class StreamingleFacialReceiver : MonoBehaviour natnetLastResolvedFor = baseName; natnetResolvedDevices.Clear(); - // Plugin 이 디스크에 적은 등록 순서를 우선 사용 (Motive 가 description 을 재정렬해서 - // description 으로는 wire 와 정렬할 수 없음). 파일이 있으면 ChannelNames 를 덮어씀. - var pluginOrderByName = LoadPluginChannelOrder(); - - // 2026-05-13: 'gi == wireIndex' 가정이 stale device 환경에서 깨짐 (description 에 - // 남아있는데 wire 에선 빠진 device 가 있으면 인덱스가 어긋남). wire 의 sentinel - // 값 (= port 번호) 으로 직접 매칭하는 fast path 우선 시도. plugin order 파일이 - // 없을 때만 옛 defs 기반 fallback. - if (pluginOrderByName != null && pluginOrderByName.Count > 0) + // 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++) { - int port = LOCAL_PORT; - string primaryDevName = $"iFacialMocap_{port}"; - string overflowDevName = $"iFacialMocap_{port}_overflow"; - List primaryNames = pluginOrderByName.TryGetValue(primaryDevName, out var pn) ? pn : null; - List overflowNames = pluginOrderByName.TryGetValue(overflowDevName, out var on) ? on : null; - - int wireCount = natnetDeviceListener.GetWireDeviceCount(); - int primaryWire = -1, overflowWire = -1; - int primaryWireLen = 0, overflowWireLen = 0; - 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; - // 같은 port 의 wire device 중 채널 수가 큰 게 primary, 작은 게 overflow. - // (현 plugin: primary=32 wire-truncated, overflow=24) - if (vals.Length > primaryWireLen) - { - // 기존 primary 후보가 있었다면 overflow 로 강등 - if (primaryWire >= 0 && primaryWireLen > overflowWireLen) - { - overflowWire = primaryWire; - overflowWireLen = primaryWireLen; - } - primaryWire = wi; - primaryWireLen = vals.Length; - } - else if (vals.Length > overflowWireLen) - { - overflowWire = wi; - overflowWireLen = vals.Length; - } - } - - if (primaryNames != null && primaryWire >= 0) - natnetResolvedDevices.Add(new ResolvedDevice { Id = 0, WireIndex = primaryWire, ChannelNames = primaryNames }); - if (overflowNames != null && overflowWire >= 0) - natnetResolvedDevices.Add(new ResolvedDevice { Id = 0, WireIndex = overflowWire, ChannelNames = overflowNames }); - } - else - { - // Fallback: defs 기반 resolve (plugin order 파일 없거나 비어있을 때) - var defs = natnetStreamingClient.GetDeviceDefinitions(); - if (defs != null) - { - 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); - } + 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(1 + kCanonicalPrimaryCount); + primarySlice.Add("sentinelPort"); + for (int i = 0; i < kCanonicalPrimaryCount && i < kCanonicalBlendshapeNames.Length; i++) + primarySlice.Add(kCanonicalBlendshapeNames[i]); + + var overflowSlice = new List(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]; } @@ -599,9 +512,7 @@ public class StreamingleFacialReceiver : MonoBehaviour if (!anyNew) return; // iFacialMocap 와이어 포맷 합성: name-value|name-value|...= - // 채널값은 NatnetDeviceListener (wire 직접 파싱) 에서, 채널 이름은 description에서. - // **WIRE INDEX 기반 매칭** — Motive가 모든 plugin device id를 0으로 broadcasting하는 - // 버그 회피. description 순서 = wire 순서 가정 (Motive registration order). + // 채널 이름은 canonical 리스트(plugin과 공유)에서, 값은 NatnetDeviceListener wire 파싱. natnetMsgBuilder.Length = 0; bool first = true; for (int di = 0; di < natnetResolvedDevices.Count; di++) @@ -610,26 +521,6 @@ public class StreamingleFacialReceiver : MonoBehaviour float[] vals = natnetDeviceListener.GetDeviceChannelsByIndex(rd.WireIndex); if (vals == null || rd.ChannelNames == null) continue; - // 2026-05-13: 이름에 "NN_" prefix 가 박혀있으면 그게 wire 슬롯 인덱스. Motive 의 - // description 재정렬에 영향 안 받는 self-describing 방식. prefix 없으면 legacy - // (positional) 페어링 + sentinel-based valOffset fallback. - int valOffset = 0; - bool usePrefixedMode = rd.ChannelNames.Count > 0 && HasIdxPrefix(rd.ChannelNames[0]); - if (!usePrefixedMode) - { - bool namesIncludeSentinel = false; - if (rd.ChannelNames.Count > 0) - { - string n0 = rd.ChannelNames[0]; - if (n0 == "sentinelPort" || n0 == "head_posX") namesIncludeSentinel = true; - } - if (!namesIncludeSentinel && vals.Length > 0) - { - int v0 = Mathf.RoundToInt(vals[0]); - if (v0 >= 40001 && v0 <= 40010) valOffset = 1; - } - } - if (!natnetDiagLogged) { int dumpLen = Math.Min(5, vals.Length); @@ -638,52 +529,23 @@ public class StreamingleFacialReceiver : MonoBehaviour 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}] descLen={rd.ChannelNames.Count} valsLen={vals.Length} valOffset={valOffset} firstNames=[{string.Join(",", nameArr)}] firstVals=[{string.Join(",", valDump)}]"); - // 전체 이름 덤프 — plugin 등록순 vs Motive description 정합성 검증용. fallback (Motive desc) - // 일 때만 의미있음. plugin order override 활성 시 자기 자신 vs 자기 자신이라 skip. - var rawDefs = natnetStreamingClient != null ? natnetStreamingClient.GetDeviceDefinitions() : null; - if (rawDefs != null && di < rawDefs.Count) - { - var motiveDescNames = rawDefs[rd.WireIndex].ChannelNames; - if (motiveDescNames != null) - { - var indexed = new string[motiveDescNames.Count]; - for (int k = 0; k < motiveDescNames.Count; k++) indexed[k] = "[" + k + "]" + motiveDescNames[k]; - Debug.Log($"[Motive Desc Full] dev[{di}] WireIdx={rd.WireIndex}: {string.Join(",", indexed)}"); - } - } + 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)}]"); } - int loopEnd = usePrefixedMode ? rd.ChannelNames.Count : Math.Min(vals.Length - valOffset, rd.ChannelNames.Count); - for (int c = 0; c < loopEnd; c++) + // 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; - - int wireSlot; - string rawName; - if (usePrefixedMode && TryParseIdxPrefix(name, out int parsedIdx, out string parsedRaw)) - { - wireSlot = parsedIdx; - rawName = parsedRaw; - } - else - { - wireSlot = c + valOffset; - rawName = name; - } - - if (rawName[0] == 'h' && rawName.StartsWith("head_")) continue; - if (rawName[0] == 'l' && rawName.StartsWith("leftEye_")) continue; - if (rawName[0] == 'r' && rawName.StartsWith("rightEye_")) continue; - if (rawName == "sentinelPort" || rawName == "head_posX") continue; - - if (wireSlot < 0 || wireSlot >= vals.Length) continue; // wire-truncated 채널 + if (name == "sentinelPort") continue; if (!first) natnetMsgBuilder.Append('|'); - natnetMsgBuilder.Append(rawName); + natnetMsgBuilder.Append(name); natnetMsgBuilder.Append('-'); - natnetMsgBuilder.Append(vals[wireSlot].ToString("0.####", CultureInfo.InvariantCulture)); + natnetMsgBuilder.Append(vals[c].ToString("0.####", CultureInfo.InvariantCulture)); first = false; } }