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€ 필터 (속도 적응형 저역통과)")]
[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;
/// <summary>
/// 런타임에서 필터 강도를 변경합니다. StreamDeck/핫키 등에서 호출.
/// </summary>
@ -148,6 +156,16 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
}
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 노드 접미사 기본 매핑
public static readonly Dictionary<string, string> DefaultOptiToFbxSuffix = new Dictionary<string, string>
{
@ -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)의 지연이 추가되지만 모션이 매끄러워짐.
/// <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) ──────────────────────────────────────────
// 참고: Géry Casiez et al., "1€ Filter: A Simple Speed-based Low-pass Filter", CHI 2012
// 속도가 빠를수록 cutoff 상승 → 지연 감소, 속도가 느릴수록 cutoff = minCutoff → 노이즈 제거

View File

@ -51,6 +51,7 @@ public class StreamDeckServerManager : MonoBehaviour
private readonly List<Camera> previewCameraPool = new List<Camera>();
private RenderTexture previewRT;
private Texture2D previewReadbackTexture;
private int previewTextureVersion = 0;
private int currentPreviewIndex = 0;
private int previewFrameCounter = 0;
private readonly Dictionary<Camera, int> previewPendingCaptures = new Dictionary<Camera, int>();
@ -66,26 +67,26 @@ public class StreamDeckServerManager : MonoBehaviour
void Start()
{
cameraManager = FindObjectOfType<CameraManager>();
cameraManager = FindAnyObjectByType<CameraManager>();
if (cameraManager == null)
{
Debug.LogError("[StreamDeckServerManager] CameraManager를 찾을 수 없습니다!");
return;
}
itemController = FindObjectOfType<ItemController>();
itemController = FindAnyObjectByType<ItemController>();
if (itemController == null)
Debug.LogWarning("[StreamDeckServerManager] ItemController를 찾을 수 없습니다. 아이템 컨트롤 기능이 비활성화됩니다.");
eventController = FindObjectOfType<EventController>();
eventController = FindAnyObjectByType<EventController>();
if (eventController == null)
Debug.LogWarning("[StreamDeckServerManager] EventController를 찾을 수 없습니다. 이벤트 컨트롤 기능이 비활성화됩니다.");
avatarOutfitController = FindObjectOfType<AvatarOutfitController>();
avatarOutfitController = FindAnyObjectByType<AvatarOutfitController>();
if (avatarOutfitController == null)
Debug.LogWarning("[StreamDeckServerManager] AvatarOutfitController를 찾을 수 없습니다. 아바타 의상 컨트롤 기능이 비활성화됩니다.");
systemController = FindObjectOfType<SystemController>();
systemController = FindAnyObjectByType<SystemController>();
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<StreamDeckService>("/");
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<StreamDeckService>();
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<byte> data = request.GetData<byte>();
previewReadbackTexture.LoadRawTextureData(data);
@ -1051,19 +1074,24 @@ public class StreamDeckServerManager : MonoBehaviour
{
var statusData = new Dictionary<string, object>();
// 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
{

View File

@ -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}");
}
}
}