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

183 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ─────────────────────────────────────────────────────────────────────────────
// 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; }
}
}