Streamingle_URP/Assets/Scripts/KindRetargeting/Remote/RetargetingRemoteController.cs
2026-03-08 00:23:54 +09:00

815 lines
31 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using UnityEngine;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace KindRetargeting.Remote
{
/// <summary>
/// 리타게팅 원격 제어 컨트롤러
/// WebSocket 서버를 관리하고 메시지를 처리합니다.
/// (HTTP UI는 Streamingle Dashboard에 통합됨)
/// </summary>
public class RetargetingRemoteController : MonoBehaviour
{
[Header("서버 설정")]
[SerializeField] private int wsPort = 64212;
[SerializeField] private bool autoStart = true;
[Header("캐릭터 등록")]
[SerializeField] private List<CustomRetargetingScript> registeredCharacters = new List<CustomRetargetingScript>();
private RetargetingWebSocketServer wsServer;
private Queue<Action> mainThreadActions = new Queue<Action>();
public bool IsRunning => wsServer?.IsRunning == true;
public int WsPort { get => wsPort; set => wsPort = value; }
public bool AutoStart { get => autoStart; set => autoStart = value; }
// 손 포즈 프리셋 목록
private static readonly string[] handPosePresets = new string[]
{
"가위", "바위", "보", "브이", "검지", "초기화"
};
private void Start()
{
if (autoStart)
{
StartServer();
}
}
private void OnDestroy()
{
StopServer();
}
private void Update()
{
// 메인 스레드 액션 처리
lock (mainThreadActions)
{
while (mainThreadActions.Count > 0)
{
var action = mainThreadActions.Dequeue();
try
{
action?.Invoke();
}
catch (Exception ex)
{
Debug.LogError($"[RetargetingRemote] 메인 스레드 액션 오류: {ex.Message}");
}
}
}
}
public void StartServer()
{
if (IsRunning) return;
wsServer = new RetargetingWebSocketServer(wsPort);
wsServer.OnMessageReceived += OnWebSocketMessage;
wsServer.Start();
Debug.Log($"[RetargetingRemote] WebSocket 서버 시작됨 - WS: {wsPort}");
}
public void StopServer()
{
wsServer?.Stop();
if (wsServer != null)
{
wsServer.OnMessageReceived -= OnWebSocketMessage;
}
Debug.Log("[RetargetingRemote] 서버 중지됨");
}
private void OnWebSocketMessage(string message)
{
EnqueueMainThread(() => ProcessMessage(message));
}
private void EnqueueMainThread(Action action)
{
lock (mainThreadActions)
{
mainThreadActions.Enqueue(action);
}
}
private void ProcessMessage(string message)
{
try
{
var json = JObject.Parse(message);
string action = json["action"]?.ToString();
switch (action)
{
case "refresh":
SendCharacterList();
SendHandPosePresets();
break;
case "getCharacterData":
{
string charId = json["characterId"]?.ToString();
SendCharacterData(charId);
}
break;
case "updateValueRealtime":
{
string charId = json["characterId"]?.ToString();
string property = json["property"]?.ToString();
float value = json["value"]?.Value<float>() ?? 0f;
UpdateValue(charId, property, value);
BroadcastValueChanged(charId, property, value);
}
break;
case "setHandPosePreset":
{
string charId = json["characterId"]?.ToString();
string property = json["property"]?.ToString();
string presetName = json["stringValue"]?.ToString();
ApplyHandPosePreset(charId, property, presetName);
}
break;
case "calibrateIPose":
{
string charId = json["characterId"]?.ToString();
CalibrateIPose(charId);
}
break;
case "resetCalibration":
{
string charId = json["characterId"]?.ToString();
ResetCalibration(charId);
}
break;
case "calibrateHeadForward":
{
string charId = json["characterId"]?.ToString();
CalibrateHeadForward(charId);
}
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;
}
}
catch (Exception ex)
{
Debug.LogError($"[RetargetingRemote] 메시지 처리 오류: {ex.Message}");
}
}
private void SendCharacterList()
{
var charIds = new List<string>();
foreach (var script in registeredCharacters)
{
if (script != null)
{
charIds.Add(script.gameObject.name);
}
}
var response = new
{
type = "characterList",
characters = charIds
};
wsServer?.Broadcast(JsonConvert.SerializeObject(response));
}
private void SendHandPosePresets()
{
var response = new
{
type = "handPosePresets",
presets = handPosePresets
};
wsServer?.Broadcast(JsonConvert.SerializeObject(response));
}
private void SendCharacterData(string characterId)
{
var script = FindCharacter(characterId);
if (script == null)
{
SendStatus(false, $"캐릭터를 찾을 수 없습니다: {characterId}");
return;
}
var data = new Dictionary<string, object>
{
// 힙 위치 보정 (로컬)
{ "hipsVertical", GetPrivateField<float>(script, "hipsOffsetY") },
{ "hipsForward", GetPrivateField<float>(script, "hipsOffsetZ") },
{ "hipsHorizontal", GetPrivateField<float>(script, "hipsOffsetX") },
// 무릎 위치 조정
{ "kneeFrontBackWeight", GetPrivateField<float>(script, "kneeFrontBackWeight") },
{ "kneeInOutWeight", GetPrivateField<float>(script, "kneeInOutWeight") },
// 발 IK 위치 조정
{ "feetForwardBackward", GetPrivateField<float>(script, "footFrontBackOffset") },
{ "feetNarrow", GetPrivateField<float>(script, "footInOutOffset") },
// 바닥 높이
{ "floorHeight", script.floorHeight },
// 아바타 크기
{ "avatarScale", GetPrivateField<float>(script, "avatarScale") },
// 머리 크기
{ "headScale", script.GetHeadScale() },
// 머리 회전 오프셋
{ "headRotationOffsetX", script.headRotationOffsetX },
{ "headRotationOffsetY", script.headRotationOffsetY },
{ "headRotationOffsetZ", script.headRotationOffsetZ },
// 손가락 복제 모드
{ "fingerCopyMode", (int)GetPrivateField<EnumsList.FingerCopyMode>(script, "fingerCopyMode") },
// 캘리브레이션 상태
{ "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") },
};
var response = new
{
type = "characterData",
characterId = characterId,
data = data
};
wsServer?.Broadcast(JsonConvert.SerializeObject(response));
}
private void UpdateValue(string characterId, string property, float value)
{
var script = FindCharacter(characterId);
if (script == null) return;
switch (property)
{
// 힙 위치 보정
case "hipsVertical":
SetPrivateField(script, "hipsOffsetY", value);
break;
case "hipsForward":
SetPrivateField(script, "hipsOffsetZ", value);
break;
case "hipsHorizontal":
SetPrivateField(script, "hipsOffsetX", value);
break;
// 무릎 위치 조정
case "kneeFrontBackWeight":
SetPrivateField(script, "kneeFrontBackWeight", value);
break;
case "kneeInOutWeight":
SetPrivateField(script, "kneeInOutWeight", value);
break;
// 발 IK 위치 조정
case "feetForwardBackward":
SetPrivateField(script, "footFrontBackOffset", value);
break;
case "feetNarrow":
SetPrivateField(script, "footInOutOffset", value);
break;
// 바닥 높이
case "floorHeight":
script.floorHeight = value;
break;
// 아바타 크기
case "avatarScale":
SetPrivateField(script, "avatarScale", value);
break;
// 머리 크기
case "headScale":
script.SetHeadScale(value);
break;
// 머리 회전 오프셋
case "headRotationOffsetX":
script.headRotationOffsetX = value;
break;
case "headRotationOffsetY":
script.headRotationOffsetY = value;
break;
case "headRotationOffsetZ":
script.headRotationOffsetZ = value;
break;
// 손가락 복제 모드
case "fingerCopyMode":
SetPrivateField(script, "fingerCopyMode", (EnumsList.FingerCopyMode)(int)value);
break;
// LimbWeightController 속성
case "limbMinDistance":
script.limbWeight.minDistance = value;
break;
case "limbMaxDistance":
script.limbWeight.maxDistance = value;
break;
case "weightSmoothSpeed":
script.limbWeight.weightSmoothSpeed = value;
break;
case "hipsMinDistance":
script.limbWeight.hipsMinDistance = value;
break;
case "hipsMaxDistance":
script.limbWeight.hipsMaxDistance = value;
break;
case "groundHipsMinHeight":
script.limbWeight.groundHipsMinHeight = value;
break;
case "groundHipsMaxHeight":
script.limbWeight.groundHipsMaxHeight = value;
break;
case "footHeightMinThreshold":
script.limbWeight.footHeightMinThreshold = value;
break;
case "footHeightMaxThreshold":
script.limbWeight.footHeightMaxThreshold = value;
break;
case "chairSeatHeightOffset":
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":
script.fingerShaped.enabled = value > 0.5f;
break;
case "leftHandEnabled":
script.fingerShaped.leftHandEnabled = value > 0.5f;
if (script.fingerShaped.leftHandEnabled)
script.fingerShaped.enabled = true;
break;
case "rightHandEnabled":
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:
Debug.LogWarning($"[RetargetingRemote] 알 수 없는 속성: {property}");
break;
}
}
private void ApplyHandPosePreset(string characterId, string property, string presetName)
{
var script = FindCharacter(characterId);
if (script == null) return;
var handPose = script.fingerShaped;
// 스크립트 자동 활성화
handPose.enabled = true;
// 해당 손 활성화
if (property == "leftHandPreset")
handPose.leftHandEnabled = true;
else if (property == "rightHandPreset")
handPose.rightHandEnabled = true;
// 프리셋 적용
float thumb = 0, index = 0, middle = 0, ring = 0, pinky = 0, spread = 0;
switch (presetName)
{
case "가위":
thumb = 1f; index = 1f; middle = -1f; ring = -1f; pinky = -1f; spread = 0.3f;
break;
case "바위":
thumb = -1f; index = -1f; middle = -1f; ring = -1f; pinky = -1f; spread = 0f;
break;
case "보":
thumb = 1f; index = 1f; middle = 1f; ring = 1f; pinky = 1f; spread = 1f;
break;
case "브이":
thumb = -1f; index = 1f; middle = 1f; ring = -1f; pinky = -1f; spread = 1f;
break;
case "검지":
thumb = -1f; index = 1f; middle = -1f; ring = -1f; pinky = -1f; spread = 0f;
break;
case "초기화":
thumb = 0.8f; index = 0.8f; middle = 0.8f; ring = 0.8f; pinky = 0.8f; spread = 0.8f;
break;
}
if (property == "leftHandPreset" && handPose.leftHandEnabled)
{
handPose.leftThumbCurl = thumb;
handPose.leftIndexCurl = index;
handPose.leftMiddleCurl = middle;
handPose.leftRingCurl = ring;
handPose.leftPinkyCurl = pinky;
handPose.leftSpreadFingers = spread;
}
else if (property == "rightHandPreset" && handPose.rightHandEnabled)
{
handPose.rightThumbCurl = thumb;
handPose.rightIndexCurl = index;
handPose.rightMiddleCurl = middle;
handPose.rightRingCurl = ring;
handPose.rightPinkyCurl = pinky;
handPose.rightSpreadFingers = spread;
}
SendCharacterData(characterId);
SendStatus(true, $"{presetName} 프리셋 적용됨");
}
private void CalibrateIPose(string characterId)
{
var script = FindCharacter(characterId);
if (script == null) return;
script.I_PoseCalibration();
script.SaveSettings(); // 캘리브레이션 후 설정 저장
SendCharacterData(characterId);
SendStatus(true, "I-포즈 캘리브레이션 완료");
}
private void ResetCalibration(string characterId)
{
var script = FindCharacter(characterId);
if (script == null) return;
script.ResetPoseAndCache();
SendCharacterData(characterId);
SendStatus(true, "캘리브레이션 초기화됨");
}
private void CalibrateHeadForward(string characterId)
{
var script = FindCharacter(characterId);
if (script == null) return;
script.CalibrateHeadToForward();
script.SaveSettings();
SendCharacterData(characterId);
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)
{
if (script != null && script.gameObject.name == characterId)
{
return script;
}
}
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
{
type = "status",
success = success,
message = message
};
wsServer?.Broadcast(JsonConvert.SerializeObject(response));
}
#region Reflection Helpers
private T GetPrivateField<T>(object obj, string fieldName)
{
try
{
var field = obj.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public);
if (field != null)
{
return (T)field.GetValue(obj);
}
}
catch (Exception) { }
return default(T);
}
private void SetPrivateField<T>(object obj, string fieldName, T value)
{
try
{
var field = obj.GetType().GetField(fieldName, BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Public);
if (field != null)
{
field.SetValue(obj, value);
}
}
catch (Exception ex)
{
Debug.LogError($"[RetargetingRemote] 필드 설정 오류 ({fieldName}): {ex.Message}");
}
}
#endregion
#region Public API
/// <summary>
/// 캐릭터 등록
/// </summary>
public void RegisterCharacter(CustomRetargetingScript character)
{
if (!registeredCharacters.Contains(character))
{
registeredCharacters.Add(character);
}
}
/// <summary>
/// 캐릭터 등록 해제
/// </summary>
public void UnregisterCharacter(CustomRetargetingScript character)
{
registeredCharacters.Remove(character);
}
#endregion
}
}