// ───────────────────────────────────────────────────────────────────────────── // DashAfterImage.cs — 대시 잔상(811g-② · SkinnedMeshRenderer.BakeMesh 고스트 · 원본 훅 0 · 새 셰이더/머티리얼 에셋 0) // // PD 지시 #813 · 발주서 WL-811gh §1-3 · 설계안 §C 811g 행 (2026-09-09) // // ■ 무엇을 하나 (CombatEvents.Dash 구독 · DashDriver 가 이미 RaiseDashBegan 을 발행한다 — 실측 DashDriver.cs:158) // 대시 시작 → 0.06 s 간격으로 고스트 3개를 남기고 각각 0.25 s 동안 알파를 0 으로 줄인다. // 고스트 = PC 의 SkinnedMeshRenderer 를 그 순간 포즈 그대로 BakeMesh 한 정적 메시. // // ■ 왜 BakeMesh 인가 / 예산 // · 스킨 애니메이션을 그 프레임 포즈로 굳혀야 잔상이 '그 자세' 로 남는다. 머티리얼 교체·셰이더 추가가 필요 없다. // · 메시·GameObject·MaterialPropertyBlock 은 전부 **풀**(기본 6)에서 재사용한다 → 2회차부터 할당 0. // · BakeMesh 호출 = 대시 1회당 (고스트 수 × SkinnedMeshRenderer 수) · SO 상한(ghostCount · ghostMaxRenderers). // · 런타임 머티리얼은 **1개**(모든 고스트가 공유 · 색/알파는 MaterialPropertyBlock) → SetPass 증가는 반투명 큐 1개분. // · EffectBudget(AfterImage · 코어 SO afterImageMax) 경유 · 절두체 밖이면 스폰 자체를 건너뛴다. // // ■ 색 (발주서: 클래스 원소 색 · 없으면 흰색) // ① 최근 시전 스킬의 추가효과(eSkillExtraType · 811i 와 같은 기준) 색 → ② 클래스 ID(Actor.Get_ID()) 색 → ③ 기본(흰색). // ①·② 표는 SO 에 있고 비어 있으면 자동으로 다음 단계로 내려간다. // // ■ 대시가 없는 클래스 // AttackStepDriver.StepCount(정적 카운터) 변화를 폴링해 공격 전진 시작에도 1개(옵션 SO · 기본 off). // 폴링이라 AttackStepDriver·원본 파일을 한 줄도 건드리지 않는다. // // ■ C8 롤백 — 에셋이 없거나 enabled=0 또는 ghostEnabled=0 이면 구독만 하고 아무 것도 만들지 않는다. // // 🔴 어셈블리 주의: Assets/WL/Combat/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using WL.Combat.Core; namespace WL.Combat.Reaction { public static class DashAfterImage { sealed class Ghost { public GameObject root; public Transform[] parts; public MeshFilter[] filters; public MeshRenderer[] renders; public Mesh[] meshes; public MaterialPropertyBlock mpb; public bool active, budget; public int used; public float startAt, endAt; public Color color; } static Ghost[] s_pool; static Material s_material; static bool s_materialTried; static int s_colorPropId = -1; static bool s_hasColorProp; static readonly List s_tmpSmr = new List(8); static SkinnedMeshRenderer[] s_smr; static Actor s_smrOwner; // ── 예약 스폰(간격을 두고 하나씩) static Actor s_pendingActor; static int s_pendingLeft; static float s_nextSpawnAt; // ── 최근 스킬 원소 기억 static int s_lastElement = -1; static float s_lastElementAt = -999f; // ── AttackStep 폴링 static int s_lastStepCount = -1; // ── 진단(프로브가 읽는다) public static bool Subscribed; public static int SpawnCount, SkippedBudget, SkippedOffscreen, SkippedNoRenderer, SkippedNoShader, SkippedPoolFull, ReleasedCount, PoolCreated, BakeCount, StepSpawnCount; public static string LastInfo = ""; public static Color LastColor; public static int ActiveCount { get { int n = 0; if (s_pool != null) for (int i = 0; i < s_pool.Length; i++) if (s_pool[i] != null && s_pool[i].active) n++; return n; } } public static int PoolSize { get { return s_pool != null ? s_pool.Length : 0; } } public static bool MaterialReady { get { return s_material != null; } } public static string MaterialShaderName { get { return s_material != null && s_material.shader != null ? s_material.shader.name : ""; } } static WLImpactTierSettings St { get { return WLImpactTierSettings.Instance; } } static bool Verbose { get { var st = St; return st != null && st.verboseLog; } } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterAssembliesLoaded)] static void Register() { CombatEvents.Dash.Add(OnDash); CombatEvents.SkillFired.Add(OnSkillFired); CombatEvents.SkillCast.Add(OnSkillCast); Subscribed = true; } // ───────────────────────────────────────── 이벤트 static void OnDash(in DashEvent e) { if (!e.began) return; var st = St; if (st == null || !WLImpactTierSettings.Enabled || !st.ghostEnabled) return; Actor actor = e.actor != null ? (Actor)e.actor : MyValue.MyPC; if (actor == null) return; Schedule(st, actor, Mathf.Max(0, st.ghostCount)); } static void OnSkillFired(in SkillFiredEvent e) { RememberElement(e.actor, e.skill); } static void OnSkillCast(in SkillCastEvent e) { RememberElement(e.actor, e.skill); } static void RememberElement(Actor actor, SkillListTableData skill) { if (skill == null || actor == null || !actor.IsMainPC()) return; s_lastElement = (int)skill.e_SkillExtraType; s_lastElementAt = ImpactRunner.Now; } static void Schedule(WLImpactTierSettings st, Actor actor, int count) { if (count <= 0) return; s_pendingActor = actor; s_pendingLeft = count; s_nextSpawnAt = ImpactRunner.Now; // 첫 개는 즉시 ImpactRunner.Ensure(); } // ───────────────────────────────────────── 틱(ImpactRunner) internal static void Tick(float now) { var st = St; bool on = st != null && WLImpactTierSettings.Enabled && st.ghostEnabled; // ① 예약 스폰 if (on && s_pendingLeft > 0 && now >= s_nextSpawnAt) { Spawn(st, s_pendingActor); s_pendingLeft--; s_nextSpawnAt = now + Mathf.Max(0.005f, st.ghostIntervalSeconds); if (s_pendingLeft <= 0) s_pendingActor = null; } else if (!on && s_pendingLeft > 0) { s_pendingLeft = 0; s_pendingActor = null; } // ② 공격 전진(대시 없는 클래스) 폴링 — 원본·AttackStepDriver 무수정 if (on && st.ghostOnAttackStep) { int sc = WL.Combat.AttackStepDriver.StepCount; if (s_lastStepCount < 0) s_lastStepCount = sc; else if (sc != s_lastStepCount) { s_lastStepCount = sc; if (s_pendingLeft <= 0) { Schedule(st, MyValue.MyPC, Mathf.Max(0, st.ghostAttackStepCount)); StepSpawnCount++; } } } // ③ 페이드·반환 if (s_pool == null) return; for (int i = 0; i < s_pool.Length; i++) { var g = s_pool[i]; if (g == null || !g.active) continue; if (!on || now >= g.endAt) { Retire(g); continue; } float k = Mathf.Clamp01((now - g.startAt) / Mathf.Max(0.01f, g.endAt - g.startAt)); SetAlpha(g, g.color.a * (1f - k)); } } // ───────────────────────────────────────── 스폰 static void Spawn(WLImpactTierSettings st, Actor actor) { if (actor == null) { SkippedNoRenderer++; return; } if (st.ghostSkipOffscreen && !EffectBudget.IsInView(actor.Get_position())) { SkippedOffscreen++; return; } var smr = GetRenderers(st, actor); if (smr == null || smr.Length == 0) { SkippedNoRenderer++; return; } if (!EnsureMaterial(st)) { SkippedNoShader++; return; } var g = Rent(st); if (g == null) { SkippedPoolFull++; return; } bool budget = true; if (st.ghostUseEffectBudget) { budget = EffectBudget.TryAcquire(EffectBudgetKind.AfterImage); if (!budget) { SkippedBudget++; Return(g); return; } } g.budget = budget; int n = Mathf.Min(smr.Length, g.parts.Length); int used = 0; for (int i = 0; i < n; i++) { var r = smr[i]; if (r == null || !r.enabled || r.sharedMesh == null) continue; r.BakeMesh(g.meshes[used]); BakeCount++; g.filters[used].sharedMesh = g.meshes[used]; var rt = r.transform; g.parts[used].SetPositionAndRotation(rt.position, rt.rotation); g.parts[used].localScale = rt.lossyScale; g.parts[used].gameObject.SetActive(true); used++; } for (int i = used; i < g.parts.Length; i++) g.parts[i].gameObject.SetActive(false); if (used == 0) { if (g.budget) EffectBudget.Release(EffectBudgetKind.AfterImage); Return(g); SkippedNoRenderer++; return; } g.used = used; g.color = ResolveColor(st, actor); LastColor = g.color; g.startAt = ImpactRunner.Now; g.endAt = g.startAt + Mathf.Max(0.02f, st.ghostFadeSeconds); g.active = true; g.root.SetActive(true); SetAlpha(g, g.color.a); SpawnCount++; LastInfo = "ghost " + used + " part(s)"; if (Verbose) Debug.Log("[DashAfterImage] 고스트 " + used + "개 파트 · color=" + g.color + " active=" + ActiveCount); } static void Retire(Ghost g) { g.active = false; g.root.SetActive(false); if (g.budget) { EffectBudget.Release(EffectBudgetKind.AfterImage); g.budget = false; } ReleasedCount++; } static void Return(Ghost g) { g.active = false; g.root.SetActive(false); } // ───────────────────────────────────────── 풀 static Ghost Rent(WLImpactTierSettings st) { EnsurePool(st); for (int i = 0; i < s_pool.Length; i++) if (s_pool[i] != null && !s_pool[i].active) return s_pool[i]; return null; } static void EnsurePool(WLImpactTierSettings st) { int size = Mathf.Clamp(st.ghostPoolSize, 1, 24); int parts = Mathf.Clamp(st.ghostMaxRenderers, 1, 8); if (s_pool != null && s_pool.Length == size && s_pool[0] != null && s_pool[0].parts.Length == parts) return; DestroyPool(); s_pool = new Ghost[size]; for (int i = 0; i < size; i++) s_pool[i] = CreateGhost(st, i, parts); PoolCreated++; } static Ghost CreateGhost(WLImpactTierSettings st, int index, int parts) { var root = new GameObject("__WLDashGhost" + index); root.hideFlags = HideFlags.DontSave; if (Application.isPlaying) Object.DontDestroyOnLoad(root); // 에디트 모드(프로브)에서는 경고가 나므로 부르지 않는다 root.SetActive(false); var g = new Ghost { root = root, parts = new Transform[parts], filters = new MeshFilter[parts], renders = new MeshRenderer[parts], meshes = new Mesh[parts], mpb = new MaterialPropertyBlock() }; for (int i = 0; i < parts; i++) { var go = new GameObject("part" + i); go.transform.SetParent(root.transform, false); go.SetActive(false); g.parts[i] = go.transform; g.filters[i] = go.AddComponent(); var mr = go.AddComponent(); mr.sharedMaterial = s_material; mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; mr.receiveShadows = false; mr.lightProbeUsage = UnityEngine.Rendering.LightProbeUsage.Off; mr.reflectionProbeUsage = UnityEngine.Rendering.ReflectionProbeUsage.Off; g.renders[i] = mr; var mesh = new Mesh(); mesh.name = "__WLDashGhostMesh" + index + "_" + i; mesh.hideFlags = HideFlags.DontSave; mesh.MarkDynamic(); g.meshes[i] = mesh; } return g; } static void SetAlpha(Ghost g, float alpha) { if (!s_hasColorProp) return; var c = g.color; c.a = alpha; g.mpb.Clear(); g.mpb.SetColor(s_colorPropId, c); for (int i = 0; i < g.used; i++) if (g.renders[i] != null) g.renders[i].SetPropertyBlock(g.mpb); } // ───────────────────────────────────────── 머티리얼(런타임 1개 · 에셋 0) static bool EnsureMaterial(WLImpactTierSettings st) { if (s_material != null) return true; if (s_materialTried) return false; s_materialTried = true; Shader sh = null; var names = st.ghostShaderNames; if (names != null) for (int i = 0; i < names.Length && sh == null; i++) if (!string.IsNullOrEmpty(names[i])) sh = Shader.Find(names[i]); if (sh == null) { if (Verbose) Debug.Log("[DashAfterImage] 셰이더 후보를 못 찾음 — 잔상 비활성"); return false; } var m = new Material(sh); m.name = "__WLDashGhostMat"; m.hideFlags = HideFlags.DontSave; // URP Unlit 반투명 세팅(런타임) — 이 값들은 렌더링 배관이라 SO 튜닝 값이 아니다. if (m.HasProperty("_Surface")) m.SetFloat("_Surface", 1f); if (m.HasProperty("_Blend")) m.SetFloat("_Blend", 0f); if (m.HasProperty("_SrcBlend")) m.SetFloat("_SrcBlend", (float)UnityEngine.Rendering.BlendMode.SrcAlpha); if (m.HasProperty("_DstBlend")) m.SetFloat("_DstBlend", (float)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); if (m.HasProperty("_ZWrite")) m.SetFloat("_ZWrite", 0f); if (m.HasProperty("_AlphaClip")) m.SetFloat("_AlphaClip", 0f); m.EnableKeyword("_SURFACE_TYPE_TRANSPARENT"); m.DisableKeyword("_ALPHATEST_ON"); m.SetOverrideTag("RenderType", "Transparent"); m.renderQueue = st.ghostRenderQueue > 0 ? st.ghostRenderQueue : 3000; s_hasColorProp = false; var props = st.ghostColorProperties; if (props != null) for (int i = 0; i < props.Length; i++) if (!string.IsNullOrEmpty(props[i]) && sh.FindPropertyIndex(props[i]) >= 0) { s_colorPropId = Shader.PropertyToID(props[i]); s_hasColorProp = true; break; } s_material = m; // 풀이 먼저 만들어졌으면 머티리얼을 붙여 준다. if (s_pool != null) for (int i = 0; i < s_pool.Length; i++) { var g = s_pool[i]; if (g == null) continue; for (int j = 0; j < g.renders.Length; j++) if (g.renders[j] != null) g.renders[j].sharedMaterial = s_material; } if (Verbose) Debug.Log("[DashAfterImage] 고스트 머티리얼 = " + sh.name + " colorProp=" + s_hasColorProp); return true; } // ───────────────────────────────────────── 렌더러 캐시 static SkinnedMeshRenderer[] GetRenderers(WLImpactTierSettings st, Actor actor) { if (s_smrOwner == actor && s_smr != null) { bool ok = true; for (int i = 0; i < s_smr.Length; i++) if (s_smr[i] == null) { ok = false; break; } if (ok) return s_smr; } s_tmpSmr.Clear(); actor.GetComponentsInChildren(true, s_tmpSmr); int max = Mathf.Clamp(st.ghostMaxRenderers, 1, 8); int n = Mathf.Min(s_tmpSmr.Count, max); s_smr = new SkinnedMeshRenderer[n]; for (int i = 0; i < n; i++) s_smr[i] = s_tmpSmr[i]; s_tmpSmr.Clear(); s_smrOwner = actor; return s_smr; } // ───────────────────────────────────────── 색 static Color ResolveColor(WLImpactTierSettings st, Actor actor) { Color c = st.ghostColorDefault; bool found = false; if (st.ghostUseSkillElement && s_lastElement > 0 && ImpactRunner.Now - s_lastElementAt <= Mathf.Max(0f, st.ghostElementMemorySeconds)) { var arr = st.ghostElementColors; if (arr != null) for (int i = 0; i < arr.Length; i++) if (arr[i].extraType == s_lastElement) { c = arr[i].color; found = true; break; } } if (!found) { var arr = st.ghostClassColors; if (arr != null) { int classId = actor.Get_ID(); for (int i = 0; i < arr.Length; i++) if (arr[i].classId == classId) { c = arr[i].color; found = true; break; } } } c.a = Mathf.Clamp01(st.ghostStartAlpha); return c; } // ───────────────────────────────────────── 정리 /// 진행 중인 고스트를 전부 끄고 예산을 돌려준다(러너 파괴 · 앱 종료 · 프로브 정리). 풀은 유지. public static void RestoreAll() { s_pendingLeft = 0; s_pendingActor = null; if (s_pool == null) return; for (int i = 0; i < s_pool.Length; i++) { var g = s_pool[i]; if (g != null && g.active) Retire(g); } } /// 씬 전환: 고스트를 끄고 렌더러 캐시(파괴된 Actor 참조)를 버린다. 풀 오브젝트는 DontDestroyOnLoad 라 유지. public static void ClearAll() { RestoreAll(); s_smr = null; s_smrOwner = null; s_lastStepCount = -1; } /// 풀·머티리얼·메시를 전부 파괴한다(프로브 정리 · 도메인 리로드 전). public static void DestroyPool() { if (s_pool != null) { for (int i = 0; i < s_pool.Length; i++) { var g = s_pool[i]; if (g == null) continue; if (g.active && g.budget) EffectBudget.Release(EffectBudgetKind.AfterImage); for (int j = 0; j < g.meshes.Length; j++) if (g.meshes[j] != null) DestroySafe(g.meshes[j]); if (g.root != null) DestroySafe(g.root); } s_pool = null; } } static void DestroySafe(Object o) { if (o == null) return; if (Application.isPlaying) Object.Destroy(o); else Object.DestroyImmediate(o); } /// 프로브용: 강제로 즉시 1개 남긴다(예약 없이). public static void ForceSpawn(Actor actor) { var st = St; if (st == null) return; Spawn(st, actor != null ? actor : MyValue.MyPC); } /// 프로브용: 예약 스폰 남은 수. public static int PendingCount { get { return s_pendingLeft; } } /// /// 프로브용 — 가짜 Dash(began) 를 구독자 경로 그대로 흘린다. /// 에디트 모드에서는 RuntimeInitializeOnLoadMethod 가 돌지 않아 구독이 없으므로 핸들러를 직접 부른다. /// public static void ProbeDashBegan(PCActor actor) { var e = new DashEvent { actor = actor, began = true, distance = 0f, endedInAttack = false, time = Time.unscaledTime, frame = Time.frameCount }; OnDash(in e); } /// 프로브용 — 예약된 스폰을 간격 대기 없이 전부 즉시 처리한다(에디트 모드에는 프레임이 없다). public static void ProbeFlushPending(Actor actor) { var st = St; if (st == null) return; int guard = 0; while (s_pendingLeft > 0 && guard++ < 32) { Spawn(st, s_pendingActor != null ? s_pendingActor : actor); s_pendingLeft--; } s_pendingActor = null; } /// 프로브용 — 지금 살아 있는 고스트를 강제로 만료시킨다(페이드 끝 상태 확인). public static void ProbeExpireAll() { if (s_pool == null) return; for (int i = 0; i < s_pool.Length; i++) { var g = s_pool[i]; if (g != null && g.active) Retire(g); } } /// 프로브용 카운터 초기화. public static void ResetDiagnostics() { SpawnCount = SkippedBudget = SkippedOffscreen = SkippedNoRenderer = SkippedNoShader = SkippedPoolFull = ReleasedCount = PoolCreated = BakeCount = StepSpawnCount = 0; LastInfo = ""; s_lastStepCount = -1; } } }