Fix : 리타겟팅 및 옵티 연결부 안정화

This commit is contained in:
qsxft258@gmail.com 2026-06-03 01:57:50 +09:00
parent 88515f51a8
commit 27ac11e2b4
13 changed files with 289 additions and 608 deletions

View File

@ -1768,10 +1768,11 @@ public class OptitrackStreamingClient : MonoBehaviour
private System.Collections.IEnumerator ConnectCoroutine()
{
m_receivedFrameSinceConnect = false;
// --- 메인 스레드: 주소/모드 파싱 (Unity API 미사용, 즉시 완료) ---
IPAddress serverAddr;
IPAddress localAddr;
NatNetConnectionType connType;
try
{
serverAddr = IPAddress.Parse( ServerAddress );
@ -1780,42 +1781,75 @@ public class OptitrackStreamingClient : MonoBehaviour
connType = ConnectionType == ClientConnectionType.Unicast
? NatNetConnectionType.NatNetConnectionType_Unicast
: 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 )
{
Debug.LogException( ex, 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;
}
// --- 백그라운드 스레드: 블로킹 네이티브 연결 + 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 스킵했으면 대기 불필요)
if (!m_isReconnecting)
yield return new UnityEngine.WaitForSeconds( 0.1f );

Binary file not shown.

View File

@ -22,28 +22,9 @@ namespace KindRetargeting
// IK 컴포넌트 참조
[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 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>
/// 소스 본 Transform 접근 래퍼 (OptiTrack 매핑 사용)
/// </summary>
@ -76,13 +57,6 @@ namespace KindRetargeting
// HumanBodyBones.LastBone을 이용한 본 순회 범위
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 위치 조정")]
[SerializeField, Range(-1f, 1f)]
private float footFrontBackOffset = 0f; // 발 앞뒤 오프셋 (+: 앞, -: 뒤)
@ -153,11 +127,6 @@ namespace KindRetargeting
[System.Serializable]
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 footInOutOffset; // 발 안쪽/바깥쪽 오프셋
public float floorHeight;
@ -203,9 +172,6 @@ namespace KindRetargeting
// IK 타겟 생성 (무릎 시각화 오브젝트 포함)
CreateIKTargets();
// T-포즈 전에 축 정규화 계수 계산
CalculateAxisNormalizer();
// 원본 및 대상 아바타를 T-포즈로 복원
// OptiTrack 소스는 Humanoid가 아니므로 현재 포즈를 기준으로 캐싱
if (optitrackSource != null)
@ -275,105 +241,6 @@ namespace KindRetargeting
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>
/// HumanPoseHandler를 초기화합니다.
/// </summary>
@ -457,11 +324,6 @@ namespace KindRetargeting
var settings = new RetargetingSettings
{
hipsOffsetX = hipsOffsetX,
hipsOffsetY = hipsOffsetY,
hipsOffsetZ = hipsOffsetZ,
kneeInOutWeight = kneeInOutWeight,
kneeFrontBackWeight = kneeFrontBackWeight,
footFrontBackOffset = footFrontBackOffset,
footInOutOffset = footInOutOffset,
floorHeight = floorHeight,
@ -515,11 +377,6 @@ namespace KindRetargeting
var settings = JsonUtility.FromJson<RetargetingSettings>(json);
// 설정 적용
hipsOffsetX = settings.hipsOffsetX;
hipsOffsetY = settings.hipsOffsetY;
hipsOffsetZ = settings.hipsOffsetZ;
kneeInOutWeight = settings.kneeInOutWeight;
kneeFrontBackWeight = settings.kneeFrontBackWeight;
footFrontBackOffset = settings.footFrontBackOffset;
footInOutOffset = settings.footInOutOffset;
floorHeight = settings.floorHeight;
@ -928,44 +785,25 @@ namespace KindRetargeting
if (sourceHips != null && targetHips != null)
{
// 1. 힙 회전 먼저 동기화 (회전 오프셋 적용)
Quaternion finalHipsRotation = sourceHips.rotation;
// 1. 힙 회전 동기화 (회전 오프셋 적용)
if (rotationOffsets.TryGetValue(HumanBodyBones.Hips, out Quaternion hipsOffset))
{
finalHipsRotation = sourceHips.rotation * hipsOffset;
targetHips.rotation = finalHipsRotation;
targetHips.rotation = sourceHips.rotation * hipsOffset;
}
// 2. 캐릭터 기준 로컬 오프셋 계산 (축 정규화 적용)
//
// 문제: 아바타마다 힙의 로컬 축 방향이 다름
// - 아바타 A: 로컬 Y가 "위", 로컬 Z가 "앞"
// - 아바타 B: 로컬 Z가 "위", 로컬 X가 "앞"
//
// 해결: T-포즈에서 계산한 축 매핑을 사용
// - localAxisForWorldRight: 실제로 "오른쪽"을 가리키는 로컬 축
// - localAxisForWorldUp: 실제로 "위"를 가리키는 로컬 축
// - localAxisForWorldForward: 실제로 "앞"을 가리키는 로컬 축
//
// 이렇게 하면 모든 아바타에서 동일하게 작동합니다.
// 2. 힙 위치 동기화 (수동 힙 위치 보정 제거됨 — 소스 위치 직접 사용)
Vector3 adjustedPosition = sourceHips.position;
// 힙의 현재 회전을 기준으로, 정규화된 방향 벡터 계산
Vector3 characterRight = finalHipsRotation * localAxisForWorldRight;
Vector3 characterUp = finalHipsRotation * localAxisForWorldUp;
Vector3 characterForward = finalHipsRotation * localAxisForWorldForward;
// 3. 다리 높이 자동 보정 (월드 Y축, 앉기 가중치 HipsWeightOffset 반영)
// 타겟 다리가 소스보다 길면 힙을 올려 발 접지를 맞춘다.
// 매 프레임 계산되어 avatarScale 변경에도 자동 대응한다
// (다리 분절 길이는 포즈와 무관하게 일정하므로 매 프레임 계산해도 안전).
adjustedPosition.y += ComputeLegHeightOffset() * HipsWeightOffset;
Vector3 characterOffset =
characterRight * (hipsOffsetX * HipsWeightOffset) + // 캐릭터 기준 좌우
characterUp * (hipsOffsetY * HipsWeightOffset) + // 캐릭터 기준 상하
characterForward * (hipsOffsetZ * HipsWeightOffset); // 캐릭터 기준 앞뒤
// 3. 힙 위치 동기화 + 캐릭터 기준 오프셋 적용
Vector3 adjustedPosition = sourceHips.position + characterOffset;
// 4. 바닥 높이 추가 (월드 Y축 - 바닥은 항상 월드 기준)
// 4. 바닥 높이 추가 (월드 Y축)
adjustedPosition.y += floorHeight;
// 5. 의자 좌석 높이 오프셋 추가 (월드 Y축 - 로컬 보정과 별개)
// 5. 의자 좌석 높이 오프셋 추가 (월드 Y축)
adjustedPosition.y += ChairSeatHeightOffset;
targetHips.position = adjustedPosition;
@ -978,6 +816,28 @@ namespace KindRetargeting
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>
@ -1319,9 +1179,7 @@ namespace KindRetargeting
sourceIKpoint = bone == HumanBodyBones.LeftLowerLeg ?
sourceIKJoints.leftLowerLeg.position :
sourceIKJoints.rightLowerLeg.position;
zOffset = kneeFrontBackWeight; // 무릎 앞/뒤 조정
yOffset = floorHeight;
xOffset = kneeInOutWeight * (bone == HumanBodyBones.LeftLowerLeg ? -1f : 1f); // 무릎 안/밖 조정
break;
case HumanBodyBones.LeftLowerArm:
case HumanBodyBones.RightLowerArm:
@ -1430,18 +1288,6 @@ namespace KindRetargeting
return File.Exists(filePath);
}
// 무릎 앞/뒤뒤 위치 조정을 위한 public 메서드 추가
public void SetKneeFrontBackOffset(float offset)
{
kneeFrontBackWeight = offset;
}
// 무릎 조정을 위한 public 메서드들
public void SetKneeInOutOffset(float offset)
{
kneeInOutWeight = offset;
}
public void ResetPoseAndCache()
{
// 캐시 파일 삭제

View File

@ -31,19 +31,6 @@ namespace KindRetargeting
scaleFoldout.Add(new PropertyField(serializedObject.FindProperty("headScale"), "머리 크기"));
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 위치 조정 ──
var footFoldout = new Foldout { text = "발 IK 위치 조정", value = false };
var footFB = new Slider("발 앞/뒤", -1f, 1f) { showInputField = true };
@ -83,58 +70,6 @@ namespace KindRetargeting
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()
@ -216,6 +151,11 @@ namespace KindRetargeting
serializedObject.FindProperty("limbWeight.footHeightMaxThreshold"),
0.1f, 1f));
// 의자 앉기 높이
var chairSlider = new Slider("의자 앉기 높이", -1f, 1f) { showInputField = true, tooltip = "의자에 앉을 때 엉덩이 높이 조정 (월드 Y 기준)" };
chairSlider.BindProperty(serializedObject.FindProperty("limbWeight.chairSeatHeightOffset"));
foldout.Add(chairSlider);
return foldout;
}
@ -345,7 +285,7 @@ namespace KindRetargeting
{
if (!Application.isPlaying) { Debug.LogWarning("플레이 모드에서만 사용 가능합니다."); return; }
AutoCalibrateAll((CustomRetargetingScript)target, serializedObject);
}) { text = "전체 자동 보정 (크기 + 힙 높이 + 머리 정면)", tooltip = "아바타 크기, 힙 높이, 머리 정면을 자동 보정합니다." };
}) { text = "전체 자동 보정 (크기 + 머리 정면)", tooltip = "아바타 크기와 머리 정면을 자동 보정합니다. 힙 높이는 매 프레임 다리 길이로 자동 유지됩니다." };
autoBtn.style.marginTop = 4; autoBtn.style.height = 28;
box.Add(autoBtn);
@ -419,7 +359,6 @@ namespace KindRetargeting
script.ResetScale();
so.FindProperty("avatarScale").floatValue = 1f;
so.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script);
so.ApplyModifiedProperties();
EditorApplication.delayCall += () =>
@ -440,7 +379,6 @@ namespace KindRetargeting
{
if (script == null) return;
var so3 = new SerializedObject(script);
so3.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script);
var xP = so3.FindProperty("headRotationOffsetX");
var yP = so3.FindProperty("headRotationOffsetY");
@ -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)
{
CustomRetargetingScript script = so.targetObject as CustomRetargetingScript;

View File

@ -195,22 +195,6 @@ public class RetargetingControlWindow : EditorWindow
// 가중치 설정
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 위치 조정
var footFoldout = new Foldout { text = "발 IK 위치 조정", value = false };
var footContainer = new VisualElement();
@ -245,16 +229,6 @@ public class RetargetingControlWindow : EditorWindow
if (headScaleProp != null)
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);
scaleFoldout.Add(scaleContainer);
panel.Add(scaleFoldout);
@ -331,71 +305,11 @@ public class RetargetingControlWindow : EditorWindow
var smoothField = new PropertyField(so.FindProperty("limbWeight.weightSmoothSpeed"), "가중치 변화 속도");
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 기준)" };
chairSlider.BindProperty(so.FindProperty("limbWeight.chairSeatHeightOffset"));
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);
return foldout;
}
@ -836,7 +750,8 @@ public class RetargetingControlWindow : EditorWindow
// ========== Auto Full Calibration ==========
/// <summary>
/// 소스/타겟 목 높이 비율로 avatarScale을 맞추고, 다리 길이 차이로 hipsOffsetY를 보정합니다.
/// 소스/타겟 목 높이 비율로 avatarScale을 맞추고 머리 정면을 보정합니다.
/// 힙 높이는 매 프레임 다리 길이 자동 보정이 처리하므로 여기서 건드리지 않습니다.
/// </summary>
private void AutoCalibrateAll(CustomRetargetingScript script, SerializedObject so)
{
@ -849,11 +764,10 @@ public class RetargetingControlWindow : EditorWindow
return;
}
// ── 프레임 1: 스케일 리셋 + 다리 보정 ──
// ── 프레임 1: 스케일 리셋 ──
script.ResetScale();
var scaleProp = so.FindProperty("avatarScale");
scaleProp.floatValue = 1f;
so.FindProperty("hipsOffsetY").floatValue = CalculateHipsOffsetFromLegDifference(script);
so.ApplyModifiedProperties();
// ── 프레임 2: 리타게팅 반영 후 목 높이 측정 → avatarScale 설정 ──
@ -885,8 +799,6 @@ public class RetargetingControlWindow : EditorWindow
if (script == null) return;
var so3 = new SerializedObject(script);
float finalHipsOffset = CalculateHipsOffsetFromLegDifference(script);
so3.FindProperty("hipsOffsetY").floatValue = finalHipsOffset;
var xProp = so3.FindProperty("headRotationOffsetX");
var yProp = so3.FindProperty("headRotationOffsetY");
@ -898,69 +810,11 @@ public class RetargetingControlWindow : EditorWindow
so3.Dispose();
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)
{
CustomRetargetingScript script = so.targetObject as CustomRetargetingScript;

BIN
Assets/Scripts/KindRetargeting/README.md (Stored with Git LFS)

Binary file not shown.

View File

@ -166,13 +166,6 @@ namespace KindRetargeting.Remote
}
break;
case "autoHipsOffset":
{
string charId = json["characterId"]?.ToString();
AutoHipsOffset(charId);
}
break;
case "autoCalibrateAll":
{
string charId = json["characterId"]?.ToString();
@ -233,15 +226,6 @@ namespace KindRetargeting.Remote
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") },
@ -316,25 +300,6 @@ namespace KindRetargeting.Remote
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);
@ -570,19 +535,6 @@ namespace KindRetargeting.Remote
SendStatus(true, "정면 캘리브레이션 완료");
}
private void AutoHipsOffset(string characterId)
{
var script = FindCharacter(characterId);
if (script == null) return;
float offset = CalculateHipsOffsetFromLegDifference(script);
SetPrivateField(script, "hipsOffsetY", offset);
script.SaveSettings();
SendCharacterData(characterId);
SendStatus(true, $"다리 길이 자동 보정 완료: hipsOffsetY={offset:F4}");
}
private void AutoCalibrateAll(string characterId)
{
var script = FindCharacter(characterId);
@ -596,10 +548,9 @@ namespace KindRetargeting.Remote
return;
}
// Step 1: 크기 초기화 + 힙 오프셋 계산
// Step 1: 크기 초기화 (힙 높이는 매 프레임 다리 길이 자동 보정이 처리)
script.ResetScale();
SetPrivateField(script, "avatarScale", 1f);
SetPrivateField(script, "hipsOffsetY", CalculateHipsOffsetFromLegDifference(script));
// Step 2: 1프레임 후 목 높이 비율로 크기 조정
StartCoroutine(AutoCalibrateCoroutine(script, characterId));
@ -626,8 +577,7 @@ namespace KindRetargeting.Remote
yield return null; // 1프레임 대기
// Step 3: 힙 오프셋 재계산 + 머리 정면 캘리브레이션
SetPrivateField(script, "hipsOffsetY", CalculateHipsOffsetFromLegDifference(script));
// Step 3: 머리 정면 캘리브레이션 (힙 높이는 매 프레임 다리 길이 자동 보정이 처리)
script.CalibrateHeadToForward();
script.SaveSettings();
@ -635,37 +585,6 @@ namespace KindRetargeting.Remote
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)
{
foreach (var script in registeredCharacters)

View File

@ -2,8 +2,8 @@ using UnityEngine;
using UnityEditor;
using UnityEngine.UIElements;
using UnityEditor.UIElements;
using System.Net;
using System.Net.Sockets;
using System.Collections.Generic;
using System.Linq;
[CustomEditor(typeof(StreamDeckServerManager))]
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 CommonUssPath = "Assets/Scripts/Streamingle/StreamingleControl/Editor/UXML/StreamingleCommon.uss";
private const string AutoOption = "자동 (auto)";
private StreamDeckServerManager manager;
private VisualElement playStatusContainer;
private Label playStatusLabel;
private Label lanIPLabel;
private Label dashboardUrlLabel;
private VisualElement dashboardPortField;
private VisualElement networkAdapterField;
public override VisualElement CreateInspectorGUI()
{
@ -41,23 +44,24 @@ public class StreamDeckServerManagerEditor : Editor
lanIPLabel = root.Q<Label>("lanIPLabel");
dashboardUrlLabel = root.Q<Label>("dashboardUrlLabel");
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");
if (openBtn != null)
openBtn.clicked += () =>
{
string ip = GetLocalIPAddress();
string ip = ResolveHostAddress();
string host = !string.IsNullOrEmpty(ip) ? ip : "localhost";
Application.OpenURL($"http://{host}:{manager.dashboardPort}");
};
// LAN IP detection
string lanIP = GetLocalIPAddress();
if (lanIPLabel != null)
lanIPLabel.text = !string.IsNullOrEmpty(lanIP) ? $"LAN IP: {lanIP}" : "LAN IP: not available";
UpdateDashboardUrl(lanIP);
// LAN IP / URL 표시
UpdateLanLabels(ResolveHostAddress());
// Track enableDashboard for conditional visibility
var enableProp = serializedObject.FindProperty("enableDashboard");
@ -67,8 +71,8 @@ public class StreamDeckServerManagerEditor : Editor
// Track port changes to update URL display
var dashboardPortProp = serializedObject.FindProperty("dashboardPort");
var wsPortProp = serializedObject.FindProperty("port");
root.TrackPropertyValue(dashboardPortProp, _ => UpdateDashboardUrl(lanIP));
root.TrackPropertyValue(wsPortProp, _ => UpdateDashboardUrl(lanIP));
root.TrackPropertyValue(dashboardPortProp, _ => UpdateLanLabels(ResolveHostAddress()));
root.TrackPropertyValue(wsPortProp, _ => UpdateLanLabels(ResolveHostAddress()));
// Play mode polling
root.schedule.Execute(UpdatePlayModeState).Every(500);
@ -76,15 +80,81 @@ public class StreamDeckServerManagerEditor : Editor
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}";
if (!string.IsNullOrEmpty(lanIP))
dashboardUrlLabel.text = $"{local} | http://{lanIP}:{manager.dashboardPort}";
else
dashboardUrlLabel.text = local;
var adapters = NetworkInterfaceUtil.GetAdapters();
string current = serializedObject.FindProperty("preferredHostAddress").stringValue;
var choices = new List<string> { AutoOption };
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)
@ -112,19 +182,4 @@ public class StreamDeckServerManagerEditor : Editor
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 "";
}
}

View File

@ -22,6 +22,7 @@
<ui:VisualElement class="section">
<ui:Foldout text="WebSocket Server" value="true" class="section-foldout">
<uie:PropertyField binding-path="port" label="WebSocket Port"/>
<ui:VisualElement name="networkAdapterField"/>
</ui:Foldout>
</ui:VisualElement>

View 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 : "";
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9b31a210682a1d244954a689a136c9a5

View File

@ -13,6 +13,10 @@ public class StreamDeckServerManager : MonoBehaviour
[Header("WebSocket 서버 설정")]
public int port = 64211;
[Tooltip("대시보드 주소로 사용할 네트워크 어댑터 IP. 비우면 자동 선택. (Inspector 드롭다운에서 선택)\n" +
"서버 자체는 항상 모든 인터페이스+localhost에서 접속 가능하며, 이 값은 표시·대시보드 바인딩에 사용됩니다.")]
public string preferredHostAddress = "";
[Header("대시보드 설정")]
public int dashboardPort = 64210;
public bool enableDashboard = true;
@ -193,7 +197,7 @@ public class StreamDeckServerManager : MonoBehaviour
if (!enableDashboard) return;
int retargetingWsPort = systemController?.retargetingRemote?.retargetingWsPort ?? 0;
dashboardServer = new StreamingleDashboardServer(dashboardPort, port, retargetingWsPort);
dashboardServer = new StreamingleDashboardServer(dashboardPort, port, retargetingWsPort, preferredHostAddress);
dashboardServer.Start();
}

View File

@ -19,6 +19,7 @@ public class StreamingleDashboardServer
private int httpPort;
private int wsPort;
private int retargetingWsPort;
private string preferredHostAddress;
private string cachedCSS = "";
private string cachedTemplate = "";
@ -29,11 +30,12 @@ public class StreamingleDashboardServer
public bool IsRunning => isRunning;
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.wsPort = wsPort;
this.retargetingWsPort = retargetingWsPort;
this.preferredHostAddress = preferredHostAddress;
}
public void Start()
@ -56,7 +58,7 @@ public class StreamingleDashboardServer
wildcardBound = true;
boundAddresses.Add($"http://localhost:{httpPort}");
string localIP = GetLocalIPAddress();
string localIP = NetworkInterfaceUtil.ResolveHostAddress(preferredHostAddress);
if (!string.IsNullOrEmpty(localIP))
{
boundAddresses.Add($"http://{localIP}:{httpPort}");
@ -69,8 +71,8 @@ public class StreamingleDashboardServer
try { listener.Close(); } catch { }
listener = new HttpListener();
// LAN IP 직접 바인딩 시도
string localIP = GetLocalIPAddress();
// LAN IP 직접 바인딩 시도 (사용자가 선택한 어댑터 우선)
string localIP = NetworkInterfaceUtil.ResolveHostAddress(preferredHostAddress);
bool lanBound = false;
listener.Prefixes.Add($"http://localhost:{httpPort}/");
@ -243,23 +245,6 @@ public class StreamingleDashboardServer
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()
{
return @"