Merge remote-tracking branch 'origin/MMRP-System'

This commit is contained in:
DESKTOP-S4BOTN2\user 2026-06-18 21:12:49 +09:00
commit 130351b533
11 changed files with 1604 additions and 633 deletions

Binary file not shown.

View File

@ -1,52 +0,0 @@
fileFormatVersion: 2
guid: 86f370a71a6ec2a4da168a46e45eef86
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
Any:
second:
enabled: 1
settings: {}
- first:
Editor: Editor
second:
enabled: 0
settings:
CPU: x86
DefaultValueInitialized: true
- first:
Standalone: Linux64
second:
enabled: 0
settings:
CPU: None
- first:
Standalone: OSXUniversal
second:
enabled: 0
settings:
CPU: x86
- first:
Standalone: Win
second:
enabled: 1
settings:
CPU: x86
- first:
Standalone: Win64
second:
enabled: 0
settings:
CPU: None
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 90a37dc4ae5b35a45876059d687031bc

Binary file not shown.

View File

@ -22,30 +22,25 @@ public class OptitrackStreamingClientEditor : Editor
client = (OptitrackStreamingClient)target;
var root = new VisualElement();
// Load stylesheets
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
if (commonUss != null) root.styleSheets.Add(commonUss);
var uss = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
if (uss != null) root.styleSheets.Add(uss);
// Load UXML
var uxml = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(UxmlPath);
if (uxml != null) uxml.CloneTree(root);
// Cache references
statusDot = root.Q("statusDot");
statusText = root.Q<Label>("statusText");
runtimeOffline = root.Q("runtimeOffline");
runtimeOnline = root.Q("runtimeOnline");
runtimeInfo = root.Q("runtimeInfo");
// Reconnect button
var reconnectBtn = root.Q<Button>("reconnectBtn");
if (reconnectBtn != null)
reconnectBtn.clicked += () => { if (Application.isPlaying) client.Reconnect(); };
// Play mode polling
root.schedule.Execute(UpdatePlayModeState).Every(300);
return root;
@ -57,7 +52,6 @@ public class OptitrackStreamingClientEditor : Editor
bool isPlaying = Application.isPlaying;
// Toggle runtime sections
if (isPlaying)
{
if (!runtimeOnline.ClassListContains("opti-runtime-online--visible"))
@ -73,7 +67,6 @@ public class OptitrackStreamingClientEditor : Editor
runtimeOffline.RemoveFromClassList("opti-runtime-offline--hidden");
}
// Update status badge
if (isPlaying)
{
bool connected = client.IsConnected();
@ -97,7 +90,6 @@ public class OptitrackStreamingClientEditor : Editor
{
if (statusDot == null || statusText == null) return;
// Clear all states
statusDot.RemoveFromClassList("opti-status-dot--connected");
statusDot.RemoveFromClassList("opti-status-dot--disconnected");
statusText.RemoveFromClassList("opti-status-text--connected");
@ -125,6 +117,8 @@ public class OptitrackStreamingClientEditor : Editor
? client.LocalAddress
: client.ResolvedLocalAddress);
AddInfoRow(runtimeInfo, "Connection Type", client.ConnectionType.ToString());
if (client.EnableReplayPriority)
AddInfoRow(runtimeInfo, "Replay / MMRP", client.ReplayServerAddress);
if (!string.IsNullOrEmpty(client.ServerNatNetVersion))
AddInfoRow(runtimeInfo, "Server NatNet", client.ServerNatNetVersion);

View File

@ -1,4 +1,4 @@
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements">
<ui:UXML xmlns:ui="UnityEngine.UIElements" xmlns:uie="UnityEditor.UIElements">
<!-- Title Bar -->
<ui:VisualElement name="titleBar" class="opti-title-bar">
@ -12,11 +12,19 @@
<!-- Connection Settings -->
<ui:VisualElement class="section">
<ui:Foldout text="Connection Settings" value="true" class="section-foldout">
<uie:PropertyField binding-path="ServerAddress" label="Server Address"/>
<uie:PropertyField binding-path="ServerAddress" label="Live Motive IP"/>
<uie:PropertyField binding-path="EnableReplayPriority" label="Use MMRP Replay Priority"/>
<uie:PropertyField binding-path="ReplayServerAddress" label="MMRP Replay IP"/>
<uie:PropertyField binding-path="ConnectionType" label="Connection Type"/>
<uie:PropertyField binding-path="SkeletonCoordinates" label="Skeleton Coordinates"/>
<uie:PropertyField binding-path="TMarkersetCoordinates" label="TMarkerset Coordinates"/>
<uie:PropertyField binding-path="BoneNamingConvention" label="Bone Naming Convention"/>
<ui:Foldout text="Advanced Endpoint Override" value="false" class="section-foldout">
<uie:PropertyField binding-path="CommandPort" label="Command Port"/>
<uie:PropertyField binding-path="DataPort" label="Data Port"/>
<uie:PropertyField binding-path="MulticastAddress" label="Multicast Address"/>
<uie:PropertyField binding-path="ReplayFreshnessSeconds" label="Replay Freshness Seconds"/>
</ui:Foldout>
</ui:Foldout>
</ui:VisualElement>
@ -28,7 +36,7 @@
<uie:PropertyField binding-path="DrawCameras" label="Draw Cameras"/>
<uie:PropertyField binding-path="DrawForcePlates" label="Draw Force Plates"/>
<uie:PropertyField binding-path="ReceiveDevices" label="Receive Devices"/>
<ui:HelpBox message-type="Info" text="iFacialMocap 등 analog device 데이터 수신. 매 프레임 sFrameOfMocapData 마샬링 발생(~200KB) — 페이셜 안 쓰면 OFF."/>
<ui:HelpBox message-type="Info" text="Receives analog device data such as iFacialMocap face streams. Turn this off if the scene does not use NatNet device data."/>
<uie:PropertyField binding-path="RecordOnPlay" label="Record On Play"/>
<uie:PropertyField binding-path="SkipDataDescriptions" label="Skip Data Descriptions"/>
<uie:PropertyField binding-path="AutoReconnect" label="Auto Reconnect"/>
@ -38,15 +46,7 @@
<!-- Mirror Mode -->
<ui:VisualElement class="section">
<ui:Foldout text="Mirror Mode" value="true" class="section-foldout">
<uie:PropertyField binding-path="MirrorMode" label="Mirror Mode (좌우 반전)"/>
</ui:Foldout>
</ui:VisualElement>
<!-- Skeleton Frame Filter -->
<ui:VisualElement class="section">
<ui:Foldout text="Skeleton Frame Filter" value="true" class="section-foldout">
<uie:PropertyField binding-path="EnableSkeletonFrameFilter" label="Enable Filter (엄격)"/>
<ui:HelpBox message-type="Info" text="ON: 본 하나라도 트래킹 실패/손상되면 그 프레임 전체를 폐기 → 떨림은 줄지만 라이브에서 마커 가림 시 액터가 얼어붙을 수 있음.&#10;OFF(권장·라이브): 정상 본만 갱신, 미트래킹·손상 본은 직전 포즈 유지 → 모션이 끊기지 않음."/>
<uie:PropertyField binding-path="MirrorMode" label="Mirror Mode"/>
</ui:Foldout>
</ui:VisualElement>
@ -62,7 +62,7 @@
<ui:VisualElement name="runtimeSection" class="section">
<ui:Foldout text="Runtime Controls" value="true" class="section-foldout">
<ui:VisualElement name="runtimeOffline" class="opti-runtime-offline">
<ui:HelpBox message-type="Info" text="재접속 기능은 플레이 모드에서만 사용할 수 있습니다."/>
<ui:HelpBox message-type="Info" text="Runtime controls are available in Play Mode."/>
</ui:VisualElement>
<ui:VisualElement name="runtimeOnline" class="opti-runtime-online">
<ui:Button name="reconnectBtn" text="OptiTrack Reconnect" class="opti-reconnect-btn"/>

View File

@ -49,6 +49,8 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
private OptitrackSkeletonDefinition m_skeletonDef;
private string previousSkeletonName;
private OptitrackStreamingClient m_boundStreamingClient;
[HideInInspector]
public bool isSkeletonFound = false;
@ -89,6 +91,23 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
HumanBodyBones.RightToes,
};
private static readonly string[] s_fingerBoneNameTokens =
{
"Thumb",
"Index",
"Middle",
"Ring",
"Pinky",
"Little",
"Finger",
};
private const bool k_EnableResetPoseFrameFilter = true;
private const bool k_ResetPoseComparePositions = false;
private const float k_ResetPosePositionTolerance = 0.025f;
private const float k_ResetPoseRotationToleranceDegrees = 5f;
private const int k_ResetPoseMinimumBodyBones = 8;
// 스파인/넥 체인 Transform 캐시 (GetSpineChainTransforms 매 호출 List 할당 방지)
private List<Transform> m_spineChainCache = new List<Transform>();
@ -261,15 +280,45 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
{
if (StreamingClient != null)
{
StreamingClient.RegisterSkeleton(this, this.SkeletonAssetName);
previousSkeletonName = SkeletonAssetName;
RebindStreamingClient();
}
// 코루틴이 이미 돌고 있지 않으면 시작
if (m_checkCoroutine == null)
m_checkCoroutine = StartCoroutine(CheckSkeletonConnectionPeriodically());
}
public void SetStreamingClient(OptitrackStreamingClient client)
{
if (StreamingClient == client && m_boundStreamingClient == client)
return;
StreamingClient = client;
RebindStreamingClient();
}
private void RebindStreamingClient()
{
m_boundStreamingClient = StreamingClient;
m_skeletonDef = null;
m_boneIdToMappingIndex.Clear();
m_snapshotPositions.Clear();
m_snapshotOrientations.Clear();
ClearInterpolationBuffers();
m_hasLastFrameTimestamp = false;
isSkeletonFound = false;
if (StreamingClient == null)
return;
StreamingClient.RegisterSkeleton(this, SkeletonAssetName);
previousSkeletonName = SkeletonAssetName;
m_skeletonDef = StreamingClient.GetSkeletonDefinitionByName(SkeletonAssetName);
if (m_skeletonDef != null)
{
RebuildBoneIdMapping();
isSkeletonFound = true;
}
}
private bool m_lastMirrorMode = false;
void Update()
@ -280,6 +329,12 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
return;
}
if (StreamingClient != m_boundStreamingClient)
{
RebindStreamingClient();
return;
}
// MirrorMode 변경 감지 → 보간 상태 리셋 (불연속 튐 방지)
bool currentMirrorMode = StreamingClient != null && StreamingClient.MirrorMode;
if (currentMirrorMode != m_lastMirrorMode)
@ -291,12 +346,7 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
// 스켈레톤 이름 변경 감지
if (previousSkeletonName != SkeletonAssetName)
{
StreamingClient.RegisterSkeleton(this, SkeletonAssetName);
m_skeletonDef = StreamingClient.GetSkeletonDefinitionByName(SkeletonAssetName);
previousSkeletonName = SkeletonAssetName;
if (m_skeletonDef != null)
RebuildBoneIdMapping();
ClearInterpolationBuffers();
RebindStreamingClient();
return;
}
@ -308,6 +358,9 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
return;
// ── NatNet 실제 프레임 간격 계산 (하드웨어 타이머 — 렌더 프레임 등락과 완전 독립) ──
if (ShouldSkipResetPoseFrame())
return;
if (m_hasLastFrameTimestamp && frameTs.m_ticks != m_lastFrameTimestamp.m_ticks)
{
float measuredDt = frameTs.SecondsSince(m_lastFrameTimestamp);
@ -479,8 +532,7 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
m_hipBoneId = -1;
foreach (var bone in m_skeletonDef.Bones)
{
string boneName = bone.Name;
string optiName = boneName.Contains("_") ? boneName.Substring(boneName.IndexOf('_') + 1) : boneName;
string optiName = ExtractOptiTrackBoneName(bone.Name, nameToIdx);
if (nameToIdx.TryGetValue(optiName, out int idx))
{
m_boneIdToMappingIndex[bone.Id] = idx;
@ -492,6 +544,41 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
Debug.Log($"[OptiTrack] 본 ID 매핑 완료: {matchCount}/{m_skeletonDef.Bones.Count} 매칭");
}
private static string ExtractOptiTrackBoneName(string streamedBoneName, Dictionary<string, int> knownOptiNames)
{
if (string.IsNullOrEmpty(streamedBoneName))
return streamedBoneName;
if (knownOptiNames.ContainsKey(streamedBoneName))
return streamedBoneName;
int firstSep = streamedBoneName.IndexOf('_');
if (firstSep >= 0)
{
string afterFirst = streamedBoneName.Substring(firstSep + 1);
if (knownOptiNames.ContainsKey(afterFirst))
return afterFirst;
string beforeFirst = streamedBoneName.Substring(0, firstSep);
if (knownOptiNames.ContainsKey(beforeFirst))
return beforeFirst;
}
int lastSep = streamedBoneName.LastIndexOf('_');
if (lastSep > 0 && lastSep != firstSep)
{
string beforeLast = streamedBoneName.Substring(0, lastSep);
if (knownOptiNames.ContainsKey(beforeLast))
return beforeLast;
string afterLast = streamedBoneName.Substring(lastSep + 1);
if (knownOptiNames.ContainsKey(afterLast))
return afterLast;
}
return streamedBoneName;
}
private void InitializeStreamingClient()
{
if (StreamingClient == null)
@ -580,6 +667,66 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
/// 두 OptiTrack 프레임 사이를 시간 기반으로 보간합니다.
/// m_snapshotPositions/Orientations를 보간된 결과로 덮어씁니다.
/// </summary>
private bool ShouldSkipResetPoseFrame()
{
if (!k_EnableResetPoseFrameFilter || !m_isRestPoseCached || m_skeletonDef == null)
return false;
int testedBodyBoneCount = 0;
float positionTolerance = Mathf.Max(0.0f, k_ResetPosePositionTolerance);
float rotationTolerance = Mathf.Max(0.0f, k_ResetPoseRotationToleranceDegrees);
for (int i = 0; i < m_skeletonDef.Bones.Count; ++i)
{
OptitrackSkeletonDefinition.BoneDefinition bone = m_skeletonDef.Bones[i];
if (!m_boneIdToMappingIndex.TryGetValue(bone.Id, out int mappingIndex))
continue;
OptiTrackBoneMapping mapping = boneMappings[mappingIndex];
if (!mapping.isMapped)
continue;
if (IsFingerBoneName(mapping.optiTrackBoneName))
continue;
if (!m_restLocalRotations.TryGetValue(mapping.optiTrackBoneName, out Quaternion restRot) ||
!m_snapshotOrientations.TryGetValue(bone.Id, out Quaternion frameRot))
{
return false;
}
if (Quaternion.Angle(restRot, frameRot) > rotationTolerance)
return false;
if (k_ResetPoseComparePositions &&
mapping.applyPosition &&
m_restLocalPositions.TryGetValue(mapping.optiTrackBoneName, out Vector3 restPos) &&
m_snapshotPositions.TryGetValue(bone.Id, out Vector3 framePos) &&
Vector3.Distance(restPos, framePos) > positionTolerance)
{
return false;
}
++testedBodyBoneCount;
}
return testedBodyBoneCount >= k_ResetPoseMinimumBodyBones;
}
private static bool IsFingerBoneName(string boneName)
{
if (string.IsNullOrEmpty(boneName))
return false;
for (int i = 0; i < s_fingerBoneNameTokens.Length; ++i)
{
if (boneName.IndexOf(s_fingerBoneNameTokens[i], StringComparison.InvariantCultureIgnoreCase) >= 0)
return true;
}
return false;
}
private void InterpolateSnapshots(OptitrackHiResTimer.Timestamp frameTs)
{
// 새 프레임 감지 (타임스탬프가 변경되었으면 새 OptiTrack 프레임이 도착한 것)

View File

@ -1,281 +1,17 @@
//======================================================================================================
// NatnetDeviceListener
//
// Joins NatNet's multicast data stream and parses the WIRE format directly to extract
// analog device frame data. Bypasses OptitrackStreamingClient's wrapper struct (which
// can't read device data correctly because the C# struct layout doesn't match what
// the native NatNet DLL fills into the pFrame buffer for plugin-registered devices).
//
// Verified: Motive DOES broadcast plugin device data via NatNet (both live and playback);
// the existing wrapper just can't read it. This listener confirms by parsing wire bytes
// directly.
//
// Wire format (NatNet 4.x) per section: int32 count, int32 sectionSize, then `sectionSize`
// bytes of section payload. We walk past markerSets, otherMarkers, rigidBodies, skeletons,
// assets, labeledMarkers, forcePlates, then read devices.
//======================================================================================================
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using UnityEngine;
/// <summary>
/// Legacy compatibility component.
/// NatNet device channels are now parsed directly by OptitrackStreamingClient.
/// This stub keeps old scenes/prefabs from producing missing-script references.
/// </summary>
public class NatnetDeviceListener : MonoBehaviour
{
// ----------------------- Singleton -----------------------
// Only one NatnetDeviceListener is needed per scene (multicast — single subscription
// serves all consumers). Use NatnetDeviceListener.Instance to access; auto-creates
// on first access if not already in scene.
[Header("Legacy")]
[Tooltip("Deprecated. OptitrackStreamingClient now parses NatNet device channels directly.")]
public bool disabledByUnifiedOptitrackClient = true;
private static NatnetDeviceListener _instance;
public static NatnetDeviceListener Instance
{
get
{
if (_instance != null) return _instance;
_instance = FindObjectOfType<NatnetDeviceListener>();
if (_instance == null)
{
var go = new GameObject("NatnetDeviceListener");
_instance = go.AddComponent<NatnetDeviceListener>();
}
return _instance;
}
}
void Awake()
{
if (_instance != null && _instance != this)
{
Debug.LogWarning($"[NatnetDeviceListener] Duplicate instance on '{gameObject.name}' — destroying. Use NatnetDeviceListener.Instance.", gameObject);
Destroy(this);
return;
}
_instance = this;
}
[Header("NatNet Multicast Settings")]
[Tooltip("Default Motive multicast group = 239.255.42.99")]
[Header("Old Settings (unused)")]
public string multicastGroup = "239.255.42.99";
[Tooltip("Default Motive data port = 1511")]
public int dataPort = 1511;
[Header("Diagnostics (runtime-only)")]
[SerializeField] private int _packetsReceived;
[SerializeField] private int _framesWithDevices;
[SerializeField] private int _activeDeviceCount;
[SerializeField] private int _lastFrameNumber;
[SerializeField] private string _lastError = "";
// Per-device latest channel values, keyed by WIRE INDEX (0, 1, 2, ...) NOT
// device ID. Motive empirically assigns ID=0 to all plugin devices, so ID-based
// keying causes collision. Wire index = position in NatNet frame's devices array.
// Description order assumed to match wire order (Motive sends in registration order).
private readonly Dictionary<int, float[]> _deviceChannelsByIndex = new Dictionary<int, float[]>();
private readonly Dictionary<int, ulong> _deviceFrameSeqByIndex = new Dictionary<int, ulong>();
// Also track id for diagnostics.
private readonly Dictionary<int, int> _deviceIdByIndex = new Dictionary<int, int>();
private int _wireDeviceCount = 0;
private readonly object _stateLock = new object();
private UdpClient _udp;
private Thread _thread;
private volatile bool _running;
void Start() { StartListener(); }
void OnDisable() { StopListener(); }
void OnApplicationQuit() { StopListener(); }
void OnDestroy()
{
if (_instance == this) _instance = null;
StopListener();
}
void StartListener()
{
if (_running) return;
try
{
_udp = new UdpClient();
_udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
_udp.Client.Bind(new IPEndPoint(IPAddress.Any, dataPort));
// INADDR_ANY interface — let OS pick. Matches the working Python sniffer pattern.
_udp.JoinMulticastGroup(IPAddress.Parse(multicastGroup));
_udp.Client.ReceiveTimeout = 1000;
}
catch (Exception e)
{
_lastError = $"bind/join failed: {e.Message}";
Debug.LogError($"[NatnetDeviceListener] {_lastError}", this);
return;
}
_running = true;
_thread = new Thread(ThreadLoop) { IsBackground = true, Name = "NatnetDeviceListener" };
_thread.Start();
}
void StopListener()
{
_running = false;
try { _udp?.Close(); } catch { }
_udp = null;
if (_thread != null && _thread.IsAlive)
{
_thread.Join(500);
_thread = null;
}
}
void ThreadLoop()
{
var ep = new IPEndPoint(IPAddress.Any, 0);
while (_running)
{
try
{
byte[] data = _udp.Receive(ref ep);
Interlocked.Increment(ref _packetsReceived);
ParsePacket(data);
}
catch (SocketException se)
{
if (!_running) break;
if (se.SocketErrorCode == SocketError.TimedOut) continue;
_lastError = $"recv: {se.Message}";
}
catch (ObjectDisposedException) { break; }
catch (Exception e)
{
_lastError = $"loop: {e.Message}";
}
}
}
// Walks NatNet 4.x frame data sections (each prefixed with count + sectionSize),
// jumps past unknown sections by raw byte count, extracts device data.
void ParsePacket(byte[] data)
{
if (data.Length < 12) return;
ushort msgId = BitConverter.ToUInt16(data, 0);
if (msgId != 7) return; // NAT_FRAMEOFDATA
int o = 4;
int frameNumber = BitConverter.ToInt32(data, o); o += 4;
// 7 variable-length sections before Devices, each as [int32 count][int32 sectionSize][bytes...]
// Order: markerSets, otherMarkers, rigidBodies, skeletons, assets, labeledMarkers, forcePlates.
try
{
for (int s = 0; s < 7; s++)
{
if (o + 8 > data.Length) return;
/* count */ BitConverter.ToInt32(data, o); o += 4;
int sz = BitConverter.ToInt32(data, o); o += 4;
o += sz;
if (o > data.Length) return;
}
// Devices section
if (o + 8 > data.Length) return;
int devCnt = BitConverter.ToInt32(data, o); o += 4;
int devSz = BitConverter.ToInt32(data, o); o += 4;
int devEnd = o + devSz;
if (devEnd > data.Length || devCnt < 0 || devCnt > 64) return;
// Parse each device — store by WIRE INDEX, not device ID.
// GC OPT: reuse pre-allocated float[] per wire index. Only realloc when
// channel count changes (rare — only on device re-registration).
int activeCount = 0;
for (int d = 0; d < devCnt; d++)
{
if (o + 8 > devEnd) break;
int devId = BitConverter.ToInt32(data, o); o += 4;
int nCh = BitConverter.ToInt32(data, o); o += 4;
if (nCh < 0 || nCh > 1024) break;
// Reuse existing buffer if size matches; else allocate new (GC opt).
// Stale detection moved to plugin-side (Motive Devices Panel state).
float[] vals;
_deviceChannelsByIndex.TryGetValue(d, out vals);
if (vals == null || vals.Length != nCh)
vals = new float[nCh];
bool ok = true;
for (int c = 0; c < nCh; c++)
{
if (o + 4 > devEnd) { ok = false; break; }
int nFr = BitConverter.ToInt32(data, o); o += 4;
vals[c] = (nFr > 0 && o + 4 <= devEnd) ? BitConverter.ToSingle(data, o) : 0f;
o += nFr * 4;
if (o > devEnd) { ok = false; break; }
}
if (!ok) break;
lock (_stateLock)
{
_deviceChannelsByIndex[d] = vals;
_deviceIdByIndex[d] = devId;
_deviceFrameSeqByIndex[d] = _deviceFrameSeqByIndex.TryGetValue(d, out var s2) ? s2 + 1 : 1;
}
activeCount++;
}
lock (_stateLock) { _wireDeviceCount = activeCount; }
_lastFrameNumber = frameNumber;
_activeDeviceCount = activeCount;
if (activeCount > 0) Interlocked.Increment(ref _framesWithDevices);
}
catch (Exception e)
{
_lastError = $"parse: {e.Message}";
}
}
// ----------------------- Public API -----------------------
/// <summary>Returns the latest channel values for a device by WIRE INDEX (0,1,2,...).
/// Wire index = position in NatNet frame's devices array, in registration/broadcast order.
/// Returns the internal array DIRECTLY (no Clone, no GC alloc). Caller must finish reading
/// in one Update tick — values may be overwritten by the NatNet thread otherwise. Acceptable
/// for our use (worst case = 1-frame torn read).</summary>
public float[] GetDeviceChannelsByIndex(int wireIndex)
{
lock (_stateLock)
{
return _deviceChannelsByIndex.TryGetValue(wireIndex, out var arr) ? arr : null;
}
}
/// <summary>Snapshot version: copies values into caller-provided buffer under lock.
/// Returns number of values copied (or 0 if device not found).
/// Use this if you need exact frame consistency without race window.</summary>
public int CopyDeviceChannelsByIndex(int wireIndex, float[] dest)
{
if (dest == null) return 0;
lock (_stateLock)
{
if (!_deviceChannelsByIndex.TryGetValue(wireIndex, out var arr) || arr == null) return 0;
int n = Math.Min(arr.Length, dest.Length);
Array.Copy(arr, dest, n);
return n;
}
}
public ulong GetDeviceFrameSeqByIndex(int wireIndex)
{
lock (_stateLock)
{
return _deviceFrameSeqByIndex.TryGetValue(wireIndex, out var n) ? n : 0UL;
}
}
public int GetWireDeviceCount()
{
lock (_stateLock) { return _wireDeviceCount; }
}
}

View File

@ -36,8 +36,6 @@ public class StreamingleFacialReceiver : MonoBehaviour
[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_";
@ -46,6 +44,24 @@ public class StreamingleFacialReceiver : MonoBehaviour
/// </summary>
public string EffectiveNatnetDeviceBaseName => natnetDeviceNamePrefix + LOCAL_PORT;
public void SetNatNetSource(OptitrackStreamingClient client)
{
if (natnetStreamingClient == client) return;
natnetStreamingClient = client;
ResetNatNetDeviceResolution();
}
private void ResetNatNetDeviceResolution()
{
natnetResolvedDevices.Clear();
natnetLastResolveAttempt = -10f;
natnetLastResolvedFor = "";
Array.Clear(natnetLastSeenSeq, 0, natnetLastSeenSeq.Length);
natnetDiagLogged = false;
messageString = "";
lastProcessedMessage = "";
}
// NatNet 모드: device name → (device id, channel names) 캐시.
// OptitrackStreamingClient의 description으로부터 1회 lookup.
private struct ResolvedDevice
@ -54,7 +70,7 @@ public class StreamingleFacialReceiver : MonoBehaviour
// NatNet 프레임 전체에서의 위치 (description 순서 = wire 순서 가정).
// listener는 wire 글로벌 인덱스로 키잉되므로 로컬 리스트 인덱스를 넘기면 안 됨
// — 다른 포트의 device 데이터를 잘못 가져오게 됨.
public int WireIndex;
public string DeviceName;
public List<string> ChannelNames;
}
private List<ResolvedDevice> natnetResolvedDevices = new List<ResolvedDevice>(2);
@ -252,8 +268,6 @@ public class StreamingleFacialReceiver : MonoBehaviour
Debug.LogWarning("[Streamingle] OptitrackStreamingClient.ReceiveDevices 가 꺼져있음 — 켜야 device description이 들어옴.", this);
}
if (natnetDeviceListener == null)
natnetDeviceListener = NatnetDeviceListener.Instance;
}
else if (useSharedPort)
{
@ -438,11 +452,73 @@ public class StreamingleFacialReceiver : MonoBehaviour
"tongueOut", "trackingStatus",
};
private static OptitrackDeviceState FindNatNetDeviceState(List<OptitrackDeviceState> states, int id, string name)
{
if (states == null) return null;
for (int i = 0; i < states.Count; i++)
{
var state = states[i];
if (state == null) continue;
if (state.Id == id) return state;
if (!string.IsNullOrEmpty(name) && state.Name == name) return state;
}
return null;
}
private static bool TryBuildNatNetDeviceValueArray(OptitrackDeviceState state, out float[] values)
{
values = null;
if (state == null || state.ChannelValues == null || state.ChannelValues.Count == 0)
return false;
int maxChannelIndex = -1;
foreach (var key in state.ChannelValues.Keys)
{
int index;
if (TryParseNatNetChannelIndex(key, out index))
maxChannelIndex = Mathf.Max(maxChannelIndex, index);
}
if (maxChannelIndex >= 0)
{
values = new float[maxChannelIndex + 1];
foreach (var kvp in state.ChannelValues)
{
int index;
if (TryParseNatNetChannelIndex(kvp.Key, out index) && index >= 0 && index < values.Length)
values[index] = kvp.Value;
}
return true;
}
if (!state.ChannelValues.TryGetValue("sentinelPort", out float sentinel))
return false;
values = new float[1 + kCanonicalBlendshapeNames.Length];
values[0] = sentinel;
for (int i = 0; i < kCanonicalBlendshapeNames.Length; i++)
{
float value;
if (state.ChannelValues.TryGetValue(kCanonicalBlendshapeNames[i], out value))
values[i + 1] = value;
}
return true;
}
private static bool TryParseNatNetChannelIndex(string key, out int index)
{
index = -1;
if (string.IsNullOrEmpty(key) || !key.StartsWith("Channel", StringComparison.Ordinal))
return false;
return int.TryParse(key.Substring("Channel".Length), NumberStyles.Integer, CultureInfo.InvariantCulture, out index);
}
void PollNatNetDevice()
{
if (natnetStreamingClient == null || natnetDeviceListener == null) return;
if (natnetStreamingClient == null) return;
string baseName = EffectiveNatnetDeviceBaseName;
var deviceStates = natnetStreamingClient.GetLatestDeviceStates();
// 포트 변경 또는 정의 미발견 시 재해석. OptitrackStreamingClient의 device descriptions
// 에서 우리 base name과 매칭되는 device id + channel names를 캐시.
@ -462,17 +538,18 @@ public class StreamingleFacialReceiver : MonoBehaviour
// 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++)
var wireCandidates = new List<(OptitrackDeviceState state, float[] vals)>();
for (int si = 0; si < deviceStates.Count; si++)
{
float[] vals = natnetDeviceListener.GetDeviceChannelsByIndex(wi);
float[] vals;
if (!TryBuildNatNetDeviceValueArray(deviceStates[si], out vals))
continue;
if (vals == null || vals.Length < 1) continue;
int sentinel = Mathf.RoundToInt(vals[0]);
if (sentinel != port) continue;
wireCandidates.Add((wi, vals.Length));
wireCandidates.Add((deviceStates[si], vals));
}
wireCandidates.Sort((a, b) => b.len.CompareTo(a.len));
wireCandidates.Sort((a, b) => b.vals.Length.CompareTo(a.vals.Length));
// canonical 을 primary slice + overflow slice 로 분리
var primarySlice = new List<string>(1 + kCanonicalPrimaryCount);
@ -487,9 +564,9 @@ public class StreamingleFacialReceiver : MonoBehaviour
// 0: primary (큰 채널 수), 1: overflow (작은 채널 수)
if (wireCandidates.Count > 0)
natnetResolvedDevices.Add(new ResolvedDevice { Id = 0, WireIndex = wireCandidates[0].wi, ChannelNames = primarySlice });
natnetResolvedDevices.Add(new ResolvedDevice { Id = wireCandidates[0].state.Id, DeviceName = wireCandidates[0].state.Name, ChannelNames = primarySlice });
if (wireCandidates.Count > 1)
natnetResolvedDevices.Add(new ResolvedDevice { Id = 0, WireIndex = wireCandidates[1].wi, ChannelNames = overflowSlice });
natnetResolvedDevices.Add(new ResolvedDevice { Id = wireCandidates[1].state.Id, DeviceName = wireCandidates[1].state.Name, ChannelNames = overflowSlice });
if (natnetLastSeenSeq.Length < natnetResolvedDevices.Count)
natnetLastSeenSeq = new ulong[natnetResolvedDevices.Count];
}
@ -501,8 +578,8 @@ public class StreamingleFacialReceiver : MonoBehaviour
bool anyNew = false;
for (int di = 0; di < natnetResolvedDevices.Count; di++)
{
int wi = natnetResolvedDevices[di].WireIndex;
ulong seq = natnetDeviceListener.GetDeviceFrameSeqByIndex(wi);
OptitrackDeviceState state = FindNatNetDeviceState(deviceStates, natnetResolvedDevices[di].Id, natnetResolvedDevices[di].DeviceName);
ulong seq = state != null ? (ulong)(uint)state.FrameNumber : 0;
if (di < natnetLastSeenSeq.Length && seq != natnetLastSeenSeq[di])
{
natnetLastSeenSeq[di] = seq;
@ -518,7 +595,10 @@ public class StreamingleFacialReceiver : MonoBehaviour
for (int di = 0; di < natnetResolvedDevices.Count; di++)
{
var rd = natnetResolvedDevices[di];
float[] vals = natnetDeviceListener.GetDeviceChannelsByIndex(rd.WireIndex);
OptitrackDeviceState state = FindNatNetDeviceState(deviceStates, rd.Id, rd.DeviceName);
float[] vals;
if (!TryBuildNatNetDeviceValueArray(state, out vals))
continue;
if (vals == null || rd.ChannelNames == null) continue;
if (!natnetDiagLogged)
@ -529,7 +609,7 @@ 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}] WireIdx={rd.WireIndex} canonicalLen={rd.ChannelNames.Count} wireLen={vals.Length} firstNames=[{string.Join(",", nameArr)}] firstVals=[{string.Join(",", valDump)}]");
Debug.Log($"[NatNet Align Diag] dev[{di}] Device={rd.DeviceName} Id={rd.Id} canonicalLen={rd.ChannelNames.Count} wireLen={vals.Length} firstNames=[{string.Join(",", nameArr)}] firstVals=[{string.Join(",", valDump)}]");
}
// Canonical 매핑: rd.ChannelNames 는 [sentinelPort, canonical[a], canonical[a+1], ...]