// ───────────────────────────────────────────────────────────────────────────── // WLSilhouette.cs — 가려진 캐릭터 실루엣 (#845 · 2026-10-12 · PD 「텐트나 나무 같은 오브젝트에 캐릭터가 가려질 경우 // 지금은 오브젝트를 투명하게 하는데, FR 프로젝트처럼 오브젝트는 보이고 캐릭터 실루엣이 보이게」) // // ■ 방식 — 원본 훅 0 · 가림 판정 0(픽셀 단위로 저절로) // PC 의 불투명 렌더러(스킨 메시 + 무기)에 `WL/Silhouette` 머티리얼을 **추가 슬롯**으로 붙인다(WL_CharBacklight 와 같은 방식). // 셰이더가 깊이 뒤(ZTest Greater)인 픽셀만 색칠하므로 텐트·나무·바위 뒤에 들어간 부분이 그대로 실루엣이 된다. // 켜져 있는 동안 OccluderFade(소품 숨김)는 눌러 둔다(OccluderFade.Suppressed) — 소품은 그대로 보인다. // ■ 값 = Resources/WL/WLSilhouette.json (C45) · 셰이더 = Resources/WL/Shaders/WLSilhouette (빌드 포함용 Resources). // ■ PC 모델이 직업 교체로 바뀌어도 pollSeconds 마다 다시 붙인다. 끄면(또는 JSON enabled=false) 전부 떼어 원복. // 🔴 어셈블리 주의: Assets/WL/Look/ 에 .asmdef 를 만들지 말 것. // ───────────────────────────────────────────────────────────────────────────── using System.Collections.Generic; using UnityEngine; namespace WL.Look.Character { public sealed class WLSilhouetteSettings { public bool enabled = true; public float[] color = { 0.55f, 0.85f, 1f, 0.55f }; public int renderQueue = 3100; public bool includeWeapon = true; public bool applyToMobs = false; public bool suppressOccluderFade = true; public float pollSeconds = 0.1f; // #845 PD 「일부만 가려지는 경우까지는 연출하지 않아도 될 것 같아」 — 카메라→몸 중심 선분이 막혔을 때만 실루엣을 켠다(부분 가림은 안 켬). public bool gateByOcclusion = true; public float probeRadius = 0.3f; // 선분 반경(m) — 몸통 폭보다 작게 = 중심이 가려질 때만 public float aimHeightFraction = 0.6f; // 발에서 키의 이 비율 높이를 겨냥(가슴) public float endMargin = 0.35f; // 선분 끝에서 이만큼은 검사 안 함(캐릭터 자신·발밑 바닥) public float offDelaySeconds = 0.3f; // 가림이 풀린 뒤 실루엣을 떼기까지(깜빡임 방지) public float boundsScale = 0.6f; // 콜라이더 없는 소품의 바운드 상자를 이 배율로 줄여 판정(텐트 AABB 오판 방지) public float minAhead = 0.4f; // 가리는 것이 겨냥점보다 최소 이만큼(m) 앞에 있어야 함 public float scanRebuildSeconds = 5f; // 소품 목록 재구축 주기(새로 놓인 텐트·상인 반영) } public static class WLSilhouette { public const string JsonPath = "WL/WLSilhouette"; public const string ShaderPath = "WL/Shaders/WLSilhouette"; static WLSilhouetteSettings s_settings; static bool s_loaded; public static WLSilhouetteSettings S { get { if (!s_loaded) Load(); return s_settings; } } public static bool Enabled { get { var s = S; return s != null && s.enabled && !RuntimeDisabled; } } /// 진단·A/B 런타임 스위치 — 에셋을 안 건드리고 이 기능만 끈다. public static bool RuntimeDisabled; public static void Reload() { s_loaded = false; RemoveAll(); if (s_mat != null) { Object.Destroy(s_mat); s_mat = null; } if (s_mark != null) { Object.Destroy(s_mark); s_mark = null; } } static void Load() { s_loaded = true; var ta = Resources.Load(JsonPath); try { s_settings = ta != null ? Newtonsoft.Json.JsonConvert.DeserializeObject(ta.text) : null; } catch (System.Exception ex) { Debug.LogException(ex); s_settings = null; } } // ── 진단 public static int Applied, Removed, Polls, OcclusionOn, OcclusionOff; public static string LastLog = "", LastProbe = ""; public static bool Occluded; // 지금 가림 판정(게이트) public static int AppliedCount { get { return s_applied.Count; } } static float s_lastOccludedAt = -999f, s_nextScan; static Material s_mat, s_mark; // 색칠(큐 renderQueue) · 스텐실 표시(큐 renderQueue−1) — URP 는 머티리얼당 LightMode 1패스만 그려 2장으로 나눈다 static readonly HashSet s_applied = new HashSet(); static readonly List s_buf = new List(32); static readonly List s_tmp = new List(8); static readonly int PropColor = Shader.PropertyToID("_Color"); public const string MarkShaderPath = "WL/Shaders/WLSilhouetteMark"; static bool IsOurs(Material m) { return m != null && (m == s_mat || m == s_mark); } [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)] static void Boot() { if (Object.FindFirstObjectByType() != null) return; var go = new GameObject("WL_Silhouette"); Object.DontDestroyOnLoad(go); go.hideFlags = HideFlags.DontSave; go.AddComponent(); } static Material Mat() { var s = S; if (s_mat == null) { var sh = Resources.Load(ShaderPath); if (sh == null) sh = Shader.Find("WL/Silhouette"); if (sh == null) { LastLog = "셰이더 없음 " + ShaderPath; return null; } s_mat = new Material(sh) { name = "WL_Silhouette(runtime)", hideFlags = HideFlags.DontSave }; } if (s_mark == null) { var sh = Resources.Load(MarkShaderPath); if (sh == null) sh = Shader.Find("WL/SilhouetteMark"); if (sh == null) { LastLog = "셰이더 없음 " + MarkShaderPath; return null; } s_mark = new Material(sh) { name = "WL_SilhouetteMark(runtime)", hideFlags = HideFlags.DontSave }; } if (s != null) { var c = s.color != null && s.color.Length >= 4 ? new Color(s.color[0], s.color[1], s.color[2], s.color[3]) : new Color(0.55f, 0.85f, 1f, 0.55f); s_mat.SetColor(PropColor, c); s_mat.renderQueue = s.renderQueue; s_mark.renderQueue = s.renderQueue - 1; } return s_mat; } /// 러너가 pollSeconds 마다 부른다 — PC 렌더러에 붙어 있는지 확인하고 없으면 붙인다. internal static void Poll() { Polls++; var s = S; if (!Enabled || s == null) { if (s_applied.Count > 0) RemoveAll(); WL.CameraFraming.OccluderFade.Suppressed = false; return; } WL.CameraFraming.OccluderFade.Suppressed = s.suppressOccluderFade; var pc = MyValue.MyPC; if (pc == null) return; var mat = Mat(); if (mat == null) return; bool want = true; if (s.gateByOcclusion) { var cam = Camera.main; if (cam != null) { float h = 1f; try { h = Mathf.Max(0.3f, pc.Get_Top_postion().y - pc.Get_position().y); } catch (System.Exception) { } Vector3 aim = pc.Get_position() + Vector3.up * (h * s.aimHeightFraction); if (s.scanRebuildSeconds > 0f && Time.unscaledTime >= s_nextScan) { s_nextScan = Time.unscaledTime + s.scanRebuildSeconds; WL.CameraFraming.OccluderFade.RebuildScan(); } string hit; bool now = WL.CameraFraming.OccluderFade.ProbeOcclusion(cam.transform.position, aim, s.probeRadius, s.endMargin, s.boundsScale, s.minAhead, out hit); if (now) { s_lastOccludedAt = Time.unscaledTime; LastProbe = "가림 " + hit; } bool was = Occluded; Occluded = now || (Time.unscaledTime - s_lastOccludedAt) < s.offDelaySeconds; if (Occluded && !was) OcclusionOn++; else if (!Occluded && was) OcclusionOff++; want = Occluded; } } else Occluded = true; if (want) Apply(pc.gameObject, s.includeWeapon, mat); else if (s_applied.Count > 0) RemoveAll(); if (s_applied.Count > 256) s_applied.RemoveWhere(r => r == null); } static void Apply(GameObject root, bool includeMesh, Material mat) { 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) continue; if (!(r is SkinnedMeshRenderer) && !(includeMesh && r is MeshRenderer)) continue; var mats = r.sharedMaterials; if (mats == null || mats.Length == 0) continue; Material baseMat = null; bool hasFill = false, hasMark = false; for (int m = 0; m < mats.Length; m++) { if (mats[m] == mat) hasFill = true; else if (mats[m] == s_mark) hasMark = true; else if (baseMat == null && mats[m] != null) baseMat = mats[m]; } if (baseMat == null || baseMat.renderQueue >= 2500) continue; // 불투명 본체만(얼굴 Unlit/Transparent · 이펙트 메시 제외) if (hasFill && hasMark) { s_applied.Add(r); continue; } s_tmp.Clear(); for (int m = 0; m < mats.Length; m++) if (mats[m] != null && !IsOurs(mats[m])) s_tmp.Add(mats[m]); s_tmp.Add(s_mark); s_tmp.Add(mat); r.sharedMaterials = s_tmp.ToArray(); s_applied.Add(r); Applied++; LastLog = "붙임 " + root.name + "/" + r.name + " mats=" + s_tmp.Count; } } /// 전부 떼어 원복(끄기·종료·재적용). public static void RemoveAll() { foreach (var r in s_applied) { if (r == null) continue; var mats = r.sharedMaterials; if (mats == null) continue; s_tmp.Clear(); bool changed = false; for (int m = 0; m < mats.Length; m++) { if (IsOurs(mats[m])) { changed = true; continue; } s_tmp.Add(mats[m]); } if (changed) { r.sharedMaterials = s_tmp.ToArray(); Removed++; } } s_applied.Clear(); } } /// 폴링 러너(DontDestroyOnLoad · 1개). public sealed class WLSilhouetteRunner : MonoBehaviour { float _next; void Update() { if (Time.unscaledTime < _next) return; var s = WLSilhouette.S; _next = Time.unscaledTime + Mathf.Max(0.1f, s != null ? s.pollSeconds : 0.5f); WLSilhouette.Poll(); } void OnDestroy() { WLSilhouette.RemoveAll(); WL.CameraFraming.OccluderFade.Suppressed = false; } } }