// ───────────────────────────────────────────────────────────────────────────── // MobHitFlash.cs — 몹 피격 점멸(MaterialPropertyBlock · 머티리얼 인스턴스 0 · GC 0) + 처치 디졸브 엣지 세팅 // // PD 지시 #813 · 발주서 WL-811def §1-3 · 구 발주서 WL-802d(주석 처리된 Actor.HitEffect_Color 의 MPB 복원) · 설계안 §C 811f 행 (2026-09-09) // // ■ 무엇을 하나 (HitConfirmed / Killed / Spawned 구독 · 원본 훅 0 · Actor.cs/MobActor.cs 무수정) // HitConfirmed(몹) → 렌더러별 MPB 에 알베도 색(HDR 흰색) + 에미션을 써서 blinkOnSeconds 뒤 비운다. // 같은 프레임 다중 히트(스윙 1회 5~7타)는 켜진 동안 연장 · 꺼진 뒤 blinkOffSeconds 안은 재점멸 안 함. // Killed(몹) → 같은 MPB 에 디졸브 엣지 세기/범위(등급별)를 얹는다. 0.5 s 뒤 원본 Co_Dissolve 가 렌더러 머티리얼을 // 디졸브 머티리얼로 바꾸는 순간 그 프로퍼티가 적용된다(MPB 는 렌더러에 붙어 머티리얼 교체와 무관). // Spawned(몹) → 풀 재사용 몹의 MPB 를 비운다(잔류 0). // ■ 왜 원본 방식(머티리얼 Add/Remove)이 아니라 MPB 인가 // 원본 HitEffect_Color 는 sharedMaterials.ToList()+materials= 대입으로 매 타격 2.3 KB GC + 머티리얼 인스턴스가 생겨 주석 처리됐다. // MPB 는 렌더러당 1개를 캐시해 재사용하므로 타격당 할당 0 · 머티리얼 인스턴스 0 · 드로우 +0(프로퍼티만). // 대신 MPB 가 붙은 렌더러는 그 동안 SRP Batcher 에서 빠진다(≤ 0.03 s · 비운 뒤 복귀 · 디졸브 중 사망체 ≤ 6). // ■ 렌더러 캐시 = Actor.arr_Renderer(protected · Awake 에서 ParticleSystemRenderer 를 뺀 캐시)를 리플렉션으로 1회 읽는다(원본 무수정 · // 같은 필터 결과). 필드를 못 찾으면 GetComponentsInChildren 로 같은 필터를 1회 적용. 액터당 상태는 처음 볼 때 1회만 할당(풀이라 유한). // ■ 프로퍼티는 셰이더별로 1회 Shader.FindPropertyIndex 로 실측해 캐시 · 없으면 그 렌더러는 건너뛴다(예외 0). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using System.Reflection; using UnityEngine; using WL.Combat.Core; namespace WL.Combat.Reaction { public static class MobHitFlash { struct Resolved { public bool hasColor, hasEmission; public int colorId, emissionId; } sealed class State { public Actor actor; public Renderer[] renderers; public MaterialPropertyBlock[] blocks; public Resolved[] resolved; public bool blinkOn, tint; public float blinkOffAt, blinkReadyAt, tintHdr, tintRange; // WL #816zb — 이번 점멸이 처음 켜진 시각. 연장(ExtendCount)이 blinkMaxSeconds 를 넘지 못하게 하는 기준점. public float blinkStartAt; public int activeIndex = -1; // WL #811gh — 임팩트 티어별 점멸 강도. 1 = 기존 동작(티어 시스템 off 이면 항상 1). public float blinkColorMul = 1f, blinkTimeMul = 1f; // WL #813s — 엘리트 상시 색(EliteMarker 가 등록). false = 기존 동작 그대로. public bool elite; public Color eliteAlbedo, eliteEmission; } static readonly Dictionary s_states = new Dictionary(64); static readonly Dictionary s_byShader = new Dictionary(8); static State[] s_active = new State[32]; static int s_activeCount; static FieldInfo s_rendererField; static bool s_rendererFieldLooked; static bool s_dissolveResolved, s_dissolveHasHdr, s_dissolveHasRange; static int s_edgeHdrId, s_edgeRangeId; static readonly List s_tmpRenderers = new List(16); // ── 진단(프로브가 읽는다) public static bool Subscribed; public static int OnCount, OffCount, ExtendCount, ThrottledCount, TintCount, ClearCount, SkippedFilter, SkippedNoProperty, StatesCreated, RendererFieldViaReflection; public static string LastInfo = ""; public static int ActiveCount { get { return s_activeCount; } } public static int StateCount { get { return s_states.Count; } } public static bool DissolveResolved { get { return s_dissolveResolved; } } public static bool DissolveHasHdr { get { return s_dissolveHasHdr; } } public static bool DissolveHasRange { get { return s_dissolveHasRange; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] static void Register() { CombatEvents.HitConfirmed.Add(OnHitConfirmed); CombatEvents.Killed.Add(OnKilled); CombatEvents.Spawned.Add(OnSpawned); Subscribed = true; } static WLHitBlinkSettings St { get { return WLHitBlinkSettings.Instance; } } static bool Verbose { get { var st = St; return st != null && st.verboseLog; } } static void Log(string msg) { var st = St; if (st != null && st.verboseLog) Debug.Log("[MobHitFlash] " + msg); } // ───────────────────────────────────────── 이벤트 static void OnHitConfirmed(in HitConfirmedEvent e) { if (!WLHitBlinkSettings.Enabled) return; var st = St; var victim = e.victim; if (victim == null) return; if (st.targetMobsOnly ? !(victim is MobActor) : victim.IsMainPC()) { SkippedFilter++; return; } if (st.onlyByMainPC && !e.byMainPC) { SkippedFilter++; return; } if (st.skipWhenDamageZero && e.damage <= 0d) { SkippedFilter++; return; } var s = GetState(victim, st); if (s == null) return; // WL #811gh — 임팩트 티어별 점멸 색/시간 배수. 티어 시스템이 꺼져 있으면 (1, 1) = 기존 동작 그대로. float colorMul, timeMul; ImpactTier.GetBlinkModifiers(victim, out colorMul, out timeMul); float now = Time.unscaledTime; // WL #816zb — 연장도 '처음 켜진 시각 + blinkMaxSeconds' 를 넘지 못한다(PD: 몹이 계속 하얗게 보이는 현상). if (s.blinkOn) { s.blinkOffAt = CapOffAt(st, s.blinkStartAt, now + st.blinkOnSeconds * timeMul); ExtendCount++; return; } if (now < s.blinkReadyAt) { ThrottledCount++; return; } if (s.tint && !victim.IsDead()) s.tint = false; // 부활·재사용 뒤 남은 엣지 세팅 정리 WLReactionRunner.Ensure(); s.blinkColorMul = colorMul; s.blinkTimeMul = timeMul; s.blinkOn = true; s.blinkStartAt = now; s.blinkOffAt = CapOffAt(st, now, now + st.blinkOnSeconds * timeMul); Apply(s, st); Activate(s); OnCount++; LastInfo = "on"; if (Verbose) Log("점멸 on " + victim.name + " renderers=" + s.renderers.Length); } static void OnKilled(in KilledEvent e) { if (!WLHitBlinkSettings.Enabled) return; var st = St; if (!st.dissolveEdgeEnabled) return; var victim = e.victim; if (victim == null || !(victim is MobActor)) return; ResolveDissolve(st); if (!s_dissolveHasHdr && !s_dissolveHasRange) { SkippedNoProperty++; return; } var s = GetState(victim, st); if (s == null) return; WLReactionRunner.Ensure(); // 리뷰 Minor-4: 첫 이벤트가 Killed 여도 씬 전환 훅이 붙어 s_states(파괴 액터 참조)가 씬을 넘지 않게 float hdr = e.subRole == eSubRol.Boss ? st.dissolveEdgeHdrBoss : e.subRole == eSubRol.Elite ? st.dissolveEdgeHdrElite : st.dissolveEdgeHdrNormal; float range = e.subRole == eSubRol.Boss ? st.dissolveEdgeRangeBoss : e.subRole == eSubRol.Elite ? st.dissolveEdgeRangeElite : st.dissolveEdgeRangeNormal; if (hdr < 0f && range < 0f) return; s.tint = true; s.tintHdr = hdr; s.tintRange = range; Apply(s, st); TintCount++; if (Verbose) Log("디졸브 엣지 " + victim.name + " sub=" + e.subRole + " hdr=" + hdr + " range=" + range); } static void OnSpawned(in SpawnedEvent e) { if (e.actor == null) return; State s; if (!s_states.TryGetValue(e.actor, out s)) return; if (!s.blinkOn && !s.tint) return; s.blinkOn = false; s.tint = false; s.blinkReadyAt = 0f; var st = St; // 리뷰 Minor-8ⓒ: st == null(에셋 자체가 없음)이면 Apply 를 못 부르지만, 그 상태에서는 Enabled 가 false 라 애초에 MPB 를 쓴 적이 없다 // (블록이 있다면 RestoreAll 의 st == null 경로가 SetPropertyBlock(null) 로 뗀다) → 잔류 0. ⓑ s_states 는 씬 전환(ClearAll)까지 유지 = 풀 크기 상한. if (st != null) Apply(s, st); Deactivate(s); ClearCount++; } // ───────────────────────────────────────── 상태 static State GetState(Actor actor, WLHitBlinkSettings st) { State s; if (s_states.TryGetValue(actor, out s)) return s; var renderers = FindRenderers(actor); if (renderers == null || renderers.Length == 0) return null; s = new State { actor = actor, renderers = renderers, blocks = new MaterialPropertyBlock[renderers.Length], resolved = new Resolved[renderers.Length] }; for (int i = 0; i < renderers.Length; i++) { s.blocks[i] = new MaterialPropertyBlock(); var r = renderers[i]; var mat = r != null ? r.sharedMaterial : null; s.resolved[i] = Resolve(mat != null ? mat.shader : null, st); } s_states.Add(actor, s); StatesCreated++; return s; } /// Actor.arr_Renderer(protected 캐시)를 리플렉션으로 1회 읽는다. 실패하면 같은 필터로 직접 수집. static Renderer[] FindRenderers(Actor actor) { if (!s_rendererFieldLooked) { s_rendererFieldLooked = true; s_rendererField = typeof(Actor).GetField("arr_Renderer", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); } if (s_rendererField != null) { var arr = s_rendererField.GetValue(actor) as Renderer[]; if (arr != null && arr.Length > 0) { RendererFieldViaReflection++; return arr; } } s_tmpRenderers.Clear(); actor.GetComponentsInChildren(true, s_tmpRenderers); int n = 0; for (int i = 0; i < s_tmpRenderers.Count; i++) if (!(s_tmpRenderers[i] is ParticleSystemRenderer)) n++; var result = new Renderer[n]; n = 0; for (int i = 0; i < s_tmpRenderers.Count; i++) if (!(s_tmpRenderers[i] is ParticleSystemRenderer)) result[n++] = s_tmpRenderers[i]; s_tmpRenderers.Clear(); return result; } static Resolved Resolve(Shader shader, WLHitBlinkSettings st) { Resolved r; if (shader == null) return default(Resolved); if (s_byShader.TryGetValue(shader, out r)) return r; r = default(Resolved); var names = st.colorProperties; if (names != null) for (int i = 0; i < names.Length; i++) if (!string.IsNullOrEmpty(names[i]) && shader.FindPropertyIndex(names[i]) >= 0) { r.hasColor = true; r.colorId = Shader.PropertyToID(names[i]); break; } names = st.emissionProperties; if (names != null) for (int i = 0; i < names.Length; i++) if (!string.IsNullOrEmpty(names[i]) && shader.FindPropertyIndex(names[i]) >= 0) { r.hasEmission = true; r.emissionId = Shader.PropertyToID(names[i]); break; } s_byShader.Add(shader, r); if (!r.hasColor && !r.hasEmission) SkippedNoProperty++; if (Verbose) Log("셰이더 실측 " + shader.name + " color=" + r.hasColor + " emission=" + r.hasEmission); return r; } static void ResolveDissolve(WLHitBlinkSettings st) { if (s_dissolveResolved) return; Shader sh = null; var info = InGameInfo.Ins; if (info != null && info.mat_Dissolve != null) sh = info.mat_Dissolve.shader; if (sh == null && !string.IsNullOrEmpty(st.dissolveShaderName)) sh = Shader.Find(st.dissolveShaderName); if (sh == null) return; // 아직 못 찾음(타이틀 등) — 다음 Killed 때 재시도 s_dissolveResolved = true; s_dissolveHasHdr = !string.IsNullOrEmpty(st.dissolveEdgeHdrProperty) && sh.FindPropertyIndex(st.dissolveEdgeHdrProperty) >= 0; s_dissolveHasRange = !string.IsNullOrEmpty(st.dissolveEdgeRangeProperty) && sh.FindPropertyIndex(st.dissolveEdgeRangeProperty) >= 0; if (s_dissolveHasHdr) s_edgeHdrId = Shader.PropertyToID(st.dissolveEdgeHdrProperty); if (s_dissolveHasRange) s_edgeRangeId = Shader.PropertyToID(st.dissolveEdgeRangeProperty); if (Verbose) Log("디졸브 셰이더 실측 " + sh.name + " hdr=" + s_dissolveHasHdr + " range=" + s_dissolveHasRange); } /// 상태(blinkOn · tint)로 렌더러별 MPB 를 다시 구성해 쓴다. 둘 다 꺼져 있으면 블록을 떼어(null) 오버라이드 0 · SRP Batcher 복귀. static void Apply(State s, WLHitBlinkSettings st) { for (int i = 0; i < s.renderers.Length; i++) { var r = s.renderers[i]; if (r == null) continue; var mpb = s.blocks[i]; mpb.Clear(); // WL #813s — 엘리트 상시 색을 먼저 깐다. 아래 점멸이 같은 프로퍼티를 덮으므로 우선순위 = 점멸 > 상시. if (s.elite) { var eres = s.resolved[i]; if (eres.hasColor) mpb.SetColor(eres.colorId, s.eliteAlbedo); if (eres.hasEmission) mpb.SetColor(eres.emissionId, s.eliteEmission); } if (s.blinkOn) { var res = s.resolved[i]; if (res.hasColor) mpb.SetColor(res.colorId, ScaleRgb(st.blinkColor, s.blinkColorMul)); if (st.useEmission && res.hasEmission) mpb.SetColor(res.emissionId, ScaleRgb(st.blinkEmission, s.blinkColorMul)); } if (s.tint) { if (s_dissolveHasHdr && s.tintHdr >= 0f) mpb.SetFloat(s_edgeHdrId, s.tintHdr); if (s_dissolveHasRange && s.tintRange >= 0f) mpb.SetFloat(s_edgeRangeId, s.tintRange); } // 빈 블록을 써도 HasPropertyBlock 은 true 로 남는다(2026-09-09 Title Play 실측) → 오버라이드가 없으면 null 로 블록을 떼어 SRP Batcher 로 복귀 if (mpb.isEmpty) r.SetPropertyBlock(null); else r.SetPropertyBlock(mpb); } } /// /// WL #816zb — 점멸 종료 시각의 상한. startAt + blinkMaxSeconds 를 넘지 못한다. /// blinkMaxSeconds ≤ 0 이면 상한 없음 = 기존 동작 그대로(C8 롤백 경로). /// static float CapOffAt(WLHitBlinkSettings st, float startAt, float wantOffAt) { float max = st.blinkMaxSeconds; if (max <= 0f) return wantOffAt; float limit = startAt + max; return wantOffAt < limit ? wantOffAt : limit; } /// WL #811gh — RGB 만 배수. 알파는 건드리지 않는다(배수 1 이면 원본 Color 를 그대로 돌려준다 = 오차 0). static Color ScaleRgb(Color c, float m) { if (m == 1f) return c; return new Color(c.r * m, c.g * m, c.b * m, c.a); } static void Activate(State s) { if (s.activeIndex >= 0) return; if (s_activeCount == s_active.Length) System.Array.Resize(ref s_active, s_active.Length * 2); s.activeIndex = s_activeCount; s_active[s_activeCount++] = s; } static void Deactivate(State s) { int i = s.activeIndex; if (i < 0) return; int last = --s_activeCount; s_active[i] = s_active[last]; if (s_active[i] != null) s_active[i].activeIndex = i; s_active[last] = null; s.activeIndex = -1; } internal static void Tick(float now) { if (s_activeCount == 0) return; var st = St; for (int i = s_activeCount - 1; i >= 0; i--) { var s = s_active[i]; if (s.blinkOn && (now >= s.blinkOffAt || st == null || !WLHitBlinkSettings.Enabled)) { s.blinkOn = false; s.blinkReadyAt = now + (st != null ? st.blinkOffSeconds : 0f); if (st != null) Apply(s, st); OffCount++; LastInfo = "off"; } if (!s.blinkOn) Deactivate(s); } } /// 모든 몹의 MPB 를 비운다(러너 파괴 · 앱 종료 · 프로브 정리). 상태 캐시는 유지. public static void RestoreAll() { var st = St; foreach (var kv in s_states) { var s = kv.Value; if (!s.blinkOn && !s.tint && !s.elite) continue; s.blinkOn = false; s.tint = false; s.elite = false; if (st != null) Apply(s, st); else for (int i = 0; i < s.renderers.Length; i++) if (s.renderers[i] != null) { s.blocks[i].Clear(); s.renderers[i].SetPropertyBlock(null); } } for (int i = 0; i < s_activeCount; i++) { s_active[i].activeIndex = -1; s_active[i] = null; } s_activeCount = 0; } /// 씬 전환: MPB 를 비우고 상태 캐시(파괴된 Actor 참조)를 버린다. public static void ClearAll() { RestoreAll(); s_states.Clear(); } // ───────────────────────────────────────── WL #813s — 엘리트 상시 색 (EliteMarker 가 부른다) // 렌더러 MPB 의 소유자를 하나로 유지하기 위한 진입점이다. 별도 MPB 를 쓰면 이 클래스의 Apply(=Clear 후 재구성)가 // 첫 피격 점멸에서 통째로 지운다. 상태만 넣고 합성은 기존 Apply 가 한다(우선순위 = 점멸 > 상시). // WLHitBlinkSettings 의 enabled 와는 무관하다 — 이 SO 는 프로퍼티명 목록(셰이더 실측)의 출처로만 쓴다. /// 엘리트 상시 색을 건다. 렌더러·프로퍼티를 못 찾으면 false(색 없음). public static bool SetEliteTint(Actor actor, Color albedo, Color emission) { var st = St; if (actor == null || st == null) return false; var s = GetState(actor, st); if (s == null) return false; bool any = false; for (int i = 0; i < s.resolved.Length; i++) if (s.resolved[i].hasColor || s.resolved[i].hasEmission) { any = true; break; } if (!any) { SkippedNoProperty++; return false; } s.elite = true; s.eliteAlbedo = albedo; s.eliteEmission = emission; Apply(s, st); if (Verbose) Log("엘리트 상시 색 on " + actor.name + " renderers=" + s.renderers.Length); return true; } /// 엘리트 상시 색을 뗀다(처치·정리). 점멸/디졸브 상태는 건드리지 않는다. public static void ClearEliteTint(Actor actor) { State s; if (actor == null || !s_states.TryGetValue(actor, out s) || !s.elite) return; s.elite = false; var st = St; if (st != null) Apply(s, st); else for (int i = 0; i < s.renderers.Length; i++) if (s.renderers[i] != null) { s.blocks[i].Clear(); s.renderers[i].SetPropertyBlock(null); } } /// 프로브용: 이 액터에 엘리트 상시 색이 걸려 있는가. public static bool HasEliteTint(Actor actor) { State s; return actor != null && s_states.TryGetValue(actor, out s) && s.elite; } /// 프로브용: 특정 액터의 렌더러가 지금 프로퍼티 블록을 갖고 있는가(잔류 검사). public static bool HasAnyBlock(Actor actor) { State s; if (actor == null || !s_states.TryGetValue(actor, out s)) return false; for (int i = 0; i < s.renderers.Length; i++) if (s.renderers[i] != null && s.renderers[i].HasPropertyBlock()) return true; return false; } /// 프로브용 카운터 초기화. public static void ResetDiagnostics() { OnCount = OffCount = ExtendCount = ThrottledCount = TintCount = ClearCount = SkippedFilter = SkippedNoProperty = StatesCreated = RendererFieldViaReflection = 0; LastInfo = ""; } } }