Fix : 리타겟팅 및 옵티 연결부 안정화
This commit is contained in:
parent
88515f51a8
commit
27ac11e2b4
@ -1768,10 +1768,11 @@ public class OptitrackStreamingClient : MonoBehaviour
|
|||||||
private System.Collections.IEnumerator ConnectCoroutine()
|
private System.Collections.IEnumerator ConnectCoroutine()
|
||||||
{
|
{
|
||||||
m_receivedFrameSinceConnect = false;
|
m_receivedFrameSinceConnect = false;
|
||||||
|
|
||||||
|
// --- 메인 스레드: 주소/모드 파싱 (Unity API 미사용, 즉시 완료) ---
|
||||||
IPAddress serverAddr;
|
IPAddress serverAddr;
|
||||||
IPAddress localAddr;
|
IPAddress localAddr;
|
||||||
NatNetConnectionType connType;
|
NatNetConnectionType connType;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
serverAddr = IPAddress.Parse( ServerAddress );
|
serverAddr = IPAddress.Parse( ServerAddress );
|
||||||
@ -1780,42 +1781,75 @@ public class OptitrackStreamingClient : MonoBehaviour
|
|||||||
connType = ConnectionType == ClientConnectionType.Unicast
|
connType = ConnectionType == ClientConnectionType.Unicast
|
||||||
? NatNetConnectionType.NatNetConnectionType_Unicast
|
? NatNetConnectionType.NatNetConnectionType_Unicast
|
||||||
: NatNetConnectionType.NatNetConnectionType_Multicast;
|
: NatNetConnectionType.NatNetConnectionType_Multicast;
|
||||||
|
|
||||||
m_client = new NatNetClient();
|
|
||||||
m_client.Connect( connType, localAddr, serverAddr );
|
|
||||||
|
|
||||||
// SetProperty는 최초 연결에서만 전송 — 재연결 시 Motive 글로벌 설정 반복 변경 방지
|
|
||||||
if ( !m_hasAppliedServerSettings )
|
|
||||||
{
|
|
||||||
// Remotely change the Skeleton Coordinate property to Global/Local
|
|
||||||
if (SkeletonCoordinates == StreamingCoordinatesValues.Global)
|
|
||||||
m_client.RequestCommand("SetProperty,,Skeleton Coordinates,false");
|
|
||||||
else
|
|
||||||
m_client.RequestCommand("SetProperty,,Skeleton Coordinates,true");
|
|
||||||
|
|
||||||
// Remotely change the Bone Naming Convention to Motive/FBX/BVH
|
|
||||||
if (BoneNamingConvention == OptitrackBoneNameConvention.Motive)
|
|
||||||
m_client.RequestCommand("SetProperty,,Bone Naming Convention,0");
|
|
||||||
else if (BoneNamingConvention == OptitrackBoneNameConvention.FBX)
|
|
||||||
m_client.RequestCommand("SetProperty,,Bone Naming Convention,1");
|
|
||||||
else if (BoneNamingConvention == OptitrackBoneNameConvention.BVH)
|
|
||||||
m_client.RequestCommand("SetProperty,,Bone Naming Convention,2");
|
|
||||||
|
|
||||||
m_hasAppliedServerSettings = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Debug.Log(GetType().FullName + ": 재연결 — SetProperty 명령 스킵 (최초 연결에서 이미 적용됨).", this);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch ( Exception ex )
|
catch ( Exception ex )
|
||||||
{
|
{
|
||||||
Debug.LogException( ex, this );
|
Debug.LogException( ex, this );
|
||||||
Debug.LogError( GetType().FullName + ": Error connecting to server; check your configuration, and make sure the server is currently streaming.", this );
|
Debug.LogError( GetType().FullName + ": Error connecting to server; check your configuration, and make sure the server is currently streaming.", this );
|
||||||
if (m_client != null) { m_client.Dispose(); m_client = null; }
|
|
||||||
yield break;
|
yield break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 백그라운드 스레드: 블로킹 네이티브 연결 + SetProperty ---
|
||||||
|
// NatNet_Client_Connect 는 서버가 즉시 응답하지 않으면 내부 타임아웃까지 블로킹된다.
|
||||||
|
// 코루틴은 메인 스레드에서 실행되므로 여기서 직접 호출하면 매 재접속마다 화면이 멈춘다.
|
||||||
|
// Task 로 분리하고 메인 스레드는 매 프레임 양보(yield)하며 완료를 폴링한다.
|
||||||
|
bool applyServerSettings = !m_hasAppliedServerSettings;
|
||||||
|
NatNetClient connectedClient = null;
|
||||||
|
Exception connectError = null;
|
||||||
|
|
||||||
|
System.Threading.Tasks.Task connectTask = System.Threading.Tasks.Task.Run( () =>
|
||||||
|
{
|
||||||
|
NatNetClient c = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
c = new NatNetClient();
|
||||||
|
c.Connect( connType, localAddr, serverAddr );
|
||||||
|
|
||||||
|
// SetProperty는 최초 연결에서만 전송 — 재연결 시 Motive 글로벌 설정 반복 변경 방지
|
||||||
|
if ( applyServerSettings )
|
||||||
|
{
|
||||||
|
// Remotely change the Skeleton Coordinate property to Global/Local
|
||||||
|
if (SkeletonCoordinates == StreamingCoordinatesValues.Global)
|
||||||
|
c.RequestCommand("SetProperty,,Skeleton Coordinates,false");
|
||||||
|
else
|
||||||
|
c.RequestCommand("SetProperty,,Skeleton Coordinates,true");
|
||||||
|
|
||||||
|
// Remotely change the Bone Naming Convention to Motive/FBX/BVH
|
||||||
|
if (BoneNamingConvention == OptitrackBoneNameConvention.Motive)
|
||||||
|
c.RequestCommand("SetProperty,,Bone Naming Convention,0");
|
||||||
|
else if (BoneNamingConvention == OptitrackBoneNameConvention.FBX)
|
||||||
|
c.RequestCommand("SetProperty,,Bone Naming Convention,1");
|
||||||
|
else if (BoneNamingConvention == OptitrackBoneNameConvention.BVH)
|
||||||
|
c.RequestCommand("SetProperty,,Bone Naming Convention,2");
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedClient = c;
|
||||||
|
}
|
||||||
|
catch ( Exception ex )
|
||||||
|
{
|
||||||
|
connectError = ex;
|
||||||
|
if ( c != null ) { try { c.Dispose(); } catch { } }
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
|
||||||
|
// 메인 스레드는 매 프레임 양보하며 연결 완료를 대기 → 렌더링/입력이 멈추지 않음
|
||||||
|
while ( !connectTask.IsCompleted )
|
||||||
|
yield return null;
|
||||||
|
|
||||||
|
if ( connectError != null || connectedClient == null )
|
||||||
|
{
|
||||||
|
if ( connectError != null )
|
||||||
|
Debug.LogException( connectError, this );
|
||||||
|
Debug.LogError( GetType().FullName + ": Error connecting to server; check your configuration, and make sure the server is currently streaming.", this );
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_client = connectedClient;
|
||||||
|
if ( applyServerSettings )
|
||||||
|
m_hasAppliedServerSettings = true;
|
||||||
|
else
|
||||||
|
Debug.Log(GetType().FullName + ": 재연결 — SetProperty 명령 스킵 (최초 연결에서 이미 적용됨).", this);
|
||||||
|
|
||||||
// SetProperty 명령이 서버에 적용될 때까지 대기 (재연결 시 SetProperty 스킵했으면 대기 불필요)
|
// SetProperty 명령이 서버에 적용될 때까지 대기 (재연결 시 SetProperty 스킵했으면 대기 불필요)
|
||||||
if (!m_isReconnecting)
|
if (!m_isReconnecting)
|
||||||
yield return new UnityEngine.WaitForSeconds( 0.1f );
|
yield return new UnityEngine.WaitForSeconds( 0.1f );
|
||||||
|
|||||||
BIN
Assets/Resources/StreamingleDashboard/dashboard_script.txt
(Stored with Git LFS)
BIN
Assets/Resources/StreamingleDashboard/dashboard_script.txt
(Stored with Git LFS)
Binary file not shown.
@ -22,28 +22,9 @@ namespace KindRetargeting
|
|||||||
// IK 컴포넌트 참조
|
// IK 컴포넌트 참조
|
||||||
[SerializeField] public TwoBoneIKSolver ikSolver = new TwoBoneIKSolver();
|
[SerializeField] public TwoBoneIKSolver ikSolver = new TwoBoneIKSolver();
|
||||||
|
|
||||||
[Header("힙 위치 보정 (로컬 좌표계 기반)")]
|
|
||||||
[SerializeField, Range(-1, 1)]
|
|
||||||
private float hipsOffsetX = 0f; // 캐릭터 기준 좌우 (항상 Right 방향)
|
|
||||||
[SerializeField, Range(-1, 1)]
|
|
||||||
private float hipsOffsetY = 0f; // 캐릭터 기준 상하 (항상 Up 방향)
|
|
||||||
[SerializeField, Range(-1, 1)]
|
|
||||||
private float hipsOffsetZ = 0f; // 캐릭터 기준 앞뒤 (항상 Forward 방향)
|
|
||||||
|
|
||||||
[HideInInspector] public float HipsWeightOffset = 1f;
|
[HideInInspector] public float HipsWeightOffset = 1f;
|
||||||
[HideInInspector] public float ChairSeatHeightOffset = 0f; // 의자 좌석 높이 오프셋 (월드 Y 기준)
|
[HideInInspector] public float ChairSeatHeightOffset = 0f; // 의자 좌석 높이 오프셋 (월드 Y 기준)
|
||||||
|
|
||||||
// 축 매핑: 월드 방향(Right/Up/Forward)을 담당하는 로컬 축을 저장
|
|
||||||
// 예: localAxisForWorldRight = (0, 0, 1) 이면 로컬 Z축이 월드 Right 방향을 담당
|
|
||||||
// 부호는 방향을 나타냄: (0, 0, -1)이면 로컬 -Z가 월드 Right를 담당
|
|
||||||
private Vector3 localAxisForWorldRight = Vector3.right; // 월드 좌우(Right)를 담당하는 로컬 축
|
|
||||||
private Vector3 localAxisForWorldUp = Vector3.up; // 월드 상하(Up)를 담당하는 로컬 축
|
|
||||||
private Vector3 localAxisForWorldForward = Vector3.forward; // 월드 앞뒤(Forward)를 담당하는 로컬 축
|
|
||||||
|
|
||||||
[Header("축 정규화 정보 (읽기 전용)")]
|
|
||||||
[SerializeField]
|
|
||||||
public Vector3 debugAxisNormalizer = Vector3.one;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 소스 본 Transform 접근 래퍼 (OptiTrack 매핑 사용)
|
/// 소스 본 Transform 접근 래퍼 (OptiTrack 매핑 사용)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -76,13 +57,6 @@ namespace KindRetargeting
|
|||||||
// HumanBodyBones.LastBone을 이용한 본 순회 범위
|
// HumanBodyBones.LastBone을 이용한 본 순회 범위
|
||||||
private int lastBoneIndex = 55; // 0~54: 몸체 + 손가락 전부
|
private int lastBoneIndex = 55; // 0~54: 몸체 + 손가락 전부
|
||||||
|
|
||||||
[Header("무릎 안/밖 조정")]
|
|
||||||
[SerializeField, Range(-1f, 1f)]
|
|
||||||
private float kneeInOutWeight = 0f; // 무릎 안/밖 위치 조정 가중치
|
|
||||||
[Header("무릎 앞/뒤 조정")]
|
|
||||||
[SerializeField, Range(-1f, 1f)]
|
|
||||||
private float kneeFrontBackWeight = 0.4f; // 무릎 앞/뒤 위치 조정 가중치
|
|
||||||
|
|
||||||
[Header("발 IK 위치 조정")]
|
[Header("발 IK 위치 조정")]
|
||||||
[SerializeField, Range(-1f, 1f)]
|
[SerializeField, Range(-1f, 1f)]
|
||||||
private float footFrontBackOffset = 0f; // 발 앞뒤 오프셋 (+: 앞, -: 뒤)
|
private float footFrontBackOffset = 0f; // 발 앞뒤 오프셋 (+: 앞, -: 뒤)
|
||||||
@ -153,11 +127,6 @@ namespace KindRetargeting
|
|||||||
[System.Serializable]
|
[System.Serializable]
|
||||||
private class RetargetingSettings
|
private class RetargetingSettings
|
||||||
{
|
{
|
||||||
public float hipsOffsetX; // 변경: hipsWeight → hipsOffsetX/Y/Z
|
|
||||||
public float hipsOffsetY;
|
|
||||||
public float hipsOffsetZ;
|
|
||||||
public float kneeInOutWeight;
|
|
||||||
public float kneeFrontBackWeight;
|
|
||||||
public float footFrontBackOffset; // 발 앞뒤 오프셋
|
public float footFrontBackOffset; // 발 앞뒤 오프셋
|
||||||
public float footInOutOffset; // 발 안쪽/바깥쪽 오프셋
|
public float footInOutOffset; // 발 안쪽/바깥쪽 오프셋
|
||||||
public float floorHeight;
|
public float floorHeight;
|
||||||
@ -203,9 +172,6 @@ namespace KindRetargeting
|
|||||||
// IK 타겟 생성 (무릎 시각화 오브젝트 포함)
|
// IK 타겟 생성 (무릎 시각화 오브젝트 포함)
|
||||||
CreateIKTargets();
|
CreateIKTargets();
|
||||||
|
|
||||||
// T-포즈 전에 축 정규화 계수 계산
|
|
||||||
CalculateAxisNormalizer();
|
|
||||||
|
|
||||||
// 원본 및 대상 아바타를 T-포즈로 복원
|
// 원본 및 대상 아바타를 T-포즈로 복원
|
||||||
// OptiTrack 소스는 Humanoid가 아니므로 현재 포즈를 기준으로 캐싱
|
// OptiTrack 소스는 Humanoid가 아니므로 현재 포즈를 기준으로 캐싱
|
||||||
if (optitrackSource != null)
|
if (optitrackSource != null)
|
||||||
@ -275,105 +241,6 @@ namespace KindRetargeting
|
|||||||
fingerShaped.Initialize(targetAnimator);
|
fingerShaped.Initialize(targetAnimator);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 타겟 아바타의 로컬 축과 월드 축의 관계를 분석하여 축 매핑을 계산합니다.
|
|
||||||
/// T-포즈 상태에서 힙의 각 로컬 축이 월드의 어느 방향을 가리키는지 분석합니다.
|
|
||||||
///
|
|
||||||
/// 예시:
|
|
||||||
/// - 아바타 A: 로컬 Y가 월드 Up을 가리킴 → localAxisForWorldUp = (0, 1, 0)
|
|
||||||
/// - 아바타 B: 로컬 Z가 월드 Up을 가리킴 → localAxisForWorldUp = (0, 0, 1)
|
|
||||||
/// - 아바타 C: 로컬 -Z가 월드 Up을 가리킴 → localAxisForWorldUp = (0, 0, -1)
|
|
||||||
///
|
|
||||||
/// 이를 통해 hipsOffsetY는 항상 "위/아래" 방향으로 작동합니다.
|
|
||||||
/// </summary>
|
|
||||||
private void CalculateAxisNormalizer()
|
|
||||||
{
|
|
||||||
if (targetAnimator == null) return;
|
|
||||||
|
|
||||||
Transform hips = targetAnimator.GetBoneTransform(HumanBodyBones.Hips);
|
|
||||||
if (hips == null) return;
|
|
||||||
|
|
||||||
// 힙의 각 로컬 축을 월드 공간으로 변환
|
|
||||||
Vector3 localXInWorld = hips.TransformDirection(Vector3.right).normalized;
|
|
||||||
Vector3 localYInWorld = hips.TransformDirection(Vector3.up).normalized;
|
|
||||||
Vector3 localZInWorld = hips.TransformDirection(Vector3.forward).normalized;
|
|
||||||
|
|
||||||
// 월드 Right(X)에 가장 가까운 로컬 축 찾기
|
|
||||||
localAxisForWorldRight = FindBestLocalAxisForWorld(localXInWorld, localYInWorld, localZInWorld, Vector3.right, out string rightAxisName);
|
|
||||||
|
|
||||||
// 월드 Up(Y)에 가장 가까운 로컬 축 찾기
|
|
||||||
localAxisForWorldUp = FindBestLocalAxisForWorld(localXInWorld, localYInWorld, localZInWorld, Vector3.up, out string upAxisName);
|
|
||||||
|
|
||||||
// 월드 Forward(Z)에 가장 가까운 로컬 축 찾기
|
|
||||||
localAxisForWorldForward = FindBestLocalAxisForWorld(localXInWorld, localYInWorld, localZInWorld, Vector3.forward, out string forwardAxisName);
|
|
||||||
|
|
||||||
// 디버그용: 각 오프셋이 어느 로컬 축에 매핑되는지 표시
|
|
||||||
// X: 좌우 오프셋이 사용하는 로컬 축 (1=X, 2=Y, 3=Z, 부호는 방향)
|
|
||||||
// Y: 상하 오프셋이 사용하는 로컬 축
|
|
||||||
// Z: 앞뒤 오프셋이 사용하는 로컬 축
|
|
||||||
debugAxisNormalizer = new Vector3(
|
|
||||||
GetAxisIndex(localAxisForWorldRight),
|
|
||||||
GetAxisIndex(localAxisForWorldUp),
|
|
||||||
GetAxisIndex(localAxisForWorldForward)
|
|
||||||
);
|
|
||||||
|
|
||||||
Debug.Log($"[{gameObject.name}] 축 매핑 분석 완료:\n" +
|
|
||||||
$" 월드 Right(좌우) ← 로컬 {rightAxisName} → 매핑: {localAxisForWorldRight}\n" +
|
|
||||||
$" 월드 Up(상하) ← 로컬 {upAxisName} → 매핑: {localAxisForWorldUp}\n" +
|
|
||||||
$" 월드 Forward(앞뒤) ← 로컬 {forwardAxisName} → 매핑: {localAxisForWorldForward}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 축 벡터를 인덱스로 변환합니다 (디버그용).
|
|
||||||
/// X축=1, Y축=2, Z축=3, 부호는 방향을 나타냄
|
|
||||||
/// </summary>
|
|
||||||
private float GetAxisIndex(Vector3 axisVector)
|
|
||||||
{
|
|
||||||
if (Mathf.Abs(axisVector.x) > 0.5f)
|
|
||||||
return 1f * Mathf.Sign(axisVector.x); // X축
|
|
||||||
else if (Mathf.Abs(axisVector.y) > 0.5f)
|
|
||||||
return 2f * Mathf.Sign(axisVector.y); // Y축
|
|
||||||
else
|
|
||||||
return 3f * Mathf.Sign(axisVector.z); // Z축
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 세 로컬 축 중에서 목표 월드 방향과 가장 일치하는 축을 찾아 로컬 축 벡터를 반환합니다.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="localXInWorld">로컬 X축의 월드 방향</param>
|
|
||||||
/// <param name="localYInWorld">로컬 Y축의 월드 방향</param>
|
|
||||||
/// <param name="localZInWorld">로컬 Z축의 월드 방향</param>
|
|
||||||
/// <param name="worldDirection">비교할 월드 방향</param>
|
|
||||||
/// <param name="matchedAxisName">매칭된 축 이름 (출력용)</param>
|
|
||||||
/// <returns>해당 월드 방향을 담당하는 로컬 축 벡터 (부호 포함)</returns>
|
|
||||||
private Vector3 FindBestLocalAxisForWorld(Vector3 localXInWorld, Vector3 localYInWorld, Vector3 localZInWorld, Vector3 worldDirection, out string matchedAxisName)
|
|
||||||
{
|
|
||||||
float dotX = Vector3.Dot(localXInWorld, worldDirection);
|
|
||||||
float dotY = Vector3.Dot(localYInWorld, worldDirection);
|
|
||||||
float dotZ = Vector3.Dot(localZInWorld, worldDirection);
|
|
||||||
|
|
||||||
float absDotX = Mathf.Abs(dotX);
|
|
||||||
float absDotY = Mathf.Abs(dotY);
|
|
||||||
float absDotZ = Mathf.Abs(dotZ);
|
|
||||||
|
|
||||||
// 가장 큰 내적값을 가진 축이 해당 월드 방향과 가장 일치하는 축
|
|
||||||
if (absDotX >= absDotY && absDotX >= absDotZ)
|
|
||||||
{
|
|
||||||
matchedAxisName = dotX > 0 ? "+X (Right)" : "-X (Left)";
|
|
||||||
return Vector3.right * Mathf.Sign(dotX); // 로컬 X축 (부호 포함)
|
|
||||||
}
|
|
||||||
else if (absDotY >= absDotX && absDotY >= absDotZ)
|
|
||||||
{
|
|
||||||
matchedAxisName = dotY > 0 ? "+Y (Up)" : "-Y (Down)";
|
|
||||||
return Vector3.up * Mathf.Sign(dotY); // 로컬 Y축 (부호 포함)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
matchedAxisName = dotZ > 0 ? "+Z (Forward)" : "-Z (Back)";
|
|
||||||
return Vector3.forward * Mathf.Sign(dotZ); // 로컬 Z축 (부호 포함)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// HumanPoseHandler를 초기화합니다.
|
/// HumanPoseHandler를 초기화합니다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -457,11 +324,6 @@ namespace KindRetargeting
|
|||||||
|
|
||||||
var settings = new RetargetingSettings
|
var settings = new RetargetingSettings
|
||||||
{
|
{
|
||||||
hipsOffsetX = hipsOffsetX,
|
|
||||||
hipsOffsetY = hipsOffsetY,
|
|
||||||
hipsOffsetZ = hipsOffsetZ,
|
|
||||||
kneeInOutWeight = kneeInOutWeight,
|
|
||||||
kneeFrontBackWeight = kneeFrontBackWeight,
|
|
||||||
footFrontBackOffset = footFrontBackOffset,
|
footFrontBackOffset = footFrontBackOffset,
|
||||||
footInOutOffset = footInOutOffset,
|
footInOutOffset = footInOutOffset,
|
||||||
floorHeight = floorHeight,
|
floorHeight = floorHeight,
|
||||||
@ -515,11 +377,6 @@ namespace KindRetargeting
|
|||||||
var settings = JsonUtility.FromJson<RetargetingSettings>(json);
|
var settings = JsonUtility.FromJson<RetargetingSettings>(json);
|
||||||
|
|
||||||
// 설정 적용
|
// 설정 적용
|
||||||
hipsOffsetX = settings.hipsOffsetX;
|
|
||||||
hipsOffsetY = settings.hipsOffsetY;
|
|
||||||
hipsOffsetZ = settings.hipsOffsetZ;
|
|
||||||
kneeInOutWeight = settings.kneeInOutWeight;
|
|
||||||
kneeFrontBackWeight = settings.kneeFrontBackWeight;
|
|
||||||
footFrontBackOffset = settings.footFrontBackOffset;
|
footFrontBackOffset = settings.footFrontBackOffset;
|
||||||
footInOutOffset = settings.footInOutOffset;
|
footInOutOffset = settings.footInOutOffset;
|
||||||
floorHeight = settings.floorHeight;
|
floorHeight = settings.floorHeight;
|
||||||
@ -928,44 +785,25 @@ namespace KindRetargeting
|
|||||||
|
|
||||||
if (sourceHips != null && targetHips != null)
|
if (sourceHips != null && targetHips != null)
|
||||||
{
|
{
|
||||||
// 1. 힙 회전 먼저 동기화 (회전 오프셋 적용)
|
// 1. 힙 회전 동기화 (회전 오프셋 적용)
|
||||||
Quaternion finalHipsRotation = sourceHips.rotation;
|
|
||||||
if (rotationOffsets.TryGetValue(HumanBodyBones.Hips, out Quaternion hipsOffset))
|
if (rotationOffsets.TryGetValue(HumanBodyBones.Hips, out Quaternion hipsOffset))
|
||||||
{
|
{
|
||||||
finalHipsRotation = sourceHips.rotation * hipsOffset;
|
targetHips.rotation = sourceHips.rotation * hipsOffset;
|
||||||
targetHips.rotation = finalHipsRotation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 캐릭터 기준 로컬 오프셋 계산 (축 정규화 적용)
|
// 2. 힙 위치 동기화 (수동 힙 위치 보정 제거됨 — 소스 위치 직접 사용)
|
||||||
//
|
Vector3 adjustedPosition = sourceHips.position;
|
||||||
// 문제: 아바타마다 힙의 로컬 축 방향이 다름
|
|
||||||
// - 아바타 A: 로컬 Y가 "위", 로컬 Z가 "앞"
|
|
||||||
// - 아바타 B: 로컬 Z가 "위", 로컬 X가 "앞"
|
|
||||||
//
|
|
||||||
// 해결: T-포즈에서 계산한 축 매핑을 사용
|
|
||||||
// - localAxisForWorldRight: 실제로 "오른쪽"을 가리키는 로컬 축
|
|
||||||
// - localAxisForWorldUp: 실제로 "위"를 가리키는 로컬 축
|
|
||||||
// - localAxisForWorldForward: 실제로 "앞"을 가리키는 로컬 축
|
|
||||||
//
|
|
||||||
// 이렇게 하면 모든 아바타에서 동일하게 작동합니다.
|
|
||||||
|
|
||||||
// 힙의 현재 회전을 기준으로, 정규화된 방향 벡터 계산
|
// 3. 다리 높이 자동 보정 (월드 Y축, 앉기 가중치 HipsWeightOffset 반영)
|
||||||
Vector3 characterRight = finalHipsRotation * localAxisForWorldRight;
|
// 타겟 다리가 소스보다 길면 힙을 올려 발 접지를 맞춘다.
|
||||||
Vector3 characterUp = finalHipsRotation * localAxisForWorldUp;
|
// 매 프레임 계산되어 avatarScale 변경에도 자동 대응한다
|
||||||
Vector3 characterForward = finalHipsRotation * localAxisForWorldForward;
|
// (다리 분절 길이는 포즈와 무관하게 일정하므로 매 프레임 계산해도 안전).
|
||||||
|
adjustedPosition.y += ComputeLegHeightOffset() * HipsWeightOffset;
|
||||||
|
|
||||||
Vector3 characterOffset =
|
// 4. 바닥 높이 추가 (월드 Y축)
|
||||||
characterRight * (hipsOffsetX * HipsWeightOffset) + // 캐릭터 기준 좌우
|
|
||||||
characterUp * (hipsOffsetY * HipsWeightOffset) + // 캐릭터 기준 상하
|
|
||||||
characterForward * (hipsOffsetZ * HipsWeightOffset); // 캐릭터 기준 앞뒤
|
|
||||||
|
|
||||||
// 3. 힙 위치 동기화 + 캐릭터 기준 오프셋 적용
|
|
||||||
Vector3 adjustedPosition = sourceHips.position + characterOffset;
|
|
||||||
|
|
||||||
// 4. 바닥 높이 추가 (월드 Y축 - 바닥은 항상 월드 기준)
|
|
||||||
adjustedPosition.y += floorHeight;
|
adjustedPosition.y += floorHeight;
|
||||||
|
|
||||||
// 5. 의자 좌석 높이 오프셋 추가 (월드 Y축 - 로컬 보정과 별개)
|
// 5. 의자 좌석 높이 오프셋 추가 (월드 Y축)
|
||||||
adjustedPosition.y += ChairSeatHeightOffset;
|
adjustedPosition.y += ChairSeatHeightOffset;
|
||||||
|
|
||||||
targetHips.position = adjustedPosition;
|
targetHips.position = adjustedPosition;
|
||||||
@ -978,6 +816,28 @@ namespace KindRetargeting
|
|||||||
SyncBoneRotations(skipBone: HumanBodyBones.Hips);
|
SyncBoneRotations(skipBone: HumanBodyBones.Hips);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 타겟/소스 왼다리 길이 차이를 반환합니다 (힙 월드 Y 보정용).
|
||||||
|
/// 타겟 다리가 소스보다 길면 양수 → 힙을 올려 발 접지를 맞춘다.
|
||||||
|
/// IK 솔버에 캐싱된 다리 본을 재사용하며, 분절 길이는 포즈와 무관하게 일정하므로
|
||||||
|
/// 매 프레임 호출해도 안전하고 avatarScale 변화에 자동 대응한다.
|
||||||
|
/// </summary>
|
||||||
|
private float ComputeLegHeightOffset()
|
||||||
|
{
|
||||||
|
var leg = ikSolver?.leftLeg;
|
||||||
|
if (leg == null) return 0f;
|
||||||
|
if (leg.upper == null || leg.lower == null || leg.end == null) return 0f;
|
||||||
|
if (leg.sourceUpper == null || leg.sourceLower == null || leg.sourceEnd == null) return 0f;
|
||||||
|
|
||||||
|
float targetLeg = Vector3.Distance(leg.upper.position, leg.lower.position)
|
||||||
|
+ Vector3.Distance(leg.lower.position, leg.end.position);
|
||||||
|
float sourceLeg = Vector3.Distance(leg.sourceUpper.position, leg.sourceLower.position)
|
||||||
|
+ Vector3.Distance(leg.sourceLower.position, leg.sourceEnd.position);
|
||||||
|
|
||||||
|
if (sourceLeg < 0.01f || targetLeg < 0.01f) return 0f;
|
||||||
|
return targetLeg - sourceLeg;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 힙을 제외한 모든 본의 회전을 오프셋을 적용하여 동기화합니다.
|
/// 힙을 제외한 모든 본의 회전을 오프셋을 적용하여 동기화합니다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -1319,9 +1179,7 @@ namespace KindRetargeting
|
|||||||
sourceIKpoint = bone == HumanBodyBones.LeftLowerLeg ?
|
sourceIKpoint = bone == HumanBodyBones.LeftLowerLeg ?
|
||||||
sourceIKJoints.leftLowerLeg.position :
|
sourceIKJoints.leftLowerLeg.position :
|
||||||
sourceIKJoints.rightLowerLeg.position;
|
sourceIKJoints.rightLowerLeg.position;
|
||||||
zOffset = kneeFrontBackWeight; // 무릎 앞/뒤 조정
|
|
||||||
yOffset = floorHeight;
|
yOffset = floorHeight;
|
||||||
xOffset = kneeInOutWeight * (bone == HumanBodyBones.LeftLowerLeg ? -1f : 1f); // 무릎 안/밖 조정
|
|
||||||
break;
|
break;
|
||||||
case HumanBodyBones.LeftLowerArm:
|
case HumanBodyBones.LeftLowerArm:
|
||||||
case HumanBodyBones.RightLowerArm:
|
case HumanBodyBones.RightLowerArm:
|
||||||
@ -1430,18 +1288,6 @@ namespace KindRetargeting
|
|||||||
return File.Exists(filePath);
|
return File.Exists(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 무릎 앞/뒤뒤 위치 조정을 위한 public 메서드 추가
|
|
||||||
public void SetKneeFrontBackOffset(float offset)
|
|
||||||
{
|
|
||||||
kneeFrontBackWeight = offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 무릎 조정을 위한 public 메서드들
|
|
||||||
public void SetKneeInOutOffset(float offset)
|
|
||||||
{
|
|
||||||
kneeInOutWeight = offset;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ResetPoseAndCache()
|
public void ResetPoseAndCache()
|
||||||
{
|
{
|
||||||
// 캐시 파일 삭제
|
// 캐시 파일 삭제
|
||||||
|
|||||||
@ -31,19 +31,6 @@ namespace KindRetargeting
|
|||||||
scaleFoldout.Add(new PropertyField(serializedObject.FindProperty("headScale"), "머리 크기"));
|
scaleFoldout.Add(new PropertyField(serializedObject.FindProperty("headScale"), "머리 크기"));
|
||||||
root.Add(scaleFoldout);
|
root.Add(scaleFoldout);
|
||||||
|
|
||||||
// ── 힙 위치 보정 ──
|
|
||||||
root.Add(BuildHipsSection());
|
|
||||||
|
|
||||||
// ── 무릎 위치 조정 ──
|
|
||||||
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 위치 조정 ──
|
// ── 발 IK 위치 조정 ──
|
||||||
var footFoldout = new Foldout { text = "발 IK 위치 조정", value = false };
|
var footFoldout = new Foldout { text = "발 IK 위치 조정", value = false };
|
||||||
var footFB = new Slider("발 앞/뒤", -1f, 1f) { showInputField = true };
|
var footFB = new Slider("발 앞/뒤", -1f, 1f) { showInputField = true };
|
||||||
@ -83,58 +70,6 @@ namespace KindRetargeting
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 힙 위치 보정 ==========
|
|
||||||
|
|
||||||
private VisualElement BuildHipsSection()
|
|
||||||
{
|
|
||||||
var foldout = new Foldout { text = "힙 위치 보정 (로컬 좌표계)", value = true };
|
|
||||||
|
|
||||||
var axisInfo = new HelpBox("플레이 모드에서 축 매핑 정보가 표시됩니다.", HelpBoxMessageType.Info);
|
|
||||||
foldout.Add(axisInfo);
|
|
||||||
|
|
||||||
foldout.schedule.Execute(() =>
|
|
||||||
{
|
|
||||||
if (target == null) return;
|
|
||||||
serializedObject.Update();
|
|
||||||
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);
|
|
||||||
|
|
||||||
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; }
|
|
||||||
var script = (CustomRetargetingScript)target;
|
|
||||||
float offset = CalculateHipsOffsetFromLegDifference(script);
|
|
||||||
serializedObject.FindProperty("hipsOffsetY").floatValue = offset;
|
|
||||||
serializedObject.ApplyModifiedProperties();
|
|
||||||
script.SaveSettings();
|
|
||||||
}) { text = "다리 길이 자동 보정", tooltip = "소스/타겟 다리 길이 차이로 힙 상하 오프셋을 자동 계산합니다." };
|
|
||||||
autoHipsBtn.style.marginTop = 4; autoHipsBtn.style.height = 25;
|
|
||||||
foldout.Add(autoHipsBtn);
|
|
||||||
|
|
||||||
return foldout;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== 머리 회전 오프셋 ==========
|
// ========== 머리 회전 오프셋 ==========
|
||||||
|
|
||||||
private VisualElement BuildHeadRotationSection()
|
private VisualElement BuildHeadRotationSection()
|
||||||
@ -216,6 +151,11 @@ namespace KindRetargeting
|
|||||||
serializedObject.FindProperty("limbWeight.footHeightMaxThreshold"),
|
serializedObject.FindProperty("limbWeight.footHeightMaxThreshold"),
|
||||||
0.1f, 1f));
|
0.1f, 1f));
|
||||||
|
|
||||||
|
// 의자 앉기 높이
|
||||||
|
var chairSlider = new Slider("의자 앉기 높이", -1f, 1f) { showInputField = true, tooltip = "의자에 앉을 때 엉덩이 높이 조정 (월드 Y 기준)" };
|
||||||
|
chairSlider.BindProperty(serializedObject.FindProperty("limbWeight.chairSeatHeightOffset"));
|
||||||
|
foldout.Add(chairSlider);
|
||||||
|
|
||||||
return foldout;
|
return foldout;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -345,7 +285,7 @@ namespace KindRetargeting
|
|||||||
{
|
{
|
||||||
if (!Application.isPlaying) { Debug.LogWarning("플레이 모드에서만 사용 가능합니다."); return; }
|
if (!Application.isPlaying) { Debug.LogWarning("플레이 모드에서만 사용 가능합니다."); return; }
|
||||||
AutoCalibrateAll((CustomRetargetingScript)target, serializedObject);
|
AutoCalibrateAll((CustomRetargetingScript)target, serializedObject);
|
||||||
}) { text = "전체 자동 보정 (크기 + 힙 높이 + 머리 정면)", tooltip = "아바타 크기, 힙 높이, 머리 정면을 자동 보정합니다." };
|
}) { text = "전체 자동 보정 (크기 + 머리 정면)", tooltip = "아바타 크기와 머리 정면을 자동 보정합니다. 힙 높이는 매 프레임 다리 길이로 자동 유지됩니다." };
|
||||||
autoBtn.style.marginTop = 4; autoBtn.style.height = 28;
|
autoBtn.style.marginTop = 4; autoBtn.style.height = 28;
|
||||||
box.Add(autoBtn);
|
box.Add(autoBtn);
|
||||||
|
|
||||||
@ -419,7 +359,6 @@ namespace KindRetargeting
|
|||||||
|
|
||||||
script.ResetScale();
|
script.ResetScale();
|
||||||
so.FindProperty("avatarScale").floatValue = 1f;
|
so.FindProperty("avatarScale").floatValue = 1f;
|
||||||
so.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script);
|
|
||||||
so.ApplyModifiedProperties();
|
so.ApplyModifiedProperties();
|
||||||
|
|
||||||
EditorApplication.delayCall += () =>
|
EditorApplication.delayCall += () =>
|
||||||
@ -440,7 +379,6 @@ namespace KindRetargeting
|
|||||||
{
|
{
|
||||||
if (script == null) return;
|
if (script == null) return;
|
||||||
var so3 = new SerializedObject(script);
|
var so3 = new SerializedObject(script);
|
||||||
so3.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script);
|
|
||||||
|
|
||||||
var xP = so3.FindProperty("headRotationOffsetX");
|
var xP = so3.FindProperty("headRotationOffsetX");
|
||||||
var yP = so3.FindProperty("headRotationOffsetY");
|
var yP = so3.FindProperty("headRotationOffsetY");
|
||||||
@ -457,37 +395,6 @@ namespace KindRetargeting
|
|||||||
|
|
||||||
// ========== 유틸리티 ==========
|
// ========== 유틸리티 ==========
|
||||||
|
|
||||||
private float CalculateHipsOffsetFromLegDifference(CustomRetargetingScript script)
|
|
||||||
{
|
|
||||||
var source = script.optitrackSource;
|
|
||||||
Animator targetAnim = script.targetAnimator;
|
|
||||||
if (source == null || targetAnim == null) return 0f;
|
|
||||||
|
|
||||||
float sourceLeg = GetSourceLegLength(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 float GetSourceLegLength(OptitrackSkeletonAnimator_Mingle source)
|
|
||||||
{
|
|
||||||
Transform upper = source.GetBoneTransform(HumanBodyBones.LeftUpperLeg);
|
|
||||||
Transform lower = source.GetBoneTransform(HumanBodyBones.LeftLowerLeg);
|
|
||||||
Transform foot = source.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 void CalibrateHeadToForward(SerializedObject so, SerializedProperty xProp, SerializedProperty yProp, SerializedProperty zProp)
|
private void CalibrateHeadToForward(SerializedObject so, SerializedProperty xProp, SerializedProperty yProp, SerializedProperty zProp)
|
||||||
{
|
{
|
||||||
CustomRetargetingScript script = so.targetObject as CustomRetargetingScript;
|
CustomRetargetingScript script = so.targetObject as CustomRetargetingScript;
|
||||||
|
|||||||
@ -195,22 +195,6 @@ public class RetargetingControlWindow : EditorWindow
|
|||||||
// 가중치 설정
|
// 가중치 설정
|
||||||
panel.Add(BuildWeightSection(script, so));
|
panel.Add(BuildWeightSection(script, so));
|
||||||
|
|
||||||
// 힙 위치 보정
|
|
||||||
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 위치 조정
|
// 발 IK 위치 조정
|
||||||
var footFoldout = new Foldout { text = "발 IK 위치 조정", value = false };
|
var footFoldout = new Foldout { text = "발 IK 위치 조정", value = false };
|
||||||
var footContainer = new VisualElement();
|
var footContainer = new VisualElement();
|
||||||
@ -245,16 +229,6 @@ public class RetargetingControlWindow : EditorWindow
|
|||||||
if (headScaleProp != null)
|
if (headScaleProp != null)
|
||||||
scaleContainer.Add(new PropertyField(headScaleProp, "머리 크기"));
|
scaleContainer.Add(new PropertyField(headScaleProp, "머리 크기"));
|
||||||
|
|
||||||
// 아바타 크기 변경 시 다리 길이 자동 보정 (실시간)
|
|
||||||
scaleContainer.TrackPropertyValue(so.FindProperty("avatarScale"), _ =>
|
|
||||||
{
|
|
||||||
if (!Application.isPlaying || script == null) return;
|
|
||||||
var sox = new SerializedObject(script);
|
|
||||||
sox.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script);
|
|
||||||
sox.ApplyModifiedProperties();
|
|
||||||
sox.Dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
scaleContainer.Bind(so);
|
scaleContainer.Bind(so);
|
||||||
scaleFoldout.Add(scaleContainer);
|
scaleFoldout.Add(scaleContainer);
|
||||||
panel.Add(scaleFoldout);
|
panel.Add(scaleFoldout);
|
||||||
@ -331,71 +305,11 @@ public class RetargetingControlWindow : EditorWindow
|
|||||||
var smoothField = new PropertyField(so.FindProperty("limbWeight.weightSmoothSpeed"), "가중치 변화 속도");
|
var smoothField = new PropertyField(so.FindProperty("limbWeight.weightSmoothSpeed"), "가중치 변화 속도");
|
||||||
container.Add(smoothField);
|
container.Add(smoothField);
|
||||||
|
|
||||||
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(() =>
|
|
||||||
{
|
|
||||||
try { if (so == null || so.targetObject == null) return; }
|
|
||||||
catch (System.Exception) { 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 chairSlider = new Slider("의자 앉기 높이", -1f, 1f) { showInputField = true, tooltip = "의자에 앉을 때 엉덩이 높이 조정 (월드 Y 기준)" };
|
var chairSlider = new Slider("의자 앉기 높이", -1f, 1f) { showInputField = true, tooltip = "의자에 앉을 때 엉덩이 높이 조정 (월드 Y 기준)" };
|
||||||
chairSlider.BindProperty(so.FindProperty("limbWeight.chairSeatHeightOffset"));
|
chairSlider.BindProperty(so.FindProperty("limbWeight.chairSeatHeightOffset"));
|
||||||
container.Add(chairSlider);
|
container.Add(chairSlider);
|
||||||
|
|
||||||
// 다리 길이 자동 보정 버튼
|
|
||||||
var autoHipsBtn = new Button(() =>
|
|
||||||
{
|
|
||||||
if (!Application.isPlaying)
|
|
||||||
{
|
|
||||||
Debug.LogWarning("다리 길이 자동 보정은 플레이 모드에서만 사용 가능합니다.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
float offset = CalculateHipsOffsetFromLegDifference(script);
|
|
||||||
var hipsProp = so.FindProperty("hipsOffsetY");
|
|
||||||
hipsProp.floatValue = offset;
|
|
||||||
so.ApplyModifiedProperties();
|
|
||||||
script.SaveSettings();
|
|
||||||
Debug.Log($"자동 보정 완료: hipsOffsetY = {offset:F4}");
|
|
||||||
}) { text = "다리 길이 자동 보정", tooltip = "소스/타겟 다리 길이 차이로 힙 상하 오프셋을 자동 계산합니다. (플레이 모드 전용)" };
|
|
||||||
autoHipsBtn.style.marginTop = 4;
|
|
||||||
autoHipsBtn.style.height = 25;
|
|
||||||
container.Add(autoHipsBtn);
|
|
||||||
|
|
||||||
container.Bind(so);
|
|
||||||
foldout.Add(container);
|
foldout.Add(container);
|
||||||
return foldout;
|
return foldout;
|
||||||
}
|
}
|
||||||
@ -836,7 +750,8 @@ public class RetargetingControlWindow : EditorWindow
|
|||||||
// ========== Auto Full Calibration ==========
|
// ========== Auto Full Calibration ==========
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 소스/타겟 목 높이 비율로 avatarScale을 맞추고, 다리 길이 차이로 hipsOffsetY를 보정합니다.
|
/// 소스/타겟 목 높이 비율로 avatarScale을 맞추고 머리 정면을 보정합니다.
|
||||||
|
/// 힙 높이는 매 프레임 다리 길이 자동 보정이 처리하므로 여기서 건드리지 않습니다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private void AutoCalibrateAll(CustomRetargetingScript script, SerializedObject so)
|
private void AutoCalibrateAll(CustomRetargetingScript script, SerializedObject so)
|
||||||
{
|
{
|
||||||
@ -849,11 +764,10 @@ public class RetargetingControlWindow : EditorWindow
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 프레임 1: 스케일 리셋 + 다리 보정 ──
|
// ── 프레임 1: 스케일 리셋 ──
|
||||||
script.ResetScale();
|
script.ResetScale();
|
||||||
var scaleProp = so.FindProperty("avatarScale");
|
var scaleProp = so.FindProperty("avatarScale");
|
||||||
scaleProp.floatValue = 1f;
|
scaleProp.floatValue = 1f;
|
||||||
so.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script);
|
|
||||||
so.ApplyModifiedProperties();
|
so.ApplyModifiedProperties();
|
||||||
|
|
||||||
// ── 프레임 2: 리타게팅 반영 후 목 높이 측정 → avatarScale 설정 ──
|
// ── 프레임 2: 리타게팅 반영 후 목 높이 측정 → avatarScale 설정 ──
|
||||||
@ -885,8 +799,6 @@ public class RetargetingControlWindow : EditorWindow
|
|||||||
if (script == null) return;
|
if (script == null) return;
|
||||||
|
|
||||||
var so3 = new SerializedObject(script);
|
var so3 = new SerializedObject(script);
|
||||||
float finalHipsOffset = CalculateHipsOffsetFromLegDifference(script);
|
|
||||||
so3.FindProperty("hipsOffsetY").floatValue = finalHipsOffset;
|
|
||||||
|
|
||||||
var xProp = so3.FindProperty("headRotationOffsetX");
|
var xProp = so3.FindProperty("headRotationOffsetX");
|
||||||
var yProp = so3.FindProperty("headRotationOffsetY");
|
var yProp = so3.FindProperty("headRotationOffsetY");
|
||||||
@ -898,69 +810,11 @@ public class RetargetingControlWindow : EditorWindow
|
|||||||
so3.Dispose();
|
so3.Dispose();
|
||||||
|
|
||||||
script.SaveSettings();
|
script.SaveSettings();
|
||||||
Debug.Log($"전체 자동 보정 완료: avatarScale = {scaleRatio:F3}, hipsOffsetY = {finalHipsOffset:F4}m");
|
Debug.Log($"전체 자동 보정 완료: avatarScale = {scaleRatio:F3} (힙 높이는 자동 유지)");
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Auto Hips Offset ==========
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 소스/타겟 다리 길이 차이로 힙 상하 오프셋을 계산합니다.
|
|
||||||
/// 타겟 다리가 소스보다 짧으면 → 양수 (힙을 올려서 다리를 펴줌)
|
|
||||||
/// 타겟 다리가 소스보다 길면 → 음수 (힙을 내려서 다리를 펴줌)
|
|
||||||
/// </summary>
|
|
||||||
private float CalculateHipsOffsetFromLegDifference(CustomRetargetingScript script)
|
|
||||||
{
|
|
||||||
var source = script.optitrackSource;
|
|
||||||
Animator target = script.targetAnimator;
|
|
||||||
|
|
||||||
if (source == null || target == null || !target.isHuman)
|
|
||||||
{
|
|
||||||
Debug.LogWarning("소스 OptiTrack 또는 타겟 Animator가 설정되지 않았습니다.");
|
|
||||||
return 0f;
|
|
||||||
}
|
|
||||||
|
|
||||||
float sourceLeg = GetSourceLegLength(source);
|
|
||||||
float targetLeg = GetLegLength(target);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
private float GetLegLength(Animator animator)
|
|
||||||
{
|
|
||||||
Transform upperLeg = animator.GetBoneTransform(HumanBodyBones.LeftUpperLeg);
|
|
||||||
Transform lowerLeg = animator.GetBoneTransform(HumanBodyBones.LeftLowerLeg);
|
|
||||||
Transform foot = animator.GetBoneTransform(HumanBodyBones.LeftFoot);
|
|
||||||
|
|
||||||
if (upperLeg == null || lowerLeg == null || foot == null) return 0f;
|
|
||||||
|
|
||||||
return Vector3.Distance(upperLeg.position, lowerLeg.position)
|
|
||||||
+ Vector3.Distance(lowerLeg.position, foot.position);
|
|
||||||
}
|
|
||||||
|
|
||||||
private float GetSourceLegLength(OptitrackSkeletonAnimator_Mingle source)
|
|
||||||
{
|
|
||||||
Transform upperLeg = source.GetBoneTransform(HumanBodyBones.LeftUpperLeg);
|
|
||||||
Transform lowerLeg = source.GetBoneTransform(HumanBodyBones.LeftLowerLeg);
|
|
||||||
Transform foot = source.GetBoneTransform(HumanBodyBones.LeftFoot);
|
|
||||||
|
|
||||||
if (upperLeg == null || lowerLeg == null || foot == null) return 0f;
|
|
||||||
|
|
||||||
return Vector3.Distance(upperLeg.position, lowerLeg.position)
|
|
||||||
+ Vector3.Distance(lowerLeg.position, foot.position);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void CalibrateHeadToForward(SerializedObject so, SerializedProperty xProp, SerializedProperty yProp, SerializedProperty zProp)
|
private void CalibrateHeadToForward(SerializedObject so, SerializedProperty xProp, SerializedProperty yProp, SerializedProperty zProp)
|
||||||
{
|
{
|
||||||
CustomRetargetingScript script = so.targetObject as CustomRetargetingScript;
|
CustomRetargetingScript script = so.targetObject as CustomRetargetingScript;
|
||||||
|
|||||||
BIN
Assets/Scripts/KindRetargeting/README.md
(Stored with Git LFS)
BIN
Assets/Scripts/KindRetargeting/README.md
(Stored with Git LFS)
Binary file not shown.
@ -166,13 +166,6 @@ namespace KindRetargeting.Remote
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "autoHipsOffset":
|
|
||||||
{
|
|
||||||
string charId = json["characterId"]?.ToString();
|
|
||||||
AutoHipsOffset(charId);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "autoCalibrateAll":
|
case "autoCalibrateAll":
|
||||||
{
|
{
|
||||||
string charId = json["characterId"]?.ToString();
|
string charId = json["characterId"]?.ToString();
|
||||||
@ -233,15 +226,6 @@ namespace KindRetargeting.Remote
|
|||||||
|
|
||||||
var data = new Dictionary<string, object>
|
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 위치 조정
|
// 발 IK 위치 조정
|
||||||
{ "feetForwardBackward", GetPrivateField<float>(script, "footFrontBackOffset") },
|
{ "feetForwardBackward", GetPrivateField<float>(script, "footFrontBackOffset") },
|
||||||
{ "feetNarrow", GetPrivateField<float>(script, "footInOutOffset") },
|
{ "feetNarrow", GetPrivateField<float>(script, "footInOutOffset") },
|
||||||
@ -316,25 +300,6 @@ namespace KindRetargeting.Remote
|
|||||||
|
|
||||||
switch (property)
|
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 위치 조정
|
// 발 IK 위치 조정
|
||||||
case "feetForwardBackward":
|
case "feetForwardBackward":
|
||||||
SetPrivateField(script, "footFrontBackOffset", value);
|
SetPrivateField(script, "footFrontBackOffset", value);
|
||||||
@ -570,19 +535,6 @@ namespace KindRetargeting.Remote
|
|||||||
SendStatus(true, "정면 캘리브레이션 완료");
|
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)
|
private void AutoCalibrateAll(string characterId)
|
||||||
{
|
{
|
||||||
var script = FindCharacter(characterId);
|
var script = FindCharacter(characterId);
|
||||||
@ -596,10 +548,9 @@ namespace KindRetargeting.Remote
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: 크기 초기화 + 힙 오프셋 계산
|
// Step 1: 크기 초기화 (힙 높이는 매 프레임 다리 길이 자동 보정이 처리)
|
||||||
script.ResetScale();
|
script.ResetScale();
|
||||||
SetPrivateField(script, "avatarScale", 1f);
|
SetPrivateField(script, "avatarScale", 1f);
|
||||||
SetPrivateField(script, "hipsOffsetY", CalculateHipsOffsetFromLegDifference(script));
|
|
||||||
|
|
||||||
// Step 2: 1프레임 후 목 높이 비율로 크기 조정
|
// Step 2: 1프레임 후 목 높이 비율로 크기 조정
|
||||||
StartCoroutine(AutoCalibrateCoroutine(script, characterId));
|
StartCoroutine(AutoCalibrateCoroutine(script, characterId));
|
||||||
@ -626,8 +577,7 @@ namespace KindRetargeting.Remote
|
|||||||
|
|
||||||
yield return null; // 1프레임 대기
|
yield return null; // 1프레임 대기
|
||||||
|
|
||||||
// Step 3: 힙 오프셋 재계산 + 머리 정면 캘리브레이션
|
// Step 3: 머리 정면 캘리브레이션 (힙 높이는 매 프레임 다리 길이 자동 보정이 처리)
|
||||||
SetPrivateField(script, "hipsOffsetY", CalculateHipsOffsetFromLegDifference(script));
|
|
||||||
script.CalibrateHeadToForward();
|
script.CalibrateHeadToForward();
|
||||||
script.SaveSettings();
|
script.SaveSettings();
|
||||||
|
|
||||||
@ -635,37 +585,6 @@ namespace KindRetargeting.Remote
|
|||||||
SendStatus(true, $"전체 자동 보정 완료: avatarScale={scaleRatio:F3}");
|
SendStatus(true, $"전체 자동 보정 완료: avatarScale={scaleRatio:F3}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private float CalculateHipsOffsetFromLegDifference(CustomRetargetingScript script)
|
|
||||||
{
|
|
||||||
var source = script.optitrackSource;
|
|
||||||
Animator targetAnim = script.targetAnimator;
|
|
||||||
if (source == null || targetAnim == null) return 0f;
|
|
||||||
|
|
||||||
float sourceLeg = GetSourceLegLength(source);
|
|
||||||
float targetLeg = GetLegLength(targetAnim);
|
|
||||||
if (sourceLeg < 0.01f || targetLeg < 0.01f) return 0f;
|
|
||||||
|
|
||||||
return targetLeg - sourceLeg;
|
|
||||||
}
|
|
||||||
|
|
||||||
private float GetSourceLegLength(OptitrackSkeletonAnimator_Mingle source)
|
|
||||||
{
|
|
||||||
Transform upper = source.GetBoneTransform(HumanBodyBones.LeftUpperLeg);
|
|
||||||
Transform lower = source.GetBoneTransform(HumanBodyBones.LeftLowerLeg);
|
|
||||||
Transform foot = source.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 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)
|
private CustomRetargetingScript FindCharacter(string characterId)
|
||||||
{
|
{
|
||||||
foreach (var script in registeredCharacters)
|
foreach (var script in registeredCharacters)
|
||||||
|
|||||||
@ -2,8 +2,8 @@ using UnityEngine;
|
|||||||
using UnityEditor;
|
using UnityEditor;
|
||||||
using UnityEngine.UIElements;
|
using UnityEngine.UIElements;
|
||||||
using UnityEditor.UIElements;
|
using UnityEditor.UIElements;
|
||||||
using System.Net;
|
using System.Collections.Generic;
|
||||||
using System.Net.Sockets;
|
using System.Linq;
|
||||||
|
|
||||||
[CustomEditor(typeof(StreamDeckServerManager))]
|
[CustomEditor(typeof(StreamDeckServerManager))]
|
||||||
public class StreamDeckServerManagerEditor : Editor
|
public class StreamDeckServerManagerEditor : Editor
|
||||||
@ -12,12 +12,15 @@ public class StreamDeckServerManagerEditor : Editor
|
|||||||
private const string UssPath = "Assets/Scripts/Streamdeck/Editor/UXML/StreamDeckServerManagerEditor.uss";
|
private const string UssPath = "Assets/Scripts/Streamdeck/Editor/UXML/StreamDeckServerManagerEditor.uss";
|
||||||
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
private const string CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
|
||||||
|
|
||||||
|
private const string AutoOption = "자동 (auto)";
|
||||||
|
|
||||||
private StreamDeckServerManager manager;
|
private StreamDeckServerManager manager;
|
||||||
private VisualElement playStatusContainer;
|
private VisualElement playStatusContainer;
|
||||||
private Label playStatusLabel;
|
private Label playStatusLabel;
|
||||||
private Label lanIPLabel;
|
private Label lanIPLabel;
|
||||||
private Label dashboardUrlLabel;
|
private Label dashboardUrlLabel;
|
||||||
private VisualElement dashboardPortField;
|
private VisualElement dashboardPortField;
|
||||||
|
private VisualElement networkAdapterField;
|
||||||
|
|
||||||
public override VisualElement CreateInspectorGUI()
|
public override VisualElement CreateInspectorGUI()
|
||||||
{
|
{
|
||||||
@ -41,23 +44,24 @@ public class StreamDeckServerManagerEditor : Editor
|
|||||||
lanIPLabel = root.Q<Label>("lanIPLabel");
|
lanIPLabel = root.Q<Label>("lanIPLabel");
|
||||||
dashboardUrlLabel = root.Q<Label>("dashboardUrlLabel");
|
dashboardUrlLabel = root.Q<Label>("dashboardUrlLabel");
|
||||||
dashboardPortField = root.Q("dashboardPortField");
|
dashboardPortField = root.Q("dashboardPortField");
|
||||||
|
networkAdapterField = root.Q("networkAdapterField");
|
||||||
|
|
||||||
// Open Dashboard button (LAN IP 우선)
|
// 네트워크 어댑터 드롭다운
|
||||||
|
if (networkAdapterField != null)
|
||||||
|
BuildAdapterDropdown(networkAdapterField);
|
||||||
|
|
||||||
|
// Open Dashboard button (선택 어댑터 IP 우선)
|
||||||
var openBtn = root.Q<Button>("openDashboardBtn");
|
var openBtn = root.Q<Button>("openDashboardBtn");
|
||||||
if (openBtn != null)
|
if (openBtn != null)
|
||||||
openBtn.clicked += () =>
|
openBtn.clicked += () =>
|
||||||
{
|
{
|
||||||
string ip = GetLocalIPAddress();
|
string ip = ResolveHostAddress();
|
||||||
string host = !string.IsNullOrEmpty(ip) ? ip : "localhost";
|
string host = !string.IsNullOrEmpty(ip) ? ip : "localhost";
|
||||||
Application.OpenURL($"http://{host}:{manager.dashboardPort}");
|
Application.OpenURL($"http://{host}:{manager.dashboardPort}");
|
||||||
};
|
};
|
||||||
|
|
||||||
// LAN IP detection
|
// LAN IP / URL 표시
|
||||||
string lanIP = GetLocalIPAddress();
|
UpdateLanLabels(ResolveHostAddress());
|
||||||
if (lanIPLabel != null)
|
|
||||||
lanIPLabel.text = !string.IsNullOrEmpty(lanIP) ? $"LAN IP: {lanIP}" : "LAN IP: not available";
|
|
||||||
|
|
||||||
UpdateDashboardUrl(lanIP);
|
|
||||||
|
|
||||||
// Track enableDashboard for conditional visibility
|
// Track enableDashboard for conditional visibility
|
||||||
var enableProp = serializedObject.FindProperty("enableDashboard");
|
var enableProp = serializedObject.FindProperty("enableDashboard");
|
||||||
@ -67,8 +71,8 @@ public class StreamDeckServerManagerEditor : Editor
|
|||||||
// Track port changes to update URL display
|
// Track port changes to update URL display
|
||||||
var dashboardPortProp = serializedObject.FindProperty("dashboardPort");
|
var dashboardPortProp = serializedObject.FindProperty("dashboardPort");
|
||||||
var wsPortProp = serializedObject.FindProperty("port");
|
var wsPortProp = serializedObject.FindProperty("port");
|
||||||
root.TrackPropertyValue(dashboardPortProp, _ => UpdateDashboardUrl(lanIP));
|
root.TrackPropertyValue(dashboardPortProp, _ => UpdateLanLabels(ResolveHostAddress()));
|
||||||
root.TrackPropertyValue(wsPortProp, _ => UpdateDashboardUrl(lanIP));
|
root.TrackPropertyValue(wsPortProp, _ => UpdateLanLabels(ResolveHostAddress()));
|
||||||
|
|
||||||
// Play mode polling
|
// Play mode polling
|
||||||
root.schedule.Execute(UpdatePlayModeState).Every(500);
|
root.schedule.Execute(UpdatePlayModeState).Every(500);
|
||||||
@ -76,15 +80,81 @@ public class StreamDeckServerManagerEditor : Editor
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateDashboardUrl(string lanIP)
|
private void BuildAdapterDropdown(VisualElement container)
|
||||||
{
|
{
|
||||||
if (dashboardUrlLabel == null || manager == null) return;
|
container.Clear();
|
||||||
|
|
||||||
string local = $"http://localhost:{manager.dashboardPort}";
|
var adapters = NetworkInterfaceUtil.GetAdapters();
|
||||||
if (!string.IsNullOrEmpty(lanIP))
|
string current = serializedObject.FindProperty("preferredHostAddress").stringValue;
|
||||||
dashboardUrlLabel.text = $"{local} | http://{lanIP}:{manager.dashboardPort}";
|
|
||||||
else
|
var choices = new List<string> { AutoOption };
|
||||||
dashboardUrlLabel.text = local;
|
var labelToIp = new Dictionary<string, string> { { AutoOption, "" } };
|
||||||
|
foreach (var a in adapters)
|
||||||
|
{
|
||||||
|
string label = $"{a.name} — {a.ip}";
|
||||||
|
if (!labelToIp.ContainsKey(label))
|
||||||
|
{
|
||||||
|
choices.Add(label);
|
||||||
|
labelToIp[label] = a.ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 선택값 라벨 결정 (저장된 IP가 목록에 없으면 별도 항목으로 표시)
|
||||||
|
string currentLabel = AutoOption;
|
||||||
|
if (!string.IsNullOrEmpty(current))
|
||||||
|
{
|
||||||
|
var match = adapters.FirstOrDefault(a => a.ip == current);
|
||||||
|
if (!string.IsNullOrEmpty(match.ip))
|
||||||
|
{
|
||||||
|
currentLabel = $"{match.name} — {match.ip}";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
currentLabel = $"{current} (현재 없음)";
|
||||||
|
choices.Add(currentLabel);
|
||||||
|
labelToIp[currentLabel] = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var dropdown = new DropdownField("Network Adapter")
|
||||||
|
{
|
||||||
|
choices = choices,
|
||||||
|
value = currentLabel,
|
||||||
|
tooltip = "대시보드 주소로 사용할 네트워크 어댑터.\n'자동'은 기본 게이트웨이가 있는 어댑터를 우선 선택합니다.\n서버 자체는 항상 모든 인터페이스+localhost에서 접속 가능합니다."
|
||||||
|
};
|
||||||
|
dropdown.RegisterValueChangedCallback(evt =>
|
||||||
|
{
|
||||||
|
string ip = labelToIp.TryGetValue(evt.newValue, out var v) ? v : "";
|
||||||
|
serializedObject.Update();
|
||||||
|
serializedObject.FindProperty("preferredHostAddress").stringValue = ip;
|
||||||
|
serializedObject.ApplyModifiedProperties();
|
||||||
|
UpdateLanLabels(NetworkInterfaceUtil.ResolveHostAddress(ip));
|
||||||
|
});
|
||||||
|
container.Add(dropdown);
|
||||||
|
|
||||||
|
var refreshBtn = new Button(() => BuildAdapterDropdown(container)) { text = "어댑터 목록 새로고침" };
|
||||||
|
refreshBtn.style.marginTop = 2;
|
||||||
|
container.Add(refreshBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string ResolveHostAddress()
|
||||||
|
{
|
||||||
|
if (manager == null) return "";
|
||||||
|
return NetworkInterfaceUtil.ResolveHostAddress(manager.preferredHostAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateLanLabels(string lanIP)
|
||||||
|
{
|
||||||
|
if (lanIPLabel != null)
|
||||||
|
lanIPLabel.text = !string.IsNullOrEmpty(lanIP) ? $"LAN IP: {lanIP}" : "LAN IP: not available";
|
||||||
|
|
||||||
|
if (dashboardUrlLabel != null && manager != null)
|
||||||
|
{
|
||||||
|
string local = $"http://localhost:{manager.dashboardPort}";
|
||||||
|
dashboardUrlLabel.text = !string.IsNullOrEmpty(lanIP)
|
||||||
|
? $"{local} | http://{lanIP}:{manager.dashboardPort}"
|
||||||
|
: local;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateDashboardPortVisibility(bool enabled)
|
private void UpdateDashboardPortVisibility(bool enabled)
|
||||||
@ -112,19 +182,4 @@ public class StreamDeckServerManagerEditor : Editor
|
|||||||
playStatusContainer.RemoveFromClassList("sdm-play-status--visible");
|
playStatusContainer.RemoveFromClassList("sdm-play-status--visible");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GetLocalIPAddress()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var host = Dns.GetHostEntry(Dns.GetHostName());
|
|
||||||
foreach (var ip in host.AddressList)
|
|
||||||
{
|
|
||||||
if (ip.AddressFamily == AddressFamily.InterNetwork)
|
|
||||||
return ip.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { }
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
<ui:VisualElement class="section">
|
<ui:VisualElement class="section">
|
||||||
<ui:Foldout text="WebSocket Server" value="true" class="section-foldout">
|
<ui:Foldout text="WebSocket Server" value="true" class="section-foldout">
|
||||||
<uie:PropertyField binding-path="port" label="WebSocket Port"/>
|
<uie:PropertyField binding-path="port" label="WebSocket Port"/>
|
||||||
|
<ui:VisualElement name="networkAdapterField"/>
|
||||||
</ui:Foldout>
|
</ui:Foldout>
|
||||||
</ui:VisualElement>
|
</ui:VisualElement>
|
||||||
|
|
||||||
|
|||||||
74
Assets/Scripts/Streamdeck/NetworkInterfaceUtil.cs
Normal file
74
Assets/Scripts/Streamdeck/NetworkInterfaceUtil.cs
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.NetworkInformation;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 활성 네트워크 어댑터의 IPv4 주소를 열거하고, 선호 주소를 해석하는 유틸리티.
|
||||||
|
/// PC에 NIC이 여러 개(유선/무선/Hyper-V/VPN 등)일 때 서버가 사용/표시할 IP를
|
||||||
|
/// 사용자가 명시적으로 고를 수 있도록 돕는다.
|
||||||
|
/// </summary>
|
||||||
|
public static class NetworkInterfaceUtil
|
||||||
|
{
|
||||||
|
public struct Adapter
|
||||||
|
{
|
||||||
|
public string name; // 어댑터 이름 (예: "이더넷", "Wi-Fi")
|
||||||
|
public string ip; // IPv4 주소 문자열
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 활성(Up) IPv4 어댑터 목록을 반환한다. 루프백/APIPA(169.254.x.x)는 제외.
|
||||||
|
/// 기본 게이트웨이가 있는 어댑터(실제 LAN/인터넷 연결)를 앞쪽에 정렬하여
|
||||||
|
/// Hyper-V/VPN 같은 가상 어댑터보다 우선 노출한다.
|
||||||
|
/// </summary>
|
||||||
|
public static List<Adapter> GetAdapters()
|
||||||
|
{
|
||||||
|
var withGateway = new List<Adapter>();
|
||||||
|
var withoutGateway = new List<Adapter>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
|
||||||
|
{
|
||||||
|
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
||||||
|
if (ni.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
|
||||||
|
|
||||||
|
var props = ni.GetIPProperties();
|
||||||
|
bool hasGateway = props.GatewayAddresses.Any(g =>
|
||||||
|
g.Address != null &&
|
||||||
|
g.Address.AddressFamily == AddressFamily.InterNetwork &&
|
||||||
|
!g.Address.Equals(IPAddress.Any));
|
||||||
|
|
||||||
|
foreach (var addr in props.UnicastAddresses)
|
||||||
|
{
|
||||||
|
if (addr.Address.AddressFamily != AddressFamily.InterNetwork) continue;
|
||||||
|
if (IPAddress.IsLoopback(addr.Address)) continue;
|
||||||
|
|
||||||
|
byte[] b = addr.Address.GetAddressBytes();
|
||||||
|
if (b[0] == 169 && b[1] == 254) continue; // APIPA(자동 사설 IP) 제외
|
||||||
|
|
||||||
|
var adapter = new Adapter { name = ni.Name, ip = addr.Address.ToString() };
|
||||||
|
(hasGateway ? withGateway : withoutGateway).Add(adapter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
|
||||||
|
withGateway.AddRange(withoutGateway);
|
||||||
|
return withGateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 선호 주소(preferred)가 현재 유효한 어댑터 IP면 그대로 사용하고,
|
||||||
|
/// 비었거나 더 이상 존재하지 않으면 자동 선택(첫 번째 = 게이트웨이 우선)한다.
|
||||||
|
/// 사용 가능한 IPv4가 전혀 없으면 빈 문자열.
|
||||||
|
/// </summary>
|
||||||
|
public static string ResolveHostAddress(string preferred)
|
||||||
|
{
|
||||||
|
var adapters = GetAdapters();
|
||||||
|
if (!string.IsNullOrEmpty(preferred) && adapters.Any(a => a.ip == preferred))
|
||||||
|
return preferred;
|
||||||
|
return adapters.Count > 0 ? adapters[0].ip : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/Scripts/Streamdeck/NetworkInterfaceUtil.cs.meta
Normal file
2
Assets/Scripts/Streamdeck/NetworkInterfaceUtil.cs.meta
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9b31a210682a1d244954a689a136c9a5
|
||||||
@ -13,6 +13,10 @@ public class StreamDeckServerManager : MonoBehaviour
|
|||||||
[Header("WebSocket 서버 설정")]
|
[Header("WebSocket 서버 설정")]
|
||||||
public int port = 64211;
|
public int port = 64211;
|
||||||
|
|
||||||
|
[Tooltip("대시보드 주소로 사용할 네트워크 어댑터 IP. 비우면 자동 선택. (Inspector 드롭다운에서 선택)\n" +
|
||||||
|
"서버 자체는 항상 모든 인터페이스+localhost에서 접속 가능하며, 이 값은 표시·대시보드 바인딩에 사용됩니다.")]
|
||||||
|
public string preferredHostAddress = "";
|
||||||
|
|
||||||
[Header("대시보드 설정")]
|
[Header("대시보드 설정")]
|
||||||
public int dashboardPort = 64210;
|
public int dashboardPort = 64210;
|
||||||
public bool enableDashboard = true;
|
public bool enableDashboard = true;
|
||||||
@ -193,7 +197,7 @@ public class StreamDeckServerManager : MonoBehaviour
|
|||||||
if (!enableDashboard) return;
|
if (!enableDashboard) return;
|
||||||
|
|
||||||
int retargetingWsPort = systemController?.retargetingRemote?.retargetingWsPort ?? 0;
|
int retargetingWsPort = systemController?.retargetingRemote?.retargetingWsPort ?? 0;
|
||||||
dashboardServer = new StreamingleDashboardServer(dashboardPort, port, retargetingWsPort);
|
dashboardServer = new StreamingleDashboardServer(dashboardPort, port, retargetingWsPort, preferredHostAddress);
|
||||||
dashboardServer.Start();
|
dashboardServer.Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,6 +19,7 @@ public class StreamingleDashboardServer
|
|||||||
private int httpPort;
|
private int httpPort;
|
||||||
private int wsPort;
|
private int wsPort;
|
||||||
private int retargetingWsPort;
|
private int retargetingWsPort;
|
||||||
|
private string preferredHostAddress;
|
||||||
|
|
||||||
private string cachedCSS = "";
|
private string cachedCSS = "";
|
||||||
private string cachedTemplate = "";
|
private string cachedTemplate = "";
|
||||||
@ -29,11 +30,12 @@ public class StreamingleDashboardServer
|
|||||||
public bool IsRunning => isRunning;
|
public bool IsRunning => isRunning;
|
||||||
public IReadOnlyList<string> BoundAddresses => boundAddresses;
|
public IReadOnlyList<string> BoundAddresses => boundAddresses;
|
||||||
|
|
||||||
public StreamingleDashboardServer(int httpPort, int wsPort, int retargetingWsPort = 0)
|
public StreamingleDashboardServer(int httpPort, int wsPort, int retargetingWsPort = 0, string preferredHostAddress = "")
|
||||||
{
|
{
|
||||||
this.httpPort = httpPort;
|
this.httpPort = httpPort;
|
||||||
this.wsPort = wsPort;
|
this.wsPort = wsPort;
|
||||||
this.retargetingWsPort = retargetingWsPort;
|
this.retargetingWsPort = retargetingWsPort;
|
||||||
|
this.preferredHostAddress = preferredHostAddress;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Start()
|
public void Start()
|
||||||
@ -56,7 +58,7 @@ public class StreamingleDashboardServer
|
|||||||
wildcardBound = true;
|
wildcardBound = true;
|
||||||
|
|
||||||
boundAddresses.Add($"http://localhost:{httpPort}");
|
boundAddresses.Add($"http://localhost:{httpPort}");
|
||||||
string localIP = GetLocalIPAddress();
|
string localIP = NetworkInterfaceUtil.ResolveHostAddress(preferredHostAddress);
|
||||||
if (!string.IsNullOrEmpty(localIP))
|
if (!string.IsNullOrEmpty(localIP))
|
||||||
{
|
{
|
||||||
boundAddresses.Add($"http://{localIP}:{httpPort}");
|
boundAddresses.Add($"http://{localIP}:{httpPort}");
|
||||||
@ -69,8 +71,8 @@ public class StreamingleDashboardServer
|
|||||||
try { listener.Close(); } catch { }
|
try { listener.Close(); } catch { }
|
||||||
listener = new HttpListener();
|
listener = new HttpListener();
|
||||||
|
|
||||||
// LAN IP 직접 바인딩 시도
|
// LAN IP 직접 바인딩 시도 (사용자가 선택한 어댑터 우선)
|
||||||
string localIP = GetLocalIPAddress();
|
string localIP = NetworkInterfaceUtil.ResolveHostAddress(preferredHostAddress);
|
||||||
bool lanBound = false;
|
bool lanBound = false;
|
||||||
|
|
||||||
listener.Prefixes.Add($"http://localhost:{httpPort}/");
|
listener.Prefixes.Add($"http://localhost:{httpPort}/");
|
||||||
@ -243,23 +245,6 @@ public class StreamingleDashboardServer
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetLocalIPAddress()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var host = Dns.GetHostEntry(Dns.GetHostName());
|
|
||||||
foreach (var ip in host.AddressList)
|
|
||||||
{
|
|
||||||
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
|
||||||
{
|
|
||||||
return ip.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception) { }
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
private string GetFallbackCSS()
|
private string GetFallbackCSS()
|
||||||
{
|
{
|
||||||
return @"
|
return @"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user