Project_WL/Assets/WL/Island/WLIslandCameraRig.cs

410 lines
27 KiB
C#

// ─────────────────────────────────────────────────────────────────────────────
// WLIslandCameraRig.cs — 던전(전장)의 카메라 구도를 **섬 구도 그대로** 만든다
//
// PD 지시(2026-09-13) 「카메라 구도는 섬에서 조작할 때의 각도와 뷰를 말한거야.
// 전장에 진입해도 바뀌지 않아야 하기 때문」
// → 기준은 **섬**. 던전 카메라를 섬 값에 맞춘다(반대가 아니다).
//
// ■ 섬 카메라 실측(Assets/FarmingIsland/Scenes/Level01.unity · 씬 파일 값 그대로)
// PlayerVirtualCamera(Cinemachine) — 회전 오일러 (45, 0, 0) · Follow = Player
// Body = CinemachineFramingTransposer · m_CameraDistance 22 · ScreenX/Y 0.5(대상 화면 정중앙)
// Lens — FieldOfView 60 · 원근(orthographic 0) · Near 0.1 · Far 500
// = 「대상에서 22 m · 45° 위 · 화각 60° · 플레이어를 따라 돌지 않는 고정 방향」
//
// ■ 던전 카메라는 힘민지 `RealCamera`(Assets/Script/Util/RealCamera.cs) 다. 원본 0줄이 원칙이라
// **그 컴포넌트의 값만** 매 프레임 채워 넣는다(Update → RealCamera.LateUpdate 순서).
// 그러면 흔들림(ShakeCamera)·스킬 뷰(CamStatus 2·3)·가림 처리는 전부 원본 그대로 산다.
//
// ■ RealCamera 의 위치 식(원본 Update_Cam)
// rotateVector = Quaternion.Euler(cameraHeight, rotateAround, cameraPan) * Vector3.one
// camPosition = target.position + Vector3.up * DistanceUp - rotateVector * DistanceAway
// Vector3.one(1,1,1) 을 돌리는 식이라 각도 = 구도 가 아니다 → 아래에서 **역으로 풀어** 값을 만든다.
// 결과 오프셋이 정확히 (0, R·sinθ, -R·cosθ) 를 yaw φ 로 돌린 것이 되게 한다(= 섬과 같은 구도).
//
// 🔴 Debug.LogError / Debug.LogException 을 쓰지 않는다.
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.Island
{
/// <summary>던전에서만 살아 있는 카메라 보정기. 씬이 내려가면 값을 원래대로 돌려놓는다(C8).</summary>
// 🔴 WL-816l — 실행 순서를 앞으로 당긴다.
// 입장 연출(`Set_Target`)은 **코루틴 구간**(SceneInfo.Co_Control_AddScene → addloadend)에서 걸리는데,
// 코루틴은 모든 Update 뒤 · 모든 LateUpdate 앞에 돈다. 그래서 그 프레임에 우리가 손쓸 수 있는 유일한 자리가
// **RealCamera 보다 먼저 도는 LateUpdate** 다. 기본 실행 순서(0)로는 늦어서 한 프레임이 그대로 새어 나간다.
[DefaultExecutionOrder(-100)]
public sealed class WLDungeonCameraRig : MonoBehaviour
{
public const string RigName = "~WL_DungeonCameraRig";
/// <summary>보고·진단용 — 마지막으로 적용한 값.</summary>
public static string LastApplied = "";
public static int Applies;
WLIslandSettings _cfg;
RealCamera _rc;
Camera _cam;
// 되돌리기(C8) — 우리가 만지기 전 값
bool _saved;
float _oMin, _oMax, _oHeight, _oUp, _oRot, _oPan, _oFov;
bool _oOrtho;
Vector3 _oLookAt;
float[] _oCamMin, _oCamMax, _oCamHeight, _oCamUp;
bool _leadTouched; float _oLead = 1f;
public static WLDungeonCameraRig Create(WLIslandSettings cfg, UnityEngine.SceneManagement.Scene scene)
{
if (cfg == null || cfg.dungeonCameraMatchIsland == 0) return null;
var go = new GameObject(RigName);
UnityEngine.SceneManagement.SceneManager.MoveGameObjectToScene(go, scene);
var rig = go.AddComponent<WLDungeonCameraRig>();
rig._cfg = cfg;
Settled = false; // WL-816l — 새 던전에 들어갈 때마다 로딩 게이트를 다시 닫는다
return rig;
}
// ─────────────────────────────────────────────────────────────────
// 섬 구도 → RealCamera 값 풀이
// ─────────────────────────────────────────────────────────────────
/// <summary>
/// 원하는 구도(내려보는 각 θ · 거리 R)를 RealCamera 의 (cameraHeight, DistanceAway, DistanceUp, rotateAround) 로 푼다.
/// 결과 오프셋 = Ry(φ) · (0, R sinθ, -R cosθ) — 섬의 Cinemachine 구도와 같은 식이다.
/// </summary>
public static bool Solve(float pitchDeg, float distance, float yawDeg,
out float camHeight, out float away, out float up, out float rotateAround)
{
camHeight = pitchDeg; away = 0f; up = 0f; rotateAround = yawDeg;
float p = pitchDeg * Mathf.Deg2Rad;
float c = Mathf.Cos(p), s = Mathf.Sin(p);
float k = c - s, m = s + c; // Rx(p)·(1,1,1) = (1, k, m)
if (Mathf.Abs(m) < 1e-4f) return false;
float a = -Mathf.Atan(1f / m); // cos a + m·sin a = 0 → 오프셋의 x 성분 0
float z = -Mathf.Sin(a) + m * Mathf.Cos(a);
if (Mathf.Abs(z) < 1e-4f) return false;
away = distance * c / z;
up = distance * s + away * k;
rotateAround = a * Mathf.Rad2Deg + yawDeg;
return true;
}
/// <summary>
/// 던전에서 쓸 카메라 거리(m). WL-816i2 — PD 「던전에서는 카메라를 조금만 더 당기자」.
/// 각도·yaw·FOV 는 섬 그대로 두고 **거리만** 던전 전용 값을 쓴다. 0 이하면 섬 값을 그대로 쓴다.
/// </summary>
public static float DungeonDistance(WLIslandSettings cfg)
{
if (cfg == null) return 22f;
if (DistanceOverride > 0f) return DistanceOverride; // 검증용(프로브)
return cfg.dungeonCamDistance > 0f ? cfg.dungeonCamDistance : cfg.camDistance;
}
/// <summary>검증 전용 — 0 보다 크면 그 거리를 쓴다(3단 비교 캡처용 · 실기에는 영향 0).</summary>
public static float DistanceOverride = 0f;
/// <summary>스테이지 진입 시 원본이 쓰는 초기 카메라 방향(MapZoneData.CamRot_Portal) 값.</summary>
public static float SolvedYaw(WLIslandSettings cfg)
{
if (cfg == null) return 0f;
float h, d, u, r;
if (!Solve(cfg.camPitch, cfg.camDistance, cfg.camYaw, out h, out d, out u, out r)) return cfg.camYaw;
return r;
}
// ─────────────────────────────────────────────────────────────────
// 적용 — Update 는 모든 LateUpdate 보다 먼저 돈다(= RealCamera 가 이 값을 그대로 쓴다)
// ─────────────────────────────────────────────────────────────────
void Update()
{
if (_cfg == null) _cfg = WLIslandSettings.Instance;
if (_cfg == null || _cfg.dungeonCameraMatchIsland == 0) return;
if (_rc == null)
{
_rc = MyValue.m_RealCamera;
if (_rc == null) return;
_cam = _rc.GetComponent<Camera>();
if (_cam == null) _cam = Camera.main;
Save();
}
float dist = DungeonDistance(_cfg); // WL-816i2 — 던전만 조금 더 당긴다(각도·FOV 는 섬 그대로)
float camHeight, away, up, rot;
if (!Solve(_cfg.camPitch, dist, _cfg.camYaw, out camHeight, out away, out up, out rot)) return;
_rc.cameraHeight = camHeight;
_rc.minDistance = away;
_rc.maxDistance = away;
_rc.DistanceUp = up;
_rc.cameraPan = 0f;
_rc.m_LookAt = new Vector3(0f, _cfg.camLookHeight, 0f);
if (_cfg.camLockYaw != 0) _rc.rotateAround = rot;
// Init_Cam()/Set_DistanceUp() 이 이 배열에서 값을 되읽으므로 같은 값을 넣어 둔다(1프레임 튐 방지).
if (_rc.arr_cCamData != null)
for (int i = 0; i < _rc.arr_cCamData.Length; i++)
{
var d = _rc.arr_cCamData[i];
if (d == null) continue;
d.minDistance = away; d.maxDistance = away;
d.cameraHeight = camHeight; d.DistanceUp = up;
}
if (_cam != null)
{
if (_cam.orthographic) _cam.orthographic = false;
if (!Mathf.Approximately(_cam.fieldOfView, _cfg.camFov)) _cam.fieldOfView = _cfg.camFov;
}
// 이동 방향 리드(#755) 배율 — 기본 1 = 아무것도 만지지 않는다(다른 팀 기능 그대로).
// 0 으로 두면 던전에서 화면 중심이 흔들리지 않아 섬과 완전히 같은 고정 구도가 된다.
if (_cfg.camLeadMultiplier >= 0f && !Mathf.Approximately(_cfg.camLeadMultiplier, 1f))
{
if (!_leadTouched) { _leadTouched = true; _oLead = CameraMoveLead.LeadMultiplier; }
CameraMoveLead.LeadMultiplier = _cfg.camLeadMultiplier;
}
Applies++;
LastApplied = "pitch " + camHeight.ToString("F2") + "° · 거리 " + dist.ToString("F2") +
" m · FOV " + _cfg.camFov.ToString("F1") + " · (RealCamera away " + away.ToString("F4") +
" · up " + up.ToString("F4") + " · rotateAround " + rot.ToString("F3") + ")";
}
// ─────────────────────────────────────────────────────────────────
// WL-816l — 입장 직후 「카메라가 휙휙 바뀐다」(PD) 를 없앤다
//
// 🔴 실측한 원본 동작(RealCamera.cs · 라인 그대로):
// ① `Set_Target(pc, CamRot_Portal)`(PCActor.cs:251) 이 `CamStatus = 0` 으로 만든다.
// ② 그 프레임의 `LateUpdate` CamStatus==0 가지는
// · 위치: `Vector3.Lerp(pos, camPosition, 1f)` = **즉시 순간이동**(:208)
// · 회전: `Quaternion.Slerp(rot, LookRotation(...), Time.deltaTime * 2.5f)` = **한 프레임에 4 % 만**(:199)
// → 위치는 던전인데 방향은 아직 섬 쪽인 **엉뚱한 한 프레임**이 그대로 화면에 나간다.
// ③ 위치가 목표와 같아지므로(:210 거리 0) 목록을 비우고 `CamStatus = 1` · `Start_Game()`.
// ④ 다음 프레임 `Update_Cam` 의 `transform.LookAt`(:283) 이 회전을 **한 번에** 맞춘다 → 두 번째 튐.
// ⑤ 그 뒤 `StageDirector.Enter → WarpPlayerToAnchor` 가 PC 를 앵커 뒤(startRadius m)로 **순간이동**시킨다.
// 원본 추적은 `Lerp(pos, camPosition, Time.deltaTime * smooth)`(:269) 라 그 거리를 0.5 초쯤 끌고 간다 → 세 번째 튐.
// → ①~④ 는 **입장 프레임에 최종 구도로 미리 앉혀** 없앤다(원본 계산과 같은 값이라 :210 의 거리 판정과
// `Start_Game()` 도달은 그대로 산다). ⑤ 는 **대상의 순간이동을 감지**해 같은 프레임에 따라 붙는다.
// 원본 `RealCamera.cs` 0줄.
// ─────────────────────────────────────────────────────────────────
public static int Snaps;
public static string LastSnap = "";
/// <summary>던전 카메라가 최종 구도에 앉았는가(로딩 화면 게이트가 읽는다).</summary>
public static bool Settled;
/// <summary>검증 전용 — 0/1 이면 SO 대신 이 값을 쓴다(-1 = SO 그대로).</summary>
public static int InstantSettleOverride = -1;
Vector3 _lastTargetPos; bool _haveLastTarget;
static bool InstantSettleOn(WLIslandSettings cfg)
{
if (InstantSettleOverride >= 0) return InstantSettleOverride != 0;
return cfg != null && cfg.dungeonCamInstantSettle != 0;
}
/// <summary>지금 대상·설정으로 계산한 **최종 구도**(원본 Update_Cam 이 수렴하는 자리)를 돌려준다.</summary>
public static bool IdealPose(RealCamera rc, WLIslandSettings cfg, out Vector3 pos, out Quaternion rot)
{
pos = Vector3.zero; rot = Quaternion.identity;
if (rc == null || cfg == null || rc.target == null) return false;
float camHeight, away, up, yaw;
if (!Solve(cfg.camPitch, DungeonDistance(cfg), cfg.camYaw, out camHeight, out away, out up, out yaw)) return false;
if (cfg.camLockYaw == 0) yaw = rc.rotateAround;
Vector3 targetOffset = rc.target.position;
Vector3 rotateVector = Quaternion.Euler(camHeight, yaw, 0f) * Vector3.one; // 원본과 같은 식(RealCamera.cs:204)
pos = targetOffset + Vector3.up * up - rotateVector * away;
Vector3 look = targetOffset + new Vector3(0f, cfg.camLookHeight, 0f) - pos; // 원본과 같은 시선(RealCamera.cs:283)
if (look.sqrMagnitude < 1e-6f) return false;
rot = Quaternion.LookRotation(look);
return true;
}
/// <summary>카메라를 최종 구도로 **즉시** 옮긴다(보간 없음). 옮겼으면 1.</summary>
public static int SnapToPose(RealCamera rc, string why)
{
var cfg = WLIslandSettings.Instance;
Vector3 pos; Quaternion rot;
if (!IdealPose(rc, cfg, out pos, out rot)) return 0;
rc.transform.position = pos;
rc.transform.rotation = rot;
Snaps++;
LastSnap = why + " — pos " + pos.ToString("F3") + " · euler " + rot.eulerAngles.ToString("F2");
return 1;
}
// 🔴 RealCamera 보다 **먼저** 도는 LateUpdate(실행 순서 -100). 코루틴에서 걸린 Set_Target·워프를 같은 프레임에 받는다.
void LateUpdate()
{
if (_cfg == null) _cfg = WLIslandSettings.Instance;
if (_rc == null || _cfg == null || !InstantSettleOn(_cfg)) return;
var tf = _rc.target;
if (tf == null) { _haveLastTarget = false; Settled = false; return; }
bool teleported = false;
if (_haveLastTarget && _cfg.dungeonCamTeleportSnap > 0f)
teleported = Vector3.Distance(tf.position, _lastTargetPos) >= _cfg.dungeonCamTeleportSnap;
_lastTargetPos = tf.position; _haveLastTarget = true;
// ⓐ 입장 연출 프레임(CamStatus 0) ⓑ 대상 순간이동(워프·부활) — 둘 다 같은 프레임에 최종 구도로 앉힌다.
if (_rc.IsEnter()) { SnapToPose(_rc, "입장 연출(CamStatus 0)"); return; }
if (teleported) SnapToPose(_rc, "대상 순간이동");
// 로딩 화면 게이트가 읽는 값 — 연출이 끝났고(= Start_Game 도달) 구도 오차가 충분히 작다.
Vector3 pos; Quaternion rot;
Settled = IdealPose(_rc, _cfg, out pos, out rot) &&
Vector3.Distance(_rc.transform.position, pos) <= 0.25f &&
Quaternion.Angle(_rc.transform.rotation, rot) <= 2f;
}
void Save()
{
if (_saved || _rc == null) return;
_saved = true;
_oMin = _rc.minDistance; _oMax = _rc.maxDistance; _oHeight = _rc.cameraHeight;
_oUp = _rc.DistanceUp; _oRot = _rc.rotateAround; _oPan = _rc.cameraPan; _oLookAt = _rc.m_LookAt;
if (_cam != null) { _oFov = _cam.fieldOfView; _oOrtho = _cam.orthographic; }
if (_rc.arr_cCamData != null)
{
int n = _rc.arr_cCamData.Length;
_oCamMin = new float[n]; _oCamMax = new float[n]; _oCamHeight = new float[n]; _oCamUp = new float[n];
for (int i = 0; i < n; i++)
{
var d = _rc.arr_cCamData[i];
if (d == null) continue;
_oCamMin[i] = d.minDistance; _oCamMax[i] = d.maxDistance;
_oCamHeight[i] = d.cameraHeight; _oCamUp[i] = d.DistanceUp;
}
}
WLIslandBridge.Log(_cfg, "던전 카메라 원래 값 보관 — 거리 " + _oMin.ToString("F2") + "~" + _oMax.ToString("F2") +
" · cameraHeight " + _oHeight.ToString("F1") + " · DistanceUp " + _oUp.ToString("F2") +
" · FOV " + _oFov.ToString("F1") + (_oOrtho ? " · 직교" : " · 원근"));
}
void OnDestroy()
{
Settled = false; // WL-816l — 던전을 나가면 다시 닫힌 상태로
if (_leadTouched) { CameraMoveLead.LeadMultiplier = _oLead; _leadTouched = false; }
if (!_saved || _rc == null) return;
_rc.minDistance = _oMin; _rc.maxDistance = _oMax; _rc.cameraHeight = _oHeight;
_rc.DistanceUp = _oUp; _rc.rotateAround = _oRot; _rc.cameraPan = _oPan; _rc.m_LookAt = _oLookAt;
if (_cam != null) { _cam.fieldOfView = _oFov; _cam.orthographic = _oOrtho; }
if (_rc.arr_cCamData != null && _oCamMin != null)
for (int i = 0; i < _rc.arr_cCamData.Length && i < _oCamMin.Length; i++)
{
var d = _rc.arr_cCamData[i];
if (d == null) continue;
d.minDistance = _oCamMin[i]; d.maxDistance = _oCamMax[i];
d.cameraHeight = _oCamHeight[i]; d.DistanceUp = _oCamUp[i];
}
}
// ─────────────────────────────────────────────────────────────────
// WL-816i 에러 ② — "Screen position out of view frustum" 감시·수리
//
// 🔴 실측(이 프로젝트 에디터 · 같은 문구 그대로 재현):
// 이 에러는 **좌표가 화면 밖이라서** 나는 것이 아니다. 화면 밖·클립 밖 좌표(뷰포트 1,0 · z 1000)를
// 넣어도 정상 카메라에서는 에러가 **나지 않는다**. 에러가 나는 유일한 조건은 **카메라의 투영이 깨진 것**이다:
// · near == far → 에러
// · 직교인데 orthographicSize ≤ 0 → 에러
// · aspect ≤ 0 → 에러
// · 카메라를 꺼 두거나 오브젝트를 꺼 둔 것 → 에러 아님(정상 동작)
// PD 콘솔의 「screen pos 1080, 0, 1000」 = (pixelRect.xMax, pixelRect.yMin, 대상 깊이) —
// NGUI `UIAnchor.Update`(UIAnchor.cs:206-207)가 화면 우하단 앵커를 계산할 때 넣는 값 모양 그대로다.
// → 그래서 값을 넣는 쪽이 아니라 **깨진 카메라**를 찾아 되살린다. 깨진 카메라는 어차피 아무것도 못 그린다.
// ─────────────────────────────────────────────────────────────────
public static int Repairs;
public static string LastRepair = "";
// ─────────────────────────────────────────────────────────────────
// WL-816i2 — 「재입장하면 전투가 진행되지 않는다」(PD) 의 원인 지점
//
// 원본 흐름(실측): 전투는 이렇게 **시작**된다.
// InGameInfo.StartCam → PCActor.StartCam(PCActor.cs:245)
// → LoadMapMgr.Get_PortalPos 콜백 → RealCamera.Set_Target(pc, CamRot_Portal)
// → CamStatus = 0 · list_tfCamactionTarget.Add(pc) (RealCamera.cs:84·87)
// → LateUpdate 의 CamStatus==0 가지가 목록을 **다 비우면** Start_Game() (RealCamera.cs:212)
// → ActorInfo.All_Stop(false) + Time.timeScale + DungeonInfo.Start_Dungeon(true)
// 🔴 `list_tfCamactionTarget` 은 원본에 **비우는 곳이 없다**(Add 와 RemoveAt(0) 뿐 · RealCamera.cs:52·122).
// 맵을 바꾸면 그 목록의 Transform(이전 맵 PC)은 파괴되는데 항목은 남는다.
// 그 상태로 다시 입장하면 CamStatus==0 가지의 첫 줄 `camTarget.position`(RealCamera.cs:197·199)이
// **MissingReferenceException** 을 던져 LateUpdate 가 매 프레임 중단된다 →
// 목록이 영영 안 비고 → **Start_Game() 이 안 불려 전투가 멈춘 채로 남는다.**
// → 원본 0줄 유지를 위해 **죽은 항목만 걷어낸다**. 다 걷어내 비면 지금 대상을 한 개 넣어
// 원본이 정상적으로 목록을 소진하고 Start_Game() 에 도달하게 한다(= 첫 입장과 같은 경로).
// ─────────────────────────────────────────────────────────────────
public static int CamTargetsPruned, CamTargetsRefilled;
static readonly System.Reflection.FieldInfo F_CamActionList =
typeof(RealCamera).GetField("list_tfCamactionTarget",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
/// <summary>죽은 카메라 연출 대상만 걷어낸다. 걷어낸 개수를 돌려준다(정상이면 0 · 아무것도 안 한다).</summary>
public static int PruneCamActionTargets(RealCamera rc)
{
if (rc == null || F_CamActionList == null) return 0;
var list = F_CamActionList.GetValue(rc) as System.Collections.Generic.List<Transform>;
if (list == null || list.Count == 0) return 0;
int removed = 0;
for (int i = list.Count - 1; i >= 0; i--)
if (list[i] == null) { list.RemoveAt(i); removed++; }
if (removed == 0) return 0;
CamTargetsPruned += removed;
// 🔴 다 걷어내 비었는데 CamStatus 가 0 이면 원본이 list[0] 에서 터진다 → 지금 대상을 한 개 채워 준다.
if (list.Count == 0 && rc.target != null) { list.Add(rc.target); CamTargetsRefilled++; }
return removed;
}
/// <summary>투영이 깨진 카메라를 찾아 되살린다. 되살린 수를 돌려준다(정상이면 0 · 로그도 없다).</summary>
public static int RepairDegenerateCameras(WLIslandSettings cfg)
{
if (cfg == null || cfg.fixDegenerateCameras == 0) return 0;
var cams = Object.FindObjectsByType<Camera>(FindObjectsInactive.Include, FindObjectsSortMode.None);
int n = 0;
for (int i = 0; i < cams.Length; i++)
{
var c = cams[i];
if (c == null) continue;
string why = null;
if (c.nearClipPlane >= c.farClipPlane) { why = "near " + c.nearClipPlane + " ≥ far " + c.farClipPlane; c.nearClipPlane = 0.3f; c.farClipPlane = 500f; }
if (c.orthographic && c.orthographicSize <= 0f) { why = (why == null ? "" : why + " · ") + "직교 size " + c.orthographicSize; c.orthographicSize = 5f; }
if (c.aspect <= 0f || float.IsNaN(c.aspect)) { why = (why == null ? "" : why + " · ") + "aspect " + c.aspect; c.ResetAspect(); }
if (why == null) continue;
n++; Repairs++;
LastRepair = c.name + "(" + (c.gameObject.scene.IsValid() ? c.gameObject.scene.name : "-") + ") — " + why;
WLIslandBridge.Log(cfg, "🔴 투영이 깨진 카메라를 되살렸다 — " + LastRepair);
}
return n;
}
// ─────────────────────────────────────────────────────────────────
// 실측 보고용 — 지금 화면의 구도를 잰다(섬·던전 공통 · 캡처와 함께 표로 남긴다)
// ─────────────────────────────────────────────────────────────────
/// <summary>현재 활성 카메라와 추적 대상 사이의 실제 구도(각·거리·FOV)를 문자열로 돌려준다.</summary>
public static string Measure(Transform target)
{
var cam = Camera.main;
if (cam == null) return "카메라 없음";
if (target == null) return "카메라 " + cam.name + " · 대상 없음 · FOV " + cam.fieldOfView.ToString("F2") +
(cam.orthographic ? (" · 직교 size " + cam.orthographicSize.ToString("F2")) : " · 원근") +
" · 오일러 " + cam.transform.eulerAngles.ToString("F2");
Vector3 off = cam.transform.position - target.position;
float horiz = new Vector2(off.x, off.z).magnitude;
float pitch = Mathf.Atan2(off.y, horiz) * Mathf.Rad2Deg;
float yaw = Mathf.Atan2(-off.x, -off.z) * Mathf.Rad2Deg;
return "카메라 " + cam.name + " · 거리 " + off.magnitude.ToString("F3") + " m · 내려보는 각 " +
pitch.ToString("F2") + "° · 수평각 " + yaw.ToString("F2") + "° · 높이 " + off.y.ToString("F3") +
" m · FOV " + cam.fieldOfView.ToString("F2") +
(cam.orthographic ? (" · 직교 size " + cam.orthographicSize.ToString("F2")) : " · 원근") +
" · near/far " + cam.nearClipPlane.ToString("F2") + "/" + cam.farClipPlane.ToString("F0") +
" · 카메라 오일러 " + cam.transform.eulerAngles.ToString("F2");
}
}
}