Project_WL/Assets/WL/Camera/OccluderFade.cs

488 lines
25 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.

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