Project_WL/Assets/WL/Look/Character/WLSilhouette.cs

183 lines
11 KiB
C#
Raw Normal View History

// ─────────────────────────────────────────────────────────────────────────────
// 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; } }
/// <summary>진단·A/B 런타임 스위치 — 에셋을 안 건드리고 이 기능만 끈다.</summary>
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<TextAsset>(JsonPath);
try { s_settings = ta != null ? Newtonsoft.Json.JsonConvert.DeserializeObject<WLSilhouetteSettings>(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<Renderer> s_applied = new HashSet<Renderer>();
static readonly List<Renderer> s_buf = new List<Renderer>(32);
static readonly List<Material> s_tmp = new List<Material>(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<WLSilhouetteRunner>() != null) return;
var go = new GameObject("WL_Silhouette"); Object.DontDestroyOnLoad(go); go.hideFlags = HideFlags.DontSave;
go.AddComponent<WLSilhouetteRunner>();
}
static Material Mat()
{
var s = S;
if (s_mat == null)
{
var sh = Resources.Load<Shader>(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<Shader>(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;
}
/// <summary>러너가 pollSeconds 마다 부른다 — PC 렌더러에 붙어 있는지 확인하고 없으면 붙인다.</summary>
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;
}
}
/// <summary>전부 떼어 원복(끄기·종료·재적용).</summary>
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();
}
}
/// <summary>폴링 러너(DontDestroyOnLoad · 1개).</summary>
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; }
}
}