// ───────────────────────────────────────────────────────────────────────────── // 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); } } }