Merge branch 'wl/gameplay/WL-811o-camera-occlusion'

This commit is contained in:
깃 관리자 2026-09-09 11:08:56 +09:00
commit b668ff4d67
10 changed files with 1816 additions and 2 deletions

View File

@ -0,0 +1,763 @@
// PD 지시 #813 · 발주서 WL-811o — 카메라 가림 해소(구도 값 · 가림 소품 숨김 · 이펙트 스케일) 검증 프로브
// (에디트 모드 전용 · Play 0 · 로그인 0 · 에셋 무수정)
//
// 실행 (CLAUDE.md §1: using 금지 · 네임스페이스 풀어 쓰기)
// unity command run_script --file AgentScripts/WL811o_Probe.cs --entry WL811o_Probe.Edit --timeout 1800
//
// 무엇을 재는가
// A. 맵 — WL_Nature.prefab 인스턴스화 · 렌더러/콜라이더 수 · 레이어 분포 · 소품 셰이더 큐(숨김 방식 결정 근거)
// B. 앵커 — 존1~4 스포너(813001~813004) 월드 좌표 + 아레나 5지점((139,0.8,20) 중심 + 반경 10 의 4점) · 지면 스냅
// C. 가림률 — 9지점 × 8방향(카메라 yaw) × 9타겟(PC + 반경 4 m 8방향) = 648 선분
// ① 기하 가림(렌더러 바운드 · 콜라이더 없어도 잰다 = 화면에서 실제로 가리는 것)
// ② 물리 가림(SphereCast · OccluderFade 가 실제로 잡을 수 있는 것)
// 「전(원본 arr_cCamData[0] = 65° · 3 m · 2)」 vs 「후(SO 값)」
// D. 스윕 — cameraHeight × distance 격자의 기하 가림률(값 선택 근거)
// E. 숨김 — 가짜 가림(큐브)으로 OccluderFade 숨김/복귀 · 동시 상한 · 제외 규칙(바닥·Actor·스킨드)
// F. GC — EvaluateSegment 100회의 GC.GetAllocatedBytesForCurrentThread() 증가분(0 B 목표)
// G. C8 — RuntimeDisabled = true → 구도 미적용 · 숨김 전부 원복(원본 카메라·소품 100 %)
// H. 이펙트 — WLSkillSpectacleSettings 슬롯 4 본체 스케일 표(811i 튜닝 후보 적용 확인)
//
// 에디트 모드 주의
// · Time.unscaledTime 이 한 호출 안에서 멈춰 있다 → OccluderFade.TimeOverride 로 시뮬 시계를 민다(811gh·813s 방식).
// · 맵 인스턴스는 HideAndDontSave + DestroyImmediate. 어떤 에셋도 쓰지 않는다(씬 dirty 전/후 보고).
public static class WL811o_Probe
{
const string kOut = "AgentScripts/staging/WL811o/EDIT_PROBE.txt";
const string kMap = "Assets/Res_Addr/Map/WL_Nature.prefab";
const string kSpectacle = "Assets/WL/Combat/Settings/Resources/WL/WLSkillSpectacleSettings.asset";
// 원본 실측 (Assets/ResWork/Prefabs/Ingame/Main Camera.prefab · arr_cCamData[0])
const float kOrgHeight = 65f, kOrgDist = 3f, kOrgUp = 2f;
const float kLookY = 1.06f; // RealCamera.m_LookAt.y 실측
const float kMapYaw = 233f; // WL_Nature MapZoneData.CamRot_Portal 실측
const float kRadius = 4f; // 발주서 §1-1 「PC 주변 반경 4 m」
const float kTargetY = 1.0f; // 반경 4 m 지점의 검사 높이(가슴 높이)
static System.Collections.Generic.List<string> s_log = new System.Collections.Generic.List<string>();
static bool s_hooked;
static void OnLog(string cond, string stack, UnityEngine.LogType t)
{
if (t == UnityEngine.LogType.Error || t == UnityEngine.LogType.Exception || t == UnityEngine.LogType.Assert)
s_log.Add(t + " | " + cond);
}
static void Hook() { if (!s_hooked) { UnityEngine.Application.logMessageReceived += OnLog; s_hooked = true; } }
static void Unhook() { if (s_hooked) { UnityEngine.Application.logMessageReceived -= OnLog; s_hooked = false; } }
static UnityEngine.GameObject s_map;
static System.Collections.Generic.List<UnityEngine.Renderer> s_props = new System.Collections.Generic.List<UnityEngine.Renderer>();
static System.Collections.Generic.List<UnityEngine.Bounds> s_bounds = new System.Collections.Generic.List<UnityEngine.Bounds>();
struct Anchor { public string name; public UnityEngine.Vector3 pos; }
static System.Collections.Generic.List<Anchor> s_anchors = new System.Collections.Generic.List<Anchor>();
public static string Edit()
{
var sb = new System.Text.StringBuilder();
sb.Append("# WL-811o 프로브 (에디트 모드 · Play 0 · 로그인 0)\n");
sb.Append("unity=").Append(UnityEngine.Application.unityVersion)
.Append(" isPlaying=").Append(UnityEngine.Application.isPlaying)
.Append(" utc=").Append(System.DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss")).Append("\n");
bool dirtyBefore = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().isDirty;
sb.Append("scene=").Append(UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().name)
.Append(" dirtyBefore=").Append(dirtyBefore).Append("\n\n");
// 에셋을 손으로 고친 직후에도 현재 값을 읽게 한다(정적 캐시 + 임포트)
UnityEditor.AssetDatabase.Refresh();
WL.CameraFraming.WLCameraFramingSettings.ClearCache();
Hook(); s_log.Clear();
try
{
SectionA(sb);
SectionB(sb);
SectionC(sb);
SectionD(sb);
SectionE(sb);
SectionE2(sb);
SectionF(sb);
SectionG(sb);
SectionH(sb);
}
catch (System.Exception ex) { sb.Append("\n🔴 예외: ").Append(ex.ToString()).Append("\n"); }
finally
{
Cleanup();
Unhook();
}
bool dirtyAfter = UnityEditor.SceneManagement.EditorSceneManager.GetActiveScene().isDirty;
sb.Append("\n── 로그(Error/Exception) ").Append(s_log.Count).Append("건 ──\n");
for (int i = 0; i < s_log.Count && i < 20; i++) sb.Append(" ").Append(s_log[i]).Append("\n");
sb.Append("scene dirtyAfter=").Append(dirtyAfter).Append(" (before=").Append(dirtyBefore).Append(")\n");
Write(sb.ToString());
return sb.ToString();
}
// ─────────────────────────────────────────────────────────────────────────
// A. 맵 · 소품
// ─────────────────────────────────────────────────────────────────────────
static void SectionA(System.Text.StringBuilder sb)
{
sb.Append("── A. 맵 · 소품 실측 ──\n");
var asset = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.GameObject>(kMap);
if (asset == null) { sb.Append("🔴 ").Append(kMap).Append(" 로드 실패\n"); return; }
s_map = UnityEngine.Object.Instantiate(asset);
s_map.name = "[WL811o] MapProbe";
s_map.hideFlags = UnityEngine.HideFlags.HideAndDontSave;
UnityEngine.Physics.SyncTransforms();
var rends = s_map.GetComponentsInChildren<UnityEngine.Renderer>(true);
var cols = s_map.GetComponentsInChildren<UnityEngine.Collider>(true);
sb.Append("맵 인스턴스: 렌더러 ").Append(rends.Length).Append(" · 콜라이더 ").Append(cols.Length).Append("\n");
// 레이어 분포(콜라이더 기준 — SphereCast 가 볼 수 있는 것)
var layerCount = new int[32];
for (int i = 0; i < cols.Length; i++) layerCount[cols[i].gameObject.layer]++;
sb.Append("콜라이더 레이어: ");
for (int i = 0; i < 32; i++) if (layerCount[i] > 0)
sb.Append(i).Append('(').Append(UnityEngine.LayerMask.LayerToName(i)).Append(")=").Append(layerCount[i]).Append(' ');
sb.Append("\n");
// 「가릴 수 있는」 소품 후보 = 높이 0.8 m 이상 · 최대 변 30 m 이하 · 이름 제외 규칙 밖
s_props.Clear(); s_bounds.Clear();
int tooLow = 0, tooBig = 0, byName = 0, noMesh = 0;
for (int i = 0; i < rends.Length; i++)
{
var r = rends[i];
if (r == null) continue;
if (r is UnityEngine.ParticleSystemRenderer || r is UnityEngine.TrailRenderer || r is UnityEngine.LineRenderer) { noMesh++; continue; }
var b = r.bounds;
if (b.size.y < 0.8f) { tooLow++; continue; }
float side = UnityEngine.Mathf.Max(b.size.x, UnityEngine.Mathf.Max(b.size.y, b.size.z));
if (side > 30f) { tooBig++; continue; }
if (Excluded(r.transform.name) || (r.transform.parent != null && Excluded(r.transform.parent.name))) { byName++; continue; }
s_props.Add(r); s_bounds.Add(b);
}
sb.Append("가림 후보 렌더러 ").Append(s_props.Count)
.Append(" (제외: 높이<0.8 ").Append(tooLow).Append(" · 최대변>30 ").Append(tooBig)
.Append(" · 이름 ").Append(byName).Append(" · 이펙트 ").Append(noMesh).Append(")\n");
// 후보 중 콜라이더를 가진 비율 = OccluderFade(SphereCast)가 실제로 잡을 수 있는 비율
int withCol = 0;
for (int i = 0; i < s_props.Count; i++)
{
var t = s_props[i].transform;
if (t.GetComponent<UnityEngine.Collider>() != null || t.GetComponentInParent<UnityEngine.Collider>() != null) withCol++;
}
sb.Append("후보 중 콜라이더 보유 ").Append(withCol).Append(" / ").Append(s_props.Count)
.Append(" (").Append(Pct(withCol, s_props.Count)).Append(")\n");
// 셰이더 큐 — 숨김 방식(Auto) 결정 근거
int opaque = 0, transparent = 0;
string sample = "";
for (int i = 0; i < s_props.Count; i++)
{
var m = s_props[i].sharedMaterial;
if (m == null) continue;
if (m.renderQueue >= 3000) transparent++; else opaque++;
if (sample.Length == 0 && m.shader != null)
sample = s_props[i].transform.name + " / " + m.shader.name + " / queue " + m.renderQueue;
}
sb.Append("소품 머티리얼: 불투명 큐 ").Append(opaque).Append(" · 투명 큐 ").Append(transparent)
.Append(" → hideMode Auto 는 ").Append(transparent > opaque ? "MpbAlpha" : "ShadowsOnly").Append(" 로 내려간다\n");
sb.Append("샘플: ").Append(sample.Length > 0 ? sample : "(없음)").Append("\n");
// OccluderFade 규칙(레이어·이름·크기·Actor)을 실제로 통과하는 수 = 런타임 스캔 목록 크기 예상치
var stA = WL.CameraFraming.WLCameraFramingSettings.Instance;
if (stA != null)
{
int elig = 0;
for (int i = 0; i < rends.Length; i++)
if (rends[i] is UnityEngine.MeshRenderer && WL.CameraFraming.OccluderFade.Eligible(stA, rends[i], true)) elig++;
sb.Append("OccluderFade 규칙 통과(런타임 스캔 목록 예상) ").Append(elig).Append(" / MeshRenderer ").Append(rends.Length).Append("\n");
WL.CameraFraming.OccluderFade.ResetDiagnostics();
}
sb.Append("\n");
}
static bool Excluded(string name)
{
if (string.IsNullOrEmpty(name)) return false;
string[] toks = { "terrain", "ground", "floor", "water", "river", "navmesh", "plane", "road", "path" };
for (int i = 0; i < toks.Length; i++)
if (name.IndexOf(toks[i], System.StringComparison.OrdinalIgnoreCase) >= 0) return true;
return false;
}
// ─────────────────────────────────────────────────────────────────────────
// B. 앵커
// ─────────────────────────────────────────────────────────────────────────
static void SectionB(System.Text.StringBuilder sb)
{
sb.Append("── B. 앵커(존1~4 + 아레나 5지점) ──\n");
s_anchors.Clear();
if (s_map == null) { sb.Append("🔴 맵 인스턴스 없음\n\n"); return; }
// 시작 지점 — 🔴 QA 캡처 01_enter_hud(가림 ≈40 %)가 찍힌 자리
var portal = FindByName(s_map.transform, "Portal_Start");
if (portal != null) Add("시작(Portal_Start)", portal.position);
int[] zones = { 813001, 813002, 813003, 813004 };
var mgrs = s_map.GetComponentsInChildren<MobControlMgr>(true);
for (int z = 0; z < zones.Length; z++)
{
for (int i = 0; i < mgrs.Length; i++)
{
if (mgrs[i].SpawnerID != zones[z]) continue;
Add("존" + (z + 1) + "(" + zones[z] + ")", mgrs[i].transform.position);
break;
}
}
// 아레나 = WLBossArenaSettings 실측(중심 (139,0.8,20) · 반경 20) → 중심 + 반경 10 의 4점
var arena = new UnityEngine.Vector3(139f, 0.8f, -20f);
Add("아레나-중심", arena);
Add("아레나-N", arena + new UnityEngine.Vector3(0f, 0f, 10f));
Add("아레나-E", arena + new UnityEngine.Vector3(10f, 0f, 0f));
Add("아레나-S", arena + new UnityEngine.Vector3(0f, 0f, -10f));
Add("아레나-W", arena + new UnityEngine.Vector3(-10f, 0f, 0f));
for (int i = 0; i < s_anchors.Count; i++)
sb.Append(" ").Append(s_anchors[i].name).Append(" = ").Append(V(s_anchors[i].pos)).Append("\n");
sb.Append("앵커 ").Append(s_anchors.Count).Append("개 (지면 스냅: 위 50 m 에서 아래로 레이캐스트 · 실패 시 원좌표)\n\n");
}
static UnityEngine.Transform FindByName(UnityEngine.Transform root, string name)
{
var all = root.GetComponentsInChildren<UnityEngine.Transform>(true);
for (int i = 0; i < all.Length; i++) if (all[i].name == name) return all[i];
return null;
}
/// <summary>지면 스냅 — 소품 꼭대기·메시 뒷면에 얹히지 않게 **원 좌표 y 에 가장 가까운** 히트를 쓴다(±5 m 안에서만).</summary>
static void Add(string name, UnityEngine.Vector3 p)
{
var from = new UnityEngine.Vector3(p.x, p.y + 50f, p.z);
var hits = UnityEngine.Physics.RaycastAll(from, UnityEngine.Vector3.down, 200f, ~0, UnityEngine.QueryTriggerInteraction.Ignore);
float best = 0f, bestD = float.MaxValue;
for (int i = 0; i < hits.Length; i++)
{
float d = UnityEngine.Mathf.Abs(hits[i].point.y - p.y);
if (d < bestD) { bestD = d; best = hits[i].point.y; }
}
if (bestD <= 5f) p.y = best;
var a = new Anchor(); a.name = name; a.pos = p;
s_anchors.Add(a);
}
// ─────────────────────────────────────────────────────────────────────────
// C. 가림률 전/후
// ─────────────────────────────────────────────────────────────────────────
static void SectionC(System.Text.StringBuilder sb)
{
sb.Append("── C. 가림률 (9지점 × 8방향 × 9타겟 = ").Append(s_anchors.Count * 8 * 9).Append(" 선분) ──\n");
var st = WL.CameraFraming.WLCameraFramingSettings.Instance;
if (st == null) { sb.Append("🔴 WLCameraFramingSettings.asset 없음\n\n"); return; }
float nh = st.cameraHeight, nd = st.distance, nu = st.distanceUp >= 0f ? st.distanceUp : kOrgUp;
sb.Append("전(원본 arr_cCamData[0]) h=").Append(kOrgHeight).Append(" d=").Append(kOrgDist).Append(" up=").Append(kOrgUp)
.Append(" · 내림각 ").Append(Down(kOrgHeight, kOrgDist, kOrgUp).ToString("0.0")).Append("°\n");
sb.Append("후(SO 값) h=").Append(nh).Append(" d=").Append(nd).Append(" up=").Append(nu)
.Append(" · 내림각 ").Append(Down(nh, nd, nu).ToString("0.0")).Append("°\n\n");
sb.Append("| 지점 | 전(원본 값) | 후(값만) | 후(값+숨김) | 참고: 물리 전/후 |\n|---|---|---|---|---|\n");
int gb = 0, ga = 0, gh = 0, pb = 0, pa = 0, tot = 0;
s_maxBlockers = 0;
int pcT0 = 0, pcG0 = 0, pcT1 = 0, pcG1 = 0, pcA1 = 0;
for (int i = 0; i < s_anchors.Count; i++)
{
int t1, g1, g2, h1, h2, p1, p2;
s_pcTotal = s_pcGeo = s_pcAfter = 0;
Measure(s_anchors[i].pos, kOrgHeight, kOrgDist, kOrgUp, out t1, out g1, out h1, out p1);
pcT0 += s_pcTotal; pcG0 += s_pcGeo;
s_pcTotal = s_pcGeo = s_pcAfter = 0;
Measure(s_anchors[i].pos, nh, nd, nu, out t1, out g2, out h2, out p2);
pcT1 += s_pcTotal; pcG1 += s_pcGeo; pcA1 += s_pcAfter;
gb += g1; ga += g2; gh += h2; pb += p1; pa += p2; tot += t1;
sb.Append("| ").Append(s_anchors[i].name)
.Append(" | ").Append(Pct(g1, t1)).Append(" | ").Append(Pct(g2, t1)).Append(" | ").Append(Pct(h2, t1))
.Append(" | ").Append(Pct(p1, t1)).Append(" → ").Append(Pct(p2, t1)).Append(" |\n");
}
sb.Append("| **합계** | **").Append(Pct(gb, tot)).Append("** | **").Append(Pct(ga, tot))
.Append("** | **").Append(Pct(gh, tot)).Append("** | ").Append(Pct(pb, tot)).Append(" → ").Append(Pct(pa, tot)).Append(" |\n");
sb.Append("가림 = 렌더러 바운드 교차(콜라이더 없어도 화면에서 가린다) · 「값+숨김」 = 막은 소품이 전부 OccluderFade 규칙을 통과하고 동시 상한 이내면 해소된 것으로 센다\n");
sb.Append("물리 = SphereCast(콜라이더만) — 지형 경사면 포함이라 값과 무관하게 오르내린다\n");
sb.Append("🔴 **PC 중심 선분만**(런타임 OccluderFade 가 실제로 쏘는 그 선분) 전 ").Append(Pct(pcG0, pcT0))
.Append(" → 값만 ").Append(Pct(pcG1, pcT1)).Append(" → 값+숨김 ").Append(Pct(pcA1, pcT1)).Append("\n");
sb.Append("한 선분을 막은 소품 최대 수 ").Append(s_maxBlockers).Append("개(동시 상한 ").Append(st.maxConcurrent).Append(")\n");
sb.Append("⚠ 반경 4 m 타겟은 OccluderFade 가 직접 쏘지 않는다 — 같은 소품이 PC 선분도 막을 때만 함께 해소된다(추정)\n\n");
}
static int s_pcTotal, s_pcGeo, s_pcAfter, s_maxBlockers; // PC 중심 선분만(= OccluderFade 가 실제로 쏘는 선분)
/// <summary>한 지점의 8방향 × 9타겟 선분 중 몇 개가 가리는지. afterHide = OccluderFade 로도 안 풀리는 수.</summary>
static void Measure(UnityEngine.Vector3 foot, float h, float d, float up, out int total, out int geo, out int afterHide, out int phys)
{
total = 0; geo = 0; afterHide = 0; phys = 0;
var st = WL.CameraFraming.WLCameraFramingSettings.Instance;
int mask = st != null ? st.occluderMask.value : ((1 << 0) | (1 << 6));
float radius = st != null ? st.sphereRadius : 0.6f;
// 이 지점 주변 후보만 추린다(성능)
var near = new System.Collections.Generic.List<int>();
for (int i = 0; i < s_bounds.Count; i++)
{
var c = s_bounds[i].center;
float dx = c.x - foot.x, dz = c.z - foot.z;
if (dx * dx + dz * dz < 400f) near.Add(i); // 20 m 이내
}
for (int y = 0; y < 8; y++)
{
float yaw = kMapYaw + y * 45f;
var cam = WL.CameraFraming.CameraFramingOverride.CameraPosition(foot, yaw, h, d, up);
for (int t = 0; t < 9; t++)
{
UnityEngine.Vector3 aim;
if (t == 0) aim = foot + new UnityEngine.Vector3(0f, kLookY, 0f);
else
{
float a = (t - 1) * 45f * UnityEngine.Mathf.Deg2Rad;
aim = foot + new UnityEngine.Vector3(UnityEngine.Mathf.Sin(a) * kRadius, kTargetY, UnityEngine.Mathf.Cos(a) * kRadius);
}
total++;
var dir = aim - cam;
float len = dir.magnitude;
if (len < 0.001f) continue;
dir /= len;
var ray = new UnityEngine.Ray(cam, dir);
// ① 기하 — 렌더러 바운드 교차 (선분 끝 0.5 m 는 PC 자신이라 제외)
int blockers = 0, hideable = 0;
for (int k = 0; k < near.Count; k++)
{
float hitDist;
var b = s_bounds[near[k]];
b.Expand(radius * 2f);
// 음수 거리 = 카메라가 그 소품 안에 있다 = 확실한 가림(버리지 않는다 · OccluderFade 와 같은 판정)
if (!b.IntersectRay(ray, out hitDist) || hitDist >= len - 0.5f) continue;
blockers++;
if (WL.CameraFraming.OccluderFade.Eligible(st, s_props[near[k]], true)) hideable++;
}
int capN = st != null ? st.maxConcurrent : 4;
bool solvedRay = blockers > 0 && hideable == blockers && blockers <= capN;
if (blockers > 0)
{
geo++;
if (!solvedRay) afterHide++;
if (blockers > s_maxBlockers) s_maxBlockers = blockers;
}
if (t == 0) // PC 중심 선분 = 런타임 OccluderFade 가 실제로 쏘는 그 선분
{
s_pcTotal++;
if (blockers > 0) { s_pcGeo++; if (!solvedRay) s_pcAfter++; }
}
// ② 물리 — SphereCast(런타임 OccluderFade 와 같은 조건)
UnityEngine.RaycastHit rh;
if (UnityEngine.Physics.SphereCast(cam + dir * 0.2f, radius, dir, out rh, UnityEngine.Mathf.Max(0f, len - 0.7f),
mask, UnityEngine.QueryTriggerInteraction.Ignore)) phys++;
}
}
}
// ─────────────────────────────────────────────────────────────────────────
// D. 값 스윕
// ─────────────────────────────────────────────────────────────────────────
static void SectionD(System.Text.StringBuilder sb)
{
sb.Append("── D. 값 스윕 (기하 가림률 · 전 지점 합계) ──\n");
float[] hs = { 65f, 70f, 74f, 76f, 78f, 80f };
float[] ds = { 3.0f, 3.4f };
float[] ups = { 2.0f, 2.4f, 2.8f };
sb.Append("| h | d | up 2.0 | up 2.4 | up 2.8 | 내림각(up 2.4) | PC선분만(d·up 최적) |\n|---|---|---|---|---|---|---|\n");
float bestPct = 999f; string bestSet = "";
for (int i = 0; i < hs.Length; i++)
{
for (int j = 0; j < ds.Length; j++)
{
sb.Append("| ").Append(hs[i]).Append(" | ").Append(ds[j]);
int pcT = 0, pcG = 0;
for (int u = 0; u < ups.Length; u++)
{
int tot = 0, geo = 0, t1, g1, h1, p1;
s_pcTotal = s_pcGeo = s_pcAfter = 0;
for (int a = 0; a < s_anchors.Count; a++)
{
Measure(s_anchors[a].pos, hs[i], ds[j], ups[u], out t1, out g1, out h1, out p1);
tot += t1; geo += g1;
}
if (u == 1) { pcT = s_pcTotal; pcG = s_pcGeo; }
sb.Append(" | ").Append(Pct(geo, tot));
float pct = tot > 0 ? 100f * geo / tot : 999f;
if (pct < bestPct) { bestPct = pct; bestSet = "h " + hs[i] + " · d " + ds[j] + " · up " + ups[u]; }
}
sb.Append(" | ").Append(Down(hs[i], ds[j], 2.4f).ToString("0.0")).Append("°")
.Append(" | ").Append(Pct(pcG, pcT)).Append(" |\n");
}
}
sb.Append("최소 = ").Append(bestSet).Append(" (").Append(bestPct.ToString("0.0")).Append("%) · 원본은 h 65 · d 3.0 · up 2.0\n\n");
}
// ─────────────────────────────────────────────────────────────────────────
// E. OccluderFade 가짜 가림
// ─────────────────────────────────────────────────────────────────────────
static void SectionE(System.Text.StringBuilder sb)
{
sb.Append("── E. OccluderFade 가짜 가림 → 숨김/복귀 ──\n");
var st = WL.CameraFraming.WLCameraFramingSettings.Instance;
if (st == null) { sb.Append("🔴 SO 없음\n\n"); return; }
// 🔴 hideFlags 를 주지 않는다 — FindObjectsByType 이 DontSave 오브젝트를 빼기 때문에
// 바운드 스캔 경로(ⓑ)를 검증하려면 평범한 오브젝트여야 한다. 씬은 저장하지 않는다(끝에 파괴 + dirty 보고).
var root = new UnityEngine.GameObject("[WL811o] FakeOcc");
var far = new UnityEngine.Vector3(0f, 0f, 3000f); // 맵에서 멀리 떨어진 빈 공간
var cam = far + new UnityEngine.Vector3(0f, 4.5f, -5.4f);
var aim = far + new UnityEngine.Vector3(0f, 1.06f, 0f);
try
{
WL.CameraFraming.WLCameraFramingSettings.RuntimeDisabled = false;
WL.CameraFraming.OccluderFade.ResetDiagnostics();
WL.CameraFraming.OccluderFade.TimeOverride = 100f;
// 큐브 6개를 선분 위에 나란히 — 동시 상한(기본 4) 검증
var cubes = new UnityEngine.GameObject[6];
for (int i = 0; i < cubes.Length; i++)
{
var c = UnityEngine.GameObject.CreatePrimitive(UnityEngine.PrimitiveType.Cube);
c.name = "FakeRock_" + i;
c.transform.SetParent(root.transform, true);
c.transform.position = UnityEngine.Vector3.Lerp(cam, aim, 0.25f + i * 0.08f) + new UnityEngine.Vector3(i * 0.03f, 0f, 0f);
c.transform.localScale = new UnityEngine.Vector3(1.4f, 2.2f, 1.4f);
c.layer = 0;
cubes[i] = c;
}
// 🔴 콜라이더 없는 소품 — WL_Nature 소품의 87.6 % 가 이 상태다. 바운드 스캔(ⓑ)만이 잡을 수 있다.
UnityEngine.Object.DestroyImmediate(cubes[0].GetComponent<UnityEngine.Collider>());
// 제외 규칙 검증용 — 바닥 이름
var floor = UnityEngine.GameObject.CreatePrimitive(UnityEngine.PrimitiveType.Cube);
floor.name = "Ground_Fake"; floor.transform.SetParent(root.transform, true);
floor.transform.position = UnityEngine.Vector3.Lerp(cam, aim, 0.5f);
floor.transform.localScale = new UnityEngine.Vector3(2f, 2f, 2f); floor.layer = 0;
UnityEngine.Physics.SyncTransforms();
WL.CameraFraming.OccluderFade.ClearScan();
WL.CameraFraming.OccluderFade.RebuildScan();
sb.Append("바운드 스캔 목록 ").Append(WL.CameraFraming.OccluderFade.ScanCount)
.Append("개 (FakeRock 6 + Ground_Fake 는 이름 규칙으로 빠진다)\n");
sb.Append("hideMode(SO)=").Append(st.hideMode).Append(" · maxConcurrent=").Append(st.maxConcurrent)
.Append(" · sphereRadius=").Append(st.sphereRadius).Append(" · fadeOut=").Append(st.fadeOutSeconds)
.Append(" · restoreDelay=").Append(st.restoreDelaySeconds).Append("\n");
// t=100 첫 관측(아직 숨기지 않는다 = 지연) → t=100.25 숨김
WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim);
int hidden0 = WL.CameraFraming.OccluderFade.ActiveHidden;
WL.CameraFraming.OccluderFade.TimeOverride = 100f + st.fadeOutSeconds + 0.05f;
WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim);
int hidden1 = WL.CameraFraming.OccluderFade.ActiveHidden;
sb.Append("첫 관측 직후 숨김 ").Append(hidden0).Append("(기대 0 = ").Append(st.fadeOutSeconds).Append(" s 지연)");
sb.Append(" → 지연 뒤 숨김 ").Append(hidden1).Append("(기대 ").Append(st.maxConcurrent).Append(" = 동시 상한)\n");
sb.Append("숨긴 것: ");
for (int i = 0; i < 8; i++)
{
var n = WL.CameraFraming.OccluderFade.SlotName(i);
if (n.Length > 0) sb.Append(n).Append(WL.CameraFraming.OccluderFade.SlotHidden(i) ? "(hidden) " : "(대기) ");
}
sb.Append("\n");
sb.Append("바닥 이름 제외(Ground_Fake 가 숨김 목록에 없어야 한다): ")
.Append(HasSlot("Ground_Fake") ? "🔴 실패" : "PASS").Append("\n");
// 실제 상태 확인
int shadowOnly = 0, off = 0;
for (int i = 0; i < cubes.Length; i++)
{
var r = cubes[i].GetComponent<UnityEngine.Renderer>();
if (r == null) continue;
if (!r.enabled) off++;
else if (r.shadowCastingMode == UnityEngine.Rendering.ShadowCastingMode.ShadowsOnly) shadowOnly++;
}
sb.Append("렌더러 상태: ShadowsOnly ").Append(shadowOnly).Append(" · enabled=false ").Append(off).Append("\n");
// 가림 해제 → 복귀
WL.CameraFraming.OccluderFade.TimeOverride = 200f;
WL.CameraFraming.OccluderFade.EvaluateSegment(cam + new UnityEngine.Vector3(500f, 0f, 0f), aim + new UnityEngine.Vector3(500f, 0f, 0f));
int hidden2 = WL.CameraFraming.OccluderFade.ActiveHidden;
int restoredOk = 0;
for (int i = 0; i < cubes.Length; i++)
{
var r = cubes[i].GetComponent<UnityEngine.Renderer>();
if (r != null && r.enabled && r.shadowCastingMode == UnityEngine.Rendering.ShadowCastingMode.On) restoredOk++;
}
sb.Append("가림 해제 후 숨김 ").Append(hidden2).Append("(기대 0) · 원상복귀 렌더러 ")
.Append(restoredOk).Append(" / ").Append(cubes.Length).Append("\n");
// 콜라이더 없는 소품 단독 검증 — 물리 경로가 슬롯을 다 채우지 못하게 큐브 1개만 둔다
for (int i = 1; i < cubes.Length; i++) cubes[i].SetActive(false);
UnityEngine.GameObject.DestroyImmediate(floor);
UnityEngine.Physics.SyncTransforms();
WL.CameraFraming.OccluderFade.ReleaseAll();
WL.CameraFraming.OccluderFade.ClearScan();
WL.CameraFraming.OccluderFade.RebuildScan();
WL.CameraFraming.OccluderFade.TimeOverride = 300f;
WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim);
WL.CameraFraming.OccluderFade.TimeOverride = 300.5f;
WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim);
var r0 = cubes[0].GetComponent<UnityEngine.Renderer>();
bool onlyNoCol = HasSlot("FakeRock_0") &&
r0.shadowCastingMode == UnityEngine.Rendering.ShadowCastingMode.ShadowsOnly;
sb.Append("콜라이더 없는 소품 단독(FakeRock_0 · 콜라이더 ")
.Append(cubes[0].GetComponent<UnityEngine.Collider>() == null ? "0" : "1")
.Append("): ").Append(onlyNoCol ? "PASS(바운드 스캔이 잡아 숨겼다)" : "🔴 실패")
.Append(" · 숨김 ").Append(WL.CameraFraming.OccluderFade.ActiveHidden)
.Append(" · shadow=").Append(r0.shadowCastingMode)
.Append(" · boundsScanEnabled=").Append(st.boundsScanEnabled)
.Append(" · 스캔 ").Append(WL.CameraFraming.OccluderFade.ScanCount)
.Append(" · Eligible=").Append(WL.CameraFraming.OccluderFade.Eligible(st, r0, true)).Append("\n");
sb.Append("카운터: polls=").Append(WL.CameraFraming.OccluderFade.Polls)
.Append(" blocked=").Append(WL.CameraFraming.OccluderFade.Blocked)
.Append(" hidden=").Append(WL.CameraFraming.OccluderFade.HiddenCount)
.Append(" restored=").Append(WL.CameraFraming.OccluderFade.RestoredCount)
.Append(" capSkip=").Append(WL.CameraFraming.OccluderFade.CapSkips)
.Append(" rejected=").Append(WL.CameraFraming.OccluderFade.Rejected).Append("\n\n");
}
finally
{
WL.CameraFraming.OccluderFade.ReleaseAll();
WL.CameraFraming.OccluderFade.TimeOverride = -1f;
UnityEngine.Object.DestroyImmediate(root);
}
}
static bool HasSlot(string name)
{
for (int i = 0; i < 8; i++) if (WL.CameraFraming.OccluderFade.SlotName(i) == name) return true;
return false;
}
// ─────────────────────────────────────────────────────────────────────────
// F. GC
// ─────────────────────────────────────────────────────────────────────────
static void SectionF(System.Text.StringBuilder sb)
{
sb.Append("── F. GC (EvaluateSegment 100회) ──\n");
var root = new UnityEngine.GameObject("[WL811o] GcOcc"); // 바운드 스캔까지 재려면 평범한 오브젝트여야 한다
var far = new UnityEngine.Vector3(0f, 0f, 4000f);
var cam = far + new UnityEngine.Vector3(0f, 4.5f, -5.4f);
var aim = far + new UnityEngine.Vector3(0f, 1.06f, 0f);
try
{
for (int i = 0; i < 3; i++)
{
var c = UnityEngine.GameObject.CreatePrimitive(UnityEngine.PrimitiveType.Cube);
c.name = "GcRock_" + i; c.transform.SetParent(root.transform, true);
c.transform.position = UnityEngine.Vector3.Lerp(cam, aim, 0.3f + i * 0.1f);
c.transform.localScale = new UnityEngine.Vector3(1.4f, 2.2f, 1.4f); c.layer = 0;
}
UnityEngine.Physics.SyncTransforms();
WL.CameraFraming.OccluderFade.ResetDiagnostics();
WL.CameraFraming.OccluderFade.TimeOverride = 1000f;
WL.CameraFraming.OccluderFade.ClearScan();
WL.CameraFraming.OccluderFade.RebuildScan(); // 스캔은 여기서 1회(측정 구간 밖)
WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim); // 워밍업(토큰 분해·MPB 최초 할당)
WL.CameraFraming.OccluderFade.TimeOverride = 1001f;
WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim);
long before = System.GC.GetAllocatedBytesForCurrentThread();
for (int i = 0; i < 100; i++)
{
WL.CameraFraming.OccluderFade.TimeOverride = 1001f + i * 0.01f;
WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim);
}
long after = System.GC.GetAllocatedBytesForCurrentThread();
sb.Append("증가분 ").Append(after - before).Append(" B (100회 · 목표 0) · 스캔 목록 ")
.Append(WL.CameraFraming.OccluderFade.ScanCount).Append("개 · 측정 중 재구축 ")
.Append(WL.CameraFraming.OccluderFade.ScanBuilds - 1).Append("회(기대 0)\n\n");
}
finally
{
WL.CameraFraming.OccluderFade.ReleaseAll();
WL.CameraFraming.OccluderFade.TimeOverride = -1f;
UnityEngine.Object.DestroyImmediate(root);
}
}
// ─────────────────────────────────────────────────────────────────────────
// E2. 구도 오버라이드 적용 · 복원 (가짜 RealCamera · 원본 프리팹 무수정)
// ─────────────────────────────────────────────────────────────────────────
static void SectionE2(System.Text.StringBuilder sb)
{
sb.Append("── E2. 구도 오버라이드 적용/복원 ──\n");
var st = WL.CameraFraming.WLCameraFramingSettings.Instance;
if (st == null) { sb.Append("🔴 SO 없음\n\n"); return; }
var go = new UnityEngine.GameObject("[WL811o] FakeRealCamera");
go.hideFlags = UnityEngine.HideFlags.HideAndDontSave;
try
{
// 🔴 에디트 모드에서는 MonoBehaviour Awake 가 돌지 않는다([ExecuteAlways] 아님) → 원본 초기화 부작용 0
var cam = go.AddComponent<RealCamera>();
cam.arr_cCamData = new cCamData[2];
cam.arr_cCamData[0] = new cCamData(); // 원본 프리팹 실측값
cam.arr_cCamData[0].minDistance = 3f; cam.arr_cCamData[0].maxDistance = 3f;
cam.arr_cCamData[0].DistanceUp = 2f; cam.arr_cCamData[0].cameraHeight = 65f;
cam.arr_cCamData[1] = new cCamData();
cam.arr_cCamData[1].minDistance = 2f; cam.arr_cCamData[1].maxDistance = 3f;
cam.arr_cCamData[1].DistanceUp = 3.5f; cam.arr_cCamData[1].cameraHeight = 78f;
cam.minDistance = 3f; cam.maxDistance = 3f; cam.DistanceUp = 2f; cam.cameraHeight = 65f;
WL.CameraFraming.WLCameraFramingSettings.RuntimeDisabled = false;
WL.CameraFraming.CameraFramingOverride.ResetDiagnostics();
bool ok = WL.CameraFraming.CameraFramingOverride.ProbeApply(cam);
sb.Append("적용 ").Append(ok ? "O" : "🔴 X")
.Append(" · arr_cCamData[0] h=").Append(cam.arr_cCamData[0].cameraHeight)
.Append(" min=").Append(cam.arr_cCamData[0].minDistance)
.Append(" max=").Append(cam.arr_cCamData[0].maxDistance)
.Append(" up=").Append(cam.arr_cCamData[0].DistanceUp).Append("\n");
sb.Append("라이브 필드 h=").Append(cam.cameraHeight).Append(" min=").Append(cam.minDistance)
.Append(" max=").Append(cam.maxDistance).Append(" up=").Append(cam.DistanceUp)
.Append(" · 내림각 ").Append(Down(cam.cameraHeight, cam.maxDistance, cam.DistanceUp).ToString("0.0")).Append("°\n");
sb.Append("보스 프리셋 arr_cCamData[1] 무변경: ")
.Append(cam.arr_cCamData[1].cameraHeight == 78f && cam.arr_cCamData[1].maxDistance == 3f ? "PASS" : "🔴 실패").Append("\n");
// C8 — SO 를 끄고 틱 1회 → 원값 복귀
WL.CameraFraming.WLCameraFramingSettings.RuntimeDisabled = true;
WL.CameraFraming.CameraFramingOverride.ForceTick();
bool restored = cam.arr_cCamData[0].cameraHeight == 65f && cam.arr_cCamData[0].maxDistance == 3f
&& cam.arr_cCamData[0].DistanceUp == 2f && cam.cameraHeight == 65f;
sb.Append("C8 off → arr_cCamData[0] h=").Append(cam.arr_cCamData[0].cameraHeight)
.Append(" max=").Append(cam.arr_cCamData[0].maxDistance).Append(" up=").Append(cam.arr_cCamData[0].DistanceUp)
.Append(" · 라이브 h=").Append(cam.cameraHeight)
.Append(" → ").Append(restored ? "PASS(원본 100 %)" : "🔴 실패").Append("\n");
sb.Append("카운터: boundaries=").Append(WL.CameraFraming.CameraFramingOverride.Boundaries)
.Append(" applied=").Append(WL.CameraFraming.CameraFramingOverride.Applied)
.Append(" restored=").Append(WL.CameraFraming.CameraFramingOverride.Restored)
.Append(" skippedBusy=").Append(WL.CameraFraming.CameraFramingOverride.SkippedBusy).Append("\n\n");
}
finally
{
WL.CameraFraming.WLCameraFramingSettings.RuntimeDisabled = false;
WL.CameraFraming.CameraFramingOverride.ProbeRestore();
UnityEngine.Object.DestroyImmediate(go);
}
}
// ─────────────────────────────────────────────────────────────────────────
// G. C8
// ─────────────────────────────────────────────────────────────────────────
static void SectionG(System.Text.StringBuilder sb)
{
sb.Append("── G. C8 롤백 ──\n");
var root = new UnityEngine.GameObject("[WL811o] C8Occ");
root.hideFlags = UnityEngine.HideFlags.HideAndDontSave;
var far = new UnityEngine.Vector3(0f, 0f, 5000f);
var cam = far + new UnityEngine.Vector3(0f, 4.5f, -5.4f);
var aim = far + new UnityEngine.Vector3(0f, 1.06f, 0f);
try
{
var c = UnityEngine.GameObject.CreatePrimitive(UnityEngine.PrimitiveType.Cube);
c.name = "C8Rock"; c.transform.SetParent(root.transform, true);
c.transform.position = UnityEngine.Vector3.Lerp(cam, aim, 0.4f);
c.transform.localScale = new UnityEngine.Vector3(1.4f, 2.2f, 1.4f); c.layer = 0;
UnityEngine.Physics.SyncTransforms();
var rnd = c.GetComponent<UnityEngine.Renderer>();
WL.CameraFraming.WLCameraFramingSettings.RuntimeDisabled = false;
WL.CameraFraming.OccluderFade.ResetDiagnostics();
WL.CameraFraming.OccluderFade.TimeOverride = 2000f;
WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim);
WL.CameraFraming.OccluderFade.TimeOverride = 2000.5f;
WL.CameraFraming.OccluderFade.EvaluateSegment(cam, aim);
sb.Append("on : 숨김 ").Append(WL.CameraFraming.OccluderFade.ActiveHidden)
.Append(" · 렌더러 shadow=").Append(rnd.shadowCastingMode).Append(" enabled=").Append(rnd.enabled).Append("\n");
WL.CameraFraming.WLCameraFramingSettings.RuntimeDisabled = true;
sb.Append("RuntimeDisabled=true → Active(occ)=").Append(WL.CameraFraming.OccluderFade.Active)
.Append(" Active(framing)=").Append(WL.CameraFraming.CameraFramingOverride.Active).Append("\n");
WL.CameraFraming.OccluderFade.ForceTick(); // Active=false 경로 → ReleaseAll
WL.CameraFraming.CameraFramingOverride.ForceTick(); // Active=false 경로 → 구도 복원
sb.Append("off: 숨김 ").Append(WL.CameraFraming.OccluderFade.ActiveHidden)
.Append(" · 렌더러 shadow=").Append(rnd.shadowCastingMode).Append(" enabled=").Append(rnd.enabled)
.Append(" · framing applied=").Append(WL.CameraFraming.CameraFramingOverride.IsApplied).Append("\n");
sb.Append(rnd.enabled && rnd.shadowCastingMode == UnityEngine.Rendering.ShadowCastingMode.On
? "→ PASS (원본 소품 100 %)\n\n" : "→ 🔴 실패\n\n");
}
finally
{
WL.CameraFraming.WLCameraFramingSettings.RuntimeDisabled = false;
WL.CameraFraming.OccluderFade.ReleaseAll();
WL.CameraFraming.OccluderFade.TimeOverride = -1f;
UnityEngine.Object.DestroyImmediate(root);
}
}
// ─────────────────────────────────────────────────────────────────────────
// H. 이펙트 스케일
// ─────────────────────────────────────────────────────────────────────────
static void SectionH(System.Text.StringBuilder sb)
{
sb.Append("── H. 대형 이펙트 스케일(슬롯 4 본체) ──\n");
var so = UnityEditor.AssetDatabase.LoadAssetAtPath<UnityEngine.ScriptableObject>(kSpectacle);
if (so == null) { sb.Append("🔴 ").Append(kSpectacle).Append(" 로드 실패\n\n"); return; }
var sobj = new UnityEditor.SerializedObject(so);
var rows = sobj.FindProperty("rows");
if (rows == null || !rows.isArray) { sb.Append("🔴 rows 배열 없음\n\n"); return; }
sb.Append("| skillId | slot | body.scale |\n|---|---|---|\n");
for (int i = 0; i < rows.arraySize; i++)
{
var e = rows.GetArrayElementAtIndex(i);
var slot = e.FindPropertyRelative("slot");
if (slot == null || slot.intValue != 4) continue;
var id = e.FindPropertyRelative("skillId");
var body = e.FindPropertyRelative("body");
var sc = body != null ? body.FindPropertyRelative("scale") : null;
sb.Append("| ").Append(id != null ? id.intValue.ToString() : "?")
.Append(" | 4 | ").Append(sc != null ? sc.floatValue.ToString("0.###") : "?").Append(" |\n");
}
sb.Append("\n");
}
// ─────────────────────────────────────────────────────────────────────────
// 공용
// ─────────────────────────────────────────────────────────────────────────
static float Down(float h, float d, float up)
{
return WL.CameraFraming.CameraFramingOverride.DownAngleDeg(h, d, up, kLookY);
}
static string Pct(int n, int total)
{
if (total <= 0) return "-";
return (100f * n / total).ToString("0.0") + "% (" + n + "/" + total + ")";
}
static string V(UnityEngine.Vector3 p)
{
return "(" + p.x.ToString("0.0") + ", " + p.y.ToString("0.00") + ", " + p.z.ToString("0.0") + ")";
}
static void Cleanup()
{
if (s_map != null) { UnityEngine.Object.DestroyImmediate(s_map); s_map = null; }
s_props.Clear(); s_bounds.Clear();
}
static void Write(string text)
{
try
{
var full = System.IO.Path.Combine(System.IO.Directory.GetParent(UnityEngine.Application.dataPath).FullName, kOut);
System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(full));
System.IO.File.WriteAllText(full, text, new System.Text.UTF8Encoding(true));
}
catch (System.Exception) { }
}
}

View File

@ -0,0 +1,329 @@
// ─────────────────────────────────────────────────────────────────────────────
// CameraFramingOverride.cs — 맵 진입 시 카메라 구도(내려다보는 각 + 거리) 1회 오버라이드
//
// PD 지시 #813 · 발주서 WL-811o §1-1 (2026-09-09) · 근거 = qa/W2-4b_재채점_v2.md D-8 · 채점 7-3
//
// ■ 관측된 결함 (QA 실측 · 캡처 Screenshots_WL/Q4b/01_enter_hud.png)
// 존1 진입 직후 큰 덤불/바위가 PC 를 화면의 40 % 가까이 덮는다. 카메라가 거의 눕다시피 해서
// 카메라~PC 선분이 지면 가까이 깔리기 때문이다(아래 실측).
//
// ■ 원인 (코드·프리팹 실측)
// Main Camera.prefab arr_cCamData[0] = minDistance 3 · maxDistance 3 · DistanceUp 2 · **cameraHeight 65**
// (원본 커밋 d5df1b2c9 「카메라 수정」 에서 78 → 65 · 1.5/1.6/1 → 3/3/2 로 바뀌었다.)
// RealCamera 궤도 수식으로 환산하면 **내림각 25.6°** — 카메라~주시점 선분이 PC 로부터 2.5 m 지점에서
// 지면 위 2.26 m 밖에 안 된다. 맵의 바위·덤불이 그보다 높으면 그대로 PC 를 덮는다.
//
// ■ 이 파일이 하는 것 — 원본 훅 0 · RealCamera 무수정 · 폴링만(AutoCombatOnEnter 선례)
// 맵(런) 진입 경계에서 **1회**, RealCamera 의 공개 데이터 arr_cCamData[0](일반 프리셋)에
// SO 값(cameraHeight · distance · distanceUp)을 쓰고, 살아 있는 공개 필드에도 같은 값을 반영한다.
// · arr_cCamData[0] 을 쓰는 이유 = 원본이 나중에 Init_Cam()(private · Set_Target(t, rot) 경유)으로
// 프리셋을 **다시 읽어** 라이브 필드를 덮어쓰기 때문. 배열까지 바꿔야 값이 유지된다.
// (같은 이유로 811d 는 arr_cCamData[1] 보스 프리셋을 덮어쓴다 — 이 파일은 [0] 만 건드린다.)
// · 라이브 필드 반영 = minDistance/maxDistance/cameraHeight 직접 대입 + Set_DistanceUp(false)(공개 API).
// Init_Cam() 의 나머지 한 줄(private VerticalAxis)은 DistanceAway 를 즉시 스냅시키는 용도인데,
// min=max 로 두면 다음 Update_Cam 의 Clamp 가 같은 일을 하므로 필요 없다.
// · 원값은 적용 직전에 스냅샷 → SO off · 씬 전환 · 러너 파괴 때 그대로 되돌린다(C8).
// · 킬캠/보스 캠(811d)이 궤도 거리를 쓰고 있는 동안에는 적용을 미룬다(다음 폴링에서 재시도) —
// 진행 중인 램프의 base 를 오염시키지 않기 위해서다.
//
// ■ 왜 매 프레임 쓰지 않는가
// 811d CombatCameraDirector 가 킬캠·보스 캠에서 같은 필드를 램프한다. 매 프레임 쓰면 두 기능이 싸운다.
// 구도는 「진입 시 1회」로 충분하다(발주서 §1-1).
//
// ■ 값(C45)은 전부 WLCameraFramingSettings. framingEnabled = false 면 아무 것도 쓰지 않는다.
//
// 🔴 어셈블리 주의: Assets/WL/Camera/ 에 .asmdef 를 만들지 말 것(RealCamera 가 Assembly-CSharp 에 있다).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
using WL.CombatCamera;
namespace WL.CameraFraming
{
/// <summary>맵 진입 시 카메라 구도 1회 오버라이드. 정적 · 새 Manager/Singleton 0.</summary>
public static class CameraFramingOverride
{
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.framingEnabled && !WLCameraFramingSettings.RuntimeDisabled; }
}
// ───────────────────────────────────────── 진단 카운터 (프로브가 읽는다)
public static int Boundaries, Applied, Restored, SkippedLobby, SkippedBusy, SkippedNoCam;
public static string LastReason = "", LastLog = "";
/// <summary>지금 우리 값이 걸려 있는가.</summary>
public static bool IsApplied { get { return s_applied; } }
/// <summary>적용 중인 값(진단) — 궤도 파라미터 · 거리 · 궤도 중심 높이.</summary>
public static float AppliedHeight, AppliedDistance, AppliedUp;
/// <summary>원값 스냅샷(진단) — 복원 대상.</summary>
public static float OriginHeight, OriginMin, OriginMax, OriginUp;
// ───────────────────────────────────────── 내부 상태
static PCActor s_lastPc;
static int s_lastMapId = int.MinValue;
static float s_nextPoll;
static bool s_faulted;
static bool s_applied;
static cCamData s_data; // 우리가 값을 쓴 프리셋(원본 [Serializable] 클래스 = fake-null 아님)
static RealCamera s_cam;
static float s_wHeight, s_wMin, s_wMax, s_wUp; // 우리가 쓴 값(외부 덮어쓰기 감지)
// ─────────────────────────────────────────────────────────────────────
// 틱
// ─────────────────────────────────────────────────────────────────────
/// <summary>러너가 매 프레임 부른다(에디트 모드에서는 프로브가 ForceTick 으로 직접 부를 수 있다).</summary>
internal static void Tick()
{
if (s_faulted) return;
// C8 — 기능이 꺼지면 즉시 원값으로 되돌린다.
if (!Active) { if (s_applied) Restore("off"); return; }
var cfg = Cfg;
float now = Time.unscaledTime;
if (now < s_nextPoll) return;
s_nextPoll = now + Mathf.Max(0.02f, cfg.framingPollSeconds);
// 🔴 Debug.LogException 을 쓰지 않는다 — ErrorLogHookManager 가 그것을 플레이어 팝업으로 띄운다(813x §D-1).
try { PollBoundary(cfg); }
catch (System.Exception ex) { s_faulted = true; Log("중단(예외) — " + ex.Message); }
}
static void PollBoundary(WLCameraFramingSettings cfg)
{
var pc = MyValue.MyPC;
if (DSUtil.CheckNull(pc)) { s_lastPc = null; return; }
var info = InGameInfo.Ins;
if (DSUtil.CheckNull(info)) return; // 아직 인게임 정보가 없다 — 경계를 소비하지 않는다
int map = MyValue.MyChoiceMapData != null ? MyValue.MyChoiceMapData.n_MapID : int.MinValue;
bool pcChanged = !ReferenceEquals(pc, s_lastPc);
bool mapChanged = map != s_lastMapId;
if (!pcChanged && !mapChanged) return;
if (!cfg.framingInLobby && info.Get_GameMode() == eGameMode.Lobby)
{
s_lastPc = pc; s_lastMapId = map;
Boundaries++; SkippedLobby++;
LastReason = "lobby";
Log("로비 — 건너뜀");
return;
}
var cam = MyValue.m_RealCamera;
if (DSUtil.CheckNull(cam) || cam.arr_cCamData == null || cam.arr_cCamData.Length == 0 || cam.arr_cCamData[0] == null)
{
SkippedNoCam++; // 경계를 소비하지 않는다 — 카메라가 뜨면 다음 폴링에서 다시 본다
return;
}
// 811d 킬캠·보스 캠이 궤도 거리를 램프하는 중이면 미룬다(그 base 를 오염시키지 않는다).
if (CombatCameraDirector.KillCamActive || CombatCameraDirector.BossCamActive || CombatCameraDirector.BossPresetActive)
{
SkippedBusy++;
return;
}
s_lastPc = pc; s_lastMapId = map;
Boundaries++;
LastReason = pcChanged ? "pc" : ("map " + map);
Apply(cfg, cam);
}
// ─────────────────────────────────────────────────────────────────────
// 적용 · 복원
// ─────────────────────────────────────────────────────────────────────
static void Apply(WLCameraFramingSettings cfg, RealCamera cam)
{
var d = cam.arr_cCamData[0];
// 이미 우리 값이 걸려 있고 같은 프리셋이면 원값 스냅샷을 다시 잡지 않는다(자기 값을 원값으로 착각 방지).
if (!s_applied || !ReferenceEquals(d, s_data))
{
s_data = d;
OriginHeight = d.cameraHeight; OriginMin = d.minDistance; OriginMax = d.maxDistance; OriginUp = d.DistanceUp;
}
float h = Mathf.Clamp(cfg.cameraHeight, 50f, 85f);
float dist = Mathf.Clamp(cfg.distance, 1f, 8f);
float up = cfg.distanceUp >= 0f ? cfg.distanceUp : OriginUp;
d.cameraHeight = h;
d.minDistance = dist;
d.maxDistance = dist;
d.DistanceUp = up;
// 라이브 필드 — Init_Cam() 이 하는 일과 같은 효과(공개 API 만 사용)
cam.cameraHeight = h;
cam.minDistance = dist;
cam.maxDistance = dist;
cam.Set_DistanceUp(false); // DistanceUp = arr_cCamData[islockTarget ? 1 : 0].DistanceUp
s_wHeight = h; s_wMin = dist; s_wMax = dist; s_wUp = up;
s_cam = cam;
s_applied = true;
Applied++;
AppliedHeight = h; AppliedDistance = dist; AppliedUp = up;
Log("구도 적용 #" + Applied + " (" + LastReason + ") h " + OriginHeight + "→" + h +
" · dist " + OriginMax + "→" + dist + " · up " + OriginUp + "→" + up +
" · 내림각 " + DownAngleDeg(h, dist, up).ToString("0.0") + "°");
}
/// <summary>원값으로 되돌린다(외부가 그 사이에 값을 바꿨으면 건드리지 않는다).</summary>
static void Restore(string why)
{
if (!s_applied) return;
s_applied = false;
var d = s_data;
if (d != null && d.cameraHeight == s_wHeight && d.minDistance == s_wMin && d.maxDistance == s_wMax && d.DistanceUp == s_wUp)
{
d.cameraHeight = OriginHeight; d.minDistance = OriginMin; d.maxDistance = OriginMax; d.DistanceUp = OriginUp;
var cam = s_cam;
if (!DSUtil.CheckNull(cam))
{
cam.cameraHeight = OriginHeight;
cam.minDistance = OriginMin;
cam.maxDistance = OriginMax;
cam.Set_DistanceUp(false);
}
Restored++;
Log("구도 복원(" + why + ") h→" + OriginHeight + " · dist→" + OriginMax);
}
else Log("구도 복원 포기(" + why + ") — 외부가 값을 바꿨다");
s_data = null; s_cam = null;
}
/// <summary>씬 전환·러너 파괴·앱 종료에서 부른다.</summary>
public static void ReleaseAll() { Restore("release"); s_lastPc = null; s_lastMapId = int.MinValue; }
// ─────────────────────────────────────────────────────────────────────
// 궤도 환산 (진단·프로브 공용 · RealCamera.Update_Cam 수식 그대로)
// ─────────────────────────────────────────────────────────────────────
/// <summary>궤도 파라미터 h 에서 카메라가 타겟 발밑보다 얼마나 높은지(m).</summary>
public static float CamHeightAbove(float h, float distance, float distanceUp)
{
float r = h * Mathf.Deg2Rad;
return distanceUp + distance * (Mathf.Sin(r) - Mathf.Cos(r));
}
/// <summary>궤도 파라미터 h 에서 카메라~타겟 수평 거리(m).</summary>
public static float CamHorizontal(float h, float distance)
{
float r = h * Mathf.Deg2Rad;
float s = Mathf.Sin(r) + Mathf.Cos(r);
return distance * Mathf.Sqrt(1f + s * s);
}
/// <summary>주시점(발밑 + lookAtY)까지의 내림각(도). lookAtY 기본 1.06 = RealCamera.m_LookAt.y 실측.</summary>
public static float DownAngleDeg(float h, float distance, float distanceUp, float lookAtY = 1.06f)
{
float dy = CamHeightAbove(h, distance, distanceUp) - lookAtY;
float dx = CamHorizontal(h, distance);
if (dx <= 0.0001f) return 90f;
return Mathf.Atan2(dy, dx) * Mathf.Rad2Deg;
}
/// <summary>궤도 파라미터 h 의 카메라 위치(월드) — 프로브가 가림률을 계산할 때 쓴다.</summary>
public static Vector3 CameraPosition(Vector3 targetFoot, float yawDeg, float h, float distance, float distanceUp)
{
Vector3 rotateVector = Quaternion.Euler(h, yawDeg, 0f) * Vector3.one;
return targetFoot + Vector3.up * distanceUp - rotateVector * distance;
}
// ─────────────────────────────────────────────────────────────────────
// 진단 · 러너
// ─────────────────────────────────────────────────────────────────────
static void Log(string msg)
{
LastLog = msg;
var c = Cfg;
if (c != null && c.verboseLog) Debug.Log("[CameraFraming] " + msg);
}
/// <summary>프로브용: 카운터·경계 상태 초기화(적용된 값은 유지).</summary>
public static void ResetDiagnostics()
{
Boundaries = Applied = Restored = SkippedLobby = SkippedBusy = SkippedNoCam = 0;
LastReason = LastLog = "";
s_lastPc = null; s_lastMapId = int.MinValue; s_nextPoll = 0f; s_faulted = false;
}
/// <summary>프로브용: 러너 없이 1회 폴링(강제 틱).</summary>
public static void ForceTick() { s_nextPoll = 0f; Tick(); }
/// <summary>
/// 프로브용: 경계 판정(PC·InGameInfo)을 건너뛰고 이 카메라에 값을 적용한다.
/// 🔴 에디트 모드에서 InGameInfo.Ins / ServerInfo.Ins 를 건드리면 싱글턴 GameObject 가 생겨 씬이 더러워진다(813x §0-12) —
/// 그래서 프로브는 이 진입점을 쓴다. 런타임 경로(PollBoundary)와 Apply 는 같은 함수를 부른다.
/// </summary>
public static bool ProbeApply(RealCamera cam)
{
var cfg = Cfg;
if (cfg == null || !Active) return false;
if (DSUtil.CheckNull(cam) || cam.arr_cCamData == null || cam.arr_cCamData.Length == 0 || cam.arr_cCamData[0] == null) return false;
LastReason = "probe";
Boundaries++;
Apply(cfg, cam);
return true;
}
/// <summary>프로브용: 즉시 원값 복원.</summary>
public static void ProbeRestore() { Restore("probe"); }
static CameraFramingRunner s_runner;
internal static void EnsureRunner()
{
if (!Application.isPlaying || !DSUtil.CheckNull(s_runner)) return;
var go = new GameObject("[WL811o] CameraFramingRunner");
go.hideFlags = HideFlags.HideAndDontSave;
s_runner = go.AddComponent<CameraFramingRunner>();
}
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void Boot()
{
var c = Cfg;
if (c != null && c.enabled && (c.framingEnabled || c.occluderFadeEnabled)) EnsureRunner(); // 둘 다 꺼져 있으면 오브젝트 0(C8)
}
}
/// <summary>구도 오버라이드 + 가림 소품 숨김의 시간 축. 숨김 GameObject 1개 · 코루틴 0.</summary>
internal sealed class CameraFramingRunner : MonoBehaviour
{
void Update()
{
CameraFramingOverride.Tick();
OccluderFade.Tick();
}
void OnDisable()
{
CameraFramingOverride.ReleaseAll();
OccluderFade.ReleaseAll();
}
void OnApplicationQuit()
{
CameraFramingOverride.ReleaseAll();
OccluderFade.ReleaseAll();
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 024842c5f9753b146876e8383ad61c20

View File

@ -0,0 +1,487 @@
// ─────────────────────────────────────────────────────────────────────────────
// 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);
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5cfde7e77950efd4b8e34398934d8c85

View File

@ -0,0 +1,41 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!114 &11400000
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 71e584b928e482c449195aca2f158271, type: 3}
m_Name: WLCameraFramingSettings
m_EditorClassIdentifier: Assembly-CSharp::WL.CameraFraming.WLCameraFramingSettings
enabled: 1
verboseLog: 0
framingEnabled: 1
cameraHeight: 76
distance: 3
distanceUp: 2.4
framingPollSeconds: 0.25
framingInLobby: 0
occluderFadeEnabled: 1
sphereRadius: 0.6
pollSeconds: 0.1
fadeOutSeconds: 0.2
restoreDelaySeconds: 0.3
maxConcurrent: 4
hideMode: 0
occluderMask:
serializedVersion: 2
m_Bits: 65
excludeNameContains: terrain,ground,floor,water,river,navmesh,plane,road,path
minOccluderHeight: 0.8
maxOccluderSize: 30
skipSkinnedRenderers: 1
skipActors: 1
boundsScanEnabled: 1
boundsScanRadius: 0
scanRebuildSeconds: 0
scanMax: 2048

View File

@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8bd9e92e9e0e4270891b81d1010bca52
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 11400000
userData:
assetBundleName:
assetBundleVariant:

View File

@ -0,0 +1,180 @@
// ─────────────────────────────────────────────────────────────────────────────
// WLCameraFramingSettings.cs — 카메라 가림 해소(구도 값 + 가림 소품 숨김) 값의 단일 출처(SOT · C45)
//
// PD 지시 #813 · 발주서 WL-811o (2026-09-09) · 근거 = qa/W2-4b_재채점_v2.md D-8 · 채점 7-3 = 0
// 「지형 바위가 PC 를 완전히 가림(01_enter_hud ≈40 %) · 대형 이펙트가 ≈70 %(PD_07)」
//
// 에셋 경로(고정): Assets/WL/Camera/Resources/WL/WLCameraFramingSettings.asset
// → Resources.Load<WLCameraFramingSettings>("WL/WLCameraFramingSettings") 로 1회 로드 후 캐시(기존 설정 SO 관례).
// 에셋이 없거나 enabled = false 면 러너 자체가 뜨지 않는다 = 원본 카메라·소품 100 %(C8).
//
// ■ 왜 새 SO 인가 (실측 · 발주서 §1-1 조건)
// 구도 값(거리·궤도 높이·피치 파라미터)은 **원본 프리팹의 arr_cCamData 에만** 있다:
// Assets/ResWork/Prefabs/Ingame/Main Camera.prefab
// arr_cCamData[0](일반) minDistance 3 · maxDistance 3 · DistanceUp 2 · cameraHeight 65
// arr_cCamData[1](보스/락타겟) minDistance 2 · maxDistance 3 · DistanceUp 3.5 · cameraHeight 78
// m_LookAt (0, 1.06, 0) · smooth 4 · FOV 60(세로) · rotateAround = MapZoneData.CamRot_Portal(WL_Nature = 233)
// 기존 WL 카메라 SO 2개에는 이 값이 **없다**:
// · CameraLeadSettings = 이동 리드/후진 피치 **가산분**만(기준 구도 값 없음)
// · WLCombatCameraSettings = 킬캠 배율 + arr_cCamData[**1**] 보스 프리셋만(일반 프리셋 [0] 은 안 건드린다)
// → 발주서 §1-1 의 「WL 설정이 없으면 WL 오버라이드 SO(NEW)」 경로. 프리팹은 수정하지 않는다.
//
// ■ 궤도 수식(RealCamera.Update_Cam 실측) — 이 값들이 화면에서 뭘 바꾸는지
// rotateVector = Quaternion.Euler(h, r, 0) * Vector3.one (|rotateVector| = √3 · h 와 무관)
// 카메라 = 타겟 + up*DistanceUp rotateVector*DistanceAway
// · 타겟 발밑 기준 높이 = DistanceUp + D*(sin h cos h)
// · 수평 거리 = D*√(1 + (sin h + cos h)²)
// · 주시점(0,1.06,0) 내림각 = atan((높이 1.06) / 수평거리)
// 실측 h = 65 → 높이 3.451 m · 수평 4.990 m · **내림각 25.6°** ← 지금(가림 발생)
// 참고 h = 78 → 높이 4.311 m · 수평 4.654 m · 내림각 34.9° ← 원본 커밋 d5df1b2c9 「카메라 수정」 이전 값
// 내림각이 낮을수록 카메라~PC 선분이 지면에 가깝게 깔려 **키 2~3 m 짜리 바위/덤불이 PC 를 덮는다.**
// 선분 높이 y(x) = 1.06 + tan(내림각)·x (x = PC 로부터 카메라 쪽 수평거리)
// h 65 → y(2.5 m) = 2.26 m (3 m 바위가 전 구간에서 가린다)
// h 74 → y(2.5 m) = 2.66 m
//
// 🔴 어셈블리 주의: Assets/WL/Camera/ 에 .asmdef 를 만들지 말 것(RealCamera 가 Assembly-CSharp 에 있다).
// ─────────────────────────────────────────────────────────────────────────────
using UnityEngine;
namespace WL.CameraFraming
{
/// <summary>가림 소품을 숨기는 방식.</summary>
public enum OccluderHideMode
{
/// <summary>머티리얼 큐를 보고 자동 선택(투명 큐 + 색 프로퍼티 → 알파 페이드 · 그 외 → 그림자만).</summary>
Auto = 0,
/// <summary>ShadowCastingMode.ShadowsOnly — 렌더러는 안 보이고 그림자는 남는다(불투명 셰이더 기본 경로 · 머티리얼 무변경).</summary>
ShadowsOnly = 1,
/// <summary>Renderer.enabled = false — 그림자까지 사라진다.</summary>
RendererOff = 2,
/// <summary>MaterialPropertyBlock 알파 페이드 — 셰이더가 투명 큐일 때만 실제로 보인다(새 셰이더·머티리얼 0).</summary>
MpbAlpha = 3,
}
[CreateAssetMenu(fileName = "WLCameraFramingSettings", menuName = "WL/Camera Framing Settings", order = 322)]
public sealed class WLCameraFramingSettings : ScriptableObject
{
public const string ResourcesPath = "WL/WLCameraFramingSettings";
static WLCameraFramingSettings s_cached;
static bool s_lookupDone;
/// <summary>설정 에셋(1회 로드 후 캐시). 없으면 null.</summary>
public static WLCameraFramingSettings Instance
{
get
{
if (!s_lookupDone)
{
s_cached = Resources.Load<WLCameraFramingSettings>(ResourcesPath);
s_lookupDone = true;
}
return s_cached;
}
}
/// <summary>진단·A/B 전용 런타임 스위치(WLHitFeel.RuntimeDisabled 선례). 에셋을 안 건드리고 이 기능만 끈다.</summary>
public static bool RuntimeDisabled;
/// <summary>기능이 살아 있는가 = 에셋 존재 · enabled · 런타임 스위치 off.</summary>
public static bool Enabled
{
get { var i = Instance; return i != null && i.enabled && !RuntimeDisabled; }
}
/// <summary>테스트용: 캐시를 비워 다음 접근 때 다시 Resources.Load 한다.</summary>
public static void ClearCache() { s_cached = null; s_lookupDone = false; }
// ─────────────────────────────────────────── 전체 스위치
[Header("전체 스위치")]
[Tooltip("끄면 구도 오버라이드·가림 소품 숨김이 전부 비활성 — 원본 카메라 값(arr_cCamData) · 소품 100 %. 롤백 1순위.")]
public bool enabled = true;
[Tooltip("적용/복원/숨김 결정을 Console 에 남긴다(검증용 · 기본 off · GC 발생).")]
public bool verboseLog = false;
// ─────────────────────────────────────────── ① 구도 값 오버라이드
[Header("① 구도 오버라이드 — 맵 진입 경계에서 1회 적용(원값 스냅샷 후 복원 가능)")]
[Tooltip("구도 오버라이드 사용 여부. off 면 arr_cCamData 원값(65° · 3 m · 2)을 그대로 쓴다.")]
public bool framingEnabled = true;
[Tooltip("arr_cCamData[0]( ) cameraHeight ().\n" +
"실제 고도각 = asin((sin h cos h)/√3) · 실측 65 = 16.2° · 74 = 22.6° · 78 = 26.4°.\n" +
"올릴수록 더 내려다봐서 지형 소품 위로 시선이 지나간다(= 가림 감소). 너무 올리면 탑뷰가 되어 액션 체감이 떨어진다.")]
[Range(50f, 85f)] public float cameraHeight = 74f;
[Tooltip("arr_cCamData[0].minDistance = maxDistance (m · 3).\n" +
"소폭만 늘린다 — 거리를 늘리면 카메라~PC 선분이 길어져 그 사이에 들어올 수 있는 소품 수도 늘어난다.")]
[Range(2f, 6f)] public float distance = 3.4f;
[Tooltip("arr_cCamData[0].DistanceUp (m · 2). .")]
public float distanceUp = 2.2f;
[Tooltip("맵 진입 경계 검사 주기(초 · unscaled).")]
public float framingPollSeconds = 0.25f;
[Tooltip("로비에서도 적용한다. 기본 off — 로비는 전투가 없어 가림이 문제되지 않고, 연출 구도를 건드리지 않는다.")]
public bool framingInLobby = false;
// ─────────────────────────────────────────── ② 가림 소품 자동 숨김
[Header("② 가림 소품 자동 숨김 — 카메라→PC SphereCast")]
[Tooltip("가림 소품 숨김 사용 여부.")]
public bool occluderFadeEnabled = true;
[Tooltip("카메라→PC 선분 검사 반경(m). PC 실루엣 폭 정도. 0 이면 Linecast.")]
[Range(0f, 2f)] public float sphereRadius = 0.6f;
[Tooltip("검사 주기(초 · unscaled). 0.1 = 10 Hz.")]
public float pollSeconds = 0.1f;
[Tooltip("숨김까지의 시간(초). 불투명 셰이더 경로(ShadowsOnly/RendererOff)에서는 **지연**으로 동작한다\n" +
"— 스쳐 지나가는 소품이 한 프레임 깜빡이는 것을 막는다. MpbAlpha 경로에서는 알파 램프 시간.")]
public float fadeOutSeconds = 0.2f;
[Tooltip("가림이 풀린 뒤 복귀까지 기다리는 시간(초).")]
public float restoreDelaySeconds = 0.3f;
[Tooltip("동시에 숨길 수 있는 소품 수 상한. 넘으면 새 소품은 숨기지 않는다(맵이 통째로 사라지는 사고 방지).")]
[Range(1, 8)] public int maxConcurrent = 4;
[Tooltip("숨김 방식. Auto = 머티리얼이 투명 큐면 알파 페이드, 아니면 그림자만 남기고 숨김.")]
public OccluderHideMode hideMode = OccluderHideMode.Auto;
[Tooltip("검사 레이어 마스크. 실측 레이어: 0 Default · 3 Actor(PC·몹) · 4 Water · 5 UI · 6 Map · 8 GrassFX.\n" +
"기본 = Default + Map (몹·PC·이펙트·UI 제외). 인스펙터에서 바꾼다.")]
public LayerMask occluderMask = (1 << 0) | (1 << 6);
[Tooltip("이 문자열들이 이름에 들어간 오브젝트는 **숨기지 않는다**(바닥·지형·물). 소문자 비교 · 쉼표 구분.")]
public string excludeNameContains = "terrain,ground,floor,water,river,navmesh,plane,road,path";
[Tooltip("렌더러 바운드 높이가 이 값(m) 미만이면 숨기지 않는다(잔디·자갈은 가리지 않는다).")]
public float minOccluderHeight = 0.8f;
[Tooltip("렌더러 바운드 최대 변이 이 값(m)을 넘으면 숨기지 않는다(지형 통짜 메시·배경 산 보호).")]
public float maxOccluderSize = 30f;
[Tooltip("SkinnedMeshRenderer(=캐릭터·몹)는 절대 숨기지 않는다. 안전장치라 켠 채로 둔다.")]
public bool skipSkinnedRenderers = true;
[Tooltip("Actor 컴포넌트를 가진 오브젝트(PC·몹)는 절대 숨기지 않는다. 안전장치라 켠 채로 둔다.")]
public bool skipActors = true;
// ─────────────────────────────────────────── ②-b 바운드 스캔 (콜라이더 없는 소품 대응)
[Header("②-b 바운드 스캔 — 🔴 실측: 가림 후보 소품 979개 중 콜라이더 보유는 121개(12.4 %)")]
[Tooltip("SphereCast 는 콜라이더가 있는 것만 잡는다. WL_Nature 의 나무·덤불·바위는 대부분 콜라이더가 없어\n" +
"물리 검사만으로는 화면을 가리는 소품의 12 % 밖에 못 잡는다(프로브 A 절 실측).\n" +
"켜면 맵 진입 때 한 번 훑어 둔 소품 렌더러의 **바운드**로도 가림을 판정한다(콜라이더 불요).")]
public bool boundsScanEnabled = true;
[Tooltip("바운드 판정에 쓰는 선분 반경(m). PC 실루엣 폭 — sphereRadius 와 같은 뜻이지만 따로 조절할 수 있게 뒀다. 0 이면 sphereRadius 를 쓴다.")]
public float boundsScanRadius = 0f;
[Tooltip("스캔 목록 재구축 주기(초). 0 = 맵/PC 경계에서만 재구축(정상 · 이때만 할당이 있고 폴링은 GC 0).\n" +
"0 보다 크면 그 주기로도 다시 훑는다(런타임에 소품이 생기는 맵용).")]
public float scanRebuildSeconds = 0f;
[Tooltip("스캔 목록 상한(맵 소품 수). WL_Nature 실측 가림 후보 979개.")]
public int scanMax = 2048;
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 71e584b928e482c449195aca2f158271

View File

@ -157,7 +157,7 @@ MonoBehaviour:
lifetime: 0
body:
prefab: Effect_WLSwingArc
scale: 2
scale: 1.5
anchor: 1
forwardOffset: 0.5
useCasterRotation: 1
@ -505,7 +505,7 @@ MonoBehaviour:
lifetime: 0
body:
prefab: ShineBulletFlash
scale: 2
scale: 1.5
anchor: 1
forwardOffset: 0
useCasterRotation: 0