Project_WL/Assets/WL/Camera/OccluderFade.cs

488 lines
25 KiB
C#
Raw Normal View History

// ─────────────────────────────────────────────────────────────────────────────
// OccluderFade.cs — 카메라와 PC 사이를 막은 지형 소품을 잠시 숨긴다
//
// PD 지시 #813 · 발주서 WL-811o §1-2 (2026-09-09) · 근거 = qa/W2-4b_재채점_v2.md D-8 · 채점 7-3 = 0
// 「지형 바위가 PC 를 완전히 가림(Screenshots_WL/Q4b/01_enter_hud.png ≈40 %)」
//
// ■ 이 파일이 하는 것 — 원본 훅 0 · RealCamera 무수정 · 새 셰이더/머티리얼 0 · 폴링만
// 0.1 s 마다 카메라 → PC 주시점 선분을 검사해서 걸린 **지형 소품 렌더러**를 fadeOutSeconds 뒤 숨기고,
// 가림이 풀리면 restoreDelaySeconds 뒤 원래대로 되돌린다. 동시 상한 4(SO).
//
// ■ 검사 2단 (발주서는 SphereCast 만 지정했지만 실측이 그것으로 부족하다고 말한다)
// ⓐ SphereCast(반경 0.6 m · SO) — 콜라이더가 있는 소품·지형
// ⓑ 바운드 스캔 — 🔴 **WL_Nature 실측: 가림 후보 소품 979개 중 콜라이더 보유는 121개(12.4 %)**.
// 나무·덤불·바위 대부분에 콜라이더가 없어 ⓐ 만으로는 화면을 실제로 가리는 것의 12 % 밖에 못 잡는다.
// 그래서 맵 진입(PC 교체·씬 변경) 때 소품 렌더러 목록을 한 번 훑어 두고, 폴링에서는 그 목록의
// **렌더러 바운드**와 선분을 교차 검사한다(콜라이더 불요 · 폴링 중 할당 0). SO 로 끌 수 있다.
//
// ■ 숨김 방식 (실측 후 선택 · SO hideMode)
// 🔴 실측: 가림 후보 979개의 머티리얼이 **전부 불투명 큐**(URP/Lit queue 2000 · 투명 0).
// 불투명 셰이더에서는 MaterialPropertyBlock 알파가 화면에 아무 효과가 없고, 새 셰이더·머티리얼 생성은
// 발주서 §2 금지다. → Auto 는 **ShadowsOnly**(렌더러는 안 보이고 그림자는 남는다 · 머티리얼 무변경 · GC 0)로 내려간다.
// 이 경로에서 fadeOutSeconds 는 「스쳐 지나가는 소품이 한 프레임 깜빡이는 것」을 막는 지연으로 쓴다.
// (투명 큐 소품이 있는 맵에서는 Auto 가 알파 페이드를 고른다 — 코드는 두 경로를 다 갖고 있다.)
//
// ■ 안전장치 (맵이 통째로 사라지는 사고 방지)
// · 레이어 마스크(기본 Default + Map) — Actor(PC·몹)·UI·Water·GrassFX 레이어는 애초에 안 본다
// · Actor 컴포넌트 보유 · SkinnedMeshRenderer · ParticleSystem/Trail/Line 렌더러(이펙트)는 절대 제외
// · 이름 규칙(terrain/ground/floor/water/…) · 바운드 높이 하한 · 바운드 최대 변 상한(지형 통짜 메시 보호)
// · 동시 상한(기본 4) · 러너 파괴·씬 전환·SO off 에 **전부 원복**(C8)
//
// ■ GC 0
// Physics.SphereCastNonAlloc(사전 할당 RaycastHit[16]) · 슬롯 배열 사전 할당 · 스캔 배열 사전 할당 ·
// MaterialPropertyBlock 1개 재사용 · 이름 비교는 IndexOf(OrdinalIgnoreCase)(ToLower 할당 없음) ·
// verboseLog 를 켤 때만 문자열 생성. **할당은 스캔 재구축(맵 진입 1회) 에만 있다.**
//
// 🔴 어셈블리 주의: Assets/WL/Camera/ 에 .asmdef 를 만들지 말 것(RealCamera 가 Assembly-CSharp 에 있다).
// ─────────────────────────────────────────────────────────────────────────────
using System;
using UnityEngine;
using UnityEngine.Rendering;
namespace WL.CameraFraming
{
/// <summary>카메라~PC 가림 소품 임시 숨김. 정적 · 새 Manager/Singleton 0(시간 축은 CameraFramingRunner 공용).</summary>
public static class OccluderFade
{
const int SlotMax = 8; // SO maxConcurrent(1~8)의 물리 상한
const int HitMax = 16; // 한 번의 SphereCast 에서 볼 최대 히트 수
static WLCameraFramingSettings Cfg { get { return WLCameraFramingSettings.Instance; } }
/// <summary>기능이 살아 있는가. 에셋이 없거나 플래그가 꺼져 있으면 false = 소품 100 % 표시.</summary>
public static bool Active
{
get { var c = Cfg; return c != null && c.enabled && c.occluderFadeEnabled && !WLCameraFramingSettings.RuntimeDisabled; }
}
// ───────────────────────────────────────── 진단 카운터 (프로브가 읽는다)
public static int Polls, Blocked, HiddenCount, RestoredCount, CapSkips, Rejected, ScanBuilds;
public static int ActiveHidden; // 지금 실제로 숨겨 둔 수
public static string LastLog = "";
public static string LastHiddenName = "";
public static OccluderHideMode LastHideMode = OccluderHideMode.Auto;
// ───────────────────────────────────────── 내부 상태 (전부 사전 할당)
sealed class Slot
{
public Renderer r;
public Transform tf;
public ShadowCastingMode originShadow;
public bool originEnabled;
public bool hidden;
public OccluderHideMode mode;
public int colorProp;
public Color originColor;
public float firstSeen, lastSeen;
}
static readonly Slot[] s_slots = NewSlots();
static readonly RaycastHit[] s_hits = new RaycastHit[HitMax];
static MaterialPropertyBlock s_mpb;
static string[] s_excludeTokens = new string[0];
static string s_excludeSource;
static float s_nextPoll;
static bool s_faulted;
// ── 바운드 스캔(콜라이더 없는 소품) 상태
static Renderer[] s_scan = new Renderer[0];
static int s_scanCount;
static float s_nextScan;
static PCActor s_scanPc;
static int s_scanScene = -1;
/// <summary>스캔 목록에 들어간 소품 수(진단).</summary>
public static int ScanCount { get { return s_scanCount; } }
static readonly int PropBaseColor = Shader.PropertyToID("_BaseColor");
static readonly int PropColor = Shader.PropertyToID("_Color");
/// <summary>
/// 프로브 전용 시간 소스(초). 0 미만이면 Time.unscaledTime 을 쓴다.
/// 🔴 에디트 모드에서는 Time.unscaledTime 이 흐르지 않아 페이드/복귀를 검증할 수 없다 —
/// 프로브가 이 값을 밀어 가짜 시간으로 상태 전이를 확인한다. 런타임에서는 항상 1.
/// </summary>
public static float TimeOverride = -1f;
static float Now { get { return TimeOverride >= 0f ? TimeOverride : Time.unscaledTime; } }
static Slot[] NewSlots()
{
var a = new Slot[SlotMax];
for (int i = 0; i < SlotMax; i++) a[i] = new Slot();
return a;
}
// ─────────────────────────────────────────────────────────────────────
// 틱
// ─────────────────────────────────────────────────────────────────────
/// <summary>러너가 매 프레임 부른다. 내부에서 pollSeconds 로 스로틀한다.</summary>
internal static void Tick()
{
if (s_faulted) return;
if (!Active) { if (ActiveHidden > 0) ReleaseAll(); return; }
var cfg = Cfg;
float now = Now;
if (now < s_nextPoll) return;
s_nextPoll = now + Mathf.Max(0.02f, cfg.pollSeconds);
// 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 팝업으로 띄운다(813x §D-1).
try
{
var cam = MyValue.m_RealCamera;
var pc = MyValue.MyPC;
if (DSUtil.CheckNull(cam) || DSUtil.CheckNull(pc)) { UpdateSlots(cfg, now); return; }
Vector3 camPos = cam.transform.position;
Vector3 aim = pc.transform.position + cam.m_LookAt; // RealCamera.transform.LookAt 과 같은 점
EvaluateSegment(camPos, aim);
}
catch (Exception ex) { s_faulted = true; Log("중단(예외) — " + ex.Message); }
}
/// <summary>
/// 카메라 위치 → 주시점 선분을 검사하고 슬롯을 갱신한다.
/// 프로브가 에디트 모드에서 「가짜 가림」을 만들 때 직접 부른다(러너·PC 없이도 동작).
/// </summary>
public static void EvaluateSegment(Vector3 camPos, Vector3 aim)
{
var cfg = Cfg;
if (cfg == null) return;
float now = Now;
Polls++;
Vector3 dir = aim - camPos;
float dist = dir.magnitude;
if (dist > 0.0001f)
{
dir /= dist;
// 카메라 근평면 안쪽에서 시작하면 겹친 콜라이더가 distance 0 으로 보고된다 — 조금 앞에서 쏜다.
float skip = Mathf.Min(0.2f, dist * 0.1f);
float segLen = Mathf.Max(0f, dist - skip - 0.5f); // 끝 0.5 m 는 PC 자신
float radius = Mathf.Max(0f, cfg.sphereRadius);
// ⓐ 콜라이더가 있는 소품·지형
int n = radius > 0.0001f
? Physics.SphereCastNonAlloc(camPos + dir * skip, radius, dir, s_hits, segLen,
cfg.occluderMask, QueryTriggerInteraction.Ignore)
: Physics.RaycastNonAlloc(camPos + dir * skip, dir, s_hits, segLen,
cfg.occluderMask, QueryTriggerInteraction.Ignore);
if (n > HitMax) n = HitMax;
if (n > 0) Blocked++;
for (int i = 0; i < n; i++)
{
var col = s_hits[i].collider;
if (col == null) continue;
var rnd = Resolve(cfg, col);
if (rnd == null) continue;
Touch(cfg, rnd, now);
}
// ⓑ 콜라이더가 없는 소품 — 사전 스캔 목록의 바운드로 판정(폴링 중 할당 0)
EnsureScan(cfg, now);
if (s_scanCount > 0)
{
float br = cfg.boundsScanRadius > 0.0001f ? cfg.boundsScanRadius : radius;
var ray = new Ray(camPos + dir * skip, dir);
for (int i = 0; i < s_scanCount; i++)
{
var rnd = s_scan[i];
if (rnd == null) continue;
var b = rnd.bounds;
b.Expand(br * 2f); // 선분 반경만큼 부풀린다(SphereCast 와 같은 뜻)
float hitD;
if (!b.IntersectRay(ray, out hitD)) continue;
// 🔴 Bounds.IntersectRay 는 광선 시작점이 바운드 **안**에 있으면 음수 거리를 준다
// (= 카메라가 그 소품 속에 있다 = 확실한 가림). 음수를 버리면 가장 심한 가림을 놓친다.
if (hitD > segLen) continue;
Touch(cfg, rnd, now);
}
}
}
UpdateSlots(cfg, now);
}
// ─────────────────────────────────────────────────────────────────────
// 후보 판정
// ─────────────────────────────────────────────────────────────────────
/// <summary>콜라이더 → 숨겨도 되는 렌더러. 자격이 없으면 null.</summary>
static Renderer Resolve(WLCameraFramingSettings cfg, Collider col)
{
// PC·몹 (레이어로 이미 걸러지지만 프리팹이 레이어를 안 맞춰 놓은 경우까지 막는다)
if (cfg.skipActors && col.GetComponentInParent<Actor>() != null) { Rejected++; return null; }
var rnd = col.GetComponent<Renderer>();
if (rnd == null) rnd = col.GetComponentInParent<Renderer>();
if (rnd == null) { Rejected++; return null; }
return Eligible(cfg, rnd, false) ? rnd : null;
}
/// <summary>이 렌더러를 숨겨도 되는가 — 콜라이더 경로와 바운드 스캔 경로 공용 규칙.</summary>
public static bool Eligible(WLCameraFramingSettings cfg, Renderer rnd, bool checkActor)
{
if (cfg == null || rnd == null) return false;
if (rnd is ParticleSystemRenderer || rnd is TrailRenderer || rnd is LineRenderer) { Rejected++; return false; } // 이펙트
if (cfg.skipSkinnedRenderers && rnd is SkinnedMeshRenderer) { Rejected++; return false; }
if (((1 << rnd.gameObject.layer) & cfg.occluderMask.value) == 0) { Rejected++; return false; } // 마스크 밖(Actor·UI·Water…)
if (checkActor && cfg.skipActors && rnd.GetComponentInParent<Actor>() != null) { Rejected++; return false; }
// 바닥·지형·물 — 이름 규칙(자기 이름 + 부모 이름)
var tf = rnd.transform;
if (NameExcluded(cfg, tf.name) || (tf.parent != null && NameExcluded(cfg, tf.parent.name))) { Rejected++; return false; }
var b = rnd.bounds;
if (b.size.y < cfg.minOccluderHeight) { Rejected++; return false; } // 잔디·자갈은 가리지 않는다
float maxSide = Mathf.Max(b.size.x, Mathf.Max(b.size.y, b.size.z));
if (maxSide > cfg.maxOccluderSize) { Rejected++; return false; } // 지형 통짜 메시·배경 산
return true;
}
static bool NameExcluded(WLCameraFramingSettings cfg, string name)
{
if (string.IsNullOrEmpty(name)) return false;
EnsureTokens(cfg);
for (int i = 0; i < s_excludeTokens.Length; i++)
{
var t = s_excludeTokens[i];
if (t.Length > 0 && name.IndexOf(t, StringComparison.OrdinalIgnoreCase) >= 0) return true;
}
return false;
}
static void EnsureTokens(WLCameraFramingSettings cfg)
{
string src = cfg.excludeNameContains;
if (ReferenceEquals(src, s_excludeSource)) return; // 문자열이 그대로면 재분해 없음 = GC 0
s_excludeSource = src;
s_excludeTokens = string.IsNullOrEmpty(src) ? new string[0] : src.Split(',');
for (int i = 0; i < s_excludeTokens.Length; i++) s_excludeTokens[i] = s_excludeTokens[i].Trim();
}
// ─────────────────────────────────────────────────────────────────────
// 바운드 스캔 — 맵 진입 1회 목록 구축(여기서만 할당)
// ─────────────────────────────────────────────────────────────────────
static void EnsureScan(WLCameraFramingSettings cfg, float now)
{
if (!cfg.boundsScanEnabled) { s_scanCount = 0; return; }
var pc = MyValue.MyPC;
int scene = UnityEngine.SceneManagement.SceneManager.GetActiveScene().handle;
bool boundary = s_scanCount == 0 || !ReferenceEquals(pc, s_scanPc) || scene != s_scanScene;
bool timed = cfg.scanRebuildSeconds > 0.0001f && now >= s_nextScan;
if (!boundary && !timed) return;
s_scanPc = pc; s_scanScene = scene;
s_nextScan = now + Mathf.Max(1f, cfg.scanRebuildSeconds);
BuildScan(cfg);
}
/// <summary>맵 소품 목록 재구축(프로브·맵 교체용). 이 함수 안에서만 할당이 있다.</summary>
public static void RebuildScan()
{
var cfg = Cfg;
if (cfg == null) return;
s_scanPc = MyValue.MyPC;
s_scanScene = UnityEngine.SceneManagement.SceneManager.GetActiveScene().handle;
s_nextScan = Now + Mathf.Max(1f, cfg.scanRebuildSeconds);
BuildScan(cfg);
}
static void BuildScan(WLCameraFramingSettings cfg)
{
var all = UnityEngine.Object.FindObjectsByType<MeshRenderer>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
int cap = Mathf.Clamp(cfg.scanMax, 16, 8192);
if (s_scan.Length < cap) s_scan = new Renderer[cap];
int n = 0;
for (int i = 0; i < all.Length && n < cap; i++)
{
var r = all[i];
if (r == null) continue;
if (!Eligible(cfg, r, true)) continue;
s_scan[n++] = r;
}
for (int i = n; i < s_scan.Length; i++) s_scan[i] = null;
s_scanCount = n;
ScanBuilds++;
Log("스캔 재구축 " + n + " / 렌더러 " + all.Length);
}
// ─────────────────────────────────────────────────────────────────────
// 슬롯
// ─────────────────────────────────────────────────────────────────────
/// <summary>이번 폴링에서 이 렌더러가 가림으로 잡혔다 — 슬롯을 잡거나 시간을 갱신한다.</summary>
static void Touch(WLCameraFramingSettings cfg, Renderer rnd, float now)
{
int cap = Mathf.Clamp(cfg.maxConcurrent, 1, SlotMax);
int free = -1, used = 0;
for (int i = 0; i < SlotMax; i++)
{
var s = s_slots[i];
if (s.r == null) { if (free < 0) free = i; continue; }
used++;
if (ReferenceEquals(s.r, rnd)) { s.lastSeen = now; return; }
}
if (used >= cap || free < 0) { CapSkips++; return; }
var slot = s_slots[free];
slot.r = rnd;
slot.tf = rnd.transform;
slot.originShadow = rnd.shadowCastingMode;
slot.originEnabled = rnd.enabled;
slot.hidden = false;
slot.firstSeen = now;
slot.lastSeen = now;
slot.mode = ResolveMode(cfg, rnd, ref slot.colorProp, ref slot.originColor);
}
static OccluderHideMode ResolveMode(WLCameraFramingSettings cfg, Renderer rnd, ref int colorProp, ref Color originColor)
{
colorProp = 0;
originColor = Color.white;
var mode = cfg.hideMode;
if (mode == OccluderHideMode.MpbAlpha || mode == OccluderHideMode.Auto)
{
var mat = rnd.sharedMaterial;
if (mat != null)
{
int p = mat.HasProperty(PropBaseColor) ? PropBaseColor : (mat.HasProperty(PropColor) ? PropColor : 0);
// 투명 큐가 아니면 알파를 낮춰도 화면에 안 나타난다 → Auto 는 ShadowsOnly 로 내려간다.
if (p != 0 && (mode == OccluderHideMode.MpbAlpha || mat.renderQueue >= 3000))
{
colorProp = p;
originColor = mat.GetColor(p);
return OccluderHideMode.MpbAlpha;
}
}
return OccluderHideMode.ShadowsOnly;
}
return mode;
}
/// <summary>슬롯 시간 진행 — 숨김/복귀/원복.</summary>
static void UpdateSlots(WLCameraFramingSettings cfg, float now)
{
float fade = Mathf.Max(0f, cfg.fadeOutSeconds);
float back = Mathf.Max(0f, cfg.restoreDelaySeconds);
int active = 0;
for (int i = 0; i < SlotMax; i++)
{
var s = s_slots[i];
if (s.r == null) continue;
if (s.tf == null) { Clear(s, false); continue; } // 파괴됨(Unity fake-null)
if ((now - s.lastSeen) >= back) { Clear(s, true); continue; } // 가림이 풀렸다 → 복귀
if (s.mode == OccluderHideMode.MpbAlpha)
{
float t = fade > 0.0001f ? Mathf.Clamp01((now - s.firstSeen) / fade) : 1f;
SetAlpha(s, Mathf.Lerp(1f, 0f, t));
if (!s.hidden && t >= 1f) { s.hidden = true; HiddenCount++; Mark(s); }
}
else if (!s.hidden && (now - s.firstSeen) >= fade)
{
if (s.mode == OccluderHideMode.RendererOff) s.r.enabled = false;
else s.r.shadowCastingMode = ShadowCastingMode.ShadowsOnly;
s.hidden = true; HiddenCount++; Mark(s);
}
if (s.hidden) active++;
}
ActiveHidden = active;
}
static void Mark(Slot s)
{
LastHideMode = s.mode;
var c = Cfg;
if (c != null && c.verboseLog)
{
LastHiddenName = s.tf != null ? s.tf.name : "?";
Log("숨김 " + LastHiddenName + " (" + s.mode + ")");
}
}
static void SetAlpha(Slot s, float a)
{
if (s.colorProp == 0) return;
if (s_mpb == null) s_mpb = new MaterialPropertyBlock();
s.r.GetPropertyBlock(s_mpb);
var c = s.originColor; c.a = s.originColor.a * a;
s_mpb.SetColor(s.colorProp, c);
s.r.SetPropertyBlock(s_mpb);
}
/// <summary>슬롯 비우기 — restore = true 면 원래 상태로 되돌린다.</summary>
static void Clear(Slot s, bool restore)
{
if (restore && s.r != null)
{
if (s.mode == OccluderHideMode.MpbAlpha) s.r.SetPropertyBlock(null);
else if (s.mode == OccluderHideMode.RendererOff) s.r.enabled = s.originEnabled;
else s.r.shadowCastingMode = s.originShadow;
if (s.hidden) RestoredCount++;
}
s.r = null; s.tf = null; s.hidden = false; s.colorProp = 0;
}
// ─────────────────────────────────────────────────────────────────────
// 진단
// ─────────────────────────────────────────────────────────────────────
/// <summary>숨겨 둔 것을 전부 원복한다(씬 전환·러너 파괴·앱 종료·SO off).</summary>
public static void ReleaseAll()
{
for (int i = 0; i < SlotMax; i++) Clear(s_slots[i], true);
ActiveHidden = 0;
}
/// <summary>프로브용: 카운터 초기화(먼저 전부 원복한다).</summary>
public static void ResetDiagnostics()
{
ReleaseAll();
Polls = Blocked = HiddenCount = RestoredCount = CapSkips = Rejected = ScanBuilds = 0;
LastLog = LastHiddenName = "";
s_nextPoll = 0f; s_faulted = false;
}
/// <summary>프로브용: 스캔 목록을 버린다(다음 폴링에서 다시 훑는다).</summary>
public static void ClearScan() { s_scanCount = 0; s_scanPc = null; s_scanScene = -1; }
/// <summary>프로브용: 러너 없이 1회 폴링(강제 틱).</summary>
public static void ForceTick() { s_nextPoll = 0f; Tick(); }
/// <summary>프로브용: 지금 슬롯에 잡힌 렌더러 이름(없으면 빈 문자열). i = 0..7</summary>
public static string SlotName(int i)
{
if (i < 0 || i >= SlotMax) return "";
var s = s_slots[i];
return (s.r != null && s.tf != null) ? s.tf.name : "";
}
/// <summary>프로브용: 슬롯이 실제로 숨겨진 상태인지.</summary>
public static bool SlotHidden(int i)
{
if (i < 0 || i >= SlotMax) return false;
return s_slots[i].r != null && s_slots[i].hidden;
}
static void Log(string msg)
{
LastLog = msg;
var c = Cfg;
if (c != null && c.verboseLog) Debug.Log("[OccluderFade] " + msg);
}
}
}