using UnityEngine; namespace WL.Player { /// /// 모바일용 추적 카메라. 회전·줌 등 사용자 시야 조절 입력은 없고, 시야 모드 2종(1 숄더뷰 / 2 근거리) 토글만 지원한다. /// 숄더뷰(ThirdPersonFollow)는 요가 이동 방향을 **연속 감쇠**로 추종하는 근접 3인칭이고, /// 근거리(ThirdPerson)는 피치·요가 맵 기준으로 고정된 쿼터뷰다. /// 모든 수치는 FollowCameraSettings(ScriptableObject)에서 읽는다(C45). /// /// PD #719 재설계 — 모드 1 의 "멈췄다 따라잡기·툭 튐"은 서로 싸우는 게이트가 겹쳐서 생겼다. /// 없앤 것: 요 각속도 상한 · 요 데드존(0) · 주시점 데드존 · 카메라 속도 상한(캐릭터 속도 배수) · 급반전 유예 · 반전 스냅 상태기 /// 남긴 것: 정지 시 요 유지 하나뿐. 큰 각도는 **시간상수를 짧게** 해서 자연스럽게 빨리 돌린다(상태기 불필요). /// PD #719 보충 — 모드 1 의 룩어헤드는 모드 2 값 × 거리비로 스케일한다("모드 2 를 가까이 당긴 카메라"). /// PD #721 — 모드 2 에서 캐릭터가 시야에서 사라지지 않게 3중으로 막는다. /// ① 가림 회피 거리를 **감쇠**한다. 매 프레임 새로 캐스팅해 바로 적용하던 구 동작은 히트/미스가 갈리는 순간 /// 15m ↔ 3m 로 순간이동했다(PD 조작 25초 실측: 거리 최소 3.21m·평균 7.97m·62%가 10m 미만 · 카메라 속도 68m/s). /// ② 캐릭터 뷰포트 좌표를 안전 사각형 안에 들어오도록 룩어헤드·포커스 오프셋을 줄인다. /// ③ 프레임당 카메라 이동에 스파이크 상한을 건다(전환 보간 제외). /// [RequireComponent(typeof(Camera))] public class FollowCamera : MonoBehaviour { [Header("설정 (ScriptableObject)")] [SerializeField] private FollowCameraSettings settings; [Header("추적 대상")] [SerializeField] private Transform target; [Tooltip("target 이 비어 있을 때 이 태그로 찾는다")] [SerializeField] private string fallbackTargetTag = "Player"; [Tooltip("1인칭에서 숨길 캐릭터 모델 루트. 비어 있으면 target 하위 전체")] [SerializeField] private Transform characterModelRoot; [Header("지형 충돌 회피 대상 레이어")] [SerializeField] private LayerMask obstacleMask = ~0; [Header("현재 상태 (읽기 전용)")] [SerializeField] private int modeIndex = 1; private Vector3 _posVelocity; private Camera _cam; private Renderer[] _characterRenderers; private int _charHiddenState = -1; // -1 = 미적용(첫 호출은 항상 반영) · 0 = 표시 · 1 = 숨김 private float _anchorY = -1f; // 현재 적용 중인 앵커(보간 결과) private float _anchorVelocity; private float _followYaw; // 숄더뷰에서 이동 방향을 감쇠 추종한 요 private bool _followYawInit; private float _followYawVelocity; private Vector3 _lastTargetPos; // 이동 여부 판정용 직전 프레임 대상 위치 private readonly RaycastHit[] _hitBuffer = new RaycastHit[16]; // 충돌 회피용 — 매 프레임 할당 방지 private float _charSpeed; // 캐릭터 수평 속도(m/s) private Vector3 _charVelocityDir = Vector3.forward; // 캐릭터 수평 이동 방향(정지 시 마지막 방향 유지) private Vector3 _lookAhead; // 이동 방향 주시점 오프셋(월드 · 수평) private Vector3 _lookAheadVel; private Vector3 _lookAheadDir = Vector3.forward; // 감쇠된 룩어헤드 방향(급전환 시 휘두름 방지) private Vector3 _lookAheadDirVel; private Vector3 _livePivot; // 이번 프레임 주시점(오프셋 제외) private Vector3 _smoothPivot; // PD #722 궤도 중심 — 캐릭터 주시점을 감쇠 추종한 점 private Vector3 _smoothPivotVel; private bool _smoothPivotInit; private float _pitch = -1f; // 현재 적용 중인 이동 피치(보간 결과 · -1 = 미초기화) private float _pitchVel; private float _occlusionPitch; // PD #721 ①-b 가림 시 위로 올리는 추가 피치(도) private float _occlusionPitchVel; private float _avoidDistance = -1f; // PD #721 ①-a 감쇠된 충돌 회피 거리(m · -1 = 미초기화) private float _avoidDistanceVel; private float _rawAvoidDistance; // 이번 프레임 캐스팅 결과(감쇠 전) — 진단용 private float _offsetScale = 1f; // PD #721 ② 안전 사각형을 지키려고 줄인 오프셋 배율(1 = 그대로) private bool _occludedNow; private float _blendTimer; // 모드 전환 보간 잔여 시간(초) private Vector3 _blendFromPos; private Quaternion _blendFromRot; private Vector3 _blendFromAim; // PD #724 전환 시작 시점의 화면 중앙 주시점(월드) — 전환 중 회전은 이 점과 목표 주시점 사이를 보며 잇는다 private float _blendFromFov; // ── 검증용 진단 (프레임당 변화량 실측) private float _lastPosDelta, _lastYawDelta, _maxPosDelta, _maxYawDelta; /// 직전 프레임 카메라 위치 변화량(m). public float LastPosDelta { get { return _lastPosDelta; } } /// 직전 프레임 카메라 요 변화량(도). public float LastYawDelta { get { return _lastYawDelta; } } /// 측정 구간 내 프레임당 위치 변화 최대값(m). public float MaxPosDelta { get { return _maxPosDelta; } } /// 측정 구간 내 프레임당 요 변화 최대값(도). public float MaxYawDelta { get { return _maxYawDelta; } } /// 캐릭터 수평 속도(m/s). public float CharacterSpeed { get { return _charSpeed; } } /// 모드 전환 보간 잔여 시간(초). 0 이면 보간 없음. public float ModeBlendRemaining { get { return _blendTimer; } } /// 이번 프레임 주시점(오프셋 제외). 검증용. public Vector3 PivotAnchor { get { return _livePivot; } } /// 현재 적용 중인 룩어헤드 오프셋(월드 · 수평). 검증용. public Vector3 LookAheadOffset { get { return _lookAhead * _offsetScale; } } /// 캐릭터 수평 이동 방향. 검증용. public Vector3 CharacterVelocityDir { get { return _charVelocityDir; } } /// 현재 적용 중인 피치(도 · 가림 상향 포함). 검증용. public float CurrentPitchDeg { get { return CurrentPitch; } } /// 감쇠된 충돌 회피 거리(m). 검증용 (PD #721 ①). public float AvoidDistance { get { return _avoidDistance; } } /// 이번 프레임 캐스팅이 요구한 거리(감쇠 전 · m). 검증용. public float RawAvoidDistance { get { return _rawAvoidDistance; } } /// 가림 상향으로 더해진 피치(도). 검증용 (PD #721 ①-b). public float OcclusionPitchBoost { get { return _occlusionPitch; } } /// 지금 카메라→캐릭터 시선이 막혀 있는가. 검증용 (PD #721). public bool IsOccluded { get { return _occludedNow; } } /// 안전 사각형을 지키려고 오프셋을 줄인 배율(1 = 그대로). 검증용 (PD #721 ②). public float OffsetScale { get { return _offsetScale; } } /// 프레임당 변화량 최대값 측정을 초기화한다. public void ResetDiagnostics() { _maxPosDelta = 0f; _maxYawDelta = 0f; } // ── PD #713 ② 가상패드 입력 방향 추종 private float _moveInputYaw; // 마지막으로 보고받은 이동 입력의 월드 요(도) private float _moveInputTime = -99f; // 그 보고 시각(초) private float _desiredYaw; // 이번 프레임 요 추종 목표(도) — 진단용 [Tooltip("이동 입력 보고가 이 시간(초) 안이면 요 목표로 쓴다. 지나면 캐릭터 facing 으로 되돌린다 · 임시 0.25")] [SerializeField] private float moveInputYawValiditySeconds = 0.25f; /// /// PlayerController 가 매 프레임 이동 입력의 월드 방향을 보고한다(PD #713 ②). /// 요 추종 목표를 캐릭터 facing 대신 이 방향으로 삼아, 캐릭터 회전 지연·조준 회전이 시야를 끌지 않게 한다. /// public void ReportMoveDirection(Vector3 worldDir) { worldDir.y = 0f; if (worldDir.sqrMagnitude < 0.0001f) return; _moveInputYaw = Mathf.Atan2(worldDir.x, worldDir.z) * Mathf.Rad2Deg; _moveInputTime = Time.time; } // ── PD #719 ⑤ 가상패드 기준 요 래치 private Vector2 _latchedPadDir; private float _padRefYawLatched; private float _padInputTime = -99f; private bool _padLatchInit; /// /// PlayerController 가 **방향 변환 직전에** 가상패드 원본 입력(화면 기준)을 보고한다 (PD #719 ⑤). /// /// 패드를 누르고 있는 동안 카메라가 돌면 "패드 방향 → 월드 방향 → 카메라 요 → 다시 패드 기준" 순환이 생겨 /// 같은 패드 방향인데 이동 벡터가 계속 휘어 나간다. 그래서 **누른 시점의 카메라 요를 붙잡아 두고**, /// 패드 방향이 크게 바뀌거나 손을 뗄 때만 다시 잡는다. /// public void ReportPadInput(Vector2 padInput) { if (padInput.sqrMagnitude < 0.000001f) return; // 손을 뗀 프레임 — 유효시간이 지나면 래치가 저절로 풀린다 Vector2 d = padInput.normalized; bool expired = Time.time - _padInputTime > moveInputYawValiditySeconds; float relatch = settings != null ? Mathf.Max(settings.inputYawRelatchAngle, 0f) : 30f; if (!_padLatchInit || expired || Vector2.Angle(_latchedPadDir, d) >= relatch) { _latchedPadDir = d; _padRefYawLatched = transform.eulerAngles.y; _padLatchInit = true; } _padInputTime = Time.time; } /// /// 가상패드 입력(화면 기준)을 월드 방향으로 바꿀 때 쓸 기준 요(도). /// ModeFixedYaw 면 모드의 고정 요를, CameraRelative 면 **래치된 카메라 요**를 쓴다 (PD #719 ⑤). /// public float InputReferenceYaw { get { // PD #715 ① — 모드 전환 보간 중에는 전환 직전 기준을 그대로 쓴다. // 보간 중 기준이 바뀌면 같은 패드 방향이 매 프레임 다른 월드 방향이 되어 캐릭터가 휘어 나간다. if (_blendTimer > 0f) return _inputRefYawLatched; var m = Mode; if (m != null && m.padReference == PadReference.ModeFixedYaw) return m.yaw; if (_padLatchInit && Time.time - _padInputTime <= moveInputYawValiditySeconds) return _padRefYawLatched; return transform.eulerAngles.y; } } private float _inputRefYawLatched; // 모드 전환 보간 동안 고정해 두는 입력 기준 요 /// 이번 프레임 요 추종 목표(도). 검증용. public float DesiredFollowYaw { get { return _desiredYaw; } } /// 이동 입력 보고가 유효한가(요 목표로 쓰이는가). 검증용. public bool HasMoveInputYaw { get { return Time.time - _moveInputTime <= moveInputYawValiditySeconds; } } /// 현재 적용 중인 화면 세로 앵커(보간 결과). 검증용. public float CurrentAnchorY { get { return _anchorY; } } /// 숄더뷰에서 현재 적용 중인 요(도). 검증용. public float CurrentFollowYaw { get { return _followYaw; } } /// 캐릭터가 카메라 쪽을 보는 정도 0(전방)~1(카메라 쪽). 검증용. public float FacingTowardCamera01 { get { return ComputeFacingToward01(); } } public int ModeIndex { get { return modeIndex; } } public FollowCameraSettings Settings { get { return settings; } } public string CurrentModeLabel { get { var m = settings != null ? settings.GetMode(modeIndex) : null; return m != null ? m.label : (modeIndex + 1).ToString(); } } public void SetTarget(Transform t) { target = t; CacheRenderers(); SnapToTarget(); } private void Awake() { _cam = GetComponent(); if (target == null && !string.IsNullOrEmpty(fallbackTargetTag)) { var go = GameObject.FindGameObjectWithTag(fallbackTargetTag); if (go != null) target = go.transform; } if (settings != null) modeIndex = Mathf.Clamp(settings.startModeIndex, 0, Mathf.Max(settings.ModeCount - 1, 0)); CacheRenderers(); ApplyCharacterVisibility(); // 모드에 맞는 표시 상태로 복원(잔재 방지) SnapToTarget(); } private void CacheRenderers() { Transform root = characterModelRoot != null ? characterModelRoot : target; _characterRenderers = root != null ? root.GetComponentsInChildren(true) : null; } // ───────────────────────────────── 모드 /// 모드 순환. 현재 구성 = 1 숄더뷰 → 2 근거리 → 1 public int CycleMode() { int count = settings != null ? Mathf.Max(settings.ModeCount, 1) : 1; SetMode((modeIndex + 1) % count); return modeIndex; } public void SetMode(int index) { int count = settings != null ? Mathf.Max(settings.ModeCount, 1) : 1; int newIndex = Mathf.Clamp(index, 0, count - 1); float blend = settings != null ? settings.modeBlendSeconds : 0f; // PD #709 ③-d — 재생 중 모드가 실제로 바뀌면 즉시 점프하지 않고 시간에 걸쳐 잇는다. if (Application.isPlaying && blend > 0.001f && modeIndex != newIndex) { if (_cam == null) _cam = GetComponent(); _inputRefYawLatched = InputReferenceYaw; // PD #715 ① — 전환 직전 기준을 붙잡아 둔다 _blendFromPos = transform.position; _blendFromRot = transform.rotation; _blendFromAim = transform.position + transform.forward * AimDistance(); // PD #724 — 모드 바꾸기 전(구 모드 거리) 기준 _blendFromFov = _cam != null ? _cam.fieldOfView : 0f; modeIndex = newIndex; ApplyCharacterVisibility(); var mm = Mode; if (_cam != null && mm != null && mm.nearClipPlane > 0f) _cam.nearClipPlane = mm.nearClipPlane; // 화각만 보간, 클리핑은 즉시 _blendTimer = blend; _avoidDistance = -1f; // 모드마다 기준 거리가 다르다(8 ↔ 15) — 새 모드 기준으로 다시 잡는다 _avoidDistanceVel = 0f; return; } modeIndex = newIndex; ApplyLens(); ApplyCharacterVisibility(); SnapToTarget(); } private CameraModeEntry Mode { get { return settings != null ? settings.GetMode(modeIndex) : null; } } private void ApplyLens() { if (_cam == null) _cam = GetComponent(); var m = Mode; if (_cam == null || m == null) return; if (m.fieldOfView > 0f) _cam.fieldOfView = m.fieldOfView; if (m.nearClipPlane > 0f) _cam.nearClipPlane = m.nearClipPlane; } private void ApplyCharacterVisibility() { var m = Mode; bool hide = m != null && m.kind == CameraKind.FirstPerson && m.hideCharacterInFirstPerson; // 첫 호출(_charHiddenState == -1)은 캐시와 무관하게 항상 반영해 잔재 상태를 복원한다. if (_charHiddenState == (hide ? 1 : 0)) return; _charHiddenState = hide ? 1 : 0; if (_characterRenderers == null) return; for (int i = 0; i < _characterRenderers.Length; i++) { var r = _characterRenderers[i]; if (r == null) continue; r.shadowCastingMode = hide ? UnityEngine.Rendering.ShadowCastingMode.ShadowsOnly : UnityEngine.Rendering.ShadowCastingMode.On; } } // ───────────────────────────────── 지오메트리 /// /// 캐릭터를 화면 세로 screenAnchorY 지점에 놓기 위해 카메라를 자기 상방으로 밀어내는 거리. /// 화면 중앙(0.5) 대비 (0.5 - anchor) 비율 × 그 거리에서의 화면 세로 크기. /// private float FocusShift(float dist) { if (_cam == null) return 0f; float halfHeight = dist * Mathf.Tan(_cam.fieldOfView * 0.5f * Mathf.Deg2Rad); return (0.5f - _anchorY) * 2f * halfHeight; } /// /// 캐릭터가 카메라 쪽(화면 아래쪽)을 보는 정도. 0 = 카메라 전방(화면 위쪽), 1 = 카메라 쪽. /// 카메라의 **현재 요**를 쓴다 — 모드 1 처럼 요가 도는 모드에서도 맞게 나온다. /// private float ComputeFacingToward01() { var m = Mode; if (target == null || m == null) return 0f; float camYaw = m.kind == CameraKind.ThirdPersonFollow ? _followYaw : m.yaw; Vector3 charFwd = Vector3.ProjectOnPlane(target.forward, Vector3.up); Vector3 camFwd = Vector3.ProjectOnPlane(Quaternion.Euler(0f, camYaw, 0f) * Vector3.forward, Vector3.up); if (charFwd.sqrMagnitude < 0.0001f || camFwd.sqrMagnitude < 0.0001f) return 0f; float dot = Vector3.Dot(charFwd.normalized, camFwd.normalized); // +1 카메라 전방 · -1 카메라 쪽 return Mathf.Clamp01((1f - dot) * 0.5f); } /// 바라보는 방향에 따라 앵커를 보간하고 감쇠 적용. 두 모드 공통(1인칭만 제외). private void UpdateAnchor(float dt) { var m = Mode; if (m == null) return; float wanted; bool follow = settings != null && settings.anchorFollowsFacing && m.kind != CameraKind.FirstPerson; if (follow) wanted = Mathf.Lerp(m.screenAnchorY, m.screenAnchorYTowardCamera, ComputeFacingToward01()); else wanted = m.screenAnchorY; float smooth = settings != null ? settings.anchorSmoothTime : 0.4f; if (_anchorY < 0f || dt <= 0f || smooth <= 0.001f) { _anchorY = wanted; _anchorVelocity = 0f; return; } _anchorY = Mathf.SmoothDamp(_anchorY, wanted, ref _anchorVelocity, smooth, Mathf.Infinity, dt); } /// /// 숄더뷰의 요를 이동 방향으로 추종시킨다 (PD #719 ① 재설계). /// /// 게이트를 겹쳐 쓰던 구 방식(데드존 → 급반전 유예 → 반전 스냅 상태기 → 감쇠 → 각속도 상한)은 /// 서로 싸우면서 "멈췄다 갑자기 따라잡는" 체감을 만들었다. 이제 **연속 감쇠 하나**만 쓴다. /// 각도차가 클수록 시간상수를 줄여(0° = Near, 180° = Far) 큰 각은 알아서 빨리 돈다 — 상태기가 필요 없다. /// 남긴 게이트는 "정지 중에는 요를 유지" 하나뿐이다. /// private void UpdateFollowYaw(float dt) { var m = Mode; if (target == null || m == null || m.kind != CameraKind.ThirdPersonFollow) return; // PD #713 ② — 요 목표 = 가상패드 입력 방향(월드). 보고가 끊기면 캐릭터 facing 으로 되돌린다. float wanted = (m.followYawFromMoveInput && HasMoveInputYaw) ? _moveInputYaw : target.eulerAngles.y; _desiredYaw = wanted; if (!_followYawInit || dt <= 0f) { _followYaw = wanted; _followYawVelocity = 0f; _followYawInit = true; _lastTargetPos = target.position; return; } // PD #719 ③ — 모드 전환 보간 중에는 갱신을 멈춘다(전환 보간과 추종이 합성되면 튄다) if (_blendTimer > 0f) { _followYawVelocity = 0f; return; } // 유일하게 남긴 게이트 — 제자리에서 방향만 트는 동안은 시야를 흔들지 않는다 if (_charSpeed < m.followMoveSpeedThreshold) { _followYawVelocity = 0f; return; } float diff = Mathf.DeltaAngle(_followYaw, wanted); float a = Mathf.Abs(diff); // 데드존은 기본 0(게이트 제거). 미세 떨림이 보일 때만 2° 정도로 쓴다. float dead = Mathf.Max(m.followYawDeadzone, 0f); if (dead > 0f && a <= dead) { _followYawVelocity = 0f; return; } // 각도차 → 시간상수 선형 보간 (0° = Near 느긋 · 180° = Far 빠름) float k = Mathf.Clamp01(a / 180f); float smooth = Mathf.Max(Mathf.Lerp(m.followYawSmoothTimeNear, m.followYawSmoothTimeFar, k), 0.0001f); // PD #724 — 각속도 상한(도/초). 위치를 누르던 스파이크 상한(#721 ③)은 궤도 모드에서 회전과 위치를 갈라놓아 // 캐릭터가 카메라 뒤로 빠졌다(#722 실측: 유지-하 화면안 20.8% · 뷰포트 x -332). 속도 제한은 여기 회전 한 곳에서만 건다. float maxSpeed = m.followYawMaxSpeed > 0.001f ? m.followYawMaxSpeed : Mathf.Infinity; _followYaw = Mathf.SmoothDampAngle(_followYaw, wanted, ref _followYawVelocity, smooth, maxSpeed, dt); } /// /// 궤도 중심 갱신 (PD #722 ③). 캐릭터 주시점을 positionSmoothTime 으로 감쇠 추종한다. /// 카메라는 이 점을 중심으로 도는 원호 위에 놓이므로, 위치 감쇠가 반지름을 갉아먹지 않는다. /// private void UpdatePivot(float dt) { var m = Mode; if (m == null || target == null) return; Vector3 wanted = HeadPoint(m); float smooth = m.positionSmoothTime; if (!_smoothPivotInit || dt <= 0f || smooth <= 0.001f) { _smoothPivot = wanted; _smoothPivotVel = Vector3.zero; _smoothPivotInit = true; return; } _smoothPivot = Vector3.SmoothDamp(_smoothPivot, wanted, ref _smoothPivotVel, smooth, Mathf.Infinity, dt); } /// 캐릭터 수평 속도를 잰다. private void UpdateCharacterSpeed(float dt) { if (target == null || dt <= 0f) return; Vector3 move = target.position - _lastTargetPos; move.y = 0f; _charSpeed = move.magnitude / dt; _charVelocityDir = move.sqrMagnitude > 0.0000001f ? move.normalized : _charVelocityDir; _lastTargetPos = target.position; } /// /// 이동 중 피치 하향 (PD #717 ②). 세로 출렁임을 만들어 PD #719 ④ 로 **기본 off** — /// FollowCameraSettings.usePitchMoving 을 켤 때만 동작한다. /// private void UpdatePitch(float dt) { var m = Mode; if (m == null) return; float wanted = m.pitch; bool on = settings != null && settings.usePitchMoving && m.pitchMoving > 0.001f; if (on) { float refSpeed = Mathf.Max(settings.pitchSpeedReference, 0.0001f); float k = Mathf.Clamp01(_charSpeed / refSpeed); wanted = Mathf.Lerp(m.pitch, m.pitchMoving, k); } float smooth = settings != null ? settings.pitchBlendSmoothTime : 0.4f; if (_pitch < 0f || dt <= 0f || smooth <= 0.001f) { _pitch = wanted; _pitchVel = 0f; return; } _pitch = Mathf.SmoothDamp(_pitch, wanted, ref _pitchVel, smooth, Mathf.Infinity, dt); } /// /// 가림 시 피치 상향 (PD #721 ①-b). 카메라를 당기는 것만으로 시선이 안 풀리면 위에서 내려다본다. /// 판정은 **직전 프레임 카메라 위치** 기준이다 — 피치가 위치를 정하고 위치가 피치를 정하는 순환을 끊는다. /// private void UpdateOcclusionPitch(float dt) { var m = Mode; float wanted = 0f; _occludedNow = false; if (m != null && m.avoidGeometry && target != null && settings != null && settings.occlusionPitchBoostMax > 0.001f) { _occludedNow = IsLineBlocked(transform.position, HeadPoint(m)); if (_occludedNow) wanted = settings.occlusionPitchBoostMax; } float smooth = settings != null ? settings.occlusionPitchSmoothTime : 0.25f; if (dt <= 0f || smooth <= 0.001f) { _occlusionPitch = wanted; _occlusionPitchVel = 0f; return; } _occlusionPitch = Mathf.SmoothDamp(_occlusionPitch, wanted, ref _occlusionPitchVel, smooth, Mathf.Infinity, dt); } /// 카메라에서 화면 중앙 주시점까지의 거리(m). 가림 회피가 적용 중이면 그 거리, 아니면 모드 거리 (PD #724 전환 보간용). private float AimDistance() { var m = Mode; float d = _avoidDistance >= 0f ? _avoidDistance : (m != null ? m.distance : 0f); return Mathf.Max(d, 0.01f); } /// 지금 적용 중인 피치(도 · 가림 상향 포함). 미초기화면 모드 값. private float CurrentPitch { get { var m = Mode; if (m == null) return 0f; float p = _pitch < 0f ? m.pitch : _pitch; return Mathf.Clamp(p + _occlusionPitch, 0f, 85f); } } /// 카메라가 바라보는 캐릭터 기준점(월드). private Vector3 HeadPoint(CameraModeEntry m) { if (target == null || m == null) return Vector3.zero; return m.kind == CameraKind.ThirdPersonFollow ? target.position + Vector3.up * m.followPivotHeight : target.position + m.lookAtOffset; } /// 두 점 사이가 지형·나무·바위로 막혀 있는가(추적 대상 자신의 콜라이더는 제외). private bool IsLineBlocked(Vector3 from, Vector3 to) { Vector3 dir = to - from; float d = dir.magnitude; if (d <= 0.01f) return false; dir /= d; int count = Physics.RaycastNonAlloc(from, dir, _hitBuffer, d, obstacleMask, QueryTriggerInteraction.Ignore); for (int i = 0; i < count; i++) { var h = _hitBuffer[i]; if (h.collider == null) continue; if (h.distance <= 0.0001f) continue; if (target != null && (h.transform == target || h.transform.IsChildOf(target))) continue; if (h.collider is CharacterController) continue; // PD #736 — 몬스터·캐릭터 몸통은 가림으로 치지 않는다 return true; } return false; } /// /// 룩어헤드 (PD #712 ①). 주시점을 이동 속도 방향으로 속도에 비례해 민다. /// 최대 거리는 **거리비 스케일**을 거친 값을 쓴다 (PD #719 보충 · FollowCameraSettings.EffectiveLookAhead). /// private void UpdateLookAhead(float dt) { // PD #719 ③ — 모드 전환 보간 중에는 갱신을 멈춘다(전환 보간과 합성 방지) if (_blendTimer > 0f) return; var m = Mode; Vector3 wanted = Vector3.zero; float maxDist = settings != null ? settings.EffectiveLookAhead(modeIndex) : 0f; bool on = settings != null && settings.lookAheadEnabled && m != null && maxDist > 0.0001f; if (on) { // 방향 자체도 감쇠한다 — 급전환 때 오프셋이 화면을 가로질러 휘두르는 것을 막는다(PD 긴급 피드백 ①) float ds = settings.lookAheadDirSmoothTime; if (dt > 0f && ds > 0.001f) _lookAheadDir = Vector3.SmoothDamp(_lookAheadDir, _charVelocityDir, ref _lookAheadDirVel, ds, Mathf.Infinity, dt); else _lookAheadDir = _charVelocityDir; if (_lookAheadDir.sqrMagnitude > 0.0001f) _lookAheadDir.Normalize(); else _lookAheadDir = _charVelocityDir; float refSpeed = Mathf.Max(settings.lookAheadSpeedReference, 0.0001f); float k = Mathf.Clamp01(_charSpeed / refSpeed); wanted = _lookAheadDir * (k * maxDist); } float smooth = settings != null ? settings.lookAheadSmoothTime : 0.7f; if (dt <= 0f || smooth <= 0.001f) { _lookAhead = wanted; _lookAheadVel = Vector3.zero; return; } _lookAhead = Vector3.SmoothDamp(_lookAhead, wanted, ref _lookAheadVel, smooth, Mathf.Infinity, dt); } /// /// 충돌 회피 거리를 **감쇠해서** 갱신한다 (PD #721 ①-a). /// /// 구 동작은 매 프레임 SphereCast 결과를 그대로 썼다. 나무·바위를 스치듯 지나가면 히트/미스가 프레임마다 /// 갈려 카메라 거리가 15m ↔ 3m 로 튀었다(PD 조작 25초 실측 = 최소 3.21m·평균 7.97m·62%가 10m 미만, /// 카메라 속도 최대 68m/s). 이제 목표 거리만 캐스팅으로 구하고, 실제 거리는 감쇠로 따라간다. /// private void UpdateAvoidDistance(float dt) { var m = Mode; if (m == null) return; float wanted = m.distance; if (m.avoidGeometry) { Quaternion rot = Quaternion.Euler(CurrentPitch, m.kind == CameraKind.ThirdPersonFollow ? _followYaw : m.yaw, 0f); Vector3 pivot = HeadPoint(m); Vector3 dir = -(rot * Vector3.forward); // PD #736 — 특정 지역에서 카메라가 멋대로 줌인되던 원인: 구 경로 캐스트(반지름 0.3 구)가 나무 크라운 박스 등 // **시야를 가리지 않는** 물체를 스치기만 해도 카메라를 당겼다(격자 실측 255점 중 88점). // 이제 먼저 **본래 거리의 카메라 자리에서 캐릭터 머리가 실제로 보이는지**(얇은 레이) 확인하고, // 보이면 당기지 않는다. 가려질 때만 구 캐스트로 당길 거리를 구한다. 나무는 CameraIgnore 레이어로 마스크에서 제외. Vector3 fullPos = pivot + dir * m.distance; if (IsLineBlocked(fullPos, pivot)) { int count = Physics.SphereCastNonAlloc(pivot, m.collisionPadding, dir, _hitBuffer, m.distance, obstacleMask, QueryTriggerInteraction.Ignore); bool ignoreOverlap = settings == null || settings.ignoreInitialOverlap; float nearest = m.distance; for (int i = 0; i < count; i++) { var h = _hitBuffer[i]; if (h.collider == null) continue; if (ignoreOverlap && h.distance <= 0.0001f) continue; if (target != null && (h.transform == target || h.transform.IsChildOf(target))) continue; if (h.collider is CharacterController) continue; // 몬스터 몸통은 무시 if (h.distance < nearest) nearest = h.distance; } float floor = Mathf.Max(m.collisionPadding, settings != null ? settings.minCollisionDistance : 0f); wanted = Mathf.Clamp(nearest, Mathf.Min(floor, m.distance), m.distance); } } _rawAvoidDistance = wanted; float smooth = settings != null ? settings.occlusionDistanceSmoothTime : 0.25f; if (_avoidDistance < 0f || dt <= 0f || smooth <= 0.001f) { _avoidDistance = wanted; _avoidDistanceVel = 0f; return; } _avoidDistance = Mathf.SmoothDamp(_avoidDistance, wanted, ref _avoidDistanceVel, smooth, Mathf.Infinity, dt); } /// /// 카메라의 목표 위치·회전. offsetScale 은 룩어헤드·전방 포커스 오프셋에만 곱한다 (PD #721 ② 안전 사각형용). /// private bool ComputeDesired(float offsetScale, out Vector3 pos, out Quaternion rot) { pos = transform.position; rot = transform.rotation; var m = Mode; if (target == null || m == null) return false; if (m.kind == CameraKind.FirstPerson) { rot = Quaternion.Euler(m.firstPersonPitch, target.eulerAngles.y, 0f); pos = target.position + target.rotation * m.firstPersonEyeOffset; return true; } float dist = _avoidDistance >= 0f ? _avoidDistance : m.distance; Vector3 look = _lookAhead * offsetScale; float shift = FocusShift(dist) * offsetScale; if (m.kind == CameraKind.ThirdPersonFollow) { // PD #722 — **캐릭터를 중심으로 한 궤도 회전**. // 궤도 중심은 감쇠 추종한 캐릭터 주시점이고, 카메라는 그 점에서 정확히 거리만큼 떨어진 원호 위에 놓는다. // (위치를 월드에서 SmoothDamp 하면 요가 도는 동안 원호의 현을 가로질러 반지름이 줄어든다 — // 그래서 위치 감쇠를 **피벗에만** 걸고 카메라는 궤도 위에 직접 놓는다. 요 감쇠가 곧 회전 감쇠다) rot = Quaternion.Euler(CurrentPitch, _followYaw, 0f); _livePivot = HeadPoint(m); Vector3 orbitCenter = _smoothPivotInit ? _smoothPivot : _livePivot; Vector3 orbitPivot = orbitCenter + look + (rot * Vector3.right) * (m.followShoulderOffsetX * offsetScale); pos = orbitPivot - (rot * Vector3.forward) * dist + (rot * Vector3.up) * shift; return true; } rot = Quaternion.Euler(CurrentPitch, m.yaw, 0f); _livePivot = HeadPoint(m); Vector3 pivot = _livePivot + look; if (settings != null && settings.focusDirection == FocusDirection.CharacterFacing) { Vector3 fwd = Vector3.ProjectOnPlane(target.forward, Vector3.up); if (fwd.sqrMagnitude > 0.0001f) pivot += fwd.normalized * shift; pos = pivot - (rot * Vector3.forward) * dist; } else { pos = pivot - (rot * Vector3.forward) * dist + (rot * Vector3.up) * shift; } return true; } /// 주어진 카메라 자세에서 월드 점의 뷰포트 좌표. 카메라 뒤면 false. private bool ViewportOf(Vector3 camPos, Quaternion camRot, Vector3 world, out Vector2 vp) { vp = Vector2.zero; if (_cam == null) return false; Vector3 local = Quaternion.Inverse(camRot) * (world - camPos); if (local.z <= 0.01f) return false; float halfH = Mathf.Tan(_cam.fieldOfView * 0.5f * Mathf.Deg2Rad) * local.z; float halfW = halfH * Mathf.Max(_cam.aspect, 0.0001f); vp = new Vector2(0.5f + local.x / (2f * halfW), 0.5f + local.y / (2f * halfH)); return true; } /// /// 캐릭터가 안전 사각형 밖으로 나가지 않도록 오프셋 배율을 구한다 (PD #721 ②). /// 오프셋(룩어헤드·포커스)이 뷰포트 좌표를 거의 선형으로 밀기 때문에, 배율 0 과 1 두 지점만 재고 /// 경계에 닿는 배율을 바로 푼다(반복 캐스팅 불필요). /// private float SolveOffsetScale() { if (settings == null || !settings.viewportClampEnabled) return 1f; var m = Mode; if (m == null || m.kind == CameraKind.FirstPerson || _cam == null) return 1f; Vector3 p0, p1; Quaternion r0, r1; if (!ComputeDesired(0f, out p0, out r0)) return 1f; if (!ComputeDesired(1f, out p1, out r1)) return 1f; // PD 긴급 피드백 ② — 머리·발 두 점 모두 안전 사각형 안에 들어와야 한다 Vector3 head = HeadPoint(m); Vector3 feet = target.position; Vector2 lo = settings.safeViewportMin, hi = settings.safeViewportMax; float k = 1f; for (int i = 0; i < 2; i++) { Vector3 p = i == 0 ? head : feet; Vector2 v0, v1; if (!ViewportOf(p0, r0, p, out v0)) return 0f; if (!ViewportOf(p1, r1, p, out v1)) return 0f; k = Mathf.Min(k, AxisLimit(v0.x, v1.x, lo.x, hi.x)); k = Mathf.Min(k, AxisLimit(v0.y, v1.y, lo.y, hi.y)); } return Mathf.Clamp01(k); } /// 배율 0 일 때 a, 1 일 때 b 로 가는 좌표가 [lo, hi] 를 벗어나지 않는 최대 배율. private static float AxisLimit(float a, float b, float lo, float hi) { if (b >= lo && b <= hi) return 1f; // 배율 1 이어도 안전 — 줄일 필요 없다 float d = b - a; if (Mathf.Abs(d) < 0.000001f) return 1f; float bound = b > hi ? hi : lo; return Mathf.Clamp01((bound - a) / d); } private void LateUpdate() { float dt = Time.deltaTime; Vector3 prevPos = transform.position; float prevYaw = transform.eulerAngles.y; UpdateCharacterSpeed(dt); UpdatePivot(dt); UpdateAnchor(dt); UpdateFollowYaw(dt); UpdateLookAhead(dt); UpdatePitch(dt); UpdateOcclusionPitch(dt); UpdateAvoidDistance(dt); _offsetScale = SolveOffsetScale(); Vector3 pos; Quaternion rot; if (!ComputeDesired(_offsetScale, out pos, out rot)) return; var m = Mode; if (_blendTimer > 0f) { // PD #709 ③-d — 모드 전환 시 위치·각도·화각을 시간에 걸쳐 잇는다(즉시 점프 금지) float total = settings != null ? Mathf.Max(settings.modeBlendSeconds, 0.0001f) : 0.6f; _blendTimer -= dt; float k = Mathf.Clamp01(1f - (_blendTimer / total)); k = k * k * (3f - 2f * k); // smoothstep — 시작·끝을 부드럽게 transform.position = Vector3.Lerp(_blendFromPos, pos, k); // PD #724 — 전환 중 회전은 Slerp 대신 **주시점을 보는 회전**으로 잇는다. // 위치와 회전을 따로 보간하면 중간에 캐릭터가 카메라 뒤로 빠진다(#722 실측: 모드전환 화면안 86.6% · 뷰포트 x -322). // 시작점의 화면 중앙 주시점과 목표 자세의 화면 중앙 주시점을 같은 k 로 섞고, 지금 위치에서 그 점을 본다. // k=0 이면 전환 직전 회전, k=1 이면 목표 회전과 정확히 같다(연속). Vector3 aimTo = pos + rot * Vector3.forward * AimDistance(); Vector3 aimDir = Vector3.Lerp(_blendFromAim, aimTo, k) - transform.position; transform.rotation = aimDir.sqrMagnitude > 0.0001f ? Quaternion.LookRotation(aimDir, Vector3.up) : Quaternion.Slerp(_blendFromRot, rot, k); if (_cam != null && m != null && m.fieldOfView > 0f) _cam.fieldOfView = Mathf.Lerp(_blendFromFov, m.fieldOfView, k); if (_blendTimer <= 0f) { _blendTimer = 0f; _posVelocity = Vector3.zero; } } else { // PD #722 ③ — 궤도 모드는 위치를 **다시** 감쇠하지 않는다. // 감쇠는 이미 궤도 중심(UpdatePivot)과 요(UpdateFollowYaw)에 걸려 있다. // 여기서 월드 좌표를 또 SmoothDamp 하면 회전 중 원호의 현을 가로질러 반지름(카메라-캐릭터 거리)이 줄어든다. bool orbit = m != null && m.kind == CameraKind.ThirdPersonFollow; float smooth = m != null ? m.positionSmoothTime : 0.15f; Vector3 next = orbit ? pos : (smooth > 0.001f ? Vector3.SmoothDamp(transform.position, pos, ref _posVelocity, smooth) : pos); // PD #721 ③ — 순간이동 방지 상한. 캐릭터 속도 배수 + 하한이라 이동 중 카메라가 멈추지는 않는다. // (구 동작의 "캐릭터 속도 × 1.8" 추종 상한과 다르다. 그건 PD #719 ② 로 제거했고, 이건 스파이크만 자른다) // PD #724 — **궤도 모드(모드 1)에는 걸지 않는다.** 위치만 눌리고 회전은 그대로 적용돼 카메라가 옛 자리에서 새 방향을 // 보는 프레임이 생겼다(#722 실측: 유지-하 화면안 20.8%). 궤도 모드의 속도 제한은 요 각속도 상한(followYawMaxSpeed)이 맡고, // 위치는 항상 궤도 위·캐릭터는 항상 앵커 위치에 놓인다. 모드 2 는 그대로다(PD "이대로 픽스"). if (!orbit && settings != null && dt > 0f && settings.cameraSpeedSpikeFactor > 0f) { float cap = Mathf.Max(_charSpeed * settings.cameraSpeedSpikeFactor, settings.cameraSpeedSpikeFloor); Vector3 step = next - transform.position; float maxStep = cap * dt; if (step.magnitude > maxStep && maxStep > 0f) { next = transform.position + step.normalized * maxStep; _posVelocity = Vector3.ClampMagnitude(_posVelocity, cap); } } transform.position = next; transform.rotation = rot; // 각도는 요 추종 결과를 그대로 적용(모드 2 는 고정각) } // 검증용 — 프레임당 변화량 실측 _lastPosDelta = (transform.position - prevPos).magnitude; _lastYawDelta = Mathf.Abs(Mathf.DeltaAngle(prevYaw, transform.eulerAngles.y)); if (_lastPosDelta > _maxPosDelta) _maxPosDelta = _lastPosDelta; if (_lastYawDelta > _maxYawDelta) _maxYawDelta = _lastYawDelta; } /// 카메라를 즉시 목표 위치로 스냅한다(첫 프레임 튐 방지). public void SnapToTarget() { ApplyLens(); _anchorY = -1f; // 즉시 목표 앵커로 스냅 UpdateAnchor(0f); _followYawInit = false; // 즉시 이동 방향으로 스냅 _blendTimer = 0f; _lookAhead = Vector3.zero; _lookAheadVel = Vector3.zero; _lookAheadDir = _charVelocityDir; _lookAheadDirVel = Vector3.zero; _smoothPivotInit = false; _smoothPivotVel = Vector3.zero; UpdatePivot(0f); _pitch = -1f; _pitchVel = 0f; _occlusionPitch = 0f; _occlusionPitchVel = 0f; _avoidDistance = -1f; _avoidDistanceVel = 0f; _offsetScale = 1f; _padLatchInit = false; UpdateFollowYaw(0f); UpdatePitch(0f); UpdateAvoidDistance(0f); Vector3 pos; Quaternion rot; if (!ComputeDesired(1f, out pos, out rot)) return; transform.position = pos; transform.rotation = rot; _posVelocity = Vector3.zero; } #if UNITY_EDITOR private void OnValidate() { if (!Application.isPlaying && target != null) { _cam = GetComponent(); SnapToTarget(); } } #endif } }