183 lines
11 KiB
C#
183 lines
11 KiB
C#
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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; }
|
||
}
|
||
}
|