// ───────────────────────────────────────────────────────────────────────────── // WLCharacterBacklight.cs — 캐릭터 역광 패스 덧붙이기 + 가상 역광 방향 갱신 + 번짐(Bloom) 스코프 // // 2026-09-17 PD 「우리 게임의 캐릭터가 조금 더 돋보이게 · 적절한 방법을 검토해 비주얼 완성도 강화」. // // ■ 어디서 불리나 // `WLCharacterLook.Apply(root)` 끝(= LH_M05 프리팹의 모든 인스턴스 · 섬 교체 모델 · 던전 PC)에서 `Apply(root)`. // 재질 배열 **뒤에** `WL/CharBacklight` 런타임 재질 1장을 덧붙인다(서브메시 1개 = 메시 전체가 가산으로 한 번 더 그려진다). // 피부 재질(이름 힌트)은 피부 투과를 켠 재질을, 나머지는 끈 재질을 쓴다. 몇 번 불러도 결과가 같다(멱등). // // ■ 가상 역광 방향 // 드라이버(숨은 오브젝트 1개)가 LateUpdate 마다 카메라에서 「뒤 + 위 + 옆」 방향을 계산해 전역 `_WLBacklightDir` 에 쓴다. // // ■ 번짐(Bloom) // 설정의 씬이 떠 있을 때만 전역 Volume(Bloom 1개) + 메인 카메라 후처리/HDR 을 켜고, 벗어나면 원래 값으로 되돌린다. // // 🔴 본체 재질·셰이더 그래프·프리팹 0줄 · Debug.LogError / Debug.LogException 을 쓰지 않는다(813z 규칙). // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; using UnityEngine.Rendering.Universal; using UnityEngine.SceneManagement; namespace WL.Look.Character { public static class WLCharacterBacklight { // ── 진단(프로브가 읽는다) public static int AppliedRenderers, SkinRenderers, MultiSubmeshRenderers; public static string LastLog = ""; static Material s_rim, s_rimSkin, s_rimT2; static readonly List s_buf = new List(16); static readonly List s_tmp = new List(4); public static bool IsOurs(Material m) { return m != null && (m == s_rim || m == s_rimSkin || m == s_rimT2); } /// root 아래 캐릭터 렌더러에 역광 패스를 덧붙인다(설정이 꺼져 있으면 떼어낸다). public static void Apply(GameObject root) { if (root == null) return; var cfg = WLCharacterBacklightSettings.Instance; if (cfg == null) return; if (cfg.enabled_ == 0) { Remove(root); return; } if (!EnsureMaterials(cfg)) { LastLog = "셰이더 WL/CharBacklight 없음 — 적용 0"; return; } WLCharacterBacklightDriver.Ensure(); WLCharacterBacklightDriver.Register(root); // 나중에 붙는 무기·장비 렌더러까지 덮도록 2 s 마다 다시 맞춘다(실측: 던전 PC 의 무기 2개는 Awake 뒤에 붙는다) int applied = 0, skin = 0, multi = 0; s_buf.Clear(); root.GetComponentsInChildren(true, s_buf); for (int i = 0; i < s_buf.Count; i++) { var r = s_buf[i]; if (r == null || r is ParticleSystemRenderer || r is TrailRenderer || r is LineRenderer) continue; var mats = r.sharedMaterials; if (mats == null || mats.Length == 0) continue; bool match = false, isSkin = false; s_tmp.Clear(); for (int m = 0; m < mats.Length; m++) { var mat = mats[m]; if (IsOurs(mat)) continue; // 이미 붙은 우리 재질은 걷어 내고 다시 붙인다(멱등) s_tmp.Add(mat); if (mat == null || mat.shader == null) continue; if (string.IsNullOrEmpty(cfg.shaderNameFilter) || mat.shader.name == cfg.shaderNameFilter) match = true; if (IsSkinName(cfg, mat.name)) isSkin = true; } if (!match) { if (s_tmp.Count != mats.Length) r.sharedMaterials = s_tmp.ToArray(); // 대상이 아닌데 붙어 있었으면 뗀다 continue; } var want = isSkin ? s_rimSkin : s_rim; bool same = mats.Length == s_tmp.Count + 1 && mats[mats.Length - 1] == want; if (!same) { s_tmp.Add(want); r.sharedMaterials = s_tmp.ToArray(); } BrightenBase(cfg, r, s_tmp.Count > 0 ? s_tmp[0] : null, cfg.baseBrighten != 0); applied++; if (isSkin) skin++; if (SubMeshCount(r) > 1) multi++; // 서브메시가 여럿이면 마지막 서브메시만 덧그려진다(진단용) } AppliedRenderers = applied; SkinRenderers = skin; MultiSubmeshRenderers = multi; LastLog = "역광 패스 " + applied + "개(피부 " + skin + " · 다중 서브메시 " + multi + ") @" + root.name; if (cfg.verboseLog != 0) Debug.Log("[WL CharBacklight] " + LastLog); } /// root 아래에서 우리 재질을 전부 뗀다. public static void Remove(GameObject root) { if (root == null) return; s_buf.Clear(); root.GetComponentsInChildren(true, s_buf); for (int i = 0; i < s_buf.Count; i++) { var r = s_buf[i]; if (r == null) continue; var mats = r.sharedMaterials; if (mats == null) continue; s_tmp.Clear(); for (int m = 0; m < mats.Length; m++) if (!IsOurs(mats[m])) s_tmp.Add(mats[m]); if (s_tmp.Count != mats.Length) { r.sharedMaterials = s_tmp.ToArray(); BrightenBase(null, r, s_tmp.Count > 0 ? s_tmp[0] : null, false); // 우리가 붙였던 렌더러만 본체 값도 되돌린다 RestoreMkTint(r, s_tmp.Count > 0 ? s_tmp[0] : null); s_overlayRends.Remove(r); } } LastLog = "역광 패스 제거 @" + root.name; } // ───────────────────────────────────────────────────────────────── // 본체 밝히기 — 2026-09-17 PD 「캐릭터가 어둡게 보인다 · 원한 건 더 밝고 화사하게」 // 본체(Toon) 재질의 그늘 최소 밝기·그늘 색·구름 그늘 세기를 **렌더러별 프로퍼티 블록**으로 덮는다(재질 에셋·공유 재질 0줄). // 끌 때는 같은 속성을 재질의 원래 값으로 되써서 되돌린다(다른 시스템이 쓰는 블록 속성은 건드리지 않는다). // ───────────────────────────────────────────────────────────────── static MaterialPropertyBlock s_mpb; static readonly int kMinDark = Shader.PropertyToID("_MinimumDarkness"); static readonly int kShadowCol = Shader.PropertyToID("_ShadowDiffuseColor"); static readonly int kCloud = Shader.PropertyToID("_Cloud_Strength"); public static int BrightenedRenderers; static void BrightenBase(WLCharacterBacklightSettings cfg, Renderer r, Material baseMat, bool on) { if (r == null || baseMat == null) return; if (!baseMat.HasProperty(kMinDark) || !baseMat.HasProperty(kShadowCol)) return; if (s_mpb == null) s_mpb = new MaterialPropertyBlock(); r.GetPropertyBlock(s_mpb); if (on && cfg != null) { s_mpb.SetFloat(kMinDark, cfg.baseMinimumDarkness); s_mpb.SetColor(kShadowCol, cfg.baseShadowColor); if (cfg.baseCloudStrength >= 0f && baseMat.HasProperty(kCloud)) s_mpb.SetFloat(kCloud, cfg.baseCloudStrength); BrightenedRenderers++; } else { s_mpb.SetFloat(kMinDark, baseMat.GetFloat(kMinDark)); s_mpb.SetColor(kShadowCol, baseMat.GetColor(kShadowCol)); if (baseMat.HasProperty(kCloud)) s_mpb.SetFloat(kCloud, baseMat.GetFloat(kCloud)); } r.SetPropertyBlock(s_mpb); } /// 2등급 본체 색(MK Toon 그늘 색)을 재질 원래 값으로 되쓴다. static void RestoreMkTint(Renderer r, Material baseMat) { if (r == null || baseMat == null || !baseMat.HasProperty(kGoochDark)) return; if (s_mpb == null) s_mpb = new MaterialPropertyBlock(); r.GetPropertyBlock(s_mpb); s_mpb.SetColor(kGoochDark, baseMat.GetColor(kGoochDark)); r.SetPropertyBlock(s_mpb); } // ───────────────────────────────────────────────────────────────── // 등급 — 2026-09-17 PD 「톤앤매너는 전체가 공유 · 플레이어 1등급 · 몬스터와 NPC 2등급 · 배경(프랍) 3등급」 // 공유하는 것 = 빛 방향(가상 역광·채움빛) · 림 색 · 그늘 색 계열(따뜻한 보랏빛). 등급은 **세기**만 다르다. // 2등급 = 같은 덧그리기 패스(림 약하게 + 몸 전체 채움빛 약하게) + 본체 그늘을 조금 밝게(Toon = 그늘 최소 밝기/색 · MK Toon = _GoochDarkColor) // 3등급 = 덧그리기 없음(드로우콜 0 추가) · 프랍이 공유하는 Toon 재질의 **런타임 복사본**에 그늘 값만 살짝(재질당 1장 → 배칭 유지) // ───────────────────────────────────────────────────────────────── public static int Tier2Roots, Tier2Renderers, Tier3Renderers, Tier3Materials; static readonly int kGoochDark = Shader.PropertyToID("_GoochDarkColor"); static readonly HashSet s_overlayRends = new HashSet(); /// 이 렌더러에 우리 덧그리기 패스가 붙어 있는가(피격 하이라이트가 덮기 방식으로 그릴 수 있는가). public static bool HasOverlay(Renderer r) { return r != null && s_overlayRends.Contains(r); } /// /// 몹 피격 점멸(`MobHitFlash`)이 렌더러의 프로퍼티 블록을 통째로 다시 쓸 때 불러, 2등급 본체 그늘 색을 **같은 블록**에 같이 써 넣는다 /// (따로 쓰면 맞을 때마다 지워져 그늘 색이 깜빡인다 · 블록의 소유자는 MobHitFlash 하나). /// public static void WriteTier2Props(Renderer r, MaterialPropertyBlock mpb) { if (r == null || mpb == null || !s_overlayRends.Contains(r)) return; var cfg = WLCharacterBacklightSettings.Instance; if (cfg == null || cfg.enabled_ == 0 || cfg.tiersEnabled == 0) return; var baseMat = r.sharedMaterial; if (baseMat == null) return; if (baseMat.HasProperty(kGoochDark)) mpb.SetColor(kGoochDark, cfg.tier2MkShadowColor); if (baseMat.HasProperty(kMinDark) && baseMat.HasProperty(kShadowCol)) { mpb.SetFloat(kMinDark, Mathf.Clamp01(baseMat.GetFloat(kMinDark) + cfg.tier2MinimumDarknessAdd)); mpb.SetColor(kShadowCol, Color.Lerp(baseMat.GetColor(kShadowCol), cfg.tier2ShadowColor, cfg.tier2ShadowBlend)); } } public static void ApplyTier2(GameObject root) { if (root == null) return; var cfg = WLCharacterBacklightSettings.Instance; if (cfg == null) return; if (cfg.enabled_ == 0 || cfg.tiersEnabled == 0) { Remove(root); return; } if (!EnsureMaterials(cfg)) return; int n = 0; s_buf.Clear(); root.GetComponentsInChildren(false, s_buf); for (int i = 0; i < s_buf.Count; i++) { var r = s_buf[i]; if (r == null || !(r is SkinnedMeshRenderer || r is MeshRenderer)) continue; var mats = r.sharedMaterials; if (mats == null || mats.Length == 0) continue; s_tmp.Clear(); Material baseMat = null; bool hasT2 = false; for (int m = 0; m < mats.Length; m++) { if (IsOurs(mats[m])) { if (mats[m] == s_rimT2) hasT2 = true; continue; } s_tmp.Add(mats[m]); if (baseMat == null && mats[m] != null) baseMat = mats[m]; } if (baseMat == null || baseMat.renderQueue >= 2500) continue; // 불투명 본체만(반투명 이펙트 메시 제외) if (!(hasT2 && mats.Length == s_tmp.Count + 1)) { s_tmp.Add(s_rimT2); r.sharedMaterials = s_tmp.ToArray(); } TintTier2Base(cfg, r, baseMat); s_overlayRends.Add(r); n++; } if (s_overlayRends.Count > 512) s_overlayRends.RemoveWhere(x => x == null); Tier2Renderers += n; } static void TintTier2Base(WLCharacterBacklightSettings cfg, Renderer r, Material baseMat) { if (s_mpb == null) s_mpb = new MaterialPropertyBlock(); bool mk = baseMat.HasProperty(kGoochDark); bool toon = baseMat.HasProperty(kMinDark) && baseMat.HasProperty(kShadowCol); if (!mk && !toon) return; r.GetPropertyBlock(s_mpb); if (mk) s_mpb.SetColor(kGoochDark, cfg.tier2MkShadowColor); if (toon) { s_mpb.SetFloat(kMinDark, Mathf.Clamp01(baseMat.GetFloat(kMinDark) + cfg.tier2MinimumDarknessAdd)); s_mpb.SetColor(kShadowCol, Color.Lerp(baseMat.GetColor(kShadowCol), cfg.tier2ShadowColor, cfg.tier2ShadowBlend)); } r.SetPropertyBlock(s_mpb); } // ── 3등급(프랍) — 공유 재질 복사본으로 갈아끼운다 static readonly Dictionary s_t3Copy = new Dictionary(); static readonly HashSet s_t3Made = new HashSet(); static readonly HashSet s_t3Seen = new HashSet(); struct T3State { public Renderer r; public Material[] mats; } static readonly List s_t3Swapped = new List(256); /// 3등급으로 갈아끼운 프랍 재질을 전부 원래대로(설정을 끄면 다음 검색 때 자동으로 불린다). public static void RestoreTier3() { for (int i = 0; i < s_t3Swapped.Count; i++) if (s_t3Swapped[i].r != null) s_t3Swapped[i].r.sharedMaterials = s_t3Swapped[i].mats; s_t3Swapped.Clear(); s_t3Seen.Clear(); } public static void ScanTier3() { var cfg = WLCharacterBacklightSettings.Instance; if (cfg == null) return; if (cfg.enabled_ == 0 || cfg.tiersEnabled == 0 || cfg.tier3ShaderNames == null || cfg.tier3ShaderNames.Length == 0) { if (s_t3Swapped.Count > 0) RestoreTier3(); return; } if (s_t3Seen.Count > 20000) s_t3Seen.Clear(); for (int i = s_t3Swapped.Count - 1; i >= 0; i--) if (s_t3Swapped[i].r == null) s_t3Swapped.RemoveAt(i); // 씬이 내려가 사라진 것 var rends = Object.FindObjectsByType(FindObjectsSortMode.None); int swapped = 0; for (int i = 0; i < rends.Length; i++) { var r = rends[i]; if (r == null || !s_t3Seen.Add(r.GetInstanceID())) continue; var mats = r.sharedMaterials; if (mats == null || mats.Length == 0) continue; bool any = false; for (int m = 0; m < mats.Length; m++) if (IsTier3Source(cfg, mats[m])) { any = true; break; } if (!any) continue; // 캐릭터·몬스터·동물에 달린 것은 1·2등급이 맡는다 if (r.GetComponentInParent() != null || r.GetComponentInParent() != null || r.GetComponentInParent() != null || r.GetComponentInParent() != null) continue; s_t3Swapped.Add(new T3State { r = r, mats = (Material[])mats.Clone() }); for (int m = 0; m < mats.Length; m++) if (IsTier3Source(cfg, mats[m])) mats[m] = Tier3Copy(cfg, mats[m]); r.sharedMaterials = mats; swapped++; } Tier3Renderers += swapped; Tier3Materials = s_t3Copy.Count; } static bool IsTier3Source(WLCharacterBacklightSettings cfg, Material m) { if (m == null || m.shader == null || s_t3Made.Contains(m) || IsOurs(m)) return false; if (!m.HasProperty(kMinDark) || !m.HasProperty(kShadowCol)) return false; var n = m.shader.name; for (int i = 0; i < cfg.tier3ShaderNames.Length; i++) if (cfg.tier3ShaderNames[i] == n) return true; return false; } static Material Tier3Copy(WLCharacterBacklightSettings cfg, Material src) { Material c; if (s_t3Copy.TryGetValue(src, out c) && c != null) return c; c = new Material(src) { name = src.name + " (WL tier3)" }; c.SetFloat(kMinDark, Mathf.Clamp01(src.GetFloat(kMinDark) + cfg.tier3MinimumDarknessAdd)); c.SetColor(kShadowCol, Color.Lerp(src.GetColor(kShadowCol), cfg.tier3ShadowColor, cfg.tier3ShadowBlend)); s_t3Copy[src] = c; s_t3Made.Add(c); return c; } /// 설정 값을 런타임 재질에 다시 밀어 넣는다(프로브에서 값을 바꾼 뒤 호출). public static void RefreshValues() { var cfg = WLCharacterBacklightSettings.Instance; if (cfg == null) return; if (s_rim != null) Push(cfg, s_rim, false); if (s_rimSkin != null) Push(cfg, s_rimSkin, true); if (s_rimT2 != null) PushTier2(cfg, s_rimT2); } static bool EnsureMaterials(WLCharacterBacklightSettings cfg) { if (s_rim != null && s_rimSkin != null && s_rimT2 != null) return true; var sh = Shader.Find("WL/CharBacklight"); if (sh == null) return false; if (s_rim == null) s_rim = new Material(sh) { name = "WL_CharBacklight(runtime)" }; if (s_rimSkin == null) s_rimSkin = new Material(sh) { name = "WL_CharBacklight_Skin(runtime)" }; Push(cfg, s_rim, false); Push(cfg, s_rimSkin, true); if (s_rimT2 == null) s_rimT2 = new Material(sh) { name = "WL_CharBacklight_Tier2(runtime)" }; PushTier2(cfg, s_rimT2); return true; } /// 2등급(몬스터·NPC) 재질 = 1등급과 같은 색·방향(톤 공유)에 세기만 낮춘다 · 얼굴빛/피부 투과 없음 · 몸 전체 채움빛 약하게. static void PushTier2(WLCharacterBacklightSettings c, Material m) { Push(c, m, false); m.SetFloat("_RimStrength", c.tier2RimStrength); m.SetFloat("_RimOpacity", c.tier2RimOpacity); m.SetFloat("_WrapStrength", c.wrapStrength * 0.5f); m.SetFloat("_FaceStrength", 0f); m.SetFloat("_FillStrength", c.tier2FillStrength); } static void Push(WLCharacterBacklightSettings c, Material m, bool skin) { m.SetColor("_RimColor", c.rimColor); m.SetFloat("_RimStrength", c.rimStrength); m.SetFloat("_RimThreshold", c.rimThreshold); m.SetFloat("_RimThresholdFar", c.rimThresholdFar); m.SetFloat("_RimNearDist", c.rimNearDistance); m.SetFloat("_RimFarDist", c.rimFarDistance); m.SetFloat("_RimSoftness", c.rimSoftness); m.SetFloat("_RimDirectional", c.rimDirectional); m.SetFloat("_RimOpacity", c.rimOpacity); // 깊이 림은 URP 에셋이 카메라 깊이 텍스처를 만들 때만(꺼져 있으면 샘플 값이 쓰레기 → 프레넬만) var urp = GraphicsSettings.currentRenderPipeline as UniversalRenderPipelineAsset; bool depthOk = c.rimUseDepth != 0 && urp != null && urp.supportsCameraDepthTexture; m.SetFloat("_UseDepthRim", depthOk ? 1f : 0f); m.SetFloat("_RimWidth", c.rimWidth); m.SetFloat("_RimDepthThreshold", c.rimDepthThreshold); m.SetFloat("_FresnelRimNear", c.rimFresnelNear); m.SetFloat("_FresnelRimFar", c.rimFresnelFar); m.SetColor("_WrapColor", c.wrapColor); m.SetFloat("_WrapStrength", c.wrapStrength); m.SetFloat("_WrapPower", c.wrapPower); m.SetColor("_SssColor", c.sssColor); m.SetFloat("_SssStrength", skin ? c.sssStrength : 0f); m.SetFloat("_SssPower", c.sssPower); m.SetColor("_FaceColor", c.faceColor); m.SetFloat("_FaceStrength", c.faceEnabled != 0 ? c.faceStrength : 0f); m.SetFloat("_FaceWrap", c.faceWrap); } static bool IsSkinName(WLCharacterBacklightSettings c, string matName) { if (c.skinMaterialHints == null || string.IsNullOrEmpty(matName)) return false; for (int i = 0; i < c.skinMaterialHints.Length; i++) { var h = c.skinMaterialHints[i]; if (!string.IsNullOrEmpty(h) && matName.IndexOf(h, System.StringComparison.OrdinalIgnoreCase) >= 0) return true; } return false; } static int SubMeshCount(Renderer r) { var smr = r as SkinnedMeshRenderer; if (smr != null) return smr.sharedMesh != null ? smr.sharedMesh.subMeshCount : 0; var mf = r.GetComponent(); return mf != null && mf.sharedMesh != null ? mf.sharedMesh.subMeshCount : 0; } } /// 가상 역광 방향(전역 벡터) 갱신 + 번짐(Bloom) 스코프 — 숨은 오브젝트 1개. public sealed class WLCharacterBacklightDriver : MonoBehaviour { static WLCharacterBacklightDriver s_inst; static readonly int kDirId = Shader.PropertyToID("_WLBacklightDir"); // ── 진단 public static bool BloomHeld; public static string BloomLog = ""; public static Vector3 LastDir; // 번짐 스코프가 바꾼 것(되돌리기용) GameObject _volumeGo; VolumeProfile _profile; Bloom _bloom; Camera _cam; bool _camPostPrev, _camHdrPrev; float _nextScopeCheck; // 다시 맞출 캐릭터 루트(죽은 참조는 훑을 때 걷어 낸다) static readonly List s_roots = new List(8); float _nextRescan; public static int Rescans; public static void Register(GameObject root) { if (root == null || s_roots.Contains(root)) return; s_roots.Add(root); } public static void Ensure() { if (s_inst != null) return; // 🔴 HideFlags.DontSave 를 쓰지 않는다 — Play 중 만든 DontSave 오브젝트는 Play 종료 뒤에도 에디터에 남는다(815c 장벽 고아 14개 실증). if (!Application.isPlaying) return; var go = new GameObject("~WL_CharBacklightDriver"); DontDestroyOnLoad(go); s_inst = go.AddComponent(); } void LateUpdate() { var cfg = WLCharacterBacklightSettings.Instance; var cam = Camera.main; if (cfg != null && cam != null) { // 카메라 기준 「뒤(카메라가 보는 쪽의 수평 성분) + 위 + 옆」 = 표면에서 가상 역광을 향하는 방향 var f = cam.transform.forward; f.y = 0f; if (f.sqrMagnitude < 1e-4f) f = cam.transform.up; // 수직 내려다보기 예외 f.Normalize(); var d = f + Vector3.up * cfg.backlightUp + cam.transform.right * cfg.backlightSide; if (d.sqrMagnitude < 1e-6f) d = Vector3.up; d.Normalize(); LastDir = d; Shader.SetGlobalVector(kDirId, new Vector4(d.x, d.y, d.z, 1f)); UpdateFaceLight(cfg, cam, f); } if (Time.unscaledTime >= _nextScopeCheck) { _nextScopeCheck = Time.unscaledTime + 0.5f; UpdateBloomScope(cfg, cam); } if (cfg != null) { if (Time.unscaledTime >= _nextTier2) { _nextTier2 = Time.unscaledTime + Mathf.Max(0.5f, cfg.tier2ScanSeconds); ScanTier2(cfg); } if (Time.unscaledTime >= _nextTier3) { _nextTier3 = Time.unscaledTime + Mathf.Max(1f, cfg.tier3ScanSeconds); WLCharacterBacklight.ScanTier3(); } } if (Time.unscaledTime >= _nextRescan) { _nextRescan = Time.unscaledTime + 2f; for (int i = s_roots.Count - 1; i >= 0; i--) { var root = s_roots[i]; if (root == null) { s_roots.RemoveAt(i); continue; } if (!root.activeInHierarchy) continue; WLCharacterBacklight.Apply(root); // 멱등 — 바뀐 게 없으면 재질 배열 대입 0 Rescans++; } } } // ── 등급 검색 — 원본 Actor 를 역할로 나눠 2등급을 붙인다(PC 는 1등급 · 원본 0줄: 읽기만 한다) float _nextTier2, _nextTier3; static HashSet s_t2Roles; static string[] s_t2RolesSrc; public static int Tier2Scans, LastTier2Actors; /// 지금 바로 1·2·3등급을 다시 맞춘다(프로브의 끔/켬 비교용). public static void ForceScan() { var cfg = WLCharacterBacklightSettings.Instance; if (cfg == null) return; for (int i = s_roots.Count - 1; i >= 0; i--) if (s_roots[i] != null && s_roots[i].activeInHierarchy) WLCharacterBacklight.Apply(s_roots[i]); ScanTier2(cfg); WLCharacterBacklight.ScanTier3(); } static void ScanTier2(WLCharacterBacklightSettings cfg) { if (s_t2Roles == null || s_t2RolesSrc != cfg.tier2Roles) { s_t2RolesSrc = cfg.tier2Roles; s_t2Roles = new HashSet(); if (cfg.tier2Roles != null) for (int i = 0; i < cfg.tier2Roles.Length; i++) if (!string.IsNullOrEmpty(cfg.tier2Roles[i])) s_t2Roles.Add(cfg.tier2Roles[i]); } int n = 0; var actors = Object.FindObjectsByType(FindObjectsSortMode.None); for (int i = 0; i < actors.Length; i++) { var a = actors[i]; if (a == null || !a.gameObject.activeInHierarchy) continue; if (a.m_Role == eRole.PC) { // 룩 컴포넌트 없는 PC 프리팹도 1등급 if (!s_roots.Contains(a.gameObject) && a.GetComponentInChildren() == null) WLCharacterBacklight.Apply(a.gameObject); continue; } if (!s_t2Roles.Contains(a.m_Role.ToString())) continue; WLCharacterBacklight.ApplyTier2(a.gameObject); n++; } var animals = Object.FindObjectsByType(FindObjectsSortMode.None); for (int i = 0; i < animals.Length; i++) if (animals[i] != null) { WLCharacterBacklight.ApplyTier2(animals[i].gameObject); n++; } LastTier2Actors = n; WLCharacterBacklight.Tier2Roots = n; Tier2Scans++; } // ── 얼굴 포인트 라이트 — 카메라에 가장 가까운 등록 캐릭터의 얼굴(머리) 렌더러 경계 중심을 전역 벡터로 넘긴다 static readonly int kFaceCenterId = Shader.PropertyToID("_WLFaceCenter"); static readonly int kFaceDirId = Shader.PropertyToID("_WLFaceLightDir"); static readonly Dictionary s_faceRend = new Dictionary(); public static Vector3 LastFaceCenter; public static float LastFaceRadius; public static string LastFaceLog = ""; void UpdateFaceLight(WLCharacterBacklightSettings cfg, Camera cam, Vector3 camForwardFlat) { if (cfg.enabled_ == 0 || cfg.faceEnabled == 0 || cfg.faceStrength <= 0f) { Shader.SetGlobalVector(kFaceCenterId, Vector4.zero); LastFaceRadius = 0f; return; } // 대상 = 카메라에 가장 가까운 활성 캐릭터(지금은 PC 하나) Renderer best = null; float bestD = float.MaxValue; for (int i = 0; i < s_roots.Count; i++) { var root = s_roots[i]; if (root == null || !root.activeInHierarchy) continue; Renderer fr; if (!s_faceRend.TryGetValue(root, out fr) || fr == null) { fr = FindFaceRenderer(cfg, root); s_faceRend[root] = fr; } if (fr == null || !fr.enabled || !fr.gameObject.activeInHierarchy) continue; float dsq = (fr.bounds.center - cam.transform.position).sqrMagnitude; if (dsq < bestD) { bestD = dsq; best = fr; } } if (best == null) { Shader.SetGlobalVector(kFaceCenterId, Vector4.zero); LastFaceRadius = 0f; LastFaceLog = "얼굴 렌더러 없음"; return; } var b = best.bounds; float radius = Mathf.Max(0.05f, b.extents.magnitude * cfg.faceRadiusScale); LastFaceCenter = b.center; LastFaceRadius = radius; LastFaceLog = best.name + " r=" + radius.ToString("F2"); Shader.SetGlobalVector(kFaceCenterId, new Vector4(b.center.x, b.center.y, b.center.z, radius)); // 빛 방향(표면 → 빛) = 카메라 쪽(앞) + 위 + 옆 — 역광의 반대편에서 얼굴을 받쳐 주는 빛 var l = -camForwardFlat + Vector3.up * cfg.faceLightUp + cam.transform.right * cfg.faceLightSide; if (l.sqrMagnitude < 1e-6f) l = Vector3.up; l.Normalize(); Shader.SetGlobalVector(kFaceDirId, new Vector4(l.x, l.y, l.z, 0f)); } static Renderer FindFaceRenderer(WLCharacterBacklightSettings cfg, GameObject root) { if (cfg.faceRendererHints == null) return null; var rends = root.GetComponentsInChildren(true); for (int h = 0; h < cfg.faceRendererHints.Length; h++) { var hint = cfg.faceRendererHints[h]; if (string.IsNullOrEmpty(hint)) continue; for (int i = 0; i < rends.Length; i++) { var r = rends[i]; if (r == null || r is ParticleSystemRenderer) continue; if (r.name.IndexOf(hint, System.StringComparison.OrdinalIgnoreCase) >= 0) return r; } } return null; } void UpdateBloomScope(WLCharacterBacklightSettings cfg, Camera cam) { bool want = cfg != null && cfg.enabled_ != 0 && cfg.bloomEnabled != 0 && cam != null && InScope(cfg); if (!want) { ReleaseBloom(); return; } if (_volumeGo == null) { _volumeGo = new GameObject("~WL_CharBacklight_BloomVolume"); DontDestroyOnLoad(_volumeGo); _profile = ScriptableObject.CreateInstance(); _bloom = _profile.Add(true); var v = _volumeGo.AddComponent(); v.isGlobal = true; v.priority = 50f; v.weight = 1f; v.sharedProfile = _profile; } _bloom.active = true; _bloom.threshold.Override(cfg.bloomThreshold); _bloom.intensity.Override(cfg.bloomIntensity); _bloom.scatter.Override(cfg.bloomScatter); _bloom.downscale.Override(cfg.bloomQuarter != 0 ? BloomDownscaleMode.Quarter : BloomDownscaleMode.Half); _bloom.maxIterations.Override(Mathf.Clamp(cfg.bloomMaxIterations, 2, 8)); if (_cam != cam) { RestoreCamera(); var ad = cam.GetComponent(); if (ad != null) { _cam = cam; _camPostPrev = ad.renderPostProcessing; _camHdrPrev = cam.allowHDR; ad.renderPostProcessing = true; if (cfg.bloomHdr != 0) cam.allowHDR = true; } } BloomHeld = true; BloomLog = "번짐 켬 — 카메라 " + (_cam != null ? _cam.name : "없음") + " · 임계 " + cfg.bloomThreshold.ToString("F2") + " · 세기 " + cfg.bloomIntensity.ToString("F2"); } static bool InScope(WLCharacterBacklightSettings cfg) { if (cfg.bloomScenes == null || cfg.bloomScenes.Length == 0) return false; for (int i = 0; i < SceneManager.sceneCount; i++) { var s = SceneManager.GetSceneAt(i); if (!s.isLoaded) continue; for (int k = 0; k < cfg.bloomScenes.Length; k++) if (!string.IsNullOrEmpty(cfg.bloomScenes[k]) && s.name == cfg.bloomScenes[k]) return true; } return false; } void RestoreCamera() { if (_cam != null) { var ad = _cam.GetComponent(); if (ad != null) ad.renderPostProcessing = _camPostPrev; _cam.allowHDR = _camHdrPrev; } _cam = null; } void ReleaseBloom() { if (!BloomHeld && _volumeGo == null) return; RestoreCamera(); if (_volumeGo != null) Destroy(_volumeGo); if (_profile != null) Destroy(_profile); _volumeGo = null; _profile = null; _bloom = null; BloomHeld = false; BloomLog = "번짐 끔(스코프 밖 또는 설정 off)"; } void OnDestroy() { ReleaseBloom(); if (s_inst == this) s_inst = null; } } }