Fix : 페이셜 버그 패치

This commit is contained in:
qsxft258@gmail.com 2026-05-13 01:50:09 +09:00
parent 835e641b79
commit edf6896d65

View File

@ -410,84 +410,33 @@ public class StreamingleFacialReceiver : MonoBehaviour
}
}
// Plugin 이 디스크에 쓴 등록 순서 파일을 파싱. Motive 의 description 재정렬 우회용.
// 파일 포맷:
// === CHANNEL DIAG '<deviceName>' 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<string, List<string>> _cachedPluginOrder = null;
private System.DateTime _cachedPluginOrderTime = System.DateTime.MinValue;
Dictionary<string, List<string>> 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, List<string>>();
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<string>(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<string, List<string>> 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_<name>" -> idx=NN, raw=<name>. 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<string> primaryNames = pluginOrderByName.TryGetValue(primaryDevName, out var pn) ? pn : null;
List<string> 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<ResolvedDevice> secondaries = new List<ResolvedDevice>();
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<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];
}
@ -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;
}
}