Merge remote-tracking branch 'origin/retargeting-update'

This commit is contained in:
DESKTOP-S4BOTN2\user 2026-03-13 15:45:42 +09:00
commit 64ad1eec3a
17 changed files with 1174 additions and 1286 deletions

Binary file not shown.

View File

@ -9,12 +9,6 @@ namespace KindRetargeting
/// 이 스크립트는 원본 아바타(Source)의 포즈 손가락 움직임을 대상 아바타(Target)에 리타게팅(Retargeting)합니다.
/// 또한 IK 타겟을 생성하여 대상 아바타의 관절 움직임을 자연스럽게 조정합니다.
/// </summary>
[RequireComponent(typeof(LimbWeightController))]
[RequireComponent(typeof(ShoulderCorrectionFunction))]
[RequireComponent(typeof(TwoBoneIKSolver))]
[RequireComponent(typeof(FootGroundingController))]
[RequireComponent(typeof(PropLocationController))]
[RequireComponent(typeof(FingerShapedController))]
[DefaultExecutionOrder(1)]
public class CustomRetargetingScript : MonoBehaviour
{
@ -25,7 +19,7 @@ namespace KindRetargeting
[HideInInspector] public Animator targetAnimator; // 대상 아바타의 Animator
// IK 컴포넌트 참조
private TwoBoneIKSolver ikSolver;
[SerializeField] public TwoBoneIKSolver ikSolver = new TwoBoneIKSolver();
[Header("힙 위치 보정 (로컬 좌표계 기반)")]
[SerializeField, Range(-1, 1)]
@ -101,6 +95,21 @@ namespace KindRetargeting
[HideInInspector] public Vector3 tPoseHeadForward = Vector3.forward;
[HideInInspector] public Vector3 tPoseHeadUp = Vector3.up;
[Header("어깨 보정")]
[SerializeField] public ShoulderCorrectionFunction shoulderCorrection = new ShoulderCorrectionFunction();
[Header("발 접지")]
[SerializeField] public FootGroundingController footGrounding = new FootGroundingController();
[Header("프랍 부착")]
[SerializeField] public PropLocationController propLocation = new PropLocationController();
[Header("사지 가중치")]
[SerializeField] public LimbWeightController limbWeight = new LimbWeightController();
[Header("손가락 셰이핑")]
[SerializeField] public FingerShapedController fingerShaped = new FingerShapedController();
[Header("아바타 크기 조정")]
[SerializeField, Range(0.1f, 3f)] private float avatarScale = 1f;
private float previousScale = 1f;
@ -269,8 +278,7 @@ namespace KindRetargeting
// 설정 로드
LoadSettings();
// IK 컴포넌트 참조 가져오기
ikSolver = GetComponent<TwoBoneIKSolver>();
// IK 모듈은 InitializeIKJoints에서 초기화
// IK 타겟 생성 (무릎 시각화 오브젝트 포함)
CreateIKTargets();
@ -324,6 +332,24 @@ namespace KindRetargeting
Debug.LogWarning("[CustomRetargetingScript] 머리 본을 찾을 수 없습니다!");
}
}
// 어깨 보정 모듈 초기화
if (targetAnimator != null)
shoulderCorrection.Initialize(targetAnimator);
// 발 접지 모듈 초기화
footGrounding.Initialize(ikSolver, targetAnimator);
// 프랍 부착 모듈 초기화
if (targetAnimator != null)
propLocation.Initialize(targetAnimator);
// 사지 가중치 모듈 초기화
limbWeight.Initialize(ikSolver, this, transform);
// 손가락 셰이핑 모듈 초기화
if (targetAnimator != null)
fingerShaped.Initialize(targetAnimator);
}
/// <summary>
@ -521,8 +547,7 @@ namespace KindRetargeting
}
// LimbWeightController에서 의자 높이 오프셋 가져오기
var limbController = GetComponent<LimbWeightController>();
float chairOffset = limbController != null ? limbController.chairSeatHeightOffset : 0.05f;
float chairOffset = limbWeight.chairSeatHeightOffset;
var settings = new RetargetingSettings
{
@ -600,11 +625,7 @@ namespace KindRetargeting
headScale = settings.headScale;
// LimbWeightController에 의자 높이 오프셋 적용
var limbController = GetComponent<LimbWeightController>();
if (limbController != null)
{
limbController.chairSeatHeightOffset = settings.chairSeatHeightOffset;
}
limbWeight.chairSeatHeightOffset = settings.chairSeatHeightOffset;
// 머리 회전 오프셋 로드
headRotationOffsetX = settings.headRotationOffsetX;
@ -821,6 +842,21 @@ namespace KindRetargeting
break;
}
// 손가락 셰이핑 (기존 ExecutionOrder 2)
fingerShaped.OnUpdate();
// 어깨 보정 (기존 ExecutionOrder 3)
shoulderCorrection.OnUpdate();
// 사지 가중치 (기존 ExecutionOrder 4)
limbWeight.OnUpdate();
// 발 접지 Pre-IK (기존 ExecutionOrder 5)
footGrounding.OnUpdate();
// IK 솔버 (기존 ExecutionOrder 6)
ikSolver.OnUpdate();
// 스케일 변경 확인 및 적용
if (!Mathf.Approximately(previousScale, avatarScale))
{
@ -834,6 +870,9 @@ namespace KindRetargeting
/// </summary>
void LateUpdate()
{
// 발 접지 Post-IK (기존 FootGroundingController LateUpdate)
footGrounding.OnLateUpdate();
ApplyHeadRotationOffset();
ApplyHeadScale();
}
@ -1147,11 +1186,6 @@ namespace KindRetargeting
/// </summary>
private void CreateIKTargets()
{
// IK 컴포넌트 가져오기 또는 새로 추가
ikSolver = GetComponent<TwoBoneIKSolver>();
if (ikSolver == null)
ikSolver = gameObject.AddComponent<TwoBoneIKSolver>();
ikSolver.animator = targetAnimator;
// IK 타겟들을 담을 부모 오브젝트 생성
@ -1183,7 +1217,7 @@ namespace KindRetargeting
ikSolver.rightLeg.bendGoal = rightLegGoal.transform;
// TwoBoneIKSolver 본 캐싱 초기화
ikSolver.Initialize();
ikSolver.Initialize(targetAnimator);
}
/// <summary>
@ -1384,6 +1418,7 @@ namespace KindRetargeting
{
sourcePoseHandler?.Dispose();
targetPoseHandler?.Dispose();
fingerShaped.Cleanup();
}
/// <summary>

View File

@ -10,48 +10,9 @@ namespace KindRetargeting
{
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
private SerializedObject groundingSO;
// SerializedProperty
private SerializedProperty sourceAnimatorProp;
private SerializedProperty targetAnimatorProp;
private SerializedProperty hipsOffsetXProp;
private SerializedProperty hipsOffsetYProp;
private SerializedProperty hipsOffsetZProp;
private SerializedProperty debugAxisNormalizerProp;
private SerializedProperty fingerCopyModeProp;
private SerializedProperty kneeInOutWeightProp;
private SerializedProperty kneeFrontBackWeightProp;
private SerializedProperty footFrontBackOffsetProp;
private SerializedProperty footInOutOffsetProp;
private SerializedProperty floorHeightProp;
private SerializedProperty avatarScaleProp;
// Dynamic UI
private Label cacheStatusLabel;
protected override void OnDisable()
{
base.OnDisable();
groundingSO = null;
}
protected override void OnEnable()
{
base.OnEnable();
sourceAnimatorProp = serializedObject.FindProperty("sourceAnimator");
targetAnimatorProp = serializedObject.FindProperty("targetAnimator");
hipsOffsetXProp = serializedObject.FindProperty("hipsOffsetX");
hipsOffsetYProp = serializedObject.FindProperty("hipsOffsetY");
hipsOffsetZProp = serializedObject.FindProperty("hipsOffsetZ");
debugAxisNormalizerProp = serializedObject.FindProperty("debugAxisNormalizer");
fingerCopyModeProp = serializedObject.FindProperty("fingerCopyMode");
kneeInOutWeightProp = serializedObject.FindProperty("kneeInOutWeight");
kneeFrontBackWeightProp = serializedObject.FindProperty("kneeFrontBackWeight");
footFrontBackOffsetProp = serializedObject.FindProperty("footFrontBackOffset");
footInOutOffsetProp = serializedObject.FindProperty("footInOutOffset");
floorHeightProp = serializedObject.FindProperty("floorHeight");
avatarScaleProp = serializedObject.FindProperty("avatarScale");
}
public override VisualElement CreateInspectorGUI()
@ -61,52 +22,68 @@ namespace KindRetargeting
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
if (commonUss != null) root.styleSheets.Add(commonUss);
// 원본 Animator
root.Add(new PropertyField(sourceAnimatorProp, "원본 Animator"));
// ── 기본 설정 ──
root.Add(new PropertyField(serializedObject.FindProperty("sourceAnimator"), "원본 Animator"));
// 아바타 크기 설정
// ── 아바타 크기 ──
var scaleFoldout = new Foldout { text = "아바타 크기 설정", value = true };
scaleFoldout.Add(new PropertyField(avatarScaleProp, "아바타 크기"));
scaleFoldout.Add(new PropertyField(serializedObject.FindProperty("avatarScale"), "아바타 크기"));
scaleFoldout.Add(new PropertyField(serializedObject.FindProperty("headScale"), "머리 크기"));
root.Add(scaleFoldout);
// 힙 위치 보정
// ── 힙 위치 보정 ──
root.Add(BuildHipsSection());
// 무릎 위치 조정
var kneeFoldout = new Foldout { text = "무릎 위치 조정", value = true };
kneeFoldout.Add(new Slider("무릎 앞/뒤 가중치", -1f, 1f) { showInputField = true, tooltip = "음수: 뒤로, 양수: 앞으로" });
kneeFoldout.Q<Slider>().BindProperty(kneeFrontBackWeightProp);
var kneeInOut = new Slider("무릎 안/밖 가중치", -1f, 1f) { showInputField = true, tooltip = "음수: 안쪽, 양수: 바깥쪽" };
kneeInOut.BindProperty(kneeInOutWeightProp);
kneeFoldout.Add(kneeInOut);
// ── 무릎 위치 조정 ──
var kneeFoldout = new Foldout { text = "무릎 위치 조정", value = false };
var kneeFB = new Slider("무릎 앞/뒤", -1f, 1f) { showInputField = true };
kneeFB.BindProperty(serializedObject.FindProperty("kneeFrontBackWeight"));
kneeFoldout.Add(kneeFB);
var kneeIO = new Slider("무릎 안/밖", -1f, 1f) { showInputField = true };
kneeIO.BindProperty(serializedObject.FindProperty("kneeInOutWeight"));
kneeFoldout.Add(kneeIO);
root.Add(kneeFoldout);
// 발 IK 위치 조정
var footFoldout = new Foldout { text = "발 IK 위치 조정", value = true };
var footFB = new Slider("발 앞/뒤 오프셋", -1f, 1f) { showInputField = true, tooltip = "+: 앞으로, -: 뒤로" };
footFB.BindProperty(footFrontBackOffsetProp);
// ── 발 IK 위치 조정 ──
var footFoldout = new Foldout { text = "발 IK 위치 조정", value = false };
var footFB = new Slider("발 앞/뒤", -1f, 1f) { showInputField = true };
footFB.BindProperty(serializedObject.FindProperty("footFrontBackOffset"));
footFoldout.Add(footFB);
var footIO = new Slider("발 벌리기/모으기", -1f, 1f) { showInputField = true, tooltip = "+: 벌리기, -: 모으기" };
footIO.BindProperty(footInOutOffsetProp);
var footIO = new Slider("발 벌리기/모으기", -1f, 1f) { showInputField = true };
footIO.BindProperty(serializedObject.FindProperty("footInOutOffset"));
footFoldout.Add(footIO);
root.Add(footFoldout);
// 손가락 복제 설정
root.Add(BuildFingerCopySection());
// 바닥 높이 조정
// ── 바닥 높이 ──
var floorFoldout = new Foldout { text = "바닥 높이 조정", value = false };
floorFoldout.Add(new PropertyField(floorHeightProp, "바닥 높이 (-1 ~ 1)"));
floorFoldout.Add(new PropertyField(serializedObject.FindProperty("floorHeight"), "바닥 높이 (-1 ~ 1)"));
floorFoldout.Add(new PropertyField(serializedObject.FindProperty("minimumAnkleHeight"), "최소 발목 높이"));
root.Add(floorFoldout);
// 접지 설정 (FootGroundingController)
// ── 머리 회전 오프셋 ──
root.Add(BuildHeadRotationSection());
// ── 어깨 보정 (ShoulderCorrection) ──
root.Add(BuildShoulderSection());
// ── 사지 가중치 (LimbWeight) ──
root.Add(BuildLimbWeightSection());
// ── 접지 설정 (FootGrounding) ──
root.Add(BuildGroundingSection());
// 캐시 상태 + 캘리브레이션 버튼
// ── 손가락 복제 설정 ──
var fingerCopyFoldout = new Foldout { text = "손가락 복제 설정", value = false };
fingerCopyFoldout.Add(new PropertyField(serializedObject.FindProperty("fingerCopyMode"), "복제 방식"));
root.Add(fingerCopyFoldout);
// ── 손가락 셰이핑 (FingerShaped) ──
root.Add(BuildFingerShapedSection());
// ── 캘리브레이션 ──
root.Add(BuildCacheSection());
// 변경 시 저장
// ── 변경 시 저장 ──
root.TrackSerializedObjectValue(serializedObject, so =>
{
if (target == null) return;
@ -118,225 +95,504 @@ namespace KindRetargeting
return root;
}
// ========== 힙 위치 보정 ==========
private VisualElement BuildHipsSection()
{
var foldout = new Foldout { text = "힙 위치 보정 (로컬 좌표계)" };
var foldout = new Foldout { text = "힙 위치 보정 (로컬 좌표계)", value = true };
// 축 매핑 정보
var axisInfo = new HelpBox("플레이 모드에서 T-포즈 분석 후 축 매핑 정보가 표시됩니다.\n이 매핑은 각 아바타의 힙 로컬 축 방향에 맞춰 자동 계산됩니다.", HelpBoxMessageType.Info);
var axisInfo = new HelpBox("플레이 모드에서 축 매핑 정보가 표시됩니다.", HelpBoxMessageType.Info);
foldout.Add(axisInfo);
// 주기적으로 축 매핑 정보 갱신
foldout.schedule.Execute(() =>
{
if (target == null || debugAxisNormalizerProp == null) return;
if (target == null) return;
serializedObject.Update();
Vector3 axisMapping = debugAxisNormalizerProp.vector3Value;
if (Application.isPlaying && axisMapping != Vector3.one)
{
string GetAxisName(float value)
{
int axis = Mathf.RoundToInt(Mathf.Abs(value));
string sign = value > 0 ? "+" : "-";
return axis switch { 1 => $"{sign}X", 2 => $"{sign}Y", 3 => $"{sign}Z", _ => "?" };
}
axisInfo.text = "T-포즈에서 분석된 축 매핑:\n" +
$" 좌우 오프셋 → 로컬 {GetAxisName(axisMapping.x)} 축\n" +
$" 상하 오프셋 → 로컬 {GetAxisName(axisMapping.y)} 축\n" +
$" 앞뒤 오프셋 → 로컬 {GetAxisName(axisMapping.z)} 축\n\n" +
"이 매핑 덕분에 모든 아바타에서 동일하게 작동합니다.";
}
else
{
axisInfo.text = "플레이 모드에서 T-포즈 분석 후 축 매핑 정보가 표시됩니다.\n이 매핑은 각 아바타의 힙 로컬 축 방향에 맞춰 자동 계산됩니다.";
}
var axisProp = serializedObject.FindProperty("debugAxisNormalizer");
if (axisProp == null || !Application.isPlaying) { axisInfo.text = "플레이 모드에서 축 매핑 정보가 표시됩니다."; return; }
Vector3 m = axisProp.vector3Value;
if (m == Vector3.one) { axisInfo.text = "플레이 모드에서 축 매핑 정보가 표시됩니다."; return; }
string A(float v) => Mathf.RoundToInt(Mathf.Abs(v)) switch { 1 => (v > 0 ? "+X" : "-X"), 2 => (v > 0 ? "+Y" : "-Y"), 3 => (v > 0 ? "+Z" : "-Z"), _ => "?" };
axisInfo.text = $"축 매핑: 좌우→{A(m.x)} 상하→{A(m.y)} 앞뒤→{A(m.z)}";
}).Every(500);
foldout.Add(new PropertyField(hipsOffsetXProp, "좌우 오프셋 (←-/+→)") { tooltip = "캐릭터 기준 왼쪽(-) / 오른쪽(+)" });
foldout.Add(new PropertyField(hipsOffsetYProp, "상하 오프셋 (↓-/+↑)") { tooltip = "캐릭터 기준 아래(-) / 위(+)" });
foldout.Add(new PropertyField(hipsOffsetZProp, "앞뒤 오프셋 (←-/+→)") { tooltip = "캐릭터 기준 뒤(-) / 앞(+)" });
foldout.Add(new HelpBox("로컬 좌표계 기반: 캐릭터의 회전 상태와 관계없이 항상 캐릭터 기준으로 이동합니다.", HelpBoxMessageType.Info));
var hx = new Slider("← 좌우 →", -1f, 1f) { showInputField = true };
hx.BindProperty(serializedObject.FindProperty("hipsOffsetX"));
foldout.Add(hx);
var hy = new Slider("↓ 상하 ↑", -1f, 1f) { showInputField = true };
hy.BindProperty(serializedObject.FindProperty("hipsOffsetY"));
foldout.Add(hy);
var hz = new Slider("← 앞뒤 →", -1f, 1f) { showInputField = true };
hz.BindProperty(serializedObject.FindProperty("hipsOffsetZ"));
foldout.Add(hz);
// 의자 앉기 높이
var chairSlider = new Slider("의자 앉기 높이", -1f, 1f) { showInputField = true, tooltip = "의자에 앉을 때 엉덩이 높이 조정" };
chairSlider.BindProperty(serializedObject.FindProperty("limbWeight.chairSeatHeightOffset"));
foldout.Add(chairSlider);
// 다리 길이 자동 보정 버튼
var autoHipsBtn = new Button(() =>
{
if (!Application.isPlaying)
{
Debug.LogWarning("다리 길이 자동 보정은 플레이 모드에서만 사용 가능합니다.");
return;
}
if (!Application.isPlaying) { Debug.LogWarning("플레이 모드에서만 사용 가능합니다."); return; }
var script = (CustomRetargetingScript)target;
float offset = CalculateHipsOffsetFromLegDifference(script);
hipsOffsetYProp.floatValue = offset;
serializedObject.FindProperty("hipsOffsetY").floatValue = offset;
serializedObject.ApplyModifiedProperties();
script.SaveSettings();
Debug.Log($"자동 보정 완료: hipsOffsetY = {offset:F4}");
}) { text = "다리 길이 자동 보정", tooltip = "소스/타겟 다리 길이 차이로 힙 상하 오프셋을 자동 계산합니다. (플레이 모드 전용)" };
autoHipsBtn.style.marginTop = 4;
}) { text = "다리 길이 자동 보정", tooltip = "소스/타겟 다리 길이 차이로 힙 상하 오프셋을 자동 계산합니다." };
autoHipsBtn.style.marginTop = 4; autoHipsBtn.style.height = 25;
foldout.Add(autoHipsBtn);
return foldout;
}
private VisualElement BuildFingerCopySection()
// ========== 머리 회전 오프셋 ==========
private VisualElement BuildHeadRotationSection()
{
var foldout = new Foldout { text = "손가락 복제 설정" };
foldout.Add(new PropertyField(fingerCopyModeProp, "복제 방식") { tooltip = "손가락 포즈를 복제하는 방식을 선택합니다." });
var foldout = new Foldout { text = "머리 회전 오프셋", value = false };
var xProp = serializedObject.FindProperty("headRotationOffsetX");
var yProp = serializedObject.FindProperty("headRotationOffsetY");
var zProp = serializedObject.FindProperty("headRotationOffsetZ");
var xSlider = new Slider("X (Roll)", -180f, 180f) { showInputField = true };
xSlider.BindProperty(xProp);
foldout.Add(xSlider);
var ySlider = new Slider("Y (Yaw)", -180f, 180f) { showInputField = true };
ySlider.BindProperty(yProp);
foldout.Add(ySlider);
var zSlider = new Slider("Z (Pitch)", -180f, 180f) { showInputField = true };
zSlider.BindProperty(zProp);
foldout.Add(zSlider);
var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } };
var resetBtn = new Button(() =>
{
xProp.floatValue = yProp.floatValue = zProp.floatValue = 0f;
serializedObject.ApplyModifiedProperties();
EditorUtility.SetDirty(target);
}) { text = "초기화" };
resetBtn.style.flexGrow = 1; resetBtn.style.height = 25; resetBtn.style.marginRight = 2;
btnRow.Add(resetBtn);
var calibBtn = new Button(() =>
{
if (!Application.isPlaying) { Debug.LogWarning("플레이 모드에서만 사용 가능합니다."); return; }
CalibrateHeadToForward(serializedObject, xProp, yProp, zProp);
}) { text = "정면 캘리브레이션" };
calibBtn.style.flexGrow = 1; calibBtn.style.height = 25;
btnRow.Add(calibBtn);
foldout.Add(btnRow);
foldout.schedule.Execute(() =>
{
calibBtn.SetEnabled(Application.isPlaying);
}).Every(500);
return foldout;
}
// ========== 어깨 보정 ==========
private VisualElement BuildShoulderSection()
{
var foldout = new Foldout { text = "어깨 보정 (ShoulderCorrection)", value = false };
var strength = new Slider("블렌드 강도", 0f, 5f) { showInputField = true };
strength.BindProperty(serializedObject.FindProperty("shoulderCorrection.blendStrength"));
foldout.Add(strength);
var maxBlend = new Slider("최대 블렌드", 0f, 1f) { showInputField = true };
maxBlend.BindProperty(serializedObject.FindProperty("shoulderCorrection.maxShoulderBlend"));
foldout.Add(maxBlend);
foldout.Add(new PropertyField(serializedObject.FindProperty("shoulderCorrection.reverseLeftRotation"), "왼쪽 회전 반전"));
foldout.Add(new PropertyField(serializedObject.FindProperty("shoulderCorrection.reverseRightRotation"), "오른쪽 회전 반전"));
foldout.Add(BuildMinMaxRange("높이 차이 범위",
serializedObject.FindProperty("shoulderCorrection.minHeightDifference"),
serializedObject.FindProperty("shoulderCorrection.maxHeightDifference"),
-0.5f, 2f));
foldout.Add(new PropertyField(serializedObject.FindProperty("shoulderCorrection.shoulderCorrectionCurve"), "보정 커브"));
return foldout;
}
// ========== 사지 가중치 ==========
private VisualElement BuildLimbWeightSection()
{
var foldout = new Foldout { text = "사지 가중치 (LimbWeight)", value = false };
// IK 활성화 토글
foldout.Add(new PropertyField(serializedObject.FindProperty("limbWeight.enableLeftArmIK"), "왼팔 IK 활성화"));
foldout.Add(new PropertyField(serializedObject.FindProperty("limbWeight.enableRightArmIK"), "오른팔 IK 활성화"));
foldout.Add(BuildMinMaxRange("손-프랍 거리 범위 (가중치 1→0)",
serializedObject.FindProperty("limbWeight.minDistance"),
serializedObject.FindProperty("limbWeight.maxDistance"),
0f, 1f));
var smoothField = new Slider("가중치 변화 속도", 0.1f, 20f) { showInputField = true };
smoothField.BindProperty(serializedObject.FindProperty("limbWeight.weightSmoothSpeed"));
foldout.Add(smoothField);
foldout.Add(BuildMinMaxRange("의자-허리 거리 범위 (가중치 1→0)",
serializedObject.FindProperty("limbWeight.hipsMinDistance"),
serializedObject.FindProperty("limbWeight.hipsMaxDistance"),
0f, 1f));
foldout.Add(BuildMinMaxRange("바닥-허리 높이 블렌딩 (가중치 0→1)",
serializedObject.FindProperty("limbWeight.groundHipsMinHeight"),
serializedObject.FindProperty("limbWeight.groundHipsMaxHeight"),
0f, 2f));
foldout.Add(BuildMinMaxRange("발 높이 IK 블렌딩 (가중치 1→0)",
serializedObject.FindProperty("limbWeight.footHeightMinThreshold"),
serializedObject.FindProperty("limbWeight.footHeightMaxThreshold"),
0.1f, 1f));
return foldout;
}
// ========== 접지 설정 ==========
private VisualElement BuildGroundingSection()
{
var foldout = new Foldout { text = "접지 설정 (FootGrounding)", value = false };
foldout.Add(new HelpBox(
"HIK 스타일 2-Pass 접지 시스템:\n" +
"• Pre-IK: IK 타겟을 조정하여 발이 바닥을 뚫지 않도록 보정\n" +
"• Post-IK: Foot 회전으로 Toes 접지 잔차 미세 보정\n" +
"• Toe Pivot: 발끝 고정 + 발목 회전 자동 감지",
"• Pre-IK: 발이 바닥을 뚫지 않도록 IK 타겟 보정\n" +
"• Post-IK: Foot 회전으로 Toes 접지 미세 보정",
HelpBoxMessageType.Info));
// FootGroundingController의 SerializedObject를 직접 바인딩
var script = (CustomRetargetingScript)target;
var grounding = script.GetComponent<FootGroundingController>();
if (grounding != null)
foldout.Add(new PropertyField(serializedObject.FindProperty("footGrounding.groundHeight"), "바닥 높이"));
var weightSlider = new Slider("접지 강도", 0f, 1f) { showInputField = true };
weightSlider.BindProperty(serializedObject.FindProperty("footGrounding.groundingWeight"));
foldout.Add(weightSlider);
foldout.Add(new PropertyField(serializedObject.FindProperty("footGrounding.activationHeight"), "활성화 높이") { tooltip = "발목이 이 높이 이상이면 보정 비활성화" });
foldout.Add(new PropertyField(serializedObject.FindProperty("footGrounding.plantThreshold"), "접지 판정 범위"));
var smoothField = new Slider("스무딩 속도", 1f, 30f) { showInputField = true };
smoothField.BindProperty(serializedObject.FindProperty("footGrounding.smoothSpeed"));
foldout.Add(smoothField);
return foldout;
}
// ========== 손가락 셰이핑 ==========
private VisualElement BuildFingerShapedSection()
{
var foldout = new Foldout { text = "손가락 셰이핑 (FingerShaped)", value = false };
foldout.Add(new PropertyField(serializedObject.FindProperty("fingerShaped.enabled"), "셰이핑 활성화"));
foldout.Add(BuildHandSliders("왼손", "left"));
foldout.Add(BuildHandSliders("오른손", "right"));
// 프리셋 버튼
var presetLabel = new Label("프리셋") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginTop = 6 } };
foldout.Add(presetLabel);
string[,] presets = { { "가위", "바위", "보" }, { "브이", "검지", "초기화" } };
for (int row = 0; row < 2; row++)
{
groundingSO = new SerializedObject(grounding);
var groundHeightField = new PropertyField(groundingSO.FindProperty("groundHeight"), "바닥 높이");
foldout.Add(groundHeightField);
var weightSlider = new Slider("접지 강도", 0f, 1f) { showInputField = true };
weightSlider.BindProperty(groundingSO.FindProperty("groundingWeight"));
foldout.Add(weightSlider);
var activationField = new PropertyField(groundingSO.FindProperty("activationHeight"), "활성화 높이");
activationField.tooltip = "발목이 이 높이 이상이면 접지 보정 비활성화 (점프 등)";
foldout.Add(activationField);
var thresholdField = new PropertyField(groundingSO.FindProperty("plantThreshold"), "접지 판정 범위");
thresholdField.tooltip = "Toes가 이 범위 안이면 접지 중으로 판정";
foldout.Add(thresholdField);
var smoothField = new PropertyField(groundingSO.FindProperty("smoothSpeed"), "보정 스무딩 속도");
smoothField.tooltip = "보정량 변화 속도 (높을수록 빠른 반응, 낮으면 부드러운 전환)";
foldout.Add(smoothField);
foldout.Add(new HelpBox("힙 높이 보정은 '바닥 높이 조정' 섹션의 floorHeight로 제어합니다.", HelpBoxMessageType.Info));
foldout.TrackSerializedObjectValue(groundingSO, so => so.ApplyModifiedProperties());
}
else
{
foldout.Add(new HelpBox("FootGroundingController 컴포넌트를 찾을 수 없습니다.", HelpBoxMessageType.Warning));
var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, justifyContent = Justify.Center, marginBottom = 2 } };
for (int col = 0; col < 3; col++)
{
string name = presets[row, col];
var btn = new Button(() => ApplyFingerPreset(name)) { text = name };
btn.style.height = 26; btn.style.width = 80; btn.style.marginLeft = btn.style.marginRight = 2;
btnRow.Add(btn);
}
foldout.Add(btnRow);
}
return foldout;
}
private VisualElement BuildHandSliders(string label, string prefix)
{
var handFoldout = new Foldout { text = label, value = false };
handFoldout.Add(new PropertyField(serializedObject.FindProperty($"fingerShaped.{prefix}HandEnabled"), "활성화"));
string[] names = { "Thumb", "Index", "Middle", "Ring", "Pinky" };
string[] korNames = { "엄지", "검지", "중지", "약지", "새끼" };
var slidersRow = new VisualElement { style = { flexDirection = FlexDirection.Row, justifyContent = Justify.Center, marginTop = 4 } };
for (int i = 0; i < names.Length; i++)
{
var col = new VisualElement { style = { alignItems = Align.Center, width = 45, marginLeft = 2, marginRight = 2 } };
col.Add(new Label(korNames[i]) { style = { fontSize = 10, color = new Color(0.6f, 0.6f, 0.6f) } });
var prop = serializedObject.FindProperty($"fingerShaped.{prefix}{names[i]}Curl");
var valLabel = new Label(prop.floatValue.ToString("F1")) { style = { fontSize = 10, color = new Color(0.6f, 0.6f, 0.6f) } };
col.Add(valLabel);
var slider = new Slider(-1f, 1f) { direction = SliderDirection.Vertical };
slider.style.height = 80; slider.style.width = 25;
slider.BindProperty(prop);
slider.RegisterValueChangedCallback(evt => valLabel.text = evt.newValue.ToString("F1"));
col.Add(slider);
slidersRow.Add(col);
}
handFoldout.Add(slidersRow);
var spreadSlider = new Slider("벌리기", -1f, 1f) { showInputField = true };
spreadSlider.BindProperty(serializedObject.FindProperty($"fingerShaped.{prefix}SpreadFingers"));
handFoldout.Add(spreadSlider);
return handFoldout;
}
private void ApplyFingerPreset(string presetName)
{
var script = (CustomRetargetingScript)target;
var fc = script.fingerShaped;
if (!fc.enabled) fc.enabled = true;
(float t, float i, float m, float r, float p, float s) = presetName switch
{
"가위" => (1f, 1f, -1f, -1f, -1f, 0.3f),
"바위" => (-1f, -1f, -1f, -1f, -1f, 0f),
"보" => (1f, 1f, 1f, 1f, 1f, 1f),
"브이" => (-1f, 1f, 1f, -1f, -1f, 1f),
"검지" => (-1f, 1f, -1f, -1f, -1f, 0f),
"초기화" => (0.8f, 0.8f, 0.8f, 0.8f, 0.8f, 0.8f),
_ => (0f, 0f, 0f, 0f, 0f, 0f)
};
if (fc.leftHandEnabled)
{
fc.leftThumbCurl = t; fc.leftIndexCurl = i; fc.leftMiddleCurl = m;
fc.leftRingCurl = r; fc.leftPinkyCurl = p; fc.leftSpreadFingers = s;
}
if (fc.rightHandEnabled)
{
fc.rightThumbCurl = t; fc.rightIndexCurl = i; fc.rightMiddleCurl = m;
fc.rightRingCurl = r; fc.rightPinkyCurl = p; fc.rightSpreadFingers = s;
}
EditorUtility.SetDirty(target);
}
// ========== 캘리브레이션 ==========
private VisualElement BuildCacheSection()
{
var container = new VisualElement { style = { marginTop = 8 } };
var box = new VisualElement { style = { marginTop = 8 } };
box.style.backgroundColor = new Color(0, 0, 0, 0.1f);
box.style.borderTopLeftRadius = box.style.borderTopRightRadius =
box.style.borderBottomLeftRadius = box.style.borderBottomRightRadius = 4;
box.style.paddingTop = box.style.paddingBottom =
box.style.paddingLeft = box.style.paddingRight = 6;
var script = (CustomRetargetingScript)target;
bool hasCached = script.HasCachedSettings();
cacheStatusLabel = new Label(hasCached ?
"캘리브레이션 데이터가 저장되어 있습니다." :
"저장된 캘리브레이션 데이터가 없습니다.");
cacheStatusLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
container.Add(cacheStatusLabel);
var cacheLabel = new Label();
cacheLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
box.Add(cacheLabel);
var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } };
var calibBtn = new Button(() =>
{
((CustomRetargetingScript)target).I_PoseCalibration();
UpdateCacheStatus();
}) { text = "I-포즈 캘리브레이션" };
calibBtn.style.flexGrow = 1;
calibBtn.style.marginRight = 2;
var calibBtn = new Button(() => { ((CustomRetargetingScript)target).I_PoseCalibration(); }) { text = "I-포즈 캘리브레이션" };
calibBtn.style.flexGrow = 1; calibBtn.style.marginRight = 2;
btnRow.Add(calibBtn);
var deleteCacheBtn = new Button(() =>
var resetBtn = new Button(() => { ((CustomRetargetingScript)target).ResetPoseAndCache(); }) { text = "캘리브레이션 초기화" };
resetBtn.style.flexGrow = 1;
btnRow.Add(resetBtn);
box.Add(btnRow);
// 전체 자동 보정
var autoBtn = new Button(() =>
{
((CustomRetargetingScript)target).ResetPoseAndCache();
UpdateCacheStatus();
}) { text = "캐시 데이터 삭제" };
deleteCacheBtn.style.flexGrow = 1;
btnRow.Add(deleteCacheBtn);
if (!Application.isPlaying) { Debug.LogWarning("플레이 모드에서만 사용 가능합니다."); return; }
AutoCalibrateAll((CustomRetargetingScript)target, serializedObject);
}) { text = "전체 자동 보정 (크기 + 힙 높이 + 머리 정면)", tooltip = "아바타 크기, 힙 높이, 머리 정면을 자동 보정합니다." };
autoBtn.style.marginTop = 4; autoBtn.style.height = 28;
box.Add(autoBtn);
container.Add(btnRow);
// 주기적으로 캐시 상태 갱신
container.schedule.Execute(() =>
box.schedule.Execute(() =>
{
if (target == null) return;
bool cached = ((CustomRetargetingScript)target).HasCachedSettings();
deleteCacheBtn.style.display = cached ? DisplayStyle.Flex : DisplayStyle.None;
cacheStatusLabel.text = cached ?
"캘리브레이션 데이터가 저장되어 있습니다." :
"저장된 캘리브레이션 데이터가 없습니다.";
cacheLabel.text = cached ? "캘리브레이션 데이터가 저장되어 있습니다." : "저장된 캘리브레이션 데이터가 없습니다.";
resetBtn.style.display = cached ? DisplayStyle.Flex : DisplayStyle.None;
}).Every(1000);
deleteCacheBtn.style.display = hasCached ? DisplayStyle.Flex : DisplayStyle.None;
return box;
}
// ========== MinMax 헬퍼 ==========
private VisualElement BuildMinMaxRange(string label, SerializedProperty minProp, SerializedProperty maxProp, float limitMin, float limitMax)
{
var container = new VisualElement { style = { marginBottom = 4 } };
if (minProp == null || maxProp == null)
{
container.Add(new HelpBox($"'{label}' 프로퍼티를 찾을 수 없습니다.", HelpBoxMessageType.Warning));
return container;
}
container.Add(new Label(label) { style = { marginBottom = 2, fontSize = 11 } });
var row = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center } };
var minField = new FloatField { value = minProp.floatValue, style = { width = 50 } };
var slider = new MinMaxSlider(minProp.floatValue, maxProp.floatValue, limitMin, limitMax);
slider.style.flexGrow = 1; slider.style.marginLeft = slider.style.marginRight = 4;
var maxField = new FloatField { value = maxProp.floatValue, style = { width = 50 } };
slider.RegisterValueChangedCallback(evt =>
{
minProp.floatValue = evt.newValue.x; maxProp.floatValue = evt.newValue.y;
serializedObject.ApplyModifiedProperties();
minField.SetValueWithoutNotify(evt.newValue.x); maxField.SetValueWithoutNotify(evt.newValue.y);
});
minField.RegisterValueChangedCallback(evt =>
{
float v = Mathf.Clamp(evt.newValue, limitMin, maxProp.floatValue);
minProp.floatValue = v; serializedObject.ApplyModifiedProperties();
slider.SetValueWithoutNotify(new Vector2(v, maxProp.floatValue)); minField.SetValueWithoutNotify(v);
});
maxField.RegisterValueChangedCallback(evt =>
{
float v = Mathf.Clamp(evt.newValue, minProp.floatValue, limitMax);
maxProp.floatValue = v; serializedObject.ApplyModifiedProperties();
slider.SetValueWithoutNotify(new Vector2(minProp.floatValue, v)); maxField.SetValueWithoutNotify(v);
});
container.TrackPropertyValue(minProp, p => { minField.SetValueWithoutNotify(p.floatValue); slider.SetValueWithoutNotify(new Vector2(p.floatValue, maxProp.floatValue)); });
container.TrackPropertyValue(maxProp, p => { maxField.SetValueWithoutNotify(p.floatValue); slider.SetValueWithoutNotify(new Vector2(minProp.floatValue, p.floatValue)); });
row.Add(minField); row.Add(slider); row.Add(maxField);
container.Add(row);
return container;
}
private void UpdateCacheStatus()
// ========== 자동 보정 ==========
private void AutoCalibrateAll(CustomRetargetingScript script, SerializedObject so)
{
if (cacheStatusLabel == null || target == null) return;
bool hasCached = ((CustomRetargetingScript)target).HasCachedSettings();
cacheStatusLabel.text = hasCached ?
"캘리브레이션 데이터가 저장되어 있습니다." :
"저장된 캘리브레이션 데이터가 없습니다.";
Animator source = script.sourceAnimator;
Animator targetAnim = script.targetAnimator;
if (source == null || targetAnim == null || !source.isHuman || !targetAnim.isHuman)
{
Debug.LogWarning("소스/타겟 Animator가 없거나 Humanoid가 아닙니다.");
return;
}
script.ResetScale();
so.FindProperty("avatarScale").floatValue = 1f;
so.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script);
so.ApplyModifiedProperties();
EditorApplication.delayCall += () =>
{
if (script == null) return;
Transform sourceNeck = source.GetBoneTransform(HumanBodyBones.Neck);
Transform targetNeck = targetAnim.GetBoneTransform(HumanBodyBones.Neck);
if (sourceNeck == null || targetNeck == null) return;
float scaleRatio = Mathf.Clamp(sourceNeck.position.y / Mathf.Max(targetNeck.position.y, 0.01f), 0.1f, 3f);
script.SetAvatarScale(scaleRatio);
var so2 = new SerializedObject(script);
so2.FindProperty("avatarScale").floatValue = scaleRatio;
so2.ApplyModifiedProperties();
EditorApplication.delayCall += () =>
{
if (script == null) return;
var so3 = new SerializedObject(script);
so3.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script);
var xP = so3.FindProperty("headRotationOffsetX");
var yP = so3.FindProperty("headRotationOffsetY");
var zP = so3.FindProperty("headRotationOffsetZ");
if (xP != null && yP != null && zP != null)
CalibrateHeadToForward(so3, xP, yP, zP);
so3.ApplyModifiedProperties();
script.SaveSettings();
Debug.Log($"전체 자동 보정 완료: avatarScale={scaleRatio:F3}");
};
};
}
/// <summary>
/// 소스/타겟 다리 길이 차이로 힙 상하 오프셋을 계산합니다.
/// 소스 다리가 더 길면 → 음수 (힙을 내려서 타겟이 뜨지 않게)
/// 소스 다리가 더 짧으면 → 양수 (힙을 올려서 타겟 다리를 펴줌)
/// </summary>
// ========== 유틸리티 ==========
private float CalculateHipsOffsetFromLegDifference(CustomRetargetingScript script)
{
Animator source = script.sourceAnimator;
Animator target = script.targetAnimator;
if (source == null || target == null || !source.isHuman || !target.isHuman)
{
Debug.LogWarning("소스/타겟 Animator가 없거나 Humanoid가 아닙니다.");
return 0f;
}
Animator targetAnim = script.targetAnimator;
if (source == null || targetAnim == null) return 0f;
float sourceLeg = GetLegLength(source);
float targetLeg = GetLegLength(target);
float targetLeg = GetLegLength(targetAnim);
if (sourceLeg < 0.01f || targetLeg < 0.01f) return 0f;
if (sourceLeg < 0.01f || targetLeg < 0.01f)
{
Debug.LogWarning("다리 길이를 계산할 수 없습니다. 본이 올바르게 설정되어 있는지 확인해주세요.");
return 0f;
}
// 소스 다리가 더 길면 타겟이 뜨므로 힙을 내려야 함 (음수)
// 소스 다리가 더 짧으면 타겟 다리가 구부러지므로 힙을 올려야 함 (양수)
float diff = targetLeg - sourceLeg;
Debug.Log($"소스 다리 길이: {sourceLeg:F4}, 타겟 다리 길이: {targetLeg:F4}, 힙 오프셋: {diff:F4}m");
return diff;
return targetLeg - sourceLeg;
}
private float GetLegLength(Animator animator)
{
Transform upperLeg = animator.GetBoneTransform(HumanBodyBones.LeftUpperLeg);
Transform lowerLeg = animator.GetBoneTransform(HumanBodyBones.LeftLowerLeg);
Transform upper = animator.GetBoneTransform(HumanBodyBones.LeftUpperLeg);
Transform lower = animator.GetBoneTransform(HumanBodyBones.LeftLowerLeg);
Transform foot = animator.GetBoneTransform(HumanBodyBones.LeftFoot);
if (upper == null || lower == null || foot == null) return 0f;
return Vector3.Distance(upper.position, lower.position) + Vector3.Distance(lower.position, foot.position);
}
if (upperLeg == null || lowerLeg == null || foot == null) return 0f;
private void CalibrateHeadToForward(SerializedObject so, SerializedProperty xProp, SerializedProperty yProp, SerializedProperty zProp)
{
CustomRetargetingScript script = so.targetObject as CustomRetargetingScript;
if (script == null) return;
float upper = Vector3.Distance(upperLeg.position, lowerLeg.position);
float lower = Vector3.Distance(lowerLeg.position, foot.position);
return upper + lower;
Animator targetAnimator = script.GetComponent<Animator>();
if (targetAnimator == null) return;
Transform targetHead = targetAnimator.GetBoneTransform(HumanBodyBones.Head);
if (targetHead == null) return;
Vector3 tPoseForward = script.tPoseHeadForward;
Vector3 tPoseUp = script.tPoseHeadUp;
if (tPoseForward.sqrMagnitude < 0.001f) return;
float prevX = xProp.floatValue, prevY = yProp.floatValue, prevZ = zProp.floatValue;
Quaternion currentLocalRot = targetHead.localRotation;
Quaternion prevOffset = Quaternion.Euler(prevX, prevY, prevZ);
Quaternion baseLocalRot = currentLocalRot * Quaternion.Inverse(prevOffset);
Transform headParent = targetHead.parent;
Quaternion parentWorldRot = headParent != null ? headParent.rotation : Quaternion.identity;
Quaternion baseWorldRot = parentWorldRot * baseLocalRot;
Vector3 currentHeadForward = baseWorldRot * Vector3.forward;
Vector3 currentHeadUp = baseWorldRot * Vector3.up;
Quaternion forwardCorrection = Quaternion.FromToRotation(currentHeadForward, tPoseForward);
Vector3 correctedUp = forwardCorrection * currentHeadUp;
float rollAngle = Vector3.SignedAngle(correctedUp, tPoseUp, tPoseForward);
Quaternion rollCorrection = Quaternion.AngleAxis(rollAngle, tPoseForward);
Quaternion worldCorrection = rollCorrection * forwardCorrection;
Quaternion correctedWorldRot = worldCorrection * baseWorldRot;
Quaternion correctedLocalRot = Quaternion.Inverse(parentWorldRot) * correctedWorldRot;
Quaternion offsetQuat = Quaternion.Inverse(baseLocalRot) * correctedLocalRot;
Vector3 euler = offsetQuat.eulerAngles;
if (euler.x > 180f) euler.x -= 360f;
if (euler.y > 180f) euler.y -= 360f;
if (euler.z > 180f) euler.z -= 360f;
xProp.floatValue = Mathf.Clamp(euler.x, -180f, 180f);
yProp.floatValue = Mathf.Clamp(euler.y, -180f, 180f);
zProp.floatValue = Mathf.Clamp(euler.z, -180f, 180f);
so.ApplyModifiedProperties();
EditorUtility.SetDirty(so.targetObject);
}
}
}

View File

@ -1,193 +0,0 @@
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;
using UnityEditor.UIElements;
[CustomEditor(typeof(FingerShapedController))]
public class FingerShapedControllerEditor : Editor
{
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
private static readonly string[] fingerPropNames = { "ThumbCurl", "IndexCurl", "MiddleCurl", "RingCurl", "PinkyCurl" };
private static readonly string[] fingerKoreanNames = { "엄지", "검지", "중지", "약지", "새끼" };
private FingerShapedController controller;
public override VisualElement CreateInspectorGUI()
{
controller = target as FingerShapedController;
if (controller == null) return new VisualElement();
var root = new VisualElement();
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
if (commonUss != null) root.styleSheets.Add(commonUss);
// 활성화 토글
var enabledProp = serializedObject.FindProperty("m_Enabled");
var enableToggle = new PropertyField(enabledProp, "손가락 제어 활성화");
root.Add(enableToggle);
// 왼손
var leftFoldout = new Foldout { text = "왼손", value = true };
leftFoldout.Add(BuildHandControls("left"));
root.Add(leftFoldout);
// 오른손
var rightFoldout = new Foldout { text = "오른손", value = true };
rightFoldout.Add(BuildHandControls("right"));
root.Add(rightFoldout);
// 프리셋 버튼
root.Add(BuildPresetButtons());
return root;
}
private VisualElement BuildHandControls(string prefix)
{
var container = new VisualElement();
container.style.backgroundColor = new Color(0, 0, 0, 0.08f);
container.style.borderTopLeftRadius = container.style.borderTopRightRadius =
container.style.borderBottomLeftRadius = container.style.borderBottomRightRadius = 4;
container.style.paddingTop = container.style.paddingBottom =
container.style.paddingLeft = container.style.paddingRight = 6;
// 헤더: 활성화 토글 + 초기화 버튼
var header = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center, marginBottom = 4 } };
var handEnabledProp = serializedObject.FindProperty($"{prefix}HandEnabled");
header.Add(new PropertyField(handEnabledProp, "제어 활성화") { style = { flexGrow = 1 } });
var resetBtn = new Button(() => ResetHandValues(prefix)) { text = "초기화" };
resetBtn.style.width = 60;
header.Add(resetBtn);
container.Add(header);
// 손가락 슬라이더 (가로 배치)
var slidersRow = new VisualElement { style = { flexDirection = FlexDirection.Row, justifyContent = Justify.Center, marginTop = 4 } };
for (int i = 0; i < fingerPropNames.Length; i++)
{
var fingerProp = serializedObject.FindProperty($"{prefix}{fingerPropNames[i]}");
slidersRow.Add(BuildVerticalSlider(fingerKoreanNames[i], fingerProp));
}
container.Add(slidersRow);
// 벌리기 슬라이더
var spreadProp = serializedObject.FindProperty($"{prefix}SpreadFingers");
var spreadSlider = new Slider("벌리기", -1f, 1f) { showInputField = true };
spreadSlider.BindProperty(spreadProp);
spreadSlider.style.marginTop = 8;
container.Add(spreadSlider);
// 비활성 시 숨기기
container.TrackPropertyValue(handEnabledProp, prop =>
{
slidersRow.style.display = prop.boolValue ? DisplayStyle.Flex : DisplayStyle.None;
spreadSlider.style.display = prop.boolValue ? DisplayStyle.Flex : DisplayStyle.None;
});
slidersRow.style.display = handEnabledProp.boolValue ? DisplayStyle.Flex : DisplayStyle.None;
spreadSlider.style.display = handEnabledProp.boolValue ? DisplayStyle.Flex : DisplayStyle.None;
return container;
}
private VisualElement BuildVerticalSlider(string label, SerializedProperty prop)
{
var column = new VisualElement { style = { alignItems = Align.Center, width = 45, marginLeft = 2, marginRight = 2 } };
column.Add(new Label(label) { style = { fontSize = 10, color = new Color(0.6f, 0.6f, 0.6f) } });
var valueLabel = new Label(prop.floatValue.ToString("F1")) { style = { fontSize = 10, color = new Color(0.6f, 0.6f, 0.6f) } };
column.Add(valueLabel);
// UI Toolkit Slider in vertical mode (direction: Vertical 대신 세로용 Slider 사용)
// SliderDirection.Vertical 사용
var slider = new Slider(-1f, 1f) { direction = SliderDirection.Vertical };
slider.style.height = 100;
slider.style.width = 25;
slider.BindProperty(prop);
slider.RegisterValueChangedCallback(evt => valueLabel.text = evt.newValue.ToString("F1"));
// 초기값 동기화
column.TrackPropertyValue(prop, p => valueLabel.text = p.floatValue.ToString("F1"));
column.Add(slider);
return column;
}
private VisualElement BuildPresetButtons()
{
var container = new VisualElement { style = { marginTop = 10 } };
container.Add(new Label("손 모양 프리셋") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginBottom = 4 } });
string[,] presets = {
{ "가위", "바위", "보" },
{ "브이", "검지", "초기화" }
};
for (int row = 0; row < 2; row++)
{
var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, justifyContent = Justify.Center, marginBottom = 4 } };
for (int col = 0; col < 3; col++)
{
string presetName = presets[row, col];
var btn = new Button(() => ApplyPreset(presetName)) { text = presetName };
btn.style.height = 30;
btn.style.width = 100;
btn.style.marginLeft = btn.style.marginRight = 4;
btnRow.Add(btn);
}
container.Add(btnRow);
}
return container;
}
private void ResetHandValues(string prefix)
{
serializedObject.Update();
string[] props = { "ThumbCurl", "IndexCurl", "MiddleCurl", "RingCurl", "PinkyCurl", "SpreadFingers" };
foreach (var prop in props)
serializedObject.FindProperty($"{prefix}{prop}").floatValue = 0f;
serializedObject.ApplyModifiedProperties();
}
private void ApplyPreset(string presetName)
{
if (!controller.enabled)
controller.enabled = true;
switch (presetName)
{
case "가위": SetPreset(1f, 1f, -1f, -1f, -1f, 0.3f); break;
case "바위": SetPreset(-1f, -1f, -1f, -1f, -1f, 0f); break;
case "보": SetPreset(1f, 1f, 1f, 1f, 1f, 1f); break;
case "브이": SetPreset(-1f, 1f, 1f, -1f, -1f, 1f); break;
case "검지": SetPreset(-1f, 1f, -1f, -1f, -1f, 0f); break;
case "초기화": SetPreset(0.8f, 0.8f, 0.8f, 0.8f, 0.8f, 0.8f); break;
}
}
private void SetPreset(float thumb, float index, float middle, float ring, float pinky, float spread)
{
if (controller.leftHandEnabled)
{
controller.leftThumbCurl = thumb;
controller.leftIndexCurl = index;
controller.leftMiddleCurl = middle;
controller.leftRingCurl = ring;
controller.leftPinkyCurl = pinky;
controller.leftSpreadFingers = spread;
}
if (controller.rightHandEnabled)
{
controller.rightThumbCurl = thumb;
controller.rightIndexCurl = index;
controller.rightMiddleCurl = middle;
controller.rightRingCurl = ring;
controller.rightPinkyCurl = pinky;
controller.rightSpreadFingers = spread;
}
EditorUtility.SetDirty(controller);
}
}

View File

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 291a583b9a953e041a119ba6c332d187
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,185 +0,0 @@
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;
using UnityEditor.UIElements;
namespace KindRetargeting
{
[CustomEditor(typeof(LimbWeightController))]
public class LimbWeightControllerEditor : BaseRetargetingEditor
{
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
SerializedProperty maxDistance;
SerializedProperty minDistance;
SerializedProperty weightSmoothSpeed;
SerializedProperty middleWeightMultiplier;
SerializedProperty hipsMinDistance;
SerializedProperty hipsMaxDistance;
SerializedProperty props;
SerializedProperty characterRoot;
SerializedProperty groundHipsMinHeight;
SerializedProperty groundHipsMaxHeight;
SerializedProperty footHeightMinThreshold;
SerializedProperty footHeightMaxThreshold;
SerializedProperty enableLeftArmIK;
SerializedProperty enableRightArmIK;
SerializedProperty chairSeatHeightOffset;
protected override void OnEnable()
{
base.OnEnable();
if (serializedObject == null) return;
maxDistance = serializedObject.FindProperty("maxDistance");
minDistance = serializedObject.FindProperty("minDistance");
weightSmoothSpeed = serializedObject.FindProperty("weightSmoothSpeed");
middleWeightMultiplier = serializedObject.FindProperty("middleWeightMultiplier");
hipsMinDistance = serializedObject.FindProperty("hipsMinDistance");
hipsMaxDistance = serializedObject.FindProperty("hipsMaxDistance");
props = serializedObject.FindProperty("props");
characterRoot = serializedObject.FindProperty("characterRoot");
groundHipsMinHeight = serializedObject.FindProperty("groundHipsMinHeight");
groundHipsMaxHeight = serializedObject.FindProperty("groundHipsMaxHeight");
footHeightMinThreshold = serializedObject.FindProperty("footHeightMinThreshold");
footHeightMaxThreshold = serializedObject.FindProperty("footHeightMaxThreshold");
enableLeftArmIK = serializedObject.FindProperty("enableLeftArmIK");
enableRightArmIK = serializedObject.FindProperty("enableRightArmIK");
chairSeatHeightOffset = serializedObject.FindProperty("chairSeatHeightOffset");
}
public override VisualElement CreateInspectorGUI()
{
if (serializedObject == null || target == null)
return new VisualElement();
var root = new VisualElement();
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
if (commonUss != null) root.styleSheets.Add(commonUss);
// IK 활성화 설정
var ikFoldout = new Foldout { text = "IK 활성화 설정", value = true };
ikFoldout.Add(new PropertyField(enableLeftArmIK, "왼팔 IK 활성화"));
ikFoldout.Add(new PropertyField(enableRightArmIK, "오른팔 IK 활성화"));
root.Add(ikFoldout);
// 거리 기반 가중치 설정
var distFoldout = new Foldout { text = "거리 기반 가중치 설정", value = true };
distFoldout.Add(BuildMinMaxRange("거리 범위 (가중치 1 → 0)", minDistance, maxDistance, 0f, 1f));
root.Add(distFoldout);
// 가중치 변화 설정
var weightFoldout = new Foldout { text = "가중치 변화 설정", value = true };
weightFoldout.Add(new PropertyField(weightSmoothSpeed, "가중치 변화 속도"));
root.Add(weightFoldout);
// 허리 가중치 설정
var hipsFoldout = new Foldout { text = "허리 가중치 설정", value = true };
hipsFoldout.Add(BuildMinMaxRange("허리 거리 범위 (가중치 1 → 0)", hipsMinDistance, hipsMaxDistance, 0f, 1f));
root.Add(hipsFoldout);
// 바닥 기준 히프 보정 설정
var groundFoldout = new Foldout { text = "바닥 기준 히프 보정 설정", value = true };
groundFoldout.Add(BuildMinMaxRange("바닥 기준 히프 높이 범위 (가중치 0 → 1)", groundHipsMinHeight, groundHipsMaxHeight, 0f, 2f));
root.Add(groundFoldout);
// 발 높이 기반 가중치 설정
var footFoldout = new Foldout { text = "발 높이 기반 가중치 설정", value = true };
footFoldout.Add(BuildMinMaxRange("발 높이 범위 (가중치 1 → 0)", footHeightMinThreshold, footHeightMaxThreshold, 0.1f, 1f));
root.Add(footFoldout);
// 의자 앉기 높이 설정
var chairFoldout = new Foldout { text = "의자 앉기 높이 설정", value = true };
chairFoldout.Add(new PropertyField(chairSeatHeightOffset, "좌석 높이 오프셋"));
root.Add(chairFoldout);
// 참조 설정
var refFoldout = new Foldout { text = "참조 설정", value = true };
refFoldout.Add(new PropertyField(props, "프랍 오브젝트"));
refFoldout.Add(new PropertyField(characterRoot, "캐릭터 루트"));
root.Add(refFoldout);
// 변경 감지
root.TrackSerializedObjectValue(serializedObject, so =>
{
if (target == null) return;
MarkDirty();
var script = (LimbWeightController)target;
var retargeting = script.GetComponent<CustomRetargetingScript>();
if (retargeting != null)
EditorUtility.SetDirty(retargeting);
});
return root;
}
private VisualElement BuildMinMaxRange(string label, SerializedProperty minProp, SerializedProperty maxProp, float limitMin, float limitMax)
{
var container = new VisualElement { style = { marginBottom = 4 } };
if (minProp == null || maxProp == null)
{
container.Add(new HelpBox($"'{label}' 프로퍼티를 찾을 수 없습니다.", HelpBoxMessageType.Warning));
return container;
}
container.Add(new Label(label) { style = { marginBottom = 2 } });
var row = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center } };
var minField = new FloatField { value = minProp.floatValue, style = { width = 50 } };
var slider = new MinMaxSlider(minProp.floatValue, maxProp.floatValue, limitMin, limitMax);
slider.style.flexGrow = 1;
slider.style.marginLeft = slider.style.marginRight = 4;
var maxField = new FloatField { value = maxProp.floatValue, style = { width = 50 } };
// MinMaxSlider → sync fields + properties
slider.RegisterValueChangedCallback(evt =>
{
minProp.floatValue = evt.newValue.x;
maxProp.floatValue = evt.newValue.y;
serializedObject.ApplyModifiedProperties();
minField.SetValueWithoutNotify(evt.newValue.x);
maxField.SetValueWithoutNotify(evt.newValue.y);
});
// FloatField min → sync slider + property
minField.RegisterValueChangedCallback(evt =>
{
float clamped = Mathf.Clamp(evt.newValue, limitMin, maxProp.floatValue);
minProp.floatValue = clamped;
serializedObject.ApplyModifiedProperties();
slider.SetValueWithoutNotify(new Vector2(clamped, maxProp.floatValue));
minField.SetValueWithoutNotify(clamped);
});
// FloatField max → sync slider + property
maxField.RegisterValueChangedCallback(evt =>
{
float clamped = Mathf.Clamp(evt.newValue, minProp.floatValue, limitMax);
maxProp.floatValue = clamped;
serializedObject.ApplyModifiedProperties();
slider.SetValueWithoutNotify(new Vector2(minProp.floatValue, clamped));
maxField.SetValueWithoutNotify(clamped);
});
// Track external changes (undo, etc.)
container.TrackPropertyValue(minProp, p =>
{
minField.SetValueWithoutNotify(p.floatValue);
slider.SetValueWithoutNotify(new Vector2(p.floatValue, maxProp.floatValue));
});
container.TrackPropertyValue(maxProp, p =>
{
maxField.SetValueWithoutNotify(p.floatValue);
slider.SetValueWithoutNotify(new Vector2(minProp.floatValue, p.floatValue));
});
row.Add(minField);
row.Add(slider);
row.Add(maxField);
container.Add(row);
return container;
}
}
}

View File

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 199539b34f08aac41a86f4767bc49def
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -1,167 +0,0 @@
using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;
using UnityEditor.UIElements;
namespace KindRetargeting
{
[CustomEditor(typeof(PropLocationController))]
public class PropLocationControllerEditor : BaseRetargetingEditor
{
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
private VisualElement offsetContainer;
private VisualElement propListContainer;
private PropLocationController controller;
public override VisualElement CreateInspectorGUI()
{
controller = target as PropLocationController;
if (controller == null) return new VisualElement();
var root = new VisualElement();
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
if (commonUss != null) root.styleSheets.Add(commonUss);
// 기본 프로퍼티
var iterator = serializedObject.GetIterator();
iterator.NextVisible(true); // skip m_Script
while (iterator.NextVisible(false))
{
var field = new PropertyField(iterator.Copy());
root.Add(field);
}
// 오프셋 조정 섹션
root.Add(new Label("오프셋 조정") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginTop = 10 } });
offsetContainer = new VisualElement();
root.Add(offsetContainer);
// 부착된 프랍 목록 섹션
root.Add(new Label("부착된 프랍 목록") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginTop = 10 } });
propListContainer = new VisualElement();
root.Add(propListContainer);
// 프랍 위치 이동 버튼
root.Add(new Label("프랍 위치 이동") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginTop = 10 } });
var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } };
var headBtn = new Button(() => controller.MoveToHead()) { text = "머리로 이동" };
headBtn.style.flexGrow = 1; headBtn.style.height = 30; headBtn.style.marginRight = 2;
btnRow.Add(headBtn);
var leftBtn = new Button(() => controller.MoveToLeftHand()) { text = "왼손으로 이동" };
leftBtn.style.flexGrow = 1; leftBtn.style.height = 30; leftBtn.style.marginRight = 2;
btnRow.Add(leftBtn);
var rightBtn = new Button(() => controller.MoveToRightHand()) { text = "오른손으로 이동" };
rightBtn.style.flexGrow = 1; rightBtn.style.height = 30;
btnRow.Add(rightBtn);
root.Add(btnRow);
var detachBtn = new Button(() =>
{
if (Selection.activeGameObject != null)
Undo.RecordObject(Selection.activeGameObject.transform, "Detach Prop");
controller.DetachProp();
}) { text = "프랍 해제" };
detachBtn.style.height = 30;
detachBtn.style.marginTop = 4;
root.Add(detachBtn);
// 주기적으로 동적 UI 갱신
root.schedule.Execute(() => RebuildDynamicUI()).Every(500);
RebuildDynamicUI();
return root;
}
private void RebuildDynamicUI()
{
if (controller == null) return;
RebuildOffsets();
RebuildPropLists();
}
private void RebuildOffsets()
{
if (offsetContainer == null) return;
offsetContainer.Clear();
BuildOffsetSection(offsetContainer, "왼손 오프셋", controller.GetLeftHandOffset());
BuildOffsetSection(offsetContainer, "오른손 오프셋", controller.GetRightHandOffset());
BuildOffsetSection(offsetContainer, "머리 오프셋", controller.GetHeadOffset());
}
private void BuildOffsetSection(VisualElement parent, string label, Transform offset)
{
if (offset == null) return;
var foldout = new Foldout { text = label, value = true };
var posField = new Vector3Field("위치") { value = offset.localPosition };
posField.RegisterValueChangedCallback(evt =>
{
Undo.RecordObject(offset, $"Update {label}");
offset.localPosition = evt.newValue;
EditorUtility.SetDirty(offset);
});
foldout.Add(posField);
var rotField = new Vector3Field("회전") { value = offset.localRotation.eulerAngles };
rotField.RegisterValueChangedCallback(evt =>
{
Undo.RecordObject(offset, $"Update {label}");
offset.localRotation = Quaternion.Euler(evt.newValue);
EditorUtility.SetDirty(offset);
});
foldout.Add(rotField);
parent.Add(foldout);
}
private void RebuildPropLists()
{
if (propListContainer == null) return;
propListContainer.Clear();
BuildPropListSection(propListContainer, "머리 프랍", controller.GetHeadProps());
BuildPropListSection(propListContainer, "왼손 프랍", controller.GetLeftHandProps());
BuildPropListSection(propListContainer, "오른손 프랍", controller.GetRightHandProps());
}
private void BuildPropListSection(VisualElement parent, string title, GameObject[] propList)
{
var box = new VisualElement();
box.style.backgroundColor = new Color(0, 0, 0, 0.1f);
box.style.borderTopLeftRadius = box.style.borderTopRightRadius =
box.style.borderBottomLeftRadius = box.style.borderBottomRightRadius = 4;
box.style.paddingTop = box.style.paddingBottom =
box.style.paddingLeft = box.style.paddingRight = 4;
box.style.marginBottom = 4;
box.Add(new Label(title) { style = { unityFontStyleAndWeight = FontStyle.Bold } });
if (propList.Length > 0)
{
foreach (var prop in propList)
{
if (prop == null) continue;
var row = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center, marginTop = 2 } };
row.Add(new Label(prop.name) { style = { flexGrow = 1 } });
var selectBtn = new Button(() => Selection.activeGameObject = prop) { text = "선택" };
selectBtn.style.width = 60;
row.Add(selectBtn);
box.Add(row);
}
}
else
{
box.Add(new Label("부착된 프랍 없음") { style = { color = new Color(0.6f, 0.6f, 0.6f) } });
}
parent.Add(box);
}
}
}

View File

@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: a49ee1ae55b970e4c8ca00ccae5d6f97
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -144,12 +144,7 @@ public class RetargetingControlWindow : EditorWindow
foreach (var s in retargetingScripts)
{
if (s == null) continue;
if (s.GetComponent<LimbWeightController>() == null)
s.gameObject.AddComponent<LimbWeightController>();
if (s.GetComponent<FingerShapedController>() == null)
s.gameObject.AddComponent<FingerShapedController>();
if (s.GetComponent<PropLocationController>() == null)
s.gameObject.AddComponent<PropLocationController>();
// 모든 컴포넌트는 CRS 내부 모듈로 이동됨
EditorUtility.SetDirty(s.gameObject);
}
@ -198,7 +193,7 @@ public class RetargetingControlWindow : EditorWindow
panel.Add(BuildHeader(script));
// 가중치 설정
panel.Add(BuildWeightSection(script));
panel.Add(BuildWeightSection(script, so));
// 힙 위치 보정
panel.Add(BuildHipsSection(script, so));
@ -229,17 +224,25 @@ public class RetargetingControlWindow : EditorWindow
footContainer.Bind(so);
panel.Add(footFoldout);
// 어깨 보정
panel.Add(BuildShoulderSection(script, so));
// 접지 설정
panel.Add(BuildGroundingSection(script, so));
// 손가락 제어 설정
panel.Add(BuildFingerControlSection(script));
panel.Add(BuildFingerControlSection(script, so));
// 손가락 복제 설정
panel.Add(BuildFingerCopySection(script, so));
// 바닥 높이 설정
var floorFoldout = new Foldout { text = "바닥 높이 설정", value = false };
var floorField = new PropertyField(so.FindProperty("floorHeight"), "바닥 높이 (-1 ~ 1)");
floorFoldout.Add(floorField);
floorField.Bind(so);
var floorContainer = new VisualElement();
floorContainer.Add(new PropertyField(so.FindProperty("floorHeight"), "바닥 높이 (-1 ~ 1)"));
floorContainer.Add(new PropertyField(so.FindProperty("minimumAnkleHeight"), "최소 발목 높이"));
floorContainer.Bind(so);
floorFoldout.Add(floorContainer);
panel.Add(floorFoldout);
// 아바타 크기 설정
@ -304,27 +307,26 @@ public class RetargetingControlWindow : EditorWindow
// ========== Weight Settings ==========
private VisualElement BuildWeightSection(CustomRetargetingScript script)
private VisualElement BuildWeightSection(CustomRetargetingScript script, SerializedObject so)
{
var foldout = new Foldout { text = "가중치 설정", value = false };
var limb = script.GetComponent<LimbWeightController>();
if (limb == null) { foldout.Add(new HelpBox("LimbWeightController가 없습니다.", HelpBoxMessageType.Warning)); return foldout; }
var limbSO = CreateTrackedSO(limb);
var container = new VisualElement();
container.Add(BuildMinMaxRange("손과 프랍과의 범위 (가중치 1 → 0)",
limbSO.FindProperty("minDistance"), limbSO.FindProperty("maxDistance"), 0f, 1f, limbSO));
container.Add(BuildMinMaxRange("의자와 허리 거리 범위 (가중치 1 → 0)",
limbSO.FindProperty("hipsMinDistance"), limbSO.FindProperty("hipsMaxDistance"), 0f, 1f, limbSO));
container.Add(BuildMinMaxRange("바닥과 허리 높이에 의한 블렌딩 (가중치 0 → 1)",
limbSO.FindProperty("groundHipsMinHeight"), limbSO.FindProperty("groundHipsMaxHeight"), 0f, 2f, limbSO));
container.Add(BuildMinMaxRange("지면으로부터 발의 범위에 의한 IK 블렌딩 (가중치 1 → 0)",
limbSO.FindProperty("footHeightMinThreshold"), limbSO.FindProperty("footHeightMaxThreshold"), 0.1f, 1f, limbSO));
// IK 활성화 토글
container.Add(new PropertyField(so.FindProperty("limbWeight.enableLeftArmIK"), "왼팔 IK 활성화"));
container.Add(new PropertyField(so.FindProperty("limbWeight.enableRightArmIK"), "오른팔 IK 활성화"));
var smoothField = new PropertyField(limbSO.FindProperty("weightSmoothSpeed"), "가중치 변화 속도");
container.Add(BuildMinMaxRange("손과 프랍과의 범위 (가중치 1 → 0)",
so.FindProperty("limbWeight.minDistance"), so.FindProperty("limbWeight.maxDistance"), 0f, 1f, so));
container.Add(BuildMinMaxRange("의자와 허리 거리 범위 (가중치 1 → 0)",
so.FindProperty("limbWeight.hipsMinDistance"), so.FindProperty("limbWeight.hipsMaxDistance"), 0f, 1f, so));
container.Add(BuildMinMaxRange("바닥과 허리 높이에 의한 블렌딩 (가중치 0 → 1)",
so.FindProperty("limbWeight.groundHipsMinHeight"), so.FindProperty("limbWeight.groundHipsMaxHeight"), 0f, 2f, so));
container.Add(BuildMinMaxRange("지면으로부터 발의 범위에 의한 IK 블렌딩 (가중치 1 → 0)",
so.FindProperty("limbWeight.footHeightMinThreshold"), so.FindProperty("limbWeight.footHeightMaxThreshold"), 0.1f, 1f, so));
var smoothField = new PropertyField(so.FindProperty("limbWeight.weightSmoothSpeed"), "가중치 변화 속도");
container.Add(smoothField);
container.Bind(limbSO);
foldout.Add(container);
return foldout;
@ -367,15 +369,9 @@ public class RetargetingControlWindow : EditorWindow
container.Add(hz);
// 의자 앉기 높이
var limb = script.GetComponent<LimbWeightController>();
if (limb != null)
{
var limbSO = CreateTrackedSO(limb);
var chairSlider = new Slider("의자 앉기 높이", -1f, 1f) { showInputField = true, tooltip = "의자에 앉을 때 엉덩이 높이 조정 (월드 Y 기준)" };
chairSlider.BindProperty(limbSO.FindProperty("chairSeatHeightOffset"));
container.Add(chairSlider);
chairSlider.Bind(limbSO);
}
var chairSlider = new Slider("의자 앉기 높이", -1f, 1f) { showInputField = true, tooltip = "의자에 앉을 때 엉덩이 높이 조정 (월드 Y 기준)" };
chairSlider.BindProperty(so.FindProperty("limbWeight.chairSeatHeightOffset"));
container.Add(chairSlider);
// 다리 길이 자동 보정 버튼
var autoHipsBtn = new Button(() =>
@ -403,48 +399,28 @@ public class RetargetingControlWindow : EditorWindow
// ========== Finger Control ==========
private VisualElement BuildFingerControlSection(CustomRetargetingScript script)
private VisualElement BuildFingerControlSection(CustomRetargetingScript script, SerializedObject so)
{
var foldout = new Foldout { text = "손가락 제어 설정", value = false };
var fingerController = script.GetComponent<FingerShapedController>();
if (fingerController == null)
{
foldout.Add(new HelpBox("FingerShapedController가 없습니다.", HelpBoxMessageType.Warning));
var addBtn = new Button(() =>
{
script.gameObject.AddComponent<FingerShapedController>();
EditorUtility.SetDirty(script.gameObject);
RebuildCharacterPanels();
}) { text = "FingerShapedController 추가" };
foldout.Add(addBtn);
return foldout;
}
var fso = CreateTrackedSO(fingerController);
var container = new VisualElement();
// 활성화 토글
var enableToggle = new Toggle("손가락 제어 활성화") { value = fingerController.enabled };
enableToggle.RegisterValueChangedCallback(evt =>
{
fingerController.enabled = evt.newValue;
EditorUtility.SetDirty(fingerController);
});
var enabledProp = so.FindProperty("fingerShaped.enabled");
var enableToggle = new PropertyField(enabledProp, "손가락 제어 활성화");
container.Add(enableToggle);
// 왼손
container.Add(BuildHandSection("왼손", "left", fso, fingerController));
container.Add(BuildHandSection("왼손", "left", so, script.fingerShaped));
// 오른손
container.Add(BuildHandSection("오른손", "right", fso, fingerController));
container.Add(BuildHandSection("오른손", "right", so, script.fingerShaped));
// 프리셋
container.Add(BuildFingerPresets(fingerController));
container.Add(BuildFingerPresets(script, script.fingerShaped));
container.Bind(fso);
foldout.Add(container);
return foldout;
}
private VisualElement BuildHandSection(string label, string prefix, SerializedObject fso, FingerShapedController fc)
private VisualElement BuildHandSection(string label, string prefix, SerializedObject so, FingerShapedController fc)
{
var box = new VisualElement();
box.style.backgroundColor = new Color(0, 0, 0, 0.08f);
@ -456,13 +432,13 @@ public class RetargetingControlWindow : EditorWindow
var header = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center } };
var handFoldout = new Foldout { text = label, value = false };
var handEnabledProp = fso.FindProperty($"{prefix}HandEnabled");
var handEnabledProp = so.FindProperty($"fingerShaped.{prefix}HandEnabled");
header.Add(new PropertyField(handEnabledProp, "활성화") { style = { flexGrow = 1 } });
var resetBtn = new Button(() =>
{
string[] props = { "ThumbCurl", "IndexCurl", "MiddleCurl", "RingCurl", "PinkyCurl", "SpreadFingers" };
foreach (var p in props) fso.FindProperty($"{prefix}{p}").floatValue = 0f;
fso.ApplyModifiedProperties();
foreach (var p in props) so.FindProperty($"fingerShaped.{prefix}{p}").floatValue = 0f;
so.ApplyModifiedProperties();
}) { text = "초기화" };
resetBtn.style.width = 60;
header.Add(resetBtn);
@ -478,7 +454,7 @@ public class RetargetingControlWindow : EditorWindow
var col = new VisualElement { style = { alignItems = Align.Center, width = 45, marginLeft = 2, marginRight = 2 } };
col.Add(new Label(korNames[i]) { style = { fontSize = 10, color = new Color(0.6f, 0.6f, 0.6f) } });
var prop = fso.FindProperty($"{prefix}{fingerNames[i]}Curl");
var prop = so.FindProperty($"fingerShaped.{prefix}{fingerNames[i]}Curl");
var valLabel = new Label(prop.floatValue.ToString("F1")) { style = { fontSize = 10, color = new Color(0.6f, 0.6f, 0.6f) } };
col.Add(valLabel);
@ -495,16 +471,18 @@ public class RetargetingControlWindow : EditorWindow
// 벌리기
var spreadSlider = new Slider("벌리기", -1f, 1f) { showInputField = true };
spreadSlider.BindProperty(fso.FindProperty($"{prefix}SpreadFingers"));
spreadSlider.BindProperty(so.FindProperty($"fingerShaped.{prefix}SpreadFingers"));
handFoldout.Add(spreadSlider);
// 비활성 시 숨김
handFoldout.schedule.Execute(() =>
{
try { if (fso == null || fso.targetObject == null) return; }
try { if (so == null || so.targetObject == null) return; }
catch (System.Exception) { return; }
fso.Update();
bool enabled = fso.FindProperty($"{prefix}HandEnabled").boolValue;
so.Update();
var enabledProp = so.FindProperty($"fingerShaped.{prefix}HandEnabled");
if (enabledProp == null) return;
bool enabled = enabledProp.boolValue;
slidersRow.style.display = enabled ? DisplayStyle.Flex : DisplayStyle.None;
spreadSlider.style.display = enabled ? DisplayStyle.Flex : DisplayStyle.None;
}).Every(300);
@ -513,7 +491,7 @@ public class RetargetingControlWindow : EditorWindow
return box;
}
private VisualElement BuildFingerPresets(FingerShapedController controller)
private VisualElement BuildFingerPresets(CustomRetargetingScript script, FingerShapedController controller)
{
var container = new VisualElement { style = { marginTop = 6 } };
container.Add(new Label("손 모양 프리셋") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginBottom = 4 } });
@ -525,7 +503,7 @@ public class RetargetingControlWindow : EditorWindow
for (int col = 0; col < 3; col++)
{
string name = presets[row, col];
var btn = new Button(() => ApplyFingerPreset(controller, name)) { text = name };
var btn = new Button(() => ApplyFingerPreset(script, controller, name)) { text = name };
btn.style.height = 30; btn.style.width = 100; btn.style.marginLeft = btn.style.marginRight = 4;
btnRow.Add(btn);
}
@ -606,19 +584,7 @@ public class RetargetingControlWindow : EditorWindow
private VisualElement BuildPropSection(CustomRetargetingScript script)
{
var foldout = new Foldout { text = "프랍 설정", value = false };
var propController = script.GetComponent<PropLocationController>();
if (propController == null)
{
foldout.Add(new HelpBox("PropLocationController가 없습니다.", HelpBoxMessageType.Warning));
var addBtn = new Button(() =>
{
script.gameObject.AddComponent<PropLocationController>();
EditorUtility.SetDirty(script.gameObject);
RebuildCharacterPanels();
}) { text = "PropLocationController 추가" };
foldout.Add(addBtn);
return foldout;
}
var propController = script.propLocation;
var dynamicContainer = new VisualElement();
foldout.Add(dynamicContainer);
@ -782,6 +748,61 @@ public class RetargetingControlWindow : EditorWindow
return box;
}
// ========== Shoulder Correction ==========
private VisualElement BuildShoulderSection(CustomRetargetingScript script, SerializedObject so)
{
var foldout = new Foldout { text = "어깨 보정", value = false };
var container = new VisualElement();
var strength = new Slider("블렌드 강도", 0f, 5f) { showInputField = true };
strength.BindProperty(so.FindProperty("shoulderCorrection.blendStrength"));
container.Add(strength);
var maxBlend = new Slider("최대 블렌드", 0f, 1f) { showInputField = true };
maxBlend.BindProperty(so.FindProperty("shoulderCorrection.maxShoulderBlend"));
container.Add(maxBlend);
container.Add(new PropertyField(so.FindProperty("shoulderCorrection.reverseLeftRotation"), "왼쪽 회전 반전"));
container.Add(new PropertyField(so.FindProperty("shoulderCorrection.reverseRightRotation"), "오른쪽 회전 반전"));
container.Add(BuildMinMaxRange("높이 차이 범위",
so.FindProperty("shoulderCorrection.minHeightDifference"),
so.FindProperty("shoulderCorrection.maxHeightDifference"),
-0.5f, 2f, so));
container.Add(new PropertyField(so.FindProperty("shoulderCorrection.shoulderCorrectionCurve"), "보정 커브"));
container.Bind(so);
foldout.Add(container);
return foldout;
}
// ========== Foot Grounding ==========
private VisualElement BuildGroundingSection(CustomRetargetingScript script, SerializedObject so)
{
var foldout = new Foldout { text = "접지 설정", value = false };
var container = new VisualElement();
container.Add(new PropertyField(so.FindProperty("footGrounding.groundHeight"), "바닥 높이"));
var weightSlider = new Slider("접지 강도", 0f, 1f) { showInputField = true };
weightSlider.BindProperty(so.FindProperty("footGrounding.groundingWeight"));
container.Add(weightSlider);
container.Add(new PropertyField(so.FindProperty("footGrounding.activationHeight"), "활성화 높이") { tooltip = "발목이 이 높이 이상이면 보정 비활성화" });
container.Add(new PropertyField(so.FindProperty("footGrounding.plantThreshold"), "접지 판정 범위"));
var smoothField = new Slider("스무딩 속도", 1f, 30f) { showInputField = true };
smoothField.BindProperty(so.FindProperty("footGrounding.smoothSpeed"));
container.Add(smoothField);
container.Bind(so);
foldout.Add(container);
return foldout;
}
// ========== Helpers ==========
private VisualElement BuildMinMaxRange(string label, SerializedProperty minProp, SerializedProperty maxProp, float limitMin, float limitMax, SerializedObject so)
@ -848,7 +869,7 @@ public class RetargetingControlWindow : EditorWindow
return container;
}
private void ApplyFingerPreset(FingerShapedController controller, string presetName)
private void ApplyFingerPreset(CustomRetargetingScript script, FingerShapedController controller, string presetName)
{
if (!controller.enabled) controller.enabled = true;
@ -873,7 +894,7 @@ public class RetargetingControlWindow : EditorWindow
controller.rightThumbCurl = t; controller.rightIndexCurl = i; controller.rightMiddleCurl = m;
controller.rightRingCurl = r; controller.rightPinkyCurl = p; controller.rightSpreadFingers = s;
}
EditorUtility.SetDirty(controller);
EditorUtility.SetDirty(script);
}
// ========== Head Calibration ==========

View File

@ -1,168 +1,173 @@
using UnityEngine;
using System.Collections.Generic;
[DefaultExecutionOrder(2)]
public class FingerShapedController : MonoBehaviour
namespace KindRetargeting
{
private Animator animator;
private HumanPoseHandler humanPoseHandler;
// 손가락을 제외한 모든 본의 로컬 회전 저장용 (SetHumanPose 호출 시 몸 복원용)
private Dictionary<HumanBodyBones, Quaternion> savedBoneLocalRotations = new Dictionary<HumanBodyBones, Quaternion>();
// 손가락을 제외한 모든 휴먼본 목록
private static readonly HumanBodyBones[] nonFingerBones = new HumanBodyBones[]
[System.Serializable]
public class FingerShapedController
{
HumanBodyBones.Hips,
HumanBodyBones.Spine,
HumanBodyBones.Chest,
HumanBodyBones.UpperChest,
HumanBodyBones.Neck,
HumanBodyBones.Head,
HumanBodyBones.LeftShoulder,
HumanBodyBones.LeftUpperArm,
HumanBodyBones.LeftLowerArm,
HumanBodyBones.LeftHand,
HumanBodyBones.RightShoulder,
HumanBodyBones.RightUpperArm,
HumanBodyBones.RightLowerArm,
HumanBodyBones.RightHand,
HumanBodyBones.LeftUpperLeg,
HumanBodyBones.LeftLowerLeg,
HumanBodyBones.LeftFoot,
HumanBodyBones.LeftToes,
HumanBodyBones.RightUpperLeg,
HumanBodyBones.RightLowerLeg,
HumanBodyBones.RightFoot,
HumanBodyBones.RightToes,
HumanBodyBones.LeftEye,
HumanBodyBones.RightEye,
HumanBodyBones.Jaw
};
private Animator animator;
private HumanPoseHandler humanPoseHandler;
[Header("왼손 제어 값")]
[Range(-1, 1)] public float leftPinkyCurl; // 새끼손가락 구부리기
[Range(-1, 1)] public float leftRingCurl; // 약지 구부리기
[Range(-1, 1)] public float leftMiddleCurl; // 중지 구부리기
[Range(-1, 1)] public float leftIndexCurl; // 검지 구부리기
[Range(-1, 1)] public float leftThumbCurl; // 엄지 구부리기
[Range(-1, 1)] public float leftSpreadFingers; // 손가락 벌리기
// 손가락을 제외한 모든 본의 로컬 회전 저장용 (SetHumanPose 호출 시 몸 복원용)
private Dictionary<HumanBodyBones, Quaternion> savedBoneLocalRotations = new Dictionary<HumanBodyBones, Quaternion>();
[Header("오른손 제어 값")]
[Range(-1, 1)] public float rightPinkyCurl; // 새끼손가락 구부리기
[Range(-1, 1)] public float rightRingCurl; // 약지 구부리기
[Range(-1, 1)] public float rightMiddleCurl; // 중지 구부리기
[Range(-1, 1)] public float rightIndexCurl; // 검지 구부리기
[Range(-1, 1)] public float rightThumbCurl; // 엄지 구부리기
[Range(-1, 1)] public float rightSpreadFingers; // 손가락 벌리기
public bool leftHandEnabled = false; // 왼손 제어 활성화 상태
public bool rightHandEnabled = false; // 오른손 제어 활성화 상태
private void Reset()
{
// 컴포넌트가 처음 추가될 때 자동으로 비활성화
enabled = false;
leftHandEnabled = false;
rightHandEnabled = false;
}
private void Awake()
{
animator = GetComponent<Animator>();
humanPoseHandler = new HumanPoseHandler(animator.avatar, animator.transform);
}
private void OnDestroy()
{
if (humanPoseHandler != null)
// 손가락을 제외한 모든 휴먼본 목록
private static readonly HumanBodyBones[] nonFingerBones = new HumanBodyBones[]
{
humanPoseHandler.Dispose();
HumanBodyBones.Hips,
HumanBodyBones.Spine,
HumanBodyBones.Chest,
HumanBodyBones.UpperChest,
HumanBodyBones.Neck,
HumanBodyBones.Head,
HumanBodyBones.LeftShoulder,
HumanBodyBones.LeftUpperArm,
HumanBodyBones.LeftLowerArm,
HumanBodyBones.LeftHand,
HumanBodyBones.RightShoulder,
HumanBodyBones.RightUpperArm,
HumanBodyBones.RightLowerArm,
HumanBodyBones.RightHand,
HumanBodyBones.LeftUpperLeg,
HumanBodyBones.LeftLowerLeg,
HumanBodyBones.LeftFoot,
HumanBodyBones.LeftToes,
HumanBodyBones.RightUpperLeg,
HumanBodyBones.RightLowerLeg,
HumanBodyBones.RightFoot,
HumanBodyBones.RightToes,
HumanBodyBones.LeftEye,
HumanBodyBones.RightEye,
HumanBodyBones.Jaw
};
public bool enabled = false;
[Header("왼손 제어 값")]
[Range(-1, 1)] public float leftPinkyCurl; // 새끼손가락 구부리기
[Range(-1, 1)] public float leftRingCurl; // 약지 구부리기
[Range(-1, 1)] public float leftMiddleCurl; // 중지 구부리기
[Range(-1, 1)] public float leftIndexCurl; // 검지 구부리기
[Range(-1, 1)] public float leftThumbCurl; // 엄지 구부리기
[Range(-1, 1)] public float leftSpreadFingers; // 손가락 벌리기
[Header("오른손 제어 값")]
[Range(-1, 1)] public float rightPinkyCurl; // 새끼손가락 구부리기
[Range(-1, 1)] public float rightRingCurl; // 약지 구부리기
[Range(-1, 1)] public float rightMiddleCurl; // 중지 구부리기
[Range(-1, 1)] public float rightIndexCurl; // 검지 구부리기
[Range(-1, 1)] public float rightThumbCurl; // 엄지 구부리기
[Range(-1, 1)] public float rightSpreadFingers; // 손가락 벌리기
public bool leftHandEnabled = false; // 왼손 제어 활성화 상태
public bool rightHandEnabled = false; // 오른손 제어 활성화 상태
private bool isInitialized;
public void Initialize(Animator targetAnimator)
{
animator = targetAnimator;
if (animator == null || !animator.isHuman) return;
humanPoseHandler = new HumanPoseHandler(animator.avatar, animator.transform);
isInitialized = true;
}
}
private void Update()
{
UpdateMuscleValues();
}
private void UpdateMuscleValues()
{
// 1. 손가락을 제외한 모든 본의 로컬 회전 저장 (SetHumanPose 호출 전)
savedBoneLocalRotations.Clear();
for (int i = 0; i < nonFingerBones.Length; i++)
public void Cleanup()
{
Transform bone = animator.GetBoneTransform(nonFingerBones[i]);
if (bone != null)
if (humanPoseHandler != null)
{
savedBoneLocalRotations[nonFingerBones[i]] = bone.localRotation;
humanPoseHandler.Dispose();
humanPoseHandler = null;
}
isInitialized = false;
}
public void OnUpdate()
{
if (!isInitialized || !enabled) return;
UpdateMuscleValues();
}
private void UpdateMuscleValues()
{
// 1. 손가락을 제외한 모든 본의 로컬 회전 저장 (SetHumanPose 호출 전)
savedBoneLocalRotations.Clear();
for (int i = 0; i < nonFingerBones.Length; i++)
{
Transform bone = animator.GetBoneTransform(nonFingerBones[i]);
if (bone != null)
{
savedBoneLocalRotations[nonFingerBones[i]] = bone.localRotation;
}
}
// 2. HumanPose 가져오기 및 손가락 머슬 설정
HumanPose humanPose = new HumanPose();
humanPoseHandler.GetHumanPose(ref humanPose);
// 왼손 제어
SetHandMuscles(true, leftThumbCurl, leftIndexCurl, leftMiddleCurl, leftRingCurl,
leftPinkyCurl, leftSpreadFingers, ref humanPose);
// 오른손 제어
SetHandMuscles(false, rightThumbCurl, rightIndexCurl, rightMiddleCurl, rightRingCurl,
rightPinkyCurl, rightSpreadFingers, ref humanPose);
// 3. 머슬 포즈 적용 (손가락 포함 전체 본에 영향)
humanPoseHandler.SetHumanPose(ref humanPose);
// 4. 손가락을 제외한 모든 본의 로컬 회전 복원 (본 길이 변형 방지)
foreach (var kvp in savedBoneLocalRotations)
{
Transform bone = animator.GetBoneTransform(kvp.Key);
if (bone != null)
{
bone.localRotation = kvp.Value;
}
}
}
// 2. HumanPose 가져오기 및 손가락 머슬 설정
HumanPose humanPose = new HumanPose();
humanPoseHandler.GetHumanPose(ref humanPose);
// 왼손 제어
SetHandMuscles(true, leftThumbCurl, leftIndexCurl, leftMiddleCurl, leftRingCurl,
leftPinkyCurl, leftSpreadFingers, ref humanPose);
// 오른손 제어
SetHandMuscles(false, rightThumbCurl, rightIndexCurl, rightMiddleCurl, rightRingCurl,
rightPinkyCurl, rightSpreadFingers, ref humanPose);
// 3. 머슬 포즈 적용 (손가락 포함 전체 본에 영향)
humanPoseHandler.SetHumanPose(ref humanPose);
// 4. 손가락을 제외한 모든 본의 로컬 회전 복원 (본 길이 변형 방지)
foreach (var kvp in savedBoneLocalRotations)
private void SetHandMuscles(bool isLeft, float thumb, float index, float middle, float ring,
float pinky, float spread, ref HumanPose humanPose)
{
Transform bone = animator.GetBoneTransform(kvp.Key);
if (bone != null)
{
bone.localRotation = kvp.Value;
}
// 해당 손이 비활성화 상태면 건너뛰기
if (isLeft && !leftHandEnabled) return;
if (!isLeft && !rightHandEnabled) return;
int baseOffset = isLeft ? 55 : 75; // 왼손은 55부터, 오른손은 75부터 시작
int muscleCount = humanPose.muscles.Length;
// 엄지손가락
if (baseOffset < muscleCount) humanPose.muscles[baseOffset] = thumb; // Thumb 1
if (baseOffset + 1 < muscleCount) humanPose.muscles[baseOffset + 1] = thumb; // Thumb Spread
if (baseOffset + 2 < muscleCount) humanPose.muscles[baseOffset + 2] = thumb; // Thumb 2
if (baseOffset + 3 < muscleCount) humanPose.muscles[baseOffset + 3] = thumb; // Thumb 3
// 검지
if (baseOffset + 4 < muscleCount) humanPose.muscles[baseOffset + 4] = index; // Index 1
if (baseOffset + 5 < muscleCount) humanPose.muscles[baseOffset + 5] = spread; // Index Spread
if (baseOffset + 6 < muscleCount) humanPose.muscles[baseOffset + 6] = index; // Index 2
if (baseOffset + 7 < muscleCount) humanPose.muscles[baseOffset + 7] = index; // Index 3
// 중지
if (baseOffset + 8 < muscleCount) humanPose.muscles[baseOffset + 8] = middle; // Middle 1
if (baseOffset + 9 < muscleCount) humanPose.muscles[baseOffset + 9] = spread; // Middle Spread
if (baseOffset + 10 < muscleCount) humanPose.muscles[baseOffset + 10] = middle; // Middle 2
if (baseOffset + 11 < muscleCount) humanPose.muscles[baseOffset + 11] = middle; // Middle 3
// 약지
if (baseOffset + 12 < muscleCount) humanPose.muscles[baseOffset + 12] = ring; // Ring 1
if (baseOffset + 13 < muscleCount) humanPose.muscles[baseOffset + 13] = spread; // Ring Spread
if (baseOffset + 14 < muscleCount) humanPose.muscles[baseOffset + 14] = ring; // Ring 2
if (baseOffset + 15 < muscleCount) humanPose.muscles[baseOffset + 15] = ring; // Ring 3
// 새끼손가락
if (baseOffset + 16 < muscleCount) humanPose.muscles[baseOffset + 16] = pinky; // Little 1
if (baseOffset + 17 < muscleCount) humanPose.muscles[baseOffset + 17] = spread; // Little Spread
if (baseOffset + 18 < muscleCount) humanPose.muscles[baseOffset + 18] = pinky; // Little 2
if (baseOffset + 19 < muscleCount) humanPose.muscles[baseOffset + 19] = pinky; // Little 3
}
}
private void SetHandMuscles(bool isLeft, float thumb, float index, float middle, float ring,
float pinky, float spread, ref HumanPose humanPose)
{
// 해당 손이 비활성화 상태면 건너뛰기
if (isLeft && !leftHandEnabled) return;
if (!isLeft && !rightHandEnabled) return;
int baseOffset = isLeft ? 55 : 75; // 왼손은 55부터, 오른손은 75부터 시작
int muscleCount = humanPose.muscles.Length;
// 엄지손가락
if (baseOffset < muscleCount) humanPose.muscles[baseOffset] = thumb; // Thumb 1
if (baseOffset + 1 < muscleCount) humanPose.muscles[baseOffset + 1] = thumb; // Thumb Spread
if (baseOffset + 2 < muscleCount) humanPose.muscles[baseOffset + 2] = thumb; // Thumb 2
if (baseOffset + 3 < muscleCount) humanPose.muscles[baseOffset + 3] = thumb; // Thumb 3
// 검지
if (baseOffset + 4 < muscleCount) humanPose.muscles[baseOffset + 4] = index; // Index 1
if (baseOffset + 5 < muscleCount) humanPose.muscles[baseOffset + 5] = spread; // Index Spread
if (baseOffset + 6 < muscleCount) humanPose.muscles[baseOffset + 6] = index; // Index 2
if (baseOffset + 7 < muscleCount) humanPose.muscles[baseOffset + 7] = index; // Index 3
// 중지
if (baseOffset + 8 < muscleCount) humanPose.muscles[baseOffset + 8] = middle; // Middle 1
if (baseOffset + 9 < muscleCount) humanPose.muscles[baseOffset + 9] = spread; // Middle Spread
if (baseOffset + 10 < muscleCount) humanPose.muscles[baseOffset + 10] = middle; // Middle 2
if (baseOffset + 11 < muscleCount) humanPose.muscles[baseOffset + 11] = middle; // Middle 3
// 약지
if (baseOffset + 12 < muscleCount) humanPose.muscles[baseOffset + 12] = ring; // Ring 1
if (baseOffset + 13 < muscleCount) humanPose.muscles[baseOffset + 13] = spread; // Ring Spread
if (baseOffset + 14 < muscleCount) humanPose.muscles[baseOffset + 14] = ring; // Ring 2
if (baseOffset + 15 < muscleCount) humanPose.muscles[baseOffset + 15] = ring; // Ring 3
// 새끼손가락
if (baseOffset + 16 < muscleCount) humanPose.muscles[baseOffset + 16] = pinky; // Little 1
if (baseOffset + 17 < muscleCount) humanPose.muscles[baseOffset + 17] = spread; // Little Spread
if (baseOffset + 18 < muscleCount) humanPose.muscles[baseOffset + 18] = pinky; // Little 2
if (baseOffset + 19 < muscleCount) humanPose.muscles[baseOffset + 19] = pinky; // Little 3
}
}

View File

@ -5,19 +5,14 @@ namespace KindRetargeting
/// <summary>
/// HIK 스타일 2-Pass 접지 시스템.
///
/// Pass 1 (Update, Order 5 → IK 전):
/// Pass 1 (OnUpdate, Order 5 → IK 전):
/// IK 타겟 위치를 수정하여 Toes가 바닥을 뚫지 않도록 보정.
/// Toe Pivot 감지: 발끝이 바닥에 있고 발목이 올라가면
/// 발목 타겟을 역산하여 Toes가 groundHeight에 고정.
///
/// Pass 2 (LateUpdate → IK 후):
/// Pass 2 (OnLateUpdate → IK 후):
/// IK 결과의 잔차를 Foot 회전으로 미세 보정.
/// 위치 변경 없음 — 본 길이 보존.
///
/// 힙 높이 보정은 CRS의 floorHeight가 담당합니다 (이중 보정 방지).
/// </summary>
[DefaultExecutionOrder(5)]
public class FootGroundingController : MonoBehaviour
[System.Serializable]
public class FootGroundingController
{
[Header("접지 설정")]
[Tooltip("바닥 Y 좌표 (월드 공간)")]
@ -41,37 +36,31 @@ namespace KindRetargeting
private TwoBoneIKSolver ikSolver;
private Animator animator;
// 타겟 아바타 캐싱
private Transform leftFoot;
private Transform rightFoot;
private Transform leftToes;
private Transform rightToes;
// Toes의 Foot 로컬 오프셋 (T-pose에서 캐싱)
private Vector3 leftLocalToesOffset;
private Vector3 rightLocalToesOffset;
// flat 상태에서 발목 최소 높이 (Foot.y - Toes.y)
private float leftFootHeight;
private float rightFootHeight;
// Toes 본 존재 여부
private bool leftHasToes;
private bool rightHasToes;
// 스무딩용: 이전 프레임 보정량
private float leftPrevAdj;
private float rightPrevAdj;
private bool isInitialized;
private void Start()
public void Initialize(TwoBoneIKSolver ikSolver, Animator animator)
{
ikSolver = GetComponent<TwoBoneIKSolver>();
animator = GetComponent<Animator>();
this.ikSolver = ikSolver;
this.animator = animator;
if (animator == null || !animator.isHuman || ikSolver == null) return;
if (leftFoot == null && rightFoot == null) return;
leftFoot = animator.GetBoneTransform(HumanBodyBones.LeftFoot);
rightFoot = animator.GetBoneTransform(HumanBodyBones.RightFoot);
@ -80,7 +69,6 @@ namespace KindRetargeting
if (leftFoot == null || rightFoot == null) return;
// Toes 존재 여부 + 캐싱
leftHasToes = leftToes != null;
rightHasToes = rightToes != null;
@ -91,7 +79,7 @@ namespace KindRetargeting
}
else
{
leftFootHeight = 0.05f; // Toes 없을 때 기본 발목 높이
leftFootHeight = 0.05f;
}
if (rightHasToes)
@ -110,7 +98,7 @@ namespace KindRetargeting
/// <summary>
/// Pass 1: Pre-IK — IK 타겟 위치를 수정합니다.
/// </summary>
private void Update()
public void OnUpdate()
{
if (!isInitialized || groundingWeight < 0.001f) return;
@ -121,16 +109,11 @@ namespace KindRetargeting
ikSolver.rightLeg, rightLocalToesOffset, rightFootHeight,
rightHasToes, ikSolver.rightLeg.positionWeight);
// 스무딩: 보정량 급변 방지
float dt = Time.deltaTime * smoothSpeed;
leftPrevAdj = Mathf.Lerp(leftPrevAdj, leftAdj, Mathf.Clamp01(dt));
rightPrevAdj = Mathf.Lerp(rightPrevAdj, rightAdj, Mathf.Clamp01(dt));
}
/// <summary>
/// 발 IK 타겟을 접지 모드에 따라 보정합니다.
/// Toes가 없는 아바타는 발목 Y 클램프만 수행합니다.
/// </summary>
private float AdjustFootTarget(TwoBoneIKSolver.LimbIK limb, Vector3 localToesOffset,
float footHeight, bool hasToes, float ikWeight)
{
@ -140,12 +123,10 @@ namespace KindRetargeting
Vector3 anklePos = ankleTarget.position;
float ankleY = anklePos.y;
// AIRBORNE 체크
if (ankleY - groundHeight > activationHeight) return 0f;
float weight = groundingWeight * ikWeight;
// === Toes 없는 아바타: 단순 Y 클램프 ===
if (!hasToes)
{
float minAnkleY = groundHeight + footHeight;
@ -159,7 +140,6 @@ namespace KindRetargeting
return 0f;
}
// === Toes 있는 아바타: 예측 기반 보정 ===
Vector3 predictedToesWorld = anklePos + ankleTarget.rotation * localToesOffset;
float predictedToesY = predictedToesWorld.y;
@ -171,7 +151,6 @@ namespace KindRetargeting
if (ankleY < groundHeight + footHeight + plantThreshold)
{
// PLANTED: 발 전체가 바닥 근처
float minAnkleY = groundHeight + footHeight;
if (ankleY < minAnkleY)
{
@ -188,7 +167,6 @@ namespace KindRetargeting
}
else
{
// TOE_PIVOT: 발끝 고정, 발목 올라감
if (toesError > 0f)
{
adjustment = toesError * weight;
@ -200,7 +178,6 @@ namespace KindRetargeting
}
else
{
// Toes 충분히 위 → 발목만 바닥 아래 방지
float minAnkleY = groundHeight + footHeight;
if (ankleY < minAnkleY)
{
@ -216,7 +193,7 @@ namespace KindRetargeting
/// <summary>
/// Pass 2: Post-IK — 잔차를 Foot 회전으로 미세 보정합니다.
/// </summary>
private void LateUpdate()
public void OnLateUpdate()
{
if (!isInitialized || groundingWeight < 0.001f) return;
@ -226,10 +203,6 @@ namespace KindRetargeting
AlignFootToGround(rightFoot, rightToes, ikSolver.rightLeg.positionWeight);
}
/// <summary>
/// IK 후 실제 Toes 위치를 확인하고, Foot 본을 pitch 회전하여 잔차 보정.
/// 바닥 아래로 뚫린 경우만 보정합니다.
/// </summary>
private void AlignFootToGround(Transform foot, Transform toes, float ikWeight)
{
if (foot == null || toes == null) return;
@ -244,7 +217,6 @@ namespace KindRetargeting
if (Mathf.Abs(error) < 0.001f) return;
// 바닥 아래로 뚫린 경우만 보정
if (error > plantThreshold) return;
Vector3 footToToes = toes.position - foot.position;

View File

@ -4,8 +4,8 @@ using UnityEngine;
namespace KindRetargeting
{
[DefaultExecutionOrder(4)]
public class LimbWeightController : MonoBehaviour
[System.Serializable]
public class LimbWeightController
{
[Header("거리 기반 가중치 설정")]
[SerializeField, Range(0.3f, 1f)] public float maxDistance = 0.5f; // 가중치가 0이 되는 최대 거리
@ -36,9 +36,8 @@ namespace KindRetargeting
public float chairSeatHeightOffset = 0.05f;
private TwoBoneIKSolver ikSolver;
private CustomRetargetingScript crs;
private Dictionary<string, Dictionary<int, float>> weightLayers = new Dictionary<string, Dictionary<int, float>>();
private Transform characterRoot;
List<float> leftArmEndWeights = new List<float>();
List<float> rightArmEndWeights = new List<float>();
@ -57,8 +56,6 @@ namespace KindRetargeting
public List<Transform> props = new List<Transform>();
public Transform characterRoot;
// 힙스 가중치 리스트 추가
List<float> hipsWeights = new List<float>();
private float MasterHipsWeight = 1f;
@ -67,8 +64,63 @@ namespace KindRetargeting
private float currentChairSeatOffset = 0f;
private float targetChairSeatOffset = 0f;
void Update()
private bool isInitialized;
public void Initialize(TwoBoneIKSolver ikSolver, CustomRetargetingScript crs, Transform characterRoot)
{
this.ikSolver = ikSolver;
this.crs = crs;
this.characterRoot = characterRoot;
if (ikSolver == null || crs == null) return;
InitWeightLayers();
//프랍 오브젝트 찾기
props = Object.FindObjectsByType<PropTypeController>(FindObjectsSortMode.None).Select(controller => controller.transform).ToList();
// 다른 캐릭터의 손을 props에 추가
GetHand();
//HandDistances()에서 사용을 위한 리스트 추가
//손 거리에 따른 웨이트 업데이트 인덱스 0번
leftArmEndWeights.Add(0);
rightArmEndWeights.Add(0);
// 프랍과의 거리에 따른 웨이트 업데이트 인덱스 1번
leftArmEndWeights.Add(0);
rightArmEndWeights.Add(0);
// 앉아있을 때 다리와의 거리에 따른 가중치 적용 인덱스 0번
leftLegEndWeights.Add(0);
rightLegEndWeights.Add(0);
// 다리 골 가중치 초기화
leftLegBendWeights.Add(1f); // 기본 가중치
rightLegBendWeights.Add(1f); // 기본 가중치
if (this.characterRoot == null)
{
this.characterRoot = crs.transform;
}
// 힙스 가중치 초기화 인덱스 0번
hipsWeights.Add(1f); // 의자 거리 기반 가중치
// 지면 높이 기반 가중치 초기화 인덱스 1번
hipsWeights.Add(1f); // 지면 높이 기반 가중치
// 발 높이 기반 가중치 초기화 인덱스 1번
leftLegEndWeights.Add(1f);
rightLegEndWeights.Add(1f);
isInitialized = true;
}
public void OnUpdate()
{
if (!isInitialized) return;
//손의 거리를 기반으로한 가중치 적용
HandDistances();
@ -94,69 +146,15 @@ namespace KindRetargeting
ApplyWeightsToFBIK();
}
void Start()
{
ikSolver = GetComponent<TwoBoneIKSolver>();
crs = GetComponent<CustomRetargetingScript>();
InitWeightLayers();
//프랍 오브젝트 찾기
props = FindObjectsByType<PropTypeController>(FindObjectsSortMode.None).Select(controller => controller.transform).ToList();
// 프랍 오브젝트 찾기
GetHand();
//HandDistances();에서 사용을 위한 리스트 추가
//손 거리에 따른 웨이트 업데이트 인덱스 0번
leftArmEndWeights.Add(0);
rightArmEndWeights.Add(0);
// 프랍과의 거리에 따른 웨이트 업데이트 인덱스 1번
leftArmEndWeights.Add(0);
rightArmEndWeights.Add(0);
// 앉아있을 때 다리와의 거리에 따른 가중치 적용 인덱스 0번
leftLegEndWeights.Add(0);
rightLegEndWeights.Add(0);
// 다리 골 가중치 초기화
leftLegBendWeights.Add(1f); // 기본 가중치
rightLegBendWeights.Add(1f); // 기본 가중치
// CharacterController가 있는 루트 오브젝트 찾기
if (characterRoot == null)
{
characterRoot = transform;
}
// 힙스 가중치 초기화 인덱스 0번
hipsWeights.Add(1f); // 의자 거리 기반 가중치
// 지면 높이 기반 가중치 초기화 인덱스 1번
hipsWeights.Add(1f); // 지면 높이 기반 가중치
// 발 높이 기반 가중치 초기화 인덱스 1번
leftLegEndWeights.Add(1f);
rightLegEndWeights.Add(1f);
}
private void GetHand()
{
// 모든 LimbWeightController 찾기
LimbWeightController[] allControllers = FindObjectsOfType<LimbWeightController>();
// 모든 CustomRetargetingScript 찾기 (다른 캐릭터의 손을 props에 추가)
CustomRetargetingScript[] allCrs = Object.FindObjectsByType<CustomRetargetingScript>(FindObjectsSortMode.None);
foreach (LimbWeightController controller in allControllers)
foreach (CustomRetargetingScript otherCrs in allCrs)
{
// 자기 자신은 제외
if (controller == this) continue;
// CustomRetargetingScript 가져오기
CustomRetargetingScript otherCrs = controller.GetComponent<CustomRetargetingScript>();
if (otherCrs == null) continue;
if (otherCrs == crs) continue;
// 왼손과 오른손 Transform 가져오기
Transform leftHand = otherCrs?.sourceAnimator?.GetBoneTransform(HumanBodyBones.LeftHand);

View File

@ -4,7 +4,8 @@ using UniHumanoid;
namespace KindRetargeting
{
public class PropLocationController : MonoBehaviour
[System.Serializable]
public class PropLocationController
{
// 캐시된 타겟과 오프셋 Transform
[System.Serializable]
@ -18,9 +19,9 @@ namespace KindRetargeting
[SerializeField] private TargetOffset rightHandTargetOffset;
[SerializeField] private TargetOffset headTargetOffset;
private void Start()
public void Initialize(Animator animator)
{
CreateTargets();
CreateTargets(animator);
}
public void SetTPose(Animator animator)
@ -31,7 +32,6 @@ namespace KindRetargeting
Avatar avatar = animator.avatar;
Transform transform = animator.transform;
// HumanPoseClip에 저장된 T-포즈 데이터를 로드하여 적용
var humanPoseClip = Resources.Load<HumanPoseClip>(HumanPoseClip.TPoseResourcePath);
if (humanPoseClip != null)
{
@ -43,9 +43,9 @@ namespace KindRetargeting
Debug.LogWarning("T-Pose 데이터가 존재하지 않습니다.");
}
}
private void CreateTargets()
private void CreateTargets(Animator animator)
{
Animator animator = GetComponent<Animator>();
SetTPose(animator);
// 왼손 타겟 및 오프셋 설정
@ -54,7 +54,7 @@ namespace KindRetargeting
{
leftHandTargetOffset = new TargetOffset();
GameObject leftTarget = new GameObject("Left_Hand_Target");
leftTarget.transform.parent = leftHandBone; // 왼손 본에 직접 부모 설정
leftTarget.transform.parent = leftHandBone;
leftHandTargetOffset.target = leftTarget.transform;
leftTarget.transform.position = leftHandBone.position + new Vector3(-0.039f, -0.022f, 0f);
leftTarget.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
@ -63,7 +63,6 @@ namespace KindRetargeting
leftOffset.transform.parent = leftTarget.transform;
leftHandTargetOffset.offset = leftOffset.transform;
// 로컬 포지션과 로테이션 설정
leftHandTargetOffset.offset.localPosition = Vector3.zero;
leftHandTargetOffset.offset.localRotation = Quaternion.identity;
}
@ -74,7 +73,7 @@ namespace KindRetargeting
{
rightHandTargetOffset = new TargetOffset();
GameObject rightTarget = new GameObject("Right_Hand_Target");
rightTarget.transform.parent = rightHandBone; // 오른손 본에 직접 부모 설정
rightTarget.transform.parent = rightHandBone;
rightHandTargetOffset.target = rightTarget.transform;
rightTarget.transform.position = rightHandBone.position + new Vector3(0.039f, -0.022f, 0f);
rightTarget.transform.rotation = Quaternion.Euler(90f, 0f, 0f);
@ -83,7 +82,6 @@ namespace KindRetargeting
rightOffset.transform.parent = rightTarget.transform;
rightHandTargetOffset.offset = rightOffset.transform;
// 로컬 포지션과 로테이션 설정
rightHandTargetOffset.offset.localPosition = Vector3.zero;
rightHandTargetOffset.offset.localRotation = Quaternion.identity;
}
@ -94,7 +92,7 @@ namespace KindRetargeting
{
headTargetOffset = new TargetOffset();
GameObject headTarget = new GameObject("Head_Target");
headTarget.transform.parent = headBone; // 머리 본에 직접 부모 설정
headTarget.transform.parent = headBone;
headTargetOffset.target = headTarget.transform;
headTarget.transform.position = headBone.position + new Vector3(0f, 0.16f, 0f);
headTarget.transform.rotation = Quaternion.Euler(0f, 0f, 0f);
@ -103,7 +101,6 @@ namespace KindRetargeting
headOffset.transform.parent = headTarget.transform;
headTargetOffset.offset = headOffset.transform;
// 기본 오프셋 설정
headTargetOffset.offset.localPosition = Vector3.zero;
headTargetOffset.offset.localRotation = Quaternion.identity;
}
@ -172,7 +169,6 @@ namespace KindRetargeting
}
}
// 에디터에서 사용할 메서드들
#if UNITY_EDITOR
public void MoveToHead()
{
@ -195,7 +191,6 @@ namespace KindRetargeting
}
#endif
// 오프셋 getter 메서드들 추가
public Transform GetLeftHandOffset()
{
return leftHandTargetOffset?.offset;

View File

@ -1,4 +1,5 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
@ -131,6 +132,7 @@ namespace KindRetargeting.Remote
string property = json["property"]?.ToString();
float value = json["value"]?.Value<float>() ?? 0f;
UpdateValue(charId, property, value);
BroadcastValueChanged(charId, property, value);
}
break;
@ -164,6 +166,20 @@ namespace KindRetargeting.Remote
}
break;
case "autoHipsOffset":
{
string charId = json["characterId"]?.ToString();
AutoHipsOffset(charId);
}
break;
case "autoCalibrateAll":
{
string charId = json["characterId"]?.ToString();
AutoCalibrateAll(charId);
}
break;
default:
Debug.LogWarning($"[RetargetingRemote] 알 수 없는 액션: {action}");
break;
@ -215,9 +231,6 @@ namespace KindRetargeting.Remote
return;
}
var limbWeight = script.GetComponent<LimbWeightController>();
var handPose = script.GetComponent<FingerShapedController>();
var data = new Dictionary<string, object>
{
// 힙 위치 보정 (로컬)
@ -251,38 +264,58 @@ namespace KindRetargeting.Remote
{ "fingerCopyMode", (int)GetPrivateField<EnumsList.FingerCopyMode>(script, "fingerCopyMode") },
// 캘리브레이션 상태
{ "hasCalibrationData", script.HasCachedSettings() }
{ "hasCalibrationData", script.HasCachedSettings() },
// LimbWeightController 데이터
{ "limbMinDistance", script.limbWeight.minDistance },
{ "limbMaxDistance", script.limbWeight.maxDistance },
{ "weightSmoothSpeed", script.limbWeight.weightSmoothSpeed },
{ "hipsMinDistance", script.limbWeight.hipsMinDistance },
{ "hipsMaxDistance", script.limbWeight.hipsMaxDistance },
{ "groundHipsMinHeight", script.limbWeight.groundHipsMinHeight },
{ "groundHipsMaxHeight", script.limbWeight.groundHipsMaxHeight },
{ "footHeightMinThreshold", script.limbWeight.footHeightMinThreshold },
{ "footHeightMaxThreshold", script.limbWeight.footHeightMaxThreshold },
{ "chairSeatHeightOffset", script.limbWeight.chairSeatHeightOffset },
{ "enableLeftArmIK", script.limbWeight.enableLeftArmIK },
{ "enableRightArmIK", script.limbWeight.enableRightArmIK },
// ShoulderCorrection 데이터
{ "shoulderBlendStrength", script.shoulderCorrection.blendStrength },
{ "shoulderMaxBlend", script.shoulderCorrection.maxShoulderBlend },
{ "shoulderMaxHeightDiff", script.shoulderCorrection.maxHeightDifference },
{ "shoulderMinHeightDiff", script.shoulderCorrection.minHeightDifference },
{ "shoulderReverseLeft", script.shoulderCorrection.reverseLeftRotation },
{ "shoulderReverseRight", script.shoulderCorrection.reverseRightRotation },
// FootGrounding 데이터
{ "groundingWeight", script.footGrounding.groundingWeight },
{ "groundingGroundHeight", script.footGrounding.groundHeight },
{ "groundingActivationHeight", script.footGrounding.activationHeight },
{ "groundingPlantThreshold", script.footGrounding.plantThreshold },
{ "groundingSmoothSpeed", script.footGrounding.smoothSpeed },
// FingerShapedController 데이터
{ "handPoseEnabled", script.fingerShaped.enabled },
{ "leftHandEnabled", script.fingerShaped.leftHandEnabled },
{ "rightHandEnabled", script.fingerShaped.rightHandEnabled },
{ "leftThumbCurl", script.fingerShaped.leftThumbCurl },
{ "leftIndexCurl", script.fingerShaped.leftIndexCurl },
{ "leftMiddleCurl", script.fingerShaped.leftMiddleCurl },
{ "leftRingCurl", script.fingerShaped.leftRingCurl },
{ "leftPinkyCurl", script.fingerShaped.leftPinkyCurl },
{ "leftSpreadFingers", script.fingerShaped.leftSpreadFingers },
{ "rightThumbCurl", script.fingerShaped.rightThumbCurl },
{ "rightIndexCurl", script.fingerShaped.rightIndexCurl },
{ "rightMiddleCurl", script.fingerShaped.rightMiddleCurl },
{ "rightRingCurl", script.fingerShaped.rightRingCurl },
{ "rightPinkyCurl", script.fingerShaped.rightPinkyCurl },
{ "rightSpreadFingers", script.fingerShaped.rightSpreadFingers },
// 최소 발목 높이
{ "minimumAnkleHeight", GetPrivateField<float>(script, "minimumAnkleHeight") },
};
// LimbWeightController 데이터
if (limbWeight != null)
{
data["limbMinDistance"] = limbWeight.minDistance;
data["limbMaxDistance"] = limbWeight.maxDistance;
data["weightSmoothSpeed"] = limbWeight.weightSmoothSpeed;
data["hipsMinDistance"] = limbWeight.hipsMinDistance;
data["hipsMaxDistance"] = limbWeight.hipsMaxDistance;
data["groundHipsMinHeight"] = limbWeight.groundHipsMinHeight;
data["groundHipsMaxHeight"] = limbWeight.groundHipsMaxHeight;
data["footHeightMinThreshold"] = limbWeight.footHeightMinThreshold;
data["footHeightMaxThreshold"] = limbWeight.footHeightMaxThreshold;
data["chairSeatHeightOffset"] = limbWeight.chairSeatHeightOffset;
}
// FingerShapedController 데이터
if (handPose != null)
{
data["handPoseEnabled"] = handPose.enabled;
data["leftHandEnabled"] = handPose.leftHandEnabled;
data["rightHandEnabled"] = handPose.rightHandEnabled;
}
else
{
data["handPoseEnabled"] = false;
data["leftHandEnabled"] = false;
data["rightHandEnabled"] = false;
}
var response = new
{
type = "characterData",
@ -298,9 +331,6 @@ namespace KindRetargeting.Remote
var script = FindCharacter(characterId);
if (script == null) return;
var limbWeight = script.GetComponent<LimbWeightController>();
var handPose = script.GetComponent<FingerShapedController>();
switch (property)
{
// 힙 위치 보정
@ -363,56 +393,135 @@ namespace KindRetargeting.Remote
// LimbWeightController 속성
case "limbMinDistance":
if (limbWeight != null) limbWeight.minDistance = value;
script.limbWeight.minDistance = value;
break;
case "limbMaxDistance":
if (limbWeight != null) limbWeight.maxDistance = value;
script.limbWeight.maxDistance = value;
break;
case "weightSmoothSpeed":
if (limbWeight != null) limbWeight.weightSmoothSpeed = value;
script.limbWeight.weightSmoothSpeed = value;
break;
case "hipsMinDistance":
if (limbWeight != null) limbWeight.hipsMinDistance = value;
script.limbWeight.hipsMinDistance = value;
break;
case "hipsMaxDistance":
if (limbWeight != null) limbWeight.hipsMaxDistance = value;
script.limbWeight.hipsMaxDistance = value;
break;
case "groundHipsMinHeight":
if (limbWeight != null) limbWeight.groundHipsMinHeight = value;
script.limbWeight.groundHipsMinHeight = value;
break;
case "groundHipsMaxHeight":
if (limbWeight != null) limbWeight.groundHipsMaxHeight = value;
script.limbWeight.groundHipsMaxHeight = value;
break;
case "footHeightMinThreshold":
if (limbWeight != null) limbWeight.footHeightMinThreshold = value;
script.limbWeight.footHeightMinThreshold = value;
break;
case "footHeightMaxThreshold":
if (limbWeight != null) limbWeight.footHeightMaxThreshold = value;
script.limbWeight.footHeightMaxThreshold = value;
break;
case "chairSeatHeightOffset":
if (limbWeight != null) limbWeight.chairSeatHeightOffset = value;
script.limbWeight.chairSeatHeightOffset = value;
break;
case "enableLeftArmIK":
script.limbWeight.enableLeftArmIK = value > 0.5f;
break;
case "enableRightArmIK":
script.limbWeight.enableRightArmIK = value > 0.5f;
break;
// ShoulderCorrection 속성
case "shoulderBlendStrength":
script.shoulderCorrection.blendStrength = value;
break;
case "shoulderMaxBlend":
script.shoulderCorrection.maxShoulderBlend = value;
break;
case "shoulderMaxHeightDiff":
script.shoulderCorrection.maxHeightDifference = value;
break;
case "shoulderMinHeightDiff":
script.shoulderCorrection.minHeightDifference = value;
break;
case "shoulderReverseLeft":
script.shoulderCorrection.reverseLeftRotation = value > 0.5f;
break;
case "shoulderReverseRight":
script.shoulderCorrection.reverseRightRotation = value > 0.5f;
break;
// FootGrounding 속성
case "groundingWeight":
script.footGrounding.groundingWeight = value;
break;
case "groundingGroundHeight":
script.footGrounding.groundHeight = value;
break;
case "groundingActivationHeight":
script.footGrounding.activationHeight = value;
break;
case "groundingPlantThreshold":
script.footGrounding.plantThreshold = value;
break;
case "groundingSmoothSpeed":
script.footGrounding.smoothSpeed = value;
break;
// FingerShapedController 속성
case "handPoseEnabled":
if (handPose != null)
handPose.enabled = value > 0.5f;
script.fingerShaped.enabled = value > 0.5f;
break;
case "leftHandEnabled":
if (handPose != null)
{
handPose.leftHandEnabled = value > 0.5f;
if (handPose.leftHandEnabled)
handPose.enabled = true;
}
script.fingerShaped.leftHandEnabled = value > 0.5f;
if (script.fingerShaped.leftHandEnabled)
script.fingerShaped.enabled = true;
break;
case "rightHandEnabled":
if (handPose != null)
{
handPose.rightHandEnabled = value > 0.5f;
if (handPose.rightHandEnabled)
handPose.enabled = true;
}
script.fingerShaped.rightHandEnabled = value > 0.5f;
if (script.fingerShaped.rightHandEnabled)
script.fingerShaped.enabled = true;
break;
// 개별 손가락 curl 값
case "leftThumbCurl":
script.fingerShaped.leftThumbCurl = value;
break;
case "leftIndexCurl":
script.fingerShaped.leftIndexCurl = value;
break;
case "leftMiddleCurl":
script.fingerShaped.leftMiddleCurl = value;
break;
case "leftRingCurl":
script.fingerShaped.leftRingCurl = value;
break;
case "leftPinkyCurl":
script.fingerShaped.leftPinkyCurl = value;
break;
case "leftSpreadFingers":
script.fingerShaped.leftSpreadFingers = value;
break;
case "rightThumbCurl":
script.fingerShaped.rightThumbCurl = value;
break;
case "rightIndexCurl":
script.fingerShaped.rightIndexCurl = value;
break;
case "rightMiddleCurl":
script.fingerShaped.rightMiddleCurl = value;
break;
case "rightRingCurl":
script.fingerShaped.rightRingCurl = value;
break;
case "rightPinkyCurl":
script.fingerShaped.rightPinkyCurl = value;
break;
case "rightSpreadFingers":
script.fingerShaped.rightSpreadFingers = value;
break;
// 최소 발목 높이
case "minimumAnkleHeight":
SetPrivateField(script, "minimumAnkleHeight", value);
break;
default:
@ -426,8 +535,7 @@ namespace KindRetargeting.Remote
var script = FindCharacter(characterId);
if (script == null) return;
var handPose = script.GetComponent<FingerShapedController>();
if (handPose == null) return;
var handPose = script.fingerShaped;
// 스크립트 자동 활성화
handPose.enabled = true;
@ -482,6 +590,7 @@ namespace KindRetargeting.Remote
handPose.rightSpreadFingers = spread;
}
SendCharacterData(characterId);
SendStatus(true, $"{presetName} 프리셋 적용됨");
}
@ -520,6 +629,93 @@ namespace KindRetargeting.Remote
SendStatus(true, "정면 캘리브레이션 완료");
}
private void AutoHipsOffset(string characterId)
{
var script = FindCharacter(characterId);
if (script == null) return;
float offset = CalculateHipsOffsetFromLegDifference(script);
SetPrivateField(script, "hipsOffsetY", offset);
script.SaveSettings();
SendCharacterData(characterId);
SendStatus(true, $"다리 길이 자동 보정 완료: hipsOffsetY={offset:F4}");
}
private void AutoCalibrateAll(string characterId)
{
var script = FindCharacter(characterId);
if (script == null) return;
Animator source = script.sourceAnimator;
Animator targetAnim = script.targetAnimator;
if (source == null || targetAnim == null || !source.isHuman || !targetAnim.isHuman)
{
SendStatus(false, "소스/타겟 Animator가 없거나 Humanoid가 아닙니다.");
return;
}
// Step 1: 크기 초기화 + 힙 오프셋 계산
script.ResetScale();
SetPrivateField(script, "avatarScale", 1f);
SetPrivateField(script, "hipsOffsetY", CalculateHipsOffsetFromLegDifference(script));
// Step 2: 1프레임 후 목 높이 비율로 크기 조정
StartCoroutine(AutoCalibrateCoroutine(script, characterId));
}
private IEnumerator AutoCalibrateCoroutine(CustomRetargetingScript script, string characterId)
{
yield return null; // 1프레임 대기
Animator source = script.sourceAnimator;
Animator targetAnim = script.targetAnimator;
Transform sourceNeck = source.GetBoneTransform(HumanBodyBones.Neck);
Transform targetNeck = targetAnim.GetBoneTransform(HumanBodyBones.Neck);
if (sourceNeck == null || targetNeck == null)
{
SendStatus(false, "목 본을 찾을 수 없습니다.");
yield break;
}
float scaleRatio = Mathf.Clamp(sourceNeck.position.y / Mathf.Max(targetNeck.position.y, 0.01f), 0.1f, 3f);
script.SetAvatarScale(scaleRatio);
SetPrivateField(script, "avatarScale", scaleRatio);
yield return null; // 1프레임 대기
// Step 3: 힙 오프셋 재계산 + 머리 정면 캘리브레이션
SetPrivateField(script, "hipsOffsetY", CalculateHipsOffsetFromLegDifference(script));
script.CalibrateHeadToForward();
script.SaveSettings();
SendCharacterData(characterId);
SendStatus(true, $"전체 자동 보정 완료: avatarScale={scaleRatio:F3}");
}
private float CalculateHipsOffsetFromLegDifference(CustomRetargetingScript script)
{
Animator source = script.sourceAnimator;
Animator targetAnim = script.targetAnimator;
if (source == null || targetAnim == null) return 0f;
float sourceLeg = GetLegLength(source);
float targetLeg = GetLegLength(targetAnim);
if (sourceLeg < 0.01f || targetLeg < 0.01f) return 0f;
return targetLeg - sourceLeg;
}
private float GetLegLength(Animator animator)
{
Transform upper = animator.GetBoneTransform(HumanBodyBones.LeftUpperLeg);
Transform lower = animator.GetBoneTransform(HumanBodyBones.LeftLowerLeg);
Transform foot = animator.GetBoneTransform(HumanBodyBones.LeftFoot);
if (upper == null || lower == null || foot == null) return 0f;
return Vector3.Distance(upper.position, lower.position) + Vector3.Distance(lower.position, foot.position);
}
private CustomRetargetingScript FindCharacter(string characterId)
{
foreach (var script in registeredCharacters)
@ -532,6 +728,19 @@ namespace KindRetargeting.Remote
return null;
}
private void BroadcastValueChanged(string characterId, string property, float value)
{
var response = new
{
type = "valueChanged",
characterId = characterId,
property = property,
value = value
};
wsServer?.Broadcast(JsonConvert.SerializeObject(response));
}
private void SendStatus(bool success, string message)
{
var response = new

View File

@ -2,22 +2,20 @@ using UnityEngine;
namespace KindRetargeting
{
[DefaultExecutionOrder(3)]
public class ShoulderCorrectionFunction : MonoBehaviour
[System.Serializable]
public class ShoulderCorrectionFunction
{
private CustomRetargetingScript retargetingScript; // 소스 데이터를 가져올 리타게팅 스크립트
[Header("설정")]
[Range(0f, 5f)]
public float blendStrength = 2f; // 전체적인 보정 강도
public float blendStrength = 2f;
[Range(0f, 1f)]
public float maxShoulderBlend = 0.7f; // 어깨에 최대로 전달될 수 있는 회전 비율
public bool reverseLeftRotation = false; // 왼쪽 어깨 회전 방향 반전 설정
public bool reverseRightRotation = false; // 오른쪽 어깨 회전 방향 반전 설정
public float maxShoulderBlend = 0.7f;
public bool reverseLeftRotation = false;
public bool reverseRightRotation = false;
[Header("높이 제한 설정")]
public float maxHeightDifference = 0.8f; // 최대 높이 차이
public float minHeightDifference = -0.1f; // 최소 높이 차이 (이 값 이하에서는 보정하지 않음)
public float maxHeightDifference = 0.8f;
public float minHeightDifference = -0.1f;
[Header("보정 커브 설정")]
public AnimationCurve shoulderCorrectionCurve = AnimationCurve.Linear(0f, 0f, 1f, 1f);
@ -29,26 +27,24 @@ namespace KindRetargeting
private Transform rightShoulder;
private Transform leftUpperArm;
private Transform rightUpperArm;
// 최적화: 팔꿈치(LowerArm) Transform 캐싱 추가
private Transform leftLowerArm;
private Transform rightLowerArm;
private void Start()
public void Initialize(Animator targetAnimator)
{
retargetingScript = GetComponent<CustomRetargetingScript>();
leftShoulder = retargetingScript.targetAnimator.GetBoneTransform(HumanBodyBones.LeftShoulder);
rightShoulder = retargetingScript.targetAnimator.GetBoneTransform(HumanBodyBones.RightShoulder);
leftUpperArm = retargetingScript.targetAnimator.GetBoneTransform(HumanBodyBones.LeftUpperArm);
rightUpperArm = retargetingScript.targetAnimator.GetBoneTransform(HumanBodyBones.RightUpperArm);
// 최적화: 팔꿈치 Transform도 Start에서 캐싱
leftLowerArm = retargetingScript.targetAnimator.GetBoneTransform(HumanBodyBones.LeftLowerArm);
rightLowerArm = retargetingScript.targetAnimator.GetBoneTransform(HumanBodyBones.RightLowerArm);
leftShoulder = targetAnimator.GetBoneTransform(HumanBodyBones.LeftShoulder);
rightShoulder = targetAnimator.GetBoneTransform(HumanBodyBones.RightShoulder);
leftUpperArm = targetAnimator.GetBoneTransform(HumanBodyBones.LeftUpperArm);
rightUpperArm = targetAnimator.GetBoneTransform(HumanBodyBones.RightUpperArm);
leftLowerArm = targetAnimator.GetBoneTransform(HumanBodyBones.LeftLowerArm);
rightLowerArm = targetAnimator.GetBoneTransform(HumanBodyBones.RightLowerArm);
}
private void Update()
public void OnUpdate()
{
// 왼쪽 어깨 보정 (최적화: 캐싱된 Transform 사용)
if (leftShoulder == null || rightShoulder == null) return;
// 왼쪽 어깨 보정
Vector3 leftElbowPos = leftLowerArm.position;
float leftHeightDiff = leftElbowPos.y - leftShoulder.position.y;
float leftRawBlend = Mathf.Clamp01(
@ -56,7 +52,7 @@ namespace KindRetargeting
);
leftBlendWeight = shoulderCorrectionCurve.Evaluate(leftRawBlend) * maxShoulderBlend;
// 오른쪽 어깨 보정 (최적화: 캐싱된 Transform 사용)
// 오른쪽 어깨 보정
Vector3 rightElbowPos = rightLowerArm.position;
float rightHeightDiff = rightElbowPos.y - rightShoulder.position.y;
float rightRawBlend = Mathf.Clamp01(

View File

@ -7,8 +7,8 @@ namespace KindRetargeting
/// FinalIK IKSolverTrigonometric.Solve()를 사용하는 IK 래퍼.
/// 4개 사지(양팔, 양다리)에 대해 FinalIK의 검증된 코사인 법칙 솔버를 호출합니다.
/// </summary>
[DefaultExecutionOrder(6)]
public class TwoBoneIKSolver : MonoBehaviour
[System.Serializable]
public class TwoBoneIKSolver
{
[System.Serializable]
public class LimbIK
@ -27,7 +27,6 @@ namespace KindRetargeting
[HideInInspector] public float upperLength;
[HideInInspector] public float lowerLength;
// 초기 벤드 법선 (upper 본 로컬 공간 — FinalIK 방식)
[HideInInspector] public Vector3 localBendNormal;
}
@ -43,15 +42,9 @@ namespace KindRetargeting
private bool isInitialized;
private void Start()
public void Initialize(Animator targetAnimator)
{
Initialize();
}
public void Initialize()
{
if (animator == null)
animator = GetComponent<Animator>();
animator = targetAnimator;
if (animator == null || !animator.isHuman) return;
CacheLimb(leftArm, HumanBodyBones.LeftUpperArm, HumanBodyBones.LeftLowerArm, HumanBodyBones.LeftHand);
@ -73,8 +66,6 @@ namespace KindRetargeting
limb.upperLength = Vector3.Distance(limb.upper.position, limb.lower.position);
limb.lowerLength = Vector3.Distance(limb.lower.position, limb.end.position);
// 초기 벤드 법선을 upper 본의 로컬 공간에 캐싱 (FinalIK TrigonometricBone 방식)
// 런타임에 upper.rotation * localBendNormal로 안정적인 월드 법선 획득
Vector3 ab = limb.lower.position - limb.upper.position;
Vector3 bc = limb.end.position - limb.lower.position;
Vector3 bendNormal = Vector3.Cross(ab, bc);
@ -90,7 +81,7 @@ namespace KindRetargeting
limb.localBendNormal = Quaternion.Inverse(limb.upper.rotation) * bendNormal;
}
private void Update()
public void OnUpdate()
{
if (!isInitialized) return;
@ -105,10 +96,8 @@ namespace KindRetargeting
if (!limb.enabled || limb.target == null || limb.positionWeight < 0.001f) return;
if (limb.upper == null || limb.lower == null || limb.end == null) return;
// 벤드 법선 계산
Vector3 bendNormal = GetBendNormal(limb);
// bendGoal 적용
if (limb.bendGoal != null && limb.bendGoalWeight > 0.001f)
{
Vector3 goalNormal = Vector3.Cross(
@ -121,7 +110,6 @@ namespace KindRetargeting
}
}
// FinalIK 정적 솔버 호출
IKSolverTrigonometric.Solve(
limb.upper,
limb.lower,
@ -131,7 +119,6 @@ namespace KindRetargeting
limb.positionWeight
);
// 끝단 회전
if (limb.rotationWeight > 0.001f)
{
limb.end.rotation = Quaternion.Slerp(
@ -142,19 +129,11 @@ namespace KindRetargeting
}
}
/// <summary>
/// 벤드 법선을 upper 본의 회전에서 유도합니다 (FinalIK 방식).
/// 위치 기반 Cross(ab, bc)는 직선 근처에서 불안정하지만,
/// 회전 기반은 본의 회전을 그대로 따르므로 안정적입니다.
/// </summary>
private Vector3 GetBendNormal(LimbIK limb)
{
return limb.upper.rotation * limb.localBendNormal;
}
/// <summary>
/// 히프 높이 자동 보정값을 계산합니다.
/// </summary>
public float CalculateAutoFloorHeight(float comfortRatio = 0.98f)
{
if (animator == null || leftLeg.upper == null || leftLeg.lower == null || leftLeg.end == null) return 0f;