Fix : 유니티에 의한 가변 프레임 버그 해결

This commit is contained in:
user 2026-03-31 22:46:18 +09:00
parent 71b9521372
commit b4044a90f5
3 changed files with 169 additions and 30 deletions

View File

@ -37,7 +37,7 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
[Header("본 1€ 필터 (속도 적응형 저역통과)")] [Header("본 1€ 필터 (속도 적응형 저역통과)")]
[HideInInspector] [HideInInspector]
public FilterStrength filterStrength = FilterStrength.Medium; public FilterStrength filterStrength = FilterStrength.Off;
[Header("어깨 증폭")] [Header("어깨 증폭")]
[Tooltip("어깨 회전을 증폭합니다. 1 = 원본, 2 = 2배. 하위 체인(상완)은 자동 역보정되어 손 위치가 유지됩니다.")] [Tooltip("어깨 회전을 증폭합니다. 1 = 원본, 2 = 2배. 하위 체인(상완)은 자동 역보정되어 손 위치가 유지됩니다.")]
@ -60,6 +60,14 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
[HideInInspector] public bool enableBoneFilter = true; [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;
/// <summary> /// <summary>
/// 런타임에서 필터 강도를 변경합니다. StreamDeck/핫키 등에서 호출. /// 런타임에서 필터 강도를 변경합니다. StreamDeck/핫키 등에서 호출.
/// </summary> /// </summary>
@ -148,6 +156,16 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
} }
private Dictionary<Int32, BoneFilterState> m_filterStates = new Dictionary<Int32, BoneFilterState>(); private Dictionary<Int32, BoneFilterState> m_filterStates = new Dictionary<Int32, BoneFilterState>();
// 프레임 보간용 이중 버퍼 (prev/curr OptiTrack 프레임)
private Dictionary<Int32, Vector3> m_interpPrevPos = new Dictionary<Int32, Vector3>();
private Dictionary<Int32, Quaternion> m_interpPrevOri = new Dictionary<Int32, Quaternion>();
private Dictionary<Int32, Vector3> m_interpCurrPos = new Dictionary<Int32, Vector3>();
private Dictionary<Int32, Quaternion> m_interpCurrOri = new Dictionary<Int32, Quaternion>();
private OptitrackHiResTimer.Timestamp m_interpPrevTs;
private OptitrackHiResTimer.Timestamp m_interpCurrTs;
private bool m_interpHasCurr = false;
private bool m_interpReady = false;
// OptiTrack 본 이름 → FBX 노드 접미사 기본 매핑 // OptiTrack 본 이름 → FBX 노드 접미사 기본 매핑
public static readonly Dictionary<string, string> DefaultOptiToFbxSuffix = new Dictionary<string, string> public static readonly Dictionary<string, string> DefaultOptiToFbxSuffix = new Dictionary<string, string>
{ {
@ -319,11 +337,12 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
// 필터 활성화 상태 동기화 (프리셋 값은 SetFilterStrength()에서만 적용) // 필터 활성화 상태 동기화 (프리셋 값은 SetFilterStrength()에서만 적용)
enableBoneFilter = filterStrength != FilterStrength.Off; enableBoneFilter = filterStrength != FilterStrength.Off;
// MirrorMode 변경 감지 → 필터 상태 리셋 (불연속 튐 방지) // MirrorMode 변경 감지 → 필터/보간 상태 리셋 (불연속 튐 방지)
bool currentMirrorMode = StreamingClient != null && StreamingClient.MirrorMode; bool currentMirrorMode = StreamingClient != null && StreamingClient.MirrorMode;
if (currentMirrorMode != m_lastMirrorMode) if (currentMirrorMode != m_lastMirrorMode)
{ {
m_filterStates.Clear(); m_filterStates.Clear();
ClearInterpolationBuffers();
m_lastMirrorMode = currentMirrorMode; m_lastMirrorMode = currentMirrorMode;
} }
@ -335,6 +354,7 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
previousSkeletonName = SkeletonAssetName; previousSkeletonName = SkeletonAssetName;
if (m_skeletonDef != null) if (m_skeletonDef != null)
RebuildBoneIdMapping(); RebuildBoneIdMapping();
ClearInterpolationBuffers();
return; return;
} }
@ -355,6 +375,10 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
m_lastFrameTimestamp = frameTs; m_lastFrameTimestamp = frameTs;
m_hasLastFrameTimestamp = true; m_hasLastFrameTimestamp = true;
// ── 프레임 보간: 두 OptiTrack 프레임 사이를 시간 기반으로 Lerp/Slerp ──
if (enableInterpolation)
InterpolateSnapshots(frameTs);
// ── Pass 1: Raw 데이터 적용 → IK 포인트 월드 위치 캡처 ────────────────── // ── Pass 1: Raw 데이터 적용 → IK 포인트 월드 위치 캡처 ──────────────────
// 필터가 활성화되어 있을 때만 two-pass, 비활성이면 single-pass // 필터가 활성화되어 있을 때만 two-pass, 비활성이면 single-pass
if (enableBoneFilter) if (enableBoneFilter)
@ -625,6 +649,81 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
} }
} }
// ── 프레임 보간 (Frame Interpolation) ────────────────────────────────────
// OptiTrack(고정 120fps)과 Unity(가변 프레임) 사이의 타이밍 불일치로 인한 떨림을 제거.
// 이전/현재 두 프레임을 버퍼링하고 하드웨어 타임스탬프 기반으로 Lerp/Slerp.
// 약 1 OptiTrack 프레임(~8ms @120fps)의 지연이 추가되지만 모션이 매끄러워짐.
/// <summary>
/// 보간 버퍼를 초기화합니다. MirrorMode 전환, 스켈레톤 변경 등 불연속 시점에 호출.
/// </summary>
private void ClearInterpolationBuffers()
{
m_interpPrevPos.Clear();
m_interpPrevOri.Clear();
m_interpCurrPos.Clear();
m_interpCurrOri.Clear();
m_interpHasCurr = false;
m_interpReady = false;
}
/// <summary>
/// 두 OptiTrack 프레임 사이를 시간 기반으로 보간합니다.
/// m_snapshotPositions/Orientations를 보간된 결과로 덮어씁니다.
/// </summary>
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) ────────────────────────────────────────── // ── 1€ Filter (One Euro Filter) ──────────────────────────────────────────
// 참고: Géry Casiez et al., "1€ Filter: A Simple Speed-based Low-pass Filter", CHI 2012 // 참고: Géry Casiez et al., "1€ Filter: A Simple Speed-based Low-pass Filter", CHI 2012
// 속도가 빠를수록 cutoff 상승 → 지연 감소, 속도가 느릴수록 cutoff = minCutoff → 노이즈 제거 // 속도가 빠를수록 cutoff 상승 → 지연 감소, 속도가 느릴수록 cutoff = minCutoff → 노이즈 제거

View File

@ -51,6 +51,7 @@ public class StreamDeckServerManager : MonoBehaviour
private readonly List<Camera> previewCameraPool = new List<Camera>(); private readonly List<Camera> previewCameraPool = new List<Camera>();
private RenderTexture previewRT; private RenderTexture previewRT;
private Texture2D previewReadbackTexture; private Texture2D previewReadbackTexture;
private int previewTextureVersion = 0;
private int currentPreviewIndex = 0; private int currentPreviewIndex = 0;
private int previewFrameCounter = 0; private int previewFrameCounter = 0;
private readonly Dictionary<Camera, int> previewPendingCaptures = new Dictionary<Camera, int>(); private readonly Dictionary<Camera, int> previewPendingCaptures = new Dictionary<Camera, int>();
@ -66,26 +67,26 @@ public class StreamDeckServerManager : MonoBehaviour
void Start() void Start()
{ {
cameraManager = FindObjectOfType<CameraManager>(); cameraManager = FindAnyObjectByType<CameraManager>();
if (cameraManager == null) if (cameraManager == null)
{ {
Debug.LogError("[StreamDeckServerManager] CameraManager를 찾을 수 없습니다!"); Debug.LogError("[StreamDeckServerManager] CameraManager를 찾을 수 없습니다!");
return; return;
} }
itemController = FindObjectOfType<ItemController>(); itemController = FindAnyObjectByType<ItemController>();
if (itemController == null) if (itemController == null)
Debug.LogWarning("[StreamDeckServerManager] ItemController를 찾을 수 없습니다. 아이템 컨트롤 기능이 비활성화됩니다."); Debug.LogWarning("[StreamDeckServerManager] ItemController를 찾을 수 없습니다. 아이템 컨트롤 기능이 비활성화됩니다.");
eventController = FindObjectOfType<EventController>(); eventController = FindAnyObjectByType<EventController>();
if (eventController == null) if (eventController == null)
Debug.LogWarning("[StreamDeckServerManager] EventController를 찾을 수 없습니다. 이벤트 컨트롤 기능이 비활성화됩니다."); Debug.LogWarning("[StreamDeckServerManager] EventController를 찾을 수 없습니다. 이벤트 컨트롤 기능이 비활성화됩니다.");
avatarOutfitController = FindObjectOfType<AvatarOutfitController>(); avatarOutfitController = FindAnyObjectByType<AvatarOutfitController>();
if (avatarOutfitController == null) if (avatarOutfitController == null)
Debug.LogWarning("[StreamDeckServerManager] AvatarOutfitController를 찾을 수 없습니다. 아바타 의상 컨트롤 기능이 비활성화됩니다."); Debug.LogWarning("[StreamDeckServerManager] AvatarOutfitController를 찾을 수 없습니다. 아바타 의상 컨트롤 기능이 비활성화됩니다.");
systemController = FindObjectOfType<SystemController>(); systemController = FindAnyObjectByType<SystemController>();
if (systemController == null) if (systemController == null)
Debug.LogWarning("[StreamDeckServerManager] SystemController를 찾을 수 없습니다. 시스템 컨트롤 기능이 비활성화됩니다."); Debug.LogWarning("[StreamDeckServerManager] SystemController를 찾을 수 없습니다. 시스템 컨트롤 기능이 비활성화됩니다.");
@ -97,11 +98,21 @@ public class StreamDeckServerManager : MonoBehaviour
void Update() void Update()
{ {
// 큐를 락 안에서 드레인한 뒤, 락 밖에서 실행 — WebSocket 스레드 블로킹 최소화
Action[] pendingActions = null;
lock (lockObject) 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 try
{ {
action?.Invoke(); action?.Invoke();
@ -129,10 +140,6 @@ public class StreamDeckServerManager : MonoBehaviour
void OnDestroy() void OnDestroy()
{ {
CleanupPreview(); CleanupPreview();
}
void OnApplicationQuit()
{
StopServer(); StopServer();
StopDashboardServer(); StopDashboardServer();
} }
@ -145,7 +152,7 @@ public class StreamDeckServerManager : MonoBehaviour
// 0.0.0.0 으로 바인딩하여 LAN 내 다른 기기에서도 접속 가능 // 0.0.0.0 으로 바인딩하여 LAN 내 다른 기기에서도 접속 가능
server = new WebSocketServer(port); server = new WebSocketServer(port);
server.KeepClean = true; // 비활성 세션 자동 정리 server.KeepClean = true; // 비활성 세션 자동 정리
server.WaitTime = TimeSpan.FromSeconds(3); // ping-pong 응답 대기 시간 server.WaitTime = TimeSpan.FromSeconds(10); // ping-pong 응답 대기 시간 (모바일 WiFi 대응)
server.AddWebSocketService<StreamDeckService>("/"); server.AddWebSocketService<StreamDeckService>("/");
server.Start(); server.Start();
Debug.Log($"[StreamDeckServerManager] WebSocket 서버 시작됨, 포트: {port} (모든 인터페이스)"); Debug.Log($"[StreamDeckServerManager] WebSocket 서버 시작됨, 포트: {port} (모든 인터페이스)");
@ -221,14 +228,21 @@ public class StreamDeckServerManager : MonoBehaviour
} }
public void OnClientDisconnected(StreamDeckService service) public void OnClientDisconnected(StreamDeckService service)
{
lock (lockObject)
{
mainThreadActions.Enqueue(() =>
{ {
connectedClients.Remove(service); connectedClients.Remove(service);
PreviewUnsubscribe(service); PreviewUnsubscribe(service);
Debug.Log($"[StreamDeckServerManager] 클라이언트 연결 해제됨. 총 연결: {connectedClients.Count}"); Debug.Log($"[StreamDeckServerManager] 클라이언트 연결 해제됨. 총 연결: {connectedClients.Count}");
});
}
} }
public void BroadcastMessage(string message) public void BroadcastMessage(string message)
{ {
var deadClients = new List<StreamDeckService>();
foreach (var client in connectedClients.ToArray()) foreach (var client in connectedClients.ToArray())
{ {
try try
@ -238,9 +252,13 @@ public class StreamDeckServerManager : MonoBehaviour
catch (Exception e) catch (Exception e)
{ {
Debug.LogError($"[StreamDeckServerManager] 메시지 전송 실패: {e.Message}"); Debug.LogError($"[StreamDeckServerManager] 메시지 전송 실패: {e.Message}");
connectedClients.Remove(client); deadClients.Add(client);
} }
} }
foreach (var dead in deadClients)
{
connectedClients.Remove(dead);
}
} }
#endregion #endregion
@ -775,6 +793,8 @@ public class StreamDeckServerManager : MonoBehaviour
private void CreatePreviewRenderTextures() private void CreatePreviewRenderTextures()
{ {
previewTextureVersion++;
if (previewRT != null) if (previewRT != null)
{ {
previewRT.Release(); previewRT.Release();
@ -815,8 +835,8 @@ public class StreamDeckServerManager : MonoBehaviour
return; return;
} }
previewFrameCounter++; previewFrameCounter = (previewFrameCounter + 1) % renderInterval;
if (previewFrameCounter % renderInterval != 0) if (previewFrameCounter != 0)
return; return;
var presets = cameraManager.cameraPresets; var presets = cameraManager.cameraPresets;
@ -882,10 +902,13 @@ public class StreamDeckServerManager : MonoBehaviour
string presetName = presets[presetIndex].presetName; string presetName = presets[presetIndex].presetName;
int capturedPresetIndex = presetIndex; int capturedPresetIndex = presetIndex;
int capturedTextureVersion = previewTextureVersion;
AsyncGPUReadback.Request(camera.targetTexture, 0, TextureFormat.RGB24, (request) => AsyncGPUReadback.Request(camera.targetTexture, 0, TextureFormat.RGB24, (request) =>
{ {
if (request.hasError || this == null) return; if (request.hasError || this == null) return;
// 텍스처가 재생성되었으면 이 콜백의 데이터는 무효
if (capturedTextureVersion != previewTextureVersion) return;
NativeArray<byte> data = request.GetData<byte>(); NativeArray<byte> data = request.GetData<byte>();
previewReadbackTexture.LoadRawTextureData(data); previewReadbackTexture.LoadRawTextureData(data);
@ -1051,19 +1074,24 @@ public class StreamDeckServerManager : MonoBehaviour
{ {
var statusData = new Dictionary<string, object>(); var statusData = new Dictionary<string, object>();
// OptiTrack
if (systemController != null) if (systemController != null)
{
if (systemController.optiTrack != null)
{ {
statusData["optitrack"] = new statusData["optitrack"] = new
{ {
connected = systemController.optiTrack.IsOptitrackConnected(), connected = systemController.optiTrack.IsOptitrackConnected(),
status = systemController.optiTrack.GetOptitrackConnectionStatus() status = systemController.optiTrack.GetOptitrackConnectionStatus()
}; };
}
if (systemController.facialMotion != null)
{
statusData["facial_motion"] = new statusData["facial_motion"] = new
{ {
client_count = systemController.facialMotion.facialMotionClients?.Count ?? 0 client_count = systemController.facialMotion.facialMotionClients?.Count ?? 0
}; };
}
statusData["recording"] = new statusData["recording"] = new
{ {

View File

@ -159,8 +159,20 @@ public class StreamingleDashboardServer
try try
{ {
HttpListenerContext context = listener.GetContext(); HttpListenerContext context = listener.GetContext();
// 요청을 ThreadPool에서 병렬 처리 — 동시 요청 블로킹 방지
ThreadPool.QueueUserWorkItem(_ =>
{
try
{
ProcessRequest(context); ProcessRequest(context);
} }
catch (Exception ex)
{
if (isRunning)
Debug.LogError($"[StreamingleDashboard] 요청 처리 오류: {ex.Message}");
}
});
}
catch (HttpListenerException) catch (HttpListenerException)
{ {
// 서버 종료 시 발생 // 서버 종료 시 발생
@ -169,7 +181,7 @@ public class StreamingleDashboardServer
{ {
if (isRunning) if (isRunning)
{ {
Debug.LogError($"[StreamingleDashboard] 요청 처리 오류: {ex.Message}"); Debug.LogError($"[StreamingleDashboard] 요청 수신 오류: {ex.Message}");
} }
} }
} }