// ───────────────────────────────────────────────────────────────────────────── // 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; public int activeIndex = -1; } 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; float now = Time.unscaledTime; if (s.blinkOn) { s.blinkOffAt = now + st.blinkOnSeconds; ExtendCount++; return; } // 같은 프레임 다중 히트 → 연장 if (now < s.blinkReadyAt) { ThrottledCount++; return; } if (s.tint && !victim.IsDead()) s.tint = false; // 부활·재사용 뒤 남은 엣지 세팅 정리 WLReactionRunner.Ensure(); s.blinkOn = true; s.blinkOffAt = now + st.blinkOnSeconds; 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; 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; 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 를 다시 구성해 쓴다. 둘 다 꺼져 있으면 빈 블록 = 오버라이드 0. 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(); if (s.blinkOn) { var res = s.resolved[i]; if (res.hasColor) mpb.SetColor(res.colorId, st.blinkColor); if (st.useEmission && res.hasEmission) mpb.SetColor(res.emissionId, st.blinkEmission); } 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); } r.SetPropertyBlock(mpb); } } 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) continue; s.blinkOn = false; s.tint = 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(s.blocks[i]); } } 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(); } /// 프로브용: 특정 액터의 렌더러가 지금 프로퍼티 블록을 갖고 있는가(잔류 검사). 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 = ""; } } }