diff --git a/Assets/External/OptiTrack Unity Plugin/OptiTrack/Scripts/OptitrackSkeletonAnimator_Mingle.cs b/Assets/External/OptiTrack Unity Plugin/OptiTrack/Scripts/OptitrackSkeletonAnimator_Mingle.cs
index 05183b465..21ae18214 100644
--- a/Assets/External/OptiTrack Unity Plugin/OptiTrack/Scripts/OptitrackSkeletonAnimator_Mingle.cs
+++ b/Assets/External/OptiTrack Unity Plugin/OptiTrack/Scripts/OptitrackSkeletonAnimator_Mingle.cs
@@ -37,7 +37,7 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
[Header("본 1€ 필터 (속도 적응형 저역통과)")]
[HideInInspector]
- public FilterStrength filterStrength = FilterStrength.Medium;
+ public FilterStrength filterStrength = FilterStrength.Off;
[Header("어깨 증폭")]
[Tooltip("어깨 회전을 증폭합니다. 1 = 원본, 2 = 2배. 하위 체인(상완)은 자동 역보정되어 손 위치가 유지됩니다.")]
@@ -60,6 +60,14 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
[HideInInspector] public bool enableBoneFilter = true;
+ [Header("프레임 보간")]
+ [Tooltip("OptiTrack 프레임 사이를 보간하여 Unity 가변 프레임에서도 부드러운 모션을 생성합니다. 약 1프레임(~8ms @120fps) 지연이 추가됩니다.")]
+ public bool enableInterpolation = true;
+
+ [Tooltip("보간 지연 시간(초). 0이면 자동(OptiTrack 프레임 간격 사용). 높을수록 부드럽지만 지연 증가.")]
+ [Range(0f, 0.05f)]
+ public float interpolationDelay = 0f;
+
///
/// 런타임에서 필터 강도를 변경합니다. StreamDeck/핫키 등에서 호출.
///
@@ -148,6 +156,16 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
}
private Dictionary m_filterStates = new Dictionary();
+ // 프레임 보간용 이중 버퍼 (prev/curr OptiTrack 프레임)
+ private Dictionary m_interpPrevPos = new Dictionary();
+ private Dictionary m_interpPrevOri = new Dictionary();
+ private Dictionary m_interpCurrPos = new Dictionary();
+ private Dictionary m_interpCurrOri = new Dictionary();
+ private OptitrackHiResTimer.Timestamp m_interpPrevTs;
+ private OptitrackHiResTimer.Timestamp m_interpCurrTs;
+ private bool m_interpHasCurr = false;
+ private bool m_interpReady = false;
+
// OptiTrack 본 이름 → FBX 노드 접미사 기본 매핑
public static readonly Dictionary DefaultOptiToFbxSuffix = new Dictionary
{
@@ -319,11 +337,12 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
// 필터 활성화 상태 동기화 (프리셋 값은 SetFilterStrength()에서만 적용)
enableBoneFilter = filterStrength != FilterStrength.Off;
- // MirrorMode 변경 감지 → 필터 상태 리셋 (불연속 튐 방지)
+ // MirrorMode 변경 감지 → 필터/보간 상태 리셋 (불연속 튐 방지)
bool currentMirrorMode = StreamingClient != null && StreamingClient.MirrorMode;
if (currentMirrorMode != m_lastMirrorMode)
{
m_filterStates.Clear();
+ ClearInterpolationBuffers();
m_lastMirrorMode = currentMirrorMode;
}
@@ -335,6 +354,7 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
previousSkeletonName = SkeletonAssetName;
if (m_skeletonDef != null)
RebuildBoneIdMapping();
+ ClearInterpolationBuffers();
return;
}
@@ -355,6 +375,10 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
m_lastFrameTimestamp = frameTs;
m_hasLastFrameTimestamp = true;
+ // ── 프레임 보간: 두 OptiTrack 프레임 사이를 시간 기반으로 Lerp/Slerp ──
+ if (enableInterpolation)
+ InterpolateSnapshots(frameTs);
+
// ── Pass 1: Raw 데이터 적용 → IK 포인트 월드 위치 캡처 ──────────────────
// 필터가 활성화되어 있을 때만 two-pass, 비활성이면 single-pass
if (enableBoneFilter)
@@ -625,6 +649,81 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
}
}
+ // ── 프레임 보간 (Frame Interpolation) ────────────────────────────────────
+ // OptiTrack(고정 120fps)과 Unity(가변 프레임) 사이의 타이밍 불일치로 인한 떨림을 제거.
+ // 이전/현재 두 프레임을 버퍼링하고 하드웨어 타임스탬프 기반으로 Lerp/Slerp.
+ // 약 1 OptiTrack 프레임(~8ms @120fps)의 지연이 추가되지만 모션이 매끄러워짐.
+
+ ///
+ /// 보간 버퍼를 초기화합니다. MirrorMode 전환, 스켈레톤 변경 등 불연속 시점에 호출.
+ ///
+ private void ClearInterpolationBuffers()
+ {
+ m_interpPrevPos.Clear();
+ m_interpPrevOri.Clear();
+ m_interpCurrPos.Clear();
+ m_interpCurrOri.Clear();
+ m_interpHasCurr = false;
+ m_interpReady = false;
+ }
+
+ ///
+ /// 두 OptiTrack 프레임 사이를 시간 기반으로 보간합니다.
+ /// m_snapshotPositions/Orientations를 보간된 결과로 덮어씁니다.
+ ///
+ private void InterpolateSnapshots(OptitrackHiResTimer.Timestamp frameTs)
+ {
+ // 새 프레임 감지 (타임스탬프가 변경되었으면 새 OptiTrack 프레임이 도착한 것)
+ if (frameTs.m_ticks != m_interpCurrTs.m_ticks)
+ {
+ // curr → prev 스왑 (딕셔너리 참조 교환으로 GC 방지)
+ (m_interpPrevPos, m_interpCurrPos) = (m_interpCurrPos, m_interpPrevPos);
+ (m_interpPrevOri, m_interpCurrOri) = (m_interpCurrOri, m_interpPrevOri);
+ m_interpPrevTs = m_interpCurrTs;
+
+ // 새 스냅샷 → curr 복사
+ m_interpCurrPos.Clear();
+ m_interpCurrOri.Clear();
+ foreach (var kvp in m_snapshotPositions)
+ m_interpCurrPos[kvp.Key] = kvp.Value;
+ foreach (var kvp in m_snapshotOrientations)
+ m_interpCurrOri[kvp.Key] = kvp.Value;
+ m_interpCurrTs = frameTs;
+
+ if (!m_interpReady && m_interpHasCurr)
+ m_interpReady = true;
+ m_interpHasCurr = true;
+ }
+
+ if (!m_interpReady) return;
+
+ // 프레임 간격 계산
+ float frameDuration = m_interpCurrTs.SecondsSince(m_interpPrevTs);
+ if (frameDuration < 0.001f || frameDuration > 0.1f) return; // 비정상 간격 무시
+
+ // 보간 계수: target_time = now - delay → 항상 prev~curr 사이에서 보간
+ float delay = interpolationDelay > 0f ? interpolationDelay : m_natNetDt;
+ float timeSincePrev = OptitrackHiResTimer.Now().SecondsSince(m_interpPrevTs);
+ float t = Mathf.Clamp01((timeSincePrev - delay) / frameDuration);
+
+ // m_snapshotPositions/Orientations를 보간 결과로 덮어쓰기
+ m_snapshotPositions.Clear();
+ m_snapshotOrientations.Clear();
+
+ foreach (var kvp in m_interpCurrPos)
+ {
+ m_snapshotPositions[kvp.Key] = m_interpPrevPos.TryGetValue(kvp.Key, out Vector3 prevP)
+ ? Vector3.Lerp(prevP, kvp.Value, t)
+ : kvp.Value;
+ }
+ foreach (var kvp in m_interpCurrOri)
+ {
+ m_snapshotOrientations[kvp.Key] = m_interpPrevOri.TryGetValue(kvp.Key, out Quaternion prevO)
+ ? Quaternion.Slerp(prevO, kvp.Value, t)
+ : kvp.Value;
+ }
+ }
+
// ── 1€ Filter (One Euro Filter) ──────────────────────────────────────────
// 참고: Géry Casiez et al., "1€ Filter: A Simple Speed-based Low-pass Filter", CHI 2012
// 속도가 빠를수록 cutoff 상승 → 지연 감소, 속도가 느릴수록 cutoff = minCutoff → 노이즈 제거
diff --git a/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs b/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs
index be7303cff..ce7242482 100644
--- a/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs
+++ b/Assets/Scripts/Streamdeck/StreamDeckServerManager.cs
@@ -51,6 +51,7 @@ public class StreamDeckServerManager : MonoBehaviour
private readonly List previewCameraPool = new List();
private RenderTexture previewRT;
private Texture2D previewReadbackTexture;
+ private int previewTextureVersion = 0;
private int currentPreviewIndex = 0;
private int previewFrameCounter = 0;
private readonly Dictionary previewPendingCaptures = new Dictionary();
@@ -66,26 +67,26 @@ public class StreamDeckServerManager : MonoBehaviour
void Start()
{
- cameraManager = FindObjectOfType();
+ cameraManager = FindAnyObjectByType();
if (cameraManager == null)
{
Debug.LogError("[StreamDeckServerManager] CameraManager를 찾을 수 없습니다!");
return;
}
- itemController = FindObjectOfType();
+ itemController = FindAnyObjectByType();
if (itemController == null)
Debug.LogWarning("[StreamDeckServerManager] ItemController를 찾을 수 없습니다. 아이템 컨트롤 기능이 비활성화됩니다.");
- eventController = FindObjectOfType();
+ eventController = FindAnyObjectByType();
if (eventController == null)
Debug.LogWarning("[StreamDeckServerManager] EventController를 찾을 수 없습니다. 이벤트 컨트롤 기능이 비활성화됩니다.");
- avatarOutfitController = FindObjectOfType();
+ avatarOutfitController = FindAnyObjectByType();
if (avatarOutfitController == null)
Debug.LogWarning("[StreamDeckServerManager] AvatarOutfitController를 찾을 수 없습니다. 아바타 의상 컨트롤 기능이 비활성화됩니다.");
- systemController = FindObjectOfType();
+ systemController = FindAnyObjectByType();
if (systemController == null)
Debug.LogWarning("[StreamDeckServerManager] SystemController를 찾을 수 없습니다. 시스템 컨트롤 기능이 비활성화됩니다.");
@@ -97,11 +98,21 @@ public class StreamDeckServerManager : MonoBehaviour
void Update()
{
+ // 큐를 락 안에서 드레인한 뒤, 락 밖에서 실행 — WebSocket 스레드 블로킹 최소화
+ Action[] pendingActions = null;
lock (lockObject)
{
- while (mainThreadActions.Count > 0)
+ if (mainThreadActions.Count > 0)
+ {
+ pendingActions = mainThreadActions.ToArray();
+ mainThreadActions.Clear();
+ }
+ }
+
+ if (pendingActions != null)
+ {
+ foreach (var action in pendingActions)
{
- var action = mainThreadActions.Dequeue();
try
{
action?.Invoke();
@@ -129,10 +140,6 @@ public class StreamDeckServerManager : MonoBehaviour
void OnDestroy()
{
CleanupPreview();
- }
-
- void OnApplicationQuit()
- {
StopServer();
StopDashboardServer();
}
@@ -145,7 +152,7 @@ public class StreamDeckServerManager : MonoBehaviour
// 0.0.0.0 으로 바인딩하여 LAN 내 다른 기기에서도 접속 가능
server = new WebSocketServer(port);
server.KeepClean = true; // 비활성 세션 자동 정리
- server.WaitTime = TimeSpan.FromSeconds(3); // ping-pong 응답 대기 시간
+ server.WaitTime = TimeSpan.FromSeconds(10); // ping-pong 응답 대기 시간 (모바일 WiFi 대응)
server.AddWebSocketService("/");
server.Start();
Debug.Log($"[StreamDeckServerManager] WebSocket 서버 시작됨, 포트: {port} (모든 인터페이스)");
@@ -222,13 +229,20 @@ public class StreamDeckServerManager : MonoBehaviour
public void OnClientDisconnected(StreamDeckService service)
{
- connectedClients.Remove(service);
- PreviewUnsubscribe(service);
- Debug.Log($"[StreamDeckServerManager] 클라이언트 연결 해제됨. 총 연결: {connectedClients.Count}");
+ lock (lockObject)
+ {
+ mainThreadActions.Enqueue(() =>
+ {
+ connectedClients.Remove(service);
+ PreviewUnsubscribe(service);
+ Debug.Log($"[StreamDeckServerManager] 클라이언트 연결 해제됨. 총 연결: {connectedClients.Count}");
+ });
+ }
}
public void BroadcastMessage(string message)
{
+ var deadClients = new List();
foreach (var client in connectedClients.ToArray())
{
try
@@ -238,9 +252,13 @@ public class StreamDeckServerManager : MonoBehaviour
catch (Exception e)
{
Debug.LogError($"[StreamDeckServerManager] 메시지 전송 실패: {e.Message}");
- connectedClients.Remove(client);
+ deadClients.Add(client);
}
}
+ foreach (var dead in deadClients)
+ {
+ connectedClients.Remove(dead);
+ }
}
#endregion
@@ -775,6 +793,8 @@ public class StreamDeckServerManager : MonoBehaviour
private void CreatePreviewRenderTextures()
{
+ previewTextureVersion++;
+
if (previewRT != null)
{
previewRT.Release();
@@ -815,8 +835,8 @@ public class StreamDeckServerManager : MonoBehaviour
return;
}
- previewFrameCounter++;
- if (previewFrameCounter % renderInterval != 0)
+ previewFrameCounter = (previewFrameCounter + 1) % renderInterval;
+ if (previewFrameCounter != 0)
return;
var presets = cameraManager.cameraPresets;
@@ -882,10 +902,13 @@ public class StreamDeckServerManager : MonoBehaviour
string presetName = presets[presetIndex].presetName;
int capturedPresetIndex = presetIndex;
+ int capturedTextureVersion = previewTextureVersion;
AsyncGPUReadback.Request(camera.targetTexture, 0, TextureFormat.RGB24, (request) =>
{
if (request.hasError || this == null) return;
+ // 텍스처가 재생성되었으면 이 콜백의 데이터는 무효
+ if (capturedTextureVersion != previewTextureVersion) return;
NativeArray data = request.GetData();
previewReadbackTexture.LoadRawTextureData(data);
@@ -1051,19 +1074,24 @@ public class StreamDeckServerManager : MonoBehaviour
{
var statusData = new Dictionary();
- // OptiTrack
if (systemController != null)
{
- statusData["optitrack"] = new
+ if (systemController.optiTrack != null)
{
- connected = systemController.optiTrack.IsOptitrackConnected(),
- status = systemController.optiTrack.GetOptitrackConnectionStatus()
- };
+ statusData["optitrack"] = new
+ {
+ connected = systemController.optiTrack.IsOptitrackConnected(),
+ status = systemController.optiTrack.GetOptitrackConnectionStatus()
+ };
+ }
- statusData["facial_motion"] = new
+ if (systemController.facialMotion != null)
{
- client_count = systemController.facialMotion.facialMotionClients?.Count ?? 0
- };
+ statusData["facial_motion"] = new
+ {
+ client_count = systemController.facialMotion.facialMotionClients?.Count ?? 0
+ };
+ }
statusData["recording"] = new
{
diff --git a/Assets/Scripts/Streamdeck/StreamingleDashboardServer.cs b/Assets/Scripts/Streamdeck/StreamingleDashboardServer.cs
index 89435e5d6..a2ab1288a 100644
--- a/Assets/Scripts/Streamdeck/StreamingleDashboardServer.cs
+++ b/Assets/Scripts/Streamdeck/StreamingleDashboardServer.cs
@@ -159,7 +159,19 @@ public class StreamingleDashboardServer
try
{
HttpListenerContext context = listener.GetContext();
- ProcessRequest(context);
+ // 요청을 ThreadPool에서 병렬 처리 — 동시 요청 블로킹 방지
+ ThreadPool.QueueUserWorkItem(_ =>
+ {
+ try
+ {
+ ProcessRequest(context);
+ }
+ catch (Exception ex)
+ {
+ if (isRunning)
+ Debug.LogError($"[StreamingleDashboard] 요청 처리 오류: {ex.Message}");
+ }
+ });
}
catch (HttpListenerException)
{
@@ -169,7 +181,7 @@ public class StreamingleDashboardServer
{
if (isRunning)
{
- Debug.LogError($"[StreamingleDashboard] 요청 처리 오류: {ex.Message}");
+ Debug.LogError($"[StreamingleDashboard] 요청 수신 오류: {ex.Message}");
}
}
}