Remove: OptitrackSkeletonAnimator_Mingle 1€ 필터 제거

- FilterStrength 열거형 및 관련 필드/메서드 전체 삭제
- Update() two-pass 구조를 single-pass로 단순화
- 에디터 인스펙터의 필터 강도 UI 제거
- 프레임 보간 및 어깨 증폭 기능은 유지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
qsxft258@gmail.com 2026-04-14 23:25:55 +09:00
parent 7d32cc8b8c
commit 870dee9447
2 changed files with 19 additions and 255 deletions

View File

@ -4,57 +4,12 @@ using UnityEngine;
[CustomEditor(typeof(OptitrackSkeletonAnimator_Mingle))]
public class OptitrackSkeletonAnimatorEditor : Editor
{
private static readonly string[] k_FilterLabels = { "Off", "Low", "Medium", "High", "Custom" };
public override void OnInspectorGUI()
{
DrawDefaultInspector();
var anim = (OptitrackSkeletonAnimator_Mingle)target;
// 필터 강도 버튼 나열
EditorGUILayout.Space(4);
EditorGUILayout.LabelField("필터 강도", EditorStyles.boldLabel);
EditorGUILayout.BeginHorizontal();
for (int i = 0; i < k_FilterLabels.Length; i++)
{
var strength = (OptitrackSkeletonAnimator_Mingle.FilterStrength)i;
bool isSelected = anim.filterStrength == strength;
var prevBgBtn = GUI.backgroundColor;
GUI.backgroundColor = isSelected ? new Color(0.4f, 0.7f, 1f) : Color.white;
if (GUILayout.Button(k_FilterLabels[i], GUILayout.Height(24)))
{
Undo.RecordObject(anim, "Change Filter Strength");
anim.SetFilterStrength(strength);
EditorUtility.SetDirty(anim);
}
GUI.backgroundColor = prevBgBtn;
}
EditorGUILayout.EndHorizontal();
// Custom일 때 슬라이더 표시
if (anim.filterStrength == OptitrackSkeletonAnimator_Mingle.FilterStrength.Custom)
{
EditorGUI.indentLevel++;
EditorGUI.BeginChangeCheck();
float minCutoff = EditorGUILayout.Slider("Min Cutoff (Hz)", anim.filterMinCutoff, 0.1f, 10f);
float beta = EditorGUILayout.Slider("Beta", anim.filterBeta, 0f, 5f);
float maxCutoff = EditorGUILayout.Slider("Max Cutoff (Hz)", anim.filterMaxCutoff, 5f, 120f);
if (EditorGUI.EndChangeCheck())
{
Undo.RecordObject(anim, "Change Filter Custom Values");
anim.filterMinCutoff = minCutoff;
anim.filterBeta = beta;
anim.filterMaxCutoff = maxCutoff;
EditorUtility.SetDirty(anim);
}
EditorGUI.indentLevel--;
}
// 연결 상태 배지
EditorGUILayout.Space(8);
var prevBg = GUI.backgroundColor;

View File

@ -33,33 +33,11 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
[Tooltip("에디터에서 캡처한 T-포즈 데이터. 비어 있으면 런타임 CacheRestPose()를 사용합니다.")]
public OptitrackRestPoseData RestPoseAsset;
public enum FilterStrength { Off, Low, Medium, High, Custom }
[Header("본 1€ 필터 (속도 적응형 저역통과)")]
[HideInInspector]
public FilterStrength filterStrength = FilterStrength.Off;
[Header("어깨 증폭")]
[Tooltip("어깨 회전을 증폭합니다. 1 = 원본, 2 = 2배. 하위 체인(상완)은 자동 역보정되어 손 위치가 유지됩니다.")]
[Range(0f, 10f)]
public float shoulderAmplify = 2f;
[HideInInspector] public float filterMinCutoff = 3.0f;
[HideInInspector] public float filterBeta = 1.5f;
[HideInInspector] public float filterMaxCutoff = 15.0f;
// 프리셋별 파라미터 (minCutoff, beta, maxCutoff)
private static readonly (float minCutoff, float beta, float maxCutoff)[] k_FilterPresets =
{
(0f, 0f, 0f), // Off (사용 안 함)
(5.0f, 2.0f, 25.0f), // Low
(3.0f, 1.5f, 15.0f), // Medium
(1.5f, 0.8f, 10.0f), // High
(0f, 0f, 0f), // Custom (프리셋 적용 안 함)
};
[HideInInspector] public bool enableBoneFilter = true;
[Header("프레임 보간")]
[Tooltip("OptiTrack 프레임 사이를 보간하여 Unity 가변 프레임에서도 부드러운 모션을 생성합니다. 약 1프레임(~8ms @120fps) 지연이 추가됩니다.")]
public bool enableInterpolation = true;
@ -68,32 +46,6 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
[Range(0f, 0.05f)]
public float interpolationDelay = 0f;
/// <summary>
/// 런타임에서 필터 강도를 변경합니다. StreamDeck/핫키 등에서 호출.
/// </summary>
public void SetFilterStrength(FilterStrength strength)
{
filterStrength = strength;
if (strength != FilterStrength.Off && strength != FilterStrength.Custom)
{
var preset = k_FilterPresets[(int)strength];
filterMinCutoff = preset.minCutoff;
filterBeta = preset.beta;
filterMaxCutoff = preset.maxCutoff;
}
// 프리셋 전환 시 필터 상태 리셋 (이전 값과 불연속 방지)
m_filterStates.Clear();
}
/// <summary>
/// 현재 프리셋에서 다음 프리셋으로 순환. 버튼 하나로 Off→Low→Medium→High→Custom→Off...
/// </summary>
public void CycleFilterStrength()
{
int next = ((int)filterStrength + 1) % System.Enum.GetValues(typeof(FilterStrength)).Length;
SetFilterStrength((FilterStrength)next);
}
private OptitrackSkeletonDefinition m_skeletonDef;
private string previousSkeletonName;
@ -145,17 +97,6 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
private OptitrackHiResTimer.Timestamp m_lastFrameTimestamp;
private bool m_hasLastFrameTimestamp = false;
// 1€ 필터 상태 (본 ID → 이전 프레임 필터 상태)
private struct BoneFilterState
{
public Quaternion prevOri;
public float dOriMag; // 필터된 각속도 크기 (rad/s)
public Vector3 prevPos;
public float dPosMag; // 필터된 선속도 크기 (m/s)
public bool initialized;
}
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>();
@ -334,14 +275,10 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
return;
}
// 필터 활성화 상태 동기화 (프리셋 값은 SetFilterStrength()에서만 적용)
enableBoneFilter = filterStrength != FilterStrength.Off;
// MirrorMode 변경 감지 → 필터/보간 상태 리셋 (불연속 튐 방지)
// MirrorMode 변경 감지 → 보간 상태 리셋 (불연속 튐 방지)
bool currentMirrorMode = StreamingClient != null && StreamingClient.MirrorMode;
if (currentMirrorMode != m_lastMirrorMode)
{
m_filterStates.Clear();
ClearInterpolationBuffers();
m_lastMirrorMode = currentMirrorMode;
}
@ -379,83 +316,29 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
if (enableInterpolation)
InterpolateSnapshots(frameTs);
// ── Pass 1: Raw 데이터 적용 → IK 포인트 월드 위치 캡처 ──────────────────
// 필터가 활성화되어 있을 때만 two-pass, 비활성이면 single-pass
if (enableBoneFilter)
// 본 Transform에 스냅샷 적용
foreach (var bone in m_skeletonDef.Bones)
{
// Raw 데이터로 모든 본 업데이트 (필터 없이)
foreach (var bone in m_skeletonDef.Bones)
{
if (!m_boneIdToMappingIndex.TryGetValue(bone.Id, out int mappingIdx))
continue;
var mapping = boneMappings[mappingIdx];
if (!mapping.isMapped || mapping.cachedTransform == null)
continue;
if (!m_boneIdToMappingIndex.TryGetValue(bone.Id, out int mappingIdx))
continue;
var mapping = boneMappings[mappingIdx];
if (!mapping.isMapped || mapping.cachedTransform == null)
continue;
if (mapping.applyRotation && m_snapshotOrientations.TryGetValue(bone.Id, out Quaternion rawOri))
mapping.cachedTransform.localRotation = rawOri;
if (mapping.applyPosition && m_snapshotPositions.TryGetValue(bone.Id, out Vector3 rawPos))
mapping.cachedTransform.localPosition = rawPos;
}
// Raw 상태에서 IK 포인트 월드 위치/회전 캡처
foreach (var ikBone in k_IKPointBones)
{
Transform t = GetBoneTransform(ikBone);
if (t != null)
{
m_rawWorldPositions[ikBone] = t.position;
m_rawWorldRotations[ikBone] = t.rotation;
}
}
// Pass 2: 필터 적용된 데이터로 덮어쓰기
foreach (var bone in m_skeletonDef.Bones)
{
if (!m_boneIdToMappingIndex.TryGetValue(bone.Id, out int mappingIdx))
continue;
var mapping = boneMappings[mappingIdx];
if (!mapping.isMapped || mapping.cachedTransform == null)
continue;
if (mapping.applyRotation && m_snapshotOrientations.TryGetValue(bone.Id, out Quaternion finalOri))
{
finalOri = ApplyOneEuroOri(bone.Id, finalOri, m_natNetDt);
mapping.cachedTransform.localRotation = finalOri;
}
if (mapping.applyPosition && m_snapshotPositions.TryGetValue(bone.Id, out Vector3 finalPos))
{
finalPos = ApplyOneEuroPos(bone.Id, finalPos, m_natNetDt);
mapping.cachedTransform.localPosition = finalPos;
}
}
if (mapping.applyRotation && m_snapshotOrientations.TryGetValue(bone.Id, out Quaternion finalOri))
mapping.cachedTransform.localRotation = finalOri;
if (mapping.applyPosition && m_snapshotPositions.TryGetValue(bone.Id, out Vector3 finalPos))
mapping.cachedTransform.localPosition = finalPos;
}
else
// IK 포인트 월드 위치/회전 캡처
foreach (var ikBone in k_IKPointBones)
{
// 필터 비활성: single-pass, raw = filtered
foreach (var bone in m_skeletonDef.Bones)
Transform t = GetBoneTransform(ikBone);
if (t != null)
{
if (!m_boneIdToMappingIndex.TryGetValue(bone.Id, out int mappingIdx))
continue;
var mapping = boneMappings[mappingIdx];
if (!mapping.isMapped || mapping.cachedTransform == null)
continue;
if (mapping.applyRotation && m_snapshotOrientations.TryGetValue(bone.Id, out Quaternion finalOri))
mapping.cachedTransform.localRotation = finalOri;
if (mapping.applyPosition && m_snapshotPositions.TryGetValue(bone.Id, out Vector3 finalPos))
mapping.cachedTransform.localPosition = finalPos;
}
// 필터 없으면 현재 Transform 위치/회전이 곧 raw
foreach (var ikBone in k_IKPointBones)
{
Transform t = GetBoneTransform(ikBone);
if (t != null)
{
m_rawWorldPositions[ikBone] = t.position;
m_rawWorldRotations[ikBone] = t.rotation;
}
m_rawWorldPositions[ikBone] = t.position;
m_rawWorldRotations[ikBone] = t.rotation;
}
}
@ -577,7 +460,6 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
private void RebuildBoneIdMapping()
{
m_boneIdToMappingIndex.Clear();
m_filterStates.Clear();
m_hasLastFrameTimestamp = false;
if (m_skeletonDef == null) return;
@ -724,79 +606,6 @@ public class OptitrackSkeletonAnimator_Mingle : MonoBehaviour
}
}
// ── 1€ Filter (One Euro Filter) ──────────────────────────────────────────
// 참고: Géry Casiez et al., "1€ Filter: A Simple Speed-based Low-pass Filter", CHI 2012
// 속도가 빠를수록 cutoff 상승 → 지연 감소, 속도가 느릴수록 cutoff = minCutoff → 노이즈 제거
// dt초 간격에서의 1차 LP 필터 alpha
private static float OE_Alpha(float cutoffHz, float dt)
{
float r = 2f * Mathf.PI * cutoffHz * dt;
return r / (r + 1f);
}
private Quaternion ApplyOneEuroOri(int boneId, Quaternion raw, float dt)
{
const float k_DCutoff = 1f; // 도함수 필터의 고정 cutoff (Hz)
if (!m_filterStates.TryGetValue(boneId, out BoneFilterState s) || !s.initialized)
{
m_filterStates[boneId] = new BoneFilterState
{
prevOri = raw, dOriMag = 0f,
prevPos = Vector3.zero, dPosMag = 0f,
initialized = true
};
return raw;
}
// 각속도(rad/s) 추정
float angleDeg = Quaternion.Angle(s.prevOri, raw);
float speed = angleDeg * Mathf.Deg2Rad / Mathf.Max(dt, 1e-4f);
// 도함수를 별도 LP로 스무딩
float dAlpha = OE_Alpha(k_DCutoff, dt);
float filtDeriv = s.dOriMag + dAlpha * (speed - s.dOriMag);
// 적응 cutoff: 빠르면 cutoff 상승, 단 maxCutoff 이상은 항상 제거
float cutoff = Mathf.Min(filterMinCutoff + filterBeta * filtDeriv, filterMaxCutoff);
float alpha = OE_Alpha(cutoff, dt);
Quaternion filtered = Quaternion.Slerp(s.prevOri, raw, alpha);
s.prevOri = filtered;
s.dOriMag = filtDeriv;
m_filterStates[boneId] = s;
return filtered;
}
private Vector3 ApplyOneEuroPos(int boneId, Vector3 raw, float dt)
{
const float k_DCutoff = 1f;
if (!m_filterStates.TryGetValue(boneId, out BoneFilterState s) || !s.initialized)
{
// 회전 필터가 이미 초기화했을 수 있으므로 위치만 패치
s.prevPos = raw;
s.dPosMag = 0f;
s.initialized = true;
m_filterStates[boneId] = s;
return raw;
}
float speed = (raw - s.prevPos).magnitude / Mathf.Max(dt, 1e-4f);
float dAlpha = OE_Alpha(k_DCutoff, dt);
float filtDeriv = s.dPosMag + dAlpha * (speed - s.dPosMag);
float cutoff = Mathf.Min(filterMinCutoff + filterBeta * filtDeriv, filterMaxCutoff);
float alpha = OE_Alpha(cutoff, dt);
Vector3 filtered = Vector3.Lerp(s.prevPos, raw, alpha);
s.prevPos = filtered;
s.dPosMag = filtDeriv;
m_filterStates[boneId] = s;
return filtered;
}
#region
// HumanBodyBones → OptiTrack 본 이름 매핑 (Humanoid 호환 레이어)