- BackgroundSceneLoaderWindow: OnGUI → CreateGUI (Toolbar + ToolbarSearchField) - PropBrowserWindow: OnGUI → CreateGUI (Toolbar + ToolbarSearchField) - StreamingleCommon.uss: 브라우저 공통 스타일 추가 (그리드/리스트/뷰토글/액션바/상태바) - excludeFromWeb 상태 새로고침 시 보존 수정 - 삭제된 배경 리소스 정리 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1014 lines
43 KiB
C#
1014 lines
43 KiB
C#
using UnityEngine;
|
|
using UnityEditor;
|
|
using UnityEngine.UIElements;
|
|
using UnityEditor.UIElements;
|
|
using System.Collections.Generic;
|
|
using KindRetargeting;
|
|
using KindRetargeting.EnumsList;
|
|
|
|
public class RetargetingControlWindow : EditorWindow
|
|
{
|
|
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
|
|
|
private CustomRetargetingScript[] retargetingScripts;
|
|
private VisualElement characterContainer;
|
|
private Label emptyLabel;
|
|
|
|
private bool isDirty = false;
|
|
private double lastUpdateTime;
|
|
private const double UPDATE_INTERVAL = 0.25;
|
|
|
|
private readonly List<SerializedObject> trackedSerializedObjects = new List<SerializedObject>();
|
|
|
|
[MenuItem("Tools/리타게팅 컨트롤 패널")]
|
|
public static void ShowWindow()
|
|
{
|
|
GetWindow<RetargetingControlWindow>("리타게팅 컨트롤");
|
|
}
|
|
|
|
public void CreateGUI()
|
|
{
|
|
var root = rootVisualElement;
|
|
root.AddToClassList("tool-root");
|
|
|
|
var commonUss = AssetDatabase.LoadAssetAtPath<StyleSheet>(CommonUssPath);
|
|
if (commonUss != null) root.styleSheets.Add(commonUss);
|
|
|
|
root.Add(new Label("리타게팅 컨트롤 패널") { name = "title" });
|
|
root.Q<Label>("title").AddToClassList("tool-title");
|
|
|
|
// 전역 설정
|
|
var globalFoldout = new Foldout { text = "전역 설정", value = false };
|
|
var globalBtnRow = new VisualElement { style = { flexDirection = FlexDirection.Row } };
|
|
var allCalibBtn = new Button(() =>
|
|
{
|
|
var scripts = FindObjectsByType<CustomRetargetingScript>(FindObjectsSortMode.None);
|
|
foreach (var s in scripts) s.I_PoseCalibration();
|
|
AssetDatabase.SaveAssets();
|
|
RebuildCharacterPanels();
|
|
}) { text = "모든 캐릭터 I-포즈 캘리브레이션" };
|
|
allCalibBtn.style.flexGrow = 1;
|
|
allCalibBtn.style.marginRight = 2;
|
|
globalBtnRow.Add(allCalibBtn);
|
|
|
|
var allResetBtn = new Button(() =>
|
|
{
|
|
var scripts = FindObjectsByType<CustomRetargetingScript>(FindObjectsSortMode.None);
|
|
foreach (var s in scripts) { s.ResetPoseAndCache(); EditorUtility.SetDirty(s); }
|
|
AssetDatabase.SaveAssets();
|
|
RebuildCharacterPanels();
|
|
}) { text = "모든 캐릭터 캘리브레이션 초기화" };
|
|
allResetBtn.style.flexGrow = 1;
|
|
globalBtnRow.Add(allResetBtn);
|
|
globalFoldout.Add(globalBtnRow);
|
|
root.Add(globalFoldout);
|
|
|
|
// 리프레시 버튼
|
|
var refreshBtn = new Button(() => RefreshAndRebuild()) { text = "리타게팅 스크립트 리프레시" };
|
|
refreshBtn.style.height = 30;
|
|
refreshBtn.style.marginTop = 4;
|
|
root.Add(refreshBtn);
|
|
|
|
// 빈 상태 메시지
|
|
emptyLabel = new Label();
|
|
emptyLabel.style.display = DisplayStyle.None;
|
|
root.Add(emptyLabel);
|
|
|
|
// 캐릭터 목록
|
|
var scrollView = new ScrollView { style = { flexGrow = 1, marginTop = 8 } };
|
|
characterContainer = new VisualElement();
|
|
scrollView.Add(characterContainer);
|
|
root.Add(scrollView);
|
|
|
|
// 에디터 업데이트 (dirty tracking)
|
|
EditorApplication.update += OnEditorUpdate;
|
|
Undo.undoRedoPerformed += OnUndoRedoPerformed;
|
|
root.RegisterCallback<DetachFromPanelEvent>(_ =>
|
|
{
|
|
EditorApplication.update -= OnEditorUpdate;
|
|
Undo.undoRedoPerformed -= OnUndoRedoPerformed;
|
|
DisposeTrackedSerializedObjects();
|
|
});
|
|
|
|
RefreshAndRebuild();
|
|
}
|
|
|
|
private void OnEditorUpdate()
|
|
{
|
|
if (Application.isPlaying) return;
|
|
if (EditorApplication.timeSinceStartup - lastUpdateTime < UPDATE_INTERVAL) return;
|
|
if (isDirty)
|
|
{
|
|
RefreshScripts();
|
|
isDirty = false;
|
|
}
|
|
lastUpdateTime = EditorApplication.timeSinceStartup;
|
|
}
|
|
|
|
private void OnUndoRedoPerformed() { isDirty = true; }
|
|
|
|
private SerializedObject CreateTrackedSO(Object target)
|
|
{
|
|
var so = new SerializedObject(target);
|
|
trackedSerializedObjects.Add(so);
|
|
return so;
|
|
}
|
|
|
|
private void DisposeTrackedSerializedObjects()
|
|
{
|
|
foreach (var so in trackedSerializedObjects)
|
|
so?.Dispose();
|
|
trackedSerializedObjects.Clear();
|
|
}
|
|
|
|
private void RefreshScripts()
|
|
{
|
|
if (Application.isPlaying || retargetingScripts == null) return;
|
|
foreach (var s in retargetingScripts)
|
|
{
|
|
if (s != null && s.isActiveAndEnabled)
|
|
EditorUtility.SetDirty(s);
|
|
}
|
|
if (retargetingScripts.Length > 0 && retargetingScripts[0] != null && !Application.isPlaying)
|
|
{
|
|
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(retargetingScripts[0].gameObject.scene);
|
|
}
|
|
}
|
|
|
|
private void RefreshAndRebuild()
|
|
{
|
|
retargetingScripts = FindObjectsByType<CustomRetargetingScript>(FindObjectsSortMode.None);
|
|
|
|
// 필요한 컴포넌트 자동 추가
|
|
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>();
|
|
EditorUtility.SetDirty(s.gameObject);
|
|
}
|
|
|
|
RebuildCharacterPanels();
|
|
}
|
|
|
|
private void RebuildCharacterPanels()
|
|
{
|
|
if (characterContainer == null) return;
|
|
characterContainer.Clear();
|
|
DisposeTrackedSerializedObjects();
|
|
|
|
if (retargetingScripts == null || retargetingScripts.Length == 0)
|
|
{
|
|
emptyLabel.text = "씬에서 리타게팅 스크립트를 찾을 수 없습니다.";
|
|
emptyLabel.style.display = DisplayStyle.Flex;
|
|
return;
|
|
}
|
|
|
|
emptyLabel.style.display = DisplayStyle.None;
|
|
|
|
foreach (var script in retargetingScripts)
|
|
{
|
|
if (script == null) continue;
|
|
characterContainer.Add(BuildCharacterPanel(script));
|
|
}
|
|
}
|
|
|
|
// ========== Character Panel ==========
|
|
|
|
private VisualElement BuildCharacterPanel(CustomRetargetingScript script)
|
|
{
|
|
var panel = new VisualElement();
|
|
panel.style.backgroundColor = new Color(0, 0, 0, 0.1f);
|
|
panel.style.borderTopLeftRadius = panel.style.borderTopRightRadius =
|
|
panel.style.borderBottomLeftRadius = panel.style.borderBottomRightRadius = 6;
|
|
panel.style.paddingTop = panel.style.paddingBottom =
|
|
panel.style.paddingLeft = panel.style.paddingRight = 8;
|
|
panel.style.marginBottom = 8;
|
|
|
|
var so = CreateTrackedSO(script);
|
|
|
|
// 헤더
|
|
panel.Add(BuildHeader(script));
|
|
|
|
// 가중치 설정
|
|
panel.Add(BuildWeightSection(script));
|
|
|
|
// 힙 위치 보정
|
|
panel.Add(BuildHipsSection(script, so));
|
|
|
|
// 무릎 위치 조정
|
|
var kneeFoldout = new Foldout { text = "무릎 위치 조정", value = false };
|
|
var kneeContainer = new VisualElement();
|
|
var kneeFB = new Slider("무릎 앞/뒤 가중치", -1f, 1f) { showInputField = true };
|
|
kneeFB.BindProperty(so.FindProperty("kneeFrontBackWeight"));
|
|
kneeContainer.Add(kneeFB);
|
|
var kneeIO = new Slider("무릎 안/밖 가중치", -1f, 1f) { showInputField = true };
|
|
kneeIO.BindProperty(so.FindProperty("kneeInOutWeight"));
|
|
kneeContainer.Add(kneeIO);
|
|
kneeFoldout.Add(kneeContainer);
|
|
kneeContainer.Bind(so);
|
|
panel.Add(kneeFoldout);
|
|
|
|
// 발 IK 위치 조정
|
|
var footFoldout = new Foldout { text = "발 IK 위치 조정", value = false };
|
|
var footContainer = new VisualElement();
|
|
var footFB = new Slider("발 앞/뒤 오프셋", -1f, 1f) { showInputField = true };
|
|
footFB.BindProperty(so.FindProperty("footFrontBackOffset"));
|
|
footContainer.Add(footFB);
|
|
var footIO = new Slider("발 벌리기/모으기", -1f, 1f) { showInputField = true };
|
|
footIO.BindProperty(so.FindProperty("footInOutOffset"));
|
|
footContainer.Add(footIO);
|
|
footFoldout.Add(footContainer);
|
|
footContainer.Bind(so);
|
|
panel.Add(footFoldout);
|
|
|
|
// 손가락 제어 설정
|
|
panel.Add(BuildFingerControlSection(script));
|
|
|
|
// 손가락 복제 설정
|
|
panel.Add(BuildFingerCopySection(script, so));
|
|
|
|
// 모션 설정
|
|
panel.Add(BuildMotionSection(so));
|
|
|
|
// 바닥 높이 설정
|
|
var floorFoldout = new Foldout { text = "바닥 높이 설정", value = false };
|
|
var floorField = new PropertyField(so.FindProperty("floorHeight"), "바닥 높이 (-1 ~ 1)");
|
|
floorFoldout.Add(floorField);
|
|
floorField.Bind(so);
|
|
panel.Add(floorFoldout);
|
|
|
|
// 아바타 크기 설정
|
|
var scaleFoldout = new Foldout { text = "아바타 크기 설정", value = false };
|
|
var scaleContainer = new VisualElement();
|
|
scaleContainer.Add(new PropertyField(so.FindProperty("avatarScale"), "아바타 크기"));
|
|
var headScaleProp = so.FindProperty("headScale");
|
|
if (headScaleProp != null)
|
|
scaleContainer.Add(new PropertyField(headScaleProp, "머리 크기"));
|
|
scaleContainer.Bind(so);
|
|
scaleFoldout.Add(scaleContainer);
|
|
panel.Add(scaleFoldout);
|
|
|
|
// 머리 회전 오프셋
|
|
panel.Add(BuildHeadRotationSection(script, so));
|
|
|
|
// 프랍 설정
|
|
panel.Add(BuildPropSection(script));
|
|
|
|
// 캘리브레이션
|
|
panel.Add(BuildCalibrationSection(script));
|
|
|
|
// 변경 감지
|
|
panel.TrackSerializedObjectValue(so, _ =>
|
|
{
|
|
if (script == null) return;
|
|
isDirty = true;
|
|
if (script.targetAnimator != null) script.SaveSettings();
|
|
SceneView.RepaintAll();
|
|
});
|
|
|
|
return panel;
|
|
}
|
|
|
|
// ========== Header ==========
|
|
|
|
private VisualElement BuildHeader(CustomRetargetingScript script)
|
|
{
|
|
var header = new VisualElement { style = { flexDirection = FlexDirection.Row, alignItems = Align.Center, marginBottom = 6 } };
|
|
header.Add(new Label($"캐릭터: {script.gameObject.name}") { style = { unityFontStyleAndWeight = FontStyle.Bold, flexGrow = 1 } });
|
|
|
|
var animator = script.GetComponent<Animator>();
|
|
if (animator != null)
|
|
{
|
|
void FrameBone(HumanBodyBones? bone)
|
|
{
|
|
Transform t = bone.HasValue ? animator.GetBoneTransform(bone.Value) : script.transform;
|
|
if (t != null)
|
|
{
|
|
Selection.activeGameObject = t.gameObject;
|
|
SceneView.lastActiveSceneView?.FrameSelected();
|
|
}
|
|
}
|
|
|
|
var allBtn = new Button(() => FrameBone(null)) { text = "전체" }; allBtn.style.width = 50; header.Add(allBtn);
|
|
var headBtn = new Button(() => FrameBone(HumanBodyBones.Head)) { text = "머리" }; headBtn.style.width = 50; header.Add(headBtn);
|
|
var lhBtn = new Button(() => FrameBone(HumanBodyBones.LeftHand)) { text = "왼손" }; lhBtn.style.width = 50; header.Add(lhBtn);
|
|
var rhBtn = new Button(() => FrameBone(HumanBodyBones.RightHand)) { text = "오른손" }; rhBtn.style.width = 50; header.Add(rhBtn);
|
|
}
|
|
return header;
|
|
}
|
|
|
|
// ========== Weight Settings ==========
|
|
|
|
private VisualElement BuildWeightSection(CustomRetargetingScript script)
|
|
{
|
|
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));
|
|
|
|
var smoothField = new PropertyField(limbSO.FindProperty("weightSmoothSpeed"), "가중치 변화 속도");
|
|
container.Add(smoothField);
|
|
container.Bind(limbSO);
|
|
|
|
foldout.Add(container);
|
|
return foldout;
|
|
}
|
|
|
|
// ========== Hips Settings ==========
|
|
|
|
private VisualElement BuildHipsSection(CustomRetargetingScript script, SerializedObject so)
|
|
{
|
|
var foldout = new Foldout { text = "힙 위치 보정 (로컬)", value = false };
|
|
var container = new VisualElement();
|
|
|
|
// 축 매핑 정보
|
|
var axisLabel = new Label { style = { fontSize = 10, color = new Color(0.6f, 0.6f, 0.6f) } };
|
|
container.Add(axisLabel);
|
|
|
|
container.schedule.Execute(() =>
|
|
{
|
|
if (so == null || so.targetObject == null) return;
|
|
so.Update();
|
|
var normProp = so.FindProperty("debugAxisNormalizer");
|
|
if (normProp == null || !Application.isPlaying) { axisLabel.text = ""; return; }
|
|
Vector3 m = normProp.vector3Value;
|
|
if (m == Vector3.one) { axisLabel.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"), _ => "?" };
|
|
axisLabel.text = $"축 매핑: 좌우→{A(m.x)} 상하→{A(m.y)} 앞뒤→{A(m.z)}";
|
|
}).Every(500);
|
|
|
|
var hx = new Slider("← 좌우 →", -1f, 1f) { showInputField = true };
|
|
hx.BindProperty(so.FindProperty("hipsOffsetX"));
|
|
container.Add(hx);
|
|
|
|
var hy = new Slider("↓ 상하 ↑", -1f, 1f) { showInputField = true };
|
|
hy.BindProperty(so.FindProperty("hipsOffsetY"));
|
|
container.Add(hy);
|
|
|
|
var hz = new Slider("← 앞뒤 →", -1f, 1f) { showInputField = true };
|
|
hz.BindProperty(so.FindProperty("hipsOffsetZ"));
|
|
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);
|
|
}
|
|
|
|
container.Bind(so);
|
|
foldout.Add(container);
|
|
return foldout;
|
|
}
|
|
|
|
// ========== Finger Control ==========
|
|
|
|
private VisualElement BuildFingerControlSection(CustomRetargetingScript script)
|
|
{
|
|
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);
|
|
});
|
|
container.Add(enableToggle);
|
|
|
|
// 왼손
|
|
container.Add(BuildHandSection("왼손", "left", fso, fingerController));
|
|
// 오른손
|
|
container.Add(BuildHandSection("오른손", "right", fso, fingerController));
|
|
// 프리셋
|
|
container.Add(BuildFingerPresets(fingerController));
|
|
|
|
container.Bind(fso);
|
|
foldout.Add(container);
|
|
return foldout;
|
|
}
|
|
|
|
private VisualElement BuildHandSection(string label, string prefix, SerializedObject fso, FingerShapedController fc)
|
|
{
|
|
var box = new VisualElement();
|
|
box.style.backgroundColor = new Color(0, 0, 0, 0.08f);
|
|
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;
|
|
box.style.marginTop = 4;
|
|
|
|
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");
|
|
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();
|
|
}) { text = "초기화" };
|
|
resetBtn.style.width = 60;
|
|
header.Add(resetBtn);
|
|
handFoldout.Add(header);
|
|
|
|
// 손가락 슬라이더
|
|
string[] fingerNames = { "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 < fingerNames.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 = fso.FindProperty($"{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);
|
|
|
|
var slider = new Slider(-1f, 1f) { direction = SliderDirection.Vertical };
|
|
slider.style.height = 100;
|
|
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(fso.FindProperty($"{prefix}SpreadFingers"));
|
|
handFoldout.Add(spreadSlider);
|
|
|
|
// 비활성 시 숨김
|
|
handFoldout.schedule.Execute(() =>
|
|
{
|
|
if (fso == null || fso.targetObject == null) return;
|
|
fso.Update();
|
|
bool enabled = fso.FindProperty($"{prefix}HandEnabled").boolValue;
|
|
slidersRow.style.display = enabled ? DisplayStyle.Flex : DisplayStyle.None;
|
|
spreadSlider.style.display = enabled ? DisplayStyle.Flex : DisplayStyle.None;
|
|
}).Every(300);
|
|
|
|
box.Add(handFoldout);
|
|
return box;
|
|
}
|
|
|
|
private VisualElement BuildFingerPresets(FingerShapedController controller)
|
|
{
|
|
var container = new VisualElement { style = { marginTop = 6 } };
|
|
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 name = presets[row, col];
|
|
var btn = new Button(() => ApplyFingerPreset(controller, name)) { text = name };
|
|
btn.style.height = 30; btn.style.width = 100; btn.style.marginLeft = btn.style.marginRight = 4;
|
|
btnRow.Add(btn);
|
|
}
|
|
container.Add(btnRow);
|
|
}
|
|
return container;
|
|
}
|
|
|
|
// ========== Finger Copy ==========
|
|
|
|
private VisualElement BuildFingerCopySection(CustomRetargetingScript script, SerializedObject so)
|
|
{
|
|
var foldout = new Foldout { text = "손가락 복제 설정", value = false };
|
|
var container = new VisualElement();
|
|
|
|
var copyModeProp = so.FindProperty("fingerCopyMode");
|
|
container.Add(new PropertyField(copyModeProp, "복제 방식"));
|
|
|
|
// Mingle 캘리브레이션 컨테이너
|
|
var mingleBox = new VisualElement();
|
|
mingleBox.style.backgroundColor = new Color(0, 0, 0, 0.1f);
|
|
mingleBox.style.borderTopLeftRadius = mingleBox.style.borderTopRightRadius =
|
|
mingleBox.style.borderBottomLeftRadius = mingleBox.style.borderBottomRightRadius = 4;
|
|
mingleBox.style.paddingTop = mingleBox.style.paddingBottom =
|
|
mingleBox.style.paddingLeft = mingleBox.style.paddingRight = 6;
|
|
mingleBox.style.marginTop = 4;
|
|
|
|
mingleBox.Add(new Label("Mingle 캘리브레이션") { style = { unityFontStyleAndWeight = FontStyle.Bold } });
|
|
mingleBox.Add(new HelpBox(
|
|
"1. 손가락을 완전히 펼친 상태에서 '펼침 기록' 클릭\n2. 손가락을 완전히 모은(주먹) 상태에서 '모음 기록' 클릭",
|
|
HelpBoxMessageType.Info));
|
|
|
|
var manualRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } };
|
|
var openBtn = new Button(() => script.CalibrateMingleOpen()) { text = "펼침 기록" };
|
|
openBtn.style.flexGrow = 1; openBtn.style.marginRight = 2;
|
|
manualRow.Add(openBtn);
|
|
var closeBtn = new Button(() => script.CalibrateMingleClose()) { text = "모음 기록" };
|
|
closeBtn.style.flexGrow = 1;
|
|
manualRow.Add(closeBtn);
|
|
mingleBox.Add(manualRow);
|
|
|
|
var autoBtn = new Button(() => script.StartAutoCalibration()) { text = "자동 캘리브레이션 (3초 펼침 → 3초 모음)" };
|
|
autoBtn.style.marginTop = 4;
|
|
mingleBox.Add(autoBtn);
|
|
|
|
var playWarning = new HelpBox("캘리브레이션은 플레이 모드에서만 가능합니다.", HelpBoxMessageType.Warning);
|
|
mingleBox.Add(playWarning);
|
|
|
|
// 자동 캘리브레이션 상태
|
|
var autoStatus = new VisualElement();
|
|
autoStatus.style.display = DisplayStyle.None;
|
|
var statusLabel = new Label();
|
|
var timeLabel = new Label();
|
|
var cancelBtn = new Button(() => script.StopAutoCalibration()) { text = "취소" };
|
|
autoStatus.Add(statusLabel);
|
|
autoStatus.Add(timeLabel);
|
|
autoStatus.Add(cancelBtn);
|
|
mingleBox.Add(autoStatus);
|
|
|
|
container.Add(mingleBox);
|
|
|
|
// 주기적 갱신
|
|
container.schedule.Execute(() =>
|
|
{
|
|
if (so == null || so.targetObject == null || script == null) return;
|
|
so.Update();
|
|
bool isMingle = so.FindProperty("fingerCopyMode").enumValueIndex == (int)FingerCopyMode.Mingle;
|
|
mingleBox.style.display = isMingle ? DisplayStyle.Flex : DisplayStyle.None;
|
|
if (!isMingle) return;
|
|
|
|
bool playing = Application.isPlaying;
|
|
bool autoCalib = playing && script.IsAutoCalibrating;
|
|
|
|
openBtn.SetEnabled(playing && !autoCalib);
|
|
closeBtn.SetEnabled(playing && !autoCalib);
|
|
autoBtn.SetEnabled(playing && !autoCalib);
|
|
playWarning.style.display = playing ? DisplayStyle.None : DisplayStyle.Flex;
|
|
manualRow.style.display = autoCalib ? DisplayStyle.None : DisplayStyle.Flex;
|
|
autoBtn.style.display = autoCalib ? DisplayStyle.None : DisplayStyle.Flex;
|
|
autoStatus.style.display = autoCalib ? DisplayStyle.Flex : DisplayStyle.None;
|
|
|
|
if (autoCalib)
|
|
{
|
|
statusLabel.text = $"상태: {script.AutoCalibrationStatus}";
|
|
timeLabel.text = $"남은 시간: {script.AutoCalibrationTimeRemaining:F1}초";
|
|
}
|
|
}).Every(200);
|
|
|
|
container.Bind(so);
|
|
foldout.Add(container);
|
|
return foldout;
|
|
}
|
|
|
|
// ========== Motion Settings ==========
|
|
|
|
private VisualElement BuildMotionSection(SerializedObject so)
|
|
{
|
|
var foldout = new Foldout { text = "모션 설정", value = false };
|
|
var container = new VisualElement();
|
|
|
|
var useFilterProp = so.FindProperty("useMotionFilter");
|
|
container.Add(new PropertyField(useFilterProp, "모션 필터 사용"));
|
|
var bufferField = new PropertyField(so.FindProperty("filterBufferSize"), "필터 버퍼 크기");
|
|
container.Add(bufferField);
|
|
|
|
var useBodyRoughProp = so.FindProperty("useBodyRoughMotion");
|
|
container.Add(new PropertyField(useBodyRoughProp, "몸 러프 모션 사용"));
|
|
var bodyRoughField = new PropertyField(so.FindProperty("bodyRoughness"), "몸 러프니스");
|
|
container.Add(bodyRoughField);
|
|
|
|
// 조건부 표시
|
|
container.schedule.Execute(() =>
|
|
{
|
|
if (so == null || so.targetObject == null) return;
|
|
so.Update();
|
|
bufferField.style.display = so.FindProperty("useMotionFilter").boolValue ? DisplayStyle.Flex : DisplayStyle.None;
|
|
bodyRoughField.style.display = so.FindProperty("useBodyRoughMotion").boolValue ? DisplayStyle.Flex : DisplayStyle.None;
|
|
}).Every(300);
|
|
|
|
bufferField.style.display = useFilterProp.boolValue ? DisplayStyle.Flex : DisplayStyle.None;
|
|
bodyRoughField.style.display = useBodyRoughProp.boolValue ? DisplayStyle.Flex : DisplayStyle.None;
|
|
|
|
container.Bind(so);
|
|
foldout.Add(container);
|
|
return foldout;
|
|
}
|
|
|
|
// ========== Head Rotation ==========
|
|
|
|
private VisualElement BuildHeadRotationSection(CustomRetargetingScript script, SerializedObject so)
|
|
{
|
|
var foldout = new Foldout { text = "머리 회전 오프셋", value = false };
|
|
var container = new VisualElement();
|
|
|
|
var xProp = so.FindProperty("headRotationOffsetX");
|
|
var yProp = so.FindProperty("headRotationOffsetY");
|
|
var zProp = so.FindProperty("headRotationOffsetZ");
|
|
|
|
var xSlider = new Slider("X (Roll) - 좌우 기울기", -180f, 180f) { showInputField = true };
|
|
xSlider.BindProperty(xProp);
|
|
container.Add(xSlider);
|
|
|
|
var ySlider = new Slider("Y (Yaw) - 좌우 회전", -180f, 180f) { showInputField = true };
|
|
ySlider.BindProperty(yProp);
|
|
container.Add(ySlider);
|
|
|
|
var zSlider = new Slider("Z (Pitch) - 상하 회전", -180f, 180f) { showInputField = true };
|
|
zSlider.BindProperty(zProp);
|
|
container.Add(zSlider);
|
|
|
|
var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } };
|
|
var resetBtn = new Button(() =>
|
|
{
|
|
xProp.floatValue = yProp.floatValue = zProp.floatValue = 0f;
|
|
so.ApplyModifiedProperties();
|
|
EditorUtility.SetDirty(so.targetObject);
|
|
}) { text = "회전 초기화" };
|
|
resetBtn.style.flexGrow = 1; resetBtn.style.height = 25; resetBtn.style.marginRight = 2;
|
|
btnRow.Add(resetBtn);
|
|
|
|
var calibBtn = new Button(() => CalibrateHeadToForward(so, xProp, yProp, zProp)) { text = "정면 캘리브레이션" };
|
|
calibBtn.style.flexGrow = 1; calibBtn.style.height = 25;
|
|
btnRow.Add(calibBtn);
|
|
container.Add(btnRow);
|
|
|
|
var playWarning = new HelpBox("정면 캘리브레이션은 플레이 모드에서만 사용 가능합니다.", HelpBoxMessageType.Info);
|
|
container.Add(playWarning);
|
|
|
|
container.schedule.Execute(() =>
|
|
{
|
|
calibBtn.SetEnabled(Application.isPlaying);
|
|
playWarning.style.display = Application.isPlaying ? DisplayStyle.None : DisplayStyle.Flex;
|
|
}).Every(500);
|
|
|
|
container.Bind(so);
|
|
foldout.Add(container);
|
|
return foldout;
|
|
}
|
|
|
|
// ========== Prop Controls ==========
|
|
|
|
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 dynamicContainer = new VisualElement();
|
|
foldout.Add(dynamicContainer);
|
|
|
|
// 프랍 이동 버튼
|
|
foldout.Add(new Label("프랍 위치 이동") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginTop = 8 } });
|
|
var moveRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } };
|
|
var headBtn = new Button(() => propController.MoveToHead()) { text = "머리로 이동" };
|
|
headBtn.style.flexGrow = 1; headBtn.style.height = 30; headBtn.style.marginRight = 2;
|
|
moveRow.Add(headBtn);
|
|
var lhBtn = new Button(() => propController.MoveToLeftHand()) { text = "왼손으로 이동" };
|
|
lhBtn.style.flexGrow = 1; lhBtn.style.height = 30; lhBtn.style.marginRight = 2;
|
|
moveRow.Add(lhBtn);
|
|
var rhBtn = new Button(() => propController.MoveToRightHand()) { text = "오른손으로 이동" };
|
|
rhBtn.style.flexGrow = 1; rhBtn.style.height = 30;
|
|
moveRow.Add(rhBtn);
|
|
foldout.Add(moveRow);
|
|
|
|
var detachBtn = new Button(() =>
|
|
{
|
|
if (Selection.activeGameObject != null)
|
|
Undo.RecordObject(Selection.activeGameObject.transform, "Detach Prop");
|
|
propController.DetachProp();
|
|
}) { text = "프랍 해제" };
|
|
detachBtn.style.height = 30; detachBtn.style.marginTop = 4;
|
|
foldout.Add(detachBtn);
|
|
|
|
// 주기적으로 오프셋 + 프랍 목록 갱신
|
|
foldout.schedule.Execute(() => RebuildPropDynamicContent(dynamicContainer, propController)).Every(500);
|
|
RebuildPropDynamicContent(dynamicContainer, propController);
|
|
|
|
return foldout;
|
|
}
|
|
|
|
private void RebuildPropDynamicContent(VisualElement container, PropLocationController pc)
|
|
{
|
|
if (pc == null) return;
|
|
container.Clear();
|
|
|
|
// 오프셋 섹션
|
|
container.Add(new Label("오프셋 조정") { style = { unityFontStyleAndWeight = FontStyle.Bold } });
|
|
BuildOffsetFields(container, "왼손 오프셋", pc.GetLeftHandOffset());
|
|
BuildOffsetFields(container, "오른손 오프셋", pc.GetRightHandOffset());
|
|
BuildOffsetFields(container, "머리 오프셋", pc.GetHeadOffset());
|
|
|
|
// 프랍 목록
|
|
container.Add(new Label("부착된 프랍 목록") { style = { unityFontStyleAndWeight = FontStyle.Bold, marginTop = 8 } });
|
|
BuildPropList(container, "머리 프랍", pc.GetHeadProps());
|
|
BuildPropList(container, "왼손 프랍", pc.GetLeftHandProps());
|
|
BuildPropList(container, "오른손 프랍", pc.GetRightHandProps());
|
|
}
|
|
|
|
private void BuildOffsetFields(VisualElement parent, string label, Transform offset)
|
|
{
|
|
if (offset == null) return;
|
|
var foldout = new Foldout { text = label, value = false };
|
|
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 BuildPropList(VisualElement parent, string title, GameObject[] props)
|
|
{
|
|
var box = new VisualElement();
|
|
box.style.backgroundColor = new Color(0, 0, 0, 0.08f);
|
|
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 (props.Length > 0)
|
|
{
|
|
foreach (var prop in props)
|
|
{
|
|
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);
|
|
}
|
|
|
|
// ========== Calibration ==========
|
|
|
|
private VisualElement BuildCalibrationSection(CustomRetargetingScript script)
|
|
{
|
|
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 cacheLabel = new Label();
|
|
cacheLabel.style.unityFontStyleAndWeight = FontStyle.Bold;
|
|
box.Add(cacheLabel);
|
|
|
|
var btnRow = new VisualElement { style = { flexDirection = FlexDirection.Row, marginTop = 4 } };
|
|
Button resetBtn = null;
|
|
|
|
var calibBtn = new Button(() => { script.I_PoseCalibration(); UpdateCacheLabel(); }) { text = "I-포즈 캘리브레이션" };
|
|
calibBtn.style.flexGrow = 1; calibBtn.style.marginRight = 2;
|
|
btnRow.Add(calibBtn);
|
|
|
|
resetBtn = new Button(() => { script.ResetPoseAndCache(); UpdateCacheLabel(); }) { text = "캘리브레이션 초기화" };
|
|
resetBtn.style.flexGrow = 1;
|
|
btnRow.Add(resetBtn);
|
|
box.Add(btnRow);
|
|
|
|
void UpdateCacheLabel()
|
|
{
|
|
if (script == null) return;
|
|
bool cached = script.HasCachedSettings();
|
|
cacheLabel.text = cached ? "캘리브레이션 데이터가 저장되어 있습니다." : "저장된 캘리브레이션 데이터가 없습니다.";
|
|
resetBtn.style.display = cached ? DisplayStyle.Flex : DisplayStyle.None;
|
|
}
|
|
|
|
box.schedule.Execute(UpdateCacheLabel).Every(1000);
|
|
UpdateCacheLabel();
|
|
|
|
return box;
|
|
}
|
|
|
|
// ========== Helpers ==========
|
|
|
|
private VisualElement BuildMinMaxRange(string label, SerializedProperty minProp, SerializedProperty maxProp, float limitMin, float limitMax, SerializedObject so)
|
|
{
|
|
var container = new VisualElement { style = { marginBottom = 6 } };
|
|
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;
|
|
so.ApplyModifiedProperties();
|
|
EditorUtility.SetDirty(so.targetObject);
|
|
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;
|
|
so.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;
|
|
so.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 ApplyFingerPreset(FingerShapedController controller, string presetName)
|
|
{
|
|
if (!controller.enabled) controller.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 (controller.leftHandEnabled)
|
|
{
|
|
controller.leftThumbCurl = t; controller.leftIndexCurl = i; controller.leftMiddleCurl = m;
|
|
controller.leftRingCurl = r; controller.leftPinkyCurl = p; controller.leftSpreadFingers = s;
|
|
}
|
|
if (controller.rightHandEnabled)
|
|
{
|
|
controller.rightThumbCurl = t; controller.rightIndexCurl = i; controller.rightMiddleCurl = m;
|
|
controller.rightRingCurl = r; controller.rightPinkyCurl = p; controller.rightSpreadFingers = s;
|
|
}
|
|
EditorUtility.SetDirty(controller);
|
|
}
|
|
|
|
// ========== Head Calibration ==========
|
|
|
|
private void CalibrateHeadToForward(SerializedObject so, SerializedProperty xProp, SerializedProperty yProp, SerializedProperty zProp)
|
|
{
|
|
CustomRetargetingScript script = so.targetObject as CustomRetargetingScript;
|
|
if (script == null) { Debug.LogWarning("CustomRetargetingScript가 없습니다."); return; }
|
|
|
|
Animator targetAnimator = script.GetComponent<Animator>();
|
|
if (targetAnimator == null) { Debug.LogWarning("타겟 Animator가 없습니다."); return; }
|
|
|
|
Transform targetHead = targetAnimator.GetBoneTransform(HumanBodyBones.Head);
|
|
if (targetHead == null) { Debug.LogWarning("Head 본을 찾을 수 없습니다."); return; }
|
|
|
|
Vector3 tPoseForward = script.tPoseHeadForward;
|
|
Vector3 tPoseUp = script.tPoseHeadUp;
|
|
if (tPoseForward.sqrMagnitude < 0.001f) { Debug.LogWarning("T-포즈 머리 방향이 캐싱되지 않았습니다."); 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);
|
|
|
|
Debug.Log($"정면 캘리브레이션 완료 - Offset X: {euler.x:F1}°, Y: {euler.y:F1}°, Z: {euler.z:F1}°");
|
|
}
|
|
}
|
|
|
|
[System.Serializable]
|
|
public class RetargetingPreset
|
|
{
|
|
public int ikIterations;
|
|
public float ikDeltaThreshold;
|
|
public float shoulderBlendStrength;
|
|
public float shoulderMaxHeightDiff;
|
|
public float limbMaxDistance;
|
|
public float limbMinDistance;
|
|
public float footHeightMinThreshold;
|
|
public float footHeightMaxThreshold;
|
|
}
|