// ───────────────────────────────────────────────────────────────────────────── // NullMaterialGuard.cs — 빈 머티리얼 슬롯 렌더러 비활성(D-40 마젠타 폴리곤) // // PD 지시 #813 · 발주서 WL-813m3 §1-4 · 근거 = Q9 `qa/W2-9_재채점_v7.md` §6 // // ■ Q9 가 특정한 5종(전부 `InGameInfo/Effects/**` · `slot=0` 이 null) // Effect_Hit_10501(Clone)/Pos // Effect_Hit_AnubisSkill2(Clone)/vfx_Hit_Orb08_Orange/Light // Effect_Poison(Clone)/GameObject // Select_WindFairy(Clone)/PowerOrbSciFi05 (+ /OrbGlow · /Sparks) // Hit_P_Batty_Meele(Clone)/FX_Yellow_Hit/Pos // 🔴 다섯 개 **전부 원본(힘민지) 이펙트 풀**이 스폰한 것이다 — WL 이 만든 것이 아니다. // 그래서 이 가드는 WL 스폰 지점에 훅을 넣는 대신 **풀 루트를 스윕**한다 // (그 스폰 파일들 = SkillSpectacle · LootBurst · LevelUpBurst 는 이번 발주의 수정 대상이 아니다). // WL 스폰 지점에서 부를 공개 API `Inspect(GameObject)` 도 함께 둔다(후속 1줄 연결용). // // ■ 어떻게 // 루트(`InGameInfo.tf_Effects` · `tf_Objs` · 811i Live/Pool · 메인 PC)의 **직속 자식**을 주기적으로 본다. // 처음 보는 인스턴스만 하위 렌더러를 전수 검사하고(인스턴스 ID 캐시 = 스폰 1회 검사), // `sharedMaterials` 에 null 슬롯이 있으면 `Renderer.enabled = false`(SO 로 변경 가능). // 풀은 같은 인스턴스를 재사용하므로 한 번 끈 렌더러는 계속 꺼져 있다. // // ■ GC 0 // `GetComponentsInChildren(true, List)` · `Renderer.GetSharedMaterials(List)` 둘 다 비할당 오버로드. // 캐시는 `HashSet`(성장 때만 할당) · 문자열은 **증거 표본 pathSampleMax 개까지만** 만든다. // // ■ C8 롤백: `WLNullMaterialSettings.enabled = 0` → 러너도 스윕도 없다(마젠타 그대로 = 원본 100 %). // 🔴 원본 프리팹 수정 0 — 근본 수리는 아트 발주(완료보고 「아트 수정 목록」). // 🔴 Debug.LogError/LogException 금지(813z). // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using System; using System.Collections.Generic; using System.Text; using UnityEngine; namespace WL.Combat.Reaction { /// 빈 머티리얼 슬롯 렌더러를 찾아 끈다. 정적 · 스폰 1회 검사 · GC 0. public static class NullMaterialGuard { // ── 진단(프로브가 읽는다) /// 스윕 횟수 · 검사한 오브젝트 수 · 검사한 렌더러 수. public static int SweepCount, InspectedObjects, InspectedRenderers; /// 빈 슬롯을 가진 렌더러 수 · 실제로 끈 수. public static int NullMaterialRenderers, DisabledRenderers; /// 결함이 있던 오브젝트 수(렌더러가 아니라 스폰 단위). public static int OffendingObjects; /// 마지막으로 끈 경로 · 마지막 스윕 시각(unscaled). public static string LastPath = ""; public static float LastSweepTime; /// 증거 표본(경로 문자열 · pathSampleMax 개까지). public static readonly List Samples = new List(16); static readonly HashSet s_inspected = new HashSet(); static readonly List s_rends = new List(32); static readonly List s_mats = new List(8); static StringBuilder s_sb; static float s_nextSweep; static WLNullMaterialSettings St { get { return WLNullMaterialSettings.Instance; } } /// 캐시에 담긴 인스턴스 수(프로브). public static int CacheCount { get { return s_inspected.Count; } } // ───────────────────────────────────────── 스윕 /// 러너가 매 프레임 부른다. 주기·비활성 판정은 여기서 한다(C8 = 즉시 반환). public static void Tick(float unscaledNow) { var st = St; if (st == null || !WLNullMaterialSettings.Enabled) return; if (unscaledNow < s_nextSweep) return; s_nextSweep = unscaledNow + Mathf.Max(0f, st.sweepIntervalSeconds); Sweep(st, unscaledNow); } static void Sweep(WLNullMaterialSettings st, float unscaledNow) { SweepCount++; LastSweepTime = unscaledNow; if (s_inspected.Count > Mathf.Max(64, st.inspectCacheMax)) s_inspected.Clear(); int budget = Mathf.Max(1, st.maxInspectPerSweep); var info = InGameInfo.Ins; if (info != null) { if (st.sweepIngameEffects) budget = SweepRoot(info.tf_Effects, st, budget); if (st.sweepIngameObjs) budget = SweepRoot(info.tf_Objs, st, budget); } if (st.sweepSkillSpectacle && SkillSpectacleRunner.Exists) { var runner = SkillSpectacleRunner.Ensure(); // Exists 가 true 라 새로 만들지 않는다 if (runner != null) { budget = SweepRoot(runner.LiveRoot, st, budget); budget = SweepRoot(runner.PoolRoot, st, budget); } } if (st.sweepMainPc) { var pc = MyValue.MyPC; if (pc != null) budget = SweepRoot(pc.transform, st, budget); } } /// 루트의 직속 자식 중 처음 보는 것만 검사한다. 남은 예산을 돌려준다. static int SweepRoot(Transform root, WLNullMaterialSettings st, int budget) { if (root == null || budget <= 0) return budget; int n = root.childCount; for (int i = 0; i < n && budget > 0; i++) { var child = root.GetChild(i); if (child == null) continue; int id = child.GetInstanceID(); if (s_inspected.Contains(id)) continue; s_inspected.Add(id); budget--; InspectTransform(child, st); } return budget; } // ───────────────────────────────────────── 검사 /// WL 스폰 지점용 공개 API — 스폰 직후 1회 부르면 된다(캐시 · 중복 호출 안전). public static void Inspect(GameObject go) { var st = St; if (st == null || !WLNullMaterialSettings.Enabled || go == null) return; int id = go.transform.GetInstanceID(); if (s_inspected.Contains(id)) return; s_inspected.Add(id); InspectTransform(go.transform, st); } static void InspectTransform(Transform t, WLNullMaterialSettings st) { InspectedObjects++; s_rends.Clear(); try { t.GetComponentsInChildren(true, s_rends); } catch (Exception) { return; } bool offending = false; for (int i = 0; i < s_rends.Count; i++) { var r = s_rends[i]; if (r == null) continue; InspectedRenderers++; if (!HasNullSlot(r, st)) continue; NullMaterialRenderers++; offending = true; if (Samples.Count < Mathf.Max(0, st.pathSampleMax)) { string path = PathOf(r.transform); Samples.Add(path); LastPath = path; } switch (st.action) { case NullMaterialAction.DisableRenderer: if (r.enabled) { r.enabled = false; DisabledRenderers++; } break; case NullMaterialAction.DeactivateObject: if (r.gameObject.activeSelf) { r.gameObject.SetActive(false); DisabledRenderers++; } break; default: break; // MeasureOnly — 계측만 } if (st.verboseLog) Debug.Log("[WL813m3] nullMaterial " + (st.action == NullMaterialAction.MeasureOnly ? "measured " : "disabled ") + (Samples.Count > 0 ? LastPath : r.name)); // 🔴 Log 만(813z) } if (offending) OffendingObjects++; } static bool HasNullSlot(Renderer r, WLNullMaterialSettings st) { s_mats.Clear(); try { r.GetSharedMaterials(s_mats); } catch (Exception) { return false; } if (s_mats.Count == 0) return st.treatEmptyArrayAsNull; for (int i = 0; i < s_mats.Count; i++) if (s_mats[i] == null) return true; return false; } static string PathOf(Transform t) { if (s_sb == null) s_sb = new StringBuilder(160); else s_sb.Length = 0; s_sb.Append(t.name); var p = t.parent; int guard = 0; while (p != null && guard++ < 12) { s_sb.Insert(0, '/'); s_sb.Insert(0, p.name); p = p.parent; } return s_sb.ToString(); } /// 프로브용: 카운터·캐시 초기화. public static void ResetDiagnostics() { SweepCount = InspectedObjects = InspectedRenderers = 0; NullMaterialRenderers = DisabledRenderers = OffendingObjects = 0; LastPath = ""; LastSweepTime = 0f; s_nextSweep = 0f; Samples.Clear(); s_inspected.Clear(); } } }