Merge remote-tracking branch 'origin/MMRP-System'
This commit is contained in:
commit
130351b533
BIN
Assets/External/OptiTrack Unity Plugin/OptiTrack/Plugins/x86/NatNetLib.dll
(Stored with Git LFS)
vendored
BIN
Assets/External/OptiTrack Unity Plugin/OptiTrack/Plugins/x86/NatNetLib.dll
(Stored with Git LFS)
vendored
Binary file not shown.
@ -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:
|
||||
BIN
Assets/External/OptiTrack Unity Plugin/OptiTrack/Plugins/x86_64/NatNetLib.dll
(Stored with Git LFS)
vendored
BIN
Assets/External/OptiTrack Unity Plugin/OptiTrack/Plugins/x86_64/NatNetLib.dll
(Stored with Git LFS)
vendored
Binary file not shown.
@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 90a37dc4ae5b35a45876059d687031bc
|
||||
BIN
Assets/External/OptiTrack Unity Plugin/OptiTrack/Prefabs/Client - OptiTrack.prefab
(Stored with Git LFS)
vendored
BIN
Assets/External/OptiTrack Unity Plugin/OptiTrack/Prefabs/Client - OptiTrack.prefab
(Stored with Git LFS)
vendored
Binary file not shown.
@ -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);
|
||||
|
||||
@ -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: 본 하나라도 트래킹 실패/손상되면 그 프레임 전체를 폐기 → 떨림은 줄지만 라이브에서 마커 가림 시 액터가 얼어붙을 수 있음. 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"/>
|
||||
|
||||
@ -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 프레임이 도착한 것)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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], ...]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user