From b4044a90f5f0b90f0c9584ec23095574ac187459 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 31 Mar 2026 22:46:18 +0900 Subject: [PATCH] =?UTF-8?q?Fix=20:=20=EC=9C=A0=EB=8B=88=ED=8B=B0=EC=97=90?= =?UTF-8?q?=20=EC=9D=98=ED=95=9C=20=EA=B0=80=EB=B3=80=20=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=9E=84=20=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OptitrackSkeletonAnimator_Mingle.cs | 103 +++++++++++++++++- .../Streamdeck/StreamDeckServerManager.cs | 80 +++++++++----- .../Streamdeck/StreamingleDashboardServer.cs | 16 ++- 3 files changed, 169 insertions(+), 30 deletions(-) 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}"); } } }