From c0c2cc3348d33c8bd847a2599a95ec283820a1d3 Mon Sep 17 00:00:00 2001 From: swrring Date: Wed, 9 Sep 2026 10:59:49 +0900 Subject: [PATCH] =?UTF-8?q?[WL-811o]=20=EC=B9=B4=EB=A9=94=EB=9D=BC=20?= =?UTF-8?q?=EA=B0=80=EB=A6=BC=20=ED=95=B4=EC=86=8C:=20=EA=B0=81=EB=8F=84/?= =?UTF-8?q?=EA=B1=B0=EB=A6=AC=20=EA=B0=92=20=C2=B7=20=EA=B0=80=EB=A6=BC=20?= =?UTF-8?q?=EC=86=8C=ED=92=88=20=EC=88=A8=EA=B9=80=20=C2=B7=20=EC=9D=B4?= =?UTF-8?q?=ED=8E=99=ED=8A=B8=20=EC=8A=A4=EC=BC=80=EC=9D=BC=20(#813)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- AgentScripts/WL811o_Probe.cs | 763 ++++++++++++++++++ Assets/WL/Camera/CameraFramingOverride.cs | 329 ++++++++ .../WL/Camera/CameraFramingOverride.cs.meta | 2 + Assets/WL/Camera/OccluderFade.cs | 487 +++++++++++ Assets/WL/Camera/OccluderFade.cs.meta | 2 + .../WL/WLCameraFramingSettings.asset | 41 + .../WL/WLCameraFramingSettings.asset.meta | 8 + Assets/WL/Camera/WLCameraFramingSettings.cs | 180 +++++ .../WL/Camera/WLCameraFramingSettings.cs.meta | 2 + .../WL/WLSkillSpectacleSettings.asset | 4 +- 10 files changed, 1816 insertions(+), 2 deletions(-) create mode 100644 AgentScripts/WL811o_Probe.cs create mode 100644 Assets/WL/Camera/CameraFramingOverride.cs create mode 100644 Assets/WL/Camera/CameraFramingOverride.cs.meta create mode 100644 Assets/WL/Camera/OccluderFade.cs create mode 100644 Assets/WL/Camera/OccluderFade.cs.meta create mode 100644 Assets/WL/Camera/Resources/WL/WLCameraFramingSettings.asset create mode 100644 Assets/WL/Camera/Resources/WL/WLCameraFramingSettings.asset.meta create mode 100644 Assets/WL/Camera/WLCameraFramingSettings.cs create mode 100644 Assets/WL/Camera/WLCameraFramingSettings.cs.meta diff --git a/AgentScripts/WL811o_Probe.cs b/AgentScripts/WL811o_Probe.cs new file mode 100644 index 000000000..c08ab8ce3 --- /dev/null +++ b/AgentScripts/WL811o_Probe.cs @@ -0,0 +1,763 @@ +// PD 지시 #813 · 발주서 WL-811o — 카메라 가림 해소(구도 값 · 가림 소품 숨김 · 이펙트 스케일) 검증 프로브 +// (에디트 모드 전용 · Play 0 · 로그인 0 · 에셋 무수정) +// +// 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기) +// unity command run_script --file AgentScripts/WL811o_Probe.cs --entry WL811o_Probe.Edit --timeout 1800 +// +// 무엇을 재는가 +// A. 맵 — WL_Nature.prefab 인스턴스화 · 렌더러/콜라이더 수 · 레이어 분포 · 소품 셰이더 큐(숨김 방식 결정 근거) +// B. 앵커 — 존1~4 스포너(813001~813004) 월드 좌표 + 아레나 5지점((139,0.8,−20) 중심 + 반경 10 의 4점) · 지면 스냅 +// C. 가림률 — 9지점 × 8방향(카메라 yaw) × 9타겟(PC + 반경 4 m 8방향) = 648 선분 +// ① 기하 가림(렌더러 바운드 · 콜라이더 없어도 잰다 = 화면에서 실제로 가리는 것) +// ② 물리 가림(SphereCast · OccluderFade 가 실제로 잡을 수 있는 것) +// 「전(원본 arr_cCamData[0] = 65° · 3 m · 2)」 vs 「후(SO 값)」 +// D. 스윕 — cameraHeight × distance 격자의 기하 가림률(값 선택 근거) +// E. 숨김 — 가짜 가림(큐브)으로 OccluderFade 숨김/복귀 · 동시 상한 · 제외 규칙(바닥·Actor·스킨드) +// F. GC — EvaluateSegment 100회의 GC.GetAllocatedBytesForCurrentThread() 증가분(0 B 목표) +// G. C8 — RuntimeDisabled = true → 구도 미적용 · 숨김 전부 원복(원본 카메라·소품 100 %) +// H. 이펙트 — WLSkillSpectacleSettings 슬롯 4 본체 스케일 표(811i 튜닝 후보 적용 확인) +// +// 에디트 모드 주의 +// · Time.unscaledTime 이 한 호출 안에서 멈춰 있다 → OccluderFade.TimeOverride 로 시뮬 시계를 민다(811gh·813s 방식). +// · 맵 인스턴스는 HideAndDontSave + DestroyImmediate. 어떤 에셋도 쓰지 않는다(씬 dirty 전/후 보고). +public static class WL811o_Probe +{ + const string kOut = "AgentScripts/staging/WL811o/EDIT_PROBE.txt"; + const string kMap = "Assets/Res_Addr/Map/WL_Nature.prefab"; + const string kSpectacle = "Assets/WL/Combat/Settings/Resources/WL/WLSkillSpectacleSettings.asset"; + + // 원본 실측 (Assets/ResWork/Prefabs/Ingame/Main Camera.prefab · arr_cCamData[0]) + const float kOrgHeight = 65f, kOrgDist = 3f, kOrgUp = 2f; + const float kLookY = 1.06f; // RealCamera.m_LookAt.y 실측 + const float kMapYaw = 233f; // WL_Nature MapZoneData.CamRot_Portal 실측 + const float kRadius = 4f; // 발주서 §1-1 「PC 주변 반경 4 m」 + const float kTargetY = 1.0f; // 반경 4 m 지점의 검사 높이(가슴 높이) + + static System.Collections.Generic.List s_log = new System.Collections.Generic.List(); + static bool s_hooked; + static void OnLog(string cond, string stack, UnityEngine.LogType t) + { + if (t == UnityEngine.LogType.Error || t == UnityEngine.LogType.Exception || t == UnityEngine.LogType.Assert) + s_log.Add(t + " | " + cond); + } + static void Hook() { if (!s_hooked) { UnityEngine.Application.logMessageReceived += OnLog; s_hooked = true; } } + static void Unhook() { if (s_hooked) { UnityEngine.Application.logMessageReceived -= OnLog; s_hooked = false; } } + + static UnityEngine.GameObject s_map; + static System.Collections.Generic.List s_props = new System.Collections.Generic.List(); + static System.Collections.Generic.List s_bounds = new System.Collections.Generic.List(); + + struct Anchor { public string name; public UnityEngine.Vector3 pos; } + static System.Collections.Generic.List s_anchors = new System.Collections.Generic.List(); + + public static string Edit() + { + var sb = new System.Text.StringBuilder(); + sb.Append("# WL-811o 프로브 (에디트 모드 · Play 0 · 로그인 0)\n"); + sb.Append("unity=").Append(UnityEngine.Application.unityVersion) + .Append(" isPlaying=").Append(UnityEngine.Application.isPlaying) + .Append(" utc=").Append(System.DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss")).Append("\n"); + + bool dirtyBefore = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().isDirty; + sb.Append("scene=").Append(UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().name) + .Append(" dirtyBefore=").Append(dirtyBefore).Append("\n\n"); + + // 에셋을 손으로 고친 직후에도 현재 값을 읽게 한다(정적 캐시 + 임포트) + UnityEditor.AssetDatabase.Refresh(); + WL.CameraFraming.WLCameraFramingSettings.ClearCache(); + + Hook(); s_log.Clear(); + try + { + SectionA(sb); + SectionB(sb); + SectionC(sb); + SectionD(sb); + SectionE(sb); + SectionE2(sb); + SectionF(sb); + SectionG(sb); + SectionH(sb); + } + catch (System.Exception ex) { sb.Append("\n🔴 예외: ").Append(ex.ToString()).Append("\n"); } + finally + { + Cleanup(); + Unhook(); + } + + bool dirtyAfter = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().isDirty; + sb.Append("\n── 로그(Error/Exception) ").Append(s_log.Count).Append("건 ──\n"); + for (int i = 0; i < s_log.Count && i < 20; i++) sb.Append(" ").Append(s_log[i]).Append("\n"); + sb.Append("scene dirtyAfter=").Append(dirtyAfter).Append(" (before=").Append(dirtyBefore).Append(")\n"); + + Write(sb.ToString()); + return sb.ToString(); + } + + // ───────────────────────────────────────────────────────────────────────── + // A. 맵 · 소품 + // ───────────────────────────────────────────────────────────────────────── + static void SectionA(System.Text.StringBuilder sb) + { + sb.Append("── A. 맵 · 소품 실측 ──\n"); + var asset = UnityEditor.AssetDatabase.LoadAssetAtPath(kMap); + if (asset == null) { sb.Append("🔴 ").Append(kMap).Append(" 로드 실패\n"); return; } + + s_map = UnityEngine.Object.Instantiate(asset); + s_map.name = "[WL811o] MapProbe"; + s_map.hideFlags = UnityEngine.HideFlags.HideAndDontSave; + UnityEngine.Physics.SyncTransforms(); + + var rends = s_map.GetComponentsInChildren(true); + var cols = s_map.GetComponentsInChildren(true); + sb.Append("맵 인스턴스: 렌더러 ").Append(rends.Length).Append(" · 콜라이더 ").Append(cols.Length).Append("\n"); + + // 레이어 분포(콜라이더 기준 — SphereCast 가 볼 수 있는 것) + var layerCount = new int[32]; + for (int i = 0; i < cols.Length; i++) layerCount[cols[i].gameObject.layer]++; + sb.Append("콜라이더 레이어: "); + for (int i = 0; i < 32; i++) if (layerCount[i] > 0) + sb.Append(i).Append('(').Append(UnityEngine.LayerMask.LayerToName(i)).Append(")=").Append(layerCount[i]).Append(' '); + sb.Append("\n"); + + // 「가릴 수 있는」 소품 후보 = 높이 0.8 m 이상 · 최대 변 30 m 이하 · 이름 제외 규칙 밖 + s_props.Clear(); s_bounds.Clear(); + int tooLow = 0, tooBig = 0, byName = 0, noMesh = 0; + for (int i = 0; i < rends.Length; i++) + { + var r = rends[i]; + if (r == null) continue; + if (r is UnityEngine.ParticleSystemRenderer || r is UnityEngine.TrailRenderer || r is UnityEngine.LineRenderer) { noMesh++; continue; } + var b = r.bounds; + if (b.size.y < 0.8f) { tooLow++; continue; } + float side = UnityEngine.Mathf.Max(b.size.x, UnityEngine.Mathf.Max(b.size.y, b.size.z)); + if (side > 30f) { tooBig++; continue; } + if (Excluded(r.transform.name) || (r.transform.parent != null && Excluded(r.transform.parent.name))) { byName++; continue; } + s_props.Add(r); s_bounds.Add(b); + } + sb.Append("가림 후보 렌더러 ").Append(s_props.Count) + .Append(" (제외: 높이<0.8 ").Append(tooLow).Append(" · 최대변>30 ").Append(tooBig) + .Append(" · 이름 ").Append(byName).Append(" · 이펙트 ").Append(noMesh).Append(")\n"); + + // 후보 중 콜라이더를 가진 비율 = OccluderFade(SphereCast)가 실제로 잡을 수 있는 비율 + int withCol = 0; + for (int i = 0; i < s_props.Count; i++) + { + var t = s_props[i].transform; + if (t.GetComponent() != null || t.GetComponentInParent() != null) withCol++; + } + sb.Append("후보 중 콜라이더 보유 ").Append(withCol).Append(" / ").Append(s_props.Count) + .Append(" (").Append(Pct(withCol, s_props.Count)).Append(")\n"); + + // 셰이더 큐 — 숨김 방식(Auto) 결정 근거 + int opaque = 0, transparent = 0; + string sample = ""; + for (int i = 0; i < s_props.Count; i++) + { + var m = s_props[i].sharedMaterial; + if (m == null) continue; + if (m.renderQueue >= 3000) transparent++; else opaque++; + if (sample.Length == 0 && m.shader != null) + sample = s_props[i].transform.name + " / " + m.shader.name + " / queue " + m.renderQueue; + } + sb.Append("소품 머티리얼: 불투명 큐 ").Append(opaque).Append(" · 투명 큐 ").Append(transparent) + .Append(" → hideMode Auto 는 ").Append(transparent > opaque ? "MpbAlpha" : "ShadowsOnly").Append(" 로 내려간다\n"); + sb.Append("샘플: ").Append(sample.Length > 0 ? sample : "(없음)").Append("\n"); + + // OccluderFade 규칙(레이어·이름·크기·Actor)을 실제로 통과하는 수 = 런타임 스캔 목록 크기 예상치 + var stA = WL.CameraFraming.WLCameraFramingSettings.Instance; + if (stA != null) + { + int elig = 0; + for (int i = 0; i < rends.Length; i++) + if (rends[i] is UnityEngine.MeshRenderer && WL.CameraFraming.OccluderFade.Eligible(stA, rends[i], true)) elig++; + sb.Append("OccluderFade 규칙 통과(런타임 스캔 목록 예상) ").Append(elig).Append(" / MeshRenderer ").Append(rends.Length).Append("\n"); + WL.CameraFraming.OccluderFade.ResetDiagnostics(); + } + sb.Append("\n"); + } + + static bool Excluded(string name) + { + if (string.IsNullOrEmpty(name)) return false; + string[] toks = { "terrain", "ground", "floor", "water", "river", "navmesh", "plane", "road", "path" }; + for (int i = 0; i < toks.Length; i++) + if (name.IndexOf(toks[i], System.StringComparison.OrdinalIgnoreCase) >= 0) return true; + return false; + } + + // ───────────────────────────────────────────────────────────────────────── + // B. 앵커 + // ───────────────────────────────────────────────────────────────────────── + static void SectionB(System.Text.StringBuilder sb) + { + sb.Append("── B. 앵커(존1~4 + 아레나 5지점) ──\n"); + s_anchors.Clear(); + if (s_map == null) { sb.Append("🔴 맵 인스턴스 없음\n\n"); return; } + + // 시작 지점 — 🔴 QA 캡처 01_enter_hud(가림 ≈40 %)가 찍힌 자리 + var portal = FindByName(s_map.transform, "Portal_Start"); + if (portal != null) Add("시작(Portal_Start)", portal.position); + + int[] zones = { 813001, 813002, 813003, 813004 }; + var mgrs = s_map.GetComponentsInChildren(true); + for (int z = 0; z < zones.Length; z++) + { + for (int i = 0; i < mgrs.Length; i++) + { + if (mgrs[i].SpawnerID != zones[z]) continue; + Add("존" + (z + 1) + "(" + zones[z] + ")", mgrs[i].transform.position); + break; + } + } + + // 아레나 = WLBossArenaSettings 실측(중심 (139,0.8,−20) · 반경 20) → 중심 + 반경 10 의 4점 + var arena = new UnityEngine.Vector3(139f, 0.8f, -20f); + Add("아레나-중심", arena); + Add("아레나-N", arena + new UnityEngine.Vector3(0f, 0f, 10f)); + Add("아레나-E", arena + new UnityEngine.Vector3(10f, 0f, 0f)); + Add("아레나-S", arena + new UnityEngine.Vector3(0f, 0f, -10f)); + Add("아레나-W", arena + new UnityEngine.Vector3(-10f, 0f, 0f)); + + for (int i = 0; i < s_anchors.Count; i++) + sb.Append(" ").Append(s_anchors[i].name).Append(" = ").Append(V(s_anchors[i].pos)).Append("\n"); + sb.Append("앵커 ").Append(s_anchors.Count).Append("개 (지면 스냅: 위 50 m 에서 아래로 레이캐스트 · 실패 시 원좌표)\n\n"); + } + + static UnityEngine.Transform FindByName(UnityEngine.Transform root, string name) + { + var all = root.GetComponentsInChildren(true); + for (int i = 0; i < all.Length; i++) if (all[i].name == name) return all[i]; + return null; + } + + /// 지면 스냅 — 소품 꼭대기·메시 뒷면에 얹히지 않게 **원 좌표 y 에 가장 가까운** 히트를 쓴다(±5 m 안에서만). + static void Add(string name, UnityEngine.Vector3 p) + { + var from = new UnityEngine.Vector3(p.x, p.y + 50f, p.z); + var hits = UnityEngine.Physics.RaycastAll(from, UnityEngine.Vector3.down, 200f, ~0, UnityEngine.QueryTriggerInteraction.Ignore); + float best = 0f, bestD = float.MaxValue; + for (int i = 0; i < hits.Length; i++) + { + float d = UnityEngine.Mathf.Abs(hits[i].point.y - p.y); + if (d < bestD) { bestD = d; best = hits[i].point.y; } + } + if (bestD <= 5f) p.y = best; + var a = new Anchor(); a.name = name; a.pos = p; + s_anchors.Add(a); + } + + // ───────────────────────────────────────────────────────────────────────── + // C. 가림률 전/후 + // ───────────────────────────────────────────────────────────────────────── + static void SectionC(System.Text.StringBuilder sb) + { + sb.Append("── C. 가림률 (9지점 × 8방향 × 9타겟 = ").Append(s_anchors.Count * 8 * 9).Append(" 선분) ──\n"); + var st = WL.CameraFraming.WLCameraFramingSettings.Instance; + if (st == null) { sb.Append("🔴 WLCameraFramingSettings.asset 없음\n\n"); return; } + + float nh = st.cameraHeight, nd = st.distance, nu = st.distanceUp >= 0f ? st.distanceUp : kOrgUp; + sb.Append("전(원본 arr_cCamData[0]) h=").Append(kOrgHeight).Append(" d=").Append(kOrgDist).Append(" up=").Append(kOrgUp) + .Append(" · 내림각 ").Append(Down(kOrgHeight, kOrgDist, kOrgUp).ToString("0.0")).Append("°\n"); + sb.Append("후(SO 값) h=").Append(nh).Append(" d=").Append(nd).Append(" up=").Append(nu) + .Append(" · 내림각 ").Append(Down(nh, nd, nu).ToString("0.0")).Append("°\n\n"); + + sb.Append("| 지점 | 전(원본 값) | 후(값만) | 후(값+숨김) | 참고: 물리 전/후 |\n|---|---|---|---|---|\n"); + int gb = 0, ga = 0, gh = 0, pb = 0, pa = 0, tot = 0; + s_maxBlockers = 0; + int pcT0 = 0, pcG0 = 0, pcT1 = 0, pcG1 = 0, pcA1 = 0; + for (int i = 0; i < s_anchors.Count; i++) + { + int t1, g1, g2, h1, h2, p1, p2; + s_pcTotal = s_pcGeo = s_pcAfter = 0; + Measure(s_anchors[i].pos, kOrgHeight, kOrgDist, kOrgUp, out t1, out g1, out h1, out p1); + pcT0 += s_pcTotal; pcG0 += s_pcGeo; + s_pcTotal = s_pcGeo = s_pcAfter = 0; + Measure(s_anchors[i].pos, nh, nd, nu, out t1, out g2, out h2, out p2); + pcT1 += s_pcTotal; pcG1 += s_pcGeo; pcA1 += s_pcAfter; + gb += g1; ga += g2; gh += h2; pb += p1; pa += p2; tot += t1; + sb.Append("| ").Append(s_anchors[i].name) + .Append(" | ").Append(Pct(g1, t1)).Append(" | ").Append(Pct(g2, t1)).Append(" | ").Append(Pct(h2, t1)) + .Append(" | ").Append(Pct(p1, t1)).Append(" → ").Append(Pct(p2, t1)).Append(" |\n"); + } + sb.Append("| **합계** | **").Append(Pct(gb, tot)).Append("** | **").Append(Pct(ga, tot)) + .Append("** | **").Append(Pct(gh, tot)).Append("** | ").Append(Pct(pb, tot)).Append(" → ").Append(Pct(pa, tot)).Append(" |\n"); + sb.Append("가림 = 렌더러 바운드 교차(콜라이더 없어도 화면에서 가린다) · 「값+숨김」 = 막은 소품이 전부 OccluderFade 규칙을 통과하고 동시 상한 이내면 해소된 것으로 센다\n"); + sb.Append("물리 = SphereCast(콜라이더만) — 지형 경사면 포함이라 값과 무관하게 오르내린다\n"); + sb.Append("🔴 **PC 중심 선분만**(런타임 OccluderFade 가 실제로 쏘는 그 선분) 전 ").Append(Pct(pcG0, pcT0)) + .Append(" → 값만 ").Append(Pct(pcG1, pcT1)).Append(" → 값+숨김 ").Append(Pct(pcA1, pcT1)).Append("\n"); + sb.Append("한 선분을 막은 소품 최대 수 ").Append(s_maxBlockers).Append("개(동시 상한 ").Append(st.maxConcurrent).Append(")\n"); + sb.Append("⚠ 반경 4 m 타겟은 OccluderFade 가 직접 쏘지 않는다 — 같은 소품이 PC 선분도 막을 때만 함께 해소된다(추정)\n\n"); + } + + static int s_pcTotal, s_pcGeo, s_pcAfter, s_maxBlockers; // PC 중심 선분만(= OccluderFade 가 실제로 쏘는 선분) + + /// 한 지점의 8방향 × 9타겟 선분 중 몇 개가 가리는지. afterHide = OccluderFade 로도 안 풀리는 수. + static void Measure(UnityEngine.Vector3 foot, float h, float d, float up, out int total, out int geo, out int afterHide, out int phys) + { + total = 0; geo = 0; afterHide = 0; phys = 0; + var st = WL.CameraFraming.WLCameraFramingSettings.Instance; + int mask = st != null ? st.occluderMask.value : ((1 << 0) | (1 << 6)); + float radius = st != null ? st.sphereRadius : 0.6f; + + // 이 지점 주변 후보만 추린다(성능) + var near = new System.Collections.Generic.List(); + for (int i = 0; i < s_bounds.Count; i++) + { + var c = s_bounds[i].center; + float dx = c.x - foot.x, dz = c.z - foot.z; + if (dx * dx + dz * dz < 400f) near.Add(i); // 20 m 이내 + } + + for (int y = 0; y < 8; y++) + { + float yaw = kMapYaw + y * 45f; + var cam = WL.CameraFraming.CameraFramingOverride.CameraPosition(foot, yaw, h, d, up); + for (int t = 0; t < 9; t++) + { + UnityEngine.Vector3 aim; + if (t == 0) aim = foot + new UnityEngine.Vector3(0f, kLookY, 0f); + else + { + float a = (t - 1) * 45f * UnityEngine.Mathf.Deg2Rad; + aim = foot + new UnityEngine.Vector3(UnityEngine.Mathf.Sin(a) * kRadius, kTargetY, UnityEngine.Mathf.Cos(a) * kRadius); + } + total++; + + var dir = aim - cam; + float len = dir.magnitude; + if (len < 0.001f) continue; + dir /= len; + var ray = new UnityEngine.Ray(cam, dir); + + // ① 기하 — 렌더러 바운드 교차 (선분 끝 0.5 m 는 PC 자신이라 제외) + int blockers = 0, hideable = 0; + for (int k = 0; k < near.Count; k++) + { + float hitDist; + var b = s_bounds[near[k]]; + b.Expand(radius * 2f); + // 음수 거리 = 카메라가 그 소품 안에 있다 = 확실한 가림(버리지 않는다 · OccluderFade 와 같은 판정) + if (!b.IntersectRay(ray, out hitDist) || hitDist >= len - 0.5f) continue; + blockers++; + if (WL.CameraFraming.OccluderFade.Eligible(st, s_props[near[k]], true)) hideable++; + } + int capN = st != null ? st.maxConcurrent : 4; + bool solvedRay = blockers > 0 && hideable == blockers && blockers <= capN; + if (blockers > 0) + { + geo++; + if (!solvedRay) afterHide++; + if (blockers > s_maxBlockers) s_maxBlockers = blockers; + } + if (t == 0) // PC 중심 선분 = 런타임 OccluderFade 가 실제로 쏘는 그 선분 + { + s_pcTotal++; + if (blockers > 0) { s_pcGeo++; if (!solvedRay) s_pcAfter++; } + } + + // ② 물리 — SphereCast(런타임 OccluderFade 와 같은 조건) + UnityEngine.RaycastHit rh; + if (UnityEngine.Physics.SphereCast(cam + dir * 0.2f, radius, dir, out rh, UnityEngine.Mathf.Max(0f, len - 0.7f), + mask, UnityEngine.QueryTriggerInteraction.Ignore)) phys++; + } + } + } + + // ───────────────────────────────────────────────────────────────────────── + // D. 값 스윕 + // ───────────────────────────────────────────────────────────────────────── + static void SectionD(System.Text.StringBuilder sb) + { + sb.Append("── D. 값 스윕 (기하 가림률 · 전 지점 합계) ──\n"); + float[] hs = { 65f, 70f, 74f, 76f, 78f, 80f }; + float[] ds = { 3.0f, 3.4f }; + float[] ups = { 2.0f, 2.4f, 2.8f }; + sb.Append("| h | d | up 2.0 | up 2.4 | up 2.8 | 내림각(up 2.4) | PC선분만(d·up 최적) |\n|---|---|---|---|---|---|---|\n"); + float bestPct = 999f; string bestSet = ""; + for (int i = 0; i < hs.Length; i++) + { + for (int j = 0; j < ds.Length; j++) + { + sb.Append("| ").Append(hs[i]).Append(" | ").Append(ds[j]); + int pcT = 0, pcG = 0; + for (int u = 0; u < ups.Length; u++) + { + int tot = 0, geo = 0, t1, g1, h1, p1; + s_pcTotal = s_pcGeo = s_pcAfter = 0; + for (int a = 0; a < s_anchors.Count; a++) + { + Measure(s_anchors[a].pos, hs[i], ds[j], ups[u], out t1, out g1, out h1, out p1); + tot += t1; geo += g1; + } + if (u == 1) { pcT = s_pcTotal; pcG = s_pcGeo; } + sb.Append(" | ").Append(Pct(geo, tot)); + float pct = tot > 0 ? 100f * geo / tot : 999f; + if (pct < bestPct) { bestPct = pct; bestSet = "h " + hs[i] + " · d " + ds[j] + " · up " + ups[u]; } + } + sb.Append(" | ").Append(Down(hs[i], ds[j], 2.4f).ToString("0.0")).Append("°") + .Append(" | ").Append(Pct(pcG, pcT)).Append(" |\n"); + } + } + sb.Append("최소 = ").Append(bestSet).Append(" (").Append(bestPct.ToString("0.0")).Append("%) · 원본은 h 65 · d 3.0 · up 2.0\n\n"); + } + + // ───────────────────────────────────────────────────────────────────────── + // E. OccluderFade 가짜 가림 + // ───────────────────────────────────────────────────────────────────────── + static void SectionE(System.Text.StringBuilder sb) + { + sb.Append("── E. OccluderFade 가짜 가림 → 숨김/복귀 ──\n"); + var st = WL.CameraFraming.WLCameraFramingSettings.Instance; + if (st == null) { sb.Append("🔴 SO 없음\n\n"); return; } + + // 🔴 hideFlags 를 주지 않는다 — FindObjectsByType 이 DontSave 오브젝트를 빼기 때문에 + // 바운드 스캔 경로(ⓑ)를 검증하려면 평범한 오브젝트여야 한다. 씬은 저장하지 않는다(끝에 파괴 + dirty 보고). + var root = new UnityEngine.GameObject("[WL811o] FakeOcc"); + var far = new UnityEngine.Vector3(0f, 0f, 3000f); // 맵에서 멀리 떨어진 빈 공간 + var cam = far + new UnityEngine.Vector3(0f, 4.5f, -5.4f); + var aim = far + new UnityEngine.Vector3(0f, 1.06f, 0f); + + try + { + WL.CameraFraming.WLCameraFramingSettings.RuntimeDisabled = false; + WL.CameraFraming.OccluderFade.ResetDiagnostics(); + WL.CameraFraming.OccluderFade.TimeOverride = 100f; + + // 큐브 6개를 선분 위에 나란히 — 동시 상한(기본 4) 검증 + var cubes = new UnityEngine.GameObject[6]; + for (int i = 0; i < cubes.Length; i++) + { + var c = UnityEngine.GameObject.CreatePrimitive(UnityEngine.PrimitiveType.Cube); + c.name = "FakeRock_" + i; + c.transform.SetParent(root.transform, true); + c.transform.position = UnityEngine.Vector3.Lerp(cam, aim, 0.25f + i * 0.08f) + new UnityEngine.Vector3(i * 0.03f, 0f, 0f); + c.transform.localScale = new UnityEngine.Vector3(1.4f, 2.2f, 1.4f); + c.layer = 0; + cubes[i] = c; + } + // 🔴 콜라이더 없는 소품 — WL_Nature 소품의 87.6 % 가 이 상태다. 바운드 스캔(ⓑ)만이 잡을 수 있다. + UnityEngine.Object.DestroyImmediate(cubes[0].GetComponent()); + + // 제외 규칙 검증용 — 바닥 이름 + var floor = UnityEngine.GameObject.CreatePrimitive(UnityEngine.PrimitiveType.Cube); + floor.name = "Ground_Fake"; floor.transform.SetParent(root.transform, true); + floor.transform.position = UnityEngine.Vector3.Lerp(cam, aim, 0.5f); + floor.transform.localScale = new UnityEngine.Vector3(2f, 2f, 2f); floor.layer = 0; + UnityEngine.Physics.SyncTransforms(); + WL.CameraFraming.OccluderFade.ClearScan(); + WL.CameraFraming.OccluderFade.RebuildScan(); + sb.Append("바운드 스캔 목록 ").Append(WL.CameraFraming.OccluderFade.ScanCount) + .Append("개 (FakeRock 6 + Ground_Fake 는 이름 규칙으로 빠진다)\n"); + + sb.Append("hideMode(SO)=").Append(st.hideMode).Append(" · maxConcurrent=").Append(st.maxConcurrent) + .Append(" · sphereRadius=").Append(st.sphereRadius).Append(" · fadeOut=").Append(st.fadeOutSeconds) + .Append(" · restoreDelay=").Append(st.restoreDelaySeconds).Append("\n"); + + // t=100 첫 관측(아직 숨기지 않는다 = 지연) → t=100.25 숨김 + WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim); + int hidden0 = WL.CameraFraming.OccluderFade.ActiveHidden; + WL.CameraFraming.OccluderFade.TimeOverride = 100f + st.fadeOutSeconds + 0.05f; + WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim); + int hidden1 = WL.CameraFraming.OccluderFade.ActiveHidden; + + sb.Append("첫 관측 직후 숨김 ").Append(hidden0).Append("(기대 0 = ").Append(st.fadeOutSeconds).Append(" s 지연)"); + sb.Append(" → 지연 뒤 숨김 ").Append(hidden1).Append("(기대 ").Append(st.maxConcurrent).Append(" = 동시 상한)\n"); + sb.Append("숨긴 것: "); + for (int i = 0; i < 8; i++) + { + var n = WL.CameraFraming.OccluderFade.SlotName(i); + if (n.Length > 0) sb.Append(n).Append(WL.CameraFraming.OccluderFade.SlotHidden(i) ? "(hidden) " : "(대기) "); + } + sb.Append("\n"); + sb.Append("바닥 이름 제외(Ground_Fake 가 숨김 목록에 없어야 한다): ") + .Append(HasSlot("Ground_Fake") ? "🔴 실패" : "PASS").Append("\n"); + + // 실제 상태 확인 + int shadowOnly = 0, off = 0; + for (int i = 0; i < cubes.Length; i++) + { + var r = cubes[i].GetComponent(); + if (r == null) continue; + if (!r.enabled) off++; + else if (r.shadowCastingMode == UnityEngine.Rendering.ShadowCastingMode.ShadowsOnly) shadowOnly++; + } + sb.Append("렌더러 상태: ShadowsOnly ").Append(shadowOnly).Append(" · enabled=false ").Append(off).Append("\n"); + + // 가림 해제 → 복귀 + WL.CameraFraming.OccluderFade.TimeOverride = 200f; + WL.CameraFraming.OccluderFade.EvaluateSegment(cam + new UnityEngine.Vector3(500f, 0f, 0f), aim + new UnityEngine.Vector3(500f, 0f, 0f)); + int hidden2 = WL.CameraFraming.OccluderFade.ActiveHidden; + int restoredOk = 0; + for (int i = 0; i < cubes.Length; i++) + { + var r = cubes[i].GetComponent(); + if (r != null && r.enabled && r.shadowCastingMode == UnityEngine.Rendering.ShadowCastingMode.On) restoredOk++; + } + sb.Append("가림 해제 후 숨김 ").Append(hidden2).Append("(기대 0) · 원상복귀 렌더러 ") + .Append(restoredOk).Append(" / ").Append(cubes.Length).Append("\n"); + + // 콜라이더 없는 소품 단독 검증 — 물리 경로가 슬롯을 다 채우지 못하게 큐브 1개만 둔다 + for (int i = 1; i < cubes.Length; i++) cubes[i].SetActive(false); + UnityEngine.GameObject.DestroyImmediate(floor); + UnityEngine.Physics.SyncTransforms(); + WL.CameraFraming.OccluderFade.ReleaseAll(); + WL.CameraFraming.OccluderFade.ClearScan(); + WL.CameraFraming.OccluderFade.RebuildScan(); + WL.CameraFraming.OccluderFade.TimeOverride = 300f; + WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim); + WL.CameraFraming.OccluderFade.TimeOverride = 300.5f; + WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim); + var r0 = cubes[0].GetComponent(); + bool onlyNoCol = HasSlot("FakeRock_0") && + r0.shadowCastingMode == UnityEngine.Rendering.ShadowCastingMode.ShadowsOnly; + sb.Append("콜라이더 없는 소품 단독(FakeRock_0 · 콜라이더 ") + .Append(cubes[0].GetComponent() == null ? "0" : "1") + .Append("): ").Append(onlyNoCol ? "PASS(바운드 스캔이 잡아 숨겼다)" : "🔴 실패") + .Append(" · 숨김 ").Append(WL.CameraFraming.OccluderFade.ActiveHidden) + .Append(" · shadow=").Append(r0.shadowCastingMode) + .Append(" · boundsScanEnabled=").Append(st.boundsScanEnabled) + .Append(" · 스캔 ").Append(WL.CameraFraming.OccluderFade.ScanCount) + .Append(" · Eligible=").Append(WL.CameraFraming.OccluderFade.Eligible(st, r0, true)).Append("\n"); + sb.Append("카운터: polls=").Append(WL.CameraFraming.OccluderFade.Polls) + .Append(" blocked=").Append(WL.CameraFraming.OccluderFade.Blocked) + .Append(" hidden=").Append(WL.CameraFraming.OccluderFade.HiddenCount) + .Append(" restored=").Append(WL.CameraFraming.OccluderFade.RestoredCount) + .Append(" capSkip=").Append(WL.CameraFraming.OccluderFade.CapSkips) + .Append(" rejected=").Append(WL.CameraFraming.OccluderFade.Rejected).Append("\n\n"); + } + finally + { + WL.CameraFraming.OccluderFade.ReleaseAll(); + WL.CameraFraming.OccluderFade.TimeOverride = -1f; + UnityEngine.Object.DestroyImmediate(root); + } + } + + static bool HasSlot(string name) + { + for (int i = 0; i < 8; i++) if (WL.CameraFraming.OccluderFade.SlotName(i) == name) return true; + return false; + } + + // ───────────────────────────────────────────────────────────────────────── + // F. GC + // ───────────────────────────────────────────────────────────────────────── + static void SectionF(System.Text.StringBuilder sb) + { + sb.Append("── F. GC (EvaluateSegment 100회) ──\n"); + var root = new UnityEngine.GameObject("[WL811o] GcOcc"); // 바운드 스캔까지 재려면 평범한 오브젝트여야 한다 + var far = new UnityEngine.Vector3(0f, 0f, 4000f); + var cam = far + new UnityEngine.Vector3(0f, 4.5f, -5.4f); + var aim = far + new UnityEngine.Vector3(0f, 1.06f, 0f); + try + { + for (int i = 0; i < 3; i++) + { + var c = UnityEngine.GameObject.CreatePrimitive(UnityEngine.PrimitiveType.Cube); + c.name = "GcRock_" + i; c.transform.SetParent(root.transform, true); + c.transform.position = UnityEngine.Vector3.Lerp(cam, aim, 0.3f + i * 0.1f); + c.transform.localScale = new UnityEngine.Vector3(1.4f, 2.2f, 1.4f); c.layer = 0; + } + UnityEngine.Physics.SyncTransforms(); + WL.CameraFraming.OccluderFade.ResetDiagnostics(); + WL.CameraFraming.OccluderFade.TimeOverride = 1000f; + WL.CameraFraming.OccluderFade.ClearScan(); + WL.CameraFraming.OccluderFade.RebuildScan(); // 스캔은 여기서 1회(측정 구간 밖) + + WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim); // 워밍업(토큰 분해·MPB 최초 할당) + WL.CameraFraming.OccluderFade.TimeOverride = 1001f; + WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim); + + long before = System.GC.GetAllocatedBytesForCurrentThread(); + for (int i = 0; i < 100; i++) + { + WL.CameraFraming.OccluderFade.TimeOverride = 1001f + i * 0.01f; + WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim); + } + long after = System.GC.GetAllocatedBytesForCurrentThread(); + sb.Append("증가분 ").Append(after - before).Append(" B (100회 · 목표 0) · 스캔 목록 ") + .Append(WL.CameraFraming.OccluderFade.ScanCount).Append("개 · 측정 중 재구축 ") + .Append(WL.CameraFraming.OccluderFade.ScanBuilds - 1).Append("회(기대 0)\n\n"); + } + finally + { + WL.CameraFraming.OccluderFade.ReleaseAll(); + WL.CameraFraming.OccluderFade.TimeOverride = -1f; + UnityEngine.Object.DestroyImmediate(root); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // E2. 구도 오버라이드 적용 · 복원 (가짜 RealCamera · 원본 프리팹 무수정) + // ───────────────────────────────────────────────────────────────────────── + static void SectionE2(System.Text.StringBuilder sb) + { + sb.Append("── E2. 구도 오버라이드 적용/복원 ──\n"); + var st = WL.CameraFraming.WLCameraFramingSettings.Instance; + if (st == null) { sb.Append("🔴 SO 없음\n\n"); return; } + + var go = new UnityEngine.GameObject("[WL811o] FakeRealCamera"); + go.hideFlags = UnityEngine.HideFlags.HideAndDontSave; + try + { + // 🔴 에디트 모드에서는 MonoBehaviour Awake 가 돌지 않는다([ExecuteAlways] 아님) → 원본 초기화 부작용 0 + var cam = go.AddComponent(); + cam.arr_cCamData = new cCamData[2]; + cam.arr_cCamData[0] = new cCamData(); // 원본 프리팹 실측값 + cam.arr_cCamData[0].minDistance = 3f; cam.arr_cCamData[0].maxDistance = 3f; + cam.arr_cCamData[0].DistanceUp = 2f; cam.arr_cCamData[0].cameraHeight = 65f; + cam.arr_cCamData[1] = new cCamData(); + cam.arr_cCamData[1].minDistance = 2f; cam.arr_cCamData[1].maxDistance = 3f; + cam.arr_cCamData[1].DistanceUp = 3.5f; cam.arr_cCamData[1].cameraHeight = 78f; + cam.minDistance = 3f; cam.maxDistance = 3f; cam.DistanceUp = 2f; cam.cameraHeight = 65f; + + WL.CameraFraming.WLCameraFramingSettings.RuntimeDisabled = false; + WL.CameraFraming.CameraFramingOverride.ResetDiagnostics(); + + bool ok = WL.CameraFraming.CameraFramingOverride.ProbeApply(cam); + sb.Append("적용 ").Append(ok ? "O" : "🔴 X") + .Append(" · arr_cCamData[0] h=").Append(cam.arr_cCamData[0].cameraHeight) + .Append(" min=").Append(cam.arr_cCamData[0].minDistance) + .Append(" max=").Append(cam.arr_cCamData[0].maxDistance) + .Append(" up=").Append(cam.arr_cCamData[0].DistanceUp).Append("\n"); + sb.Append("라이브 필드 h=").Append(cam.cameraHeight).Append(" min=").Append(cam.minDistance) + .Append(" max=").Append(cam.maxDistance).Append(" up=").Append(cam.DistanceUp) + .Append(" · 내림각 ").Append(Down(cam.cameraHeight, cam.maxDistance, cam.DistanceUp).ToString("0.0")).Append("°\n"); + sb.Append("보스 프리셋 arr_cCamData[1] 무변경: ") + .Append(cam.arr_cCamData[1].cameraHeight == 78f && cam.arr_cCamData[1].maxDistance == 3f ? "PASS" : "🔴 실패").Append("\n"); + + // C8 — SO 를 끄고 틱 1회 → 원값 복귀 + WL.CameraFraming.WLCameraFramingSettings.RuntimeDisabled = true; + WL.CameraFraming.CameraFramingOverride.ForceTick(); + bool restored = cam.arr_cCamData[0].cameraHeight == 65f && cam.arr_cCamData[0].maxDistance == 3f + && cam.arr_cCamData[0].DistanceUp == 2f && cam.cameraHeight == 65f; + sb.Append("C8 off → arr_cCamData[0] h=").Append(cam.arr_cCamData[0].cameraHeight) + .Append(" max=").Append(cam.arr_cCamData[0].maxDistance).Append(" up=").Append(cam.arr_cCamData[0].DistanceUp) + .Append(" · 라이브 h=").Append(cam.cameraHeight) + .Append(" → ").Append(restored ? "PASS(원본 100 %)" : "🔴 실패").Append("\n"); + sb.Append("카운터: boundaries=").Append(WL.CameraFraming.CameraFramingOverride.Boundaries) + .Append(" applied=").Append(WL.CameraFraming.CameraFramingOverride.Applied) + .Append(" restored=").Append(WL.CameraFraming.CameraFramingOverride.Restored) + .Append(" skippedBusy=").Append(WL.CameraFraming.CameraFramingOverride.SkippedBusy).Append("\n\n"); + } + finally + { + WL.CameraFraming.WLCameraFramingSettings.RuntimeDisabled = false; + WL.CameraFraming.CameraFramingOverride.ProbeRestore(); + UnityEngine.Object.DestroyImmediate(go); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // G. C8 + // ───────────────────────────────────────────────────────────────────────── + static void SectionG(System.Text.StringBuilder sb) + { + sb.Append("── G. C8 롤백 ──\n"); + var root = new UnityEngine.GameObject("[WL811o] C8Occ"); + root.hideFlags = UnityEngine.HideFlags.HideAndDontSave; + var far = new UnityEngine.Vector3(0f, 0f, 5000f); + var cam = far + new UnityEngine.Vector3(0f, 4.5f, -5.4f); + var aim = far + new UnityEngine.Vector3(0f, 1.06f, 0f); + try + { + var c = UnityEngine.GameObject.CreatePrimitive(UnityEngine.PrimitiveType.Cube); + c.name = "C8Rock"; c.transform.SetParent(root.transform, true); + c.transform.position = UnityEngine.Vector3.Lerp(cam, aim, 0.4f); + c.transform.localScale = new UnityEngine.Vector3(1.4f, 2.2f, 1.4f); c.layer = 0; + UnityEngine.Physics.SyncTransforms(); + var rnd = c.GetComponent(); + + WL.CameraFraming.WLCameraFramingSettings.RuntimeDisabled = false; + WL.CameraFraming.OccluderFade.ResetDiagnostics(); + WL.CameraFraming.OccluderFade.TimeOverride = 2000f; + WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim); + WL.CameraFraming.OccluderFade.TimeOverride = 2000.5f; + WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim); + sb.Append("on : 숨김 ").Append(WL.CameraFraming.OccluderFade.ActiveHidden) + .Append(" · 렌더러 shadow=").Append(rnd.shadowCastingMode).Append(" enabled=").Append(rnd.enabled).Append("\n"); + + WL.CameraFraming.WLCameraFramingSettings.RuntimeDisabled = true; + sb.Append("RuntimeDisabled=true → Active(occ)=").Append(WL.CameraFraming.OccluderFade.Active) + .Append(" Active(framing)=").Append(WL.CameraFraming.CameraFramingOverride.Active).Append("\n"); + WL.CameraFraming.OccluderFade.ForceTick(); // Active=false 경로 → ReleaseAll + WL.CameraFraming.CameraFramingOverride.ForceTick(); // Active=false 경로 → 구도 복원 + sb.Append("off: 숨김 ").Append(WL.CameraFraming.OccluderFade.ActiveHidden) + .Append(" · 렌더러 shadow=").Append(rnd.shadowCastingMode).Append(" enabled=").Append(rnd.enabled) + .Append(" · framing applied=").Append(WL.CameraFraming.CameraFramingOverride.IsApplied).Append("\n"); + sb.Append(rnd.enabled && rnd.shadowCastingMode == UnityEngine.Rendering.ShadowCastingMode.On + ? "→ PASS (원본 소품 100 %)\n\n" : "→ 🔴 실패\n\n"); + } + finally + { + WL.CameraFraming.WLCameraFramingSettings.RuntimeDisabled = false; + WL.CameraFraming.OccluderFade.ReleaseAll(); + WL.CameraFraming.OccluderFade.TimeOverride = -1f; + UnityEngine.Object.DestroyImmediate(root); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // H. 이펙트 스케일 + // ───────────────────────────────────────────────────────────────────────── + static void SectionH(System.Text.StringBuilder sb) + { + sb.Append("── H. 대형 이펙트 스케일(슬롯 4 본체) ──\n"); + var so = UnityEditor.AssetDatabase.LoadAssetAtPath(kSpectacle); + if (so == null) { sb.Append("🔴 ").Append(kSpectacle).Append(" 로드 실패\n\n"); return; } + var sobj = new UnityEditor.SerializedObject(so); + var rows = sobj.FindProperty("rows"); + if (rows == null || !rows.isArray) { sb.Append("🔴 rows 배열 없음\n\n"); return; } + sb.Append("| skillId | slot | body.scale |\n|---|---|---|\n"); + for (int i = 0; i < rows.arraySize; i++) + { + var e = rows.GetArrayElementAtIndex(i); + var slot = e.FindPropertyRelative("slot"); + if (slot == null || slot.intValue != 4) continue; + var id = e.FindPropertyRelative("skillId"); + var body = e.FindPropertyRelative("body"); + var sc = body != null ? body.FindPropertyRelative("scale") : null; + sb.Append("| ").Append(id != null ? id.intValue.ToString() : "?") + .Append(" | 4 | ").Append(sc != null ? sc.floatValue.ToString("0.###") : "?").Append(" |\n"); + } + sb.Append("\n"); + } + + // ───────────────────────────────────────────────────────────────────────── + // 공용 + // ───────────────────────────────────────────────────────────────────────── + static float Down(float h, float d, float up) + { + return WL.CameraFraming.CameraFramingOverride.DownAngleDeg(h, d, up, kLookY); + } + + static string Pct(int n, int total) + { + if (total <= 0) return "-"; + return (100f * n / total).ToString("0.0") + "% (" + n + "/" + total + ")"; + } + + static string V(UnityEngine.Vector3 p) + { + return "(" + p.x.ToString("0.0") + ", " + p.y.ToString("0.00") + ", " + p.z.ToString("0.0") + ")"; + } + + static void Cleanup() + { + if (s_map != null) { UnityEngine.Object.DestroyImmediate(s_map); s_map = null; } + s_props.Clear(); s_bounds.Clear(); + } + + static void Write(string text) + { + try + { + var full = System.IO.Path.Combine(System.IO.Directory.GetParent(UnityEngine.Application.dataPath).FullName, kOut); + System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(full)); + System.IO.File.WriteAllText(full, text, new System.Text.UTF8Encoding(true)); + } + catch (System.Exception) { } + } +} diff --git a/Assets/WL/Camera/CameraFramingOverride.cs b/Assets/WL/Camera/CameraFramingOverride.cs new file mode 100644 index 000000000..bf8cb795d --- /dev/null +++ b/Assets/WL/Camera/CameraFramingOverride.cs @@ -0,0 +1,329 @@ +// ───────────────────────────────────────────────────────────────────────────── +// CameraFramingOverride.cs — 맵 진입 시 카메라 구도(내려다보는 각 + 거리) 1회 오버라이드 +// +// PD 지시 #813 · 발주서 WL-811o §1-1 (2026-09-09) · 근거 = qa/W2-4b_재채점_v2.md D-8 · 채점 7-3 +// +// ■ 관측된 결함 (QA 실측 · 캡처 Screenshots_WL/Q4b/01_enter_hud.png) +// 존1 진입 직후 큰 덤불/바위가 PC 를 화면의 40 % 가까이 덮는다. 카메라가 거의 눕다시피 해서 +// 카메라~PC 선분이 지면 가까이 깔리기 때문이다(아래 실측). +// +// ■ 원인 (코드·프리팹 실측) +// Main Camera.prefab arr_cCamData[0] = minDistance 3 · maxDistance 3 · DistanceUp 2 · **cameraHeight 65** +// (원본 커밋 d5df1b2c9 「카메라 수정」 에서 78 → 65 · 1.5/1.6/1 → 3/3/2 로 바뀌었다.) +// RealCamera 궤도 수식으로 환산하면 **내림각 25.6°** — 카메라~주시점 선분이 PC 로부터 2.5 m 지점에서 +// 지면 위 2.26 m 밖에 안 된다. 맵의 바위·덤불이 그보다 높으면 그대로 PC 를 덮는다. +// +// ■ 이 파일이 하는 것 — 원본 훅 0 · RealCamera 무수정 · 폴링만(AutoCombatOnEnter 선례) +// 맵(런) 진입 경계에서 **1회**, RealCamera 의 공개 데이터 arr_cCamData[0](일반 프리셋)에 +// SO 값(cameraHeight · distance · distanceUp)을 쓰고, 살아 있는 공개 필드에도 같은 값을 반영한다. +// · arr_cCamData[0] 을 쓰는 이유 = 원본이 나중에 Init_Cam()(private · Set_Target(t, rot) 경유)으로 +// 프리셋을 **다시 읽어** 라이브 필드를 덮어쓰기 때문. 배열까지 바꿔야 값이 유지된다. +// (같은 이유로 811d 는 arr_cCamData[1] 보스 프리셋을 덮어쓴다 — 이 파일은 [0] 만 건드린다.) +// · 라이브 필드 반영 = minDistance/maxDistance/cameraHeight 직접 대입 + Set_DistanceUp(false)(공개 API). +// Init_Cam() 의 나머지 한 줄(private VerticalAxis)은 DistanceAway 를 즉시 스냅시키는 용도인데, +// min=max 로 두면 다음 Update_Cam 의 Clamp 가 같은 일을 하므로 필요 없다. +// · 원값은 적용 직전에 스냅샷 → SO off · 씬 전환 · 러너 파괴 때 그대로 되돌린다(C8). +// · 킬캠/보스 캠(811d)이 궤도 거리를 쓰고 있는 동안에는 적용을 미룬다(다음 폴링에서 재시도) — +// 진행 중인 램프의 base 를 오염시키지 않기 위해서다. +// +// ■ 왜 매 프레임 쓰지 않는가 +// 811d CombatCameraDirector 가 킬캠·보스 캠에서 같은 필드를 램프한다. 매 프레임 쓰면 두 기능이 싸운다. +// 구도는 「진입 시 1회」로 충분하다(발주서 §1-1). +// +// ■ 값(C45)은 전부 WLCameraFramingSettings. framingEnabled = false 면 아무 것도 쓰지 않는다. +// +// 🔴 어셈블리 주의: Assets/WL/Camera/ 에 .asmdef 를 만들지 말 것(RealCamera 가 Assembly-CSharp 에 있다). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; +using WL.CombatCamera; + +namespace WL.CameraFraming +{ + /// 맵 진입 시 카메라 구도 1회 오버라이드. 정적 · 새 Manager/Singleton 0. + public static class CameraFramingOverride + { + static WLCameraFramingSettings Cfg { get { return WLCameraFramingSettings.Instance; } } + + /// 기능이 살아 있는가. 에셋이 없거나 플래그가 꺼져 있으면 false = 원본 구도 100 %. + public static bool Active + { + get { var c = Cfg; return c != null && c.enabled && c.framingEnabled && !WLCameraFramingSettings.RuntimeDisabled; } + } + + // ───────────────────────────────────────── 진단 카운터 (프로브가 읽는다) + public static int Boundaries, Applied, Restored, SkippedLobby, SkippedBusy, SkippedNoCam; + public static string LastReason = "", LastLog = ""; + + /// 지금 우리 값이 걸려 있는가. + public static bool IsApplied { get { return s_applied; } } + + /// 적용 중인 값(진단) — 궤도 파라미터 · 거리 · 궤도 중심 높이. + public static float AppliedHeight, AppliedDistance, AppliedUp; + + /// 원값 스냅샷(진단) — 복원 대상. + public static float OriginHeight, OriginMin, OriginMax, OriginUp; + + // ───────────────────────────────────────── 내부 상태 + static PCActor s_lastPc; + static int s_lastMapId = int.MinValue; + static float s_nextPoll; + static bool s_faulted; + + static bool s_applied; + static cCamData s_data; // 우리가 값을 쓴 프리셋(원본 [Serializable] 클래스 = fake-null 아님) + static RealCamera s_cam; + static float s_wHeight, s_wMin, s_wMax, s_wUp; // 우리가 쓴 값(외부 덮어쓰기 감지) + + // ───────────────────────────────────────────────────────────────────── + // 틱 + // ───────────────────────────────────────────────────────────────────── + + /// 러너가 매 프레임 부른다(에디트 모드에서는 프로브가 ForceTick 으로 직접 부를 수 있다). + internal static void Tick() + { + if (s_faulted) return; + + // C8 — 기능이 꺼지면 즉시 원값으로 되돌린다. + if (!Active) { if (s_applied) Restore("off"); return; } + + var cfg = Cfg; + float now = Time.unscaledTime; + if (now < s_nextPoll) return; + s_nextPoll = now + Mathf.Max(0.02f, cfg.framingPollSeconds); + + // 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 팝업으로 띄운다(813x §D-1). + try { PollBoundary(cfg); } + catch (System.Exception ex) { s_faulted = true; Log("중단(예외) — " + ex.Message); } + } + + static void PollBoundary(WLCameraFramingSettings cfg) + { + var pc = MyValue.MyPC; + if (DSUtil.CheckNull(pc)) { s_lastPc = null; return; } + + var info = InGameInfo.Ins; + if (DSUtil.CheckNull(info)) return; // 아직 인게임 정보가 없다 — 경계를 소비하지 않는다 + + int map = MyValue.MyChoiceMapData != null ? MyValue.MyChoiceMapData.n_MapID : int.MinValue; + bool pcChanged = !ReferenceEquals(pc, s_lastPc); + bool mapChanged = map != s_lastMapId; + if (!pcChanged && !mapChanged) return; + + if (!cfg.framingInLobby && info.Get_GameMode() == eGameMode.Lobby) + { + s_lastPc = pc; s_lastMapId = map; + Boundaries++; SkippedLobby++; + LastReason = "lobby"; + Log("로비 — 건너뜀"); + return; + } + + var cam = MyValue.m_RealCamera; + if (DSUtil.CheckNull(cam) || cam.arr_cCamData == null || cam.arr_cCamData.Length == 0 || cam.arr_cCamData[0] == null) + { + SkippedNoCam++; // 경계를 소비하지 않는다 — 카메라가 뜨면 다음 폴링에서 다시 본다 + return; + } + + // 811d 킬캠·보스 캠이 궤도 거리를 램프하는 중이면 미룬다(그 base 를 오염시키지 않는다). + if (CombatCameraDirector.KillCamActive || CombatCameraDirector.BossCamActive || CombatCameraDirector.BossPresetActive) + { + SkippedBusy++; + return; + } + + s_lastPc = pc; s_lastMapId = map; + Boundaries++; + LastReason = pcChanged ? "pc" : ("map " + map); + + Apply(cfg, cam); + } + + // ───────────────────────────────────────────────────────────────────── + // 적용 · 복원 + // ───────────────────────────────────────────────────────────────────── + + static void Apply(WLCameraFramingSettings cfg, RealCamera cam) + { + var d = cam.arr_cCamData[0]; + + // 이미 우리 값이 걸려 있고 같은 프리셋이면 원값 스냅샷을 다시 잡지 않는다(자기 값을 원값으로 착각 방지). + if (!s_applied || !ReferenceEquals(d, s_data)) + { + s_data = d; + OriginHeight = d.cameraHeight; OriginMin = d.minDistance; OriginMax = d.maxDistance; OriginUp = d.DistanceUp; + } + + float h = Mathf.Clamp(cfg.cameraHeight, 50f, 85f); + float dist = Mathf.Clamp(cfg.distance, 1f, 8f); + float up = cfg.distanceUp >= 0f ? cfg.distanceUp : OriginUp; + + d.cameraHeight = h; + d.minDistance = dist; + d.maxDistance = dist; + d.DistanceUp = up; + + // 라이브 필드 — Init_Cam() 이 하는 일과 같은 효과(공개 API 만 사용) + cam.cameraHeight = h; + cam.minDistance = dist; + cam.maxDistance = dist; + cam.Set_DistanceUp(false); // DistanceUp = arr_cCamData[islockTarget ? 1 : 0].DistanceUp + + s_wHeight = h; s_wMin = dist; s_wMax = dist; s_wUp = up; + s_cam = cam; + s_applied = true; + Applied++; + AppliedHeight = h; AppliedDistance = dist; AppliedUp = up; + + Log("구도 적용 #" + Applied + " (" + LastReason + ") h " + OriginHeight + "→" + h + + " · dist " + OriginMax + "→" + dist + " · up " + OriginUp + "→" + up + + " · 내림각 " + DownAngleDeg(h, dist, up).ToString("0.0") + "°"); + } + + /// 원값으로 되돌린다(외부가 그 사이에 값을 바꿨으면 건드리지 않는다). + static void Restore(string why) + { + if (!s_applied) return; + s_applied = false; + + var d = s_data; + if (d != null && d.cameraHeight == s_wHeight && d.minDistance == s_wMin && d.maxDistance == s_wMax && d.DistanceUp == s_wUp) + { + d.cameraHeight = OriginHeight; d.minDistance = OriginMin; d.maxDistance = OriginMax; d.DistanceUp = OriginUp; + + var cam = s_cam; + if (!DSUtil.CheckNull(cam)) + { + cam.cameraHeight = OriginHeight; + cam.minDistance = OriginMin; + cam.maxDistance = OriginMax; + cam.Set_DistanceUp(false); + } + Restored++; + Log("구도 복원(" + why + ") h→" + OriginHeight + " · dist→" + OriginMax); + } + else Log("구도 복원 포기(" + why + ") — 외부가 값을 바꿨다"); + + s_data = null; s_cam = null; + } + + /// 씬 전환·러너 파괴·앱 종료에서 부른다. + public static void ReleaseAll() { Restore("release"); s_lastPc = null; s_lastMapId = int.MinValue; } + + // ───────────────────────────────────────────────────────────────────── + // 궤도 환산 (진단·프로브 공용 · RealCamera.Update_Cam 수식 그대로) + // ───────────────────────────────────────────────────────────────────── + + /// 궤도 파라미터 h 에서 카메라가 타겟 발밑보다 얼마나 높은지(m). + public static float CamHeightAbove(float h, float distance, float distanceUp) + { + float r = h * Mathf.Deg2Rad; + return distanceUp + distance * (Mathf.Sin(r) - Mathf.Cos(r)); + } + + /// 궤도 파라미터 h 에서 카메라~타겟 수평 거리(m). + public static float CamHorizontal(float h, float distance) + { + float r = h * Mathf.Deg2Rad; + float s = Mathf.Sin(r) + Mathf.Cos(r); + return distance * Mathf.Sqrt(1f + s * s); + } + + /// 주시점(발밑 + lookAtY)까지의 내림각(도). lookAtY 기본 1.06 = RealCamera.m_LookAt.y 실측. + public static float DownAngleDeg(float h, float distance, float distanceUp, float lookAtY = 1.06f) + { + float dy = CamHeightAbove(h, distance, distanceUp) - lookAtY; + float dx = CamHorizontal(h, distance); + if (dx <= 0.0001f) return 90f; + return Mathf.Atan2(dy, dx) * Mathf.Rad2Deg; + } + + /// 궤도 파라미터 h 의 카메라 위치(월드) — 프로브가 가림률을 계산할 때 쓴다. + public static Vector3 CameraPosition(Vector3 targetFoot, float yawDeg, float h, float distance, float distanceUp) + { + Vector3 rotateVector = Quaternion.Euler(h, yawDeg, 0f) * Vector3.one; + return targetFoot + Vector3.up * distanceUp - rotateVector * distance; + } + + // ───────────────────────────────────────────────────────────────────── + // 진단 · 러너 + // ───────────────────────────────────────────────────────────────────── + + static void Log(string msg) + { + LastLog = msg; + var c = Cfg; + if (c != null && c.verboseLog) Debug.Log("[CameraFraming] " + msg); + } + + /// 프로브용: 카운터·경계 상태 초기화(적용된 값은 유지). + public static void ResetDiagnostics() + { + Boundaries = Applied = Restored = SkippedLobby = SkippedBusy = SkippedNoCam = 0; + LastReason = LastLog = ""; + s_lastPc = null; s_lastMapId = int.MinValue; s_nextPoll = 0f; s_faulted = false; + } + + /// 프로브용: 러너 없이 1회 폴링(강제 틱). + public static void ForceTick() { s_nextPoll = 0f; Tick(); } + + /// + /// 프로브용: 경계 판정(PC·InGameInfo)을 건너뛰고 이 카메라에 값을 적용한다. + /// 🔴 에디트 모드에서 InGameInfo.Ins / ServerInfo.Ins 를 건드리면 싱글턴 GameObject 가 생겨 씬이 더러워진다(813x §0-12) — + /// 그래서 프로브는 이 진입점을 쓴다. 런타임 경로(PollBoundary)와 Apply 는 같은 함수를 부른다. + /// + public static bool ProbeApply(RealCamera cam) + { + var cfg = Cfg; + if (cfg == null || !Active) return false; + if (DSUtil.CheckNull(cam) || cam.arr_cCamData == null || cam.arr_cCamData.Length == 0 || cam.arr_cCamData[0] == null) return false; + LastReason = "probe"; + Boundaries++; + Apply(cfg, cam); + return true; + } + + /// 프로브용: 즉시 원값 복원. + public static void ProbeRestore() { Restore("probe"); } + + static CameraFramingRunner s_runner; + + internal static void EnsureRunner() + { + if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return; + var go = new GameObject("[WL811o] CameraFramingRunner"); + go.hideFlags = HideFlags.HideAndDontSave; + s_runner = go.AddComponent(); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] + static void Boot() + { + var c = Cfg; + if (c != null && c.enabled && (c.framingEnabled || c.occluderFadeEnabled)) EnsureRunner(); // 둘 다 꺼져 있으면 오브젝트 0(C8) + } + } + + /// 구도 오버라이드 + 가림 소품 숨김의 시간 축. 숨김 GameObject 1개 · 코루틴 0. + internal sealed class CameraFramingRunner : MonoBehaviour + { + void Update() + { + CameraFramingOverride.Tick(); + OccluderFade.Tick(); + } + + void OnDisable() + { + CameraFramingOverride.ReleaseAll(); + OccluderFade.ReleaseAll(); + } + + void OnApplicationQuit() + { + CameraFramingOverride.ReleaseAll(); + OccluderFade.ReleaseAll(); + } + } +} diff --git a/Assets/WL/Camera/CameraFramingOverride.cs.meta b/Assets/WL/Camera/CameraFramingOverride.cs.meta new file mode 100644 index 000000000..8fe17ce50 --- /dev/null +++ b/Assets/WL/Camera/CameraFramingOverride.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 024842c5f9753b146876e8383ad61c20 \ No newline at end of file diff --git a/Assets/WL/Camera/OccluderFade.cs b/Assets/WL/Camera/OccluderFade.cs new file mode 100644 index 000000000..63bd898f1 --- /dev/null +++ b/Assets/WL/Camera/OccluderFade.cs @@ -0,0 +1,487 @@ +// ───────────────────────────────────────────────────────────────────────────── +// OccluderFade.cs — 카메라와 PC 사이를 막은 지형 소품을 잠시 숨긴다 +// +// PD 지시 #813 · 발주서 WL-811o §1-2 (2026-09-09) · 근거 = qa/W2-4b_재채점_v2.md D-8 · 채점 7-3 = 0 +// 「지형 바위가 PC 를 완전히 가림(Screenshots_WL/Q4b/01_enter_hud.png ≈40 %)」 +// +// ■ 이 파일이 하는 것 — 원본 훅 0 · RealCamera 무수정 · 새 셰이더/머티리얼 0 · 폴링만 +// 0.1 s 마다 카메라 → PC 주시점 선분을 검사해서 걸린 **지형 소품 렌더러**를 fadeOutSeconds 뒤 숨기고, +// 가림이 풀리면 restoreDelaySeconds 뒤 원래대로 되돌린다. 동시 상한 4(SO). +// +// ■ 검사 2단 (발주서는 SphereCast 만 지정했지만 실측이 그것으로 부족하다고 말한다) +// ⓐ SphereCast(반경 0.6 m · SO) — 콜라이더가 있는 소품·지형 +// ⓑ 바운드 스캔 — 🔴 **WL_Nature 실측: 가림 후보 소품 979개 중 콜라이더 보유는 121개(12.4 %)**. +// 나무·덤불·바위 대부분에 콜라이더가 없어 ⓐ 만으로는 화면을 실제로 가리는 것의 12 % 밖에 못 잡는다. +// 그래서 맵 진입(PC 교체·씬 변경) 때 소품 렌더러 목록을 한 번 훑어 두고, 폴링에서는 그 목록의 +// **렌더러 바운드**와 선분을 교차 검사한다(콜라이더 불요 · 폴링 중 할당 0). SO 로 끌 수 있다. +// +// ■ 숨김 방식 (실측 후 선택 · SO hideMode) +// 🔴 실측: 가림 후보 979개의 머티리얼이 **전부 불투명 큐**(URP/Lit queue 2000 · 투명 0). +// 불투명 셰이더에서는 MaterialPropertyBlock 알파가 화면에 아무 효과가 없고, 새 셰이더·머티리얼 생성은 +// 발주서 §2 금지다. → Auto 는 **ShadowsOnly**(렌더러는 안 보이고 그림자는 남는다 · 머티리얼 무변경 · GC 0)로 내려간다. +// 이 경로에서 fadeOutSeconds 는 「스쳐 지나가는 소품이 한 프레임 깜빡이는 것」을 막는 지연으로 쓴다. +// (투명 큐 소품이 있는 맵에서는 Auto 가 알파 페이드를 고른다 — 코드는 두 경로를 다 갖고 있다.) +// +// ■ 안전장치 (맵이 통째로 사라지는 사고 방지) +// · 레이어 마스크(기본 Default + Map) — Actor(PC·몹)·UI·Water·GrassFX 레이어는 애초에 안 본다 +// · Actor 컴포넌트 보유 · SkinnedMeshRenderer · ParticleSystem/Trail/Line 렌더러(이펙트)는 절대 제외 +// · 이름 규칙(terrain/ground/floor/water/…) · 바운드 높이 하한 · 바운드 최대 변 상한(지형 통짜 메시 보호) +// · 동시 상한(기본 4) · 러너 파괴·씬 전환·SO off 에 **전부 원복**(C8) +// +// ■ GC 0 +// Physics.SphereCastNonAlloc(사전 할당 RaycastHit[16]) · 슬롯 배열 사전 할당 · 스캔 배열 사전 할당 · +// MaterialPropertyBlock 1개 재사용 · 이름 비교는 IndexOf(OrdinalIgnoreCase)(ToLower 할당 없음) · +// verboseLog 를 켤 때만 문자열 생성. **할당은 스캔 재구축(맵 진입 1회) 에만 있다.** +// +// 🔴 어셈블리 주의: Assets/WL/Camera/ 에 .asmdef 를 만들지 말 것(RealCamera 가 Assembly-CSharp 에 있다). +// ───────────────────────────────────────────────────────────────────────────── + +using System; +using UnityEngine; +using UnityEngine.Rendering; + +namespace WL.CameraFraming +{ + /// 카메라~PC 가림 소품 임시 숨김. 정적 · 새 Manager/Singleton 0(시간 축은 CameraFramingRunner 공용). + public static class OccluderFade + { + const int SlotMax = 8; // SO maxConcurrent(1~8)의 물리 상한 + const int HitMax = 16; // 한 번의 SphereCast 에서 볼 최대 히트 수 + + static WLCameraFramingSettings Cfg { get { return WLCameraFramingSettings.Instance; } } + + /// 기능이 살아 있는가. 에셋이 없거나 플래그가 꺼져 있으면 false = 소품 100 % 표시. + public static bool Active + { + get { var c = Cfg; return c != null && c.enabled && c.occluderFadeEnabled && !WLCameraFramingSettings.RuntimeDisabled; } + } + + // ───────────────────────────────────────── 진단 카운터 (프로브가 읽는다) + public static int Polls, Blocked, HiddenCount, RestoredCount, CapSkips, Rejected, ScanBuilds; + public static int ActiveHidden; // 지금 실제로 숨겨 둔 수 + public static string LastLog = ""; + public static string LastHiddenName = ""; + public static OccluderHideMode LastHideMode = OccluderHideMode.Auto; + + // ───────────────────────────────────────── 내부 상태 (전부 사전 할당) + sealed class Slot + { + public Renderer r; + public Transform tf; + public ShadowCastingMode originShadow; + public bool originEnabled; + public bool hidden; + public OccluderHideMode mode; + public int colorProp; + public Color originColor; + public float firstSeen, lastSeen; + } + + static readonly Slot[] s_slots = NewSlots(); + static readonly RaycastHit[] s_hits = new RaycastHit[HitMax]; + static MaterialPropertyBlock s_mpb; + static string[] s_excludeTokens = new string[0]; + static string s_excludeSource; + static float s_nextPoll; + static bool s_faulted; + + // ── 바운드 스캔(콜라이더 없는 소품) 상태 + static Renderer[] s_scan = new Renderer[0]; + static int s_scanCount; + static float s_nextScan; + static PCActor s_scanPc; + static int s_scanScene = -1; + + /// 스캔 목록에 들어간 소품 수(진단). + public static int ScanCount { get { return s_scanCount; } } + + static readonly int PropBaseColor = Shader.PropertyToID("_BaseColor"); + static readonly int PropColor = Shader.PropertyToID("_Color"); + + /// + /// 프로브 전용 시간 소스(초). 0 미만이면 Time.unscaledTime 을 쓴다. + /// 🔴 에디트 모드에서는 Time.unscaledTime 이 흐르지 않아 페이드/복귀를 검증할 수 없다 — + /// 프로브가 이 값을 밀어 가짜 시간으로 상태 전이를 확인한다. 런타임에서는 항상 −1. + /// + public static float TimeOverride = -1f; + + static float Now { get { return TimeOverride >= 0f ? TimeOverride : Time.unscaledTime; } } + + static Slot[] NewSlots() + { + var a = new Slot[SlotMax]; + for (int i = 0; i < SlotMax; i++) a[i] = new Slot(); + return a; + } + + // ───────────────────────────────────────────────────────────────────── + // 틱 + // ───────────────────────────────────────────────────────────────────── + + /// 러너가 매 프레임 부른다. 내부에서 pollSeconds 로 스로틀한다. + internal static void Tick() + { + if (s_faulted) return; + + if (!Active) { if (ActiveHidden > 0) ReleaseAll(); return; } + + var cfg = Cfg; + float now = Now; + if (now < s_nextPoll) return; + s_nextPoll = now + Mathf.Max(0.02f, cfg.pollSeconds); + + // 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 팝업으로 띄운다(813x §D-1). + try + { + var cam = MyValue.m_RealCamera; + var pc = MyValue.MyPC; + if (DSUtil.CheckNull(cam) || DSUtil.CheckNull(pc)) { UpdateSlots(cfg, now); return; } + + Vector3 camPos = cam.transform.position; + Vector3 aim = pc.transform.position + cam.m_LookAt; // RealCamera.transform.LookAt 과 같은 점 + EvaluateSegment(camPos, aim); + } + catch (Exception ex) { s_faulted = true; Log("중단(예외) — " + ex.Message); } + } + + /// + /// 카메라 위치 → 주시점 선분을 검사하고 슬롯을 갱신한다. + /// 프로브가 에디트 모드에서 「가짜 가림」을 만들 때 직접 부른다(러너·PC 없이도 동작). + /// + public static void EvaluateSegment(Vector3 camPos, Vector3 aim) + { + var cfg = Cfg; + if (cfg == null) return; + float now = Now; + Polls++; + + Vector3 dir = aim - camPos; + float dist = dir.magnitude; + if (dist > 0.0001f) + { + dir /= dist; + // 카메라 근평면 안쪽에서 시작하면 겹친 콜라이더가 distance 0 으로 보고된다 — 조금 앞에서 쏜다. + float skip = Mathf.Min(0.2f, dist * 0.1f); + float segLen = Mathf.Max(0f, dist - skip - 0.5f); // 끝 0.5 m 는 PC 자신 + float radius = Mathf.Max(0f, cfg.sphereRadius); + + // ⓐ 콜라이더가 있는 소품·지형 + int n = radius > 0.0001f + ? Physics.SphereCastNonAlloc(camPos + dir * skip, radius, dir, s_hits, segLen, + cfg.occluderMask, QueryTriggerInteraction.Ignore) + : Physics.RaycastNonAlloc(camPos + dir * skip, dir, s_hits, segLen, + cfg.occluderMask, QueryTriggerInteraction.Ignore); + if (n > HitMax) n = HitMax; + if (n > 0) Blocked++; + + for (int i = 0; i < n; i++) + { + var col = s_hits[i].collider; + if (col == null) continue; + var rnd = Resolve(cfg, col); + if (rnd == null) continue; + Touch(cfg, rnd, now); + } + + // ⓑ 콜라이더가 없는 소품 — 사전 스캔 목록의 바운드로 판정(폴링 중 할당 0) + EnsureScan(cfg, now); + if (s_scanCount > 0) + { + float br = cfg.boundsScanRadius > 0.0001f ? cfg.boundsScanRadius : radius; + var ray = new Ray(camPos + dir * skip, dir); + for (int i = 0; i < s_scanCount; i++) + { + var rnd = s_scan[i]; + if (rnd == null) continue; + var b = rnd.bounds; + b.Expand(br * 2f); // 선분 반경만큼 부풀린다(SphereCast 와 같은 뜻) + float hitD; + if (!b.IntersectRay(ray, out hitD)) continue; + // 🔴 Bounds.IntersectRay 는 광선 시작점이 바운드 **안**에 있으면 음수 거리를 준다 + // (= 카메라가 그 소품 속에 있다 = 확실한 가림). 음수를 버리면 가장 심한 가림을 놓친다. + if (hitD > segLen) continue; + Touch(cfg, rnd, now); + } + } + } + + UpdateSlots(cfg, now); + } + + // ───────────────────────────────────────────────────────────────────── + // 후보 판정 + // ───────────────────────────────────────────────────────────────────── + + /// 콜라이더 → 숨겨도 되는 렌더러. 자격이 없으면 null. + static Renderer Resolve(WLCameraFramingSettings cfg, Collider col) + { + // PC·몹 (레이어로 이미 걸러지지만 프리팹이 레이어를 안 맞춰 놓은 경우까지 막는다) + if (cfg.skipActors && col.GetComponentInParent() != null) { Rejected++; return null; } + + var rnd = col.GetComponent(); + if (rnd == null) rnd = col.GetComponentInParent(); + if (rnd == null) { Rejected++; return null; } + return Eligible(cfg, rnd, false) ? rnd : null; + } + + /// 이 렌더러를 숨겨도 되는가 — 콜라이더 경로와 바운드 스캔 경로 공용 규칙. + public static bool Eligible(WLCameraFramingSettings cfg, Renderer rnd, bool checkActor) + { + if (cfg == null || rnd == null) return false; + if (rnd is ParticleSystemRenderer || rnd is TrailRenderer || rnd is LineRenderer) { Rejected++; return false; } // 이펙트 + if (cfg.skipSkinnedRenderers && rnd is SkinnedMeshRenderer) { Rejected++; return false; } + + if (((1 << rnd.gameObject.layer) & cfg.occluderMask.value) == 0) { Rejected++; return false; } // 마스크 밖(Actor·UI·Water…) + if (checkActor && cfg.skipActors && rnd.GetComponentInParent() != null) { Rejected++; return false; } + + // 바닥·지형·물 — 이름 규칙(자기 이름 + 부모 이름) + var tf = rnd.transform; + if (NameExcluded(cfg, tf.name) || (tf.parent != null && NameExcluded(cfg, tf.parent.name))) { Rejected++; return false; } + + var b = rnd.bounds; + if (b.size.y < cfg.minOccluderHeight) { Rejected++; return false; } // 잔디·자갈은 가리지 않는다 + float maxSide = Mathf.Max(b.size.x, Mathf.Max(b.size.y, b.size.z)); + if (maxSide > cfg.maxOccluderSize) { Rejected++; return false; } // 지형 통짜 메시·배경 산 + + return true; + } + + static bool NameExcluded(WLCameraFramingSettings cfg, string name) + { + if (string.IsNullOrEmpty(name)) return false; + EnsureTokens(cfg); + for (int i = 0; i < s_excludeTokens.Length; i++) + { + var t = s_excludeTokens[i]; + if (t.Length > 0 && name.IndexOf(t, StringComparison.OrdinalIgnoreCase) >= 0) return true; + } + return false; + } + + static void EnsureTokens(WLCameraFramingSettings cfg) + { + string src = cfg.excludeNameContains; + if (ReferenceEquals(src, s_excludeSource)) return; // 문자열이 그대로면 재분해 없음 = GC 0 + s_excludeSource = src; + s_excludeTokens = string.IsNullOrEmpty(src) ? new string[0] : src.Split(','); + for (int i = 0; i < s_excludeTokens.Length; i++) s_excludeTokens[i] = s_excludeTokens[i].Trim(); + } + + // ───────────────────────────────────────────────────────────────────── + // 바운드 스캔 — 맵 진입 1회 목록 구축(여기서만 할당) + // ───────────────────────────────────────────────────────────────────── + + static void EnsureScan(WLCameraFramingSettings cfg, float now) + { + if (!cfg.boundsScanEnabled) { s_scanCount = 0; return; } + + var pc = MyValue.MyPC; + int scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene().handle; + bool boundary = s_scanCount == 0 || !ReferenceEquals(pc, s_scanPc) || scene != s_scanScene; + bool timed = cfg.scanRebuildSeconds > 0.0001f && now >= s_nextScan; + if (!boundary && !timed) return; + + s_scanPc = pc; s_scanScene = scene; + s_nextScan = now + Mathf.Max(1f, cfg.scanRebuildSeconds); + BuildScan(cfg); + } + + /// 맵 소품 목록 재구축(프로브·맵 교체용). 이 함수 안에서만 할당이 있다. + public static void RebuildScan() + { + var cfg = Cfg; + if (cfg == null) return; + s_scanPc = MyValue.MyPC; + s_scanScene = UnityEngine.SceneManagement.SceneManager.GetActiveScene().handle; + s_nextScan = Now + Mathf.Max(1f, cfg.scanRebuildSeconds); + BuildScan(cfg); + } + + static void BuildScan(WLCameraFramingSettings cfg) + { + var all = UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); + int cap = Mathf.Clamp(cfg.scanMax, 16, 8192); + if (s_scan.Length < cap) s_scan = new Renderer[cap]; + + int n = 0; + for (int i = 0; i < all.Length && n < cap; i++) + { + var r = all[i]; + if (r == null) continue; + if (!Eligible(cfg, r, true)) continue; + s_scan[n++] = r; + } + for (int i = n; i < s_scan.Length; i++) s_scan[i] = null; + s_scanCount = n; + ScanBuilds++; + Log("스캔 재구축 " + n + " / 렌더러 " + all.Length); + } + + // ───────────────────────────────────────────────────────────────────── + // 슬롯 + // ───────────────────────────────────────────────────────────────────── + + /// 이번 폴링에서 이 렌더러가 가림으로 잡혔다 — 슬롯을 잡거나 시간을 갱신한다. + static void Touch(WLCameraFramingSettings cfg, Renderer rnd, float now) + { + int cap = Mathf.Clamp(cfg.maxConcurrent, 1, SlotMax); + int free = -1, used = 0; + for (int i = 0; i < SlotMax; i++) + { + var s = s_slots[i]; + if (s.r == null) { if (free < 0) free = i; continue; } + used++; + if (ReferenceEquals(s.r, rnd)) { s.lastSeen = now; return; } + } + if (used >= cap || free < 0) { CapSkips++; return; } + + var slot = s_slots[free]; + slot.r = rnd; + slot.tf = rnd.transform; + slot.originShadow = rnd.shadowCastingMode; + slot.originEnabled = rnd.enabled; + slot.hidden = false; + slot.firstSeen = now; + slot.lastSeen = now; + slot.mode = ResolveMode(cfg, rnd, ref slot.colorProp, ref slot.originColor); + } + + static OccluderHideMode ResolveMode(WLCameraFramingSettings cfg, Renderer rnd, ref int colorProp, ref Color originColor) + { + colorProp = 0; + originColor = Color.white; + var mode = cfg.hideMode; + if (mode == OccluderHideMode.MpbAlpha || mode == OccluderHideMode.Auto) + { + var mat = rnd.sharedMaterial; + if (mat != null) + { + int p = mat.HasProperty(PropBaseColor) ? PropBaseColor : (mat.HasProperty(PropColor) ? PropColor : 0); + // 투명 큐가 아니면 알파를 낮춰도 화면에 안 나타난다 → Auto 는 ShadowsOnly 로 내려간다. + if (p != 0 && (mode == OccluderHideMode.MpbAlpha || mat.renderQueue >= 3000)) + { + colorProp = p; + originColor = mat.GetColor(p); + return OccluderHideMode.MpbAlpha; + } + } + return OccluderHideMode.ShadowsOnly; + } + return mode; + } + + /// 슬롯 시간 진행 — 숨김/복귀/원복. + static void UpdateSlots(WLCameraFramingSettings cfg, float now) + { + float fade = Mathf.Max(0f, cfg.fadeOutSeconds); + float back = Mathf.Max(0f, cfg.restoreDelaySeconds); + int active = 0; + + for (int i = 0; i < SlotMax; i++) + { + var s = s_slots[i]; + if (s.r == null) continue; + if (s.tf == null) { Clear(s, false); continue; } // 파괴됨(Unity fake-null) + + if ((now - s.lastSeen) >= back) { Clear(s, true); continue; } // 가림이 풀렸다 → 복귀 + + if (s.mode == OccluderHideMode.MpbAlpha) + { + float t = fade > 0.0001f ? Mathf.Clamp01((now - s.firstSeen) / fade) : 1f; + SetAlpha(s, Mathf.Lerp(1f, 0f, t)); + if (!s.hidden && t >= 1f) { s.hidden = true; HiddenCount++; Mark(s); } + } + else if (!s.hidden && (now - s.firstSeen) >= fade) + { + if (s.mode == OccluderHideMode.RendererOff) s.r.enabled = false; + else s.r.shadowCastingMode = ShadowCastingMode.ShadowsOnly; + s.hidden = true; HiddenCount++; Mark(s); + } + if (s.hidden) active++; + } + ActiveHidden = active; + } + + static void Mark(Slot s) + { + LastHideMode = s.mode; + var c = Cfg; + if (c != null && c.verboseLog) + { + LastHiddenName = s.tf != null ? s.tf.name : "?"; + Log("숨김 " + LastHiddenName + " (" + s.mode + ")"); + } + } + + static void SetAlpha(Slot s, float a) + { + if (s.colorProp == 0) return; + if (s_mpb == null) s_mpb = new MaterialPropertyBlock(); + s.r.GetPropertyBlock(s_mpb); + var c = s.originColor; c.a = s.originColor.a * a; + s_mpb.SetColor(s.colorProp, c); + s.r.SetPropertyBlock(s_mpb); + } + + /// 슬롯 비우기 — restore = true 면 원래 상태로 되돌린다. + static void Clear(Slot s, bool restore) + { + if (restore && s.r != null) + { + if (s.mode == OccluderHideMode.MpbAlpha) s.r.SetPropertyBlock(null); + else if (s.mode == OccluderHideMode.RendererOff) s.r.enabled = s.originEnabled; + else s.r.shadowCastingMode = s.originShadow; + if (s.hidden) RestoredCount++; + } + s.r = null; s.tf = null; s.hidden = false; s.colorProp = 0; + } + + // ───────────────────────────────────────────────────────────────────── + // 진단 + // ───────────────────────────────────────────────────────────────────── + + /// 숨겨 둔 것을 전부 원복한다(씬 전환·러너 파괴·앱 종료·SO off). + public static void ReleaseAll() + { + for (int i = 0; i < SlotMax; i++) Clear(s_slots[i], true); + ActiveHidden = 0; + } + + /// 프로브용: 카운터 초기화(먼저 전부 원복한다). + public static void ResetDiagnostics() + { + ReleaseAll(); + Polls = Blocked = HiddenCount = RestoredCount = CapSkips = Rejected = ScanBuilds = 0; + LastLog = LastHiddenName = ""; + s_nextPoll = 0f; s_faulted = false; + } + + /// 프로브용: 스캔 목록을 버린다(다음 폴링에서 다시 훑는다). + public static void ClearScan() { s_scanCount = 0; s_scanPc = null; s_scanScene = -1; } + + /// 프로브용: 러너 없이 1회 폴링(강제 틱). + public static void ForceTick() { s_nextPoll = 0f; Tick(); } + + /// 프로브용: 지금 슬롯에 잡힌 렌더러 이름(없으면 빈 문자열). i = 0..7 + public static string SlotName(int i) + { + if (i < 0 || i >= SlotMax) return ""; + var s = s_slots[i]; + return (s.r != null && s.tf != null) ? s.tf.name : ""; + } + + /// 프로브용: 슬롯이 실제로 숨겨진 상태인지. + public static bool SlotHidden(int i) + { + if (i < 0 || i >= SlotMax) return false; + return s_slots[i].r != null && s_slots[i].hidden; + } + + static void Log(string msg) + { + LastLog = msg; + var c = Cfg; + if (c != null && c.verboseLog) Debug.Log("[OccluderFade] " + msg); + } + } +} diff --git a/Assets/WL/Camera/OccluderFade.cs.meta b/Assets/WL/Camera/OccluderFade.cs.meta new file mode 100644 index 000000000..6bdcc8b8b --- /dev/null +++ b/Assets/WL/Camera/OccluderFade.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5cfde7e77950efd4b8e34398934d8c85 \ No newline at end of file diff --git a/Assets/WL/Camera/Resources/WL/WLCameraFramingSettings.asset b/Assets/WL/Camera/Resources/WL/WLCameraFramingSettings.asset new file mode 100644 index 000000000..6243db17a --- /dev/null +++ b/Assets/WL/Camera/Resources/WL/WLCameraFramingSettings.asset @@ -0,0 +1,41 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 71e584b928e482c449195aca2f158271, type: 3} + m_Name: WLCameraFramingSettings + m_EditorClassIdentifier: Assembly-CSharp::WL.CameraFraming.WLCameraFramingSettings + enabled: 1 + verboseLog: 0 + framingEnabled: 1 + cameraHeight: 76 + distance: 3 + distanceUp: 2.4 + framingPollSeconds: 0.25 + framingInLobby: 0 + occluderFadeEnabled: 1 + sphereRadius: 0.6 + pollSeconds: 0.1 + fadeOutSeconds: 0.2 + restoreDelaySeconds: 0.3 + maxConcurrent: 4 + hideMode: 0 + occluderMask: + serializedVersion: 2 + m_Bits: 65 + excludeNameContains: terrain,ground,floor,water,river,navmesh,plane,road,path + minOccluderHeight: 0.8 + maxOccluderSize: 30 + skipSkinnedRenderers: 1 + skipActors: 1 + boundsScanEnabled: 1 + boundsScanRadius: 0 + scanRebuildSeconds: 0 + scanMax: 2048 diff --git a/Assets/WL/Camera/Resources/WL/WLCameraFramingSettings.asset.meta b/Assets/WL/Camera/Resources/WL/WLCameraFramingSettings.asset.meta new file mode 100644 index 000000000..442b876d9 --- /dev/null +++ b/Assets/WL/Camera/Resources/WL/WLCameraFramingSettings.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8bd9e92e9e0e4270891b81d1010bca52 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/WL/Camera/WLCameraFramingSettings.cs b/Assets/WL/Camera/WLCameraFramingSettings.cs new file mode 100644 index 000000000..6fb1e6489 --- /dev/null +++ b/Assets/WL/Camera/WLCameraFramingSettings.cs @@ -0,0 +1,180 @@ +// ───────────────────────────────────────────────────────────────────────────── +// WLCameraFramingSettings.cs — 카메라 가림 해소(구도 값 + 가림 소품 숨김) 값의 단일 출처(SOT · C45) +// +// PD 지시 #813 · 발주서 WL-811o (2026-09-09) · 근거 = qa/W2-4b_재채점_v2.md D-8 · 채점 7-3 = 0 +// 「지형 바위가 PC 를 완전히 가림(01_enter_hud ≈40 %) · 대형 이펙트가 ≈70 %(PD_07)」 +// +// 에셋 경로(고정): Assets/WL/Camera/Resources/WL/WLCameraFramingSettings.asset +// → Resources.Load("WL/WLCameraFramingSettings") 로 1회 로드 후 캐시(기존 설정 SO 관례). +// 에셋이 없거나 enabled = false 면 러너 자체가 뜨지 않는다 = 원본 카메라·소품 100 %(C8). +// +// ■ 왜 새 SO 인가 (실측 · 발주서 §1-1 조건) +// 구도 값(거리·궤도 높이·피치 파라미터)은 **원본 프리팹의 arr_cCamData 에만** 있다: +// Assets/ResWork/Prefabs/Ingame/Main Camera.prefab +// arr_cCamData[0](일반) minDistance 3 · maxDistance 3 · DistanceUp 2 · cameraHeight 65 +// arr_cCamData[1](보스/락타겟) minDistance 2 · maxDistance 3 · DistanceUp 3.5 · cameraHeight 78 +// m_LookAt (0, 1.06, 0) · smooth 4 · FOV 60(세로) · rotateAround = MapZoneData.CamRot_Portal(WL_Nature = 233) +// 기존 WL 카메라 SO 2개에는 이 값이 **없다**: +// · CameraLeadSettings = 이동 리드/후진 피치 **가산분**만(기준 구도 값 없음) +// · WLCombatCameraSettings = 킬캠 배율 + arr_cCamData[**1**] 보스 프리셋만(일반 프리셋 [0] 은 안 건드린다) +// → 발주서 §1-1 의 「WL 설정이 없으면 WL 오버라이드 SO(NEW)」 경로. 프리팹은 수정하지 않는다. +// +// ■ 궤도 수식(RealCamera.Update_Cam 실측) — 이 값들이 화면에서 뭘 바꾸는지 +// rotateVector = Quaternion.Euler(h, r, 0) * Vector3.one (|rotateVector| = √3 · h 와 무관) +// 카메라 = 타겟 + up*DistanceUp − rotateVector*DistanceAway +// · 타겟 발밑 기준 높이 = DistanceUp + D*(sin h − cos h) +// · 수평 거리 = D*√(1 + (sin h + cos h)²) +// · 주시점(0,1.06,0) 내림각 = atan((높이 − 1.06) / 수평거리) +// 실측 h = 65 → 높이 3.451 m · 수평 4.990 m · **내림각 25.6°** ← 지금(가림 발생) +// 참고 h = 78 → 높이 4.311 m · 수평 4.654 m · 내림각 34.9° ← 원본 커밋 d5df1b2c9 「카메라 수정」 이전 값 +// 내림각이 낮을수록 카메라~PC 선분이 지면에 가깝게 깔려 **키 2~3 m 짜리 바위/덤불이 PC 를 덮는다.** +// 선분 높이 y(x) = 1.06 + tan(내림각)·x (x = PC 로부터 카메라 쪽 수평거리) +// h 65 → y(2.5 m) = 2.26 m (3 m 바위가 전 구간에서 가린다) +// h 74 → y(2.5 m) = 2.66 m +// +// 🔴 어셈블리 주의: Assets/WL/Camera/ 에 .asmdef 를 만들지 말 것(RealCamera 가 Assembly-CSharp 에 있다). +// ───────────────────────────────────────────────────────────────────────────── + +using UnityEngine; + +namespace WL.CameraFraming +{ + /// 가림 소품을 숨기는 방식. + public enum OccluderHideMode + { + /// 머티리얼 큐를 보고 자동 선택(투명 큐 + 색 프로퍼티 → 알파 페이드 · 그 외 → 그림자만). + Auto = 0, + /// ShadowCastingMode.ShadowsOnly — 렌더러는 안 보이고 그림자는 남는다(불투명 셰이더 기본 경로 · 머티리얼 무변경). + ShadowsOnly = 1, + /// Renderer.enabled = false — 그림자까지 사라진다. + RendererOff = 2, + /// MaterialPropertyBlock 알파 페이드 — 셰이더가 투명 큐일 때만 실제로 보인다(새 셰이더·머티리얼 0). + MpbAlpha = 3, + } + + [CreateAssetMenu(fileName = "WLCameraFramingSettings", menuName = "WL/Camera Framing Settings", order = 322)] + public sealed class WLCameraFramingSettings : ScriptableObject + { + public const string ResourcesPath = "WL/WLCameraFramingSettings"; + + static WLCameraFramingSettings s_cached; + static bool s_lookupDone; + + /// 설정 에셋(1회 로드 후 캐시). 없으면 null. + public static WLCameraFramingSettings Instance + { + get + { + if (!s_lookupDone) + { + s_cached = Resources.Load(ResourcesPath); + s_lookupDone = true; + } + return s_cached; + } + } + + /// 진단·A/B 전용 런타임 스위치(WLHitFeel.RuntimeDisabled 선례). 에셋을 안 건드리고 이 기능만 끈다. + public static bool RuntimeDisabled; + + /// 기능이 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off. + public static bool Enabled + { + get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; } + } + + /// 테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다. + public static void ClearCache() { s_cached = null; s_lookupDone = false; } + + // ─────────────────────────────────────────── 전체 스위치 + [Header("전체 스위치")] + [Tooltip("끄면 구도 오버라이드·가림 소품 숨김이 전부 비활성 — 원본 카메라 값(arr_cCamData) · 소품 100 %. 롤백 1순위.")] + public bool enabled = true; + + [Tooltip("적용/복원/숨김 결정을 Console 에 남긴다(검증용 · 기본 off · GC 발생).")] + public bool verboseLog = false; + + // ─────────────────────────────────────────── ① 구도 값 오버라이드 + [Header("① 구도 오버라이드 — 맵 진입 경계에서 1회 적용(원값 스냅샷 후 복원 가능)")] + [Tooltip("구도 오버라이드 사용 여부. off 면 arr_cCamData 원값(65° · 3 m · 2)을 그대로 쓴다.")] + public bool framingEnabled = true; + + [Tooltip("arr_cCamData[0](일반 프리셋)의 cameraHeight 로 쓸 궤도 파라미터(도).\n" + + "실제 고도각 = asin((sin h − cos h)/√3) · 실측 65 = 16.2° · 74 = 22.6° · 78 = 26.4°.\n" + + "올릴수록 더 내려다봐서 지형 소품 위로 시선이 지나간다(= 가림 감소). 너무 올리면 탑뷰가 되어 액션 체감이 떨어진다.")] + [Range(50f, 85f)] public float cameraHeight = 74f; + + [Tooltip("arr_cCamData[0].minDistance = maxDistance 로 쓸 궤도 거리(m · 실측 원값 3).\n" + + "소폭만 늘린다 — 거리를 늘리면 카메라~PC 선분이 길어져 그 사이에 들어올 수 있는 소품 수도 늘어난다.")] + [Range(2f, 6f)] public float distance = 3.4f; + + [Tooltip("arr_cCamData[0].DistanceUp — 궤도 중심을 발밑에서 띄우는 높이(m · 실측 원값 2). 음수면 원값을 유지한다.")] + public float distanceUp = 2.2f; + + [Tooltip("맵 진입 경계 검사 주기(초 · unscaled).")] + public float framingPollSeconds = 0.25f; + + [Tooltip("로비에서도 적용한다. 기본 off — 로비는 전투가 없어 가림이 문제되지 않고, 연출 구도를 건드리지 않는다.")] + public bool framingInLobby = false; + + // ─────────────────────────────────────────── ② 가림 소품 자동 숨김 + [Header("② 가림 소품 자동 숨김 — 카메라→PC SphereCast")] + [Tooltip("가림 소품 숨김 사용 여부.")] + public bool occluderFadeEnabled = true; + + [Tooltip("카메라→PC 선분 검사 반경(m). PC 실루엣 폭 정도. 0 이면 Linecast.")] + [Range(0f, 2f)] public float sphereRadius = 0.6f; + + [Tooltip("검사 주기(초 · unscaled). 0.1 = 10 Hz.")] + public float pollSeconds = 0.1f; + + [Tooltip("숨김까지의 시간(초). 불투명 셰이더 경로(ShadowsOnly/RendererOff)에서는 **지연**으로 동작한다\n" + + "— 스쳐 지나가는 소품이 한 프레임 깜빡이는 것을 막는다. MpbAlpha 경로에서는 알파 램프 시간.")] + public float fadeOutSeconds = 0.2f; + + [Tooltip("가림이 풀린 뒤 복귀까지 기다리는 시간(초).")] + public float restoreDelaySeconds = 0.3f; + + [Tooltip("동시에 숨길 수 있는 소품 수 상한. 넘으면 새 소품은 숨기지 않는다(맵이 통째로 사라지는 사고 방지).")] + [Range(1, 8)] public int maxConcurrent = 4; + + [Tooltip("숨김 방식. Auto = 머티리얼이 투명 큐면 알파 페이드, 아니면 그림자만 남기고 숨김.")] + public OccluderHideMode hideMode = OccluderHideMode.Auto; + + [Tooltip("검사 레이어 마스크. 실측 레이어: 0 Default · 3 Actor(PC·몹) · 4 Water · 5 UI · 6 Map · 8 GrassFX.\n" + + "기본 = Default + Map (몹·PC·이펙트·UI 제외). 인스펙터에서 바꾼다.")] + public LayerMask occluderMask = (1 << 0) | (1 << 6); + + [Tooltip("이 문자열들이 이름에 들어간 오브젝트는 **숨기지 않는다**(바닥·지형·물). 소문자 비교 · 쉼표 구분.")] + public string excludeNameContains = "terrain,ground,floor,water,river,navmesh,plane,road,path"; + + [Tooltip("렌더러 바운드 높이가 이 값(m) 미만이면 숨기지 않는다(잔디·자갈은 가리지 않는다).")] + public float minOccluderHeight = 0.8f; + + [Tooltip("렌더러 바운드 최대 변이 이 값(m)을 넘으면 숨기지 않는다(지형 통짜 메시·배경 산 보호).")] + public float maxOccluderSize = 30f; + + [Tooltip("SkinnedMeshRenderer(=캐릭터·몹)는 절대 숨기지 않는다. 안전장치라 켠 채로 둔다.")] + public bool skipSkinnedRenderers = true; + + [Tooltip("Actor 컴포넌트를 가진 오브젝트(PC·몹)는 절대 숨기지 않는다. 안전장치라 켠 채로 둔다.")] + public bool skipActors = true; + + // ─────────────────────────────────────────── ②-b 바운드 스캔 (콜라이더 없는 소품 대응) + [Header("②-b 바운드 스캔 — 🔴 실측: 가림 후보 소품 979개 중 콜라이더 보유는 121개(12.4 %)")] + [Tooltip("SphereCast 는 콜라이더가 있는 것만 잡는다. WL_Nature 의 나무·덤불·바위는 대부분 콜라이더가 없어\n" + + "물리 검사만으로는 화면을 가리는 소품의 12 % 밖에 못 잡는다(프로브 A 절 실측).\n" + + "켜면 맵 진입 때 한 번 훑어 둔 소품 렌더러의 **바운드**로도 가림을 판정한다(콜라이더 불요).")] + public bool boundsScanEnabled = true; + + [Tooltip("바운드 판정에 쓰는 선분 반경(m). PC 실루엣 폭 — sphereRadius 와 같은 뜻이지만 따로 조절할 수 있게 뒀다. 0 이면 sphereRadius 를 쓴다.")] + public float boundsScanRadius = 0f; + + [Tooltip("스캔 목록 재구축 주기(초). 0 = 맵/PC 경계에서만 재구축(정상 · 이때만 할당이 있고 폴링은 GC 0).\n" + + "0 보다 크면 그 주기로도 다시 훑는다(런타임에 소품이 생기는 맵용).")] + public float scanRebuildSeconds = 0f; + + [Tooltip("스캔 목록 상한(맵 소품 수). WL_Nature 실측 가림 후보 979개.")] + public int scanMax = 2048; + } +} diff --git a/Assets/WL/Camera/WLCameraFramingSettings.cs.meta b/Assets/WL/Camera/WLCameraFramingSettings.cs.meta new file mode 100644 index 000000000..f11e4d556 --- /dev/null +++ b/Assets/WL/Camera/WLCameraFramingSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 71e584b928e482c449195aca2f158271 \ No newline at end of file diff --git a/Assets/WL/Combat/Settings/Resources/WL/WLSkillSpectacleSettings.asset b/Assets/WL/Combat/Settings/Resources/WL/WLSkillSpectacleSettings.asset index c6d973b9d..4e657954c 100644 --- a/Assets/WL/Combat/Settings/Resources/WL/WLSkillSpectacleSettings.asset +++ b/Assets/WL/Combat/Settings/Resources/WL/WLSkillSpectacleSettings.asset @@ -157,7 +157,7 @@ MonoBehaviour: lifetime: 0 body: prefab: Effect_WLSwingArc - scale: 2 + scale: 1.5 anchor: 1 forwardOffset: 0.5 useCasterRotation: 1 @@ -505,7 +505,7 @@ MonoBehaviour: lifetime: 0 body: prefab: ShineBulletFlash - scale: 2 + scale: 1.5 anchor: 1 forwardOffset: 0 useCasterRotation: 0